Files
fin-tracker/backend/src/fintracker/sources/moex/sync.py
T
Dmitry d2df86ce33 feat(moex): история бумаг с листинга и цены индексов для бенчмарков
Окно бэкфилла начинается с history_from доски, а не с первой покупки: график цены показывает историю бумаги целиком. Для активных бенчмарков source=moex синк создаёт instrument и качает индекс с его доски (IMOEX и RGBITR на SNDX, MCFTR на RTSI). Миграция засевает IMOEX, MCFTR и RGBITR; тесты чистят таблицы перед каждым тестом, чтобы сид не попадал в первый из них.
2026-09-20 10:46:24 +03:00

556 lines
22 KiB
Python

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