DRRK/tests/unit/test_quik_save.py

345 lines
11 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.

"""Сохранение загрузки QUIK: import_batch и операции одной транзакцией
(D19, D20). Временный SQLite со схемой из миграций."""
from __future__ import annotations
import time as timer
from dataclasses import replace
from datetime import date, time
from decimal import Decimal
from pathlib import Path
import pytest
from sqlalchemy import func, select
from app.db.base import run_db
from app.db.models import (
Currency,
ImportBatch,
Operation,
OperationType,
Portfolio,
Security,
)
from app.imports.quik import QuikParseResult, parse_quik_xlsx
from app.repositories.operations import OperationRepository
from app.services import quik_import
from app.services.quik_import import (
EXCLUDE,
INCLUDE,
QuikImportError,
build_plan,
load_context,
save_import,
)
USER = "analyst@rshb.ru"
SHORT = "ЕврХол3P06"
ISIN = "RU000A10EVH2"
FILE_NAME = "сделки_300326.xlsx"
@pytest.fixture
def parsed(fixtures_dir: Path) -> QuikParseResult:
return parse_quik_xlsx((fixtures_dir / "quik_sample_300326_anon.xlsx").read_bytes())
@pytest.fixture
def security_id(session_local) -> int:
async def _add() -> int:
async with session_local() as session:
rub = (
await session.execute(select(Currency.id).where(Currency.code == "RUB"))
).scalar_one()
security = Security(
isin=ISIN,
secid=ISIN,
name=SHORT,
currency_id=rub,
created_by=USER,
updated_by=USER,
)
session.add(security)
await session.commit()
return security.id
return run_db(_add())
def _with_comments(parsed: QuikParseResult, comments: list[str]) -> QuikParseResult:
trades = [
replace(t, comment=c) for t, c in zip(parsed.trades, comments, strict=True)
]
return QuikParseResult(sheet=parsed.sheet, trades=trades)
def _expected(
parsed: QuikParseResult, code: str, decisions: dict[int, str]
) -> list[int]:
context = run_db(load_context(parsed))
plan = build_plan(parsed.trades, context, code, decisions)
return [row.trade.ordinal for row in plan.to_load]
def _save(
parsed: QuikParseResult,
code: str,
decisions: dict[int, str] | None = None,
expected: list[int] | None = None,
):
decisions = decisions or {}
if expected is None:
expected = _expected(parsed, code, decisions)
return run_db(
save_import(
parsed,
file_name=FILE_NAME,
portfolio_code=code,
decisions=decisions,
expected_rows=expected,
user=USER,
)
)
def _operations(session_local) -> list[tuple[Operation, str, str, str]]:
async def _read():
async with session_local() as session:
result = await session.execute(
select(Operation, OperationType.code, Portfolio.code, Currency.code)
.join(OperationType, OperationType.id == Operation.operation_type_id)
.join(Portfolio, Portfolio.id == Operation.portfolio_id)
.join(Currency, Currency.id == Operation.currency_id)
.order_by(Operation.id)
)
return [tuple(row) for row in result.all()]
return run_db(_read())
def _batches(session_local) -> list[ImportBatch]:
async def _read():
async with session_local() as session:
result = await session.execute(select(ImportBatch).order_by(ImportBatch.id))
return list(result.scalars().all())
return run_db(_read())
def _counts(session_local) -> tuple[int, int]:
async def _read():
async with session_local() as session:
batches = await session.scalar(select(func.count(ImportBatch.id)))
operations = await session.scalar(select(func.count(Operation.id)))
return batches, operations
return run_db(_read())
# --- успешная загрузка --------------------------------------------------------
def test_save_writes_batch_and_operations(
session_local, security_id: int, parsed: QuikParseResult
) -> None:
result = _save(parsed, "SALES")
assert (result.portfolio_code, result.rows_loaded, result.rows_excluded) == (
"SALES",
6,
0,
)
[batch] = _batches(session_local)
assert batch.id == result.batch_id
assert (batch.source, batch.file_name, batch.content_sha256) == (
"QUIK",
FILE_NAME,
parsed.content_hash,
)
assert (batch.date_from, batch.date_to) == (date(2026, 3, 30),) * 2
assert (batch.rows_total, batch.rows_loaded, batch.rows_excluded) == (6, 6, 0)
assert batch.loaded_rows == [1, 2, 3, 4, 5, 6]
assert batch.created_by == USER
rows = _operations(session_local)
assert [code for _, code, _, _ in rows] == [
"BUY",
"BUY",
"BUY",
"SELL",
"BUY",
"SELL",
]
assert {(portfolio, currency) for _, _, portfolio, currency in rows} == {
("SALES", "RUB")
}
assert [op.source_row for op, *_ in rows] == [1, 2, 3, 4, 5, 6]
first = rows[0][0]
assert first.security_id == security_id
assert first.import_batch_id == batch.id
assert (first.trade_date, first.trade_time, first.settlement_date) == (
date(2026, 3, 30),
time(13, 53, 10),
date(2026, 3, 30),
)
assert (first.quantity, first.price) == (Decimal("54784"), Decimal("100"))
assert first.amount == first.settlement_amount == Decimal("54784000")
assert first.amount_with_accrued_interest is None # Q17
assert first.trade_number is None # Q2
# D7: пять комиссий по отдельности.
assert first.commission_ts == Decimal("8217.6")
assert first.commission_clearing == Decimal("3492.48")
assert first.commission_exchange == Decimal("4725.12")
assert first.commission_tc == first.commission_broker == Decimal("0")
assert (first.account, first.comment, first.settlement_code) == (
"S00+00000000",
"/pp",
"Z0",
)
assert (first.order_number, first.counterparty) == ("10000000001", "Контрагент 1")
assert first.created_by == first.updated_by == USER
def test_file_split_between_portfolios(
session_local, security_id: int, parsed: QuikParseResult
) -> None:
"""Один файл — в оба портфеля по разу (D19). Строка без комментария,
отнесённая к SALES, во второй загрузке не берётся."""
mixed = _with_comments(parsed, ["/pp", "/pp", "/uw", "/trde", "", "/pp"])
sales = _save(mixed, "SALES", {4: EXCLUDE, 5: INCLUDE})
uw = _save(mixed, "UW", {4: EXCLUDE})
assert (sales.rows_loaded, sales.rows_excluded) == (4, 1)
assert (uw.rows_loaded, uw.rows_excluded) == (1, 1)
in_sales, in_uw = _batches(session_local)
assert in_sales.loaded_rows == [1, 2, 5, 6]
assert in_uw.loaded_rows == [3]
assert [
(op.source_row, portfolio) for op, _, portfolio, _ in _operations(session_local)
] == [(1, "SALES"), (2, "SALES"), (5, "SALES"), (6, "SALES"), (3, "UW")]
summary = build_plan(mixed.trades, run_db(load_context(mixed)), "UW", {}).summary
assert summary.loaded == {"UW": 1, "SALES": 4}
assert summary.nowhere == 1 # /trde исключена в обоих
def test_large_file_is_saved_in_one_go(
session_local, security_id: int, parsed: QuikParseResult
) -> None:
"""День размещения — 1000+ строк (до 1660 сделок по выпуску)."""
trades = [
replace(
parsed.trades[i % 6],
ordinal=i + 1,
row=i + 2,
order_number=str(20000000000 + i),
)
for i in range(1700)
]
big = QuikParseResult(sheet="Лист2", trades=trades)
started = timer.perf_counter()
result = _save(big, "SALES")
elapsed = timer.perf_counter() - started
assert result.rows_loaded == 1700
assert _counts(session_local) == (1, 1700)
assert elapsed < 30, elapsed
# --- отказы: ничего не записано ---------------------------------------------------
def test_same_file_same_portfolio_is_refused(
session_local, security_id: int, parsed: QuikParseResult
) -> None:
expected = _expected(parsed, "SALES", {})
_save(parsed, "SALES")
with pytest.raises(QuikImportError, match="уже загружен в SALES"):
_save(parsed, "SALES", expected=expected)
assert _counts(session_local) == (1, 6)
def test_concurrent_duplicate_is_caught_by_unique_index(
session_local, security_id: int, parsed: QuikParseResult, monkeypatch
) -> None:
"""Гонка: второй пользователь проверял файл до того, как первый сохранил.
План у него без повтора — отказ даёт уникальный индекс, без частичной записи."""
_save(parsed, "SALES")
original = quik_import._read_context
async def _stale(session, parsed_result): # noqa: ANN001, ANN202
context = await original(session, parsed_result)
context.same_file = []
return context
monkeypatch.setattr(quik_import, "_read_context", _stale)
with pytest.raises(QuikImportError, match="уже загружен в SALES"):
_save(parsed, "SALES", expected=[1, 2, 3, 4, 5, 6])
assert _counts(session_local) == (1, 6)
def test_failure_mid_save_rolls_back_everything(
session_local, security_id: int, parsed: QuikParseResult, monkeypatch
) -> None:
original = OperationRepository.add_many
async def _broken(self, rows): # noqa: ANN001, ANN202
await original(self, rows[:3])
raise RuntimeError("сбой посреди записи")
monkeypatch.setattr(OperationRepository, "add_many", _broken)
with pytest.raises(RuntimeError, match="сбой"):
_save(parsed, "SALES")
assert _counts(session_local) == (0, 0)
def test_refused_when_security_is_unknown(
session_local, parsed: QuikParseResult
) -> None:
with pytest.raises(QuikImportError, match=f"нет в справочнике бумаг: {SHORT}"):
_save(parsed, "SALES", expected=[1, 2, 3, 4, 5, 6])
assert _counts(session_local) == (0, 0)
def test_refused_while_rows_without_portfolio_are_pending(
session_local, security_id: int, parsed: QuikParseResult
) -> None:
mixed = _with_comments(parsed, ["/pp", "", "/pp", "/pp", "/pp", "/pp"])
with pytest.raises(QuikImportError, match="не решено строк без портфеля: 1"):
_save(mixed, "SALES")
assert _counts(session_local) == (0, 0)
def test_refused_when_preview_is_stale(
session_local, security_id: int, parsed: QuikParseResult
) -> None:
with pytest.raises(QuikImportError, match="изменились"):
_save(parsed, "SALES", expected=[1, 2])
assert _counts(session_local) == (0, 0)
def test_refused_for_file_with_errors(
session_local, security_id: int, parsed: QuikParseResult
) -> None:
from app.imports.quik import RowIssue
parsed.errors.append(RowIssue(3, "Цена", "не число"))
with pytest.raises(QuikImportError, match="с ошибками"):
_save(parsed, "SALES", expected=[1, 2, 3, 4, 5, 6])
assert _counts(session_local) == (0, 0)