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 подпериодов.
69 lines
2.1 KiB
Python
69 lines
2.1 KiB
Python
"""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)
|