261 lines
9.5 KiB
Python
261 lines
9.5 KiB
Python
"""История номинала (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) -> FakeMoex:
|
||
"""РЭО-02 в справочнике; котировки ещё не загружались."""
|
||
run_db(securities.add_by_isin(ISIN, USER, moex=fixture_moex))
|
||
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")
|
||
# До первой записи номинала нет (Q26).
|
||
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)) == []
|