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