feat(moex): источник MOEX — справочник инструментов и дневные цены
ISS без ключа: история по доске, текущие котировки, метаданные бумаг. Облигации приходят в процентах от номинала, поэтому price_daily хранит и price_pct как опубликовано, и close как денежную величину, плюс НКД рядом.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user