DRRK/app/services/securities.py

153 lines
5.2 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
import re
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from app.db.base import SessionLocal
from app.integrations.gateway import GatewayError
from app.integrations.moex import MoexClient, MoexDataError, SecurityInfo
from app.repositories.reference import ReferenceRepository
from app.repositories.securities import SecurityRepository
from app.services.request_log import save_request_log
_ISIN_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$")
# FACEUNIT ISS -> код валюты в справочнике. SUR — исторический код рубля на MOEX.
_FACE_UNIT_TO_CURRENCY = {"SUR": "RUB", "RUB": "RUB", "CNY": "CNY", "USD": "USD"}
class SecurityServiceError(RuntimeError):
"""Ошибка с понятным пользователю текстом."""
@dataclass(frozen=True)
class SecurityPreview:
"""Данные MOEX для предпросмотра перед сохранением."""
isin: str
info: SecurityInfo
currency_code: str
@dataclass(frozen=True)
class AddSecurityResult:
security_id: int
isin: str
secid: str
name: str
@dataclass(frozen=True)
class SecurityRow:
isin: str
secid: str
name: str
currency: str
initial_face_value: Decimal | None
placement_date: date | None
maturity_date: date | None
coupon_rate: Decimal | None
coupon_frequency: int | None
is_active: bool
def normalize_isin(isin: str) -> str:
value = (isin or "").strip().upper()
if not _ISIN_RE.match(value):
raise SecurityServiceError(
f"«{isin}» не похож на ISIN: нужно 12 символов, например RU000A106169."
)
return value
def currency_for_face_unit(face_unit: str | None) -> str:
code = _FACE_UNIT_TO_CURRENCY.get((face_unit or "").upper())
if code is None:
raise SecurityServiceError(
f"Валюта номинала «{face_unit}» не заведена в справочнике валют."
)
return code
async def _ensure_not_exists(isin: str) -> None:
async with SessionLocal() as session:
existing = await SecurityRepository(session).get_by_isin(isin)
if existing is not None:
raise SecurityServiceError(f"Бумага {isin} уже добавлена ({existing.name}).")
async def preview_by_isin(
isin: str, user: str, *, moex: MoexClient | None = None
) -> SecurityPreview:
"""ISIN -> SECID (поиск ISS) -> параметры бумаги. В БД ничего не пишет,
кроме журнала запросов."""
isin = normalize_isin(isin)
await _ensure_not_exists(isin)
moex = moex or MoexClient.from_settings()
try:
hit = moex.find_by_isin(isin)
info = moex.get_security(hit.secid) if hit is not None else None
except (GatewayError, MoexDataError) as exc:
raise SecurityServiceError(f"Не удалось получить данные MOEX: {exc}") from exc
finally:
await save_request_log(moex.gateway, user)
if info is None:
raise SecurityServiceError(f"Бумага {isin} не найдена на Мосбирже.")
return SecurityPreview(
isin=isin, info=info, currency_code=currency_for_face_unit(info.face_unit)
)
async def save_preview(preview: SecurityPreview, user: str) -> AddSecurityResult:
async with SessionLocal() as session:
securities = SecurityRepository(session)
if await securities.get_by_isin(preview.isin) is not None:
raise SecurityServiceError(f"Бумага {preview.isin} уже добавлена.")
currency_id = await ReferenceRepository(session).get_currency_id(
preview.currency_code
)
if currency_id is None:
raise SecurityServiceError(
f"Валюта {preview.currency_code} отсутствует в справочнике."
)
security = await securities.add(preview.isin, preview.info, currency_id, user)
await session.commit()
return AddSecurityResult(
security_id=security.id,
isin=security.isin,
secid=security.secid,
name=security.name,
)
async def add_by_isin(
isin: str, user: str, *, moex: MoexClient | None = None
) -> AddSecurityResult:
"""Добавить бумагу по ISIN: проверка дубля, поиск на MOEX, сохранение."""
preview = await preview_by_isin(isin, user, moex=moex)
return await save_preview(preview, user)
async def list_securities() -> list[SecurityRow]:
async with SessionLocal() as session:
rows = await SecurityRepository(session).list_with_currency()
return [
SecurityRow(
isin=security.isin,
secid=security.secid,
name=security.name,
currency=currency,
initial_face_value=security.initial_face_value,
placement_date=security.placement_date,
maturity_date=security.maturity_date,
coupon_rate=security.coupon_rate,
coupon_frequency=security.coupon_frequency,
is_active=security.is_active,
)
for security, currency in rows
]