"""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, plus the indices behind the active MOEX benchmarks (`_ensure_benchmark_instruments` gives each one an `instrument`; `analytics/benchmarks.py` and the chart overlay read them from `price_daily` like any other paper). Each is fetched from its listing on the board it is priced from (`history_start`), not from the day it was first held: the chart on the instrument card shows the paper's own history, and the analytics never read a price from before the first purchase, so the older rows cost only disk. 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 start of the paper's history (`history_start`) 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, replace 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, Benchmark, 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, AssetClass.market_index, } BENCHMARK_SINCE = date(1990, 1, 1) """An index has no first purchase: this stands in for it, and `history_start` pulls it forward to the day the index began (IMOEX: 1997-09-22).""" @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 is_benchmark: bool = False """Priced for a comparison, not because the portfolio holds it.""" @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 history_start(first_held: date, board: BoardInfo) -> date: """The day to start a paper's history from: its listing on the board it is priced from. The chart on the instrument card shows the paper's own price history, not just the stretch it was held — a chart that begins on the day of the first purchase says nothing about where the paper stood when it was bought. The board's `history_from` is the earliest day ISS has anything for, so it bounds the sweep without guessing a floor. A board that started *after* the first purchase (the T-Bank funds that moved from TQTF to TQBR in June 2026) does not go back that far: the day held stays the start, and `history_legs` asks the board it traded on before the move for the rest. """ if board.history_from is None: return first_held return min(first_held, board.history_from) 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() await _ensure_benchmark_instruments(session) 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()) first_held = target.since target = replace(target, since=history_start(first_held, board)) 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. # Only breaks in the stretch the paper was held: the older history is there # for the chart, and a halt years before the purchase is not ours to fix. # An index was never held — the comparison reports its own `days_skipped`. if not target.is_benchmark: warnings += [ f"{target.secid}: разрыв в истории {a.isoformat()}..{b.isoformat()}" for a, b in coverage.gaps if a >= first_held ] # remembered even when ISS returned nothing: we asked, and asking is the state await _store_coverage(session, target, since) if target.is_benchmark: continue # an index has no live quote to hold: only its daily close is read 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() targets = [ 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 ] held = {t.instrument_id for t in targets} indices = ( await session.execute( select(Instrument) .join(Benchmark, Benchmark.instrument_id == Instrument.id) .where(Benchmark.source == SOURCE, Benchmark.is_active, Instrument.ticker.is_not(None)) ) ).scalars() return targets + [ Target( instrument_id=i.id, secid=i.ticker or "", since=BENCHMARK_SINCE, nominal=None, board=i.board, exchange=i.exchange, asset_class=i.asset_class, is_benchmark=True, ) for i in indices if i.id not in held ] async def _ensure_benchmark_instruments(session: AsyncSession) -> None: """Give every active MOEX benchmark the `instrument` its history is stored under. The benchmark list is the user's data, and a row added through the API has no instrument yet (`instrument_id` is NULL until the index is first synced). The board is left empty: indices do not share one — IMOEX and RGBITR are on SNDX, MCFTR is on RTSI — so `_board_for` resolves it from ISS like it does for any paper. """ pending = ( ( await session.execute( select(Benchmark).where( Benchmark.source == SOURCE, Benchmark.is_active, Benchmark.instrument_id.is_(None), ) ) ) .scalars() .all() ) for benchmark in pending: instrument = ( await session.execute( select(Instrument).where( Instrument.ticker == benchmark.code, Instrument.asset_class == AssetClass.market_index, ) ) ).scalar_one_or_none() if instrument is None: instrument = Instrument( asset_class=AssetClass.market_index, ticker=benchmark.code, name=benchmark.name, currency=benchmark.currency, ) session.add(instrument) await session.flush() benchmark.instrument_id = instrument.id if pending: await session.flush() 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