DRRK/app/services/securities.py

255 lines
9.0 KiB
Python
Raw Permalink 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, datetime
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import SessionLocal
from app.db.models import NominalSource
from app.integrations.gateway import GatewayError
from app.integrations.moex import MoexClient, MoexDataError, SecurityInfo
from app.repositories.nominals import NominalHistoryRepository
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]$")
NOMINAL_SOURCES = {NominalSource.MOEX: "MOEX", NominalSource.MANUAL: "ручной"}
_REASON_MAX = 500
# 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
@dataclass(frozen=True)
class NominalRow:
valid_from: date
face_value: Decimal
source: str
reason: str | None
updated_by: str
updated_at: datetime
@dataclass(frozen=True)
class ManualNominalInput:
isin: str
valid_from: date
face_value: Decimal | None
reason: str
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
]
async def _security_id(session: AsyncSession, isin: str) -> int:
security = await SecurityRepository(session).get_by_isin(isin)
if security is None:
raise SecurityServiceError(f"Бумаги {isin} нет в справочнике.")
return security.id
async def nominal_history(isin: str) -> list[NominalRow]:
"""История номинала бумаги (D11), новые сверху."""
async with SessionLocal() as session:
security_id = await _security_id(session, isin)
rows = await NominalHistoryRepository(session).list_for_security(security_id)
return [
NominalRow(
valid_from=row.valid_from,
face_value=row.face_value,
source=NOMINAL_SOURCES.get(row.source, row.source),
reason=row.reason,
updated_by=row.updated_by,
updated_at=row.updated_at,
)
for row in rows
]
async def nominal_on(isin: str, on_date: date) -> Decimal | None:
"""Номинал на дату: последняя запись истории не позже даты, на одну дату
ручная важнее MOEX.
TODO(BIQ-38115): номинал до первой записи истории (бумаги, амортизированные
до начала загрузки котировок) — Q26; пока None.
"""
async with SessionLocal() as session:
security_id = await _security_id(session, isin)
row = await NominalHistoryRepository(session).effective_on(security_id, on_date)
return row.face_value if row is not None else None
def validate_nominal(value: ManualNominalInput) -> list[str]:
errors = []
if value.face_value is None:
errors.append("Укажите номинал.")
elif value.face_value <= 0:
errors.append("Номинал должен быть больше нуля.")
reason = value.reason.strip()
if not reason:
errors.append("Укажите причину правки.")
elif len(reason) > _REASON_MAX:
errors.append(f"Причина — не длиннее {_REASON_MAX} символов.")
if value.valid_from > date.today():
errors.append("Дата не может быть в будущем.")
return errors
async def set_manual_nominal(value: ManualNominalInput, user: str) -> None:
"""Ручной номинал с даты (D11). Повторная правка на ту же дату
перезаписывает ручную запись; запись MOEX остаётся рядом.
TODO(BIQ-38115): права на ручные значения — Q16, пока может любой
пользователь.
"""
errors = validate_nominal(value)
if errors:
raise SecurityServiceError(" ".join(errors))
async with SessionLocal() as session:
security_id = await _security_id(session, value.isin)
await NominalHistoryRepository(session).upsert(
security_id=security_id,
valid_from=value.valid_from,
face_value=value.face_value,
source=NominalSource.MANUAL,
reason=value.reason.strip(),
user=user,
)
await session.commit()