269 lines
9.7 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
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Any
from app.core.config import settings
from app.integrations.gateway import GatewayClient
class MoexDataError(RuntimeError):
"""Ответ ISS получен, но разобрать его не удалось или он не подходит."""
@dataclass(frozen=True)
class SecuritySearchHit:
"""Строка поиска /iss/securities.json?q=..."""
secid: str
isin: str
short_name: str | None
name: str | None
is_traded: bool
primary_board: str | None
@dataclass(frozen=True)
class SecurityInfo:
"""Параметры бумаги из блока description /iss/securities/{secid}.json."""
isin: str | None
secid: str
short_name: str | None
initial_face_value: Decimal | None
face_value: Decimal | None
face_unit: str | None
issue_date: date | None
mat_date: date | None
coupon_percent: Decimal | None
coupon_frequency: int | None
@dataclass(frozen=True)
class MarketQuote:
"""Строка истории торгов за дату по выбранному борду."""
trade_date: date
board: str
secid: str
clean_price: Decimal | None
clean_price_source: str | None
yield_value: Decimal | None
yield_source: str | None
duration_days: Decimal | None
accrued_interest: Decimal | None
face_value: Decimal | None
coupon_percent: Decimal | None
num_trades: int | None
volume: Decimal | None
raw: dict[str, Any]
# --- разбор ответов ISS (чистые функции, тестируются на фикстурах) ---------
def _block_rows(payload: Any, block: str) -> list[dict[str, Any]]:
"""Блок ISS {"columns": [...], "data": [[...], ...]} -> список dict."""
try:
section = payload[block]
columns = section["columns"]
return [dict(zip(columns, row)) for row in section["data"]]
except (KeyError, TypeError) as exc:
raise MoexDataError(f"В ответе MOEX ISS нет блока «{block}».") from exc
def _is_empty(value: Any) -> bool:
return value is None or (isinstance(value, str) and value.strip() == "")
def to_decimal(value: Any) -> Decimal | None:
"""Число из ISS -> Decimal через str(), чтобы не тащить погрешность float."""
if _is_empty(value):
return None
try:
return Decimal(str(value))
except InvalidOperation as exc:
raise MoexDataError(f"MOEX ISS вернул нечисловое значение: {value!r}") from exc
def to_int(value: Any) -> int | None:
number = to_decimal(value)
return None if number is None else int(number)
def to_date(value: Any) -> date | None:
# ISS пишет отсутствующие даты как "0000-00-00".
if _is_empty(value) or value == "0000-00-00":
return None
try:
return date.fromisoformat(str(value))
except ValueError as exc:
raise MoexDataError(f"MOEX ISS вернул некорректную дату: {value!r}") from exc
def _first_present(
row: dict[str, Any], fields: Sequence[str]
) -> tuple[Decimal | None, str | None]:
for name in fields:
value = to_decimal(row.get(name))
if value is not None:
return value, name
return None, None
def parse_search_hits(payload: Any) -> list[SecuritySearchHit]:
"""Блок securities поиска ISS -> строки, у которых есть secid и ISIN."""
return [
SecuritySearchHit(
secid=str(row["secid"]),
isin=str(row["isin"]),
short_name=row.get("shortname"),
name=row.get("name"),
is_traded=bool(row.get("is_traded")),
primary_board=row.get("primary_boardid"),
)
for row in _block_rows(payload, "securities")
if row.get("secid") and row.get("isin")
]
def parse_search(payload: Any, isin: str) -> SecuritySearchHit | None:
"""Находит в результатах поиска строку с точным совпадением ISIN.
Поиск ISS полнотекстовый — в выдаче могут быть и другие бумаги. Если
совпадений несколько, берём торгуемую.
"""
wanted = isin.strip().upper()
hits = [hit for hit in parse_search_hits(payload) if hit.isin.upper() == wanted]
if not hits:
return None
return sorted(hits, key=lambda hit: not hit.is_traded)[0]
def parse_security(payload: Any) -> SecurityInfo | None:
"""Блок description (строки name/value) -> SecurityInfo; пустой -> None."""
rows = _block_rows(payload, "description")
values = {row.get("name"): row.get("value") for row in rows}
if not values.get("SECID"):
return None
return SecurityInfo(
isin=values.get("ISIN") or None,
secid=str(values["SECID"]),
short_name=values.get("SHORTNAME") or None,
initial_face_value=to_decimal(values.get("INITIALFACEVALUE")),
face_value=to_decimal(values.get("FACEVALUE")),
face_unit=values.get("FACEUNIT") or None,
issue_date=to_date(values.get("ISSUEDATE")),
mat_date=to_date(values.get("MATDATE")),
coupon_percent=to_decimal(values.get("COUPONPERCENT")),
coupon_frequency=to_int(values.get("COUPONFREQUENCY")),
)
def parse_history(
payload: Any, on_date: date, preferred_boards: Sequence[str]
) -> MarketQuote | None:
"""Блок history -> MarketQuote по первому подходящему борду.
None — за дату торгов не было (строк нет). Если строки есть, но ни одна
не на приоритетном борде, — MoexDataError: это не «нет торгов», а повод
проверить MOEX_PREFERRED_BOARDS.
"""
rows = [
row
for row in _block_rows(payload, "history")
if to_date(row.get("TRADEDATE")) == on_date
]
if not rows:
return None
by_board = {row.get("BOARDID"): row for row in rows}
board = next((b for b in preferred_boards if b in by_board), None)
if board is None:
available = ", ".join(sorted(str(b) for b in by_board))
raise MoexDataError(
f"За {on_date:%d.%m.%Y} есть данные только по режимам {available}, "
f"а приоритетные — {', '.join(preferred_boards)}."
)
row = by_board[board]
clean_price, clean_price_source = _first_present(row, ("WAPRICE", "MARKETPRICE3"))
yield_value, yield_source = _first_present(row, ("YIELDATWAP", "YIELDCLOSE"))
return MarketQuote(
trade_date=on_date,
board=board,
secid=str(row.get("SECID")),
clean_price=clean_price,
clean_price_source=clean_price_source,
yield_value=yield_value,
yield_source=yield_source,
duration_days=to_decimal(row.get("DURATION")),
accrued_interest=to_decimal(row.get("ACCINT")),
face_value=to_decimal(row.get("FACEVALUE")),
coupon_percent=to_decimal(row.get("COUPONPERCENT")),
num_trades=to_int(row.get("NUMTRADES")),
volume=to_decimal(row.get("VOLUME")),
raw=row,
)
# --- клиент -----------------------------------------------------------------
class MoexClient:
"""MOEX ISS через шлюз: {GATEWAY_URL}{MOEX_PATH}/iss/...
Все запросы к конкретной бумаге — по SECID: ISS ждёт именно его, а у ОФЗ
он не совпадает с ISIN. ISIN -> SECID — через find_by_isin().
"""
def __init__(self, gateway: GatewayClient, preferred_boards: Sequence[str]) -> None:
self.gateway = gateway
self.preferred_boards = list(preferred_boards)
@classmethod
def from_settings(cls) -> MoexClient:
gateway = GatewayClient(
source="moex",
base_url=settings.GATEWAY_URL,
path_prefix=settings.MOEX_PATH,
api_key=settings.MOEX_API_KEY,
timeout_seconds=settings.HTTP_TIMEOUT_SECONDS,
retries=settings.HTTP_RETRIES,
)
return cls(gateway, settings.MOEX_PREFERRED_BOARDS)
def _get_json(self, path: str, params: dict[str, Any]) -> Any:
response = self.gateway.get(path, {**params, "iss.meta": "off"})
try:
return response.json()
except ValueError as exc:
raise MoexDataError(
f"MOEX ISS вернул не JSON ({self.gateway.endpoint(path)})."
) from exc
def search(self, query: str) -> list[SecuritySearchHit]:
payload = self._get_json("/iss/securities.json", {"q": query})
return parse_search_hits(payload)
def find_by_isin(self, isin: str) -> SecuritySearchHit | None:
payload = self._get_json("/iss/securities.json", {"q": isin})
return parse_search(payload, isin)
def get_security(self, secid: str) -> SecurityInfo | None:
payload = self._get_json(f"/iss/securities/{secid}.json", {})
return parse_security(payload)
def get_history(self, secid: str, on_date: date) -> MarketQuote | None:
# from/till на одну дату, а не limit=1 как в макросе: иначе при
# отсутствии торгов можно молча получить соседний день.
day = on_date.isoformat()
payload = self._get_json(
f"/iss/history/engines/stock/markets/bonds/securities/{secid}.json",
{"from": day, "till": day},
)
return parse_history(payload, on_date, self.preferred_boards)