307 lines
10 KiB
Python
307 lines
10 KiB
Python
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")
|