Files
fin-tracker/backend/tests/sources/test_moex.py
T
Dmitry 53096c207e feat(moex): источник MOEX — справочник инструментов и дневные цены
ISS без ключа: история по доске, текущие котировки, метаданные бумаг. Облигации
приходят в процентах от номинала, поэтому price_daily хранит и price_pct как
опубликовано, и close как денежную величину, плюс НКД рядом.
2026-09-18 13:44:31 +03:00

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