feat(moex): история бумаг с листинга и цены индексов для бенчмарков
Окно бэкфилла начинается с history_from доски, а не с первой покупки: график цены показывает историю бумаги целиком. Для активных бенчмарков source=moex синк создаёт instrument и качает индекс с его доски (IMOEX и RGBITR на SNDX, MCFTR на RTSI). Миграция засевает IMOEX, MCFTR и RGBITR; тесты чистят таблицы перед каждым тестом, чтобы сид не попадал в первый из них.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user