feat(tinvest): история цен бумаги, которую MOEX не котирует, по дневным свечам брокера
TinvestClient.daily_candles отдаёт завершённые дневные свечи, sync добирает ими историю к цене брокера (_backfill_broker_prices). Тесты БД в этой среде не запускались (нет pg_config).
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user