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
+3
View File
@@ -56,6 +56,9 @@ async def app(migrated: str):
from fintracker.db import reset_engine
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()
yield application
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.config import Settings
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.sync import (
OVERLAP_DAYS,
@@ -28,11 +36,14 @@ from fintracker.sources.moex.sync import (
choose_board,
fetch_window,
history_legs,
history_start,
needs_backfill,
)
ISS = "https://iss.moex.com/iss"
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:
@@ -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)
async def test_a_hole_in_the_past_is_pulled_back_to_the_first_held_day(app, mock_http, run_sync):
"""The TBRU@ case: held since spring, priced only from the day the source first saw it."""
def test_history_starts_at_the_listing_when_that_is_before_the_first_purchase():
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)
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])
@@ -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)
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)
assert low == first_held
assert low == LISTING
assert high >= recent[-1]
assert count > len(recent)
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)
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)
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."""
first_held = monday_back(120)
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)
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:
return BoardInfo(
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 high >= TODAY - timedelta(days=2) # ... and the new board carries it to today
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}