153 lines
5.2 KiB
Python
153 lines
5.2 KiB
Python
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
|
||
]
|