DRRK/tests/unit/test_positions.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

169 lines
6.3 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.

"""Остатки выпусков в портфелях (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") == []