feat(analytics): оценка позиций, XIRR и TWR

pricing/prices.py — цена на дату: последний close тянется вперёд, после 10 дней
считается протухшей, но всё ещё используется. Назад не тянется никогда — цена из
будущего это выдумка, а не оценка.

valuation берёт два разных источника намеренно. Дневная серия — реплей event
(только он отвечает, сколько стоило в марте), текущие холдинги — из lot, где
есть себестоимость и учтены сплиты. Расхождение между ними на последний день
становится находкой, а не поводом выбрать одно из двух. Отчётная единица —
scope: all, account:<id>, portfolio:<id>.

returns читает только metric_portfolio_value_daily. XIRR — по внешним потокам и
терминальной стоимости; TWR — цепочкой V_t / (V_{t-1} + F_t). План пишет формулу
как (V_t - F_t) / V_{t-1}, то есть с потоком в конце дня; поток в начале даёт то
же число при нулевом потоке, не требует особого случая на первый день и относит
движение рынка к деньгам, которые в этот день уже работали.

Покупка бумаги без цены трактуется как вывод из оцениваемого портфеля
(unvalued_flow_rub): иначе деньги уходят из оценки, а бумага в неё не попадает,
и день читается как обвал — именно это фонд денежного рынка без фида MOEX
устроил серии 2024 года. Пропускается только день, в который меняется ЧИСЛО
неоценённых позиций, и их счётчик уходит в metric_data_quality.

Нет цены или курса — NULL и замечание, не ноль: SIBN6P4 в холдингах именно так и
выглядит. Валютные «позиции» из сверки исключены, их двойник — cash_snapshot, а
не лот; после этого расхождений с брокером ровно пять известных.

Проверка из плана закрыта тестами: взнос 100 и 110 через год дают XIRR 10,0 % и
TWR 10,0 %, второй взнос двигает XIRR и не трогает TWR подпериодов.
This commit is contained in:
Dmitry
2026-09-18 13:44:50 +03:00
parent 53096c207e
commit c203ae65fc
8 changed files with 2244 additions and 0 deletions
@@ -0,0 +1,270 @@
"""Valuation and returns end to end: ledger + prices + rates -> metric tables."""
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import select
from factories import (
make_account,
make_cbr_rate,
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,
MetricDataQuality,
MetricHolding,
MetricPortfolioValueDaily,
MetricReturns,
)
D = Decimal
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 rates(days: list[date], ccy: str, value: str) -> None:
for d in days:
await make_cbr_rate(d, ccy, value)
async def holdings(scope: str = "all") -> dict[int, MetricHolding]:
async with get_sessionmaker()() as session:
rows = (
(await session.execute(select(MetricHolding).where(MetricHolding.scope == scope)))
.scalars()
.all()
)
return {r.instrument_id: r for r in rows}
async def value_days(scope: str = "all") -> dict[date, MetricPortfolioValueDaily]:
async with get_sessionmaker()() as session:
rows = (
(
await session.execute(
select(MetricPortfolioValueDaily).where(
MetricPortfolioValueDaily.scope == scope
)
)
)
.scalars()
.all()
)
return {r.d: r for r in rows}
async def findings() -> dict[str, MetricDataQuality]:
async with get_sessionmaker()() as session:
rows = (await session.execute(select(MetricDataQuality))).scalars().all()
return {r.check_name: r for r in rows}
async def test_a_bought_position_is_valued_and_the_cash_it_cost_is_gone(app):
t = today_local()
bought = t - timedelta(days=3)
account = await broker_account()
gazp = await make_instrument(ticker="GAZP")
await make_event(bought, account_id=account, kind=EventKind.deposit, amount="10000")
await make_event(
bought,
account_id=account,
kind=EventKind.buy,
instrument_id=gazp,
quantity="10",
price="900",
amount="-9000",
)
for offset in range(4):
await make_price(t - timedelta(days=offset), instrument_id=gazp, close="950")
await refresh()
holding = (await holdings())[gazp]
assert holding.qty == D(10)
assert holding.value_rub == D(9500)
assert holding.cost_total_rub == D(9000)
assert holding.unrealized_pnl_rub == D(500)
assert holding.price_status == "ok"
assert holding.weight == D(1)
today = (await value_days())[t]
assert today.market_value_rub == D(9500)
assert today.cash_rub == D(1000)
assert today.total_rub == D(10500)
assert today.invested_net_rub == D(10000)
assert today.pnl_total_rub == D(500)
async def test_a_foreign_position_is_converted_at_the_rate_of_each_day(app):
t = today_local()
bought = t - timedelta(days=2)
account = await broker_account()
etf = await make_instrument(ticker="SPY", currency="USD", board="SPBXM")
await make_event(
bought, account_id=account, kind=EventKind.deposit, amount="800", currency="USD"
)
await make_event(
bought,
account_id=account,
kind=EventKind.buy,
instrument_id=etf,
quantity="8",
price="100",
amount="-800",
currency="USD",
)
for offset in range(3):
d = t - timedelta(days=offset)
await make_price(d, instrument_id=etf, close="110", currency="USD")
await rates([t - timedelta(days=n) for n in range(5)], "USD", "80")
await refresh()
holding = (await holdings())[etf]
assert holding.value_native == D(880)
assert holding.value_rub == D(70400) # 880 USD * 80
assert holding.unrealized_pnl_native == D(80)
async def test_a_paper_nobody_quotes_is_null_not_zero(app):
t = today_local()
account = await broker_account()
silent = await make_instrument(ticker="SIBN6P4", asset_class=AssetClass.bond, board="SPBRUBND")
await make_event(
t - timedelta(days=5), account_id=account, kind=EventKind.deposit, amount="7000"
)
await make_event(
t - timedelta(days=5),
account_id=account,
kind=EventKind.buy,
instrument_id=silent,
quantity="7",
price="1000",
amount="-7000",
)
await refresh()
holding = (await holdings())[silent]
assert holding.qty == D(7)
assert holding.price_status == "missing"
assert holding.value_rub is None
assert holding.unrealized_pnl_rub is None
assert holding.weight is None
today = (await value_days())[t]
assert today.market_value_rub == D(0)
assert today.missing_price_count == 1
assert today.pnl_total_rub is None # the total is incomplete, so it is not reported
assert "holding_without_price" in await findings()
async def test_a_card_funded_purchase_is_an_external_flow_and_leaves_the_cash_alone(app):
t = today_local()
bought = t - timedelta(days=1)
account = await broker_account()
fund = await make_instrument(ticker="TMOS", asset_class=AssetClass.etf, board="TQTF")
await make_event(
bought,
account_id=account,
kind=EventKind.buy,
instrument_id=fund,
quantity="100",
price="50",
amount="-5000",
meta={"operation_type": "OPERATION_TYPE_BUY_CARD", "card_funded": True},
)
for offset in range(2):
await make_price(t - timedelta(days=offset), instrument_id=fund, close="50")
await refresh()
today = (await value_days())[t]
assert today.cash_rub == D(0) # the card paid, the account balance never moved
assert today.market_value_rub == D(5000)
assert today.invested_net_rub == D(5000)
assert today.pnl_total_rub == D(0)
async def test_returns_are_reported_per_period_and_agree_with_the_flows(app):
t = today_local()
start = t - timedelta(days=370)
account = await broker_account()
gazp = await make_instrument(ticker="GAZP")
await make_event(start, account_id=account, kind=EventKind.deposit, amount="1000")
await make_event(
start,
account_id=account,
kind=EventKind.buy,
instrument_id=gazp,
quantity="10",
price="100",
amount="-1000",
)
d = start
while d <= t:
await make_price(d, instrument_id=gazp, close="100" if d < t - timedelta(days=5) else "110")
d += timedelta(days=1)
await refresh()
async with get_sessionmaker()() as session:
rows = {
r.period: r
for r in (
(await session.execute(select(MetricReturns).where(MetricReturns.scope == "all")))
.scalars()
.all()
)
}
assert set(rows) >= {"1m", "3m", "1y", "all"}
whole = rows["all"]
assert whole.value_end_rub == D(1100)
assert whole.external_flow_rub == D(0) # the deposit IS the opening value
assert whole.abs_pnl_rub == D(100)
assert whole.twr == D("0.100000")
assert whole.xirr is not None and whole.xirr > D(0)
async def test_scopes_cover_the_whole_ledger_and_each_account(app):
t = today_local()
first = await broker_account()
second = await broker_account()
gazp = await make_instrument(ticker="GAZP")
for account in (first, second):
await make_event(
t - timedelta(days=1),
account_id=account,
kind=EventKind.buy,
instrument_id=gazp,
quantity="1",
price="100",
amount="-100",
)
await make_price(t, instrument_id=gazp, close="100")
await make_price(t - timedelta(days=1), instrument_id=gazp, close="100")
await refresh()
assert (await holdings())[gazp].qty == D(2)
assert (await holdings(f"account:{first}"))[gazp].qty == D(1)
assert (await holdings(f"account:{second}"))[gazp].qty == D(1)