Files
fin-tracker/backend/tests/analytics/test_benchmarks.py
T
Dmitry 15f5812ea4 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).
2026-09-19 10:42:50 +03:00

228 lines
8.4 KiB
Python

"""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)