DRRK/tests/unit/test_manual_values.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

262 lines
9.6 KiB
Python
Raw Permalink 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.

"""История номинала (D11) и ручные цена/доходность/дюрация (D6) на временном
SQLite. MOEX — фейк на реальной фикстуре РЭО-02 за 08.10.2026."""
from __future__ import annotations
from dataclasses import replace
from datetime import date, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import select
from app.db.base import run_db
from app.db.models import MarketData
from app.services import market_data, securities
from app.services.market_data import (
MANUAL_SOURCE,
ManualQuoteInput,
MarketDataError,
validate_manual,
)
from app.services.securities import ManualNominalInput, SecurityServiceError
from tests.unit.fakes import FakeMoex
ON_DATE = date(2026, 10, 8)
ISIN = "RU000A106169"
USER = "analyst@rshb.ru"
@pytest.fixture
def reo(session_local, fixture_moex: FakeMoex, add_trade) -> FakeMoex:
"""РЭО-02 в справочнике и в UW с начала года; котировки ещё не загружались."""
run_db(securities.add_by_isin(ISIN, USER, moex=fixture_moex))
add_trade(ISIN, "BUY", date(2026, 1, 1), "100")
return fixture_moex
def _load(
moex: FakeMoex, on_date: date, **changes
) -> market_data.MarketDataUpdateResult: # noqa: ANN003
quote = moex._history[ISIN]
moex._history[ISIN] = replace(quote, trade_date=on_date, **changes)
return run_db(market_data.update(on_date, "system", moex=moex))
def _history() -> list[tuple[date, Decimal, str]]:
rows = run_db(securities.nominal_history(ISIN))
return [(r.valid_from, r.face_value, r.source) for r in rows]
def _manual_nominal(valid_from: date, face_value: str, user: str = USER) -> None:
value = ManualNominalInput(ISIN, valid_from, Decimal(face_value), "погашение 10%")
run_db(securities.set_manual_nominal(value, user))
def _manual_quote(**changes) -> ManualQuoteInput: # noqa: ANN003
values = {
"isin": ISIN,
"market_date": ON_DATE,
"clean_price": None,
"yield_value": None,
"duration_days": None,
"reason": "сделок мало, цена с RFQ",
}
values.update(changes)
return ManualQuoteInput(**values)
# --- история номинала -------------------------------------------------------
def test_moex_nominal_is_recorded_only_when_changed(reo: FakeMoex) -> None:
_load(reo, ON_DATE)
_load(reo, ON_DATE) # повторная загрузка той же даты
_load(reo, date(2026, 10, 9)) # номинал тот же
assert _history() == [(ON_DATE, Decimal("645.19"), "MOEX")]
result = _load(reo, date(2026, 10, 12), face_value=Decimal("580.67"))
assert _history() == [
(date(2026, 10, 12), Decimal("580.67"), "MOEX"),
(ON_DATE, Decimal("645.19"), "MOEX"),
]
assert "Номинал изменился: 645.19 -> 580.67" in result.messages[0]
def test_earlier_date_loaded_later_gets_own_record(reo: FakeMoex) -> None:
_load(reo, ON_DATE)
_load(reo, date(2026, 10, 1), face_value=Decimal("1000"))
_load(reo, date(2026, 10, 2), face_value=Decimal("1000"))
assert _history() == [
(ON_DATE, Decimal("645.19"), "MOEX"),
(date(2026, 10, 1), Decimal("1000"), "MOEX"),
]
assert run_db(securities.nominal_on(ISIN, date(2026, 10, 5))) == Decimal("1000")
def test_manual_nominal_has_priority_until_next_moex_change(reo: FakeMoex) -> None:
_load(reo, ON_DATE)
_manual_nominal(ON_DATE, "650")
_load(reo, ON_DATE)
_load(reo, date(2026, 10, 9))
assert run_db(securities.nominal_on(ISIN, ON_DATE)) == Decimal("650")
assert run_db(securities.nominal_on(ISIN, date(2026, 10, 9))) == Decimal("650")
# До первой записи номинала нет (D11).
assert run_db(securities.nominal_on(ISIN, date(2026, 10, 7))) is None
_load(reo, date(2026, 10, 12), face_value=Decimal("580.67"))
assert run_db(securities.nominal_on(ISIN, date(2026, 10, 12))) == Decimal("580.67")
assert _history() == [
(date(2026, 10, 12), Decimal("580.67"), "MOEX"),
(ON_DATE, Decimal("650"), "ручной"),
(ON_DATE, Decimal("645.19"), "MOEX"),
]
def test_manual_nominal_same_date_is_replaced(reo: FakeMoex) -> None:
_manual_nominal(ON_DATE, "650")
_manual_nominal(ON_DATE, "640", user="head@rshb.ru")
[row] = run_db(securities.nominal_history(ISIN))
assert (row.face_value, row.source, row.updated_by) == (
Decimal("640"),
"ручной",
"head@rshb.ru",
)
assert row.reason == "погашение 10%"
@pytest.mark.parametrize(
("changes", "message"),
[
({"face_value": None}, "Укажите номинал"),
({"face_value": Decimal("0")}, "больше нуля"),
({"reason": " "}, "причину"),
({"valid_from": date.today() + timedelta(days=1)}, "в будущем"),
],
)
def test_manual_nominal_validation(reo: FakeMoex, changes: dict, message: str) -> None:
value = replace(
ManualNominalInput(ISIN, ON_DATE, Decimal("650"), "причина"), **changes
)
with pytest.raises(SecurityServiceError, match=message):
run_db(securities.set_manual_nominal(value, USER))
assert _history() == []
def test_manual_nominal_unknown_security(session_local) -> None:
value = ManualNominalInput("RU000A999999", ON_DATE, Decimal("650"), "причина")
with pytest.raises(SecurityServiceError, match="нет в справочнике"):
run_db(securities.set_manual_nominal(value, USER))
# --- ручные цена, доходность, дюрация ----------------------------------------
def test_manual_price_overrides_moex_and_survives_reload(
reo: FakeMoex, session_local
) -> None:
_load(reo, ON_DATE)
run_db(
market_data.set_manual_quote(_manual_quote(clean_price=Decimal("100.1")), USER)
)
_load(reo, ON_DATE, clean_price=Decimal("101.5"))
[row] = run_db(market_data.list_for_date(ON_DATE))
assert (row.clean_price, row.clean_price_source) == (
Decimal("100.1"),
MANUAL_SOURCE,
)
assert (row.yield_value, row.yield_source) == (Decimal("17.78"), "YIELDATWAP")
assert (row.duration_days, row.duration_source) == (Decimal("759"), "DURATION")
[manual] = run_db(market_data.list_manual_for_date(ON_DATE))
assert manual.clean_price == Decimal("100.1")
assert manual.moex_clean_price == Decimal("101.5")
assert manual.reason == "сделок мало, цена с RFQ"
assert manual.updated_by == USER
async def _moex_price() -> Decimal:
async with session_local() as session:
return (await session.execute(select(MarketData.clean_price))).scalar_one()
assert run_db(_moex_price()) == Decimal("101.5")
def test_manual_values_without_trades(reo: FakeMoex) -> None:
no_trades = date(2026, 10, 9)
run_db(
market_data.set_manual_quote(
_manual_quote(
market_date=no_trades,
clean_price=Decimal("101"),
duration_days=Decimal("758"),
),
USER,
)
)
[row] = run_db(market_data.list_for_date(no_trades))
assert row.board is None
assert (row.clean_price, row.clean_price_source) == (Decimal("101"), MANUAL_SOURCE)
assert (row.yield_value, row.yield_source) == (None, None)
assert (row.duration_days, row.duration_source) == (Decimal("758"), MANUAL_SOURCE)
[manual] = run_db(market_data.list_manual_for_date(no_trades))
assert manual.moex_clean_price is None
def test_manual_quote_edit_replaces_all_fields(reo: FakeMoex) -> None:
_load(reo, ON_DATE)
run_db(
market_data.set_manual_quote(_manual_quote(clean_price=Decimal("100.1")), USER)
)
run_db(
market_data.set_manual_quote(
_manual_quote(yield_value=Decimal("18.5"), reason="доходность по RFQ"),
"head@rshb.ru",
)
)
[row] = run_db(market_data.list_for_date(ON_DATE))
assert (row.clean_price, row.clean_price_source) == (Decimal("101.42"), "WAPRICE")
assert (row.yield_value, row.yield_source) == (Decimal("18.5"), MANUAL_SOURCE)
[manual] = run_db(market_data.list_manual_for_date(ON_DATE))
assert (manual.reason, manual.updated_by) == ("доходность по RFQ", "head@rshb.ru")
@pytest.mark.parametrize(
("changes", "message"),
[
({}, "Укажите цену, доходность или дюрацию."),
({"clean_price": Decimal("0")}, "Цена должна быть больше нуля."),
({"duration_days": Decimal("-1")}, "Дюрация не может быть отрицательной."),
({"yield_value": Decimal("-0.5"), "reason": ""}, "Укажите причину правки."),
(
{
"clean_price": Decimal("100"),
"market_date": date.today() + timedelta(days=1),
},
"Дата не может быть в будущем.",
),
],
)
def test_manual_quote_validation(changes: dict, message: str) -> None:
assert validate_manual(_manual_quote(**changes)) == [message]
def test_manual_quote_unknown_security_and_invalid_input(reo: FakeMoex) -> None:
with pytest.raises(MarketDataError, match="нет в справочнике"):
run_db(
market_data.set_manual_quote(
_manual_quote(isin="RU000A999999", clean_price=Decimal("100")), USER
)
)
with pytest.raises(MarketDataError, match="Укажите цену"):
run_db(market_data.set_manual_quote(_manual_quote(), USER))
assert run_db(market_data.list_manual_for_date(ON_DATE)) == []