Files
fin-tracker/backend/tests/analytics/test_valuation.py
T
Dmitry c203ae65fc 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 подпериодов.
2026-09-18 13:44:50 +03:00

245 lines
7.4 KiB
Python

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