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)