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
+109
View File
@@ -0,0 +1,109 @@
"""Hand corrections to an instrument over HTTP."""
from factories import make_instrument
def url(instrument_id: int) -> str:
return f"/api/v1/instruments/{instrument_id}"
async def test_patch_lot_board_name_class_and_sector(client, auth_headers):
iid = await make_instrument(ticker="MSNG", board="TQBR")
r = await client.patch(
url(iid),
json={
"lot": 1000,
"name": " Мосэнерго ",
"asset_class": "bond",
"sector": "Энергетика",
"board": "TQTF",
},
headers=auth_headers,
)
assert r.status_code == 200, r.text
row = r.json()
assert (row["lot"], row["name"], row["asset_class"]) == (1000, "Мосэнерго", "bond")
assert (row["sector"], row["board"]) == ("Энергетика", "TQTF")
detail = (await client.get(url(iid), headers=auth_headers)).json()["instrument"]
assert detail["lot"] == 1000
async def test_unset_fields_are_left_alone_and_null_clears_board(client, auth_headers):
iid = await make_instrument(ticker="AFLT", board="TQBR", name="Аэрофлот")
r = await client.patch(url(iid), json={"lot": 10}, headers=auth_headers)
assert (r.json()["name"], r.json()["board"], r.json()["lot"]) == ("Аэрофлот", "TQBR", 10)
r = await client.patch(url(iid), json={"board": None}, headers=auth_headers)
assert r.status_code == 200
assert r.json()["board"] is None
assert r.json()["lot"] == 10
async def test_invalid_values_are_refused(client, auth_headers):
iid = await make_instrument(ticker="PHOR")
for body in ({"lot": 0}, {"lot": -5}, {"name": ""}, {"unknown_field": 1}):
r = await client.patch(url(iid), json=body, headers=auth_headers)
assert r.status_code == 422, body
for body in ({"lot": None}, {"name": None}, {"asset_class": None}, {"name": " "}):
r = await client.patch(url(iid), json=body, headers=auth_headers)
assert r.status_code == 400, body
r = await client.patch(url(iid), json={"asset_class": "stock"}, headers=auth_headers)
assert r.status_code == 422
assert "share" in r.json()["detail"]
async def test_moving_to_a_taken_ticker_and_board_is_a_conflict(client, auth_headers):
await make_instrument(ticker="SBER", board="TQBR")
other = await make_instrument(ticker="SBER", board="SMAL")
r = await client.patch(url(other), json={"board": "TQBR"}, headers=auth_headers)
assert r.status_code == 409
assert r.headers["content-type"].startswith("application/problem+json")
r = await client.patch(url(other), json={"board": "TQBR", "lot": 10}, headers=auth_headers)
assert r.status_code == 409 # nothing was half-applied
assert (await client.get(url(other), headers=auth_headers)).json()["instrument"]["lot"] == 1
async def test_missing_instrument_is_404(client, auth_headers):
r = await client.patch(url(999), json={"lot": 2}, headers=auth_headers)
assert r.status_code == 404
async def test_instrument_and_holding_expose_a_public_logo_url(client, auth_headers):
from datetime import date
from factories import make_account, make_event, refresh
from fintracker.db import get_sessionmaker
from fintracker.models import AccountKind, AccountRole, EventKind, Instrument
iid = await make_instrument(ticker="SBER")
async with get_sessionmaker()() as session:
row = await session.get(Instrument, iid)
assert row is not None
row.logo_name, row.logo_color = "sber.png", "#21A038"
await session.commit()
account = await make_account(
name="Т", kind=AccountKind.broker, role=AccountRole.investment, balance=None
)
await make_event(
date(2026, 3, 1), account_id=account, kind=EventKind.buy, instrument_id=iid,
quantity=1, price=100, amount=-100,
) # fmt: skip
await refresh()
card = (await client.get(url(iid), headers=auth_headers)).json()
assert card["instrument"]["logo_url"] == "https://invest-brands.cdn-tinkoff.ru/sberx160.png"
assert card["instrument"]["logo_color"] == "#21A038"
assert card["holding"]["logo_url"] == card["instrument"]["logo_url"]
plain = await make_instrument(ticker="NOLOGO")
assert (await client.get(url(plain), headers=auth_headers)).json()["instrument"][
"logo_url"
] is None