Второй источник выплат: 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).
228 lines
8.4 KiB
Python
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)
|