fix(tinvest): чистка отменённых операций, цена бумаги от брокера, логотипы эмитентов

Отменённые и незавершённые операции удаляются из event по raw_tinvest_operation, а не только из текущего окна синка. Бумага, которую MOEX не котирует (структурные ноты, внебиржевые облигации), получает цену из портфеля брокера. Логотип и цвет бренда копируются в instrument (миграция e8b21f6a90c3).
This commit is contained in:
Dmitry
2026-09-19 21:54:47 +03:00
parent 4236993106
commit 4552efbf5d
6 changed files with 464 additions and 3 deletions
@@ -0,0 +1,141 @@
"""A paper the exchange never quotes is priced by the broker's own mark (T-Invest snapshot)."""
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
from sqlalchemy import select
from factories import make_account, make_instrument, make_price
from fintracker.db import get_sessionmaker
from fintracker.models import AccountKind, AccountRole, AssetClass, Instrument
from fintracker.models.pricing import PriceDaily
from fintracker.sources.tinvest.client import PortfolioSnapshot, PositionLine
from fintracker.sources.tinvest.sync import _store_snapshot
D = Decimal
CAPTURED = datetime(2026, 9, 19, 17, 10, tzinfo=UTC) # 20:10 in Moscow, the same calendar day
DAY = date(2026, 9, 19)
async def with_uid(instrument_id: int, uid: str) -> None:
async with get_sessionmaker()() as session:
row = await session.get(Instrument, instrument_id)
assert row is not None
row.tinvest_uid = uid
await session.commit()
def line(uid: str, *, price: str | None = "12354.6177", qty: str = "2") -> PositionLine:
return PositionLine(
instrument_uid=uid,
figi=None,
quantity=D(qty),
average_price=D("11353.3839"),
current_price=None if price is None else D(price),
currency="RUB",
instrument_type="bond",
)
async def store(account: int, *positions: PositionLine, at: datetime = CAPTURED) -> None:
snapshot = PortfolioSnapshot(
account_id="tinv-1", captured_at=at, positions=list(positions), cash=[], payload={}
)
async with get_sessionmaker()() as session:
await _store_snapshot(session, account, snapshot)
await session.commit()
async def prices(instrument_id: int) -> list[PriceDaily]:
async with get_sessionmaker()() as session:
return list(
(
await session.execute(
select(PriceDaily)
.where(PriceDaily.instrument_id == instrument_id)
.order_by(PriceDaily.d)
)
)
.scalars()
.all()
)
async def broker_account() -> int:
return await make_account(
name="Т", kind=AccountKind.broker, role=AccountRole.investment, balance=None,
source="tinvest", source_id="tinv-1",
) # fmt: skip
async def test_an_unquoted_paper_gets_the_brokers_mark_as_the_days_close(app):
account = await broker_account()
note = await make_instrument(ticker="SIBN6P4", asset_class=AssetClass.bond, board=None)
await with_uid(note, "uid-note")
await store(account, line("uid-note"))
(row,) = await prices(note)
assert (row.d, row.close, row.currency, row.source) == (DAY, D("12354.6177"), "RUB", "tinvest")
async def test_the_mark_is_refreshed_within_the_day(app):
account = await broker_account()
note = await make_instrument(ticker="SIBN6P4", asset_class=AssetClass.bond, board=None)
await with_uid(note, "uid-note")
await store(account, line("uid-note", price="12000"))
await store(account, line("uid-note", price="12100"))
(row,) = await prices(note)
assert row.close == D("12100")
async def test_an_exchange_priced_paper_is_left_to_the_exchange(app):
account = await broker_account()
sber = await make_instrument(ticker="SBER")
await with_uid(sber, "uid-sber")
await make_price(date(2026, 9, 18), instrument_id=sber, close="300")
async with get_sessionmaker()() as session:
row = (
await session.execute(select(PriceDaily).where(PriceDaily.instrument_id == sber))
).scalar_one()
row.source = "moex"
await session.commit()
await store(account, line("uid-sber", price="999"))
assert [(p.d, p.source) for p in await prices(sber)] == [(date(2026, 9, 18), "moex")]
async def test_it_never_overwrites_another_sources_row_for_the_day(app):
account = await broker_account()
note = await make_instrument(ticker="SIBN6P4", asset_class=AssetClass.bond, board=None)
await with_uid(note, "uid-note")
await make_price(DAY, instrument_id=note, close="13000") # a manual entry, say
async with get_sessionmaker()() as session:
row = (
await session.execute(select(PriceDaily).where(PriceDaily.instrument_id == note))
).scalar_one()
row.source = "manual"
await session.commit()
await store(account, line("uid-note", price="12354"))
(row,) = await prices(note)
assert (row.close, row.source) == (D("13000"), "manual")
async def test_cash_and_lines_without_a_mark_are_skipped(app):
account = await broker_account()
rub = await make_instrument(ticker="RUB000UTSTOM", asset_class=AssetClass.currency, board=None)
bare = await make_instrument(ticker="BARE", asset_class=AssetClass.bond, board=None)
await with_uid(rub, "uid-rub")
await with_uid(bare, "uid-bare")
await store(account, line("uid-rub", price="1"), line("uid-bare", price=None))
assert await prices(rub) == []
assert await prices(bare) == []
@@ -107,3 +107,64 @@ async def test_re_reading_removes_a_cancelled_order_imported_before_this_filter(
await write(account, op("cancel-1", "OPERATION_STATE_CANCELED"))
assert await dedupe_keys() == []
async def _raw(state: object, op_id: str) -> None:
from fintracker.models import RawTinvestOperation
async with get_sessionmaker()() as session:
session.add(
RawTinvestOperation(
account_id="tinv-1",
id=op_id,
operation_type="OPERATION_TYPE_BUY",
ts=datetime(2026, 4, 2, 10, 0, tzinfo=UTC),
payload={"state": state},
)
)
await session.commit()
async def _event(account: int, op_id: str) -> None:
async with get_sessionmaker()() as session:
session.add(
Event(
account_id=account,
kind=EventKind.buy,
ts=datetime(2026, 4, 2, 10, 0, tzinfo=UTC),
trade_date=datetime(2026, 4, 2, tzinfo=UTC).date(),
quantity=D(2),
amount=D("-22650.95"),
currency="RUB",
source="tinvest",
source_id=op_id,
dedupe_key=f"tinvest:tinv-1:{op_id}",
)
)
await session.commit()
async def test_a_cancelled_order_that_is_no_longer_re_read_is_still_removed(app):
"""The sync re-reads only a recent window, so an old cancelled order never comes back in
the batch. It is found through the raw table instead."""
account = await broker_account()
await _raw(2, "old-cancel") # the payload keeps the enum as its number
await _raw("OPERATION_STATE_PROGRESS", "old-progress")
await _raw(1, "old-exec")
for op_id in ("old-cancel", "old-progress", "old-exec"):
await _event(account, op_id)
await write(account, op("fresh-1", "OPERATION_STATE_EXECUTED", quantity="1"))
assert sorted(await dedupe_keys()) == ["tinvest:tinv-1:fresh-1", "tinvest:tinv-1:old-exec"]
async def test_the_purge_is_a_no_op_on_a_clean_ledger(app):
account = await broker_account()
await _raw(1, "old-exec")
await _event(account, "old-exec")
await write(account, op("fresh-1", "OPERATION_STATE_EXECUTED", quantity="1"))
await write(account, op("fresh-1", "OPERATION_STATE_EXECUTED", quantity="1"))
assert sorted(await dedupe_keys()) == ["tinvest:tinv-1:fresh-1", "tinvest:tinv-1:old-exec"]
@@ -0,0 +1,88 @@
"""Issuer logos: named by the broker, filled onto instruments, exposed as a public URL."""
from __future__ import annotations
from sqlalchemy import select
from factories import make_instrument
from fintracker.branding import logo_url
from fintracker.db import get_sessionmaker
from fintracker.models import Instrument, RawTinvestInstrument
from fintracker.sources.tinvest.sync import _fill_logos
def test_the_url_points_at_the_160_px_picture_without_the_extension_twice():
assert logo_url("sber.png") == "https://invest-brands.cdn-tinkoff.ru/sberx160.png"
assert logo_url("pik1.png") == "https://invest-brands.cdn-tinkoff.ru/pik1x160.png"
assert logo_url("noext") == "https://invest-brands.cdn-tinkoff.ru/noextx160.png"
def test_no_logo_is_no_url():
assert logo_url(None) is None
assert logo_url("") is None
async def _raw(uid: str, *, isin: str | None, brand: object) -> None:
async with get_sessionmaker()() as session:
session.add(
RawTinvestInstrument(
uid=uid, kind="share", isin=isin, figi=None, ticker=None, payload={"brand": brand}
)
)
await session.commit()
async def _logo(instrument_id: int) -> tuple[str | None, str | None]:
async with get_sessionmaker()() as session:
row = (
await session.execute(select(Instrument).where(Instrument.id == instrument_id))
).scalar_one()
return row.logo_name, row.logo_color
async def _set(instrument_id: int, **fields: object) -> None:
async with get_sessionmaker()() as session:
row = await session.get(Instrument, instrument_id)
assert row is not None
for key, value in fields.items():
setattr(row, key, value)
await session.commit()
async def fill() -> int:
async with get_sessionmaker()() as session:
filled = await _fill_logos(session)
await session.commit()
return filled
async def test_a_logo_is_matched_by_uid(app):
sber = await make_instrument(ticker="SBER")
await _set(sber, tinvest_uid="uid-sber")
await _raw("uid-sber", isin=None, brand={"logo_name": "sber.png", "logo_base_color": "#21A038"})
assert await fill() == 1
assert await _logo(sber) == ("sber.png", "#21A038")
async def test_a_paper_without_a_uid_is_matched_by_isin(app):
"""Imported from a Sber report: no T-Invest uid, but the same ISIN as one T-Invest knows."""
lkoh = await make_instrument(ticker="LKOH")
await _set(lkoh, isin="RU0009024277")
await _raw("uid-lkoh", isin="RU0009024277", brand={"logo_name": "lukoil.png"})
assert await fill() == 1
assert await _logo(lkoh) == ("lukoil.png", None)
async def test_a_logo_already_set_is_kept_and_an_undescribed_paper_stays_bare(app):
kept = await make_instrument(ticker="KEPT")
await _set(kept, tinvest_uid="uid-kept", logo_name="mine.png", logo_color="#000000")
await _raw("uid-kept", isin=None, brand={"logo_name": "other.png"})
bare = await make_instrument(ticker="BARE")
await _set(bare, tinvest_uid="uid-bare")
await _raw("uid-bare", isin=None, brand={}) # the broker names no logo for it
assert await fill() == 0
assert await _logo(kept) == ("mine.png", "#000000")
assert await _logo(bare) == (None, None)