GetInstrumentBy отдаёт общую запись, в которой поля sector нет вовсе — из-за этого сектор не был заполнен ни у одного инструмента, и аллокация по нему показывала один бакет unknown на все двадцать. Сектор живёт только в Shares/Bonds/Etfs, поэтому резолв добивает недостающие поля из потипового справочника: по uid, затем figi, затем ticker+board. Логика разрешения uid не тронута — одна бумага приезжает под несколькими instrument_uid, и InstrumentAlias с _match_by_identity остаются единственным местом, которое это разбирает. Справочник тянется лениво и один раз на экземпляр клиента, и только для встреченных типов: это 2000-5000 записей на тип и около 14 секунд на все три. _backfill_sectors нужен отдельно от резолва: у уже известных бумаг uid не попадает в missing, и без отдельного прохода они никогда бы не переразрешились. Пустой сектор остаётся NULL, строка "unknown" не пишется никогда — это литерал самой аллокации, и данные, притворяющиеся ответом, отличить от отсутствия ответа нельзя. Из 80 инструментов сектор заполнен у 73. Остальные семь честно пустые: валюта, пять фондов, у которых поле пусто у самого T-Invest, и внебиржевой структурный продукт.
214 lines
6.1 KiB
Python
214 lines
6.1 KiB
Python
"""Sector resolution: the field lives only in the per-type listings, not in GetInstrumentBy.
|
|
|
|
The client is driven through stand-ins for the SDK's `instruments` service, so the suite
|
|
stays runnable without a token or a network — same approach as `test_tinvest_client.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
|
|
from fintracker.sources.tinvest.client import TinvestClient, _instrument, _sector
|
|
|
|
|
|
@dataclass
|
|
class Record:
|
|
"""Only the fields `_instrument` reads; `sector` is absent on the generic record."""
|
|
|
|
uid: str
|
|
name: str
|
|
currency: str = "rub"
|
|
figi: str | None = None
|
|
ticker: str | None = None
|
|
class_code: str | None = None
|
|
isin: str | None = None
|
|
lot: int = 1
|
|
instrument_type: str | None = None
|
|
country_of_risk: str | None = None
|
|
exchange: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class Listed(Record):
|
|
sector: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class _Resp:
|
|
instrument: Any
|
|
|
|
|
|
@dataclass
|
|
class _ListResp:
|
|
instruments: list[Any]
|
|
|
|
|
|
class FakeInstruments:
|
|
"""Stands in for `client.instruments`, counting how often each listing is asked for."""
|
|
|
|
def __init__(self, generic: dict[str, Record], listings: dict[str, list[Listed]]) -> None:
|
|
self.generic = generic
|
|
self.listings = listings
|
|
self.listing_calls: list[str] = []
|
|
|
|
async def get_instrument_by(self, *, id_type: Any, id: str) -> _Resp:
|
|
return _Resp(instrument=self.generic[id])
|
|
|
|
def _listing(self, kind: str):
|
|
async def _call(*, instrument_status: Any) -> _ListResp:
|
|
self.listing_calls.append(kind)
|
|
return _ListResp(instruments=list(self.listings.get(kind, [])))
|
|
|
|
return _call
|
|
|
|
@property
|
|
def shares(self):
|
|
return self._listing("share")
|
|
|
|
@property
|
|
def bonds(self):
|
|
return self._listing("bond")
|
|
|
|
@property
|
|
def etfs(self):
|
|
return self._listing("etf")
|
|
|
|
@property
|
|
def currencies(self):
|
|
return self._listing("currency")
|
|
|
|
|
|
def client_with(generic: dict[str, Record], listings: dict[str, list[Listed]]) -> tuple:
|
|
client = TinvestClient("token")
|
|
instruments = FakeInstruments(generic, listings)
|
|
client._client = cast("Any", type("Stub", (), {"instruments": instruments})())
|
|
return client, instruments
|
|
|
|
|
|
SBER = Record(
|
|
uid="uid-sber",
|
|
name="Сбер Банк",
|
|
figi="BBG004730N88",
|
|
ticker="SBER",
|
|
class_code="TQBR",
|
|
instrument_type="share",
|
|
country_of_risk="RU",
|
|
)
|
|
SBER_LISTED = Listed(
|
|
uid="uid-sber",
|
|
name="Сбер Банк",
|
|
figi="BBG004730N88",
|
|
ticker="SBER",
|
|
class_code="TQBR",
|
|
country_of_risk="RU",
|
|
sector="Financial",
|
|
)
|
|
|
|
|
|
def test_generic_record_has_no_sector_at_all():
|
|
"""The premise: GetInstrumentBy is why every instrument used to come back sectorless."""
|
|
assert _instrument(SBER).sector is None
|
|
|
|
|
|
async def test_sector_comes_from_the_per_type_listing():
|
|
client, _ = client_with({"uid-sber": SBER}, {"share": [SBER_LISTED]})
|
|
|
|
resolved = await client.instruments_by_uid({"uid-sber"})
|
|
|
|
assert resolved["uid-sber"].sector == "financial"
|
|
assert resolved["uid-sber"].country == "RU"
|
|
|
|
|
|
async def test_listing_match_falls_back_to_figi_when_the_uid_differs():
|
|
"""One paper reaches us under several uids; figi is the identity that survives that."""
|
|
listed = Listed(
|
|
uid="uid-other",
|
|
name="Сбер Банк",
|
|
figi="BBG004730N88",
|
|
ticker="SBER",
|
|
class_code="TQBR",
|
|
sector="financial",
|
|
)
|
|
client, _ = client_with({"uid-sber": SBER}, {"share": [listed]})
|
|
|
|
resolved = await client.instruments_by_uid({"uid-sber"})
|
|
|
|
assert resolved["uid-sber"].sector == "financial"
|
|
|
|
|
|
async def test_instrument_absent_from_the_listing_keeps_a_null_sector():
|
|
"""A delisted or off-exchange paper must not fail the sync, and must not be guessed at."""
|
|
client, _ = client_with({"uid-sber": SBER}, {"share": []})
|
|
|
|
resolved = await client.instruments_by_uid({"uid-sber"})
|
|
|
|
assert resolved["uid-sber"].sector is None
|
|
assert resolved["uid-sber"].name == "Сбер Банк"
|
|
|
|
|
|
async def test_listing_is_fetched_once_per_kind_per_run():
|
|
"""It is a multi-thousand-row answer: once a run, not once an instrument."""
|
|
lkoh = Record(
|
|
uid="uid-lkoh",
|
|
name="ЛУКОЙЛ",
|
|
figi="BBG004731032",
|
|
ticker="LKOH",
|
|
class_code="TQBR",
|
|
instrument_type="share",
|
|
)
|
|
bond = Record(
|
|
uid="uid-bond",
|
|
name="ОФЗ 26207",
|
|
figi="BBG00JGQX0W2",
|
|
ticker="SU26207",
|
|
class_code="TQOB",
|
|
instrument_type="bond",
|
|
)
|
|
client, instruments = client_with(
|
|
{"uid-sber": SBER, "uid-lkoh": lkoh, "uid-bond": bond},
|
|
{
|
|
"share": [SBER_LISTED, Listed(uid="uid-lkoh", name="ЛУКОЙЛ", sector="energy")],
|
|
"bond": [Listed(uid="uid-bond", name="ОФЗ 26207", sector="government")],
|
|
},
|
|
)
|
|
|
|
resolved = await client.instruments_by_uid({"uid-sber", "uid-lkoh", "uid-bond"})
|
|
|
|
assert {u: i.sector for u, i in resolved.items()} == {
|
|
"uid-sber": "financial",
|
|
"uid-lkoh": "energy",
|
|
"uid-bond": "government",
|
|
}
|
|
assert sorted(instruments.listing_calls) == ["bond", "share"]
|
|
|
|
|
|
async def test_currency_never_triggers_a_listing_call():
|
|
"""Money has no sector by nature; asking for the currency listing would be pure cost."""
|
|
rouble = Record(uid="uid-rub", name="Российский рубль", instrument_type="currency")
|
|
client, instruments = client_with({"uid-rub": rouble}, {})
|
|
|
|
resolved = await client.instruments_by_uid({"uid-rub"})
|
|
|
|
assert resolved["uid-rub"].sector is None
|
|
assert instruments.listing_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[
|
|
("Financial", "financial"),
|
|
("health_care", "health_care"),
|
|
("Consumer Staples", "consumer_staples"),
|
|
(" IT ", "it"),
|
|
("", None),
|
|
(None, None),
|
|
("—", None),
|
|
],
|
|
)
|
|
def test_sector_is_normalised_into_one_stable_key(raw, expected):
|
|
"""Allocation buckets on this value verbatim, so casing must not split a sector in two."""
|
|
assert _sector(raw) == expected
|