"""История номинала (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)) == []