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)