ISS без ключа: история по доске, текущие котировки, метаданные бумаг. Облигации приходят в процентах от номинала, поэтому price_daily хранит и price_pct как опубликовано, и close как денежную величину, плюс НКД рядом.
131 lines
4.2 KiB
Python
131 lines
4.2 KiB
Python
"""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
|