"""The `moex` source: fill `price_daily` / `price_last` for papers the portfolio holds. Scope is derived from the ledger, not configured: only instruments that appear in `event` are priced, and each is fetched from the first day it was held rather than from its listing — pricing a paper for years before it was bought would be thousands of useless rows. Each instrument's board is resolved from `/securities/{secid}.json` and cached in `instrument.board`/`exchange`. A cached board is only kept while it is still trading, and the history window is split across boards when it has to be: MOEX moved the T-Bank funds from TQTF to TQBR in June 2026, so everything before the move answers on one board and everything after it on another. **The fetch window is per instrument, not per source.** A single source-wide cursor (the last run's date) only ever looks forward: a paper that entered the ledger after the first run was fetched from the cursor instead of from the day it was first held, and that hole in the past never closed — which is what left TWR skipping days for want of a price. So the window comes from the instrument's own state instead: * `price_coverage.history_from` — the earliest date we have already *asked* ISS for. While it is later than the day the paper was first held (or missing), the run does a full sweep from that day; once recorded, the paper falls back to the incremental window. Asking is what gets remembered, not receiving: a stretch the exchange has nothing for would otherwise be re-requested on every single run, forever. * `max(price_daily.d)` — the newest day stored. The incremental window starts a few days before it, because ISS revises a session's settlement price after the close, and because anchoring on the instrument's own data (rather than on the run date) makes a run that errored out heal itself on the next pass instead of leaving a gap behind. """ from __future__ import annotations import logging from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from decimal import Decimal from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from fintracker.analytics import today_local from fintracker.models import AssetClass, Event, EventStatus, Instrument from fintracker.models.pricing import PriceCoverage, PriceDaily, PriceLast from fintracker.sources.base import SyncContext, SyncResult from fintracker.sources.moex.client import BoardInfo, Candle, MoexClient, MoexError log = logging.getLogger(__name__) SOURCE = "moex" OVERLAP_DAYS = 5 """Re-read window: ISS revises settlement prices after the close.""" GAP_DAYS = 14 """A break inside a history longer than this is not a holiday any more. The Russian market closes for the whole first week of January, so anything shorter is a calendar artefact rather than a paper we failed to download. """ CHUNK = 500 #: Only these can be priced on MOEX; currencies come from the CBR and custom holdings by hand. PRICEABLE = {AssetClass.share, AssetClass.bond, AssetClass.etf, AssetClass.fund} @dataclass(frozen=True) class Target: """One instrument to price, with the window it needs.""" instrument_id: int secid: str since: date nominal: Decimal | None board: str | None exchange: str | None asset_class: AssetClass @dataclass(frozen=True) class Coverage: """What `price_daily` already holds for one instrument, and how far back we have asked. The earliest stored day is deliberately absent: it is not the backfill state. Reading it as one re-asks forever for a stretch the exchange has nothing for. """ last: date | None = None history_from: date | None = None gaps: tuple[tuple[date, date], ...] = () """Breaks longer than GAP_DAYS between two stored days, as (last before, first after).""" def needs_backfill(target: Target, coverage: Coverage) -> bool: """True while the paper's history has never been asked for from the day it was held.""" return coverage.history_from is None or coverage.history_from > target.since def fetch_window(target: Target, coverage: Coverage, today: date) -> tuple[date, date]: """The [since, until] to ask ISS for. A backfill sweeps the whole span in one window: it closes the hole at the front and any hole inside it at the same time, and it happens once per instrument. Otherwise only the tail is re-read, anchored on the newest stored day. """ if needs_backfill(target, coverage): return target.since, today anchor = coverage.last or today return max(target.since, anchor - timedelta(days=OVERLAP_DAYS)), today def choose_board(boards: Sequence[BoardInfo], recorded: str | None) -> BoardInfo: """The board to price this paper from today. The board remembered on the instrument wins — it is a deliberate choice for a paper that trades in several places at once — but only while it is still current. MOEX moved the T-Bank funds from TQTF to TQBR in June 2026, and a remembered board that has stopped trading silently freezes a fund's prices on the day of the move. """ primary = next((b for b in boards if b.is_primary), boards[0]) kept = next((b for b in boards if recorded and b.board == recorded), None) if kept is None or _is_stale(kept, primary): return primary return kept def _is_stale(board: BoardInfo, primary: BoardInfo) -> bool: if board.history_till is None or primary.history_till is None: return False # no published range to compare: take the choice at face value return board.history_till < primary.history_till def history_legs( boards: Sequence[BoardInfo], current: BoardInfo, since: date, until: date ) -> list[tuple[BoardInfo, date, date]]: """Split [since, until] across the boards that actually answer for it. The current board covers everything it reaches back to; whatever is older is asked of the board the paper traded on before the move — the one in the same market that was still running latest. Only one step back: a second migration closes on the next run, once the first has been stored. """ start = current.history_from or since legs = [(current, max(since, start), until)] if start <= since: return legs older_until = min(until, start - timedelta(days=1)) previous = [ b for b in boards if b.board != current.board and (b.engine, b.market) == (current.engine, current.market) and b.history_from is not None and b.history_till is not None and b.history_from <= older_until and b.history_till >= since ] if not previous: return legs earlier = max(previous, key=lambda b: (b.history_till or since, b.history_from or since)) return [(earlier, max(since, earlier.history_from or since), older_until), *legs] class MoexSource: name = SOURCE async def sync(self, ctx: SyncContext) -> SyncResult: session = ctx.session today = today_local() targets = await _targets(session) if not targets: log.info("moex: no priceable instruments in the ledger yet") return SyncResult(cursor_after=today.isoformat(), counts={"prices": 0}, changed=False) coverages = await _coverage(session) counts = {"instruments": 0, "prices": 0, "last": 0, "backfilled": 0} warnings: list[str] = [] async with MoexClient() as moex: for target in targets: resolved = await _board_for(session, moex, target) if resolved is None: warnings.append(f"{target.secid}: не найден на MOEX — цены не загружены") continue board, boards = resolved coverage = coverages.get(target.instrument_id, Coverage()) backfill = needs_backfill(target, coverage) since, until = fetch_window(target, coverage, today) candles: list[Candle] = [] try: for leg, leg_since, leg_until in history_legs(boards, board, since, until): candles += await moex.history( leg.secid, engine=leg.engine, market=leg.market, board=leg.board, since=leg_since, until=leg_until, ) except MoexError as err: warnings.append(f"{target.secid}: {err}") continue counts["prices"] += await _store_candles(session, target, candles) counts["instruments"] += 1 if backfill: counts["backfilled"] += 1 log.info("moex: %s backfilled from %s", target.secid, since) else: # a sweep closes these itself; reporting them otherwise keeps a paper that # stopped trading from looking like a failed download, and vice versa warnings += [ f"{target.secid}: разрыв в истории {a.isoformat()}..{b.isoformat()}" for a, b in coverage.gaps ] # remembered even when ISS returned nothing: we asked, and asking is the state await _store_coverage(session, target, since) last = await moex.last_price( board.secid, engine=board.engine, market=board.market, board=board.board ) price = last.in_money(target.nominal) if price is not None: await _store_last(session, target, price) counts["last"] += 1 await session.commit() log.info( "moex: %s instruments (%s backfilled), %s daily prices, %s last prices", counts["instruments"], counts["backfilled"], counts["prices"], counts["last"], ) return SyncResult( cursor_after=today.isoformat(), counts=counts, warnings=warnings, changed=counts["prices"] > 0, ) async def _targets(session: AsyncSession) -> list[Target]: """Instruments the ledger touches, each with the date it was first held.""" rows = ( await session.execute( select( Instrument.id, Instrument.ticker, Instrument.nominal, Instrument.board, Instrument.exchange, Instrument.asset_class, func.min(Event.trade_date).label("first_held"), ) .join(Event, Event.instrument_id == Instrument.id) .where(Event.status == EventStatus.confirmed, Instrument.ticker.is_not(None)) .group_by( Instrument.id, Instrument.ticker, Instrument.nominal, Instrument.board, Instrument.exchange, Instrument.asset_class, ) ) ).all() return [ Target( instrument_id=r.id, secid=r.ticker, since=r.first_held, nominal=r.nominal, board=r.board, exchange=r.exchange, asset_class=r.asset_class, ) for r in rows if r.asset_class in PRICEABLE and r.first_held ] async def _coverage(session: AsyncSession) -> dict[int, Coverage]: """Stored span, remembered backfill depth and long breaks, for every instrument at once.""" spans = ( await session.execute( select(PriceDaily.instrument_id, func.max(PriceDaily.d)) .where(PriceDaily.source == SOURCE) .group_by(PriceDaily.instrument_id) ) ).all() asked = { instrument_id: history_from for instrument_id, history_from in ( await session.execute( select(PriceCoverage.instrument_id, PriceCoverage.history_from).where( PriceCoverage.source == SOURCE ) ) ).all() } previous = func.lag(PriceDaily.d).over( partition_by=PriceDaily.instrument_id, order_by=PriceDaily.d ) stored = ( select(PriceDaily.instrument_id, PriceDaily.d, previous.label("previous")) .where(PriceDaily.source == SOURCE) .subquery() ) gaps: dict[int, list[tuple[date, date]]] = {} for instrument_id, day, prior in ( await session.execute( select(stored.c.instrument_id, stored.c.d, stored.c.previous).where( stored.c.d - stored.c.previous > GAP_DAYS ) ) ).all(): gaps.setdefault(instrument_id, []).append((prior, day)) return { instrument_id: Coverage( last=last, history_from=asked.get(instrument_id), gaps=tuple(sorted(gaps.get(instrument_id, []))), ) for instrument_id, last in spans } async def _board_for( session: AsyncSession, moex: MoexClient, target: Target ) -> tuple[BoardInfo, list[BoardInfo]] | None: """Resolve (and remember) which MOEX board to price this paper from, and all its boards.""" boards: list[BoardInfo] = [] for secid in _secid_candidates(target.secid): try: boards = await moex.boards(secid) except MoexError: boards = [] if boards: break if not boards: return None chosen = choose_board(boards, target.board) if target.board != chosen.board or target.exchange != chosen.market: await session.execute( update(Instrument) .where(Instrument.id == target.instrument_id) .values(board=chosen.board, exchange=chosen.market) ) return chosen, boards async def _store_candles(session: AsyncSession, target: Target, candles: list[Candle]) -> int: # one row per day: a window split across boards must never send the same key twice, which # Postgres refuses inside a single ON CONFLICT statement by_day = {candle.d: candle for candle in candles if candle.close is not None} rows = [ { "instrument_id": target.instrument_id, "d": candle.d, "close": candle.close, "open": candle.open, "high": candle.high, "low": candle.low, "volume": candle.volume, "currency": candle.currency or "RUB", "source": SOURCE, "price_pct": candle.price_pct, "accrued_interest": candle.accrued_interest, } for candle in by_day.values() ] if not rows: return 0 for start in range(0, len(rows), CHUNK): chunk = rows[start : start + CHUNK] stmt = pg_insert(PriceDaily).values(chunk) stmt = stmt.on_conflict_do_update( index_elements=["instrument_id", "d"], set_={ "close": stmt.excluded.close, "open": stmt.excluded.open, "high": stmt.excluded.high, "low": stmt.excluded.low, "volume": stmt.excluded.volume, "price_pct": stmt.excluded.price_pct, "accrued_interest": stmt.excluded.accrued_interest, "source": stmt.excluded.source, }, ) await session.execute(stmt) return len(rows) async def _store_coverage(session: AsyncSession, target: Target, since: date) -> None: """Record how deep we have asked — never letting the remembered depth move forward.""" stmt = pg_insert(PriceCoverage).values( instrument_id=target.instrument_id, source=SOURCE, history_from=since, updated_at=datetime.now(UTC), ) await session.execute( stmt.on_conflict_do_update( index_elements=["instrument_id", "source"], set_={ "history_from": func.least(PriceCoverage.history_from, stmt.excluded.history_from), "updated_at": stmt.excluded.updated_at, }, ) ) async def _store_last(session: AsyncSession, target: Target, price: Decimal) -> None: stmt = pg_insert(PriceLast).values( instrument_id=target.instrument_id, ts=datetime.now(UTC), price=price, currency="RUB", source=SOURCE, ) await session.execute( stmt.on_conflict_do_update( index_elements=["instrument_id"], set_={ "ts": stmt.excluded.ts, "price": stmt.excluded.price, "source": stmt.excluded.source, }, ) ) def _secid_candidates(ticker: str) -> list[str]: """Ticker spellings to try on MOEX, most likely first. T-Invest suffixes some fund tickers with '@' (TBRU@, TDIV@, TOFZ@) for its own trading line; MOEX lists them plain. Without stripping it those funds get no prices at all. """ candidates = [ticker] if "@" in ticker: candidates.append(ticker.replace("@", "")) return candidates