feat(api): портфели, ручные события, правка инструментов и обзор по скоупам

CRUD портфелей (/portfolios), ручные события (POST /events, DELETE только для manual), PATCH /instruments/{id}, GET /analytics/overview — карточка на каждый скоуп одним запросом. Холдинги отдают logo_url и logo_color. openapi.json обновлён.
This commit is contained in:
Dmitry
2026-09-19 22:13:55 +03:00
parent 75933993b2
commit f1f336f94f
15 changed files with 2129 additions and 21 deletions
+87
View File
@@ -0,0 +1,87 @@
"""One card per scope for the home screen."""
from datetime import timedelta
from decimal import Decimal
from factories import make_account, make_event, make_instrument, make_price, refresh
from fintracker.analytics import today_local
from fintracker.models import AccountKind, AccountRole, EventKind
URL = "/api/v1/analytics/overview"
async def _broker(name: str) -> int:
return await make_account(
name=name, kind=AccountKind.broker, role=AccountRole.investment, balance=None
)
async def _invest(account: int, *, today_price: str) -> None:
"""10 000 ₽ in, 100 shares at 100; the price is flat until today, when it is `today_price`."""
t = today_local()
bought = t - timedelta(days=5)
sber = await make_instrument(ticker=f"T{account}")
await make_event(bought, account_id=account, kind=EventKind.deposit, amount=10000)
await make_event(
bought, account_id=account, kind=EventKind.buy, instrument_id=sber,
quantity=100, price=100, amount=-10000,
) # fmt: skip
d = bought
while d < t:
await make_price(d, instrument_id=sber, close="100")
d += timedelta(days=1)
await make_price(t, instrument_id=sber, close=today_price)
async def test_cards_carry_value_result_and_the_change_over_the_last_day(client, auth_headers):
account = await _broker("Брокер")
await _invest(account, today_price="105")
await refresh()
r = await client.get(URL, headers=auth_headers)
assert r.status_code == 200, r.text
cards = {c["scope"]: c for c in r.json()}
card = cards[f"account:{account}"]
assert card["name"] == "Брокер"
assert card["kind"] == "account"
assert Decimal(card["total_rub"]) == 10500
assert Decimal(card["invested_rub"]) == 10000
assert Decimal(card["pnl_rub"]) == 500
assert Decimal(card["pnl_pct"]) == Decimal("0.05")
assert Decimal(card["day_change_rub"]) == 500
assert Decimal(card["day_change_pct"]) == Decimal("500") / Decimal("10000")
assert Decimal(card["income_year_rub"]) == 0
async def test_all_comes_first_then_portfolios_then_accounts_by_value(client, auth_headers):
small, big = await _broker("Малый"), await _broker("Большой")
await _invest(small, today_price="100")
await _invest(big, today_price="130")
created = await client.post(
"/api/v1/portfolios", json={"name": "Мой", "account_ids": [small]}, headers=auth_headers
)
assert created.status_code == 201
await refresh()
cards = (await client.get(URL, headers=auth_headers)).json()
assert [c["kind"] for c in cards] == ["all", "portfolio", "account", "account"]
assert [c["name"] for c in cards[2:]] == ["Большой", "Малый"]
assert Decimal(cards[0]["total_rub"]) == Decimal(cards[2]["total_rub"]) + Decimal(
cards[3]["total_rub"]
)
async def test_an_account_switched_off_has_no_card(client, auth_headers):
kept, off = await _broker("Оставить"), await _broker("Отключить")
await _invest(kept, today_price="100")
await _invest(off, today_price="100")
await client.patch(f"/api/v1/accounts/{off}", json={"disabled": True}, headers=auth_headers)
await refresh()
names = [c["name"] for c in (await client.get(URL, headers=auth_headers)).json()]
assert "Оставить" in names
assert "Отключить" not in names
async def test_nothing_built_yet_is_an_empty_list(client, auth_headers):
assert (await client.get(URL, headers=auth_headers)).json() == []