"""Сервисы на фейковых сессиях/репозиториях — по образцу ../sfm/tests/unit/test_opencloud_token_store.py: подменяем SessionLocal и классы репозиториев в модулях сервисов, БД не нужна.""" from __future__ import annotations import asyncio from dataclasses import dataclass, field from datetime import date from decimal import Decimal from types import SimpleNamespace import pytest from app.integrations.cbr import FxRate from app.integrations.gateway import GatewayAuthError, GatewayUnavailableError from app.integrations.moex import MarketQuote, MoexDataError, SecurityInfo from app.services import fx_rates, market_data, request_log, securities from app.services.dates import previous_business_day from app.services.securities import SecurityServiceError from tests.unit.fakes import FakeCbr, FakeMoex, search_hit ON_DATE = date(2026, 10, 8) USER = "analyst@rshb.ru" # --- фейковая БД ------------------------------------------------------------- @dataclass class FakeDB: currencies: dict[str, int] = field( default_factory=lambda: {"RUB": 1, "CNY": 2, "USD": 3} ) securities: dict[str, SimpleNamespace] = field(default_factory=dict) market_data: dict[tuple[int, date], tuple[MarketQuote, str]] = field( default_factory=dict ) nominals: dict[tuple[int, date], Decimal] = field(default_factory=dict) rates: dict[date, tuple[FxRate, str]] = field(default_factory=dict) request_log: list = field(default_factory=list) commits: int = 0 fail_upsert_for: set[int] = field(default_factory=set) closed: set[int] = field(default_factory=set) # остаток 0 на дату (D10) def add_security(self, isin: str, secid: str, name: str, is_active=True): security = SimpleNamespace( id=len(self.securities) + 1, isin=isin, secid=secid, name=name, is_active=is_active, ) self.securities[isin] = security return security class FakeSession: def __init__(self, db: FakeDB) -> None: self.db = db async def commit(self) -> None: self.db.commits += 1 async def __aenter__(self) -> FakeSession: return self async def __aexit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001 return False class FakeSessionLocal: def __init__(self, db: FakeDB) -> None: self.db = db def __call__(self) -> FakeSession: return FakeSession(self.db) class FakeReferenceRepository: def __init__(self, session: FakeSession) -> None: self.db = session.db async def get_currency_id(self, code: str) -> int | None: return self.db.currencies.get(code) class FakeSecurityRepository: def __init__(self, session: FakeSession) -> None: self.db = session.db async def get_by_isin(self, isin: str): return self.db.securities.get(isin) async def list_active(self): return [s for s in self.db.securities.values() if s.is_active] async def add(self, isin: str, info: SecurityInfo, currency_id: int, user: str): security = self.db.add_security(isin, info.secid, info.short_name or info.secid) security.currency_id = currency_id security.created_by = user return security class FakeOperationRepository: def __init__(self, session: FakeSession) -> None: self.db = session.db async def open_security_ids(self, on_date: date) -> list[int]: return [s.id for s in self.db.securities.values() if s.id not in self.db.closed] class FakeMarketDataRepository: def __init__(self, session: FakeSession) -> None: self.db = session.db async def upsert(self, security_id: int, quote: MarketQuote, user: str) -> None: if security_id in self.db.fail_upsert_for: raise RuntimeError("БД недоступна") self.db.market_data[(security_id, quote.trade_date)] = (quote, user) class FakeNominalHistoryRepository: def __init__(self, session: FakeSession) -> None: self.db = session.db async def last_moex_on(self, security_id: int, on_date: date): dates = [d for (s, d) in self.db.nominals if s == security_id and d <= on_date] if not dates: return None return SimpleNamespace(face_value=self.db.nominals[(security_id, max(dates))]) async def upsert(self, *, security_id, valid_from, face_value, **_) -> None: self.db.nominals[(security_id, valid_from)] = face_value class FakeExchangeRateRepository: def __init__(self, session: FakeSession) -> None: self.db = session.db async def last_rate_date(self, currency_id: int) -> date | None: return max(self.db.rates, default=None) async def upsert_many(self, currency_id: int, rates, user: str) -> None: for rate in rates: self.db.rates[rate.rate_date] = (rate, user) class FakeRequestLogRepository: def __init__(self, session: FakeSession) -> None: self.db = session.db async def add_many(self, records, user: str) -> None: self.db.request_log.extend((record, user) for record in records) @pytest.fixture def db(monkeypatch) -> FakeDB: fake_db = FakeDB() session_local = FakeSessionLocal(fake_db) for module in (securities, market_data, fx_rates, request_log): monkeypatch.setattr(module, "SessionLocal", session_local) monkeypatch.setattr(securities, "SecurityRepository", FakeSecurityRepository) monkeypatch.setattr(securities, "ReferenceRepository", FakeReferenceRepository) monkeypatch.setattr(market_data, "SecurityRepository", FakeSecurityRepository) monkeypatch.setattr(market_data, "MarketDataRepository", FakeMarketDataRepository) monkeypatch.setattr(market_data, "OperationRepository", FakeOperationRepository) monkeypatch.setattr( market_data, "NominalHistoryRepository", FakeNominalHistoryRepository ) monkeypatch.setattr(fx_rates, "ReferenceRepository", FakeReferenceRepository) monkeypatch.setattr(fx_rates, "ExchangeRateRepository", FakeExchangeRateRepository) monkeypatch.setattr(request_log, "RequestLogRepository", FakeRequestLogRepository) return fake_db # --- данные ------------------------------------------------------------------ def _info(secid: str, isin: str, face_unit: str = "SUR", name: str = "РЭО-02"): return SecurityInfo( isin=isin, secid=secid, short_name=name, initial_face_value=Decimal("1000"), face_value=Decimal("645.19"), face_unit=face_unit, issue_date=date(2023, 3, 30), mat_date=date(2031, 12, 20), coupon_percent=Decimal("17.35"), coupon_frequency=4, ) def _quote(secid: str, price: str = "101.42") -> MarketQuote: return MarketQuote( trade_date=ON_DATE, board="TQCB", secid=secid, clean_price=Decimal(price), clean_price_source="WAPRICE", yield_value=Decimal("17.78"), yield_source="YIELDATWAP", duration_days=Decimal("759"), accrued_interest=Decimal("3.68"), face_value=Decimal("645.19"), coupon_percent=Decimal("17.35"), num_trades=3, volume=Decimal("15"), raw={}, ) def _rate(day: date, value: str) -> FxRate: return FxRate( rate_date=day, nominal=1, value=Decimal(value), rate_per_unit=Decimal(value) ) # --- дата отчёта ------------------------------------------------------------- @pytest.mark.parametrize( ("today", "expected"), [ (date(2026, 10, 9), date(2026, 10, 8)), # пт -> чт (date(2026, 10, 12), date(2026, 10, 9)), # пн -> пт (date(2026, 10, 11), date(2026, 10, 9)), # вс -> пт (date(2026, 10, 10), date(2026, 10, 9)), # сб -> пт ], ) def test_previous_business_day(today: date, expected: date) -> None: assert previous_business_day(today) == expected # --- бумаги ------------------------------------------------------------------ def test_add_by_isin_two_step_lookup_and_save(db: FakeDB) -> None: moex = FakeMoex( search={"RU000A106169": search_hit("RU000A106169", "RU000A106169")}, securities={"RU000A106169": _info("RU000A106169", "RU000A106169")}, ) result = asyncio.run(securities.add_by_isin(" ru000a106169 ", USER, moex=moex)) assert result.isin == "RU000A106169" assert result.name == "РЭО-02" saved = db.securities["RU000A106169"] assert saved.currency_id == db.currencies["RUB"] # SUR -> RUB assert saved.created_by == USER assert moex.calls == [ ("find_by_isin", "RU000A106169"), ("get_security", "RU000A106169"), ] assert len(db.request_log) == 2 assert {user for _, user in db.request_log} == {USER} def test_add_ofz_stores_isin_and_different_secid(db: FakeDB) -> None: moex = FakeMoex( search={"RU000A1038V6": search_hit("SU26238RMFS4", "RU000A1038V6")}, securities={ "SU26238RMFS4": _info("SU26238RMFS4", "RU000A1038V6", name="ОФЗ 26238") }, ) result = asyncio.run(securities.add_by_isin("RU000A1038V6", USER, moex=moex)) assert (result.isin, result.secid) == ("RU000A1038V6", "SU26238RMFS4") assert ("get_security", "SU26238RMFS4") in moex.calls def test_add_existing_isin_is_rejected_without_moex_calls(db: FakeDB) -> None: db.add_security("RU000A106169", "RU000A106169", "РЭО-02") moex = FakeMoex() with pytest.raises(SecurityServiceError, match="уже добавлена"): asyncio.run(securities.add_by_isin("RU000A106169", USER, moex=moex)) assert moex.calls == [] def test_add_unknown_isin_is_not_found_on_moex(db: FakeDB) -> None: moex = FakeMoex() with pytest.raises(SecurityServiceError, match="не найдена на Мосбирже"): asyncio.run(securities.add_by_isin("RU000A999999", USER, moex=moex)) assert db.securities == {} assert len(db.request_log) == 1 # поиск всё равно журналируется def test_add_when_search_found_but_description_empty(db: FakeDB) -> None: moex = FakeMoex(search={"RU000A106169": search_hit("RU000A106169", "RU000A106169")}) with pytest.raises(SecurityServiceError, match="не найдена на Мосбирже"): asyncio.run(securities.add_by_isin("RU000A106169", USER, moex=moex)) @pytest.mark.parametrize("isin", ["", "RU000A10616", "RU000A1061699", "12000A106169"]) def test_add_invalid_isin(db: FakeDB, isin: str) -> None: moex = FakeMoex() with pytest.raises(SecurityServiceError, match="ISIN"): asyncio.run(securities.add_by_isin(isin, USER, moex=moex)) assert moex.calls == [] def test_add_gateway_error_is_user_friendly_and_logged(db: FakeDB) -> None: error = GatewayAuthError("Шлюз отказал в доступе", source="moex", endpoint="/x") moex = FakeMoex(search={"RU000A106169": error}) with pytest.raises(SecurityServiceError, match="Не удалось получить данные MOEX"): asyncio.run(securities.add_by_isin("RU000A106169", USER, moex=moex)) assert len(db.request_log) == 1 def test_add_unknown_face_unit(db: FakeDB) -> None: moex = FakeMoex( search={"XS0000000001": search_hit("XS0000000001", "XS0000000001")}, securities={"XS0000000001": _info("XS0000000001", "XS0000000001", "EUR")}, ) with pytest.raises(SecurityServiceError, match="EUR"): asyncio.run(securities.add_by_isin("XS0000000001", USER, moex=moex)) assert db.securities == {} # --- котировки --------------------------------------------------------------- def test_market_update_counts_and_continues_after_errors(db: FakeDB) -> None: reo = db.add_security("RU000A106169", "RU000A106169", "РЭО-02") db.add_security("RU000A1038V6", "SU26238RMFS4", "ОФЗ 26238") db.add_security("RU000A100000", "RU000A100000", "Сломанная") db.add_security("RU000A111111", "RU000A111111", "Неактивная", is_active=False) moex = FakeMoex( history={ "RU000A106169": _quote("RU000A106169"), "SU26238RMFS4": None, "RU000A100000": MoexDataError("только режим PACT"), } ) result = asyncio.run(market_data.update(ON_DATE, "system", moex=moex)) assert (result.updated, result.no_trades, result.errors) == (1, 1, 1) assert not result.ok assert len(result.messages) == 3 assert any("торгов за 08.10.2026 не было" in m for m in result.messages) assert any("PACT" in m for m in result.messages) assert db.market_data[(reo.id, ON_DATE)][1] == "system" # История — по SECID, не по ISIN; неактивные не запрашиваются. requested = [call[1] for call in moex.calls] assert requested == ["RU000A106169", "SU26238RMFS4", "RU000A100000"] assert len(db.request_log) == 3 def test_market_update_db_error_on_one_security_does_not_stop_others( db: FakeDB, ) -> None: first = db.add_security("RU000A106169", "RU000A106169", "РЭО-02") second = db.add_security("RU000A1038V6", "SU26238RMFS4", "ОФЗ 26238") db.fail_upsert_for.add(first.id) moex = FakeMoex( history={ "RU000A106169": _quote("RU000A106169"), "SU26238RMFS4": _quote("SU26238RMFS4", "95.10"), } ) result = asyncio.run(market_data.update(ON_DATE, "system", moex=moex)) assert (result.updated, result.errors) == (1, 1) assert (second.id, ON_DATE) in db.market_data assert "непредвиденная ошибка" in result.messages[0] def test_market_update_transient_gateway_error_is_per_security(db: FakeDB) -> None: db.add_security("RU000A106169", "RU000A106169", "РЭО-02") db.add_security("RU000A1038V6", "SU26238RMFS4", "ОФЗ 26238") moex = FakeMoex( history={ "RU000A106169": GatewayUnavailableError( "MOEX временно недоступен", source="moex", endpoint="/x" ), "SU26238RMFS4": _quote("SU26238RMFS4"), } ) result = asyncio.run(market_data.update(ON_DATE, "system", moex=moex)) assert (result.updated, result.errors) == (1, 1) def test_market_update_auth_error_aborts_remaining(db: FakeDB) -> None: for i in range(3): db.add_security(f"RU000A10000{i}", f"RU000A10000{i}", f"Бумага {i}") error = GatewayAuthError("Шлюз отказал в доступе", source="moex", endpoint="/x") moex = FakeMoex(history={"RU000A100000": error}) result = asyncio.run(market_data.update(ON_DATE, "system", moex=moex)) assert (result.updated, result.no_trades, result.errors) == (0, 0, 3) assert len(moex.calls) == 1 assert "прервано" in result.messages[-1] def test_market_update_only_open_positions(db: FakeDB) -> None: db.add_security("RU000A106169", "RU000A106169", "РЭО-02") closed = db.add_security("RU000A1038V6", "SU26238RMFS4", "ОФЗ 26238") db.closed.add(closed.id) moex = FakeMoex(history={"RU000A106169": _quote("RU000A106169")}) result = asyncio.run(market_data.update(ON_DATE, "system", moex=moex)) assert (result.updated, result.no_trades, result.errors) == (1, 0, 0) assert [call[1] for call in moex.calls] == ["RU000A106169"] def test_market_update_without_open_positions(db: FakeDB) -> None: security = db.add_security("RU000A106169", "RU000A106169", "РЭО-02") db.closed.add(security.id) moex = FakeMoex() result = asyncio.run(market_data.update(ON_DATE, USER, moex=moex)) assert result.ok assert result.messages == [ "Нет бумаг с открытой позицией на 08.10.2026, котировки не запрашивались." ] assert moex.calls == [] assert db.request_log == [] # --- курсы ------------------------------------------------------------------- def test_fx_update_starts_from_january_first_when_empty(db: FakeDB) -> None: cbr = FakeCbr([_rate(date(2026, 1, 13), "11.1"), _rate(ON_DATE, "12.7516")]) result = asyncio.run(fx_rates.update(ON_DATE, USER, cbr=cbr)) assert cbr.calls == [(date(2026, 1, 1), ON_DATE, "R01375")] assert result.ok assert result.saved == 2 assert result.date_from == date(2026, 1, 1) assert db.rates[ON_DATE][1] == USER assert "12.7516" in result.messages[0] assert len(db.request_log) == 1 def test_fx_update_continues_from_day_after_last_saved(db: FakeDB) -> None: db.rates[date(2026, 10, 3)] = (_rate(date(2026, 10, 3), "12.4328"), "system") cbr = FakeCbr([_rate(date(2026, 10, 6), "12.6491"), _rate(ON_DATE, "12.7516")]) result = asyncio.run(fx_rates.update(ON_DATE, "system", cbr=cbr)) assert cbr.calls == [(date(2026, 10, 4), ON_DATE, "R01375")] assert result.saved == 2 def test_fx_update_nothing_to_do_does_not_call_cbr(db: FakeDB) -> None: db.rates[ON_DATE] = (_rate(ON_DATE, "12.7516"), "system") cbr = FakeCbr([]) result = asyncio.run(fx_rates.update(ON_DATE, "system", cbr=cbr)) assert result.ok assert cbr.calls == [] assert "обновлять нечего" in result.messages[0] def test_fx_update_cbr_error_is_reported(db: FakeDB) -> None: error = GatewayAuthError( "Шлюз отказал в доступе к CBR", source="cbr", endpoint="/x" ) cbr = FakeCbr(error) result = asyncio.run(fx_rates.update(ON_DATE, "system", cbr=cbr)) assert not result.ok assert "Шлюз отказал" in result.error assert db.rates == {} assert len(db.request_log) == 1 def test_fx_update_empty_answer(db: FakeDB) -> None: result = asyncio.run(fx_rates.update(ON_DATE, "system", cbr=FakeCbr([]))) assert result.ok assert result.saved == 0 assert "не вернул" in result.messages[0]