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="", json=_bad_json) with pytest.raises(MoexDataError, match="не JSON"): client.get_security("RU000A106169")