feat(moex): история бумаг с листинга и цены индексов для бенчмарков

Окно бэкфилла начинается с history_from доски, а не с первой покупки: график цены показывает историю бумаги целиком. Для активных бенчмарков source=moex синк создаёт instrument и качает индекс с его доски (IMOEX и RGBITR на SNDX, MCFTR на RTSI). Миграция засевает IMOEX, MCFTR и RGBITR; тесты чистят таблицы перед каждым тестом, чтобы сид не попадал в первый из них.
This commit is contained in:
Dmitry
2026-09-20 10:46:24 +03:00
parent 05affeea29
commit d2df86ce33
5 changed files with 317 additions and 23 deletions
+4 -1
View File
@@ -165,7 +165,10 @@ just revision "msg" # новая alembic-миграция из изме
- `analytics/benchmarks.py` — TWR индекса на сетке дат портфеля; `kind` (`price` vs - `analytics/benchmarks.py` — TWR индекса на сетке дат портфеля; `kind` (`price` vs
`total_return`) выставляется наружу, а не скрывается: сравнение с ценовым IMOEX без `total_return`) выставляется наружу, а не скрывается: сравнение с ценовым IMOEX без
дивидендов льстит портфелю на несколько % годовых, это осознанный выбор пользователя, дивидендов льстит портфелю на несколько % годовых, это осознанный выбор пользователя,
какой индекс сравнивать; какой индекс сравнивать. Цены индексов тянет `sources/moex` (`_ensure_benchmark_instruments`
создаёт `instrument` для активной строки `benchmark`; доска берётся из ISS — MCFTR на RTSI,
IMOEX и RGBITR на SNDX); миграция `f3a91c7d5e28` засевает IMOEX, MCFTR (оба `is_default`)
и RGBITR. История бумаг и индексов качается с листинга, а не с первой покупки;
- `analytics/goals.py` + `api/routers/goals.py` — прогресс цели и требуемый ежемесячный - `analytics/goals.py` + `api/routers/goals.py` — прогресс цели и требуемый ежемесячный
взнос по trailing XIRR; взнос по trailing XIRR;
- четыре новых шага в `register_steps`: `benchmarks` после `returns` (общая сетка дат), - четыре новых шага в `register_steps`: `benchmarks` после `returns` (общая сетка дат),
@@ -0,0 +1,49 @@
"""benchmark: the MOEX indices the portfolio is compared against
The list is data, but there is no screen to add to it, and an empty `benchmark` table leaves
`/analytics/benchmarks` and the overlay on the price chart with nothing to draw. IMOEX and
MCFTR are the pair the comparison is built around (a price index next to its dividend-
reinvested twin, so the gap between them is visible); RGBITR is there for a bond-heavy
portfolio and is not shown unasked. A row that is already there is left as the user set it.
Revision ID: f3a91c7d5e28
Revises: e8b21f6a90c3
Create Date: 2026-09-20 18:00:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "f3a91c7d5e28"
down_revision: str | None = "e8b21f6a90c3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
BENCHMARKS = [
("IMOEX", "Индекс МосБиржи", "price", True),
("MCFTR", "Индекс МосБиржи полной доходности «брутто»", "total_return", True),
(
"RGBITR",
"Индекс МосБиржи государственных облигаций (полной доходности)",
"total_return",
False,
),
]
def upgrade() -> None:
insert = sa.text(
"INSERT INTO benchmark (code, name, kind, source, currency, is_default, is_active) "
"VALUES (:code, :name, CAST(:kind AS benchmark_kind), 'moex', 'RUB', :is_default, true) "
"ON CONFLICT (code) DO NOTHING"
)
for code, name, kind, is_default in BENCHMARKS:
op.execute(insert.bindparams(code=code, name=name, kind=kind, is_default=is_default))
def downgrade() -> None:
op.execute(sa.text("DELETE FROM benchmark WHERE code IN ('IMOEX', 'MCFTR', 'RGBITR')"))
+121 -11
View File
@@ -1,8 +1,12 @@
"""The `moex` source: fill `price_daily` / `price_last` for papers the portfolio holds. """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` 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 are priced, plus the indices behind the active MOEX benchmarks (`_ensure_benchmark_instruments`
listing — pricing a paper for years before it was bought would be thousands of useless rows. 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 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 `instrument.board`/`exchange`. A cached board is only kept while it is still trading, and the
@@ -17,10 +21,10 @@ the past never closed — which is what left TWR skipping days for want of a pri
window comes from the instrument's own state instead: window comes from the instrument's own state instead:
* `price_coverage.history_from` — the earliest date we have already *asked* ISS for. While * `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 it is later than the start of the paper's history (`history_start`) or missing, the run
from that day; once recorded, the paper falls back to the incremental window. Asking is does a full sweep from that day; once recorded, the paper falls back to the incremental
what gets remembered, not receiving: a stretch the exchange has nothing for would window. Asking is what gets remembered, not receiving: a stretch the exchange has nothing
otherwise be re-requested on every single run, forever. 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 * `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 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 anchoring on the instrument's own data (rather than on the run date) makes a run that
@@ -31,7 +35,7 @@ from __future__ import annotations
import logging import logging
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass, replace
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
@@ -40,7 +44,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import today_local from fintracker.analytics import today_local
from fintracker.models import AssetClass, Event, EventStatus, Instrument from fintracker.models import AssetClass, Benchmark, Event, EventStatus, Instrument
from fintracker.models.pricing import PriceCoverage, PriceDaily, PriceLast from fintracker.models.pricing import PriceCoverage, PriceDaily, PriceLast
from fintracker.sources.base import SyncContext, SyncResult from fintracker.sources.base import SyncContext, SyncResult
from fintracker.sources.moex.client import BoardInfo, Candle, MoexClient, MoexError from fintracker.sources.moex.client import BoardInfo, Candle, MoexClient, MoexError
@@ -59,7 +63,17 @@ calendar artefact rather than a paper we failed to download.
CHUNK = 500 CHUNK = 500
#: Only these can be priced on MOEX; currencies come from the CBR and custom holdings by hand. #: 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} 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) @dataclass(frozen=True)
@@ -73,6 +87,8 @@ class Target:
board: str | None board: str | None
exchange: str | None exchange: str | None
asset_class: AssetClass asset_class: AssetClass
is_benchmark: bool = False
"""Priced for a comparison, not because the portfolio holds it."""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -89,6 +105,23 @@ class Coverage:
"""Breaks longer than GAP_DAYS between two stored days, as (last before, first after).""" """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: 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.""" """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 return coverage.history_from is None or coverage.history_from > target.since
@@ -166,6 +199,7 @@ class MoexSource:
async def sync(self, ctx: SyncContext) -> SyncResult: async def sync(self, ctx: SyncContext) -> SyncResult:
session = ctx.session session = ctx.session
today = today_local() today = today_local()
await _ensure_benchmark_instruments(session)
targets = await _targets(session) targets = await _targets(session)
if not targets: if not targets:
log.info("moex: no priceable instruments in the ledger yet") log.info("moex: no priceable instruments in the ledger yet")
@@ -183,6 +217,8 @@ class MoexSource:
continue continue
board, boards = resolved board, boards = resolved
coverage = coverages.get(target.instrument_id, Coverage()) 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) backfill = needs_backfill(target, coverage)
since, until = fetch_window(target, coverage, today) since, until = fetch_window(target, coverage, today)
candles: list[Candle] = [] candles: list[Candle] = []
@@ -207,14 +243,21 @@ class MoexSource:
log.info("moex: %s backfilled from %s", target.secid, since) log.info("moex: %s backfilled from %s", target.secid, since)
else: else:
# a sweep closes these itself; reporting them otherwise keeps a paper that # a sweep closes these itself; reporting them otherwise keeps a paper that
# stopped trading from looking like a failed download, and vice versa # 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 += [ warnings += [
f"{target.secid}: разрыв в истории {a.isoformat()}..{b.isoformat()}" f"{target.secid}: разрыв в истории {a.isoformat()}..{b.isoformat()}"
for a, b in coverage.gaps for a, b in coverage.gaps
if a >= first_held
] ]
# remembered even when ISS returned nothing: we asked, and asking is the state # remembered even when ISS returned nothing: we asked, and asking is the state
await _store_coverage(session, target, since) 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( last = await moex.last_price(
board.secid, engine=board.engine, market=board.market, board=board.board board.secid, engine=board.engine, market=board.market, board=board.board
) )
@@ -264,7 +307,7 @@ async def _targets(session: AsyncSession) -> list[Target]:
) )
) )
).all() ).all()
return [ targets = [
Target( Target(
instrument_id=r.id, instrument_id=r.id,
secid=r.ticker, secid=r.ticker,
@@ -278,6 +321,73 @@ async def _targets(session: AsyncSession) -> list[Target]:
if r.asset_class in PRICEABLE and r.first_held 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]: async def _coverage(session: AsyncSession) -> dict[int, Coverage]:
"""Stored span, remembered backfill depth and long breaks, for every instrument at once.""" """Stored span, remembered backfill depth and long breaks, for every instrument at once."""
+3
View File
@@ -56,6 +56,9 @@ async def app(migrated: str):
from fintracker.db import reset_engine from fintracker.db import reset_engine
auth_router._login_limiter = None # fresh rate limiter per test auth_router._login_limiter = None # fresh rate limiter per test
# rows a migration seeds (the default benchmarks) are in the first test's database only,
# since the tables are emptied after each test — start every test from the same blank one
await _truncate_all()
application = app_module.create_app() application = app_module.create_app()
yield application yield application
await _truncate_all() await _truncate_all()
+136 -7
View File
@@ -18,7 +18,15 @@ from factories import make_account, make_event, make_instrument, make_price
from fintracker.analytics import today_local from fintracker.analytics import today_local
from fintracker.config import Settings from fintracker.config import Settings
from fintracker.db import get_sessionmaker from fintracker.db import get_sessionmaker
from fintracker.models import EventKind, PriceCoverage, PriceDaily from fintracker.models import (
AssetClass,
Benchmark,
BenchmarkKind,
EventKind,
Instrument,
PriceCoverage,
PriceDaily,
)
from fintracker.sources.moex.client import BoardInfo from fintracker.sources.moex.client import BoardInfo
from fintracker.sources.moex.sync import ( from fintracker.sources.moex.sync import (
OVERLAP_DAYS, OVERLAP_DAYS,
@@ -28,11 +36,14 @@ from fintracker.sources.moex.sync import (
choose_board, choose_board,
fetch_window, fetch_window,
history_legs, history_legs,
history_start,
needs_backfill, needs_backfill,
) )
ISS = "https://iss.moex.com/iss" ISS = "https://iss.moex.com/iss"
TODAY = today_local() TODAY = today_local()
LISTING = date(2013, 3, 25)
"""When the mocked TQBR started: the day a backfill now reaches back to."""
def monday_back(days: int) -> date: def monday_back(days: int) -> date:
@@ -198,8 +209,27 @@ def test_a_paper_without_any_price_is_asked_from_the_day_it_was_held():
assert fetch_window(target(since), Coverage(), TODAY) == (since, TODAY) assert fetch_window(target(since), Coverage(), TODAY) == (since, TODAY)
async def test_a_hole_in_the_past_is_pulled_back_to_the_first_held_day(app, mock_http, run_sync): def test_history_starts_at_the_listing_when_that_is_before_the_first_purchase():
"""The TBRU@ case: held since spring, priced only from the day the source first saw it.""" whole = board("TQBR", primary=True, since="2013-03-25", till="2026-09-17")
assert history_start(date(2025, 7, 8), whole) == date(2013, 3, 25)
def test_history_starts_at_the_first_purchase_when_the_board_began_later():
"""A board the paper moved to has no history before the move — the old board supplies it."""
assert history_start(date(2025, 7, 8), MOVED_TO) == date(2025, 7, 8)
def test_history_starts_at_the_first_purchase_when_the_board_publishes_no_range():
unknown = board("TQBR", primary=True, since=None, till=None)
assert history_start(date(2025, 7, 8), unknown) == date(2025, 7, 8)
async def test_a_hole_in_the_past_is_pulled_back_to_the_listing(app, mock_http, run_sync):
"""The TBRU@ case: held since spring, priced only from the day the source first saw it.
The sweep goes back past the first purchase to the listing, so the chart on the
instrument card has the paper's own history rather than only the stretch it was held.
"""
first_held = monday_back(60) first_held = monday_back(60)
recent = [TODAY - timedelta(days=n) for n in (3, 2, 1)] recent = [TODAY - timedelta(days=n) for n in (3, 2, 1)]
instrument_id = await seed(first_held=first_held, priced=recent, asked_from=recent[0]) instrument_id = await seed(first_held=first_held, priced=recent, asked_from=recent[0])
@@ -207,9 +237,9 @@ async def test_a_hole_in_the_past_is_pulled_back_to_the_first_held_day(app, mock
history = mock_iss(mock_http) history = mock_iss(mock_http)
result = await run_sync(MoexSource(), settings=settings()) result = await run_sync(MoexSource(), settings=settings())
assert windows(history) == [(first_held, TODAY)] assert windows(history) == [(LISTING, TODAY)]
count, low, high = await stored_days(instrument_id) count, low, high = await stored_days(instrument_id)
assert low == first_held assert low == LISTING
assert high >= recent[-1] assert high >= recent[-1]
assert count > len(recent) assert count > len(recent)
assert result.counts["backfilled"] == 1 assert result.counts["backfilled"] == 1
@@ -223,7 +253,7 @@ async def test_a_complete_history_only_re_reads_the_overlap_window(app, mock_htt
for n in range(61) for n in range(61)
if (first_held + timedelta(days=n)).isoweekday() < 6 if (first_held + timedelta(days=n)).isoweekday() < 6
] ]
await seed(first_held=first_held, priced=priced, asked_from=first_held) await seed(first_held=first_held, priced=priced, asked_from=LISTING)
history = mock_iss(mock_http) history = mock_iss(mock_http)
result = await run_sync(MoexSource(), settings=settings()) result = await run_sync(MoexSource(), settings=settings())
@@ -256,7 +286,7 @@ async def test_a_long_break_inside_a_settled_history_is_reported(app, mock_http,
so the gap is surfaced as a warning instead of being silently re-fetched every run.""" so the gap is surfaced as a warning instead of being silently re-fetched every run."""
first_held = monday_back(120) first_held = monday_back(120)
priced = [first_held, first_held + timedelta(days=1), TODAY - timedelta(days=1)] priced = [first_held, first_held + timedelta(days=1), TODAY - timedelta(days=1)]
await seed(first_held=first_held, priced=priced, asked_from=first_held) await seed(first_held=first_held, priced=priced, asked_from=LISTING)
mock_iss(mock_http) mock_iss(mock_http)
result = await run_sync(MoexSource(), settings=settings()) result = await run_sync(MoexSource(), settings=settings())
@@ -266,6 +296,22 @@ async def test_a_long_break_inside_a_settled_history_is_reported(app, mock_http,
] ]
async def test_a_break_before_the_first_purchase_is_not_reported(app, mock_http, run_sync):
"""The older history exists for the chart; a halt years before the purchase is not ours."""
first_held = monday_back(60)
held = [
first_held + timedelta(days=n)
for n in range(61)
if (first_held + timedelta(days=n)).isoweekday() < 6
]
await seed(first_held=first_held, priced=[LISTING, *held], asked_from=LISTING)
mock_iss(mock_http)
result = await run_sync(MoexSource(), settings=settings())
assert result.warnings == []
def board(name: str, *, primary: bool, since: str | None, till: str | None) -> BoardInfo: def board(name: str, *, primary: bool, since: str | None, till: str | None) -> BoardInfo:
return BoardInfo( return BoardInfo(
secid="TBRU", secid="TBRU",
@@ -343,3 +389,86 @@ async def test_a_backfill_spans_both_sides_of_a_board_move(app, mock_http, run_s
assert low == first_held # the stretch on the old board is stored under the same instrument assert low == first_held # the stretch on the old board is stored under the same instrument
assert high >= TODAY - timedelta(days=2) # ... and the new board carries it to today assert high >= TODAY - timedelta(days=2) # ... and the new board carries it to today
assert count > 60 assert count > 60
async def add_benchmark(code: str, *, source: str = "moex", active: bool = True) -> int:
async with get_sessionmaker()() as session:
benchmark = Benchmark(
code=code,
name=f"Индекс {code}",
kind=BenchmarkKind.total_return,
source=source,
currency="RUB",
is_default=False,
is_active=active,
)
session.add(benchmark)
await session.commit()
return benchmark.id
async def benchmark_instrument(benchmark_id: int) -> Instrument | None:
async with get_sessionmaker()() as session:
benchmark = await session.get(Benchmark, benchmark_id)
assert benchmark is not None
if benchmark.instrument_id is None:
return None
return await session.get(Instrument, benchmark.instrument_id)
async def test_an_active_benchmark_gets_an_instrument_and_its_whole_history(
app, mock_http, run_sync
):
"""MCFTR is on RTSI, not SNDX with the other indices — the board comes from ISS."""
benchmark_id = await add_benchmark("MCFTR")
listed = date(2003, 2, 26)
history = mock_iss(
mock_http,
boards=[["MCFTR", "RTSI", "index", "stock", 1, listed.isoformat(), TODAY.isoformat()]],
)
result = await run_sync(MoexSource(), settings=settings())
instrument = await benchmark_instrument(benchmark_id)
assert instrument is not None
assert instrument.asset_class == AssetClass.market_index
assert (instrument.ticker, instrument.board, instrument.exchange) == (
"MCFTR",
"RTSI",
"index",
)
assert legs(history) == [("RTSI", listed, TODAY)]
count, low, high = await stored_days(instrument.id)
assert low == listed
assert high >= TODAY - timedelta(days=3)
assert count > 1000
assert result.counts["last"] == 0 # an index has no live quote to keep
assert result.warnings == []
async def test_a_second_run_does_not_duplicate_the_benchmark_instrument(app, mock_http, run_sync):
benchmark_id = await add_benchmark("IMOEX")
mock_iss(
mock_http,
boards=[["IMOEX", "SNDX", "index", "stock", 1, "1997-09-22", TODAY.isoformat()]],
)
await run_sync(MoexSource(), settings=settings())
first = await benchmark_instrument(benchmark_id)
second_run = await run_sync(MoexSource(), settings=settings())
second = await benchmark_instrument(benchmark_id)
assert first is not None and second is not None
assert first.id == second.id
assert second_run.counts["backfilled"] == 0
async def test_an_inactive_or_manual_benchmark_is_left_alone(app, mock_http, run_sync):
inactive = await add_benchmark("RGBITR", active=False)
manual = await add_benchmark("SPX", source="manual")
result = await run_sync(MoexSource(), settings=settings()) # no ISS route: any call fails
assert await benchmark_instrument(inactive) is None
assert await benchmark_instrument(manual) is None
assert result.counts == {"prices": 0}