feat(analytics): доходы, ребалансировка, налоги, бенчмарки и цели — фаза 4
Второй источник выплат: sources/tinvest/sync_events.py (GetDividends, GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (ISS bondization + dividends). Приоритет между ними — pricing/payouts.resolve_payouts, решается на чтении, а не на записи: corporate_action уникален по (instrument_id, kind, source, source_id), обе версии сосуществуют, и правило можно поменять без ресинка истории. Амортизация от MOEX идёт в bond_nominal_schedule, а не в corporate_action — этим типом безраздельно владеет ledger/corporate_actions.py. analytics/income.py — metric_income_monthly (факт) и metric_income_calendar (прошлое и прогноз) с basis paid/announced/history на каждой строке, три источника числа не смешиваются. analytics/rebalance.py — сделки по portfolio_target пропорционально внутри бакета, лоты только вниз, покупки не занимают у ещё не свершившихся продаж. analytics/tax.py — оценка, не замена справки брокера: дивиденды/купоны gross, реализованный результат из lot_disposal с переоценкой каждой ноги на свою дату. analytics/benchmarks.py — TWR индекса на сетке портфеля, kind (price/total_return) не скрывается. analytics/goals.py — прогресс цели и нужный взнос по trailing XIRR. Четыре шага зарегистрированы в register_steps: benchmarks после returns (общая сетка дат), rebalance после allocation (её веса, не пересчитывает), income и tax после lots (нужен lot_disposal).
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"""Benchmarks on the portfolio's own grid — the acceptance check from the plan, фаза 4.
|
||||
|
||||
«TWR и MCFTR на одной сетке без дыр в праздники»: the day the index has no quote must show
|
||||
up in `days_skipped`, not quietly distort the return. And a price index must not be allowed
|
||||
to pass as a total-return one — the two differ on identical holdings, and `kind` is what says
|
||||
which is which.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from factories import make_account, make_event, make_instrument, make_price, refresh
|
||||
from fintracker.analytics.benchmarks import index_twr, opening_price, rebuild_benchmark_returns
|
||||
from fintracker.api.schemas.benchmarks import BenchmarkReturnOut
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
Benchmark,
|
||||
BenchmarkKind,
|
||||
EventKind,
|
||||
MetricBenchmarkReturns,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
START = date(2025, 1, 1)
|
||||
|
||||
|
||||
def day(n: int) -> date:
|
||||
return START + timedelta(days=n)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# pure chain
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_missing_quote_is_counted_not_smoothed_over():
|
||||
# the grid is every day; the index has no quote on day 2 (a holiday for it alone)
|
||||
prices = {day(0): D(100), day(1): D(110), day(3): D(121)}
|
||||
chain = index_twr(prices, [day(1), day(2), day(3)], opening=D(100))
|
||||
|
||||
assert chain.days_skipped == 1
|
||||
assert chain.days_used == 2
|
||||
# the move is not lost: day 3 links back to day 1's close, so the chain still telescopes
|
||||
assert chain.value == D("0.210000")
|
||||
|
||||
|
||||
def test_no_quote_at_all_gives_no_comparison_rather_than_zero():
|
||||
chain = index_twr({}, [day(1), day(2)], opening=None)
|
||||
assert chain.value is None
|
||||
assert chain.days_skipped == 2
|
||||
|
||||
|
||||
def test_the_period_may_open_on_a_day_the_index_did_not_trade():
|
||||
prices = {day(0): D(100), day(3): D(105)}
|
||||
# day(1) is a Sunday for the index; the level it actually stood at is day(0)'s close
|
||||
assert opening_price(prices, day(1)) == D(100)
|
||||
assert opening_price(prices, day(-5)) is None
|
||||
|
||||
|
||||
def test_kind_travels_all_the_way_out():
|
||||
# the client has to be able to mark a price-index comparison; the field is not optional
|
||||
assert "kind" in BenchmarkReturnOut.model_fields
|
||||
assert BenchmarkReturnOut.model_fields["kind"].annotation is str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# against a real portfolio
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def portfolio(app) -> dict[str, object]:
|
||||
"""One share held for 40 days, priced every single day, so the grid has no holes."""
|
||||
from fintracker.analytics import today_local
|
||||
|
||||
t = today_local()
|
||||
bought = t - timedelta(days=40)
|
||||
account = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
share = await make_instrument(ticker="GAZP", name="Газпром", asset_class=AssetClass.share)
|
||||
await make_event(bought, account_id=account, kind=EventKind.deposit, amount="10000")
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="100",
|
||||
price="100",
|
||||
amount="-10000",
|
||||
)
|
||||
for n in range(41):
|
||||
await make_price(bought + timedelta(days=n), instrument_id=share, close=100 + n)
|
||||
return {"account": account, "share": share, "bought": bought, "today": t}
|
||||
|
||||
|
||||
async def _add_index(
|
||||
code: str, kind: BenchmarkKind, closes: dict[date, str], *, ticker: str
|
||||
) -> int:
|
||||
instrument = await make_instrument(
|
||||
ticker=ticker, name=code, asset_class=AssetClass.market_index, board="SNDX"
|
||||
)
|
||||
for d, close in closes.items():
|
||||
await make_price(d, instrument_id=instrument, close=close)
|
||||
async with get_sessionmaker()() as session:
|
||||
benchmark = Benchmark(
|
||||
code=code,
|
||||
name=code,
|
||||
kind=kind,
|
||||
instrument_id=instrument,
|
||||
source="moex",
|
||||
currency="RUB",
|
||||
is_default=kind is BenchmarkKind.total_return,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(benchmark)
|
||||
await session.commit()
|
||||
await session.refresh(benchmark)
|
||||
return benchmark.id
|
||||
|
||||
|
||||
async def _rebuild_benchmarks() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_benchmark_returns(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _rows(scope: str = "all") -> dict[tuple[int, str], MetricBenchmarkReturns]:
|
||||
from sqlalchemy import select
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
found = await session.execute(
|
||||
select(MetricBenchmarkReturns).where(MetricBenchmarkReturns.scope == scope)
|
||||
)
|
||||
return {(r.benchmark_id, r.period): r for r in found.scalars()}
|
||||
|
||||
|
||||
async def test_the_index_is_chained_over_the_portfolios_days_and_reports_the_holidays(portfolio):
|
||||
"""The plan's check: one grid, and a day the index misses is visible as a hole."""
|
||||
bought, today = portfolio["bought"], portfolio["today"]
|
||||
holiday = bought + timedelta(days=20)
|
||||
closes = {
|
||||
bought + timedelta(days=n): str(1000 + n * 10)
|
||||
for n in range(41)
|
||||
if bought + timedelta(days=n) != holiday
|
||||
}
|
||||
benchmark = await _add_index("IMOEX", BenchmarkKind.price, closes, ticker="IMOEX")
|
||||
|
||||
await refresh()
|
||||
await _rebuild_benchmarks()
|
||||
|
||||
rows = await _rows()
|
||||
row = rows[(benchmark, "all")]
|
||||
# the portfolio's own row defines the window; the benchmark copied it verbatim
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.models import MetricReturns
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
found = await session.execute(
|
||||
select(MetricReturns).where(MetricReturns.scope == "all", MetricReturns.period == "all")
|
||||
)
|
||||
portfolio_row = found.scalar_one()
|
||||
assert (row.date_from, row.date_to) == (portfolio_row.date_from, portfolio_row.date_to)
|
||||
|
||||
# exactly one day of the compared window had no quote, and it is reported, not absorbed
|
||||
assert row.days_skipped == 1
|
||||
assert row.twr is not None
|
||||
# the chain still spans the whole window: 1000 -> 1400 over the priced days
|
||||
assert row.twr == D("0.400000")
|
||||
assert today >= portfolio_row.date_to
|
||||
|
||||
|
||||
async def test_a_price_index_and_a_total_return_index_do_not_agree(portfolio):
|
||||
"""Same 40 days, same start: the dividend-bearing series ends higher, and says so."""
|
||||
bought = portfolio["bought"]
|
||||
price_closes = {bought + timedelta(days=n): str(1000 + n * 10) for n in range(41)}
|
||||
total_closes = {bought + timedelta(days=n): str(1000 + n * 15) for n in range(41)}
|
||||
imoex = await _add_index("IMOEX", BenchmarkKind.price, price_closes, ticker="IMOEX")
|
||||
mcftr = await _add_index("MCFTR", BenchmarkKind.total_return, total_closes, ticker="MCFTR")
|
||||
|
||||
await refresh()
|
||||
await _rebuild_benchmarks()
|
||||
|
||||
rows = await _rows()
|
||||
assert rows[(imoex, "all")].twr == D("0.400000")
|
||||
assert rows[(mcftr, "all")].twr == D("0.600000")
|
||||
# neither has a hole — the whole point of comparing against MCFTR rather than IMOEX is
|
||||
# that the gap between them is dividends, not a difference in the days measured
|
||||
assert rows[(imoex, "all")].days_skipped == 0
|
||||
assert rows[(mcftr, "all")].days_skipped == 0
|
||||
|
||||
|
||||
async def test_an_index_without_history_yields_no_number(portfolio):
|
||||
"""A benchmark nobody has quotes for is null, never 0 % — and it is reported."""
|
||||
from fintracker.analytics import FINDINGS
|
||||
|
||||
benchmark = await _add_index("RGBITR", BenchmarkKind.total_return, {}, ticker="RGBITR")
|
||||
await refresh()
|
||||
FINDINGS.reset()
|
||||
await _rebuild_benchmarks()
|
||||
|
||||
rows = await _rows()
|
||||
assert rows[(benchmark, "all")].twr is None
|
||||
assert any(f.check_name == "benchmark_no_history" for f in FINDINGS.items)
|
||||
|
||||
|
||||
async def test_nothing_in_the_metric_rows_is_a_float(portfolio):
|
||||
bought = portfolio["bought"]
|
||||
closes = {bought + timedelta(days=n): str(1000 + n * 10) for n in range(41)}
|
||||
await _add_index("MCFTR", BenchmarkKind.total_return, closes, ticker="MCFTR")
|
||||
await refresh()
|
||||
await _rebuild_benchmarks()
|
||||
|
||||
for row in (await _rows()).values():
|
||||
for value in (row.twr, row.twr_annualized):
|
||||
assert value is None or isinstance(value, Decimal)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Goal progress: the projection rules, then the refresh step end to end."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event, make_instrument, make_price, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.analytics.goals import (
|
||||
MAX_HORIZON_MONTHS,
|
||||
MIN_XIRR_HISTORY_DAYS,
|
||||
evaluate,
|
||||
monthly_needed,
|
||||
months_between,
|
||||
pick_rate,
|
||||
rebuild_goal_progress,
|
||||
)
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
EventKind,
|
||||
Goal,
|
||||
MetricGoalProgress,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
|
||||
|
||||
def progress_of(
|
||||
*,
|
||||
current: str = "100000",
|
||||
target: str = "200000",
|
||||
target_date: date | None = None,
|
||||
monthly: str | None = None,
|
||||
xirr: str | None = None,
|
||||
as_of: date | None = None,
|
||||
):
|
||||
return evaluate(
|
||||
goal_id=1,
|
||||
as_of=as_of or today_local(),
|
||||
current=D(current),
|
||||
target=D(target),
|
||||
target_date=target_date,
|
||||
monthly_contribution=None if monthly is None else D(monthly),
|
||||
trailing_xirr=None if xirr is None else D(xirr),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the projection
|
||||
|
||||
|
||||
def test_a_growing_portfolio_gets_a_date_in_the_future():
|
||||
result = progress_of(current="100000", target="200000", xirr="0.2")
|
||||
assert result.basis == "xirr"
|
||||
assert result.projected_date is not None
|
||||
assert result.projected_date > today_local()
|
||||
# 20 % a year doubles in a bit under four years
|
||||
assert result.projected_date < today_local() + timedelta(days=365 * 5)
|
||||
|
||||
|
||||
def test_a_flat_portfolio_with_no_contributions_gets_null_not_a_far_date():
|
||||
result = progress_of(current="100000", target="200000", xirr="0")
|
||||
assert result.basis == "xirr"
|
||||
assert result.projected_date is None
|
||||
|
||||
|
||||
def test_a_falling_portfolio_with_no_contributions_gets_null():
|
||||
result = progress_of(current="100000", target="200000", xirr="-0.1")
|
||||
assert result.basis == "xirr"
|
||||
assert result.assumed_rate == D("-0.100000")
|
||||
assert result.projected_date is None
|
||||
|
||||
|
||||
def test_a_falling_portfolio_may_still_be_reached_by_contributions():
|
||||
result = progress_of(current="100000", target="200000", xirr="-0.02", monthly="20000")
|
||||
assert result.basis == "xirr"
|
||||
assert result.projected_date is not None
|
||||
|
||||
|
||||
def test_without_a_trailing_return_the_plan_is_the_contributions():
|
||||
result = progress_of(current="100000", target="200000", monthly="10000")
|
||||
assert result.basis == "contribution"
|
||||
assert result.assumed_rate == D("0.000000")
|
||||
# 100 000 left to raise at 10 000 a month is ten months of deposits
|
||||
assert result.projected_date == _add(today_local(), 10)
|
||||
|
||||
|
||||
def test_with_neither_a_return_nor_a_contribution_there_is_nothing_to_project():
|
||||
result = progress_of(current="100000", target="200000")
|
||||
assert result.basis == "none"
|
||||
assert result.projected_date is None
|
||||
assert result.assumed_rate is None
|
||||
|
||||
|
||||
def test_a_goal_already_met_is_projected_to_today():
|
||||
result = progress_of(current="300000", target="200000", xirr="0.1")
|
||||
assert result.projected_date == today_local()
|
||||
assert result.progress == D("1.500000")
|
||||
|
||||
|
||||
def test_the_projection_gives_up_rather_than_naming_a_date_beyond_the_horizon():
|
||||
# 0.01 % a year against a target ten times away: reachable in theory, not in 30 years
|
||||
result = progress_of(current="100000", target="1000000", xirr="0.0001")
|
||||
assert result.projected_date is None
|
||||
assert MAX_HORIZON_MONTHS == 360
|
||||
|
||||
|
||||
def _add(d: date, months: int) -> date:
|
||||
from fintracker.analytics.goals import add_months
|
||||
|
||||
return add_months(d, months)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- monthly needed
|
||||
|
||||
|
||||
def test_monthly_needed_is_null_without_a_deadline():
|
||||
assert progress_of(monthly="1000").monthly_needed_rub is None
|
||||
|
||||
|
||||
def test_monthly_needed_is_computed_when_there_is_a_deadline():
|
||||
as_of = date(2026, 1, 1)
|
||||
result = progress_of(
|
||||
current="100000", target="220000", target_date=date(2027, 1, 1), as_of=as_of
|
||||
)
|
||||
# no growth assumed: 120 000 over 12 months
|
||||
assert result.monthly_needed_rub == D("10000.00")
|
||||
|
||||
|
||||
def test_monthly_needed_is_zero_when_the_trend_already_gets_there():
|
||||
as_of = date(2026, 1, 1)
|
||||
result = progress_of(
|
||||
current="100000",
|
||||
target="105000",
|
||||
target_date=date(2027, 1, 1),
|
||||
xirr="0.2",
|
||||
as_of=as_of,
|
||||
)
|
||||
assert result.monthly_needed_rub == D(0)
|
||||
assert result.on_track is True
|
||||
|
||||
|
||||
def test_monthly_needed_is_null_once_the_deadline_has_passed():
|
||||
as_of = date(2026, 1, 1)
|
||||
result = progress_of(target_date=date(2025, 1, 1), as_of=as_of, monthly="1000")
|
||||
assert result.monthly_needed_rub is None
|
||||
|
||||
|
||||
def test_a_deadline_the_trend_misses_is_not_on_track():
|
||||
as_of = date(2026, 1, 1)
|
||||
result = progress_of(
|
||||
current="100000",
|
||||
target="200000",
|
||||
target_date=date(2026, 6, 1),
|
||||
monthly="1000",
|
||||
as_of=as_of,
|
||||
)
|
||||
assert result.on_track is False
|
||||
|
||||
|
||||
def test_monthly_needed_accounts_for_the_assumed_growth():
|
||||
as_of = date(2026, 1, 1)
|
||||
flat = progress_of(current="100000", target="220000", target_date=date(2027, 1, 1), as_of=as_of)
|
||||
growing = progress_of(
|
||||
current="100000",
|
||||
target="220000",
|
||||
target_date=date(2027, 1, 1),
|
||||
xirr="0.2",
|
||||
as_of=as_of,
|
||||
)
|
||||
assert growing.monthly_needed_rub is not None
|
||||
assert flat.monthly_needed_rub is not None
|
||||
assert growing.monthly_needed_rub < flat.monthly_needed_rub
|
||||
|
||||
|
||||
def test_months_between_counts_whole_months_only():
|
||||
assert months_between(date(2026, 1, 15), date(2027, 1, 14)) == 11
|
||||
assert months_between(date(2026, 1, 15), date(2027, 1, 15)) == 12
|
||||
assert months_between(date(2026, 5, 1), date(2026, 1, 1)) == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- rate choice
|
||||
|
||||
|
||||
def test_a_short_window_is_not_extrapolated_into_a_forecast():
|
||||
rows = [
|
||||
{
|
||||
"period": "1m",
|
||||
"date_from": date(2026, 8, 18),
|
||||
"date_to": date(2026, 9, 18),
|
||||
"xirr": D("3.5"),
|
||||
}
|
||||
]
|
||||
assert pick_rate(rows) == (None, "")
|
||||
|
||||
|
||||
def test_the_shortest_qualifying_window_wins():
|
||||
rows = [
|
||||
{
|
||||
"period": "all",
|
||||
"date_from": date(2020, 1, 1),
|
||||
"date_to": date(2026, 9, 18),
|
||||
"xirr": D("0.05"),
|
||||
},
|
||||
{
|
||||
"period": "1y",
|
||||
"date_from": date(2025, 9, 18),
|
||||
"date_to": date(2026, 9, 18),
|
||||
"xirr": D("0.18"),
|
||||
},
|
||||
{
|
||||
"period": "3m",
|
||||
"date_from": date(2026, 6, 18),
|
||||
"date_to": date(2026, 9, 18),
|
||||
"xirr": D("9"),
|
||||
},
|
||||
]
|
||||
assert pick_rate(rows) == (D("0.18"), "1y")
|
||||
assert MIN_XIRR_HISTORY_DAYS == 180
|
||||
|
||||
|
||||
def test_a_period_without_an_xirr_is_skipped():
|
||||
rows = [
|
||||
{
|
||||
"period": "1y",
|
||||
"date_from": date(2025, 9, 18),
|
||||
"date_to": date(2026, 9, 18),
|
||||
"xirr": None,
|
||||
}
|
||||
]
|
||||
assert pick_rate(rows) == (None, "")
|
||||
|
||||
|
||||
def test_every_number_in_the_progress_is_a_decimal():
|
||||
result = progress_of(
|
||||
current="100000", target="200000", target_date=date(2030, 1, 1), xirr="0.1"
|
||||
)
|
||||
for value in (result.current_value_rub, result.target_amount_rub, result.progress):
|
||||
assert isinstance(value, Decimal)
|
||||
assert isinstance(result.assumed_rate, Decimal)
|
||||
assert isinstance(result.monthly_needed_rub, Decimal)
|
||||
|
||||
|
||||
def test_monthly_needed_refuses_a_zero_month_window():
|
||||
assert monthly_needed(current=D(1), target=D(2), months=0, annual_rate=None) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- database
|
||||
|
||||
|
||||
async def _portfolio(close_today: str) -> None:
|
||||
"""A year of history: 100 000 in, 1000 shares at 100, ending at `close_today`."""
|
||||
t = today_local()
|
||||
start = t - timedelta(days=365)
|
||||
account = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
|
||||
await make_event(start, account_id=account, kind=EventKind.deposit, amount="100000")
|
||||
await make_event(
|
||||
start,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="1000",
|
||||
price="100",
|
||||
amount="-100000",
|
||||
)
|
||||
d = start
|
||||
while d <= t:
|
||||
close = "100" if d < t else close_today
|
||||
await make_price(d, instrument_id=share, close=close)
|
||||
d += timedelta(days=1)
|
||||
await refresh()
|
||||
|
||||
|
||||
async def _goal(**kwargs) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
goal = Goal(**kwargs)
|
||||
session.add(goal)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
return goal.id
|
||||
|
||||
|
||||
async def _rebuild() -> dict[int, MetricGoalProgress]:
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_goal_progress(session)
|
||||
await session.commit()
|
||||
rows = (await session.execute(select(MetricGoalProgress))).scalars().all()
|
||||
return {r.goal_id: r for r in rows}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("close_today", "reachable"),
|
||||
[("150", True), ("100", False), ("60", False)],
|
||||
)
|
||||
async def test_the_projection_follows_the_real_trend(app, close_today: str, reachable: bool):
|
||||
await _portfolio(close_today)
|
||||
goal_id = await _goal(name="Капитал", scope="all", target_amount=D("1000000"))
|
||||
rows = await _rebuild()
|
||||
|
||||
row = rows[goal_id]
|
||||
assert row.current_value_rub == D(close_today) * 1000
|
||||
assert row.progress == (D(close_today) * 1000 / D("1000000")).quantize(D("0.000001"))
|
||||
assert row.basis == "xirr"
|
||||
if reachable:
|
||||
assert row.projected_date is not None and row.projected_date > today_local()
|
||||
else:
|
||||
assert row.projected_date is None
|
||||
|
||||
|
||||
async def test_a_deadline_produces_a_monthly_need_and_none_without_one(app):
|
||||
await _portfolio("100")
|
||||
dated = await _goal(
|
||||
name="С датой",
|
||||
scope="all",
|
||||
target_amount=D("400000"),
|
||||
target_date=today_local() + timedelta(days=365),
|
||||
)
|
||||
undated = await _goal(name="Без даты", scope="all", target_amount=D("400000"))
|
||||
rows = await _rebuild()
|
||||
|
||||
needed = rows[dated].monthly_needed_rub
|
||||
assert needed is not None
|
||||
assert needed > 0
|
||||
assert rows[undated].monthly_needed_rub is None
|
||||
|
||||
|
||||
async def test_an_archived_goal_is_not_computed(app):
|
||||
await _portfolio("100")
|
||||
await _goal(name="Старое", scope="all", target_amount=D("1000"), archived=True)
|
||||
assert await _rebuild() == {}
|
||||
@@ -0,0 +1,701 @@
|
||||
"""Income: the pure forecast rules first, then the rebuild end to end.
|
||||
|
||||
The checks the plan names are here by name: every payout received in the last 12 months has a
|
||||
`paid` calendar row, and a quarterly payer produces exactly four future entries carrying its
|
||||
last amount. The third test is the one that catches real money: a coupon after an
|
||||
amortisation, which must shrink with the nominal instead of staying at par.
|
||||
"""
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_cbr_rate, make_event, make_instrument, refresh
|
||||
from fintracker.analytics import FINDINGS, today_local
|
||||
from fintracker.analytics.income import (
|
||||
BondFacts,
|
||||
Entry,
|
||||
Payment,
|
||||
Payout,
|
||||
add_months,
|
||||
bond_entries,
|
||||
coupon_per_unit,
|
||||
detect_frequency,
|
||||
drop_shadowed,
|
||||
fold_payments,
|
||||
history_entries,
|
||||
monthly_rows,
|
||||
nominal_at,
|
||||
project_dates,
|
||||
rebuild_income,
|
||||
regular,
|
||||
resolve_actions_fallback,
|
||||
)
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
BondNominalSchedule,
|
||||
CorporateAction,
|
||||
CorporateActionKind,
|
||||
CorporateActionStatus,
|
||||
Event,
|
||||
EventKind,
|
||||
IncomeBasis,
|
||||
Instrument,
|
||||
MetricIncomeCalendar,
|
||||
MetricIncomeMonthly,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# pure rules
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def quarterly(n: int, *, end: date = date(2026, 9, 1)) -> list[date]:
|
||||
return sorted(add_months(end, -3 * k) for k in range(n))
|
||||
|
||||
|
||||
def test_frequency_is_snapped_to_the_three_buckets_the_plan_allows():
|
||||
assert detect_frequency(quarterly(8)) == 4
|
||||
assert detect_frequency([date(2024, 6, 1), date(2024, 12, 1), date(2025, 6, 1)]) == 2
|
||||
assert detect_frequency([date(2024, 6, 1), date(2025, 6, 1), date(2026, 6, 1)]) == 1
|
||||
|
||||
|
||||
def test_a_single_payment_reads_as_annual_and_no_payment_reads_as_nothing():
|
||||
# one payment says nothing about spacing, but dropping a payout we have actually seen
|
||||
# would hide it entirely — annual is the commonest Russian dividend
|
||||
assert detect_frequency([date(2026, 5, 20)]) == 1
|
||||
assert detect_frequency([]) is None
|
||||
|
||||
|
||||
def test_irregular_spacing_is_flagged_but_still_forecast():
|
||||
assert regular(quarterly(5))
|
||||
assert not regular([date(2025, 1, 10), date(2025, 2, 10), date(2026, 5, 10)])
|
||||
|
||||
|
||||
def test_a_quarterly_payer_gives_exactly_four_dates_in_a_year():
|
||||
last = date(2026, 6, 15)
|
||||
dates = project_dates(last, 4, start=date(2026, 7, 1), end=date(2027, 6, 30))
|
||||
assert dates == [date(2026, 9, 15), date(2026, 12, 15), date(2027, 3, 15), date(2027, 6, 15)]
|
||||
|
||||
|
||||
def test_a_coupon_follows_the_nominal_in_force_on_its_own_date():
|
||||
schedule = [(date(2024, 1, 1), D(1000)), (date(2026, 6, 1), D(500))]
|
||||
assert nominal_at(schedule, date(2026, 5, 31)) == D(1000)
|
||||
assert nominal_at(schedule, date(2026, 6, 1)) == D(500)
|
||||
assert nominal_at(schedule, date(2023, 1, 1)) is None
|
||||
# a coupon published against par halves once half the principal has been repaid
|
||||
assert coupon_per_unit(D(40), D(1000), D(500)) == D(20)
|
||||
assert coupon_per_unit(D(40), D(1000), D(1000)) == D(40)
|
||||
assert coupon_per_unit(D(40), None, D(500)) == D(40)
|
||||
|
||||
|
||||
def test_bond_entries_cover_coupon_amortisation_and_redemption():
|
||||
facts = BondFacts(
|
||||
nominal=D(1000),
|
||||
nominal_schedule=((date(2024, 1, 1), D(1000)), (date(2026, 11, 1), D(600))),
|
||||
maturity_date=date(2027, 5, 1),
|
||||
currency="RUB",
|
||||
)
|
||||
coupons = [
|
||||
Payout(1, "coupon", "announced", None, date(2026, 10, 1), D(40), "RUB"),
|
||||
Payout(1, "coupon", "announced", None, date(2027, 4, 1), D(40), "RUB"),
|
||||
]
|
||||
entries = bond_entries(1, facts, coupons, D(10), start=date(2026, 9, 18), end=date(2027, 9, 18))
|
||||
by_kind = {(e.kind, e.expected_date): e for e in entries}
|
||||
assert by_kind[("coupon", date(2026, 10, 1))].amount == D(400)
|
||||
# after the amortisation the same published coupon is worth 60 % of itself
|
||||
assert by_kind[("coupon", date(2027, 4, 1))].amount == D(240)
|
||||
assert by_kind[("amortization", date(2026, 11, 1))].amount == D(4000)
|
||||
assert by_kind[("repayment", date(2027, 5, 1))].amount == D(6000)
|
||||
assert all(e.basis is IncomeBasis.schedule for e in entries)
|
||||
|
||||
|
||||
def test_an_announced_payout_displaces_the_projection_of_the_same_payment():
|
||||
announced = [
|
||||
Entry(
|
||||
1,
|
||||
"dividend",
|
||||
date(2026, 10, 12),
|
||||
date(2026, 10, 9),
|
||||
D(20),
|
||||
D(5),
|
||||
D(100),
|
||||
"RUB",
|
||||
IncomeBasis.announced,
|
||||
)
|
||||
]
|
||||
projected = [
|
||||
Entry(
|
||||
1, "dividend", date(2026, 10, 20), None, D(20), D(4), D(80), "RUB", IncomeBasis.history
|
||||
),
|
||||
Entry(
|
||||
1, "dividend", date(2027, 4, 20), None, D(20), D(4), D(80), "RUB", IncomeBasis.history
|
||||
),
|
||||
]
|
||||
kept = drop_shadowed(announced, projected)
|
||||
# the declared autumn payment wins; the undeclared spring one survives
|
||||
assert [e.expected_date for e in kept] == [date(2027, 4, 20)]
|
||||
|
||||
|
||||
def test_the_fallback_resolver_prefers_the_strongest_status():
|
||||
same_day = date(2026, 10, 12)
|
||||
payouts = [
|
||||
Payout(1, "dividend", "forecast", None, same_day, D(3), "RUB"),
|
||||
Payout(1, "dividend", "announced", None, same_day, D(5), "RUB"),
|
||||
Payout(1, "dividend", "cancelled", None, date(2026, 11, 1), D(9), "RUB"),
|
||||
]
|
||||
resolved = resolve_actions_fallback(payouts)
|
||||
assert [(p.status, p.amount_per_unit) for p in resolved] == [("announced", D(5))]
|
||||
|
||||
|
||||
def payment(d: date, amount: str, *, held: str = "10", kind: str = "dividend") -> Payment:
|
||||
return Payment(
|
||||
account_id=1,
|
||||
instrument_id=1,
|
||||
kind=kind,
|
||||
d=d,
|
||||
currency="RUB",
|
||||
amount=D(amount),
|
||||
tax=D("0"),
|
||||
held_qty=D(held),
|
||||
)
|
||||
|
||||
|
||||
def test_history_extrapolates_the_last_amount_per_unit_onto_the_current_position():
|
||||
payments = [payment(d, "1000") for d in quarterly(8)]
|
||||
entries, steady = history_entries(
|
||||
1, payments, D(5), start=date(2026, 9, 18), end=date(2027, 9, 17)
|
||||
)
|
||||
assert steady
|
||||
assert len(entries) == 4
|
||||
# 1000 ₽ on 10 units, now holding 5 — half the money, not the same money
|
||||
assert {e.amount for e in entries} == {D(500)}
|
||||
assert all(e.basis is IncomeBasis.history for e in entries)
|
||||
|
||||
|
||||
def test_payments_fold_per_instrument_kind_and_day_across_accounts():
|
||||
d = date(2026, 8, 12)
|
||||
entries = fold_payments([payment(d, "600", held="6"), payment(d, "400", held="4")])
|
||||
assert len(entries) == 1
|
||||
assert (entries[0].amount, entries[0].qty, entries[0].per_unit) == (D(1000), D(10), D(100))
|
||||
assert entries[0].basis is IncomeBasis.paid
|
||||
|
||||
|
||||
def test_monthly_rows_group_by_month_kind_and_currency():
|
||||
rows = monthly_rows(
|
||||
[
|
||||
payment(date(2026, 8, 3), "100"),
|
||||
payment(date(2026, 8, 20), "200"),
|
||||
payment(date(2026, 8, 20), "300", kind="coupon"),
|
||||
payment(date(2026, 9, 1), "400"),
|
||||
]
|
||||
)
|
||||
assert rows[(date(2026, 8, 1), "dividend", "RUB")] == (D(300), D(0), 2)
|
||||
assert rows[(date(2026, 8, 1), "coupon", "RUB")] == (D(300), D(0), 1)
|
||||
assert rows[(date(2026, 9, 1), "dividend", "RUB")] == (D(400), D(0), 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# the rebuild, against the database
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def broker_account() -> int:
|
||||
return await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
|
||||
|
||||
async def make_bond(
|
||||
*,
|
||||
ticker: str = "RU000A0",
|
||||
nominal: str = "1000",
|
||||
maturity: date | None = None,
|
||||
currency: str = "RUB",
|
||||
) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
bond = Instrument(
|
||||
asset_class=AssetClass.bond,
|
||||
ticker=ticker,
|
||||
board="TQOB",
|
||||
name=ticker,
|
||||
currency=currency,
|
||||
nominal=D(nominal),
|
||||
nominal_currency=currency,
|
||||
maturity_date=maturity,
|
||||
)
|
||||
session.add(bond)
|
||||
await session.commit()
|
||||
await session.refresh(bond)
|
||||
return bond.id
|
||||
|
||||
|
||||
async def make_payout(
|
||||
d: date,
|
||||
*,
|
||||
account_id: int,
|
||||
instrument_id: int,
|
||||
amount: str,
|
||||
kind: EventKind = EventKind.dividend,
|
||||
tax: str | None = None,
|
||||
currency: str = "RUB",
|
||||
) -> None:
|
||||
"""A received payout. `make_event` has no `tax`, and the tax column is the point here."""
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
Event(
|
||||
account_id=account_id,
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
|
||||
trade_date=d,
|
||||
amount=D(amount),
|
||||
currency=currency,
|
||||
tax=D(tax) if tax is not None else None,
|
||||
tax_currency=currency if tax is not None else None,
|
||||
source="tinvest",
|
||||
source_id=f"pay-{instrument_id}-{d}-{amount}-{kind}",
|
||||
dedupe_key=f"tinvest:pay-{instrument_id}-{d}-{amount}-{kind}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_action(
|
||||
*,
|
||||
instrument_id: int,
|
||||
kind: CorporateActionKind,
|
||||
status: CorporateActionStatus,
|
||||
pay_date: date | None = None,
|
||||
record_date: date | None = None,
|
||||
amount_per_unit: str | None = None,
|
||||
currency: str = "RUB",
|
||||
source: str = "moex",
|
||||
) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
CorporateAction(
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
pay_date=pay_date,
|
||||
record_date=record_date,
|
||||
amount_per_unit=D(amount_per_unit) if amount_per_unit is not None else None,
|
||||
currency=currency,
|
||||
source=source,
|
||||
source_id=f"{kind}-{pay_date}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_nominal(instrument_id: int, effective: date, nominal: str) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
BondNominalSchedule(
|
||||
instrument_id=instrument_id,
|
||||
effective_date=effective,
|
||||
nominal=D(nominal),
|
||||
currency="RUB",
|
||||
source="moex",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def rebuild() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_income(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def calendar(scope: str = "all") -> list[MetricIncomeCalendar]:
|
||||
async with get_sessionmaker()() as session:
|
||||
return list(
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricIncomeCalendar)
|
||||
.where(MetricIncomeCalendar.scope == scope)
|
||||
.order_by(MetricIncomeCalendar.expected_date)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def monthly(scope: str = "all") -> list[MetricIncomeMonthly]:
|
||||
async with get_sessionmaker()() as session:
|
||||
return list(
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricIncomeMonthly)
|
||||
.where(MetricIncomeMonthly.scope == scope)
|
||||
.order_by(MetricIncomeMonthly.month, MetricIncomeMonthly.kind)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def forecast_within(rows, months: int = 12) -> list[MetricIncomeCalendar]:
|
||||
"""Future rows inside a half-open window of `months`, the way the API reads them."""
|
||||
today = today_local()
|
||||
end = add_months(today, months)
|
||||
return [r for r in rows if r.basis is not IncomeBasis.paid and today <= r.expected_date < end]
|
||||
|
||||
|
||||
async def test_every_payout_of_the_last_year_has_a_paid_calendar_row(app):
|
||||
"""Plan check: каждый полученный дивиденд/купон за 12 мес имеет запись календаря."""
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
bond = await make_bond()
|
||||
await make_event(
|
||||
today - timedelta(days=400),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="20",
|
||||
price="250",
|
||||
amount="-5000",
|
||||
)
|
||||
await make_event(
|
||||
today - timedelta(days=400),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=bond,
|
||||
quantity="10",
|
||||
price="1000",
|
||||
amount="-10000",
|
||||
)
|
||||
paid_days = [30, 120, 210, 300]
|
||||
for offset in paid_days:
|
||||
await make_payout(
|
||||
today - timedelta(days=offset),
|
||||
account_id=account,
|
||||
instrument_id=share,
|
||||
amount="400",
|
||||
)
|
||||
await make_payout(
|
||||
today - timedelta(days=60),
|
||||
account_id=account,
|
||||
instrument_id=bond,
|
||||
amount="400",
|
||||
kind=EventKind.coupon,
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
rows = await calendar()
|
||||
paid = {(r.instrument_id, r.kind, r.expected_date) for r in rows if r.basis is IncomeBasis.paid}
|
||||
for offset in paid_days:
|
||||
assert (share, "dividend", today - timedelta(days=offset)) in paid
|
||||
assert (bond, "coupon", today - timedelta(days=60)) in paid
|
||||
assert len(paid) == len(paid_days) + 1
|
||||
|
||||
|
||||
async def test_a_quarterly_payer_gives_four_future_entries_with_the_last_amount(app):
|
||||
"""Plan check: квартальный плательщик даёт 4 будущих записи с последней суммой."""
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="LKOH", name="Лукойл")
|
||||
await make_event(
|
||||
add_months(today, -30),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
for k in range(1, 9): # eight payments, one a quarter, the last three months ago
|
||||
await make_payout(
|
||||
add_months(today, -3 * k),
|
||||
account_id=account,
|
||||
instrument_id=share,
|
||||
amount="500",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
future = forecast_within(await calendar())
|
||||
assert len(future) == 4
|
||||
assert {r.basis for r in future} == {IncomeBasis.history}
|
||||
assert {r.amount for r in future} == {D(500)}
|
||||
assert {r.per_unit for r in future} == {D(50)}
|
||||
|
||||
|
||||
async def test_a_coupon_shrinks_with_the_nominal_after_an_amortisation(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
bond = await make_bond(ticker="RU000AMORT")
|
||||
await make_event(
|
||||
today - timedelta(days=30),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=bond,
|
||||
quantity="10",
|
||||
price="1000",
|
||||
amount="-10000",
|
||||
)
|
||||
await make_nominal(bond, today - timedelta(days=700), "1000")
|
||||
await make_nominal(bond, add_months(today, 2), "500")
|
||||
for months in (1, 4):
|
||||
await make_action(
|
||||
instrument_id=bond,
|
||||
kind=CorporateActionKind.coupon,
|
||||
status=CorporateActionStatus.announced,
|
||||
pay_date=add_months(today, months),
|
||||
amount_per_unit="40",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
coupons = {r.expected_date: r for r in await calendar() if r.kind == "coupon"}
|
||||
before = coupons[add_months(today, 1)]
|
||||
after = coupons[add_months(today, 4)]
|
||||
assert before.basis is IncomeBasis.schedule
|
||||
assert (before.per_unit, before.amount) == (D(40), D(400))
|
||||
# half the principal has been repaid, so the same published coupon pays half
|
||||
assert (after.per_unit, after.amount) == (D(20), D(200))
|
||||
# ...and the amortisation itself is a payment, priced off the step in the schedule
|
||||
amortisation = next(r for r in await calendar() if r.kind == "amortization")
|
||||
assert (amortisation.expected_date, amortisation.amount) == (add_months(today, 2), D(5000))
|
||||
|
||||
|
||||
async def test_a_sold_position_leaves_the_forecast_and_a_halved_one_halves_it(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
kept = await make_instrument(ticker="GAZP", name="Газпром")
|
||||
gone = await make_instrument(ticker="MGNT", name="Магнит")
|
||||
for instrument in (kept, gone):
|
||||
await make_event(
|
||||
add_months(today, -18),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="20",
|
||||
price="100",
|
||||
amount="-2000",
|
||||
)
|
||||
await make_payout(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
instrument_id=instrument,
|
||||
amount="2000",
|
||||
)
|
||||
await make_event(
|
||||
add_months(today, -2),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=gone,
|
||||
quantity="-20",
|
||||
price="100",
|
||||
amount="2000",
|
||||
)
|
||||
await make_event(
|
||||
add_months(today, -2),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=kept,
|
||||
quantity="-10",
|
||||
price="100",
|
||||
amount="1000",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
future = forecast_within(await calendar())
|
||||
assert [r.instrument_id for r in future] == [kept]
|
||||
# 2000 ₽ on 20 units, 10 units left: the forecast follows the position, not the history
|
||||
assert future[0].amount == D(1000)
|
||||
|
||||
|
||||
async def test_an_announced_dividend_beats_the_history_of_the_same_payment(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="TATN", name="Татнефть")
|
||||
await make_event(
|
||||
add_months(today, -18),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="500",
|
||||
amount="-5000",
|
||||
)
|
||||
await make_payout(add_months(today, -12), account_id=account, instrument_id=share, amount="300")
|
||||
announced_on = today + timedelta(days=10)
|
||||
await make_action(
|
||||
instrument_id=share,
|
||||
kind=CorporateActionKind.dividend,
|
||||
status=CorporateActionStatus.announced,
|
||||
record_date=today + timedelta(days=7),
|
||||
pay_date=announced_on,
|
||||
amount_per_unit="45",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
future = forecast_within(await calendar())
|
||||
assert len(future) == 1
|
||||
row = future[0]
|
||||
assert (row.basis, row.expected_date, row.amount) == (
|
||||
IncomeBasis.announced,
|
||||
announced_on,
|
||||
D(450),
|
||||
)
|
||||
assert row.record_date == today + timedelta(days=7)
|
||||
|
||||
|
||||
async def test_history_groups_by_month_kind_and_currency_and_sums_the_tax(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="PHOR", name="ФосАгро")
|
||||
bond = await make_bond(ticker="RU000TAX")
|
||||
await make_event(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
month = add_months(today.replace(day=5), -3)
|
||||
await make_payout(month, account_id=account, instrument_id=share, amount="870", tax="130")
|
||||
await make_payout(
|
||||
month + timedelta(days=10),
|
||||
account_id=account,
|
||||
instrument_id=share,
|
||||
amount="435",
|
||||
tax="65",
|
||||
)
|
||||
await make_payout(
|
||||
month + timedelta(days=2),
|
||||
account_id=account,
|
||||
instrument_id=bond,
|
||||
amount="400",
|
||||
kind=EventKind.coupon,
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
rows = {(r.month, r.kind): r for r in await monthly()}
|
||||
dividends = rows[(month.replace(day=1), "dividend")]
|
||||
assert (dividends.amount, dividends.tax_withheld, dividends.payment_count) == (
|
||||
D(1305),
|
||||
D(195),
|
||||
2,
|
||||
)
|
||||
assert dividends.currency == "RUB"
|
||||
assert rows[(month.replace(day=1), "coupon")].amount == D(400)
|
||||
|
||||
|
||||
async def test_a_payment_without_a_rate_keeps_its_row_and_loses_only_the_rouble_column(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="TCS", name="TCS Group", currency="USD")
|
||||
await make_event(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="10",
|
||||
amount="-100",
|
||||
currency="USD",
|
||||
)
|
||||
paid_on = add_months(today, -2)
|
||||
await make_payout(paid_on, account_id=account, instrument_id=share, amount="20", currency="USD")
|
||||
await refresh()
|
||||
FINDINGS.reset()
|
||||
await rebuild()
|
||||
|
||||
row = next(r for r in await calendar() if r.basis is IncomeBasis.paid)
|
||||
assert (row.amount, row.currency, row.amount_rub) == (D(20), "USD", None)
|
||||
assert next(r for r in await monthly()).amount_rub is None
|
||||
assert any(f.check_name == "income_missing_fx" for f in FINDINGS.items)
|
||||
|
||||
|
||||
async def test_a_rate_on_the_payment_date_fills_the_rouble_column(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="TCS", name="TCS Group", currency="USD")
|
||||
await make_event(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="10",
|
||||
amount="-100",
|
||||
currency="USD",
|
||||
)
|
||||
paid_on = add_months(today, -2)
|
||||
await make_cbr_rate(paid_on, "USD", "90")
|
||||
await make_payout(paid_on, account_id=account, instrument_id=share, amount="20", currency="USD")
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
row = next(r for r in await calendar() if r.basis is IncomeBasis.paid)
|
||||
assert row.amount_rub == D(1800)
|
||||
|
||||
|
||||
async def test_an_instrument_with_neither_schedule_nor_history_is_a_warning_not_a_zero(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="SILENT", name="Ничего не платит")
|
||||
await make_event(
|
||||
add_months(today, -6),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await refresh()
|
||||
FINDINGS.reset()
|
||||
await rebuild()
|
||||
|
||||
assert forecast_within(await calendar()) == []
|
||||
finding = next(f for f in FINDINGS.items if f.check_name == "income_without_history")
|
||||
assert finding.severity == "warn"
|
||||
assert finding.ref == {"instruments": [share]}
|
||||
|
||||
|
||||
async def test_the_tables_are_rebuilt_from_scratch_on_every_run(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="ROSN", name="Роснефть")
|
||||
await make_event(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_payout(add_months(today, -6), account_id=account, instrument_id=share, amount="500")
|
||||
await refresh()
|
||||
await rebuild()
|
||||
before = len(await calendar())
|
||||
await rebuild()
|
||||
assert len(await calendar()) == before
|
||||
assert before > 0
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Rebalancing: the pure planning rules, then the refresh step end to end."""
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event, make_instrument, make_price, refresh
|
||||
from fintracker.analytics import FINDINGS, today_local
|
||||
from fintracker.analytics.rebalance import (
|
||||
Position,
|
||||
Target,
|
||||
build_plan,
|
||||
compute_rebalance,
|
||||
rebuild_rebalance,
|
||||
)
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AllocationDimension,
|
||||
AssetClass,
|
||||
EventKind,
|
||||
Instrument,
|
||||
MetricAllocation,
|
||||
MetricRebalance,
|
||||
Portfolio,
|
||||
PortfolioAccount,
|
||||
PortfolioTarget,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
DIM = AllocationDimension
|
||||
|
||||
|
||||
def position(
|
||||
instrument_id: int = 1,
|
||||
*,
|
||||
ticker: str = "SBER",
|
||||
lot: int = 10,
|
||||
qty: str = "100",
|
||||
unit: str = "275",
|
||||
) -> Position:
|
||||
return Position(
|
||||
instrument_id=instrument_id,
|
||||
ticker=ticker,
|
||||
name=ticker,
|
||||
lot=lot,
|
||||
qty=D(qty),
|
||||
unit_value_rub=D(unit),
|
||||
price=D(unit),
|
||||
price_currency="RUB",
|
||||
)
|
||||
|
||||
|
||||
def plan(
|
||||
*,
|
||||
bucket_values: dict[str, Decimal],
|
||||
positions: dict[str, list[Position]],
|
||||
targets: dict[str, Target],
|
||||
cash: str = "1000000",
|
||||
):
|
||||
total = sum((v for v in bucket_values.values() if v > 0), start=D(0))
|
||||
return build_plan(
|
||||
portfolio_id=1,
|
||||
dimension=DIM.asset_class,
|
||||
as_of=today_local(),
|
||||
total_value_rub=total,
|
||||
bucket_values=bucket_values,
|
||||
positions=positions,
|
||||
targets=targets,
|
||||
cash_available_rub=D(cash),
|
||||
)
|
||||
|
||||
|
||||
def bucket(result, name: str):
|
||||
return next(b for b in result.buckets if b.bucket == name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- lots and cash
|
||||
|
||||
|
||||
def test_a_buy_is_whole_lots_even_when_the_money_would_stretch_further():
|
||||
# 100 lots' worth of money, a lot of 10 at 275 => 2750 a lot
|
||||
result = plan(
|
||||
bucket_values={"share": D("27500"), "cash": D("22500")},
|
||||
positions={"share": [position(qty="100", unit="275", lot=10)]},
|
||||
targets={"share": Target(D("0.8")), "cash": Target(D("0.2"))},
|
||||
cash="22500",
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
assert trade.action == "buy"
|
||||
# 0.8 * 50000 - 27500 = 12500 -> 45.45 units -> 4 lots = 40, never 45
|
||||
assert trade.qty == D(40)
|
||||
assert trade.qty % trade.lot == 0
|
||||
assert trade.amount_rub == D(40) * D("275")
|
||||
|
||||
|
||||
def test_a_buy_is_cut_to_the_cash_on_hand_and_says_so():
|
||||
result = plan(
|
||||
bucket_values={"share": D("27500"), "cash": D("22500")},
|
||||
positions={"share": [position(qty="100", unit="275", lot=10)]},
|
||||
targets={"share": Target(D("0.8")), "cash": Target(D("0.2"))},
|
||||
cash="6000",
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
# 6000 buys two lots (5500), not the 4 the target asks for
|
||||
assert trade.qty == D(20)
|
||||
assert trade.blocked_by_cash is True
|
||||
assert trade.amount_rub <= D("6000")
|
||||
|
||||
|
||||
def test_no_cash_at_all_still_reports_the_blocked_buy_rather_than_hiding_it():
|
||||
result = plan(
|
||||
bucket_values={"share": D("27500"), "cash": D("22500")},
|
||||
positions={"share": [position(qty="100", unit="275", lot=10)]},
|
||||
targets={"share": Target(D("0.8")), "cash": Target(D("0.2"))},
|
||||
cash="0",
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
assert trade.qty == D(0)
|
||||
assert trade.blocked_by_cash is True
|
||||
|
||||
|
||||
def test_cash_is_spent_once_across_buckets():
|
||||
result = plan(
|
||||
bucket_values={"share": D("1000"), "bond": D("1000"), "cash": D("8000")},
|
||||
positions={
|
||||
"share": [position(1, ticker="SBER", qty="10", unit="100", lot=1)],
|
||||
"bond": [position(2, ticker="OFZ", qty="10", unit="100", lot=1)],
|
||||
},
|
||||
targets={"share": Target(D("0.45")), "bond": Target(D("0.45")), "cash": Target(D("0.1"))},
|
||||
cash="1000",
|
||||
)
|
||||
spent = sum(t.amount_rub for b in result.buckets for t in b.trades if t.action == "buy")
|
||||
assert spent <= D("1000")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the band
|
||||
|
||||
|
||||
def test_a_drift_inside_the_band_proposes_nothing():
|
||||
result = plan(
|
||||
bucket_values={"share": D("6200"), "bond": D("3800")},
|
||||
positions={"share": [position(qty="62", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("0.6"), D("0.05")), "bond": Target(D("0.4"), D("0.05"))},
|
||||
)
|
||||
share = bucket(result, "share")
|
||||
assert share.drift == D("0.02")
|
||||
assert share.within_band is True
|
||||
assert share.trades == []
|
||||
assert share.delta_value_rub == D(0)
|
||||
|
||||
|
||||
def test_the_same_drift_outside_the_band_proposes_a_trade():
|
||||
result = plan(
|
||||
bucket_values={"share": D("6200"), "bond": D("3800")},
|
||||
positions={
|
||||
"share": [position(qty="62", unit="100", lot=1)],
|
||||
"bond": [position(2, ticker="OFZ", qty="38", unit="100", lot=1)],
|
||||
},
|
||||
targets={"share": Target(D("0.6"), D("0.01")), "bond": Target(D("0.4"), D("0.01"))},
|
||||
)
|
||||
share = bucket(result, "share")
|
||||
assert share.within_band is False
|
||||
assert share.trades[0].action == "sell"
|
||||
assert share.trades[0].qty == D(2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- sells
|
||||
|
||||
|
||||
def test_a_sell_never_exceeds_the_position_and_never_goes_short():
|
||||
# the bucket must shrink by more than it holds: the target moved to zero
|
||||
result = plan(
|
||||
bucket_values={"share": D("1000"), "bond": D("9000")},
|
||||
positions={"share": [position(qty="10", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("0")), "bond": Target(D("1"))},
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
assert trade.action == "sell"
|
||||
assert trade.qty == D(10)
|
||||
assert trade.qty <= D(10)
|
||||
|
||||
|
||||
def test_a_sell_is_capped_to_whole_lots_of_what_is_held():
|
||||
# 25 units of a 10-lot paper: at most two lots can be sold
|
||||
result = plan(
|
||||
bucket_values={"share": D("2500"), "bond": D("7500")},
|
||||
positions={"share": [position(qty="25", unit="100", lot=10)]},
|
||||
targets={"share": Target(D("0")), "bond": Target(D("1"))},
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
assert trade.qty == D(20)
|
||||
|
||||
|
||||
def test_a_bucket_is_trimmed_proportionally_not_from_one_paper():
|
||||
result = plan(
|
||||
bucket_values={"share": D("10000"), "bond": D("0")},
|
||||
positions={
|
||||
"share": [
|
||||
position(1, ticker="BIG", qty="75", unit="100", lot=1),
|
||||
position(2, ticker="SMALL", qty="25", unit="100", lot=1),
|
||||
]
|
||||
},
|
||||
targets={"share": Target(D("0.5")), "bond": Target(D("0.5"))},
|
||||
)
|
||||
by_ticker = {t.ticker: t.qty for t in bucket(result, "share").trades}
|
||||
# 5000 to raise, split 75/25 by value: 37 and 12 units (floored to whole lots)
|
||||
assert by_ticker == {"BIG": D(37), "SMALL": D(12)}
|
||||
|
||||
|
||||
def test_a_bucket_with_nothing_priced_in_it_warns_instead_of_inventing_a_trade():
|
||||
result = plan(
|
||||
bucket_values={"share": D("10000"), "bond": D("0")},
|
||||
positions={},
|
||||
targets={"share": Target(D("0.5")), "bond": Target(D("0.5"))},
|
||||
)
|
||||
assert bucket(result, "share").trades == []
|
||||
assert any("share" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_the_cash_bucket_needs_no_trades_and_produces_no_warning():
|
||||
result = plan(
|
||||
bucket_values={"share": D("5000"), "cash": D("5000")},
|
||||
positions={"share": [position(qty="50", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("0.9")), "cash": Target(D("0.1"))},
|
||||
cash="5000",
|
||||
)
|
||||
assert bucket(result, "cash").trades == []
|
||||
assert not any("cash" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_a_bucket_without_a_target_is_reported_but_never_traded():
|
||||
result = plan(
|
||||
bucket_values={"share": D("5000"), "etf": D("5000")},
|
||||
positions={"etf": [position(2, ticker="TMOS", qty="50", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("1"))},
|
||||
)
|
||||
etf = bucket(result, "etf")
|
||||
assert etf.target_weight is None
|
||||
assert etf.drift is None
|
||||
assert etf.trades == []
|
||||
|
||||
|
||||
def test_every_number_in_the_plan_is_a_decimal():
|
||||
result = plan(
|
||||
bucket_values={"share": D("6200"), "bond": D("3800")},
|
||||
positions={"share": [position(qty="62", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("0.5")), "bond": Target(D("0.5"))},
|
||||
)
|
||||
for b in result.buckets:
|
||||
for value in (b.current_value_rub, b.current_weight, b.delta_value_rub):
|
||||
assert isinstance(value, Decimal)
|
||||
for t in b.trades:
|
||||
for value in (t.qty, t.price, t.amount_rub):
|
||||
assert isinstance(value, Decimal)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- database
|
||||
|
||||
|
||||
async def _portfolio_with(*, unpriced: bool) -> dict[str, int]:
|
||||
"""A broker account in a portfolio: 500 SBER (lot 10), 20 OFZ, the rest in cash."""
|
||||
t = today_local()
|
||||
bought = t - timedelta(days=40)
|
||||
account = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
sber = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
|
||||
ofz = await make_instrument(ticker="OFZ", name="ОФЗ", asset_class=AssetClass.bond)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = await session.get(Instrument, sber)
|
||||
assert instrument is not None
|
||||
instrument.lot = 10
|
||||
portfolio = Portfolio(name="Основной")
|
||||
session.add(portfolio)
|
||||
await session.flush()
|
||||
session.add(PortfolioAccount(portfolio_id=portfolio.id, account_id=account))
|
||||
portfolio_id = portfolio.id
|
||||
await session.commit()
|
||||
|
||||
await make_event(bought, account_id=account, kind=EventKind.deposit, amount="100000")
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=sber,
|
||||
quantity="500",
|
||||
price="100",
|
||||
amount="-50000",
|
||||
)
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=ofz,
|
||||
quantity="20",
|
||||
price="1000",
|
||||
amount="-20000",
|
||||
)
|
||||
ids = {"account": account, "portfolio": portfolio_id, "sber": sber, "ofz": ofz}
|
||||
|
||||
if unpriced:
|
||||
silent = await make_instrument(
|
||||
ticker="SIBN6P4", name="Без цены", asset_class=AssetClass.share, board="SPBRUBND"
|
||||
)
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=silent,
|
||||
quantity="5",
|
||||
price="1000",
|
||||
amount="-5000",
|
||||
)
|
||||
ids["silent"] = silent
|
||||
|
||||
d = bought
|
||||
while d <= t:
|
||||
await make_price(d, instrument_id=sber, close="100")
|
||||
await make_price(d, instrument_id=ofz, close="1000")
|
||||
d += timedelta(days=1)
|
||||
await refresh()
|
||||
return ids
|
||||
|
||||
|
||||
async def _set_targets(portfolio_id: int, rows: list[tuple[str, str, str]]) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
for bucket_name, weight, band in rows:
|
||||
session.add(
|
||||
PortfolioTarget(
|
||||
portfolio_id=portfolio_id,
|
||||
dimension=DIM.asset_class,
|
||||
bucket=bucket_name,
|
||||
target_weight=D(weight),
|
||||
band=D(band),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def portfolio(app) -> dict[str, int]:
|
||||
ids = await _portfolio_with(unpriced=False)
|
||||
await _set_targets(
|
||||
ids["portfolio"],
|
||||
[("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")],
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
async def test_the_step_fills_the_target_columns_of_metric_allocation(portfolio):
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_rebalance(session)
|
||||
await session.commit()
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricAllocation).where(
|
||||
MetricAllocation.scope == f"portfolio:{portfolio['portfolio']}",
|
||||
MetricAllocation.dimension == DIM.asset_class,
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
by_bucket = {r.bucket: r for r in rows}
|
||||
assert by_bucket["share"].target_weight == D("0.6")
|
||||
assert by_bucket["share"].weight == D("0.5")
|
||||
assert by_bucket["share"].drift == D("-0.1")
|
||||
assert by_bucket["cash"].target_weight == D("0.2")
|
||||
assert by_bucket["cash"].drift == D("0.1")
|
||||
|
||||
|
||||
async def test_metric_rebalance_agrees_with_metric_allocation(portfolio):
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_rebalance(session)
|
||||
await session.commit()
|
||||
allocation = {
|
||||
r.bucket: r
|
||||
for r in (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricAllocation).where(
|
||||
MetricAllocation.scope == f"portfolio:{portfolio['portfolio']}",
|
||||
MetricAllocation.dimension == DIM.asset_class,
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
}
|
||||
summaries = {
|
||||
r.bucket: r
|
||||
for r in (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricRebalance).where(MetricRebalance.instrument_id.is_(None))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
}
|
||||
trades = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricRebalance).where(MetricRebalance.instrument_id.is_not(None))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
for name, row in summaries.items():
|
||||
assert row.current_weight == allocation[name].weight
|
||||
assert row.target_weight == allocation[name].target_weight
|
||||
assert row.current_value_rub == allocation[name].value_rub
|
||||
|
||||
# 0.6 of 100 000 is 60 000 against 50 000 held: 100 more shares at 100, lot 10
|
||||
buy = next(t for t in trades if t.instrument_id == portfolio["sber"])
|
||||
assert buy.suggested_qty == D(100)
|
||||
assert buy.suggested_qty is not None
|
||||
assert buy.lot is not None
|
||||
assert buy.suggested_qty % buy.lot == 0
|
||||
assert buy.blocked_by_cash is False
|
||||
# the bond bucket sits exactly on its target and proposes nothing
|
||||
assert summaries["bond"].within_band is True
|
||||
assert not [t for t in trades if t.instrument_id == portfolio["ofz"]]
|
||||
|
||||
|
||||
async def test_an_instrument_without_a_price_is_left_out_but_reported(app):
|
||||
ids = await _portfolio_with(unpriced=True)
|
||||
await _set_targets(
|
||||
ids["portfolio"],
|
||||
[("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")],
|
||||
)
|
||||
FINDINGS.reset()
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_rebalance(session)
|
||||
await session.commit()
|
||||
trades = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricRebalance).where(MetricRebalance.instrument_id.is_not(None))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert ids["silent"] not in {t.instrument_id for t in trades}
|
||||
assert any(
|
||||
f.check_name == "rebalance_incomplete" and "SIBN6P4" in f.detail for f in FINDINGS.items
|
||||
)
|
||||
|
||||
|
||||
async def test_the_what_if_cash_overrides_the_real_balance(portfolio):
|
||||
async with get_sessionmaker()() as session:
|
||||
real = await compute_rebalance(session, portfolio["portfolio"], DIM.asset_class)
|
||||
poor = await compute_rebalance(
|
||||
session, portfolio["portfolio"], DIM.asset_class, cash_available_rub=D("500")
|
||||
)
|
||||
assert real.cash_available_rub == D("30000")
|
||||
rich_trade = next(t for b in real.buckets for t in b.trades)
|
||||
poor_trade = next(t for b in poor.buckets for t in b.trades)
|
||||
assert poor_trade.qty < rich_trade.qty
|
||||
assert poor_trade.blocked_by_cash is True
|
||||
@@ -0,0 +1,367 @@
|
||||
"""The tax year, checked against an example worked out by hand — the plan's фаза-4 check.
|
||||
|
||||
Everything here is an estimate by construction (the broker is the tax agent), so the tests
|
||||
are about the two things that make the estimate worth having: that it is reproducible on
|
||||
paper, and that it never invents a number it does not have.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import (
|
||||
make_account,
|
||||
make_cbr_rate,
|
||||
make_event,
|
||||
make_instrument,
|
||||
)
|
||||
from fintracker.analytics import FINDINGS
|
||||
from fintracker.analytics.tax import TAX_RATE, rebuild_tax_year
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.ledger.rebuild import rebuild_lots
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
EventKind,
|
||||
LotDisposal,
|
||||
MetricTaxYear,
|
||||
)
|
||||
from fintracker.pricing.fx import rebuild_fx_daily
|
||||
|
||||
D = Decimal
|
||||
|
||||
|
||||
async def _rebuild() -> None:
|
||||
"""The three steps a tax year depends on, without the rest of the refresh."""
|
||||
FINDINGS.reset()
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_fx_daily(session)
|
||||
await session.commit()
|
||||
await rebuild_lots(session)
|
||||
await session.commit()
|
||||
await rebuild_tax_year(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _rows() -> dict[tuple[int, int], MetricTaxYear]:
|
||||
async with get_sessionmaker()() as session:
|
||||
found = await session.execute(select(MetricTaxYear))
|
||||
return {(r.year, r.account_id): r for r in found.scalars()}
|
||||
|
||||
|
||||
async def _payment(
|
||||
account_id: int,
|
||||
instrument_id: int,
|
||||
kind: EventKind,
|
||||
d: date,
|
||||
amount: str,
|
||||
*,
|
||||
tax: str | None = None,
|
||||
currency: str = "RUB",
|
||||
) -> None:
|
||||
"""A dividend or coupon as a broker reports it: net cash plus the tax it kept back."""
|
||||
from datetime import UTC, datetime, time
|
||||
|
||||
from fintracker.models import Event
|
||||
|
||||
key = f"{kind}-{instrument_id}-{d}"
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
Event(
|
||||
account_id=account_id,
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
ts=datetime.combine(d, time.min, tzinfo=UTC),
|
||||
trade_date=d,
|
||||
amount=D(amount),
|
||||
currency=currency,
|
||||
tax=None if tax is None else D(tax),
|
||||
tax_currency=None if tax is None else currency,
|
||||
source="tinvest",
|
||||
source_id=key,
|
||||
dedupe_key=f"tinvest:{key}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _broker_account(name: str = "Брокерский") -> int:
|
||||
return await make_account(
|
||||
name=name,
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def usd_rates(app) -> None:
|
||||
"""Two CBR quotes: the dollar rose by 20 % between the purchase and the sale."""
|
||||
await make_cbr_rate(date(2023, 3, 10), "USD", "75")
|
||||
await make_cbr_rate(date(2024, 6, 20), "USD", "90")
|
||||
|
||||
|
||||
async def test_the_worked_example_adds_up(usd_rates):
|
||||
"""Done on paper first; the code has to agree with the paper, not the other way round.
|
||||
|
||||
Purchase 10.03.2023: 10 units at $100 = $1000, CBR 75 ₽/$ -> cost 75 000 ₽
|
||||
Sale 20.06.2024: 10 units at $110 = $1100, CBR 90 ₽/$ -> proceeds 99 000 ₽
|
||||
Realised in roubles = 24 000 ₽
|
||||
of which the price move is $100 x 90 = 9 000 ₽
|
||||
and currency revaluation $1000 x 15 = 15 000 ₽ (in the base, plan §7 q4)
|
||||
|
||||
Plus a rouble lot bought 10.01.2020 and sold the same day in 2024 for +1 000 ₽. It is
|
||||
held over three years, so art. 219.1 takes its result back out of the base.
|
||||
|
||||
gain 24 000 + 1 000 = 25 000 ₽
|
||||
loss 0
|
||||
ЛДВ exempt 1 000 ₽
|
||||
base 25 000 - 1 000 = 24 000 ₽
|
||||
tax 24 000 x 0.13 = 3 120 ₽
|
||||
"""
|
||||
account = await _broker_account()
|
||||
foreign = await make_instrument(ticker="AAPL", name="Apple", currency="USD")
|
||||
old = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
|
||||
await make_event(
|
||||
date(2023, 3, 10),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=foreign,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
currency="USD",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=foreign,
|
||||
quantity="-10",
|
||||
price="110",
|
||||
amount="1100",
|
||||
currency="USD",
|
||||
)
|
||||
await make_event(
|
||||
date(2020, 1, 10),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=old,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=old,
|
||||
quantity="-10",
|
||||
price="200",
|
||||
amount="2000",
|
||||
)
|
||||
|
||||
await _rebuild()
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
assert row.realized_gain_rub == D("25000.00")
|
||||
assert row.realized_loss_rub == D("0.00")
|
||||
assert row.ldv_exempt_rub == D("1000.00")
|
||||
assert row.taxable_base_rub == D("24000.00")
|
||||
assert row.estimated_tax_rub == D("3120.00")
|
||||
# the rate is recorded, not implied, so a future change stays visible in old years
|
||||
assert row.tax_rate == TAX_RATE == D("0.13")
|
||||
|
||||
|
||||
async def test_the_three_year_lot_is_flagged_by_the_ledgers_own_rule(usd_rates):
|
||||
"""`ldv_eligible` is computed once, in `ledger/lots.py`; the tax view only reads it."""
|
||||
account = await _broker_account()
|
||||
instrument = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
await make_event(
|
||||
date(2020, 1, 10),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=instrument,
|
||||
quantity="-10",
|
||||
price="200",
|
||||
amount="2000",
|
||||
)
|
||||
await _rebuild()
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
disposal = (await session.execute(select(LotDisposal))).scalar_one()
|
||||
assert disposal.holding_days >= 3 * 365
|
||||
assert disposal.ldv_eligible is True
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
assert row.ldv_exempt_rub == D("1000.00")
|
||||
assert row.taxable_base_rub == D("0.00")
|
||||
assert row.estimated_tax_rub == D("0.00")
|
||||
|
||||
|
||||
async def test_currency_revaluation_is_not_the_currency_result(usd_rates):
|
||||
"""A position flat in dollars still owes tax when the dollar rose — and the two numbers
|
||||
must not be confused: the base is 15 000 ₽ while the dollar result is exactly zero."""
|
||||
account = await _broker_account()
|
||||
instrument = await make_instrument(ticker="AAPL", name="Apple", currency="USD")
|
||||
await make_event(
|
||||
date(2023, 3, 10),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
currency="USD",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=instrument,
|
||||
quantity="-10",
|
||||
price="100",
|
||||
amount="1000",
|
||||
currency="USD",
|
||||
)
|
||||
await _rebuild()
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
disposal = (await session.execute(select(LotDisposal))).scalar_one()
|
||||
assert disposal.realized_pnl_native == D(0)
|
||||
assert disposal.realized_pnl_rub == D("15000.0000000000")
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
assert row.taxable_base_rub == D("15000.00")
|
||||
assert row.estimated_tax_rub == D("1950.00")
|
||||
# and the fact that a revaluation happened is said out loud, because a Minfin eurobond
|
||||
# hiding among these would be taxed differently and cannot be detected automatically
|
||||
assert any(f.check_name == "tax_currency_revaluation" for f in FINDINGS.items)
|
||||
|
||||
|
||||
async def test_a_leg_without_a_rate_is_left_out_and_reported(usd_rates):
|
||||
"""No rate means no rouble result. Never a substitute — a finding instead."""
|
||||
account = await _broker_account()
|
||||
quoted = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
unquoted = await make_instrument(ticker="0700", name="Tencent", currency="HKD", board="SPBHKEX")
|
||||
|
||||
await make_event(
|
||||
date(2024, 2, 1),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=quoted,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=quoted,
|
||||
quantity="-10",
|
||||
price="150",
|
||||
amount="1500",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 2, 1),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=unquoted,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
currency="HKD",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=unquoted,
|
||||
quantity="-10",
|
||||
price="300",
|
||||
amount="3000",
|
||||
currency="HKD",
|
||||
)
|
||||
await _rebuild()
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
# only the rouble trade is in the base; the HKD one did not inflate or deflate it
|
||||
assert row.realized_gain_rub == D("500.00")
|
||||
assert row.taxable_base_rub == D("500.00")
|
||||
assert row.estimated_tax_rub == D("65.00")
|
||||
assert any(f.check_name == "tax_disposal_no_fx" for f in FINDINGS.items)
|
||||
|
||||
|
||||
async def test_dividends_and_coupons_are_gross_with_the_tax_that_was_withheld(app):
|
||||
"""`amount` is what landed and `tax` is what was taken; gross is their sum."""
|
||||
account = await _broker_account()
|
||||
share = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
bond = await make_instrument(ticker="SU26238", name="ОФЗ", asset_class=AssetClass.bond)
|
||||
|
||||
await _payment(account, share, EventKind.dividend, date(2024, 5, 15), "870", tax="130")
|
||||
await _payment(account, bond, EventKind.coupon, date(2024, 8, 1), "500")
|
||||
|
||||
await _rebuild()
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
assert row.dividends_gross_rub == D("1000.00")
|
||||
assert row.coupons_gross_rub == D("500.00")
|
||||
assert row.tax_withheld_rub == D("130.00")
|
||||
# income is outside the securities base: the agent already withheld on it
|
||||
assert row.taxable_base_rub == D("0.00")
|
||||
|
||||
|
||||
async def test_every_stored_number_is_a_decimal(usd_rates):
|
||||
account = await _broker_account()
|
||||
instrument = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
await make_event(
|
||||
date(2024, 2, 1),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=instrument,
|
||||
quantity="-10",
|
||||
price="150",
|
||||
amount="1500",
|
||||
)
|
||||
await _rebuild()
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
for field in (
|
||||
"dividends_gross_rub",
|
||||
"coupons_gross_rub",
|
||||
"tax_withheld_rub",
|
||||
"realized_gain_rub",
|
||||
"realized_loss_rub",
|
||||
"ldv_exempt_rub",
|
||||
"taxable_base_rub",
|
||||
"estimated_tax_rub",
|
||||
"tax_rate",
|
||||
):
|
||||
value = getattr(row, field)
|
||||
assert isinstance(value, Decimal), field
|
||||
assert not isinstance(value, float), field
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Goal CRUD and progress over HTTP (docs/ai/phase4-contract.md §4).
|
||||
|
||||
The router is mounted here rather than taken from `create_app`: wiring it into
|
||||
`api/app.py` belongs to the phase-4 integration.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from factories import make_account, make_event, make_instrument, make_price, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind
|
||||
|
||||
D = Decimal
|
||||
PREFIX = "/api/v1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app) -> AsyncIterator[AsyncClient]:
|
||||
from fintracker.api.routers import goals
|
||||
|
||||
app.include_router(goals.router, prefix=PREFIX)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def portfolio(app) -> None:
|
||||
"""A year of history ending 50 % up: 1000 shares bought at 100, now worth 150."""
|
||||
t = today_local()
|
||||
start = t - timedelta(days=365)
|
||||
account = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
|
||||
await make_event(start, account_id=account, kind=EventKind.deposit, amount="100000")
|
||||
await make_event(
|
||||
start,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="1000",
|
||||
price="100",
|
||||
amount="-100000",
|
||||
)
|
||||
d = start
|
||||
while d <= t:
|
||||
await make_price(d, instrument_id=share, close="100" if d < t else "150")
|
||||
d += timedelta(days=1)
|
||||
await refresh()
|
||||
|
||||
|
||||
async def create(client, auth_headers, **body):
|
||||
payload = {"name": "Капитал", "scope": "all", "target_amount": "1000000"} | body
|
||||
return await client.post(f"{PREFIX}/goals", json=payload, headers=auth_headers)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- CRUD
|
||||
|
||||
|
||||
async def test_a_goal_round_trips(client, auth_headers, portfolio):
|
||||
r = await create(client, auth_headers, target_date="2030-01-01", monthly_contribution="30000")
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["name"] == "Капитал"
|
||||
assert Decimal(body["target_amount"]) == 1000000
|
||||
assert body["target_date"] == "2030-01-01"
|
||||
assert Decimal(body["monthly_contribution"]) == 30000
|
||||
assert body["archived"] is False
|
||||
|
||||
listing = await client.get(f"{PREFIX}/goals", headers=auth_headers)
|
||||
assert [g["id"] for g in listing.json()] == [body["id"]]
|
||||
|
||||
|
||||
async def test_a_duplicate_name_is_a_conflict(client, auth_headers, portfolio):
|
||||
await create(client, auth_headers)
|
||||
r = await create(client, auth_headers)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
async def test_a_scope_the_metrics_never_built_is_refused(client, auth_headers, portfolio):
|
||||
r = await create(client, auth_headers, scope="portfolio:999")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_patch_changes_only_what_is_sent(client, auth_headers, portfolio):
|
||||
created = (await create(client, auth_headers, monthly_contribution="1000")).json()
|
||||
r = await client.patch(
|
||||
f"{PREFIX}/goals/{created['id']}",
|
||||
json={"target_amount": "500000"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert Decimal(body["target_amount"]) == 500000
|
||||
assert Decimal(body["monthly_contribution"]) == 1000
|
||||
|
||||
|
||||
async def test_archived_goals_are_hidden_unless_asked_for(client, auth_headers, portfolio):
|
||||
created = (await create(client, auth_headers)).json()
|
||||
await client.patch(
|
||||
f"{PREFIX}/goals/{created['id']}", json={"archived": True}, headers=auth_headers
|
||||
)
|
||||
assert (await client.get(f"{PREFIX}/goals", headers=auth_headers)).json() == []
|
||||
shown = await client.get(
|
||||
f"{PREFIX}/goals", params={"include_archived": "true"}, headers=auth_headers
|
||||
)
|
||||
assert [g["id"] for g in shown.json()] == [created["id"]]
|
||||
|
||||
|
||||
async def test_a_goal_can_be_deleted(client, auth_headers, portfolio):
|
||||
created = (await create(client, auth_headers)).json()
|
||||
r = await client.delete(f"{PREFIX}/goals/{created['id']}", headers=auth_headers)
|
||||
assert r.status_code == 204
|
||||
assert (await client.get(f"{PREFIX}/goals", headers=auth_headers)).json() == []
|
||||
assert (
|
||||
await client.delete(f"{PREFIX}/goals/{created['id']}", headers=auth_headers)
|
||||
).status_code == 404
|
||||
|
||||
|
||||
async def test_goals_need_a_token(client, portfolio):
|
||||
assert (await client.get(f"{PREFIX}/goals")).status_code == 401
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- progress
|
||||
|
||||
|
||||
async def test_progress_is_computed_from_the_live_metrics(client, auth_headers, portfolio):
|
||||
created = (await create(client, auth_headers, target_amount="300000")).json()
|
||||
r = await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert Decimal(body["current_value_rub"]) == 150000
|
||||
assert Decimal(body["target_amount_rub"]) == 300000
|
||||
assert Decimal(body["progress"]) == Decimal("0.5")
|
||||
assert body["basis"] == "xirr"
|
||||
assert body["projected_date"] is not None
|
||||
assert body["projected_date"] > str(today_local())
|
||||
assert body["monthly_needed_rub"] is None
|
||||
assert body["on_track"] is None
|
||||
|
||||
|
||||
async def test_a_deadline_produces_a_monthly_need_and_an_on_track_flag(
|
||||
client, auth_headers, portfolio
|
||||
):
|
||||
created = (
|
||||
await create(
|
||||
client,
|
||||
auth_headers,
|
||||
target_amount="10000000",
|
||||
target_date=str(today_local() + timedelta(days=365)),
|
||||
)
|
||||
).json()
|
||||
body = (
|
||||
await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers)
|
||||
).json()
|
||||
assert Decimal(body["monthly_needed_rub"]) > 0
|
||||
assert body["on_track"] is False
|
||||
|
||||
|
||||
async def test_every_money_field_is_a_string(client, auth_headers, portfolio):
|
||||
created = (await create(client, auth_headers)).json()
|
||||
body = (
|
||||
await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers)
|
||||
).json()
|
||||
for key in ("current_value_rub", "target_amount_rub", "progress"):
|
||||
assert isinstance(body[key], str)
|
||||
for key in ("assumed_rate", "monthly_needed_rub"):
|
||||
assert body[key] is None or isinstance(body[key], str)
|
||||
|
||||
|
||||
async def test_progress_of_an_unknown_goal_is_a_404(client, auth_headers, portfolio):
|
||||
r = await client.get(f"{PREFIX}/goals/999/progress", headers=auth_headers)
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,320 @@
|
||||
"""`/income` over a small portfolio: one paid dividend, one announced, one bond coupon.
|
||||
|
||||
The router is not wired into `create_app` yet (that is done separately), so the fixture mounts
|
||||
it on the same application the rest of the API tests use.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from factories import make_account, make_event, make_instrument, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.analytics.income import add_months, rebuild_income
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
BondNominalSchedule,
|
||||
CorporateAction,
|
||||
CorporateActionKind,
|
||||
CorporateActionStatus,
|
||||
Event,
|
||||
EventKind,
|
||||
Instrument,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
PREFIX = "/api/v1/income"
|
||||
|
||||
|
||||
async def make_bond(*, ticker: str, maturity: date | None = None) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
bond = Instrument(
|
||||
asset_class=AssetClass.bond,
|
||||
ticker=ticker,
|
||||
board="TQOB",
|
||||
name=ticker,
|
||||
currency="RUB",
|
||||
nominal=D(1000),
|
||||
nominal_currency="RUB",
|
||||
maturity_date=maturity,
|
||||
)
|
||||
session.add(bond)
|
||||
await session.commit()
|
||||
await session.refresh(bond)
|
||||
return bond.id
|
||||
|
||||
|
||||
async def make_payout(
|
||||
d: date,
|
||||
*,
|
||||
account_id: int,
|
||||
instrument_id: int,
|
||||
amount: str,
|
||||
kind: EventKind = EventKind.dividend,
|
||||
tax: str | None = None,
|
||||
) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
Event(
|
||||
account_id=account_id,
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
|
||||
trade_date=d,
|
||||
amount=D(amount),
|
||||
currency="RUB",
|
||||
tax=D(tax) if tax is not None else None,
|
||||
tax_currency="RUB" if tax is not None else None,
|
||||
source="tinvest",
|
||||
source_id=f"pay-{instrument_id}-{d}-{amount}",
|
||||
dedupe_key=f"tinvest:pay-{instrument_id}-{d}-{amount}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_action(
|
||||
*,
|
||||
instrument_id: int,
|
||||
kind: CorporateActionKind,
|
||||
status: CorporateActionStatus,
|
||||
pay_date: date,
|
||||
record_date: date | None = None,
|
||||
amount_per_unit: str,
|
||||
) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
CorporateAction(
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
pay_date=pay_date,
|
||||
record_date=record_date,
|
||||
amount_per_unit=D(amount_per_unit),
|
||||
currency="RUB",
|
||||
source="moex",
|
||||
source_id=f"{kind}-{pay_date}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_nominal(instrument_id: int, effective: date, nominal: str) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
BondNominalSchedule(
|
||||
instrument_id=instrument_id,
|
||||
effective_date=effective,
|
||||
nominal=D(nominal),
|
||||
currency="RUB",
|
||||
source="moex",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def rebuild() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_income(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def income_client(app, user) -> AsyncIterator[AsyncClient]:
|
||||
from fintracker.api.routers import income
|
||||
|
||||
app.include_router(income.router, prefix="/api/v1")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
r = await c.post("/api/v1/auth/login", json=user)
|
||||
assert r.status_code == 200, r.text
|
||||
c.headers["Authorization"] = f"Bearer {r.json()['access_token']}"
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def portfolio(app) -> dict[str, int]:
|
||||
"""A share that paid twice and has a declared payout, and an amortising bond."""
|
||||
today = today_local()
|
||||
account = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
share = await make_instrument(ticker="SBER", name="Сбербанк России")
|
||||
bond = await make_bond(ticker="RU000API", maturity=add_months(today, 30))
|
||||
|
||||
await make_event(
|
||||
add_months(today, -20),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="20",
|
||||
price="250",
|
||||
amount="-5000",
|
||||
)
|
||||
await make_event(
|
||||
add_months(today, -20),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=bond,
|
||||
quantity="10",
|
||||
price="1000",
|
||||
amount="-10000",
|
||||
)
|
||||
await make_payout(
|
||||
add_months(today, -13), account_id=account, instrument_id=share, amount="696.80", tax="104"
|
||||
)
|
||||
await make_payout(
|
||||
add_months(today, -1), account_id=account, instrument_id=share, amount="696.80", tax="104"
|
||||
)
|
||||
await make_action(
|
||||
instrument_id=share,
|
||||
kind=CorporateActionKind.dividend,
|
||||
status=CorporateActionStatus.announced,
|
||||
record_date=today + timedelta(days=21),
|
||||
pay_date=today + timedelta(days=24),
|
||||
amount_per_unit="34.84",
|
||||
)
|
||||
await make_nominal(bond, add_months(today, -24), "1000")
|
||||
await make_action(
|
||||
instrument_id=bond,
|
||||
kind=CorporateActionKind.coupon,
|
||||
status=CorporateActionStatus.announced,
|
||||
pay_date=add_months(today, 2),
|
||||
amount_per_unit="40",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
return {"account": account, "share": share, "bond": bond}
|
||||
|
||||
|
||||
def floats(value: Any, path: str = "$") -> list[str]:
|
||||
"""Every place a float leaked into the payload — money must travel as a string."""
|
||||
if isinstance(value, bool):
|
||||
return []
|
||||
if isinstance(value, float):
|
||||
return [path]
|
||||
if isinstance(value, dict):
|
||||
return [p for k, v in value.items() for p in floats(v, f"{path}.{k}")]
|
||||
if isinstance(value, list):
|
||||
return [p for i, v in enumerate(value) for p in floats(v, f"{path}[{i}]")]
|
||||
return []
|
||||
|
||||
|
||||
async def test_calendar_shows_the_future_with_money_as_strings_and_a_basis_on_every_row(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
r = await income_client.get(f"{PREFIX}/calendar")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert floats(body) == []
|
||||
|
||||
assert body["currency"] == "RUB"
|
||||
assert body["entries"], body
|
||||
for entry in body["entries"]:
|
||||
assert isinstance(entry["amount"], str)
|
||||
assert isinstance(entry["qty"], str)
|
||||
assert entry["basis"] in {"schedule", "announced", "history"}
|
||||
|
||||
by_key = {(e["kind"], e["basis"]): e for e in body["entries"]}
|
||||
# the declared autumn dividend, and next year's payment that only history knows about
|
||||
announced = by_key[("dividend", "announced")]
|
||||
assert announced["amount"] == "696.8000000000"
|
||||
assert announced["ticker"] == "SBER"
|
||||
assert announced["per_unit"] == "34.8400000000"
|
||||
assert ("dividend", "history") in by_key
|
||||
assert by_key[("coupon", "schedule")]["amount"] == "400.0000000000"
|
||||
|
||||
assert set(body["by_basis"]) == {"announced", "history", "schedule"}
|
||||
total = sum(D(v) for v in body["by_basis"].values())
|
||||
assert D(body["total_expected_rub"]) == total
|
||||
|
||||
|
||||
async def test_include_paid_adds_the_history_rows_and_nothing_else(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
today = today_local()
|
||||
window = {"date_from": str(add_months(today, -24)), "date_to": str(add_months(today, 12))}
|
||||
|
||||
without = (await income_client.get(f"{PREFIX}/calendar", params=window)).json()
|
||||
assert {e["basis"] for e in without["entries"]} == {"announced", "history", "schedule"}
|
||||
|
||||
with_paid = (
|
||||
await income_client.get(f"{PREFIX}/calendar", params={**window, "include_paid": "true"})
|
||||
).json()
|
||||
paid = [e for e in with_paid["entries"] if e["basis"] == "paid"]
|
||||
assert len(paid) == 2
|
||||
assert paid[0]["tax_withheld"] == "104.0000000000"
|
||||
# a payment already received is not an expectation: the totals must not move
|
||||
assert with_paid["total_expected_rub"] == without["total_expected_rub"]
|
||||
assert "paid" not in with_paid["by_basis"]
|
||||
|
||||
|
||||
async def test_history_groups_by_month_and_totals_the_tax(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
r = await income_client.get(f"{PREFIX}/history")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert floats(body) == []
|
||||
assert [row["kind"] for row in body["rows"]] == ["dividend", "dividend"]
|
||||
assert {row["payment_count"] for row in body["rows"]} == {1}
|
||||
assert D(body["totals"]["amount_rub"]) == D("1393.60")
|
||||
assert D(body["totals"]["tax_withheld_rub"]) == D("208")
|
||||
|
||||
filtered = (await income_client.get(f"{PREFIX}/history", params={"kind": "coupon"})).json()
|
||||
assert filtered["rows"] == []
|
||||
|
||||
|
||||
async def test_forecast_splits_every_month_by_basis(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
r = await income_client.get(f"{PREFIX}/forecast", params={"months": 12})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert floats(body) == []
|
||||
assert body["months"], body
|
||||
|
||||
bases = {basis for month in body["months"] for basis in month["by_basis"]}
|
||||
assert bases <= {"schedule", "announced", "history"}
|
||||
for month in body["months"]:
|
||||
assert D(month["amount_rub"]) == sum(D(v) for v in month["by_basis"].values())
|
||||
assert D(body["total_rub"]) == sum(D(m["amount_rub"]) for m in body["months"])
|
||||
assert isinstance(body["warnings"], list)
|
||||
|
||||
|
||||
async def test_forecast_rejects_a_horizon_the_table_was_not_built_for(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 0})).status_code == 422
|
||||
assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 37})).status_code == 422
|
||||
|
||||
|
||||
async def test_an_unknown_scope_is_a_404_not_an_empty_calendar(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
r = await income_client.get(f"{PREFIX}/calendar", params={"scope": "account:999"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_the_endpoints_require_a_token(income_client: AsyncClient, app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as anon:
|
||||
assert (await anon.get(f"{PREFIX}/calendar")).status_code == 401
|
||||
|
||||
|
||||
async def test_the_endpoints_are_pure_reads_and_repeat_themselves(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
first = (await income_client.get(f"{PREFIX}/forecast")).json()
|
||||
second = (await income_client.get(f"{PREFIX}/forecast")).json()
|
||||
assert first == second
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Target weights and rebalancing over HTTP (docs/ai/phase4-contract.md §2).
|
||||
|
||||
The router is mounted here rather than taken from `create_app`: wiring it into
|
||||
`api/app.py` belongs to the phase-4 integration, and these tests should not wait on it.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from factories import make_account, make_event, make_instrument, make_price, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
EventKind,
|
||||
Instrument,
|
||||
Portfolio,
|
||||
PortfolioAccount,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
PREFIX = "/api/v1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app) -> AsyncIterator[AsyncClient]:
|
||||
from fintracker.api.routers import rebalance
|
||||
|
||||
app.include_router(rebalance.router, prefix=PREFIX)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def portfolio(app) -> dict[str, int]:
|
||||
"""500 SBER (lot 10) at 100, 20 ОФЗ at 1000, 30 000 ₽ left in cash."""
|
||||
t = today_local()
|
||||
bought = t - timedelta(days=40)
|
||||
account = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
sber = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
|
||||
ofz = await make_instrument(ticker="OFZ", name="ОФЗ", asset_class=AssetClass.bond)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = await session.get(Instrument, sber)
|
||||
assert instrument is not None
|
||||
instrument.lot = 10
|
||||
portfolio = Portfolio(name="Основной")
|
||||
session.add(portfolio)
|
||||
await session.flush()
|
||||
session.add(PortfolioAccount(portfolio_id=portfolio.id, account_id=account))
|
||||
portfolio_id = portfolio.id
|
||||
await session.commit()
|
||||
|
||||
await make_event(bought, account_id=account, kind=EventKind.deposit, amount="100000")
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=sber,
|
||||
quantity="500",
|
||||
price="100",
|
||||
amount="-50000",
|
||||
)
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=ofz,
|
||||
quantity="20",
|
||||
price="1000",
|
||||
amount="-20000",
|
||||
)
|
||||
d = bought
|
||||
while d <= t:
|
||||
await make_price(d, instrument_id=sber, close="100")
|
||||
await make_price(d, instrument_id=ofz, close="1000")
|
||||
d += timedelta(days=1)
|
||||
await refresh()
|
||||
return {"portfolio": portfolio_id, "sber": sber, "ofz": ofz}
|
||||
|
||||
|
||||
def targets(*rows: tuple[str, str, str]) -> dict:
|
||||
return {
|
||||
"dimension": "asset_class",
|
||||
"targets": [{"bucket": b, "target_weight": w, "band": band} for b, w, band in rows],
|
||||
}
|
||||
|
||||
|
||||
async def put(client, auth_headers, portfolio_id: int, body: dict):
|
||||
return await client.put(
|
||||
f"{PREFIX}/portfolios/{portfolio_id}/targets", json=body, headers=auth_headers
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- targets
|
||||
|
||||
|
||||
async def test_targets_round_trip_and_report_their_sum(client, auth_headers, portfolio):
|
||||
r = await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert Decimal(body["weights_sum"]) == 1
|
||||
assert [t["bucket"] for t in body["targets"]] == ["bond", "cash", "share"]
|
||||
assert Decimal(body["targets"][0]["target_weight"]) == Decimal("0.2")
|
||||
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/targets", headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == body
|
||||
|
||||
|
||||
async def test_weights_that_do_not_add_up_are_refused_with_the_actual_sum(
|
||||
client, auth_headers, portfolio
|
||||
):
|
||||
r = await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.3", "0.01")),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
body = r.json()
|
||||
assert "0.9" in body["detail"]
|
||||
assert Decimal(body["weights_sum"]) == Decimal("0.9")
|
||||
|
||||
|
||||
async def test_a_set_is_replaced_whole_not_merged(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
r = await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.7", "0.01"), ("cash", "0.3", "0.01")),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert [t["bucket"] for t in r.json()["targets"]] == ["cash", "share"]
|
||||
|
||||
|
||||
async def test_a_duplicated_bucket_is_refused(client, auth_headers, portfolio):
|
||||
r = await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.5", "0.01"), ("share", "0.5", "0.01")),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert "share" in r.json()["detail"]
|
||||
|
||||
|
||||
async def test_an_unknown_dimension_is_refused(client, auth_headers, portfolio):
|
||||
body = targets(("share", "1", "0.01"))
|
||||
body["dimension"] = "mood"
|
||||
r = await put(client, auth_headers, portfolio["portfolio"], body)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_an_unknown_portfolio_is_a_404(client, auth_headers, portfolio):
|
||||
r = await client.get(f"{PREFIX}/portfolios/999/targets", headers=auth_headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_targets_need_a_token(client, portfolio):
|
||||
r = await client.get(f"{PREFIX}/portfolios/{portfolio['portfolio']}/targets")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- suggestions
|
||||
|
||||
|
||||
async def test_the_suggestion_respects_the_lot_and_the_cash(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert Decimal(body["total_value_rub"]) == 100000
|
||||
assert Decimal(body["cash_available_rub"]) == 30000
|
||||
|
||||
share = next(b for b in body["buckets"] if b["bucket"] == "share")
|
||||
assert Decimal(share["current_weight"]) == Decimal("0.5")
|
||||
assert Decimal(share["target_weight"]) == Decimal("0.6")
|
||||
assert Decimal(share["drift"]) == Decimal("-0.1")
|
||||
assert share["within_band"] is False
|
||||
trade = share["trades"][0]
|
||||
assert trade["action"] == "buy"
|
||||
assert trade["lot"] == 10
|
||||
assert Decimal(trade["suggested_qty"]) % 10 == 0
|
||||
assert Decimal(trade["suggested_qty"]) == 100
|
||||
assert trade["blocked_by_cash"] is False
|
||||
|
||||
bond = next(b for b in body["buckets"] if b["bucket"] == "bond")
|
||||
assert bond["within_band"] is True
|
||||
assert bond["trades"] == []
|
||||
|
||||
|
||||
async def test_the_what_if_cash_blocks_the_buy(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance",
|
||||
params={"cash_available": "2500"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
trade = next(t for b in r.json()["buckets"] for t in b["trades"] if b["bucket"] == "share")
|
||||
assert Decimal(trade["suggested_qty"]) == 20
|
||||
assert trade["blocked_by_cash"] is True
|
||||
|
||||
|
||||
async def test_a_wide_band_silences_every_suggestion(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.5"), ("bond", "0.2", "0.5"), ("cash", "0.2", "0.5")),
|
||||
)
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
|
||||
)
|
||||
body = r.json()
|
||||
assert all(b["within_band"] for b in body["buckets"] if b["target_weight"] is not None)
|
||||
assert all(t["suggested_qty"] is None for b in body["buckets"] for t in b["trades"])
|
||||
|
||||
|
||||
async def test_without_targets_there_is_nothing_to_rebalance(client, auth_headers, portfolio):
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert all(b["target_weight"] is None for b in r.json()["buckets"])
|
||||
|
||||
|
||||
async def test_every_money_field_is_a_string(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
|
||||
)
|
||||
body = r.json()
|
||||
for key in ("total_value_rub", "cash_available_rub"):
|
||||
assert isinstance(body[key], str)
|
||||
for b in body["buckets"]:
|
||||
for key in ("current_value_rub", "current_weight", "delta_value_rub"):
|
||||
assert isinstance(b[key], str)
|
||||
for t in b["trades"]:
|
||||
for key in ("suggested_qty", "price", "amount_rub"):
|
||||
assert t[key] is None or isinstance(t[key], str)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""`GET /tax` and `GET /tax/lots` — the screen that prices selling before the three-year mark.
|
||||
|
||||
The router is not wired into `api/app.py` by this module's author, so the tests mount it on a
|
||||
copy of the application. That keeps the check honest about the routes' own behaviour while
|
||||
leaving the inclusion order to whoever owns `app.py`.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from factories import make_account, make_event, make_instrument, make_price
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.analytics.tax import rebuild_tax_year
|
||||
from fintracker.api.routers import tax as tax_router
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.ledger.rebuild import rebuild_lots
|
||||
from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind
|
||||
from fintracker.pricing.fx import rebuild_fx_daily
|
||||
|
||||
D = Decimal
|
||||
LDV_DAYS = 3 * 365
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tax_client(app, auth_headers) -> AsyncIterator[AsyncClient]:
|
||||
"""The application plus the tax router, which `app.py` does not include yet."""
|
||||
app.include_router(tax_router.router, prefix="/api/v1")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
c.headers.update(auth_headers)
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def lots(app) -> dict[str, object]:
|
||||
"""Two open lots of the same paper: one past the ЛДВ mark, one still short of it."""
|
||||
t = today_local()
|
||||
old_date = t - timedelta(days=LDV_DAYS + 30)
|
||||
young_date = t - timedelta(days=400)
|
||||
|
||||
account = await make_account(
|
||||
name="ИИС",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
old_share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
|
||||
young_share = await make_instrument(ticker="GAZP", name="Газпром", asset_class=AssetClass.share)
|
||||
await make_event(
|
||||
old_date,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=old_share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
young_date,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=young_share,
|
||||
quantity="20",
|
||||
price="240",
|
||||
amount="-4800",
|
||||
)
|
||||
await make_price(t, instrument_id=old_share, close="150")
|
||||
await make_price(t, instrument_id=young_share, close="275.89")
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_fx_daily(session)
|
||||
await session.commit()
|
||||
await rebuild_lots(session)
|
||||
await session.commit()
|
||||
await rebuild_tax_year(session)
|
||||
await session.commit()
|
||||
return {
|
||||
"account": account,
|
||||
"old": old_share,
|
||||
"young": young_share,
|
||||
"old_date": old_date,
|
||||
"young_date": young_date,
|
||||
"today": t,
|
||||
}
|
||||
|
||||
|
||||
async def test_lots_show_the_days_left_to_the_exemption(tax_client, lots):
|
||||
r = await tax_client.get("/api/v1/tax/lots")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["estimated"] is True
|
||||
assert body["tax_rate"] == "0.13"
|
||||
assert body["disclaimer"]
|
||||
|
||||
by_ticker = {lot["ticker"]: lot for lot in body["lots"]}
|
||||
young = by_ticker["GAZP"]
|
||||
ldv_date = lots["young_date"] + timedelta(days=LDV_DAYS)
|
||||
assert young["ldv_date"] == ldv_date.isoformat()
|
||||
assert young["days_to_ldv"] == (ldv_date - lots["today"]).days
|
||||
assert young["ldv_eligible"] is False
|
||||
# 20 x 275.89 = 5517.80 against a cost of 4800 -> 717.80 unrealised, 13 % of it is 93.31
|
||||
assert young["market_value_rub"] == "5517.80"
|
||||
assert young["cost_rub"] == "4800.00"
|
||||
assert young["unrealized_gain_rub"] == "717.80"
|
||||
assert young["tax_if_sold_now_rub"] == "93.31"
|
||||
|
||||
|
||||
async def test_a_lot_past_three_years_costs_nothing_to_sell(tax_client, lots):
|
||||
body = (await tax_client.get("/api/v1/tax/lots")).json()
|
||||
old = {lot["ticker"]: lot for lot in body["lots"]}["SBER"]
|
||||
|
||||
assert old["ldv_eligible"] is True
|
||||
assert old["days_to_ldv"] == 0
|
||||
# 10 x 150 = 1500 against 1000 is a real gain, and art. 219.1 makes it untaxed
|
||||
assert old["unrealized_gain_rub"] == "500.00"
|
||||
assert old["tax_if_sold_now_rub"] == "0.00"
|
||||
|
||||
|
||||
async def test_the_year_summary_is_marked_an_estimate(tax_client, lots):
|
||||
r = await tax_client.get("/api/v1/tax", params={"year": lots["today"].year})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["estimated"] is True
|
||||
assert body["tax_rate"] == "0.13"
|
||||
assert "брокер" in body["disclaimer"]
|
||||
# nothing was sold, so the year is empty — but it is still a well-formed answer
|
||||
assert body["totals"]["estimated_tax_rub"] == "0.00"
|
||||
assert body["accounts"] == []
|
||||
|
||||
|
||||
async def test_no_float_reaches_the_wire(tax_client, lots):
|
||||
def reject_float(raw: str) -> None:
|
||||
raise AssertionError(f"float in the response: {raw}")
|
||||
|
||||
for path in ("/api/v1/tax", "/api/v1/tax/lots"):
|
||||
r = await tax_client.get(path)
|
||||
assert r.status_code == 200, r.text
|
||||
json.loads(r.text, parse_float=reject_float)
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Which feed wins when both describe the same payout, and what happens to the difference.
|
||||
|
||||
These run on plain `CorporateAction` objects with no session: the priority rule is a read
|
||||
rule, and `analytics/income.py` has to be able to apply it to whatever it already loaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from fintracker.analytics import FINDINGS
|
||||
from fintracker.models.pricing import CorporateAction, CorporateActionKind, CorporateActionStatus
|
||||
from fintracker.pricing.payouts import (
|
||||
nominal_outranks,
|
||||
resolve,
|
||||
resolve_payouts,
|
||||
weaker_or_equal_nominal_sources,
|
||||
)
|
||||
|
||||
DIV = CorporateActionKind.dividend
|
||||
CPN = CorporateActionKind.coupon
|
||||
|
||||
|
||||
def action(
|
||||
*,
|
||||
kind=DIV,
|
||||
source="tinvest",
|
||||
amount: str | None = "10",
|
||||
status=CorporateActionStatus.announced,
|
||||
instrument_id=1,
|
||||
record_date=None,
|
||||
ex_date=None,
|
||||
pay_date=None,
|
||||
currency="RUB",
|
||||
source_id=None,
|
||||
) -> CorporateAction:
|
||||
return CorporateAction(
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
record_date=record_date,
|
||||
ex_date=ex_date,
|
||||
pay_date=pay_date,
|
||||
amount_per_unit=None if amount is None else Decimal(amount),
|
||||
currency=currency,
|
||||
ratio=None,
|
||||
source=source,
|
||||
source_id=source_id or f"{source}-1",
|
||||
)
|
||||
|
||||
|
||||
def test_bond_coupon_is_read_from_moex():
|
||||
"""bondization is the issuer's registered schedule; T-Invest answers for a window."""
|
||||
moex = action(kind=CPN, source="moex", amount="34.90", pay_date=date(2026, 11, 5))
|
||||
tinvest = action(kind=CPN, source="tinvest", amount="34.90", pay_date=date(2026, 11, 5))
|
||||
|
||||
resolved = resolve_payouts([tinvest, moex], report=False)
|
||||
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0].source == "moex"
|
||||
|
||||
|
||||
def test_share_dividend_is_read_from_tinvest():
|
||||
"""T-Invest states what will settle on the account; MOEX states the register."""
|
||||
moex = action(source="moex", amount="52", record_date=date(2026, 7, 10))
|
||||
tinvest = action(
|
||||
source="tinvest", amount="52", record_date=date(2026, 7, 10), pay_date=date(2026, 7, 24)
|
||||
)
|
||||
|
||||
resolved = resolve_payouts([moex, tinvest], report=False)
|
||||
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0].source == "tinvest"
|
||||
|
||||
|
||||
def test_feeds_merge_on_the_record_date_they_share():
|
||||
"""MOEX states only the register date and T-Invest also states the payment date.
|
||||
|
||||
Keying the merge on the payment date alone would leave them in separate buckets and
|
||||
count the dividend twice.
|
||||
"""
|
||||
moex = action(source="moex", amount="52", record_date=date(2026, 7, 10))
|
||||
tinvest = action(
|
||||
source="tinvest", amount="52", record_date=date(2026, 7, 10), pay_date=date(2026, 7, 24)
|
||||
)
|
||||
|
||||
assert len(resolve_payouts([moex, tinvest], report=False)) == 1
|
||||
|
||||
|
||||
def test_two_different_payouts_of_one_paper_stay_two():
|
||||
interim = action(source="tinvest", amount="17", record_date=date(2026, 1, 12))
|
||||
final = action(source="tinvest", amount="52", record_date=date(2026, 7, 10), source_id="t-2")
|
||||
|
||||
assert len(resolve_payouts([interim, final], report=False)) == 2
|
||||
|
||||
|
||||
def test_a_mismatch_in_amount_produces_a_finding_and_keeps_the_winner():
|
||||
"""The winner is still the winner; the gap goes to the quality report, not to /dev/null."""
|
||||
FINDINGS.reset()
|
||||
tinvest = action(source="tinvest", amount="45.24", record_date=date(2026, 7, 10))
|
||||
moex = action(source="moex", amount="52.00", record_date=date(2026, 7, 10))
|
||||
|
||||
resolved = resolve_payouts([moex, tinvest])
|
||||
|
||||
assert [a.source for a in resolved] == ["tinvest"]
|
||||
assert resolved[0].amount_per_unit == Decimal("45.24")
|
||||
finding = next(f for f in FINDINGS.items if f.check_name == "payout_amount_mismatch")
|
||||
assert finding.severity == "warn"
|
||||
assert finding.ref == {"instruments": [1]}
|
||||
FINDINGS.reset()
|
||||
|
||||
|
||||
def test_amounts_within_rounding_are_not_a_mismatch():
|
||||
FINDINGS.reset()
|
||||
tinvest = action(source="tinvest", amount="34.90", pay_date=date(2026, 11, 5), kind=CPN)
|
||||
moex = action(source="moex", amount="34.9000000001", pay_date=date(2026, 11, 5), kind=CPN)
|
||||
|
||||
resolve_payouts([tinvest, moex])
|
||||
|
||||
assert FINDINGS.items == []
|
||||
|
||||
|
||||
def test_a_missing_amount_is_a_gap_not_a_disagreement():
|
||||
"""A floating coupon whose rate is unfixed arrives dated and priceless — every refresh."""
|
||||
FINDINGS.reset()
|
||||
moex = action(source="moex", amount=None, pay_date=date(2027, 2, 5), kind=CPN)
|
||||
tinvest = action(source="tinvest", amount="34.90", pay_date=date(2027, 2, 5), kind=CPN)
|
||||
|
||||
resolved = resolve_payouts([moex, tinvest])
|
||||
|
||||
assert resolved[0].source == "moex"
|
||||
assert FINDINGS.items == []
|
||||
|
||||
|
||||
def test_a_paid_row_outranks_an_announcement_from_the_stronger_feed():
|
||||
"""Money that has moved beats a feed's announcement of the same payout, either way round."""
|
||||
ledger = action(
|
||||
kind=CPN,
|
||||
source="tinvest",
|
||||
amount="34.90",
|
||||
status=CorporateActionStatus.paid,
|
||||
pay_date=date(2026, 5, 5),
|
||||
)
|
||||
announced = action(
|
||||
kind=CPN,
|
||||
source="moex",
|
||||
amount="34.90",
|
||||
status=CorporateActionStatus.announced,
|
||||
pay_date=date(2026, 5, 5),
|
||||
)
|
||||
|
||||
resolved = resolve_payouts([announced, ledger], report=False)
|
||||
|
||||
assert [(a.source, a.status) for a in resolved] == [("tinvest", CorporateActionStatus.paid)]
|
||||
|
||||
|
||||
def test_different_papers_never_merge():
|
||||
a = action(source="moex", record_date=date(2026, 7, 10), instrument_id=1)
|
||||
b = action(source="moex", record_date=date(2026, 7, 10), instrument_id=2)
|
||||
|
||||
assert len(resolve_payouts([a, b], report=False)) == 2
|
||||
|
||||
|
||||
def test_an_undated_row_survives_on_its_own():
|
||||
dated = action(source="tinvest", record_date=date(2026, 7, 10))
|
||||
undated = action(source="tinvest", amount="9", source_id="t-2")
|
||||
|
||||
assert len(resolve_payouts([dated, undated], report=False)) == 2
|
||||
|
||||
|
||||
def test_conflicts_are_reported_per_group_not_summed_into_one_line():
|
||||
result = resolve(
|
||||
[
|
||||
action(source="tinvest", amount="45", record_date=date(2026, 7, 10)),
|
||||
action(source="moex", amount="52", record_date=date(2026, 7, 10)),
|
||||
action(source="tinvest", amount="10", record_date=date(2026, 1, 9), source_id="t-2"),
|
||||
action(source="moex", amount="12", record_date=date(2026, 1, 9), source_id="m-2"),
|
||||
]
|
||||
)
|
||||
|
||||
assert len(result.payouts) == 2
|
||||
assert len(result.conflicts) == 2
|
||||
|
||||
|
||||
def test_nominal_schedule_precedence_is_a_write_rule():
|
||||
"""`bond_nominal_schedule` is keyed without `source`, so the two feeds share one row."""
|
||||
assert nominal_outranks("moex", "tinvest")
|
||||
assert not nominal_outranks("tinvest", "moex")
|
||||
assert nominal_outranks("moex", "moex") # a feed must be able to correct itself
|
||||
assert set(weaker_or_equal_nominal_sources("moex")) == {"moex", "tinvest", "ledger"}
|
||||
assert set(weaker_or_equal_nominal_sources("tinvest")) == {"tinvest", "ledger"}
|
||||
|
||||
|
||||
def test_no_float_anywhere_in_a_resolution():
|
||||
resolved = resolve_payouts(
|
||||
[
|
||||
action(source="tinvest", amount="45.24", record_date=date(2026, 7, 10)),
|
||||
action(source="moex", amount="52.00", record_date=date(2026, 7, 10)),
|
||||
],
|
||||
report=False,
|
||||
)
|
||||
assert all(not isinstance(a.amount_per_unit, float) for a in resolved)
|
||||
assert isinstance(resolved[0].amount_per_unit, Decimal)
|
||||
@@ -0,0 +1,365 @@
|
||||
"""MOEX ISS as the second payout feed: bondization and the dividend register.
|
||||
|
||||
Every request goes through respx — the ISS is never actually called.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.config import Settings
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
BondNominalSchedule,
|
||||
CorporateAction,
|
||||
CorporateActionKind,
|
||||
CorporateActionStatus,
|
||||
EventKind,
|
||||
Instrument,
|
||||
)
|
||||
from fintracker.sources.moex.client import AmortisationRow, CouponRow, MoexClient
|
||||
from fintracker.sources.moex.payouts import (
|
||||
MoexDividendRow,
|
||||
MoexPayoutsSource,
|
||||
coupon_payout,
|
||||
dividend_payout,
|
||||
fetch_dividends,
|
||||
nominal_schedule,
|
||||
)
|
||||
|
||||
ISS = "https://iss.moex.com/iss"
|
||||
D = Decimal
|
||||
TODAY = today_local()
|
||||
PAST = TODAY - timedelta(days=30)
|
||||
FUTURE = TODAY + timedelta(days=30)
|
||||
|
||||
|
||||
def block(name: str, columns: list[str], data: list[list]) -> dict:
|
||||
return {name: {"columns": columns, "data": data}}
|
||||
|
||||
|
||||
def bondization(coupons: list[list], amortisations: list[list] | None = None) -> dict:
|
||||
return {
|
||||
"coupons": {
|
||||
"columns": ["coupondate", "value", "valueprc", "faceunit"],
|
||||
"data": coupons,
|
||||
},
|
||||
"amortizations": {
|
||||
"columns": ["amortdate", "value", "facevalue", "faceunit"],
|
||||
"data": amortisations or [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- mapping -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_coupon_row_maps_to_a_moex_sourced_corporate_action():
|
||||
payout = coupon_payout(
|
||||
CouponRow(coupon_date=FUTURE, value=D("34.90"), value_pct=D("7.0"), currency="RUB"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
)
|
||||
|
||||
assert payout is not None
|
||||
assert payout.kind is CorporateActionKind.coupon
|
||||
assert payout.source == "moex"
|
||||
assert payout.source_id == f"cpn:{FUTURE.isoformat()}"
|
||||
assert payout.pay_date == FUTURE
|
||||
assert payout.amount_per_unit == D("34.90")
|
||||
assert payout.currency == "RUB"
|
||||
assert payout.status is CorporateActionStatus.announced
|
||||
|
||||
|
||||
def test_a_coupon_already_paid_is_stored_as_paid():
|
||||
payout = coupon_payout(
|
||||
CouponRow(coupon_date=PAST, value=D("34.90"), value_pct=None, currency="RUB"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
)
|
||||
assert payout is not None
|
||||
assert payout.status is CorporateActionStatus.paid
|
||||
|
||||
|
||||
def test_a_floating_coupon_without_a_rate_keeps_its_date_and_loses_its_amount():
|
||||
"""The date is published long before the rate is fixed, and the calendar needs it."""
|
||||
payout = coupon_payout(
|
||||
CouponRow(coupon_date=FUTURE, value=None, value_pct=None, currency="RUB"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
)
|
||||
assert payout is not None
|
||||
assert payout.amount_per_unit is None
|
||||
|
||||
|
||||
def test_a_dividend_register_row_states_the_record_date_and_nothing_else():
|
||||
payout = dividend_payout(
|
||||
MoexDividendRow(
|
||||
secid="SBER", registry_close_date=date(2026, 7, 10), value=D("52"), currency="RUB"
|
||||
),
|
||||
instrument_id=7,
|
||||
today=TODAY,
|
||||
)
|
||||
|
||||
assert payout is not None
|
||||
assert payout.record_date == date(2026, 7, 10)
|
||||
assert payout.pay_date is None
|
||||
assert payout.source_id == "div:2026-07-10"
|
||||
assert payout.amount_per_unit == D("52")
|
||||
|
||||
|
||||
def test_amortisations_run_the_nominal_down_to_zero():
|
||||
warnings: list[str] = []
|
||||
points = nominal_schedule(
|
||||
[
|
||||
AmortisationRow(
|
||||
amort_date=date(2028, 5, 5), value=D(500), face_value=D(500), currency="SUR"
|
||||
),
|
||||
AmortisationRow(
|
||||
amort_date=date(2026, 5, 5), value=D(250), face_value=D(1000), currency="SUR"
|
||||
),
|
||||
AmortisationRow(
|
||||
amort_date=date(2027, 5, 5), value=D(250), face_value=D(750), currency="SUR"
|
||||
),
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert [(p.effective_date, p.nominal) for p in points] == [
|
||||
(date(2026, 5, 5), D(750)),
|
||||
(date(2027, 5, 5), D(500)),
|
||||
(date(2028, 5, 5), D(0)),
|
||||
]
|
||||
assert all(p.source == "moex" for p in points)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_a_plan_that_does_not_add_up_to_the_stated_nominal_is_reported():
|
||||
warnings: list[str] = []
|
||||
nominal_schedule(
|
||||
[
|
||||
AmortisationRow(
|
||||
amort_date=date(2026, 5, 5), value=D(250), face_value=D(1000), currency="SUR"
|
||||
),
|
||||
AmortisationRow(
|
||||
amort_date=date(2027, 5, 5), value=D(250), face_value=D(750), currency="SUR"
|
||||
),
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
secid="RU000A",
|
||||
)
|
||||
|
||||
assert len(warnings) == 1
|
||||
assert "1000" in warnings[0]
|
||||
|
||||
|
||||
def test_nominal_points_are_decimal_never_float():
|
||||
points = nominal_schedule(
|
||||
[
|
||||
AmortisationRow(
|
||||
amort_date=date(2026, 5, 5), value=D("1000"), face_value=None, currency=None
|
||||
)
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=[],
|
||||
)
|
||||
assert all(isinstance(p.nominal, Decimal) and not isinstance(p.nominal, float) for p in points)
|
||||
|
||||
|
||||
# --- client --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_bondization_gives_the_whole_coupon_schedule_not_just_the_near_ones():
|
||||
"""This is why bonds are read from MOEX: the plan runs to maturity, in one request."""
|
||||
schedule = [
|
||||
[(TODAY + timedelta(days=30 * n)).isoformat(), 34.9, 7.0, "SUR"] for n in range(1, 25)
|
||||
]
|
||||
respx.get(url__startswith=f"{ISS}/securities/RU000A/bondization").mock(
|
||||
return_value=httpx.Response(200, json=bondization(schedule))
|
||||
)
|
||||
|
||||
async with MoexClient() as moex:
|
||||
coupons, amortisations = await moex.bondization("RU000A")
|
||||
|
||||
within_a_year = [
|
||||
c for c in coupons if c.coupon_date and c.coupon_date <= TODAY + timedelta(days=365)
|
||||
]
|
||||
assert len(within_a_year) >= 12
|
||||
assert all(c.value == D("34.9") for c in within_a_year)
|
||||
assert amortisations == []
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_the_dividend_extract_is_read_by_column_name():
|
||||
respx.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=block(
|
||||
"dividends",
|
||||
["secid", "isin", "registryclosedate", "value", "currencyid"],
|
||||
[["SBER", "RU0009029540", "2026-07-10", 34.84, "SUR"]],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(trust_env=False) as http:
|
||||
rows = await fetch_dividends(http, "SBER")
|
||||
|
||||
assert rows == [
|
||||
MoexDividendRow(
|
||||
secid="SBER",
|
||||
registry_close_date=date(2026, 7, 10),
|
||||
value=D("34.84"),
|
||||
currency="RUB", # ISS says SUR
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# --- sync ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def seed(asset_class: AssetClass, ticker: str) -> int:
|
||||
account_id = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
source_id=f"tinv-{ticker}",
|
||||
)
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = Instrument(
|
||||
asset_class=asset_class,
|
||||
ticker=ticker,
|
||||
board="TQBR",
|
||||
name=ticker,
|
||||
currency="RUB",
|
||||
)
|
||||
session.add(instrument)
|
||||
await session.commit()
|
||||
await session.refresh(instrument)
|
||||
instrument_id = instrument.id
|
||||
await make_event(
|
||||
PAST, account_id=account_id, kind=EventKind.buy, instrument_id=instrument_id, quantity=10
|
||||
)
|
||||
return instrument_id
|
||||
|
||||
|
||||
async def rows_of(model, **where):
|
||||
async with get_sessionmaker()() as session:
|
||||
stmt = select(model)
|
||||
for column, value in where.items():
|
||||
stmt = stmt.where(getattr(model, column) == value)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
def mock_iss(mock_http) -> None:
|
||||
mock_http.get(url__startswith=f"{ISS}/securities/RU000A/bondization").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=bondization(
|
||||
[
|
||||
["2027-05-05", 34.9, 7.0, "SUR"],
|
||||
["2028-05-05", 34.9, 7.0, "SUR"],
|
||||
],
|
||||
[
|
||||
["2027-05-05", 250, 1000, "SUR"],
|
||||
["2028-05-05", 750, 750, "SUR"],
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
mock_http.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=block(
|
||||
"dividends",
|
||||
["secid", "registryclosedate", "value", "currencyid"],
|
||||
[["SBER", "2026-07-10", 34.84, "SUR"]],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_a_bond_run_writes_coupons_and_the_nominal_schedule(app, mock_http, run_sync):
|
||||
instrument_id = await seed(AssetClass.bond, "RU000A")
|
||||
mock_iss(mock_http)
|
||||
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
|
||||
actions = await rows_of(CorporateAction, instrument_id=instrument_id)
|
||||
assert {(a.kind, a.source, a.source_id) for a in actions} == {
|
||||
(CorporateActionKind.coupon, "moex", "cpn:2027-05-05"),
|
||||
(CorporateActionKind.coupon, "moex", "cpn:2028-05-05"),
|
||||
}
|
||||
schedule = sorted(
|
||||
await rows_of(BondNominalSchedule, instrument_id=instrument_id),
|
||||
key=lambda p: p.effective_date,
|
||||
)
|
||||
assert [(p.effective_date, p.nominal) for p in schedule] == [
|
||||
(date(2027, 5, 5), D(750)),
|
||||
(date(2028, 5, 5), D(0)),
|
||||
]
|
||||
|
||||
|
||||
async def test_a_share_run_writes_the_register_dividend(app, mock_http, run_sync):
|
||||
instrument_id = await seed(AssetClass.share, "SBER")
|
||||
mock_iss(mock_http)
|
||||
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
|
||||
actions = await rows_of(CorporateAction, instrument_id=instrument_id)
|
||||
assert [(a.kind, a.source, a.amount_per_unit) for a in actions] == [
|
||||
(CorporateActionKind.dividend, "moex", D("34.84"))
|
||||
]
|
||||
|
||||
|
||||
async def test_a_second_run_stores_no_duplicates(app, mock_http, run_sync):
|
||||
instrument_id = await seed(AssetClass.bond, "RU000A")
|
||||
mock_iss(mock_http)
|
||||
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
|
||||
assert len(await rows_of(CorporateAction, instrument_id=instrument_id)) == 2
|
||||
assert len(await rows_of(BondNominalSchedule, instrument_id=instrument_id)) == 2
|
||||
|
||||
|
||||
async def test_moex_wins_the_nominal_row_a_weaker_source_already_wrote(app, mock_http, run_sync):
|
||||
"""`bond_nominal_schedule` is keyed without `source`, so precedence is decided on write."""
|
||||
instrument_id = await seed(AssetClass.bond, "RU000A")
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
BondNominalSchedule(
|
||||
instrument_id=instrument_id,
|
||||
effective_date=date(2027, 5, 5),
|
||||
nominal=D(800),
|
||||
currency="RUB",
|
||||
source="tinvest",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
mock_iss(mock_http)
|
||||
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
|
||||
schedule = await rows_of(BondNominalSchedule, instrument_id=instrument_id)
|
||||
row = next(p for p in schedule if p.effective_date == date(2027, 5, 5))
|
||||
assert (row.nominal, row.source) == (D(750), "moex")
|
||||
@@ -0,0 +1,454 @@
|
||||
"""T-Invest payout feeds: what the three RPCs mean once they are flattened.
|
||||
|
||||
The mapping tests are pure — the sync ones run against the real database with a stand-in
|
||||
client, so no test here touches the network or needs a token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.config import Settings
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
BondNominalSchedule,
|
||||
CorporateAction,
|
||||
CorporateActionKind,
|
||||
CorporateActionStatus,
|
||||
EventKind,
|
||||
Instrument,
|
||||
RawTinvestEvent,
|
||||
)
|
||||
from fintracker.sources.tinvest import sync_events
|
||||
from fintracker.sources.tinvest.client import BondCouponRow, BondEventRow, DividendRow
|
||||
from fintracker.sources.tinvest.sync_events import (
|
||||
TinvestEventsSource,
|
||||
coupon_payout,
|
||||
dividend_payout,
|
||||
msk_date,
|
||||
nominal_schedule,
|
||||
status_for,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
TODAY = today_local()
|
||||
PAST = TODAY - timedelta(days=30)
|
||||
FUTURE = TODAY + timedelta(days=30)
|
||||
|
||||
|
||||
def utc(day: date) -> datetime:
|
||||
"""How the API stamps a trading day: midnight UTC, i.e. 3 a.m. in Moscow."""
|
||||
return datetime(day.year, day.month, day.day, tzinfo=UTC)
|
||||
|
||||
|
||||
def dividend(**over) -> DividendRow:
|
||||
base = {
|
||||
"instrument_uid": "uid-share",
|
||||
"amount": D("45.24"),
|
||||
"currency": "RUB",
|
||||
"payment_date": utc(date(2026, 7, 24)),
|
||||
"declared_date": utc(date(2026, 5, 30)),
|
||||
"record_date": utc(date(2026, 7, 10)),
|
||||
"last_buy_date": utc(date(2026, 7, 9)),
|
||||
"dividend_type": "Regular Cash",
|
||||
"regularity": "Annual",
|
||||
"payload": {"dividend_net": {"value": "45.24", "currency": "rub"}},
|
||||
}
|
||||
return DividendRow(**{**base, **over})
|
||||
|
||||
|
||||
def coupon(**over) -> BondCouponRow:
|
||||
base = {
|
||||
"instrument_uid": "uid-bond",
|
||||
"coupon_number": 7,
|
||||
"coupon_date": utc(date(2026, 11, 5)),
|
||||
"fix_date": utc(date(2026, 11, 4)),
|
||||
"pay_one_bond": D("34.90"),
|
||||
"currency": "RUB",
|
||||
"coupon_type": "COUPON_TYPE_CONSTANT",
|
||||
"coupon_period": 182,
|
||||
"payload": {"coupon_number": 7},
|
||||
}
|
||||
return BondCouponRow(**{**base, **over})
|
||||
|
||||
|
||||
def redemption(day: date, amount: str, **over) -> BondEventRow:
|
||||
base = {
|
||||
"instrument_uid": "uid-bond",
|
||||
"event_type": "EVENT_TYPE_MTY",
|
||||
"event_number": 1,
|
||||
"event_date": utc(day),
|
||||
"fix_date": utc(day),
|
||||
"pay_date": utc(day),
|
||||
"pay_one_bond": D(amount),
|
||||
"currency": "RUB",
|
||||
"payload": {},
|
||||
}
|
||||
return BondEventRow(**{**base, **over})
|
||||
|
||||
|
||||
# --- mapping -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_midnight_utc_stamp_is_the_moscow_trading_day():
|
||||
"""Read as UTC, every payout would move one day earlier than the exchange printed it."""
|
||||
assert msk_date(datetime(2026, 7, 10, 0, 0, tzinfo=UTC)) == date(2026, 7, 10)
|
||||
assert msk_date(datetime(2026, 7, 9, 21, 30, tzinfo=UTC)) == date(2026, 7, 10)
|
||||
assert msk_date(None) is None
|
||||
|
||||
|
||||
def test_dividend_carries_every_date_the_feed_states():
|
||||
payout = dividend_payout(dividend(), instrument_id=7, today=TODAY)
|
||||
|
||||
assert payout is not None
|
||||
assert payout.kind is CorporateActionKind.dividend
|
||||
assert payout.record_date == date(2026, 7, 10)
|
||||
assert payout.pay_date == date(2026, 7, 24)
|
||||
# last_buy_date is the last day a purchase still earns it — the ex-side date on offer
|
||||
assert payout.ex_date == date(2026, 7, 9)
|
||||
assert payout.amount_per_unit == D("45.24")
|
||||
assert payout.currency == "RUB"
|
||||
assert payout.source == "tinvest"
|
||||
assert payout.source_id == "div:2026-07-10"
|
||||
|
||||
|
||||
def test_dividend_without_any_date_is_dropped():
|
||||
assert (
|
||||
dividend_payout(
|
||||
dividend(record_date=None, payment_date=None, declared_date=None),
|
||||
instrument_id=7,
|
||||
today=TODAY,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_a_past_payout_is_paid_and_a_future_one_announced():
|
||||
assert status_for(PAST, TODAY) is CorporateActionStatus.paid
|
||||
assert status_for(FUTURE, TODAY) is CorporateActionStatus.announced
|
||||
assert status_for(None, TODAY) is CorporateActionStatus.announced
|
||||
|
||||
|
||||
def test_coupon_maps_to_the_coupon_kind_keyed_on_its_number():
|
||||
warnings: list[str] = []
|
||||
payout = coupon_payout(coupon(), instrument_id=9, today=TODAY, warnings=warnings)
|
||||
|
||||
assert payout is not None
|
||||
assert payout.kind is CorporateActionKind.coupon
|
||||
assert payout.pay_date == date(2026, 11, 5)
|
||||
assert payout.record_date == date(2026, 11, 4)
|
||||
assert payout.amount_per_unit == D("34.90")
|
||||
assert payout.source_id == "cpn:7"
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_an_unknown_coupon_type_warns_instead_of_becoming_a_plain_coupon():
|
||||
warnings: list[str] = []
|
||||
payout = coupon_payout(
|
||||
coupon(coupon_type="COUPON_TYPE_UNSPECIFIED"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert payout is None
|
||||
assert len(warnings) == 1
|
||||
assert "COUPON_TYPE_UNSPECIFIED" in warnings[0]
|
||||
|
||||
|
||||
def test_a_zero_coupon_is_not_a_payout():
|
||||
warnings: list[str] = []
|
||||
assert (
|
||||
coupon_payout(
|
||||
coupon(pay_one_bond=D(0), coupon_type="COUPON_TYPE_DISCOUNT"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
warnings=warnings,
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_redemptions_run_the_nominal_down_to_zero():
|
||||
"""An amortised bond repays the principal in slices; the nominal after each is what is left."""
|
||||
warnings: list[str] = []
|
||||
points = nominal_schedule(
|
||||
[
|
||||
redemption(date(2027, 5, 5), "250"),
|
||||
redemption(date(2026, 5, 5), "250"), # out of order on purpose
|
||||
redemption(date(2028, 5, 5), "500"),
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert [(p.effective_date, p.nominal) for p in points] == [
|
||||
(date(2026, 5, 5), D(750)),
|
||||
(date(2027, 5, 5), D(500)),
|
||||
(date(2028, 5, 5), D(0)),
|
||||
]
|
||||
assert all(p.source == "tinvest" and p.currency == "RUB" for p in points)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_a_bullet_bond_gets_a_single_point_at_maturity():
|
||||
points = nominal_schedule(
|
||||
[redemption(date(2029, 3, 1), "1000")], instrument_id=9, currency="RUB", warnings=[]
|
||||
)
|
||||
assert [(p.effective_date, p.nominal) for p in points] == [(date(2029, 3, 1), D(0))]
|
||||
|
||||
|
||||
def test_an_unknown_bond_event_type_warns_and_is_skipped():
|
||||
warnings: list[str] = []
|
||||
points = nominal_schedule(
|
||||
[redemption(date(2026, 5, 5), "250", event_type="EVENT_TYPE_CONV")],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert points == []
|
||||
assert "EVENT_TYPE_CONV" in warnings[0]
|
||||
|
||||
|
||||
def test_a_redemption_with_no_money_is_warned_about_not_read_as_zero():
|
||||
"""A silent zero would shift every later nominal upward by the missing slice."""
|
||||
warnings: list[str] = []
|
||||
points = nominal_schedule(
|
||||
[
|
||||
redemption(date(2026, 5, 5), "250", pay_one_bond=None),
|
||||
redemption(date(2027, 5, 5), "750"),
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert [p.nominal for p in points] == [D(0)]
|
||||
assert "без суммы" in warnings[0]
|
||||
|
||||
|
||||
def test_every_mapped_amount_is_a_decimal_never_a_float():
|
||||
payout = dividend_payout(dividend(), instrument_id=7, today=TODAY)
|
||||
points = nominal_schedule(
|
||||
[redemption(date(2026, 5, 5), "250"), redemption(date(2027, 5, 5), "750")],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=[],
|
||||
)
|
||||
assert payout is not None
|
||||
assert isinstance(payout.amount_per_unit, Decimal)
|
||||
assert all(isinstance(p.nominal, Decimal) and not isinstance(p.nominal, float) for p in points)
|
||||
|
||||
|
||||
# --- sync ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Stands in for `TinvestClient`: same three coroutines, no gRPC and no token."""
|
||||
|
||||
calls: ClassVar[list[str]] = []
|
||||
|
||||
def __init__(self, token: str, **_: object) -> None:
|
||||
self.token = token
|
||||
|
||||
async def __aenter__(self) -> FakeClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
async def dividends(self, uid: str, **_: object) -> list[DividendRow]:
|
||||
FakeClient.calls.append(f"dividends:{uid}")
|
||||
return [dividend(instrument_uid=uid)] if uid == "uid-share" else []
|
||||
|
||||
async def bond_coupons(self, uid: str, **_: object) -> list[BondCouponRow]:
|
||||
FakeClient.calls.append(f"coupons:{uid}")
|
||||
return [
|
||||
coupon(instrument_uid=uid),
|
||||
coupon(instrument_uid=uid, coupon_number=8, coupon_date=utc(date(2027, 5, 5))),
|
||||
]
|
||||
|
||||
async def bond_events(self, uid: str, **_: object) -> list[BondEventRow]:
|
||||
FakeClient.calls.append(f"events:{uid}")
|
||||
return [redemption(date(2027, 5, 5), "250"), redemption(date(2028, 5, 5), "750")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_client(monkeypatch):
|
||||
FakeClient.calls = []
|
||||
monkeypatch.setattr(sync_events, "TinvestClient", FakeClient)
|
||||
return FakeClient
|
||||
|
||||
|
||||
async def seed(asset_class: AssetClass, uid: str, ticker: str) -> int:
|
||||
"""One instrument with a confirmed ledger event — which is what puts it in scope."""
|
||||
account_id = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
source_id=f"tinv-{ticker}",
|
||||
)
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = Instrument(
|
||||
asset_class=asset_class,
|
||||
tinvest_uid=uid,
|
||||
ticker=ticker,
|
||||
board="TQBR",
|
||||
name=ticker,
|
||||
currency="RUB",
|
||||
)
|
||||
session.add(instrument)
|
||||
await session.commit()
|
||||
await session.refresh(instrument)
|
||||
instrument_id = instrument.id
|
||||
await make_event(
|
||||
PAST, account_id=account_id, kind=EventKind.buy, instrument_id=instrument_id, quantity=10
|
||||
)
|
||||
return instrument_id
|
||||
|
||||
|
||||
async def rows(model, **where):
|
||||
async with get_sessionmaker()() as session:
|
||||
stmt = select(model)
|
||||
for column, value in where.items():
|
||||
stmt = stmt.where(getattr(model, column) == value)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
def settings() -> Settings:
|
||||
return Settings(tinvest_token="test-token")
|
||||
|
||||
|
||||
async def test_a_share_run_writes_the_raw_row_and_the_corporate_action(app, fake_client, run_sync):
|
||||
instrument_id = await seed(AssetClass.share, "uid-share", "SBER")
|
||||
|
||||
result = await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
assert result.counts["dividends"] == 1
|
||||
action = (await rows(CorporateAction, instrument_id=instrument_id))[0]
|
||||
assert action.kind is CorporateActionKind.dividend
|
||||
assert action.source == "tinvest"
|
||||
assert action.source_id == "div:2026-07-10"
|
||||
assert action.amount_per_unit == D("45.24")
|
||||
assert action.record_date == date(2026, 7, 10)
|
||||
raw = (await rows(RawTinvestEvent, instrument_uid="uid-share"))[0]
|
||||
assert raw.kind == "dividend"
|
||||
assert raw.payload["dividend_net"]["value"] == "45.24"
|
||||
|
||||
|
||||
async def test_a_bond_run_writes_coupons_and_a_descending_nominal_schedule(
|
||||
app, fake_client, run_sync
|
||||
):
|
||||
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
actions = await rows(CorporateAction, instrument_id=instrument_id)
|
||||
assert {a.kind for a in actions} == {CorporateActionKind.coupon}
|
||||
assert {a.source_id for a in actions} == {"cpn:7", "cpn:8"}
|
||||
schedule = sorted(
|
||||
await rows(BondNominalSchedule, instrument_id=instrument_id),
|
||||
key=lambda p: p.effective_date,
|
||||
)
|
||||
assert [(p.effective_date, p.nominal, p.source) for p in schedule] == [
|
||||
(date(2027, 5, 5), D(750), "tinvest"),
|
||||
(date(2028, 5, 5), D(0), "tinvest"),
|
||||
]
|
||||
|
||||
|
||||
async def test_a_bond_never_writes_an_amortization_row(app, fake_client, run_sync):
|
||||
"""`ledger/corporate_actions.py` owns that kind and prunes anything it did not derive."""
|
||||
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
actions = await rows(CorporateAction, instrument_id=instrument_id)
|
||||
assert not any(
|
||||
a.kind in (CorporateActionKind.amortization, CorporateActionKind.repayment) for a in actions
|
||||
)
|
||||
|
||||
|
||||
async def test_a_share_is_never_asked_for_coupons(app, fake_client, run_sync):
|
||||
await seed(AssetClass.share, "uid-share", "SBER")
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
assert FakeClient.calls == ["dividends:uid-share"]
|
||||
|
||||
|
||||
async def test_a_second_run_stores_no_duplicates(app, fake_client, run_sync):
|
||||
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
assert len(await rows(CorporateAction, instrument_id=instrument_id)) == 2
|
||||
assert len(await rows(BondNominalSchedule, instrument_id=instrument_id)) == 2
|
||||
assert len(await rows(RawTinvestEvent, instrument_uid="uid-bond")) == 4
|
||||
|
||||
|
||||
async def test_a_ledger_derived_paid_row_is_not_overwritten_by_the_feed(app, fake_client, run_sync):
|
||||
"""Money that arrived outranks an announcement — and the feed cannot even reach that row."""
|
||||
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
CorporateAction(
|
||||
instrument_id=instrument_id,
|
||||
kind=CorporateActionKind.amortization,
|
||||
status=CorporateActionStatus.paid,
|
||||
ex_date=date(2027, 5, 5),
|
||||
pay_date=date(2027, 5, 5),
|
||||
amount_per_unit=D("250"),
|
||||
currency="RUB",
|
||||
source="tinvest",
|
||||
source_id="2027-05-05",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
derived = [
|
||||
a
|
||||
for a in await rows(CorporateAction, instrument_id=instrument_id)
|
||||
if a.kind is CorporateActionKind.amortization
|
||||
]
|
||||
assert len(derived) == 1
|
||||
assert derived[0].status is CorporateActionStatus.paid
|
||||
assert derived[0].amount_per_unit == D("250")
|
||||
|
||||
|
||||
async def test_an_instrument_outside_the_ledger_is_never_asked_about(app, fake_client, run_sync):
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
Instrument(
|
||||
asset_class=AssetClass.share,
|
||||
tinvest_uid="uid-never-held",
|
||||
ticker="GAZP",
|
||||
board="TQBR",
|
||||
name="GAZP",
|
||||
currency="RUB",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
result = await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
assert FakeClient.calls == []
|
||||
assert result.changed is False
|
||||
Reference in New Issue
Block a user