DRRK/app/services/manual_operations.py

208 lines
7.7 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.

"""Ручной ввод операций, которых нет в выгрузке QUIK (D21): купон (CPN),
амортизация (NOM), комиссия за андеррайтинг (UWFEE, D22), корректировка (ADJ).
Купля/продажа вручную не вводятся — только загрузкой файла.
TODO(BIQ-38115): права на ручной ввод — Q16; пока может любой пользователь.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from app.db.base import SessionLocal
from app.db.models import OperationCode
from app.repositories.operations import OperationRepository
from app.repositories.reference import ReferenceRepository
from app.repositories.securities import SecurityRepository
MANUAL_TYPES = {
OperationCode.CPN: "Купон",
OperationCode.NOM: "Амортизация (частичное погашение номинала)",
OperationCode.UWFEE: "Комиссия за андеррайтинг",
OperationCode.ADJ: "Корректировка",
}
# Комиссия за андеррайтинг — только в портфеле андеррайтинга (D22).
UWFEE_PORTFOLIO = "UW"
_POSITIVE_AMOUNT = (OperationCode.CPN, OperationCode.NOM, OperationCode.UWFEE)
_NUMBER_JUNK = re.compile(r"[\s ]")
class ManualOperationError(RuntimeError):
"""Ошибка с понятным пользователю текстом."""
@dataclass(frozen=True)
class ManualOperationInput:
operation_code: str
portfolio_code: str
isin: str
trade_date: date
amount: Decimal | None
quantity: Decimal | None
comment: str
currency_code: str | None = None # None — валюта бумаги
@dataclass(frozen=True)
class SecurityOption:
isin: str
name: str
currency: str
@dataclass(frozen=True)
class FormOptions:
portfolios: dict[str, str] # код -> название
securities: list[SecurityOption]
currencies: list[str]
@dataclass(frozen=True)
class ManualOperationRow:
trade_date: date
operation: str
portfolio: str
isin: str
name: str
quantity: Decimal | None
amount: Decimal | None
currency: str
comment: str | None
created_by: str
created_at: datetime
def parse_decimal(text: str, field: str) -> Decimal | None:
"""Поле формы -> Decimal (D15): пусто — None, запятая и пробелы допустимы."""
value = _NUMBER_JUNK.sub("", text or "").replace(",", ".")
if not value:
return None
try:
number = Decimal(value)
except InvalidOperation:
raise ManualOperationError(f"«{field}»: не число: «{text}».") from None
if not number.is_finite():
raise ManualOperationError(f"«{field}»: не число: «{text}».")
return number
def validate(operation: ManualOperationInput) -> list[str]:
code = operation.operation_code
amount, quantity = operation.amount, operation.quantity
errors = []
if code not in MANUAL_TYPES:
errors.append(f"Тип операции {code} вручную не вводится.")
if code in _POSITIVE_AMOUNT:
if amount is None:
errors.append("Укажите сумму.")
elif amount <= 0:
errors.append("Сумма должна быть больше нуля.")
if code in (OperationCode.CPN, OperationCode.NOM):
if quantity is not None and quantity <= 0:
errors.append("Количество должно быть больше нуля.")
if code == OperationCode.UWFEE:
if operation.portfolio_code != UWFEE_PORTFOLIO:
errors.append(
"Комиссия за андеррайтинг вводится только в портфеле "
f"{UWFEE_PORTFOLIO}."
)
if quantity is not None:
errors.append("У комиссии за андеррайтинг нет количества.")
if code == OperationCode.ADJ:
if not operation.comment.strip():
errors.append(
"У корректировки обязателен комментарий: что и почему корректируется."
)
if not amount and not quantity:
errors.append("Укажите сумму и/или количество (со знаком).")
return errors
async def load_form_options() -> FormOptions:
async with SessionLocal() as session:
reference = ReferenceRepository(session)
portfolios = await reference.list_portfolios()
currencies = await reference.currency_ids()
securities = await SecurityRepository(session).list_with_currency()
return FormOptions(
portfolios={p.code: p.name for p in portfolios},
securities=[
SecurityOption(security.isin, security.name, currency)
for security, currency in securities
],
currencies=sorted(currencies),
)
async def add_manual_operation(operation: ManualOperationInput, user: str) -> int:
errors = validate(operation)
if errors:
raise ManualOperationError(" ".join(errors))
async with SessionLocal() as session:
security = await SecurityRepository(session).get_by_isin(operation.isin)
if security is None:
raise ManualOperationError(f"Бумаги {operation.isin} нет в справочнике.")
reference = ReferenceRepository(session)
portfolio_id = next(
(
p.id
for p in await reference.list_portfolios()
if p.code == operation.portfolio_code
),
None,
)
if portfolio_id is None:
raise ManualOperationError(f"Нет портфеля {operation.portfolio_code}.")
currency_id = security.currency_id
if operation.currency_code is not None:
currency_id = (await reference.currency_ids()).get(operation.currency_code)
if currency_id is None:
raise ManualOperationError(
f"Валюты {operation.currency_code} нет в справочнике."
)
type_id = (await reference.operation_type_ids())[operation.operation_code]
# TODO(BIQ-38115): сумма CPN/NOM вводится, а не считается — Q17;
# участие NOM в реализованном результате — Q6; смысл ADJ — Q28.
created = await OperationRepository(session).add(
security_id=security.id,
portfolio_id=portfolio_id,
operation_type_id=type_id,
currency_id=currency_id,
trade_date=operation.trade_date,
quantity=operation.quantity,
amount=operation.amount,
comment=operation.comment.strip() or None,
created_by=user,
updated_by=user,
)
await session.commit()
return created.id
async def list_manual_operations(limit: int = 50) -> list[ManualOperationRow]:
async with SessionLocal() as session:
rows = await OperationRepository(session).list_manual(limit)
return [
ManualOperationRow(
trade_date=operation.trade_date,
operation=MANUAL_TYPES.get(code, code),
portfolio=portfolio,
isin=isin,
name=name,
quantity=operation.quantity,
amount=operation.amount,
currency=currency,
comment=operation.comment,
created_by=operation.created_by,
created_at=operation.created_at,
)
for operation, code, portfolio, isin, name, currency in rows
]