- 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
136 lines
4.4 KiB
Python
136 lines
4.4 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
FIXTURES = ROOT / "tests" / "fixtures"
|
||
FIXTURE_ISIN = "RU000A106169"
|
||
FIXTURE_DATE = date(2026, 10, 8)
|
||
|
||
|
||
@pytest.fixture
|
||
def fixtures_dir() -> Path:
|
||
"""Сохранённые реальные ответы MOEX ISS / ЦБ (байт в байт, см. .gitattributes)."""
|
||
return FIXTURES
|
||
|
||
|
||
@pytest.fixture
|
||
def session_local(tmp_path: Path, monkeypatch):
|
||
"""Временный SQLite со схемой из миграций (с сидами) вместо SessionLocal
|
||
во всех модулях сервисов. Postgres не нужен."""
|
||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||
from sqlalchemy.pool import NullPool
|
||
|
||
from alembic import command
|
||
from alembic.config import Config
|
||
from app.core.config import settings
|
||
from app.services import (
|
||
business_calendar,
|
||
funding,
|
||
fx_rates,
|
||
manual_operations,
|
||
market_data,
|
||
positions,
|
||
quik_import,
|
||
request_log,
|
||
securities,
|
||
)
|
||
|
||
db_url = f"sqlite+aiosqlite:///{(tmp_path / 'drrk.db').as_posix()}"
|
||
monkeypatch.setattr(settings, "DATABASE_URL", db_url)
|
||
monkeypatch.setattr(settings, "DATABASE_SCHEMA", "")
|
||
cfg = Config()
|
||
cfg.set_main_option("script_location", str(ROOT / "alembic"))
|
||
command.upgrade(cfg, "head")
|
||
|
||
factory = async_sessionmaker(
|
||
bind=create_async_engine(db_url, poolclass=NullPool),
|
||
autoflush=False,
|
||
expire_on_commit=False,
|
||
)
|
||
for module in (
|
||
business_calendar,
|
||
securities,
|
||
market_data,
|
||
fx_rates,
|
||
funding,
|
||
positions,
|
||
request_log,
|
||
quik_import,
|
||
manual_operations,
|
||
):
|
||
monkeypatch.setattr(module, "SessionLocal", factory)
|
||
return factory
|
||
|
||
|
||
@pytest.fixture
|
||
def add_trade(session_local):
|
||
"""add_trade(ISIN, код операции, дата, количество, портфель="UW") — сделка
|
||
напрямую в БД, без файла. Бумага уже в справочнике."""
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import select
|
||
|
||
from app.db.base import run_db
|
||
from app.db.models import Operation, Security
|
||
from app.repositories.reference import ReferenceRepository
|
||
|
||
async def _add(
|
||
isin: str, code: str, on_date: date, quantity: str, portfolio: str
|
||
) -> None:
|
||
async with session_local() as session:
|
||
reference = ReferenceRepository(session)
|
||
security = (
|
||
await session.execute(select(Security).where(Security.isin == isin))
|
||
).scalar_one()
|
||
session.add(
|
||
Operation(
|
||
security_id=security.id,
|
||
portfolio_id=await reference.get_portfolio_id(portfolio),
|
||
operation_type_id=(await reference.operation_type_ids())[code],
|
||
currency_id=security.currency_id,
|
||
trade_date=on_date,
|
||
quantity=Decimal(quantity),
|
||
amount=Decimal("1000"),
|
||
created_by="system",
|
||
updated_by="system",
|
||
)
|
||
)
|
||
await session.commit()
|
||
|
||
def add_trade(
|
||
isin: str, code: str, on_date: date, quantity: str, portfolio: str = "UW"
|
||
) -> None:
|
||
run_db(_add(isin, code, on_date, quantity, portfolio))
|
||
|
||
return add_trade
|
||
|
||
|
||
@pytest.fixture
|
||
def fixture_moex(fixtures_dir: Path):
|
||
"""Фейковый MOEX, отвечающий реальными фикстурами РЭО-02 за 08.10.2026."""
|
||
from app.integrations.moex import parse_history, parse_security
|
||
from tests.unit.fakes import FakeMoex, search_hit
|
||
|
||
security_payload = json.loads(
|
||
(fixtures_dir / "moex_security_RU000A106169.json").read_text("utf-8")
|
||
)
|
||
history_payload = json.loads(
|
||
(fixtures_dir / "moex_history_RU000A106169_2026-10-08.json").read_text("utf-8")
|
||
)
|
||
return FakeMoex(
|
||
search={FIXTURE_ISIN: search_hit(FIXTURE_ISIN, FIXTURE_ISIN)},
|
||
securities={FIXTURE_ISIN: parse_security(security_payload)},
|
||
history={
|
||
FIXTURE_ISIN: parse_history(history_payload, FIXTURE_DATE, ["TQCB", "TQOB"])
|
||
},
|
||
)
|