"""Остатки выпусков в портфелях (D29) на временном SQLite.""" from __future__ import annotations from datetime import date from decimal import Decimal from pathlib import Path import pytest from sqlalchemy import select from app.db.base import run_db from app.db.models import Currency, Operation, Security from app.imports.quik import parse_quik_xlsx from app.repositories.reference import ReferenceRepository from app.services import positions, quik_import from app.services.positions import PositionError USER = "analyst@rshb.ru" REO = "RU000A106169" EVH = "RU000A10EVH2" @pytest.fixture def add(session_local): """add(портфель, ISIN, код операции, дата, количество) — операция напрямую в БД. Бумаги РЭО-02 и ЕврХол3P06 заводятся при первом вызове.""" names = {REO: "РЭО-02", EVH: "ЕврХол3P06"} async def _add( portfolio: str, isin: str, code: str, on_date: date, quantity: str | None ) -> None: async with session_local() as session: reference = ReferenceRepository(session) rub = ( await session.execute(select(Currency.id).where(Currency.code == "RUB")) ).scalar_one() security = ( await session.execute(select(Security).where(Security.isin == isin)) ).scalar_one_or_none() if security is None: security = Security( isin=isin, secid=isin, name=names[isin], currency_id=rub, created_by=USER, updated_by=USER, ) session.add(security) await session.flush() 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=rub, trade_date=on_date, quantity=Decimal(quantity) if quantity is not None else None, amount=Decimal("1000"), created_by=USER, updated_by=USER, ) ) await session.commit() return lambda *args: run_db(_add(*args)) def _rows(on_date: date, portfolio: str | None = None, **kwargs) -> list[tuple]: rows = run_db(positions.positions_on(on_date, portfolio, **kwargs)) return [(r.portfolio, r.isin, r.bought, r.sold, r.balance) for r in rows] def test_balance_is_bought_minus_sold_by_trade_date(add) -> None: add("UW", REO, "BUY", date(2026, 1, 1), "2834360") add("UW", REO, "SELL", date(2026, 3, 30), "1000000") add("UW", REO, "SELL", date(2026, 3, 31), "34360") assert _rows(date(2025, 12, 31)) == [] assert _rows(date(2026, 1, 1)) == [ ("UW", REO, Decimal("2834360"), Decimal("0"), Decimal("2834360")) ] assert _rows(date(2026, 3, 30)) == [ ("UW", REO, Decimal("2834360"), Decimal("1000000"), Decimal("1834360")) ] assert run_db(positions.balance_on("UW", REO, date(2026, 10, 8))) == Decimal( "1800000" ) def test_portfolios_are_separate(add) -> None: add("UW", REO, "BUY", date(2026, 1, 1), "100") add("SALES", REO, "BUY", date(2026, 2, 1), "40") add("SALES", REO, "SELL", date(2026, 2, 2), "10") add("SALES", EVH, "BUY", date(2026, 3, 30), "5") on_date = date(2026, 3, 30) assert [(p, i, b) for p, i, _, _, b in _rows(on_date)] == [ ("UW", REO, Decimal("100")), ("SALES", EVH, Decimal("5")), ("SALES", REO, Decimal("30")), ] assert [(p, i) for p, i, *_ in _rows(on_date, "SALES")] == [ ("SALES", EVH), ("SALES", REO), ] assert run_db(positions.balance_on("UW", EVH, on_date)) == Decimal("0") def test_other_operations_do_not_change_quantity(add) -> None: add("UW", REO, "BUY", date(2026, 1, 1), "100") add("UW", REO, "CPN", date(2026, 2, 1), "100") add("UW", REO, "NOM", date(2026, 2, 1), "100") add("UW", REO, "ADJ", date(2026, 2, 1), "-50") add("UW", REO, "UWFEE", date(2026, 2, 1), None) assert _rows(date(2026, 3, 1)) == [ ("UW", REO, Decimal("100"), Decimal("0"), Decimal("100")) ] def test_closed_positions_are_hidden_by_default(add) -> None: add("UW", REO, "BUY", date(2026, 1, 1), "100") add("UW", REO, "SELL", date(2026, 2, 1), "100") add("UW", EVH, "SELL", date(2026, 2, 1), "5") # покупка не загружена assert _rows(date(2026, 3, 1)) == [ ("UW", EVH, Decimal("0"), Decimal("5"), Decimal("-5")) ] assert _rows(date(2026, 3, 1), include_closed=True) == [ ("UW", EVH, Decimal("0"), Decimal("5"), Decimal("-5")), ("UW", REO, Decimal("100"), Decimal("100"), Decimal("0")), ] def test_unknown_portfolio(session_local) -> None: with pytest.raises(PositionError, match="Портфеля MM нет"): run_db(positions.positions_on(date(2026, 1, 1), "MM")) def test_balance_after_quik_import(add, fixtures_dir: Path) -> None: """Обезличенная выгрузка QUIK за 30.03.2026 в SALES: остаток сходится с суммой строк файла.""" add("UW", EVH, "BUY", date(2026, 3, 1), "1") # заводит бумагу; другой портфель parsed = parse_quik_xlsx( (fixtures_dir / "quik_sample_300326_anon.xlsx").read_bytes() ) context = run_db(quik_import.load_context(parsed)) plan = quik_import.build_plan(parsed.trades, context, "SALES", {}) run_db( quik_import.save_import( parsed, file_name="сделки_300326.xlsx", portfolio_code="SALES", decisions={}, expected_rows=[row.trade.ordinal for row in plan.to_load], user=USER, ) ) bought = sum(t.quantity for t in parsed.trades if t.operation == "BUY") sold = sum(t.quantity for t in parsed.trades if t.operation == "SELL") assert bought and sold assert _rows(date(2026, 3, 30), "SALES") == [ ("SALES", EVH, bought, sold, bought - sold) ] assert _rows(date(2026, 3, 29), "SALES") == []