DRRK/tests/unit/test_moex.py

307 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import copy
import json
import xml.etree.ElementTree as ET
from datetime import date
from decimal import Decimal
from pathlib import Path
from unittest.mock import Mock
import pytest
from pydantic import SecretStr
from app.integrations.gateway import GatewayClient
from app.integrations.moex import (
MoexClient,
MoexDataError,
parse_history,
parse_search,
parse_security,
)
ON_DATE = date(2026, 10, 8)
BOARDS = ["TQCB", "TQOB"]
@pytest.fixture
def security_payload(fixtures_dir: Path) -> dict:
raw = (fixtures_dir / "moex_security_RU000A106169.json").read_text("utf-8")
return json.loads(raw)
@pytest.fixture
def history_payload(fixtures_dir: Path) -> dict:
raw = (fixtures_dir / "moex_history_RU000A106169_2026-10-08.json").read_text(
"utf-8"
)
return json.loads(raw)
def _search_payload(*rows: dict) -> dict:
# Формат /iss/securities.json?q=... (колонки в нижнем регистре).
columns = ["secid", "shortname", "name", "isin", "is_traded", "primary_boardid"]
return {
"securities": {
"columns": columns,
"data": [[row.get(column) for column in columns] for row in rows],
}
}
def _history_row(payload: dict, **overrides) -> dict:
"""Копия ответа истории, где у единственной строки подменены поля."""
patched = copy.deepcopy(payload)
columns = patched["history"]["columns"]
row = patched["history"]["data"][0]
for name, value in overrides.items():
row[columns.index(name)] = value
return patched
# --- поиск ISIN -> SECID ------------------------------------------------------
def test_parse_search_picks_exact_isin_and_returns_secid() -> None:
payload = _search_payload(
{"secid": "SU26238RMFS4", "shortname": "ОФЗ 26238", "isin": "RU000A1038V6",
"is_traded": 1, "primary_boardid": "TQOB"},
{"secid": "XS0000000000", "shortname": "Другая", "isin": "XS0000000000",
"is_traded": 1},
) # fmt: skip
hit = parse_search(payload, "ru000a1038v6")
assert hit is not None
assert hit.secid == "SU26238RMFS4"
assert hit.isin == "RU000A1038V6"
assert hit.primary_board == "TQOB"
def test_parse_search_prefers_traded_duplicate() -> None:
payload = _search_payload(
{"secid": "OLD", "isin": "RU000A106169", "is_traded": 0},
{"secid": "RU000A106169", "isin": "RU000A106169", "is_traded": 1},
)
assert parse_search(payload, "RU000A106169").secid == "RU000A106169"
def test_parse_search_not_found_returns_none() -> None:
assert parse_search(_search_payload(), "RU000A106169") is None
payload = _search_payload({"secid": "X", "isin": "RU000A999999", "is_traded": 1})
assert parse_search(payload, "RU000A106169") is None
# --- параметры бумаги --------------------------------------------------------
def test_parse_security_from_fixture(security_payload: dict) -> None:
info = parse_security(security_payload)
assert info is not None
assert info.isin == "RU000A106169"
assert info.secid == "RU000A106169"
assert info.short_name == "РЭО-02"
assert info.initial_face_value == Decimal("1000")
assert info.face_value == Decimal("645.19")
assert info.face_unit == "SUR"
assert info.issue_date == date(2023, 3, 30)
assert info.mat_date == date(2031, 12, 20)
assert info.coupon_percent == Decimal("17.35")
assert info.coupon_frequency == 4
def test_parse_security_empty_description_returns_none() -> None:
payload = {"description": {"columns": ["name", "title", "value"], "data": []}}
assert parse_security(payload) is None
def test_parse_security_without_description_block_raises() -> None:
with pytest.raises(MoexDataError):
parse_security({"boards": {"columns": [], "data": []}})
# --- история -----------------------------------------------------------------
def test_parse_history_from_fixture(history_payload: dict) -> None:
quote = parse_history(history_payload, ON_DATE, BOARDS)
assert quote is not None
assert quote.trade_date == ON_DATE
assert quote.board == "TQCB"
assert quote.secid == "RU000A106169"
assert quote.clean_price == Decimal("101.42")
assert quote.clean_price_source == "WAPRICE"
assert quote.yield_value == Decimal("17.78")
assert quote.yield_source == "YIELDATWAP"
assert quote.duration_days == Decimal("759")
assert quote.accrued_interest == Decimal("3.68")
assert quote.face_value == Decimal("645.19")
assert quote.coupon_percent == Decimal("17.35")
assert quote.num_trades == 3
assert quote.volume == Decimal("15")
assert quote.raw["BOARDID"] == "TQCB"
assert quote.raw["SHORTNAME"] == "РЭО-02"
def test_parse_history_values_are_decimal_not_float(history_payload: dict) -> None:
quote = parse_history(history_payload, ON_DATE, BOARDS)
for name in ("clean_price", "yield_value", "accrued_interest", "face_value"):
value = getattr(quote, name)
assert isinstance(value, Decimal), name
# Decimal(str(x)), а не Decimal(x): без хвоста двоичной погрешности.
assert str(quote.clean_price) == "101.42"
def test_parse_history_matches_xml_fixture(
history_payload: dict, fixtures_dir: Path
) -> None:
"""Тот же ответ ISS в XML (числа — текстом): Decimal из JSON совпадает с ним."""
root = ET.fromstring(
(fixtures_dir / "moex_history_RU000A106169_2026-10-08.xml").read_bytes()
)
xml_row = next(root.iter("row"))
quote = parse_history(history_payload, ON_DATE, BOARDS)
assert quote.clean_price == Decimal(xml_row.get("WAPRICE"))
assert quote.yield_value == Decimal(xml_row.get("YIELDATWAP"))
assert quote.accrued_interest == Decimal(xml_row.get("ACCINT"))
assert quote.face_value == Decimal(xml_row.get("FACEVALUE"))
assert quote.duration_days == Decimal(xml_row.get("DURATION"))
def test_parse_history_falls_back_to_marketprice3_and_yieldclose(
history_payload: dict,
) -> None:
payload = _history_row(history_payload, WAPRICE=None, YIELDATWAP=None)
quote = parse_history(payload, ON_DATE, BOARDS)
assert quote.clean_price == Decimal("101.38")
assert quote.clean_price_source == "MARKETPRICE3"
assert quote.yield_value == Decimal("17.98")
assert quote.yield_source == "YIELDCLOSE"
def test_parse_history_without_any_price_keeps_none(history_payload: dict) -> None:
payload = _history_row(
history_payload, WAPRICE=None, MARKETPRICE3="", YIELDATWAP=None, YIELDCLOSE=None
)
quote = parse_history(payload, ON_DATE, BOARDS)
assert quote.clean_price is None
assert quote.clean_price_source is None
assert quote.yield_value is None
assert quote.yield_source is None
def test_parse_history_board_priority(history_payload: dict) -> None:
payload = copy.deepcopy(history_payload)
columns = payload["history"]["columns"]
tqcb = payload["history"]["data"][0]
tqob = list(tqcb)
tqob[columns.index("BOARDID")] = "TQOB"
tqob[columns.index("WAPRICE")] = 99.5
payload["history"]["data"] = [tqob, tqcb]
assert parse_history(payload, ON_DATE, ["TQCB", "TQOB"]).board == "TQCB"
quote = parse_history(payload, ON_DATE, ["TQOB", "TQCB"])
assert quote.board == "TQOB"
assert quote.clean_price == Decimal("99.5")
def test_parse_history_only_unknown_boards_is_error(history_payload: dict) -> None:
payload = _history_row(history_payload, BOARDID="PACT")
with pytest.raises(MoexDataError, match="PACT"):
parse_history(payload, ON_DATE, BOARDS)
def test_parse_history_empty_returns_none(history_payload: dict) -> None:
payload = copy.deepcopy(history_payload)
payload["history"]["data"] = []
assert parse_history(payload, ON_DATE, BOARDS) is None
def test_parse_history_ignores_rows_of_other_dates(history_payload: dict) -> None:
payload = _history_row(history_payload, TRADEDATE="2026-10-07")
assert parse_history(payload, ON_DATE, BOARDS) is None
# --- клиент: пути и параметры запросов ---------------------------------------
def _client_returning(payload: dict) -> tuple[MoexClient, Mock]:
response = Mock(status_code=200, text="", json=lambda: payload)
session = Mock()
session.get.return_value = response
gateway = GatewayClient(
source="moex",
base_url="https://gateway.test/",
path_prefix="/external/moex-iss",
api_key=SecretStr("moex-key"),
session=session,
sleep=lambda _: None,
)
return MoexClient(gateway, BOARDS), session
def test_client_history_uses_secid_and_single_date(history_payload: dict) -> None:
client, session = _client_returning(history_payload)
quote = client.get_history("RU000A106169", ON_DATE)
assert quote.board == "TQCB"
url = session.get.call_args.args[0]
params = session.get.call_args.kwargs["params"]
assert url == (
"https://gateway.test/external/moex-iss/iss/history/engines/stock/"
"markets/bonds/securities/RU000A106169.json"
)
assert params == {"from": "2026-10-08", "till": "2026-10-08", "iss.meta": "off"}
assert "limit" not in params
def test_client_search_then_security(security_payload: dict) -> None:
client, session = _client_returning(
_search_payload(
{"secid": "RU000A106169", "isin": "RU000A106169", "is_traded": 1}
)
)
hit = client.find_by_isin("RU000A106169")
assert hit.secid == "RU000A106169"
assert session.get.call_args.args[0].endswith(
"/external/moex-iss/iss/securities.json"
)
assert session.get.call_args.kwargs["params"] == {
"q": "RU000A106169",
"iss.meta": "off",
}
session.get.return_value = Mock(status_code=200, json=lambda: security_payload)
info = client.get_security(hit.secid)
assert info.short_name == "РЭО-02"
assert session.get.call_args.args[0].endswith("/iss/securities/RU000A106169.json")
def test_client_non_json_response_is_data_error() -> None:
client, session = _client_returning({})
def _bad_json():
raise ValueError("not json")
session.get.return_value = Mock(status_code=200, text="<html>", json=_bad_json)
with pytest.raises(MoexDataError, match="не JSON"):
client.get_security("RU000A106169")