feat(tinvest): история цен бумаги, которую MOEX не котирует, по дневным свечам брокера

TinvestClient.daily_candles отдаёт завершённые дневные свечи, sync добирает ими историю к цене брокера (_backfill_broker_prices). Тесты БД в этой среде не запускались (нет pg_config).
This commit is contained in:
Dmitry
2026-09-19 22:13:42 +03:00
parent 29bf512735
commit 75933993b2
3 changed files with 395 additions and 2 deletions
@@ -30,6 +30,7 @@ from google.protobuf.json_format import MessageToDict
from grpc import StatusCode from grpc import StatusCode
from t_tech.invest import ( from t_tech.invest import (
AsyncClient, AsyncClient,
CandleInterval,
GetOperationsByCursorRequest, GetOperationsByCursorRequest,
InstrumentStatus, InstrumentStatus,
OperationItem, OperationItem,
@@ -155,6 +156,19 @@ class InstrumentInfo:
payload: dict[str, Any] payload: dict[str, Any]
@dataclass(frozen=True)
class DayCandle:
"""One finished daily candle. For a bond `close` is percent of nominal, as the broker
quotes it; for everything else it is the price in the instrument's currency."""
d: date
close: Decimal
open: Decimal | None
high: Decimal | None
low: Decimal | None
volume: Decimal | None
@dataclass(frozen=True) @dataclass(frozen=True)
class DividendRow: class DividendRow:
"""One `Dividend` from GetDividends, flattened. """One `Dividend` from GetDividends, flattened.
@@ -491,6 +505,47 @@ class TinvestClient:
country=info.country or match.country, country=info.country or match.country,
) )
async def daily_candles(
self, instrument_uid: str, *, since: date, until: date
) -> list[DayCandle]:
"""Finished daily candles over [since, until] — the history of a paper MOEX has none of.
The day still forming is left out: it is not a close yet, and today's price is already
the broker's mark. NOT_FOUND (a paper the market-data service does not serve) is an
empty list, not a failure of the whole sync.
"""
start = datetime(since.year, since.month, since.day, tzinfo=UTC)
end = datetime(until.year, until.month, until.day, 23, 59, 59, tzinfo=UTC)
try:
resp = await self._call(
lambda: self._client.market_data.get_candles(
instrument_id=instrument_uid,
from_=start,
to=end,
interval=CandleInterval.CANDLE_INTERVAL_DAY,
)
)
except AioRequestError as err:
if err.code is StatusCode.NOT_FOUND:
return []
raise
out: list[DayCandle] = []
for c in resp.candles:
close = quotation_to_decimal(c.close)
if not c.is_complete or close is None:
continue
out.append(
DayCandle(
d=c.time.astimezone(UTC).date(),
close=close,
open=quotation_to_decimal(c.open),
high=quotation_to_decimal(c.high),
low=quotation_to_decimal(c.low),
volume=Decimal(c.volume),
)
)
return out
async def dividends( async def dividends(
self, instrument_uid: str, *, since: datetime, until: datetime self, instrument_uid: str, *, since: datetime, until: datetime
) -> list[DividendRow]: ) -> list[DividendRow]:
+132 -2
View File
@@ -8,7 +8,8 @@ Flow of one run:
operation id), then mapped into `event`. operation id), then mapped into `event`.
3. Instruments seen in those operations are resolved once and stored in `instrument`. 3. Instruments seen in those operations are resolved once and stored in `instrument`.
4. `GetPortfolio`/`GetPositions` -> snapshots, for the derived-vs-reported check; for a paper 4. `GetPortfolio`/`GetPositions` -> snapshots, for the derived-vs-reported check; for a paper
MOEX never quotes, the broker's own mark also becomes that day's price (`_price_from_broker`). MOEX never quotes, the broker's own mark also becomes that day's price (`_price_from_broker`),
and its daily candles give the history behind it (`_backfill_broker_prices`).
**Cursor.** T-Invest's own cursor is a per-request token, not a durable watermark, so it **Cursor.** T-Invest's own cursor is a per-request token, not a durable watermark, so it
cannot be stored between runs. Instead the cursor is a JSON map `{account_id: iso_ts}` of cannot be stored between runs. Instead the cursor is a JSON map `{account_id: iso_ts}` of
@@ -30,7 +31,7 @@ from decimal import Decimal
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -105,6 +106,7 @@ class TinvestSource:
"instruments": 0, "instruments": 0,
"sectors": 0, "sectors": 0,
"snapshots": 0, "snapshots": 0,
"prices": 0,
} }
warnings: list[str] = [] warnings: list[str] = []
new_cursors: dict[str, str] = dict(cursors) new_cursors: dict[str, str] = dict(cursors)
@@ -144,6 +146,7 @@ class TinvestSource:
) )
counts["sectors"] = await _backfill_sectors(session, client) counts["sectors"] = await _backfill_sectors(session, client)
counts["prices"] = await _backfill_broker_prices(session, client)
await _fill_logos(session) await _fill_logos(session)
@@ -804,3 +807,130 @@ async def _price_from_broker(session: AsyncSession, snapshot: Any, by_uid: dict[
) )
) )
return len(rows) return len(rows)
BROKER_HISTORY_DAYS = 365
"""How far back to ask when a paper has no purchase in the ledger to start from."""
BROKER_HISTORY_MARGIN = timedelta(days=7)
BROKER_TAIL_OVERLAP = timedelta(days=5)
async def _backfill_broker_prices(session: AsyncSession, client: TinvestClient) -> int:
"""The price history of the papers only the broker quotes, from its daily candles.
`_price_from_broker` records today's mark and nothing before it, so such a paper's chart
holds one point and its value is unknown for every day since the purchase. The candles
fill that in: the first run reaches back to a week before the first purchase, later runs
re-read only the last few days.
Bonds arrive in percent of nominal and become money the way `sources/moex` does it
(`price_pct / 100 * nominal`); a nominal in a foreign currency (a yuan bond) is taken to
roubles at that day's CBR rate, the currency the broker's own mark is in — otherwise the
series would jump between two currencies on the day the mark begins. A day with no rate is
skipped, never guessed. Rows another source wrote are left alone.
"""
from fintracker.analytics import today_local
from fintracker.pricing.fx import FxTable
moex_priced = select(PriceDaily.instrument_id).where(PriceDaily.source == "moex")
marked = (
await session.execute(
select(
Instrument.id,
Instrument.tinvest_uid,
Instrument.asset_class,
Instrument.currency,
Instrument.nominal,
Instrument.nominal_currency,
).where(
Instrument.tinvest_uid.is_not(None),
Instrument.id.in_(
select(PriceDaily.instrument_id).where(PriceDaily.source == SOURCE)
),
Instrument.id.not_in(moex_priced),
)
)
).all()
if not marked:
return 0
today = today_local()
fx = await FxTable.load(session)
stored = 0
for inst in marked:
first_buy = await session.scalar(
select(func.min(Event.trade_date)).where(Event.instrument_id == inst.id)
)
wanted = (first_buy or today - timedelta(days=BROKER_HISTORY_DAYS)) - BROKER_HISTORY_MARGIN
have_from, have_to = (
await session.execute(
select(func.min(PriceDaily.d), func.max(PriceDaily.d)).where(
PriceDaily.instrument_id == inst.id, PriceDaily.source == SOURCE
)
)
).one()
# a history that already reaches the wanted start only needs its tail refreshed
since = (
wanted
if have_from is None or have_from > wanted + BROKER_HISTORY_MARGIN
else ((have_to or today) - BROKER_TAIL_OVERLAP)
)
is_bond = inst.asset_class == AssetClass.bond
nominal, nominal_ccy = inst.nominal, inst.nominal_currency
if is_bond and nominal is None:
# GetInstrumentBy does not state the nominal; the per-type bond listing does
listed = await client.reference_instrument("bond", uid=inst.tinvest_uid)
if listed is not None and listed.nominal is not None:
nominal, nominal_ccy = listed.nominal, listed.nominal_currency
await session.execute(
update(Instrument)
.where(Instrument.id == inst.id)
.values(nominal=nominal, nominal_currency=nominal_ccy)
)
candles = await client.daily_candles(inst.tinvest_uid, since=since, until=today)
currency = (nominal_ccy or inst.currency or "RUB").upper() if is_bond else None
rows = []
for c in candles:
if is_bond:
if nominal is None:
break
money = c.close / Decimal(100) * nominal
close = fx.to_rub(money, currency, c.d)
if close is None:
continue
row_currency, pct = "RUB", c.close
else:
close, row_currency, pct = c.close, (inst.currency or "RUB").upper(), None
rows.append(
{
"instrument_id": inst.id,
"d": c.d,
"close": close,
"open": None,
"high": None,
"low": None,
"volume": c.volume,
"currency": row_currency,
"source": SOURCE,
"price_pct": pct,
}
)
if not rows:
continue
stmt = pg_insert(PriceDaily).values(rows)
await session.execute(
stmt.on_conflict_do_update(
index_elements=["instrument_id", "d"],
set_={
"close": stmt.excluded.close,
"volume": stmt.excluded.volume,
"currency": stmt.excluded.currency,
"price_pct": stmt.excluded.price_pct,
},
where=PriceDaily.source == SOURCE,
)
)
stored += len(rows)
if stored:
log.info("tinvest: %s broker-quoted daily prices from candles", stored)
return stored
@@ -0,0 +1,208 @@
"""A paper only the broker quotes gets its price history from the broker's daily candles."""
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import select
from factories import make_account, make_event, make_instrument
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
AssetClass,
EventKind,
FxRateDaily,
Instrument,
)
from fintracker.models.pricing import PriceDaily
from fintracker.sources.tinvest.client import DayCandle, InstrumentInfo
from fintracker.sources.tinvest.sync import _backfill_broker_prices
D = Decimal
TODAY = date(2026, 9, 19)
class FakeClient:
"""Only what `_backfill_broker_prices` calls; remembers what it was asked for."""
def __init__(self, candles: list[DayCandle], *, nominal: InstrumentInfo | None = None) -> None:
self.candles = candles
self.nominal = nominal
self.listed = 0
self.asked: list[tuple[str, date, date]] = []
async def reference_instrument(self, kind: str, *, uid: str) -> InstrumentInfo | None:
self.listed += 1
return self.nominal
async def daily_candles(self, uid: str, *, since: date, until: date) -> list[DayCandle]:
self.asked.append((uid, since, until))
return [c for c in self.candles if since <= c.d <= until]
def candle(d: date, close: str) -> DayCandle:
return DayCandle(d=d, close=D(close), open=None, high=None, low=None, volume=D(10))
async def bond(*, nominal_currency: str = "CNY") -> int:
"""A yuan bond held since 2026-04-03, marked by the broker today in roubles."""
iid = await make_instrument(ticker="SIBN6P4", asset_class=AssetClass.bond, board=None)
account = await make_account(
name="Т", kind=AccountKind.broker, role=AccountRole.investment, balance=None
)
await make_event(
date(2026, 4, 3), account_id=account, kind=EventKind.buy, instrument_id=iid,
quantity=1, price=12000, amount=-12000,
) # fmt: skip
async with get_sessionmaker()() as session:
row = await session.get(Instrument, iid)
assert row is not None
row.tinvest_uid, row.nominal, row.nominal_currency = "uid", D(1000), nominal_currency
session.add(
PriceDaily(
instrument_id=iid, d=TODAY, close=D("12354.6177"), currency="RUB", source="tinvest"
)
)
await session.commit()
return iid
async def rates(ccy: str, value: str, *days: date) -> None:
async with get_sessionmaker()() as session:
session.add_all(
FxRateDaily(d=d, ccy=ccy, rate_rub=D(value), is_carried=False) for d in days
)
await session.commit()
async def stored(iid: int) -> dict[date, PriceDaily]:
async with get_sessionmaker()() as session:
rows = (
await session.execute(select(PriceDaily).where(PriceDaily.instrument_id == iid))
).scalars()
return {r.d: r for r in rows}
async def run(client: FakeClient) -> int:
async with get_sessionmaker()() as session:
# the sync passes `TODAY` through `today_local()`; the fake ignores the upper bound
n = await _backfill_broker_prices(session, client) # type: ignore[arg-type]
await session.commit()
return n
async def test_a_yuan_bond_gets_its_history_in_roubles(app, monkeypatch):
monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY)
iid = await bond()
await rates("CNY", "12.5", date(2026, 9, 17), date(2026, 9, 18))
client = FakeClient([candle(date(2026, 9, 17), "98.4"), candle(date(2026, 9, 18), "98.31")])
assert await run(client) == 2
rows = await stored(iid)
# 98.31 % of 1000 CNY at 12.5 ₽ — the mark is in roubles, so the history is too
assert rows[date(2026, 9, 18)].close == D("12288.75")
assert (rows[date(2026, 9, 18)].currency, rows[date(2026, 9, 18)].price_pct) == (
"RUB",
D("98.31"),
)
assert rows[TODAY].close == D("12354.6177") # the broker's mark is not touched
# first run reaches back to a week before the purchase
assert client.asked[0][1] == date(2026, 4, 3) - timedelta(days=7)
async def test_a_day_without_a_rate_is_skipped_not_guessed(app, monkeypatch):
monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY)
iid = await bond()
await rates("CNY", "12.5", date(2026, 9, 18))
client = FakeClient([candle(date(2026, 9, 17), "98.4"), candle(date(2026, 9, 18), "98.31")])
assert await run(client) == 1
assert set(await stored(iid)) == {date(2026, 9, 18), TODAY}
async def test_a_second_run_reads_only_the_tail(app, monkeypatch):
monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY)
await bond()
await rates("CNY", "12.5", *(date(2026, 4, 1) + timedelta(days=n) for n in range(200)))
first = FakeClient([candle(date(2026, 4, 1) + timedelta(days=n), "99") for n in range(170)])
await run(first)
second = FakeClient([])
await run(second)
assert second.asked[0][1] > date(2026, 9, 1)
async def test_a_paper_the_exchange_prices_is_left_to_the_exchange(app, monkeypatch):
monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY)
iid = await bond()
async with get_sessionmaker()() as session:
session.add(
PriceDaily(
instrument_id=iid, d=date(2026, 9, 1), close=D(1), currency="RUB", source="moex"
)
)
await session.commit()
client = FakeClient([candle(date(2026, 9, 18), "98")])
assert await run(client) == 0
assert client.asked == []
async def test_a_share_keeps_its_own_currency_and_price(app, monkeypatch):
monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY)
iid = await make_instrument(ticker="AAPL", currency="USD", board=None)
async with get_sessionmaker()() as session:
row = await session.get(Instrument, iid)
assert row is not None
row.tinvest_uid = "uid-aapl"
session.add(
PriceDaily(instrument_id=iid, d=TODAY, close=D(200), currency="USD", source="tinvest")
)
await session.commit()
assert await run(FakeClient([candle(date(2026, 9, 18), "199.5")])) == 1
row = (await stored(iid))[date(2026, 9, 18)]
assert (row.close, row.currency, row.price_pct) == (D("199.5"), "USD", None)
async def test_a_bond_with_no_nominal_asks_the_bond_listing_and_remembers_it(app, monkeypatch):
monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY)
iid = await bond()
async with get_sessionmaker()() as session:
row = await session.get(Instrument, iid)
assert row is not None
row.nominal = row.nominal_currency = None
await session.commit()
await rates("CNY", "12.5", date(2026, 9, 18))
listed = InstrumentInfo(
uid="uid", kind="bond", isin=None, figi=None, ticker="SIBN6P4", class_code=None,
name="x", currency="rub", lot=1, nominal=D(1000), nominal_currency="CNY",
maturity_date=None, sector=None, country=None, exchange=None, payload={},
) # fmt: skip
client = FakeClient([candle(date(2026, 9, 18), "98.31")], nominal=listed)
assert await run(client) == 1
assert (await stored(iid))[date(2026, 9, 18)].close == D("12288.75")
async with get_sessionmaker()() as session:
row = await session.get(Instrument, iid)
assert row is not None
assert (row.nominal, row.nominal_currency) == (D(1000), "CNY")
async def test_a_bond_the_listing_does_not_know_stays_unpriced_by_candles(app, monkeypatch):
monkeypatch.setattr("fintracker.analytics.today_local", lambda: TODAY)
iid = await bond()
async with get_sessionmaker()() as session:
row = await session.get(Instrument, iid)
assert row is not None
row.nominal = None
await session.commit()
assert await run(FakeClient([candle(date(2026, 9, 18), "98.31")])) == 0
assert set(await stored(iid)) == {TODAY}