- 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
498 lines
18 KiB
Python
498 lines
18 KiB
Python
"""Сервисы на фейковых сессиях/репозиториях — по образцу
|
||
../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]
|