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