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()