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