DRRK/tests/unit/test_services_sqlite.py
Raykov-MS c1c73e96f5 Add funding rates, positions, business calendar and xlsx export
- 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
2026-10-29 11:20:15 +03:00

178 lines
6.6 KiB
Python
Raw 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.

"""Сервисы + настоящие репозитории на временном SQLite (схема — миграцией
Alembic, с сидами). Внешние API — фейки на реальных фикстурах MOEX/ЦБ."""
from __future__ import annotations
from dataclasses import replace
from datetime import date
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import pytest
from sqlalchemy import func, select
from app.db.base import run_db
from app.db.models import ExchangeRate, ExternalRequestLog, MarketData
from app.integrations.cbr import parse_dynamic
from app.services import fx_rates, market_data, securities
from tests.unit.fakes import FakeCbr, FakeMoex
ON_DATE = date(2026, 10, 8)
ISIN = "RU000A106169"
def _count(session_local, model) -> int:
async def _q() -> int:
async with session_local() as session:
return (
await session.execute(select(func.count()).select_from(model))
).scalar_one()
return run_db(_q())
def test_full_flow_security_quotes_and_rates(
session_local, fixture_moex: FakeMoex, fixtures_dir: Path, add_trade
) -> None:
added = run_db(securities.add_by_isin(ISIN, "analyst@rshb.ru", moex=fixture_moex))
rows = run_db(securities.list_securities())
assert added.security_id == 1
assert [(r.isin, r.name, r.currency, r.coupon_frequency) for r in rows] == [
(ISIN, "РЭО-02", "RUB", 4)
]
assert rows[0].initial_face_value == Decimal("1000")
add_trade(ISIN, "BUY", date(2026, 10, 1), "100")
result = run_db(market_data.update(ON_DATE, "system", moex=fixture_moex))
assert (result.updated, result.no_trades, result.errors) == (1, 0, 0)
[quote_row] = run_db(market_data.list_for_date(ON_DATE))
assert quote_row.board == "TQCB"
assert quote_row.clean_price == Decimal("101.42")
assert quote_row.yield_value == Decimal("17.78")
assert quote_row.face_value == Decimal("645.19")
assert quote_row.num_trades == 3
cbr = FakeCbr(parse_dynamic((fixtures_dir / "cbr_dynamic_R01375.xml").read_bytes()))
fx_result = run_db(fx_rates.update(ON_DATE, "system", cbr=cbr))
assert fx_result.saved == 6
recent = run_db(fx_rates.list_recent(limit=3))
assert [r.rate_date for r in recent] == [
date(2026, 10, 8),
date(2026, 10, 7),
date(2026, 10, 6),
]
assert recent[0].rate_per_unit == Decimal("12.7516")
again = run_db(fx_rates.update(ON_DATE, "system", cbr=cbr))
assert again.saved == 0 and len(cbr.calls) == 1
# 2 запроса при добавлении + 1 история + 1 ЦБ
assert _count(session_local, ExternalRequestLog) == 4
def test_market_data_reload_is_upsert(
session_local, fixture_moex: FakeMoex, add_trade
) -> None:
run_db(securities.add_by_isin(ISIN, "analyst@rshb.ru", moex=fixture_moex))
add_trade(ISIN, "BUY", date(2026, 10, 1), "100")
run_db(market_data.update(ON_DATE, "analyst@rshb.ru", moex=fixture_moex))
first_quote = fixture_moex._history[ISIN]
fixture_moex._history[ISIN] = replace(first_quote, clean_price=Decimal("100.5"))
result = run_db(market_data.update(ON_DATE, "system", moex=fixture_moex))
async def _rows():
async with session_local() as session:
return (await session.execute(select(MarketData))).scalars().all()
[row] = run_db(_rows())
assert result.updated == 1
assert row.clean_price == Decimal("100.5")
assert row.created_by == "analyst@rshb.ru"
assert row.updated_by == "system"
def test_market_update_skips_closed_positions(
session_local, fixture_moex: FakeMoex, add_trade
) -> None:
"""D10: остаток 0 или покупка позже даты — MOEX не запрашиваем."""
run_db(securities.add_by_isin(ISIN, "analyst@rshb.ru", moex=fixture_moex))
add_trade(ISIN, "BUY", date(2026, 10, 1), "100")
add_trade(ISIN, "SELL", date(2026, 10, 5), "100")
add_trade(ISIN, "BUY", date(2026, 10, 9), "50", "SALES")
calls_before = len(fixture_moex.calls)
result = run_db(market_data.update(ON_DATE, "system", moex=fixture_moex))
assert (result.updated, result.no_trades, result.errors) == (0, 0, 0)
assert "Нет бумаг с открытой позицией на 08.10.2026" in result.messages[0]
assert len(fixture_moex.calls) == calls_before
result = run_db(market_data.update(date(2026, 10, 2), "system", moex=fixture_moex))
assert result.updated + result.no_trades == 1
def test_exchange_rate_reload_is_upsert(session_local, fixtures_dir: Path) -> None:
rates = parse_dynamic((fixtures_dir / "cbr_dynamic_R01375.xml").read_bytes())
run_db(fx_rates.update(date(2026, 10, 7), "system", cbr=FakeCbr(rates)))
# Тот же день повторно (например, после ручной правки последней даты).
async def _drop_last() -> None:
async with session_local() as session:
row = (
await session.execute(
select(ExchangeRate).where(
ExchangeRate.rate_date == date(2026, 10, 7)
)
)
).scalar_one()
await session.delete(row)
await session.commit()
run_db(_drop_last())
run_db(fx_rates.update(ON_DATE, "system", cbr=FakeCbr(rates)))
assert _count(session_local, ExchangeRate) == 6
def test_duplicate_isin_is_rejected_by_service(
session_local, fixture_moex: FakeMoex
) -> None:
run_db(securities.add_by_isin(ISIN, "analyst@rshb.ru", moex=fixture_moex))
with pytest.raises(securities.SecurityServiceError, match="уже добавлена"):
run_db(securities.add_by_isin(ISIN, "analyst@rshb.ru", moex=fixture_moex))
def test_request_log_saves_gravitee_transaction_id(session_local) -> None:
from app.integrations.gateway import RequestRecord
from app.services import request_log
record = RequestRecord(
source="moex",
endpoint="/external/moex-iss/iss/securities.json",
params={"q": ISIN},
http_status=200,
duration_ms=12,
ok=True,
error=None,
transaction_id="0b5c7d3e-tx",
)
gateway = SimpleNamespace(drain_records=lambda: [record])
run_db(request_log.save_request_log(gateway, "system"))
async def _saved() -> list[str | None]:
async with session_local() as session:
return list(
(
await session.execute(
select(ExternalRequestLog.gateway_transaction_id)
)
).scalars()
)
assert run_db(_saved()) == ["0b5c7d3e-tx"]