345 lines
11 KiB
Python
345 lines
11 KiB
Python
"""Сохранение загрузки 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)
|