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
+68
View File
@@ -0,0 +1,68 @@
"""The dated price lookup: carry forward, never backward, and know when it is stale."""
from datetime import date, timedelta
from decimal import Decimal
from fintracker.pricing.prices import STALE_AFTER_DAYS, PriceTable, Quote
D = Decimal
GAZP, TBRU = 10, 11
def table() -> PriceTable:
return PriceTable(
{
GAZP: [
Quote(D(100), "RUB", date(2025, 3, 3)),
Quote(D(110), "RUB", date(2025, 3, 6), accrued_interest=D(5)),
]
},
{GAZP: Quote(D(120), "RUB", date(2025, 3, 10))},
)
def at(d: date, instrument_id: int = GAZP) -> Quote:
quote = table().at(instrument_id, d)
assert quote is not None
return quote
def latest(d: date, instrument_id: int = GAZP) -> Quote:
quote = table().latest(instrument_id, d)
assert quote is not None
return quote
def test_a_quiet_day_reuses_the_last_close():
assert at(date(2025, 3, 5)).price == D(100)
def test_there_is_no_price_before_the_first_quote():
assert table().at(GAZP, date(2025, 3, 2)) is None
assert table().at(TBRU, date(2025, 3, 5)) is None
def test_staleness_is_measured_from_the_day_the_price_was_quoted():
quoted = date(2025, 3, 6)
quote = at(quoted + timedelta(days=30))
assert quote.as_of == quoted
assert not quote.is_stale_on(quoted + timedelta(days=STALE_AFTER_DAYS))
assert quote.is_stale_on(quoted + timedelta(days=STALE_AFTER_DAYS + 1))
def test_total_adds_accrued_interest():
assert at(date(2025, 3, 6)).total == D(115)
assert at(date(2025, 3, 3)).total == D(100)
def test_the_intraday_price_wins_once_the_daily_bar_is_behind():
assert latest(date(2025, 3, 11)).price == D(120)
# …but never a price from the future of the day being valued
assert latest(date(2025, 3, 7)).price == D(110)
def test_price_last_alone_is_enough_to_value_an_instrument():
only_last = PriceTable({}, {TBRU: Quote(D(7), "RUB", date(2025, 3, 10))})
assert only_last.at(TBRU, date(2025, 3, 10)) is None
quote = only_last.latest(TBRU, date(2025, 3, 10))
assert quote is not None and quote.price == D(7)
+121
View File
@@ -0,0 +1,121 @@
"""XIRR and TWR on synthetic flows — the acceptance check from the plan, §6 phase 2.
A single contribution: XIRR and TWR must agree, because there is no timing to weigh.
A second contribution: XIRR moves, every sub-period of TWR stays exactly where it was.
"""
from datetime import date, timedelta
from decimal import Decimal
import pytest
from fintracker.analytics.returns import Point, annualize, months_back, period_start, twr, xirr
D = Decimal
START = date(2025, 1, 1)
def day(n: int) -> date:
return START + timedelta(days=n)
def flat_series(values: dict[int, str], *, flows: dict[int, str] | None = None) -> list[Point]:
"""Days 1..365 of a series whose value only changes on the days listed."""
flows = flows or {}
points: list[Point] = []
current = D(values[0])
for n in range(1, 366):
if n in values:
current = D(values[n])
points.append(Point(d=day(n), value=current, flow=D(flows.get(n, "0"))))
return points
def test_single_contribution_gives_ten_percent_both_ways():
# 100 in on day 0, worth 110 a year later
assert xirr([day(0), day(365)], [D(-100), D(110)]) == D("0.100000")
chain = twr(flat_series({0: "100", 365: "110"}), opening_value=D(100))
assert chain.value == D("0.100000")
assert chain.days_skipped == 0
def test_a_second_contribution_moves_xirr_but_not_twr():
one = twr(flat_series({0: "100", 365: "110"}), opening_value=D(100))
two = twr(
flat_series({0: "100", 182: "150", 365: "165"}, flows={182: "50"}),
opening_value=D(100),
)
# the portfolio still earned exactly 10 % over the year, whoever paid in when
assert two.value == one.value == D("0.100000")
money_weighted = xirr([day(0), day(182), day(365)], [D(-100), D(-50), D(165)])
assert money_weighted is not None
assert money_weighted > D("0.100000")
def test_a_withdrawal_does_not_read_as_a_loss():
chain = twr(
flat_series({0: "100", 182: "50", 365: "55"}, flows={182: "-50"}),
opening_value=D(100),
)
assert chain.value == D("0.100000")
def test_an_unvalued_purchase_is_treated_as_leaving_the_portfolio():
"""Cash spent on a paper nobody quotes must not read as a crash (the money-market fund)."""
points = [
Point(d=day(1), value=D(83000), flow=D(17000), unvalued_flow=D(-17000), missing=1),
Point(d=day(2), value=D(83830), flow=D(0), unvalued_flow=D(0), missing=1),
]
chain = twr(points, opening_value=D(83000), opening_missing=1)
# day 1: 83000 / (83000 + 17000 - 17000) = 1; day 2: +1 %
assert chain.value == D("0.010000")
assert chain.days_skipped == 0
def test_a_position_becoming_priced_is_skipped_not_counted_as_profit():
points = [
Point(d=day(1), value=D(100), flow=D(0), missing=1),
Point(d=day(2), value=D(132), flow=D(0), missing=0),
Point(d=day(3), value=D(133), flow=D(0), missing=0),
]
chain = twr(points, opening_value=D(100), opening_missing=1)
assert chain.days_skipped == 1
# only day 3 is chained: the 32 that appeared on day 2 was never a gain
assert chain.value == D("0.007576")
def test_a_period_with_no_usable_day_has_no_twr():
points = [Point(d=day(1), value=D(100), flow=D(0), missing=1)]
assert twr(points, opening_value=D(100), opening_missing=0).value is None
def test_xirr_needs_flows_on_both_sides():
assert xirr([day(0), day(365)], [D(-100), D(-50)]) is None
assert xirr([day(0)], [D(-100)]) is None
def test_annualize_only_above_a_year():
assert annualize(D("0.1"), 180) is None
assert annualize(D("0.21"), 730) == D("0.100000")
assert annualize(None, 730) is None
@pytest.mark.parametrize(
("period", "expected"),
[
("1m", date(2026, 2, 28)),
("3m", date(2025, 12, 31)),
("1y", date(2025, 3, 31)),
("ytd", date(2026, 1, 1)),
("all", None),
],
)
def test_period_start(period: str, expected: date | None):
assert period_start(period, date(2026, 3, 31)) == expected
def test_months_back_clamps_to_a_shorter_month():
assert months_back(date(2026, 3, 31), 1) == date(2026, 2, 28)
assert months_back(date(2026, 1, 15), 13) == date(2024, 12, 15)
+244
View File
@@ -0,0 +1,244 @@
"""Valuation rules on synthetic positions — no database, no source.
The invariants under test are the ones the multi-currency rule turns on: a missing price or
a missing rate produces NULL and a counter, never a zero; a short position is negative all
the way through; and cash spent on an unquoted paper is tracked so returns can see it.
"""
from datetime import date, timedelta
from decimal import Decimal
from fintracker.analytics.valuation import (
DayValue,
Deltas,
OpenPosition,
combine,
days_between,
merge_positions,
value_holding,
value_series,
weights,
)
from fintracker.pricing.prices import STALE_AFTER_DAYS, Quote
D = Decimal
ACC, OTHER = 1, 2
GAZP, USD_ETF, SILENT = 10, 11, 12
DAY = date(2025, 3, 3)
class Prices:
def __init__(self, quotes: dict[int, Quote]) -> None:
self._quotes = quotes
def at(self, instrument_id: int, d: date) -> Quote | None:
return self._quotes.get(instrument_id)
class Fx:
def __init__(self, rates: dict[str, Decimal]) -> None:
self._rates = rates
def rate(self, d: date, ccy: str | None) -> Decimal | None:
return self._rates.get((ccy or "").upper())
def quote(price: str, *, ccy: str = "RUB", nkd: str | None = None, age: int = 0) -> Quote:
return Quote(
price=D(price),
currency=ccy,
as_of=DAY - timedelta(days=age),
accrued_interest=D(nkd) if nkd else None,
)
def deltas(*, positions=None, cash=None, flows=None, instrument_cash=None) -> Deltas:
return Deltas(positions or {}, cash or {}, flows or {}, instrument_cash or {})
def test_a_position_is_priced_and_converted_at_the_rate_of_its_day():
series = value_series(
spine=[DAY],
deltas=deltas(positions={(ACC, USD_ETF): {DAY: D(10)}}),
prices=Prices({USD_ETF: quote("12", ccy="USD")}),
fx=Fx({"USD": D(80), "RUB": D(1)}),
)
point = series[ACC][0]
assert point.market_value_rub == D(9600) # 10 * 12 * 80
assert point.complete
def test_a_bond_carries_its_accrued_interest_into_the_value():
series = value_series(
spine=[DAY],
deltas=deltas(positions={(ACC, GAZP): {DAY: D(7)}}),
prices=Prices({GAZP: quote("1000", nkd="25")}),
fx=Fx({"RUB": D(1)}),
)
point = series[ACC][0]
assert point.market_value_rub == D(7175)
assert point.accrued_interest_rub == D(175)
def test_an_unquoted_position_is_counted_not_valued_at_zero():
series = value_series(
spine=[DAY],
deltas=deltas(positions={(ACC, SILENT): {DAY: D(7)}}),
prices=Prices({}),
fx=Fx({"RUB": D(1)}),
)
point = series[ACC][0]
assert point.market_value_rub == D(0)
assert point.missing_price_count == 1
assert not point.complete
def test_a_currency_with_no_rate_drops_out_and_is_reported():
series = value_series(
spine=[DAY],
deltas=deltas(cash={(ACC, "XBT"): {DAY: D(5)}}),
prices=Prices({}),
fx=Fx({"RUB": D(1)}),
)
point = series[ACC][0]
assert point.cash_rub == D(0)
assert point.missing_fx_count == 1
def test_a_short_position_is_negative_throughout():
series = value_series(
spine=[DAY],
deltas=deltas(positions={(ACC, GAZP): {DAY: D(-4)}}),
prices=Prices({GAZP: quote("100")}),
fx=Fx({"RUB": D(1)}),
)
assert series[ACC][0].market_value_rub == D(-400)
def test_quantities_and_cash_carry_across_days():
spine = days_between(DAY, DAY + timedelta(days=2))
series = value_series(
spine=spine,
deltas=deltas(
positions={(ACC, GAZP): {DAY: D(2)}},
cash={(ACC, "RUB"): {DAY: D(500)}},
),
prices=Prices({GAZP: quote("100")}),
fx=Fx({"RUB": D(1)}),
)
assert [p.total_rub for p in series[ACC]] == [D(700), D(700), D(700)]
def test_cash_spent_on_an_unquoted_paper_is_recorded_as_an_unvalued_flow():
series = value_series(
spine=[DAY],
deltas=deltas(
positions={(ACC, SILENT): {DAY: D(140)}},
cash={(ACC, "RUB"): {DAY: D(-17000)}},
instrument_cash={(ACC, SILENT, "RUB"): {DAY: D(-17000)}},
),
prices=Prices({}),
fx=Fx({"RUB": D(1)}),
)
assert series[ACC][0].unvalued_flow_rub == D(-17000)
def test_combine_adds_accounts_day_by_day():
series = value_series(
spine=[DAY],
deltas=deltas(
positions={(ACC, GAZP): {DAY: D(1)}, (OTHER, GAZP): {DAY: D(2)}},
),
prices=Prices({GAZP: quote("100")}),
fx=Fx({"RUB": D(1)}),
)
both = combine(series, [ACC, OTHER])
assert [p.market_value_rub for p in both] == [D(300)]
assert combine(series, []) == []
def position(**over) -> OpenPosition:
base = {
"instrument_id": GAZP,
"qty": D(10),
"cost_native": D(900),
"cost_currency": "RUB",
"cost_rub": D(900),
"first_open": date(2024, 1, 9),
}
return OpenPosition(**{**base, **over})
def test_a_holding_without_a_price_is_null_with_a_status():
value = value_holding(position(), None, D(1), as_of=DAY)
assert value.status == "missing"
assert value.value_rub is None
assert value.unrealized_rub is None
def test_an_old_price_is_used_but_marked_stale():
value = value_holding(position(), quote("100", age=STALE_AFTER_DAYS + 1), D(1), as_of=DAY)
assert value.status == "stale"
assert value.value_rub == D(1000)
def test_unrealized_compares_todays_value_with_the_cost_of_its_own_day():
value = value_holding(
position(cost_rub=D(72000), cost_currency="USD", cost_native=D(900)),
quote("100", ccy="USD"),
D(80),
as_of=DAY,
)
assert value.value_rub == D(80000)
assert value.unrealized_native == D(100) # 1000 - 900 USD
assert value.unrealized_rub == D(8000) # includes the currency revaluation
def test_a_holding_with_no_rate_keeps_the_native_value_and_drops_the_rub_one():
value = value_holding(position(), quote("100"), None, as_of=DAY)
assert value.value_native == D(1000)
assert value.value_rub is None
def test_a_short_holding_profits_when_the_price_falls():
short = position(qty=D(-10), cost_native=D(-1000), cost_rub=D(-1000))
value = value_holding(short, quote("90"), D(1), as_of=DAY)
assert value.value_rub == D(-900)
assert value.unrealized_rub == D(100)
def test_weights_ignore_shorts_and_unvalued_holdings():
share = weights({1: D(300), 2: D(100), 3: None, 4: D(-50)})
assert share[1] == D("0.75")
assert share[2] == D("0.25")
assert share[3] is None
assert weights({1: None})[1] is None
def test_merging_positions_keeps_the_earliest_open_and_drops_a_mixed_currency():
merged = merge_positions(
[
position(qty=D(10), cost_rub=D(900), first_open=date(2024, 5, 1)),
position(qty=D(5), cost_currency="USD", cost_rub=D(500), first_open=date(2024, 1, 9)),
]
)
assert merged.qty == D(15)
assert merged.cost_rub == D(1400)
assert merged.cost_currency is None
assert merged.first_open == date(2024, 1, 9)
def test_merging_loses_the_rub_cost_when_any_lot_lacked_a_rate():
merged = merge_positions([position(), position(cost_rub=None)])
assert merged.cost_rub is None
def test_a_day_value_totals_market_and_cash():
point = DayValue(
d=DAY,
market_value_rub=D(100),
accrued_interest_rub=D(0),
cash_rub=D(25),
external_flow_rub=D(0),
)
assert point.total_rub == D(125)
@@ -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)