- funding_rate table and "Фондирование" page: rate from date, history, rate on date (D27)
- positions by issue: BUY - SELL per portfolio, "Остатки" page (D29)
- quotes are loaded only for securities with an open position (D10)
- business calendar from xmlcalendar.ru: yearly file upload, report date
and update job use previous working day (D30)
- xlsx download for positions, quotes and funding rate history
- manual operation type as radio buttons
- QUIK instruments with purchase marks ("МТС 2Р-18_1") match the issue,
file name kept in operation.instrument (D17)
- migrations d3a6f8b1c254, e7b2c9d4a813, f1c4a7e9b352
- project.md: decisions and questions after customer meetings
164 lines
5.8 KiB
Python
164 lines
5.8 KiB
Python
"""Ставки фондирования портфелей (D27). Расчёт стоимости фондирования —
|
||
вместе с финрезультатом."""
|
||
|
||
from __future__ import annotations
|
||
|
||
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.repositories.funding_rates import FundingRateRepository
|
||
from app.repositories.reference import ReferenceRepository
|
||
|
||
# Знаков после запятой в funding_rate.rate (Numeric(12, 6)).
|
||
_RATE_SCALE = 6
|
||
|
||
|
||
class FundingError(RuntimeError):
|
||
"""Ошибка с понятным пользователю текстом."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FundingOptions:
|
||
portfolios: dict[str, str] # код -> название
|
||
currencies: list[str]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FundingRateInput:
|
||
portfolio_code: str
|
||
currency_code: str
|
||
effective_from: date
|
||
rate: Decimal | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FundingRateRow:
|
||
currency: str
|
||
effective_from: date
|
||
rate: Decimal
|
||
updated_by: str
|
||
updated_at: datetime
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RateOnDate:
|
||
currency: str
|
||
rate: Decimal | None
|
||
effective_from: date | None
|
||
|
||
|
||
async def load_options() -> FundingOptions:
|
||
async with SessionLocal() as session:
|
||
reference = ReferenceRepository(session)
|
||
portfolios = await reference.list_portfolios()
|
||
currencies = await reference.currency_ids()
|
||
return FundingOptions(
|
||
portfolios={p.code: p.name for p in portfolios},
|
||
currencies=sorted(currencies),
|
||
)
|
||
|
||
|
||
async def _portfolio_id(session: AsyncSession, code: str) -> int:
|
||
portfolio_id = await ReferenceRepository(session).get_portfolio_id(code)
|
||
if portfolio_id is None:
|
||
raise FundingError(f"Портфеля {code} нет в справочнике.")
|
||
return portfolio_id
|
||
|
||
|
||
async def _currency_id(session: AsyncSession, code: str) -> int:
|
||
currency_id = await ReferenceRepository(session).get_currency_id(code)
|
||
if currency_id is None:
|
||
raise FundingError(f"Валюты {code} нет в справочнике.")
|
||
return currency_id
|
||
|
||
|
||
def validate_rate(value: FundingRateInput) -> list[str]:
|
||
errors = []
|
||
if value.rate is None:
|
||
errors.append("Укажите ставку.")
|
||
elif value.rate < 0:
|
||
errors.append("Ставка не может быть отрицательной.")
|
||
elif -value.rate.as_tuple().exponent > _RATE_SCALE:
|
||
errors.append(f"Ставка — не больше {_RATE_SCALE} знаков после запятой.")
|
||
return errors
|
||
|
||
|
||
async def set_rate(value: FundingRateInput, user: str) -> None:
|
||
"""Ставка с даты. Повторный ввод на ту же дату (портфель, валюта)
|
||
перезаписывает ставку.
|
||
|
||
TODO(BIQ-38115): права на ввод ставок — Q16, пока может любой пользователь.
|
||
"""
|
||
errors = validate_rate(value)
|
||
if errors:
|
||
raise FundingError(" ".join(errors))
|
||
async with SessionLocal() as session:
|
||
portfolio_id = await _portfolio_id(session, value.portfolio_code)
|
||
currency_id = await _currency_id(session, value.currency_code)
|
||
await FundingRateRepository(session).upsert(
|
||
portfolio_id=portfolio_id,
|
||
currency_id=currency_id,
|
||
effective_from=value.effective_from,
|
||
rate=value.rate,
|
||
user=user,
|
||
)
|
||
await session.commit()
|
||
|
||
|
||
async def rate_on(
|
||
portfolio_code: str, currency_code: str, on_date: date
|
||
) -> Decimal | None:
|
||
"""Ставка портфеля в валюте, действующая на дату; до первой записи — None.
|
||
|
||
TODO(BIQ-38115): индивидуальные ставки по бумагам (РЭО-02 = ключевая +
|
||
2,42) — Q8, пока только ставка портфеля. Какую дату брать для продажи —
|
||
Q19, решается в расчёте.
|
||
"""
|
||
async with SessionLocal() as session:
|
||
portfolio_id = await _portfolio_id(session, portfolio_code)
|
||
currency_id = await _currency_id(session, currency_code)
|
||
row = await FundingRateRepository(session).effective_on(
|
||
portfolio_id, currency_id, on_date
|
||
)
|
||
return row.rate if row is not None else None
|
||
|
||
|
||
async def rates_on(portfolio_code: str, on_date: date) -> list[RateOnDate]:
|
||
"""Действующие на дату ставки портфеля по всем валютам справочника."""
|
||
async with SessionLocal() as session:
|
||
portfolio_id = await _portfolio_id(session, portfolio_code)
|
||
currencies = await ReferenceRepository(session).currency_ids()
|
||
repository = FundingRateRepository(session)
|
||
result = []
|
||
for code in sorted(currencies):
|
||
row = await repository.effective_on(portfolio_id, currencies[code], on_date)
|
||
result.append(
|
||
RateOnDate(
|
||
currency=code,
|
||
rate=row.rate if row is not None else None,
|
||
effective_from=row.effective_from if row is not None else None,
|
||
)
|
||
)
|
||
return result
|
||
|
||
|
||
async def rate_history(portfolio_code: str) -> list[FundingRateRow]:
|
||
"""История ставок портфеля, новые сверху."""
|
||
async with SessionLocal() as session:
|
||
portfolio_id = await _portfolio_id(session, portfolio_code)
|
||
rows = await FundingRateRepository(session).list_for_portfolio(portfolio_id)
|
||
return [
|
||
FundingRateRow(
|
||
currency=currency,
|
||
effective_from=row.effective_from,
|
||
rate=row.rate,
|
||
updated_by=row.updated_by,
|
||
updated_at=row.updated_at,
|
||
)
|
||
for row, currency in rows
|
||
]
|