DRRK/app/services/funding.py
Raykov-MS c1c73e96f5 Add funding rates, positions, business calendar and xlsx export
- 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
2026-10-29 11:20:15 +03:00

164 lines
5.8 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.

"""Ставки фондирования портфелей (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
]