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