"""MOEX ISS parsing: the column-order and percent-of-nominal traps.""" from datetime import date from decimal import Decimal import httpx import pytest import respx from fintracker.sources.moex.client import LastPrice, MoexClient, _rows from fintracker.sources.moex.sync import _secid_candidates ISS = "https://iss.moex.com/iss" def block(name: str, columns: list[str], data: list[list]) -> dict: return {name: {"columns": columns, "data": data}} def test_rows_zips_columns_by_name(): """Reading by position is what lets a column-order change slide prices into volumes.""" payload = block("history", ["TRADEDATE", "CLOSE"], [["2026-09-17", 275.18]]) assert _rows(payload, "history") == [{"TRADEDATE": "2026-09-17", "CLOSE": 275.18}] def test_rows_on_a_missing_block_is_empty_not_an_error(): assert _rows({}, "history") == [] @respx.mock async def test_share_history_uses_the_settlement_price(): """LEGALCLOSEPRICE survives an illiquid day better than CLOSE, so it wins.""" respx.get(url__startswith=f"{ISS}/history").mock( return_value=httpx.Response( 200, json=block( "history", ["TRADEDATE", "CLOSE", "LEGALCLOSEPRICE", "CURRENCYID"], [["2026-09-17", 275.18, 274.90, "SUR"]], ), ) ) async with MoexClient() as moex: candles = await moex.history( "SBER", engine="stock", market="shares", board="TQBR", since=date(2026, 9, 17), until=date(2026, 9, 17), ) assert candles[0].close == Decimal("274.90") assert candles[0].price_pct is None assert candles[0].currency == "RUB" # ISS says SUR; the system speaks ISO @respx.mock async def test_bond_history_resolves_percent_of_nominal_into_money(): """A bond quoted at 98.396 of a 1000 nominal is worth 983.96, not 98.4.""" respx.get(url__startswith=f"{ISS}/history").mock( return_value=httpx.Response( 200, json=block( "history", ["TRADEDATE", "LEGALCLOSEPRICE", "FACEVALUE", "ACCINT", "CURRENCYID"], [["2026-09-17", 98.396, 1000, 9.6, "SUR"]], ), ) ) async with MoexClient() as moex: candles = await moex.history( "SU26207RMFS9", engine="stock", market="bonds", board="TQOB", since=date(2026, 9, 17), until=date(2026, 9, 17), ) candle = candles[0] assert candle.close == Decimal("983.960") assert candle.price_pct == Decimal("98.396") # the quote as published, kept assert candle.accrued_interest == Decimal("9.6") @respx.mock async def test_history_follows_pagination(): """ISS caps a page at 100 rows; stopping there would silently truncate the history.""" first = [[f"2026-01-{d:02d}", 10 + d] for d in range(1, 31)] * 4 # 120 rows respx.get(url__startswith=f"{ISS}/history").mock( side_effect=[ httpx.Response(200, json=block("history", ["TRADEDATE", "CLOSE"], first[:100])), httpx.Response(200, json=block("history", ["TRADEDATE", "CLOSE"], first[100:])), ] ) async with MoexClient() as moex: candles = await moex.history( "SBER", engine="stock", market="shares", board="TQBR", since=date(2026, 1, 1), until=date(2026, 1, 31), ) assert len(candles) == 120 def test_last_price_of_a_bond_needs_a_nominal_to_become_money(): quote = LastPrice(value=Decimal("98.439"), is_percent_of_nominal=True) assert quote.in_money(Decimal(1000)) == Decimal("984.39") # no nominal: unknowable, and a percent must never be passed off as roubles assert quote.in_money(None) is None def test_last_price_of_a_share_is_already_money(): assert LastPrice(value=Decimal("275.65"), is_percent_of_nominal=False).in_money( None ) == Decimal("275.65") @pytest.mark.parametrize( ("ticker", "expected"), [("TBRU@", ["TBRU@", "TBRU"]), ("SBER", ["SBER"])], ) def test_tinvest_ticker_suffix_is_stripped_as_a_fallback(ticker, expected): """T-Invest writes TBRU@ for its own line; MOEX lists it plain.""" assert _secid_candidates(ticker) == expected