fix(tinvest): чистка отменённых операций, цена бумаги от брокера, логотипы эмитентов
Отменённые и незавершённые операции удаляются из event по raw_tinvest_operation, а не только из текущего окна синка. Бумага, которую MOEX не котирует (структурные ноты, внебиржевые облигации), получает цену из портфеля брокера. Логотип и цвет бренда копируются в instrument (миграция e8b21f6a90c3).
This commit is contained in:
@@ -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) == []
|
||||
Reference in New Issue
Block a user