- 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
126 lines
4.3 KiB
Python
126 lines
4.3 KiB
Python
"""Ставки фондирования (D27) на временном SQLite."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from app.db.base import run_db
|
|
from app.services import funding
|
|
from app.services.funding import FundingError, FundingRateInput, validate_rate
|
|
|
|
USER = "analyst@rshb.ru"
|
|
|
|
|
|
def _set(
|
|
effective_from: date,
|
|
rate: str,
|
|
*,
|
|
portfolio: str = "UW",
|
|
currency: str = "RUB",
|
|
user: str = USER,
|
|
) -> None:
|
|
value = FundingRateInput(portfolio, currency, effective_from, Decimal(rate))
|
|
run_db(funding.set_rate(value, user))
|
|
|
|
|
|
def _on(on_date: date, portfolio: str = "UW", currency: str = "RUB") -> Decimal | None:
|
|
return run_db(funding.rate_on(portfolio, currency, on_date))
|
|
|
|
|
|
def test_rate_on_date_acts_until_next_change(session_local) -> None:
|
|
_set(date(2026, 1, 1), "21")
|
|
_set(date(2026, 7, 28), "18.5")
|
|
|
|
assert _on(date(2025, 12, 31)) is None # до первой ставки
|
|
assert _on(date(2026, 1, 1)) == Decimal("21")
|
|
assert _on(date(2026, 7, 27)) == Decimal("21")
|
|
assert _on(date(2026, 7, 28)) == Decimal("18.5")
|
|
assert _on(date(2026, 10, 8)) == Decimal("18.5")
|
|
|
|
|
|
def test_rates_are_separate_by_portfolio_and_currency(session_local) -> None:
|
|
_set(date(2026, 1, 1), "21")
|
|
_set(date(2026, 1, 1), "4.2", currency="CNY")
|
|
_set(date(2026, 1, 1), "20.5", portfolio="SALES")
|
|
|
|
assert _on(date(2026, 3, 1)) == Decimal("21")
|
|
assert _on(date(2026, 3, 1), currency="CNY") == Decimal("4.2")
|
|
assert _on(date(2026, 3, 1), portfolio="SALES") == Decimal("20.5")
|
|
assert _on(date(2026, 3, 1), portfolio="SALES", currency="CNY") is None
|
|
|
|
rates = run_db(funding.rates_on("UW", date(2026, 3, 1)))
|
|
assert [(r.currency, r.rate, r.effective_from) for r in rates] == [
|
|
("CNY", Decimal("4.2"), date(2026, 1, 1)),
|
|
("RUB", Decimal("21"), date(2026, 1, 1)),
|
|
("USD", None, None),
|
|
]
|
|
|
|
|
|
def test_same_date_is_replaced(session_local) -> None:
|
|
_set(date(2026, 1, 1), "21")
|
|
_set(date(2026, 1, 1), "21.25", user="head@rshb.ru")
|
|
|
|
history = run_db(funding.rate_history("UW"))
|
|
assert [(r.effective_from, r.rate, r.updated_by) for r in history] == [
|
|
(date(2026, 1, 1), Decimal("21.25"), "head@rshb.ru")
|
|
]
|
|
|
|
|
|
def test_history_newest_first(session_local) -> None:
|
|
_set(date(2026, 1, 1), "21")
|
|
_set(date(2026, 1, 1), "4.2", currency="CNY")
|
|
_set(date(2026, 7, 28), "18.5")
|
|
_set(date(2026, 1, 1), "20.5", portfolio="SALES")
|
|
|
|
history = run_db(funding.rate_history("UW"))
|
|
assert [(r.effective_from, r.currency, r.rate) for r in history] == [
|
|
(date(2026, 7, 28), "RUB", Decimal("18.5")),
|
|
(date(2026, 1, 1), "CNY", Decimal("4.2")),
|
|
(date(2026, 1, 1), "RUB", Decimal("21")),
|
|
]
|
|
assert {r.updated_by for r in history} == {USER}
|
|
|
|
|
|
def test_future_date_is_allowed(session_local) -> None:
|
|
_set(date(2027, 1, 1), "15")
|
|
|
|
assert _on(date(2026, 12, 31)) is None
|
|
assert _on(date(2027, 1, 1)) == Decimal("15")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("rate", "errors"),
|
|
[
|
|
(None, ["Укажите ставку."]),
|
|
(Decimal("-0.1"), ["Ставка не может быть отрицательной."]),
|
|
(Decimal("16.1234567"), ["Ставка — не больше 6 знаков после запятой."]),
|
|
(Decimal("0"), []),
|
|
(Decimal("16.123456"), []),
|
|
(Decimal("1E+1"), []),
|
|
],
|
|
)
|
|
def test_validate_rate(rate: Decimal | None, errors: list[str]) -> None:
|
|
value = FundingRateInput("UW", "RUB", date(2026, 1, 1), rate)
|
|
|
|
assert validate_rate(value) == errors
|
|
|
|
|
|
def test_invalid_rate_is_not_saved(session_local) -> None:
|
|
value = FundingRateInput("UW", "RUB", date(2026, 1, 1), Decimal("-1"))
|
|
|
|
with pytest.raises(FundingError, match="отрицательной"):
|
|
run_db(funding.set_rate(value, USER))
|
|
assert run_db(funding.rate_history("UW")) == []
|
|
|
|
|
|
def test_unknown_portfolio_or_currency(session_local) -> None:
|
|
with pytest.raises(FundingError, match="Портфеля MM нет"):
|
|
_set(date(2026, 1, 1), "21", portfolio="MM")
|
|
with pytest.raises(FundingError, match="Валюты EUR нет"):
|
|
_set(date(2026, 1, 1), "21", currency="EUR")
|
|
with pytest.raises(FundingError, match="Портфеля MM нет"):
|
|
run_db(funding.rates_on("MM", date(2026, 1, 1)))
|