"""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