DRRK/tests/unit/test_services.py

471 lines
16 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.

"""Сервисы на фейковых сессиях/репозиториях — по образцу
../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)
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 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, "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_without_active_securities(db: FakeDB) -> None:
moex = FakeMoex()
result = asyncio.run(market_data.update(ON_DATE, USER, moex=moex))
assert result.ok
assert result.messages == ["Нет активных бумаг для обновления."]
assert moex.calls == []
# --- курсы -------------------------------------------------------------------
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]