Files
fin-tracker/backend/tests/api/test_events_manual_api.py
Dmitry f1f336f94f feat(api): портфели, ручные события, правка инструментов и обзор по скоупам
CRUD портфелей (/portfolios), ручные события (POST /events, DELETE только для manual), PATCH /instruments/{id}, GET /analytics/overview — карточка на каждый скоуп одним запросом. Холдинги отдают logo_url и logo_color. openapi.json обновлён.
2026-09-19 22:13:55 +03:00

195 lines
7.6 KiB
Python
Raw Permalink 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.
"""Events entered by hand: derived signs, validation, and deletion of only the manual ones."""
from datetime import date
from decimal import Decimal
import pytest
from factories import make_account, make_event, make_instrument, refresh
from fintracker.models import AccountKind, AccountRole, EventKind
URL = "/api/v1/events"
D = Decimal
DAY = "2026-03-10"
@pytest.fixture
async def broker(app) -> int:
return await make_account(
name="Брокер", kind=AccountKind.broker, role=AccountRole.investment, balance=None
)
async def post(client, headers, **body):
return await client.post(URL, json=body, headers=headers)
async def test_buy_adds_the_position_and_takes_cash_including_the_fee(client, auth_headers, broker):
sber = await make_instrument(ticker="SBER")
r = await post(
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
instrument_id=sber, quantity="10", price="100", fee="5",
) # fmt: skip
assert r.status_code == 201, r.text
e = r.json()
assert D(e["quantity"]) == 10
assert D(e["amount"]) == -1005
assert (e["status"], e["ticker"], e["currency"]) == ("confirmed", "SBER", "RUB")
assert e["source"] == "manual"
assert D(e["fee"]) == 5
async def test_sell_removes_from_the_position_and_brings_cash_less_the_fee(
client, auth_headers, broker
):
sber = await make_instrument(ticker="SBER")
r = await post(
client, auth_headers, account_id=broker, kind="sell", trade_date=DAY,
instrument_id=sber, quantity="4", price="120", fee="2",
) # fmt: skip
e = r.json()
assert D(e["quantity"]) == -4
assert D(e["amount"]) == 478 # 4 × 120 2
async def test_a_bond_purchase_adds_accrued_interest(client, auth_headers, broker):
bond = await make_instrument(ticker="SU26207", name="ОФЗ")
r = await post(
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
instrument_id=bond, quantity="50", price="967.94", accrued_interest="804",
) # fmt: skip
assert D(r.json()["amount"]) == D("-49201.00") # 50 × 967.94 + 804
async def test_an_explicit_total_wins_over_the_computed_one(client, auth_headers, broker):
sber = await make_instrument(ticker="SBER")
r = await post(
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
instrument_id=sber, quantity="10", amount="1007.31",
) # fmt: skip
assert r.status_code == 201
assert D(r.json()["amount"]) == D("-1007.31")
async def test_cash_events_are_signed_by_their_kind(client, auth_headers, broker):
sber = await make_instrument(ticker="SBER")
expected = {
"deposit": 500,
"withdrawal": -500,
"commission": -500,
"tax": -500,
"tax_refund": 500,
}
for kind, amount in expected.items():
r = await post(
client, auth_headers, account_id=broker, kind=kind, trade_date=DAY, amount="500"
)
assert r.status_code == 201, (kind, r.text)
assert D(r.json()["amount"]) == amount, kind
r = await post(
client, auth_headers, account_id=broker, kind="dividend", trade_date=DAY,
instrument_id=sber, amount="87.5",
) # fmt: skip
assert D(r.json()["amount"]) == D("87.5")
assert r.json()["quantity"] is None
async def test_a_securities_transfer_moves_the_position_without_cash(client, auth_headers, broker):
five = await make_instrument(ticker="FIVE")
x5 = await make_instrument(ticker="X5")
out = await post(
client, auth_headers, account_id=broker, kind="transfer_out", trade_date=DAY,
instrument_id=five, quantity="3",
) # fmt: skip
into = await post(
client, auth_headers, account_id=broker, kind="transfer_in", trade_date=DAY,
instrument_id=x5, quantity="3",
) # fmt: skip
assert (D(out.json()["quantity"]), D(out.json()["amount"])) == (-3, 0)
assert (D(into.json()["quantity"]), D(into.json()["amount"])) == (3, 0)
async def test_a_manual_purchase_opens_a_lot_and_a_deposit_is_a_flow(client, auth_headers, broker):
sber = await make_instrument(ticker="SBER")
await post(
client, auth_headers, account_id=broker, kind="deposit", trade_date=DAY, amount="2000"
)
await post(
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
instrument_id=sber, quantity="10", price="100",
) # fmt: skip
await refresh()
detail = (await client.get(f"/api/v1/instruments/{sber}", headers=auth_headers)).json()
assert [D(lot["qty_remaining"]) for lot in detail["lots"]] == [10]
listed = (await client.get(URL, params={"account_id": broker}, headers=auth_headers)).json()
flows = [e for e in listed["items"] if e["external_flow"]]
assert [e["kind"] for e in flows] == ["deposit"]
@pytest.mark.parametrize(
("body", "status"),
[
({"kind": "buy", "quantity": "-1", "price": "1"}, 422), # sign comes from the kind
({"kind": "buy", "price": "1"}, 422), # no quantity
({"kind": "buy", "quantity": "1"}, 422), # neither price nor total
({"kind": "deposit"}, 422), # no amount
({"kind": "deposit", "amount": "0"}, 422),
({"kind": "deposit", "amount": "-5"}, 422),
({"kind": "split", "quantity": "2"}, 422), # not typed by hand
({"kind": "fx_exchange", "amount": "5"}, 422),
({"kind": "transfer_in", "quantity": "1"}, 422), # a transfer needs an instrument
({"kind": "dividend", "amount": "5"}, 422), # so does a dividend
],
)
async def test_invalid_events_are_refused(client, auth_headers, broker, body, status):
r = await post(client, auth_headers, account_id=broker, trade_date=DAY, **body)
assert r.status_code == status, r.text
assert r.headers["content-type"].startswith("application/problem+json")
assert (await client.get(URL, headers=auth_headers)).json()["total"] == 0
async def test_account_and_instrument_must_exist_and_fit(client, auth_headers, broker):
sber = await make_instrument(ticker="SBER")
card = await make_account(name="Карта") # a ZenMoney account has no ledger
r = await post(
client, auth_headers, account_id=card, kind="deposit", trade_date=DAY, amount="5"
)
assert r.status_code == 400
r = await post(
client, auth_headers, account_id=99999, kind="deposit", trade_date=DAY, amount="5"
)
assert r.status_code == 400
r = await post(
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
instrument_id=99999, quantity="1", price="1",
) # fmt: skip
assert r.status_code == 400
r = await post(
client, auth_headers, account_id=broker, kind="deposit", trade_date=DAY,
instrument_id=sber, amount="5",
) # fmt: skip
assert r.status_code == 422
async def test_only_manual_events_can_be_deleted(client, auth_headers, broker):
broker_event = await make_event(
date(2026, 3, 1), account_id=broker, kind=EventKind.deposit, amount=100
)
mine = (
await post(
client, auth_headers, account_id=broker, kind="deposit", trade_date=DAY, amount="5"
)
).json()["id"]
assert (await client.delete(f"{URL}/{mine}", headers=auth_headers)).status_code == 204
r = await client.delete(f"{URL}/{broker_event}", headers=auth_headers)
assert r.status_code == 409
assert r.headers["content-type"].startswith("application/problem+json")
assert (await client.delete(f"{URL}/{mine}", headers=auth_headers)).status_code == 404
remaining = (await client.get(URL, headers=auth_headers)).json()["items"]
assert [e["id"] for e in remaining] == [broker_event]