"""A paper only the broker quotes gets its price history from the broker's daily candles.""" from __future__ import annotations from datetime import date, timedelta from decimal import Decimal from sqlalchemy import select from factories import make_account, make_event, make_instrument from fintracker.db import get_sessionmaker from fintracker.models import ( AccountKind, AccountRole, AssetClass, EventKind, FxRateDaily, Instrument, ) from fintracker.models.pricing import PriceDaily from fintracker.sources.tinvest.client import DayCandle, InstrumentInfo from fintracker.sources.tinvest.sync import _backfill_broker_prices D = Decimal TODAY = date(2026, 9, 19) class FakeClient: """Only what `_backfill_broker_prices` calls; remembers what it was asked for.""" def __init__(self, candles: list[DayCandle], *, nominal: InstrumentInfo | None = None) -> None: self.candles = candles self.nominal = nominal self.listed = 0 self.asked: list[tuple[str, date, date]] = [] async def reference_instrument(self, kind: str, *, uid: str) -> InstrumentInfo | None: self.listed += 1 return self.nominal async def daily_candles(self, uid: str, *, since: date, until: date) -> list[DayCandle]: self.asked.append((uid, since, until)) return [c for c in self.candles if since <= c.d <= until] def candle(d: date, close: str) -> DayCandle: return DayCandle(d=d, close=D(close), open=None, high=None, low=None, volume=D(10)) async def bond(*, nominal_currency: str = "CNY") -> int: """A yuan bond held since 2026-04-03, marked by the broker today in roubles.""" iid = await make_instrument(ticker="SIBN6P4", asset_class=AssetClass.bond, board=None) account = await make_account( name="Т", kind=AccountKind.broker, role=AccountRole.investment, balance=None ) await make_event( date(2026, 4, 3), account_id=account, kind=EventKind.buy, instrument_id=iid, quantity=1, price=12000, amount=-12000, ) # fmt: skip async with get_sessionmaker()() as session: row = await session.get(Instrument, iid) assert row is not None row.tinvest_uid, row.nominal, row.nominal_currency = "uid", D(1000), nominal_currency session.add( PriceDaily( instrument_id=iid, d=TODAY, close=D("12354.6177"), currency="RUB", source="tinvest" ) ) await session.commit() return iid async def rates(ccy: str, value: str, *days: date) -> None: async with get_sessionmaker()() as session: session.add_all( FxRateDaily(d=d, ccy=ccy, rate_rub=D(value), is_carried=False) for d in days ) await session.commit() async def stored(iid: int) -> dict[date, PriceDaily]: async with get_sessionmaker()() as session: rows = ( await session.execute(select(PriceDaily).where(PriceDaily.instrument_id == iid)) ).scalars() return {r.d: r for r in rows} async def run(client: FakeClient) -> int: async with get_sessionmaker()() as session: # the sync passes `TODAY` through `today_local()`; the fake ignores the upper bound n = await _backfill_broker_prices(session, client) # type: ignore[arg-type] await session.commit() return n async def test_a_yuan_bond_gets_its_history_in_roubles(app, monkeypatch): monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY) iid = await bond() await rates("CNY", "12.5", date(2026, 9, 17), date(2026, 9, 18)) client = FakeClient([candle(date(2026, 9, 17), "98.4"), candle(date(2026, 9, 18), "98.31")]) assert await run(client) == 2 rows = await stored(iid) # 98.31 % of 1000 CNY at 12.5 ₽ — the mark is in roubles, so the history is too assert rows[date(2026, 9, 18)].close == D("12288.75") assert (rows[date(2026, 9, 18)].currency, rows[date(2026, 9, 18)].price_pct) == ( "RUB", D("98.31"), ) assert rows[TODAY].close == D("12354.6177") # the broker's mark is not touched # first run reaches back to a week before the purchase assert client.asked[0][1] == date(2026, 4, 3) - timedelta(days=7) async def test_a_day_without_a_rate_is_skipped_not_guessed(app, monkeypatch): monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY) iid = await bond() await rates("CNY", "12.5", date(2026, 9, 18)) client = FakeClient([candle(date(2026, 9, 17), "98.4"), candle(date(2026, 9, 18), "98.31")]) assert await run(client) == 1 assert set(await stored(iid)) == {date(2026, 9, 18), TODAY} async def test_a_second_run_reads_only_the_tail(app, monkeypatch): monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY) await bond() await rates("CNY", "12.5", *(date(2026, 4, 1) + timedelta(days=n) for n in range(200))) first = FakeClient([candle(date(2026, 4, 1) + timedelta(days=n), "99") for n in range(170)]) await run(first) second = FakeClient([]) await run(second) assert second.asked[0][1] > date(2026, 9, 1) async def test_a_paper_the_exchange_prices_is_left_to_the_exchange(app, monkeypatch): monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY) iid = await bond() async with get_sessionmaker()() as session: session.add( PriceDaily( instrument_id=iid, d=date(2026, 9, 1), close=D(1), currency="RUB", source="moex" ) ) await session.commit() client = FakeClient([candle(date(2026, 9, 18), "98")]) assert await run(client) == 0 assert client.asked == [] async def test_a_share_keeps_its_own_currency_and_price(app, monkeypatch): monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY) iid = await make_instrument(ticker="AAPL", currency="USD", board=None) async with get_sessionmaker()() as session: row = await session.get(Instrument, iid) assert row is not None row.tinvest_uid = "uid-aapl" session.add( PriceDaily(instrument_id=iid, d=TODAY, close=D(200), currency="USD", source="tinvest") ) await session.commit() assert await run(FakeClient([candle(date(2026, 9, 18), "199.5")])) == 1 row = (await stored(iid))[date(2026, 9, 18)] assert (row.close, row.currency, row.price_pct) == (D("199.5"), "USD", None) async def test_a_bond_with_no_nominal_asks_the_bond_listing_and_remembers_it(app, monkeypatch): monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY) iid = await bond() async with get_sessionmaker()() as session: row = await session.get(Instrument, iid) assert row is not None row.nominal = row.nominal_currency = None await session.commit() await rates("CNY", "12.5", date(2026, 9, 18)) listed = InstrumentInfo( uid="uid", kind="bond", isin=None, figi=None, ticker="SIBN6P4", class_code=None, name="x", currency="rub", lot=1, nominal=D(1000), nominal_currency="CNY", maturity_date=None, sector=None, country=None, exchange=None, payload={}, ) # fmt: skip client = FakeClient([candle(date(2026, 9, 18), "98.31")], nominal=listed) assert await run(client) == 1 assert (await stored(iid))[date(2026, 9, 18)].close == D("12288.75") async with get_sessionmaker()() as session: row = await session.get(Instrument, iid) assert row is not None assert (row.nominal, row.nominal_currency) == (D(1000), "CNY") async def test_a_bond_the_listing_does_not_know_stays_unpriced_by_candles(app, monkeypatch): monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY) iid = await bond() async with get_sessionmaker()() as session: row = await session.get(Instrument, iid) assert row is not None row.nominal = None await session.commit() assert await run(FakeClient([candle(date(2026, 9, 18), "98.31")])) == 0 assert set(await stored(iid)) == {TODAY}