Files
Dmitry 75933993b2 feat(tinvest): история цен бумаги, которую MOEX не котирует, по дневным свечам брокера
TinvestClient.daily_candles отдаёт завершённые дневные свечи, sync добирает ими историю к цене брокера (_backfill_broker_prices). Тесты БД в этой среде не запускались (нет pg_config).
2026-09-19 22:13:42 +03:00

209 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}