269 lines
9.7 KiB
Python
269 lines
9.7 KiB
Python
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)
|