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