CRUD портфелей (/portfolios), ручные события (POST /events, DELETE только для manual), PATCH /instruments/{id}, GET /analytics/overview — карточка на каждый скоуп одним запросом. Холдинги отдают logo_url и logo_color. openapi.json обновлён.
195 lines
7.6 KiB
Python
195 lines
7.6 KiB
Python
"""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]
|