From f1f336f94f291e7f08ee6681147715d83cb10c1c Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 22:13:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20=D0=BF=D0=BE=D1=80=D1=82=D1=84?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8,=20=D1=80=D1=83=D1=87=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=B7=D0=BE=D1=80=20=D0=BF=D0=BE=20=D1=81=D0=BA=D0=BE=D1=83?= =?UTF-8?q?=D0=BF=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRUD портфелей (/portfolios), ручные события (POST /events, DELETE только для manual), PATCH /instruments/{id}, GET /analytics/overview — карточка на каждый скоуп одним запросом. Холдинги отдают logo_url и logo_color. openapi.json обновлён. --- backend/src/fintracker/api/app.py | 2 + .../src/fintracker/api/routers/analytics.py | 92 ++ backend/src/fintracker/api/routers/events.py | 145 ++- .../src/fintracker/api/routers/instruments.py | 63 + .../src/fintracker/api/routers/networth.py | 1 + .../src/fintracker/api/routers/portfolios.py | 143 +++ .../src/fintracker/api/schemas/analytics.py | 48 +- backend/src/fintracker/api/schemas/events.py | 38 + .../src/fintracker/api/schemas/portfolios.py | 33 + backend/src/fintracker/branding.py | 21 + backend/tests/api/test_events_manual_api.py | 194 +++ backend/tests/api/test_instruments_api.py | 109 ++ backend/tests/api/test_overview_api.py | 87 ++ backend/tests/api/test_portfolios_api.py | 113 ++ openapi/openapi.json | 1061 ++++++++++++++++- 15 files changed, 2129 insertions(+), 21 deletions(-) create mode 100644 backend/src/fintracker/api/routers/portfolios.py create mode 100644 backend/src/fintracker/api/schemas/events.py create mode 100644 backend/src/fintracker/api/schemas/portfolios.py create mode 100644 backend/src/fintracker/branding.py create mode 100644 backend/tests/api/test_events_manual_api.py create mode 100644 backend/tests/api/test_instruments_api.py create mode 100644 backend/tests/api/test_overview_api.py create mode 100644 backend/tests/api/test_portfolios_api.py diff --git a/backend/src/fintracker/api/app.py b/backend/src/fintracker/api/app.py index a11dcc0..c4088e3 100644 --- a/backend/src/fintracker/api/app.py +++ b/backend/src/fintracker/api/app.py @@ -31,6 +31,7 @@ from fintracker.api.routers import ( links, metrics, networth, + portfolios, rebalance, rules, sync, @@ -112,6 +113,7 @@ def create_app() -> FastAPI: app.include_router(metrics.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED) app.include_router(goals.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED) app.include_router(income.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED) + app.include_router(portfolios.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED) app.include_router(rebalance.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED) app.include_router(tax.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED) app.include_router(benchmarks.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED) diff --git a/backend/src/fintracker/api/routers/analytics.py b/backend/src/fintracker/api/routers/analytics.py index 66d8c13..a5cc5f3 100644 --- a/backend/src/fintracker/api/routers/analytics.py +++ b/backend/src/fintracker/api/routers/analytics.py @@ -14,23 +14,28 @@ from typing import Annotated from fastapi import APIRouter, Query from sqlalchemy import func, select +from fintracker.analytics import today_local from fintracker.api.deps import CurrentUser, SessionDep from fintracker.api.schemas.analytics import ( AllocationBucket, CashFlowBrokerMonth, HoldingOut, ReturnsOut, + ScopeCardOut, ScopeOut, SummaryOut, ValueDay, ) from fintracker.api.scopes import DEFAULT_SCOPE, list_scopes, resolve_scope +from fintracker.branding import logo_url from fintracker.models import ( AllocationDimension, + IncomeBasis, Instrument, MetricAllocation, MetricCashFlowBroker, MetricHolding, + MetricIncomeCalendar, MetricPortfolioValueDaily, MetricRefreshLog, MetricReturns, @@ -53,6 +58,91 @@ async def scopes(session: SessionDep, _: CurrentUser) -> list[ScopeOut]: return await list_scopes(session) +@router.get("/overview", name="overview") +async def overview(session: SessionDep, _: CurrentUser) -> list[ScopeCardOut]: + """One card per scope for the home screen, in a single round trip. + + `all` first, then the portfolios, then the accounts by value. A scope holding nothing at + all is left out: a card of zeros is noise, not an answer. + """ + scope_list = await list_scopes(session) + names = {s.scope: s.name for s in scope_list} + today = today_local() + + recent: dict[str, list[MetricPortfolioValueDaily]] = {} + rows = ( + await session.execute( + select(MetricPortfolioValueDaily) + .where( + MetricPortfolioValueDaily.scope.in_(names), + MetricPortfolioValueDaily.d >= today - timedelta(days=10), + ) + .order_by(MetricPortfolioValueDaily.scope, MetricPortfolioValueDaily.d) + ) + ).scalars() + for row in rows: + recent.setdefault(row.scope, []).append(row) + + xirr = { + r.scope: r.xirr + for r in ( + await session.execute(select(MetricReturns).where(MetricReturns.period == "all")) + ).scalars() + } + income = { + scope: total + for scope, total in ( + await session.execute( + select(MetricIncomeCalendar.scope, func.sum(MetricIncomeCalendar.amount_rub)) + .where( + MetricIncomeCalendar.expected_date > today, + MetricIncomeCalendar.expected_date <= today + timedelta(days=365), + MetricIncomeCalendar.basis != IncomeBasis.paid, + ) + .group_by(MetricIncomeCalendar.scope) + ) + ).all() + } + + cards: list[ScopeCardOut] = [] + for scope, name in names.items(): + days = recent.get(scope, []) + if not days: + continue + last = days[-1] + total, invested = Decimal(last.total_rub), Decimal(last.invested_net_rub) + if total == 0 and invested == 0: + continue + pnl = last.pnl_total_rub + day_change = None + if len(days) >= 2 and pnl is not None and days[-2].pnl_total_rub is not None: + day_change = Decimal(pnl) - Decimal(days[-2].pnl_total_rub) + before = Decimal(days[-2].total_rub) if len(days) >= 2 else ZERO + yearly = Decimal(income.get(scope) or 0) + cards.append( + ScopeCardOut( + scope=scope, + name=name, + kind=scope.split(":")[0], + as_of=last.d, + total_rub=total, + invested_rub=invested, + pnl_rub=pnl, + pnl_pct=Decimal(pnl) / invested if pnl is not None and invested > 0 else None, + day_change_rub=day_change, + day_change_pct=day_change / before + if day_change is not None and before > 0 + else None, + xirr=xirr.get(scope), + income_year_rub=yearly, + income_year_pct=yearly / total if total > 0 else None, + ) + ) + order = {"all": 0, "portfolio": 1, "account": 2} + cards.sort(key=lambda c: (order.get(c.kind, 3), -c.total_rub)) + return cards + + @router.get("/value-series", name="value_series") async def value_series( session: SessionDep, @@ -276,6 +366,8 @@ def _holding(row: MetricHolding, instrument: Instrument) -> HoldingOut: instrument_id=row.instrument_id, ticker=instrument.ticker, name=instrument.name, + logo_url=logo_url(instrument.logo_name), + logo_color=instrument.logo_color, asset_class=instrument.asset_class.value, board=instrument.board, currency=instrument.currency, diff --git a/backend/src/fintracker/api/routers/events.py b/backend/src/fintracker/api/routers/events.py index bbe9cb2..1bcb34c 100644 --- a/backend/src/fintracker/api/routers/events.py +++ b/backend/src/fintracker/api/routers/events.py @@ -1,22 +1,31 @@ -"""The broker event ledger, read-only for now (plan §4). +"""The broker event ledger (plan §4). -Writing events by hand belongs with the report importer in phase 3; until then this is the -screen that answers "where did this number come from", which is what makes a reconciliation -finding actionable. +The list is the screen that answers "where did this number come from", which is what makes a +reconciliation finding actionable. Beside it, a person can enter an event the broker's feeds +do not carry (a securities conversion, a trade from before the first report) with +`source = manual`; only those can be deleted, everything else returns with the next sync. + +Like `/rules/apply`, the writes do not refresh `metric_*`: call `POST /metrics/refresh` +afterwards — lots and every valuation are rebuilt from the ledger there. """ from __future__ import annotations -from datetime import date +import uuid +from datetime import date, datetime, time +from decimal import Decimal from typing import Annotated +from zoneinfo import ZoneInfo -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Response, status from sqlalchemy import Select, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from fintracker.api.deps import CurrentUser, SessionDep +from fintracker.api.errors import Problem from fintracker.api.schemas.analytics import EventOut, EventPage -from fintracker.models import Event, EventKind, EventStatus, Instrument +from fintracker.api.schemas.events import ManualEventCreate +from fintracker.models import Account, AccountKind, Event, EventKind, EventStatus, Instrument from fintracker.models.ledger import EXTERNAL_FLOW_KINDS from fintracker.pricing.fx import FxTable @@ -90,6 +99,127 @@ async def list_events( ) +MSK = ZoneInfo("Europe/Moscow") +ZERO = Decimal(0) + +_TRADES = {EventKind.buy, EventKind.sell} +_TRANSFERS = {EventKind.transfer_in, EventKind.transfer_out} +_INCOME = {EventKind.dividend, EventKind.coupon} +_CASH_IN = {EventKind.interest, EventKind.tax_refund, EventKind.deposit} +_CASH_OUT = {EventKind.withdrawal, EventKind.commission, EventKind.tax} +MANUAL_KINDS = _TRADES | _TRANSFERS | _INCOME | _CASH_IN | _CASH_OUT +"""What a person can enter. Splits, amortisations, repayments and FX legs carry several +rows tied together (`group_id`) and are not something to type by hand.""" + + +def _bad(message: str) -> Problem: + return Problem(status.HTTP_422_UNPROCESSABLE_ENTITY, "Unprocessable Entity", message) + + +def _signed(body: ManualEventCreate) -> tuple[Decimal | None, Decimal]: + """`(quantity, amount)` as the ledger stores them: quantity by position effect, amount by + cash effect (see `models/ledger.py`).""" + kind = body.kind + quantity, price = body.quantity, body.price + fee, accrued = body.fee or ZERO, body.accrued_interest or ZERO + if (quantity is not None and quantity <= 0) or (body.amount is not None and body.amount < 0): + raise _bad("quantity и amount вводятся положительными: знак задаёт вид события") + if (price is not None and price < 0) or fee < 0 or accrued < 0: + raise _bad("price, fee и accrued_interest не могут быть отрицательными") + + if kind in _TRADES: + if quantity is None: + raise _bad("Для сделки нужно количество") + if body.amount is not None: + amount = body.amount + elif price is not None: + gross = quantity * price + amount = gross + accrued + fee if kind == EventKind.buy else gross + accrued - fee + else: + raise _bad("Для сделки нужна цена или итоговая сумма") + return (quantity, -amount) if kind == EventKind.buy else (-quantity, amount) + if kind in _TRANSFERS: + if quantity is None: + raise _bad("Для перевода бумаг нужно количество") + return (quantity if kind == EventKind.transfer_in else -quantity), ZERO + if body.amount is None or body.amount == 0: + raise _bad("Нужна сумма больше нуля") + return None, (-body.amount if kind in _CASH_OUT else body.amount) + + +@router.post("", name="create", status_code=status.HTTP_201_CREATED) +async def create_event(body: ManualEventCreate, session: SessionDep, _: CurrentUser) -> EventOut: + """Enter an event by hand. It is `confirmed` at once: it is the user's own statement, so + it is never shadowed by the account's primary feed.""" + if body.kind not in MANUAL_KINDS: + allowed = ", ".join(sorted(k.value for k in MANUAL_KINDS)) + raise _bad(f"Вид {body.kind.value!r} вручную не вводится; можно: {allowed}") + account = await session.get(Account, body.account_id) + if account is None or account.kind != AccountKind.broker: + raise Problem( + status.HTTP_400_BAD_REQUEST, "Bad request", "Нужен существующий брокерский счёт" + ) + + instrument = None + if body.instrument_id is not None: + instrument = await session.get(Instrument, body.instrument_id) + if instrument is None: + raise Problem( + status.HTTP_400_BAD_REQUEST, "Bad request", f"Нет инструмента #{body.instrument_id}" + ) + needs_instrument = body.kind in _TRADES | _TRANSFERS | _INCOME + if needs_instrument and instrument is None: + raise _bad("Для этого вида события нужен инструмент") + if not needs_instrument and instrument is not None and body.kind != EventKind.interest: + raise _bad("Денежное событие не привязывается к инструменту") + + quantity, amount = _signed(body) + currency = (body.currency or (instrument.currency if instrument else account.currency)).upper() + key = uuid.uuid4().hex + event = Event( + account_id=account.id, + instrument_id=instrument.id if instrument else None, + kind=body.kind, + status=EventStatus.confirmed, + ts=datetime.combine(body.trade_date, time(12, 0), tzinfo=MSK), + trade_date=body.trade_date, + quantity=quantity, + price=body.price, + price_currency=currency if body.price is not None else None, + amount=amount, + currency=currency, + fee=body.fee, + fee_currency=currency if body.fee is not None else None, + accrued_interest=body.accrued_interest, + source="manual", + source_id=key, + dedupe_key=f"manual:{key}", + description=body.description, + ) + session.add(event) + await session.commit() + await session.refresh(event) + return event_out(event, instrument.ticker if instrument else None, await FxTable.load(session)) + + +@router.delete("/{event_id}", name="delete", status_code=status.HTTP_204_NO_CONTENT) +async def delete_event(event_id: int, session: SessionDep, _: CurrentUser) -> Response: + """Delete an event that was entered by hand. Broker events are evidence, not ours to erase: + the next sync or import would bring them straight back.""" + event = await session.get(Event, event_id) + if event is None: + raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"Нет события #{event_id}") + if event.source != "manual": + raise Problem( + status.HTTP_409_CONFLICT, + "Conflict", + "Удалять можно только события, введённые вручную; остальные вернёт синк", + ) + await session.delete(event) + await session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + def _joined[T: tuple[object, ...]](stmt: Select[T]) -> Select[T]: """Left join the instrument: deposits and fees have none, and must still be listed.""" return stmt.outerjoin(Instrument, Instrument.id == Event.instrument_id) @@ -115,6 +245,7 @@ def event_out(event: Event, ticker: str | None, fx: FxTable) -> EventOut: tax=event.tax, accrued_interest=event.accrued_interest, description=event.description, + source=event.source, external_flow=is_external_flow(event), ) diff --git a/backend/src/fintracker/api/routers/instruments.py b/backend/src/fintracker/api/routers/instruments.py index 0407038..bfb1209 100644 --- a/backend/src/fintracker/api/routers/instruments.py +++ b/backend/src/fintracker/api/routers/instruments.py @@ -20,12 +20,14 @@ from fintracker.api.routers.events import events_of_instrument from fintracker.api.schemas.analytics import ( InstrumentDetail, InstrumentOut, + InstrumentPatch, LotOut, PriceManualIn, PriceManualOut, PricePoint, ) from fintracker.api.scopes import DEFAULT_SCOPE, resolve_scope +from fintracker.branding import logo_url from fintracker.models import AssetClass, Instrument, Lot, PriceDaily, PriceManual router = APIRouter(prefix="/instruments", tags=["instruments"]) @@ -146,6 +148,65 @@ async def get_instrument( ) +@router.patch("/{instrument_id}", name="patch") +async def patch_instrument( + instrument_id: int, body: InstrumentPatch, session: SessionDep, _: CurrentUser +) -> InstrumentOut: + """Correct an instrument by hand: name, board, lot, asset class, sector. + + The lot only rounds the quantities the rebalancing suggests; the asset class decides the + bucket it lands in. Neither is refreshed here — call `POST /metrics/refresh` after, same + as `/rules/apply`. + """ + instrument = await session.get(Instrument, instrument_id) + if instrument is None: + raise Problem(404, "Not Found", f"Нет инструмента #{instrument_id}") + + changes = body.model_dump(exclude_unset=True) + for field in ("name", "asset_class", "lot"): + if field in changes and changes[field] is None: + raise Problem(400, "Bad request", f"{field} must not be null") + if "name" in changes: + changes["name"] = changes["name"].strip() + if not changes["name"]: + raise Problem(400, "Bad request", "name must not be empty") + if "asset_class" in changes: + try: + changes["asset_class"] = AssetClass(changes["asset_class"]) + except ValueError: + known = ", ".join(a.value for a in AssetClass) + raise Problem( + 422, + "Unprocessable Entity", + f"Неизвестный класс актива {changes['asset_class']!r}; есть: {known}", + ) from None + for field in ("board", "sector"): + if field in changes and changes[field] is not None: + changes[field] = changes[field].strip() or None + + board = changes.get("board", instrument.board) + if "board" in changes and board is not None and instrument.ticker is not None: + clash = await session.scalar( + select(Instrument.id).where( + Instrument.ticker == instrument.ticker, + Instrument.board == board, + Instrument.id != instrument_id, + ) + ) + if clash is not None: + raise Problem( + 409, + "Conflict", + f"{instrument.ticker} на доске {board} уже есть: инструмент #{clash}", + ) + + for field, value in changes.items(): + setattr(instrument, field, value) + await session.commit() + await session.refresh(instrument) + return _instrument(instrument) + + @router.post( "/{instrument_id}/prices", name="set_manual_price", status_code=status.HTTP_201_CREATED ) @@ -199,6 +260,8 @@ def _instrument(row: Instrument) -> InstrumentOut: board=row.board, exchange=row.exchange, name=row.name, + logo_url=logo_url(row.logo_name), + logo_color=row.logo_color, issuer=row.issuer, currency=row.currency, lot=row.lot, diff --git a/backend/src/fintracker/api/routers/networth.py b/backend/src/fintracker/api/routers/networth.py index b405e6e..c624ad4 100644 --- a/backend/src/fintracker/api/routers/networth.py +++ b/backend/src/fintracker/api/routers/networth.py @@ -54,6 +54,7 @@ async def net_worth_breakdown(session: SessionDep, _: CurrentUser) -> NetWorthBr .where( Account.include_in_net_worth.is_(True), Account.archived.is_(False), + Account.disabled.is_(False), Account.mirror_of_account_id.is_(None), Account.balance.is_not(None), ) diff --git a/backend/src/fintracker/api/routers/portfolios.py b/backend/src/fintracker/api/routers/portfolios.py new file mode 100644 index 0000000..1a74aa1 --- /dev/null +++ b/backend/src/fintracker/api/routers/portfolios.py @@ -0,0 +1,143 @@ +"""Portfolios: CRUD and the account set (docs/ai/architecture.md §portfolio). + +Like `/rules/apply`, these writes do not refresh `metric_*`: call `POST /metrics/refresh` +afterwards so the portfolio's value history follows the new account set. Targets and the +rebalancing suggestion (`routers/rebalance.py`) are computed per request and need no refresh. +""" + +from __future__ import annotations + +from collections import defaultdict + +from fastapi import APIRouter, Response, status +from sqlalchemy import delete, select + +from fintracker.api.deps import CurrentUser, SessionDep +from fintracker.api.errors import Problem +from fintracker.api.schemas.portfolios import ( + PortfolioAccountsIn, + PortfolioCreate, + PortfolioOut, + PortfolioPatch, +) +from fintracker.models import Account, Portfolio, PortfolioAccount + +router = APIRouter(prefix="/portfolios", tags=["portfolios"]) + + +async def _portfolio(session: SessionDep, portfolio_id: int) -> Portfolio: + portfolio = await session.get(Portfolio, portfolio_id) + if portfolio is None: + raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"Нет портфеля с id {portfolio_id}") + return portfolio + + +async def _check_name(session: SessionDep, name: str, *, exclude: int | None = None) -> None: + stmt = select(Portfolio.id).where(Portfolio.name == name) + if exclude is not None: + stmt = stmt.where(Portfolio.id != exclude) + if (await session.execute(stmt)).scalar_one_or_none() is not None: + raise Problem(status.HTTP_409_CONFLICT, "Conflict", f"Портфель «{name}» уже есть") + + +async def _check_accounts(session: SessionDep, account_ids: list[int]) -> set[int]: + wanted = set(account_ids) + found = set( + (await session.execute(select(Account.id).where(Account.id.in_(wanted)))).scalars().all() + ) + if missing := sorted(wanted - found): + raise Problem( + status.HTTP_400_BAD_REQUEST, + "Bad request", + "Нет счетов с id " + ", ".join(map(str, missing)), + ) + return wanted + + +async def _account_ids(session: SessionDep, portfolio_ids: list[int]) -> dict[int, list[int]]: + rows = ( + await session.execute( + select(PortfolioAccount.portfolio_id, PortfolioAccount.account_id).where( + PortfolioAccount.portfolio_id.in_(portfolio_ids) + ) + ) + ).all() + out: dict[int, list[int]] = defaultdict(list) + for r in rows: + out[r.portfolio_id].append(r.account_id) + return {pid: sorted(ids) for pid, ids in out.items()} + + +async def _out(session: SessionDep, portfolios: list[Portfolio]) -> list[PortfolioOut]: + accounts = await _account_ids(session, [p.id for p in portfolios]) + return [ + PortfolioOut( + id=p.id, + name=p.name, + base_currency=p.base_currency, + account_ids=accounts.get(p.id, []), + ) + for p in portfolios + ] + + +@router.get("", name="list") +async def list_portfolios(session: SessionDep, _: CurrentUser) -> list[PortfolioOut]: + rows = (await session.execute(select(Portfolio).order_by(Portfolio.name))).scalars().all() + return await _out(session, list(rows)) + + +@router.post("", name="create", status_code=status.HTTP_201_CREATED) +async def create_portfolio( + body: PortfolioCreate, session: SessionDep, _: CurrentUser +) -> PortfolioOut: + name = body.name.strip() + if not name: + raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "name must not be empty") + await _check_name(session, name) + account_ids = await _check_accounts(session, body.account_ids) + portfolio = Portfolio(name=name) + session.add(portfolio) + await session.flush() + session.add_all(PortfolioAccount(portfolio_id=portfolio.id, account_id=a) for a in account_ids) + await session.commit() + await session.refresh(portfolio) + return (await _out(session, [portfolio]))[0] + + +@router.patch("/{portfolio_id}", name="patch") +async def patch_portfolio( + portfolio_id: int, body: PortfolioPatch, session: SessionDep, _: CurrentUser +) -> PortfolioOut: + portfolio = await _portfolio(session, portfolio_id) + name = body.name.strip() + if not name: + raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "name must not be empty") + await _check_name(session, name, exclude=portfolio_id) + portfolio.name = name + await session.commit() + await session.refresh(portfolio) + return (await _out(session, [portfolio]))[0] + + +@router.put("/{portfolio_id}/accounts", name="set_accounts") +async def set_portfolio_accounts( + portfolio_id: int, body: PortfolioAccountsIn, session: SessionDep, _: CurrentUser +) -> PortfolioOut: + portfolio = await _portfolio(session, portfolio_id) + account_ids = await _check_accounts(session, body.account_ids) + await session.execute( + delete(PortfolioAccount).where(PortfolioAccount.portfolio_id == portfolio_id) + ) + session.add_all(PortfolioAccount(portfolio_id=portfolio_id, account_id=a) for a in account_ids) + await session.commit() + await session.refresh(portfolio) + return (await _out(session, [portfolio]))[0] + + +@router.delete("/{portfolio_id}", name="delete", status_code=status.HTTP_204_NO_CONTENT) +async def delete_portfolio(portfolio_id: int, session: SessionDep, _: CurrentUser) -> Response: + portfolio = await _portfolio(session, portfolio_id) + await session.delete(portfolio) + await session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/src/fintracker/api/schemas/analytics.py b/backend/src/fintracker/api/schemas/analytics.py index 060a857..cc8bb6e 100644 --- a/backend/src/fintracker/api/schemas/analytics.py +++ b/backend/src/fintracker/api/schemas/analytics.py @@ -9,7 +9,7 @@ from __future__ import annotations from datetime import date, datetime -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field from fintracker.api.schemas.common import Money, MoneyOpt from fintracker.models import AllocationDimension, EventKind, EventStatus @@ -23,6 +23,31 @@ class ScopeOut(BaseModel): account_ids: list[int] +class ScopeCardOut(BaseModel): + """One tile of the home screen: a scope with what a glance needs, from the metric tables.""" + + scope: str + name: str + kind: str + """all | portfolio | account""" + as_of: date | None + total_rub: Money + invested_rub: Money + """Net money put in (deposits less withdrawals).""" + pnl_rub: MoneyOpt + pnl_pct: MoneyOpt + """A share of `invested_rub`, not a percent: `0.015` is 1.5 %. Null when nothing is invested.""" + day_change_rub: MoneyOpt + day_change_pct: MoneyOpt + """Change of the result over the last day — flows in or out do not count as a gain.""" + xirr: MoneyOpt + """Annualised money-weighted return since the start; null when the flows admit no answer.""" + income_year_rub: Money + """Dividends and coupons expected in the next 12 months (announced, scheduled, projected).""" + income_year_pct: MoneyOpt + """`income_year_rub` as a share of the current value.""" + + class ValueDay(BaseModel): d: date market_value_rub: Money @@ -43,6 +68,9 @@ class HoldingOut(BaseModel): instrument_id: int ticker: str | None name: str + logo_url: str | None = None + logo_color: str | None = None + """Issuer logo (a public URL) and its brand colour; both null when the broker has none.""" asset_class: str """Stable key: share | bond | etf | fund | currency | index | deposit | …""" board: str | None @@ -150,6 +178,8 @@ class EventOut(BaseModel): tax: MoneyOpt accrued_interest: MoneyOpt description: str | None + source: str + """tinvest | report_sber | report_vtb | csv | manual — only `manual` events can be deleted.""" external_flow: bool """True when this event moved money across the portfolio boundary (XIRR reads these).""" @@ -206,6 +236,8 @@ class InstrumentOut(BaseModel): board: str | None exchange: str | None name: str + logo_url: str | None = None + logo_color: str | None = None issuer: str | None currency: str lot: int @@ -217,6 +249,20 @@ class InstrumentOut(BaseModel): is_active: bool +class InstrumentPatch(BaseModel): + """The fields a person may correct by hand. Unset fields are left alone; an explicit `null` + clears the nullable ones (`board`, `sector`).""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=256) + asset_class: str | None = None + """Stable key, as in `InstrumentOut`.""" + board: str | None = Field(default=None, max_length=16) + lot: int | None = Field(default=None, ge=1) + sector: str | None = Field(default=None, max_length=64) + + class InstrumentDetail(BaseModel): instrument: InstrumentOut holding: HoldingOut | None diff --git a/backend/src/fintracker/api/schemas/events.py b/backend/src/fintracker/api/schemas/events.py new file mode 100644 index 0000000..7470fe5 --- /dev/null +++ b/backend/src/fintracker/api/schemas/events.py @@ -0,0 +1,38 @@ +"""Wire shapes for events entered by hand (plan §1.4: `source = manual`). + +The caller states what happened in the units a person reads off a broker statement — a +positive quantity, a positive price, a positive amount. The signs the ledger stores +(`quantity` by position effect, `amount` by cash effect) are derived from `kind` server-side, +so a form can never save a purchase that adds cash. +""" + +from __future__ import annotations + +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field + +from fintracker.api.schemas.common import MoneyOpt +from fintracker.models import EventKind + + +class ManualEventCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + account_id: int + kind: EventKind + trade_date: date + instrument_id: int | None = None + quantity: MoneyOpt = None + """Units, always positive: the sign comes from `kind`.""" + price: MoneyOpt = None + """Per unit, in `currency`.""" + amount: MoneyOpt = None + """What actually moved on the account, positive. For a trade it may be left out and is then + quantity × price ± accrued interest ± fee.""" + currency: str | None = Field(default=None, min_length=3, max_length=3) + """Defaults to the instrument's currency, or the account's for a cash event.""" + fee: MoneyOpt = None + """Already part of `amount`; kept for the record.""" + accrued_interest: MoneyOpt = None + description: str | None = Field(default=None, max_length=500) diff --git a/backend/src/fintracker/api/schemas/portfolios.py b/backend/src/fintracker/api/schemas/portfolios.py new file mode 100644 index 0000000..7a204b0 --- /dev/null +++ b/backend/src/fintracker/api/schemas/portfolios.py @@ -0,0 +1,33 @@ +"""Wire shapes for portfolios: a named set of accounts (docs/ai/architecture.md §portfolio).""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class PortfolioOut(BaseModel): + id: int + name: str + base_currency: str + account_ids: list[int] + + +class PortfolioCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=128) + account_ids: list[int] = Field(default_factory=list) + + +class PortfolioPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=128) + + +class PortfolioAccountsIn(BaseModel): + """The complete set: accounts not listed are removed from the portfolio.""" + + model_config = ConfigDict(extra="forbid") + + account_ids: list[int] diff --git a/backend/src/fintracker/branding.py b/backend/src/fintracker/branding.py new file mode 100644 index 0000000..1289d96 --- /dev/null +++ b/backend/src/fintracker/branding.py @@ -0,0 +1,21 @@ +"""Issuer logos, as T-Invest publishes them (kept out of `sources/`: the API needs the URL, +not the whole source registry). + +Every instrument the API describes carries a `brand` block: the name of a logo file and the +brand's own colour. The pictures live on T-Bank's public CDN, need no key and answer with +`access-control-allow-origin: *`, so a browser client can load them straight from there. +""" + +from __future__ import annotations + +LOGO_HOST = "https://invest-brands.cdn-tinkoff.ru" +LOGO_SIZE = 160 +"""The CDN serves 160, 320 and 640 px squares; a list row never needs more than the first.""" + + +def logo_url(logo_name: str | None) -> str | None: + """`sber.png` -> the URL of its 160 px picture; None when the instrument has no logo.""" + if not logo_name: + return None + stem = logo_name.rsplit(".", 1)[0] + return f"{LOGO_HOST}/{stem}x{LOGO_SIZE}.png" diff --git a/backend/tests/api/test_events_manual_api.py b/backend/tests/api/test_events_manual_api.py new file mode 100644 index 0000000..d804f62 --- /dev/null +++ b/backend/tests/api/test_events_manual_api.py @@ -0,0 +1,194 @@ +"""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] diff --git a/backend/tests/api/test_instruments_api.py b/backend/tests/api/test_instruments_api.py new file mode 100644 index 0000000..524927e --- /dev/null +++ b/backend/tests/api/test_instruments_api.py @@ -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 diff --git a/backend/tests/api/test_overview_api.py b/backend/tests/api/test_overview_api.py new file mode 100644 index 0000000..3143205 --- /dev/null +++ b/backend/tests/api/test_overview_api.py @@ -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() == [] diff --git a/backend/tests/api/test_portfolios_api.py b/backend/tests/api/test_portfolios_api.py new file mode 100644 index 0000000..db25f95 --- /dev/null +++ b/backend/tests/api/test_portfolios_api.py @@ -0,0 +1,113 @@ +"""Portfolio CRUD and the account set over HTTP.""" + +from factories import make_account + +URL = "/api/v1/portfolios" + + +async def test_create_list_and_replace_accounts(client, auth_headers): + a = await make_account(name="Брокер 1") + b = await make_account(name="Брокер 2") + + r = await client.post( + URL, json={"name": " Основной ", "account_ids": [b, a]}, headers=auth_headers + ) + assert r.status_code == 201, r.text + created = r.json() + assert created["name"] == "Основной" + assert created["base_currency"] == "RUB" + assert created["account_ids"] == sorted([a, b]) + + r = await client.get(URL, headers=auth_headers) + assert [p["id"] for p in r.json()] == [created["id"]] + + r = await client.put( + f"{URL}/{created['id']}/accounts", json={"account_ids": [b]}, headers=auth_headers + ) + assert r.status_code == 200, r.text + assert r.json()["account_ids"] == [b] + + r = await client.put( + f"{URL}/{created['id']}/accounts", json={"account_ids": []}, headers=auth_headers + ) + assert r.json()["account_ids"] == [] + + +async def test_create_without_accounts_is_allowed(client, auth_headers): + r = await client.post(URL, json={"name": "Пустой"}, headers=auth_headers) + assert r.status_code == 201 + assert r.json()["account_ids"] == [] + + +async def test_duplicate_name_is_a_conflict(client, auth_headers): + await client.post(URL, json={"name": "Основной"}, headers=auth_headers) + r = await client.post(URL, json={"name": "Основной"}, headers=auth_headers) + assert r.status_code == 409 + assert r.headers["content-type"].startswith("application/problem+json") + + +async def test_rename_and_rename_conflict(client, auth_headers): + first = (await client.post(URL, json={"name": "Один"}, headers=auth_headers)).json() + await client.post(URL, json={"name": "Два"}, headers=auth_headers) + + r = await client.patch( + f"{URL}/{first['id']}", json={"name": "Один и один"}, headers=auth_headers + ) + assert r.status_code == 200 + assert r.json()["name"] == "Один и один" + + r = await client.patch(f"{URL}/{first['id']}", json={"name": "Два"}, headers=auth_headers) + assert r.status_code == 409 + + r = await client.patch( + f"{URL}/{first['id']}", json={"name": "Один и один"}, headers=auth_headers + ) + assert r.status_code == 200 # keeping its own name is not a conflict + + +async def test_blank_name_is_rejected(client, auth_headers): + r = await client.post(URL, json={"name": " "}, headers=auth_headers) + assert r.status_code == 400 + + +async def test_unknown_account_is_rejected_and_nothing_is_saved(client, auth_headers): + real = await make_account(name="Брокер") + r = await client.post( + URL, json={"name": "Х", "account_ids": [real, 99999]}, headers=auth_headers + ) + assert r.status_code == 400 + assert "99999" in r.json()["detail"] + assert (await client.get(URL, headers=auth_headers)).json() == [] + + created = ( + await client.post(URL, json={"name": "Х", "account_ids": [real]}, headers=auth_headers) + ).json() + r = await client.put( + f"{URL}/{created['id']}/accounts", json={"account_ids": [99999]}, headers=auth_headers + ) + assert r.status_code == 400 + listed = (await client.get(URL, headers=auth_headers)).json() + assert listed[0]["account_ids"] == [real] + + +async def test_delete_removes_the_portfolio_but_not_its_accounts(client, auth_headers): + a = await make_account(name="Брокер") + created = ( + await client.post(URL, json={"name": "Х", "account_ids": [a]}, headers=auth_headers) + ).json() + + r = await client.delete(f"{URL}/{created['id']}", headers=auth_headers) + assert r.status_code == 204 + assert (await client.get(URL, headers=auth_headers)).json() == [] + accounts = (await client.get("/api/v1/accounts", headers=auth_headers)).json() + assert [x["id"] for x in accounts] == [a] + + +async def test_missing_portfolio_is_404(client, auth_headers): + assert ( + await client.patch(f"{URL}/999", json={"name": "Х"}, headers=auth_headers) + ).status_code == 404 + assert ( + await client.put(f"{URL}/999/accounts", json={"account_ids": []}, headers=auth_headers) + ).status_code == 404 + assert (await client.delete(f"{URL}/999", headers=auth_headers)).status_code == 404 diff --git a/openapi/openapi.json b/openapi/openapi.json index 0326fe3..46d575d 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -47,6 +47,42 @@ "title": "AccountBalance", "type": "object" }, + "AccountCreate": { + "additionalProperties": false, + "description": "A broker account for report imports. T-Invest accounts come from the sync, not from here.", + "properties": { + "broker": { + "$ref": "#/components/schemas/Broker" + }, + "currency": { + "default": "RUB", + "maxLength": 3, + "minLength": 3, + "title": "Currency", + "type": "string" + }, + "name": { + "maxLength": 256, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "source_id": { + "description": "Agreement number exactly as the broker's report prints it", + "maxLength": 128, + "minLength": 1, + "title": "Source Id", + "type": "string" + } + }, + "required": [ + "name", + "broker", + "source_id" + ], + "title": "AccountCreate", + "type": "object" + }, "AccountKind": { "enum": [ "zm_cash", @@ -118,6 +154,10 @@ "title": "Currency", "type": "string" }, + "disabled": { + "title": "Disabled", + "type": "boolean" + }, "id": { "title": "Id", "type": "integer" @@ -188,6 +228,18 @@ } ], "title": "Start Balance" + }, + "value_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value Rub" } }, "required": [ @@ -203,6 +255,7 @@ "mirror_of_account_id", "primary_event_source", "archived", + "disabled", "opened_at", "balance", "balance_as_of", @@ -216,6 +269,17 @@ "additionalProperties": false, "description": "Only the fields the user owns: everything else comes from the source on each sync.\n\nUnset fields are left alone; an explicit `null` clears the column.", "properties": { + "disabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Disabled" + }, "include_in_net_worth": { "anyOf": [ { @@ -1354,6 +1418,10 @@ ], "title": "Quantity" }, + "source": { + "title": "Source", + "type": "string" + }, "status": { "$ref": "#/components/schemas/EventStatus" }, @@ -1410,6 +1478,7 @@ "tax", "accrued_interest", "description", + "source", "external_flow" ], "title": "EventOut", @@ -2130,6 +2199,28 @@ "title": "Ldv Eligible Qty", "type": "string" }, + "logo_color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logo Color" + }, + "logo_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logo Url" + }, "market_price": { "anyOf": [ { @@ -2902,6 +2993,28 @@ ], "title": "Issuer" }, + "logo_color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logo Color" + }, + "logo_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logo Url" + }, "lot": { "title": "Lot", "type": "integer" @@ -2990,6 +3103,74 @@ "title": "InstrumentOut", "type": "object" }, + "InstrumentPatch": { + "additionalProperties": false, + "description": "The fields a person may correct by hand. Unset fields are left alone; an explicit `null`\nclears the nullable ones (`board`, `sector`).", + "properties": { + "asset_class": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Asset Class" + }, + "board": { + "anyOf": [ + { + "maxLength": 16, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Board" + }, + "lot": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Lot" + }, + "name": { + "anyOf": [ + { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "sector": { + "anyOf": [ + { + "maxLength": 64, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sector" + } + }, + "title": "InstrumentPatch", + "type": "object" + }, "JobStatus": { "enum": [ "queued", @@ -3174,6 +3355,155 @@ "title": "LotOut", "type": "object" }, + "ManualEventCreate": { + "additionalProperties": false, + "properties": { + "account_id": { + "title": "Account Id", + "type": "integer" + }, + "accrued_interest": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accrued Interest" + }, + "amount": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Amount" + }, + "currency": { + "anyOf": [ + { + "maxLength": 3, + "minLength": 3, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Currency" + }, + "description": { + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "fee": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Fee" + }, + "instrument_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Instrument Id" + }, + "kind": { + "$ref": "#/components/schemas/EventKind" + }, + "price": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Price" + }, + "quantity": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Quantity" + }, + "trade_date": { + "format": "date", + "title": "Trade Date", + "type": "string" + } + }, + "required": [ + "account_id", + "kind", + "trade_date" + ], + "title": "ManualEventCreate", + "type": "object" + }, + "MetricsStatusOut": { + "properties": { + "consistent": { + "title": "Consistent", + "type": "boolean" + }, + "last_refresh": { + "anyOf": [ + { + "$ref": "#/components/schemas/RefreshLogOut" + }, + { + "type": "null" + } + ] + }, + "refreshing": { + "title": "Refreshing", + "type": "boolean" + } + }, + "required": [ + "last_refresh", + "consistent", + "refreshing" + ], + "title": "MetricsStatusOut", + "type": "object" + }, "NetWorthBreakdown": { "properties": { "accounts": { @@ -3605,6 +3935,94 @@ "title": "PendingResolveResult", "type": "object" }, + "PortfolioAccountsIn": { + "additionalProperties": false, + "description": "The complete set: accounts not listed are removed from the portfolio.", + "properties": { + "account_ids": { + "items": { + "type": "integer" + }, + "title": "Account Ids", + "type": "array" + } + }, + "required": [ + "account_ids" + ], + "title": "PortfolioAccountsIn", + "type": "object" + }, + "PortfolioCreate": { + "additionalProperties": false, + "properties": { + "account_ids": { + "items": { + "type": "integer" + }, + "title": "Account Ids", + "type": "array" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "PortfolioCreate", + "type": "object" + }, + "PortfolioOut": { + "properties": { + "account_ids": { + "items": { + "type": "integer" + }, + "title": "Account Ids", + "type": "array" + }, + "base_currency": { + "title": "Base Currency", + "type": "string" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "id", + "name", + "base_currency", + "account_ids" + ], + "title": "PortfolioOut", + "type": "object" + }, + "PortfolioPatch": { + "additionalProperties": false, + "properties": { + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "PortfolioPatch", + "type": "object" + }, "PriceManualIn": { "properties": { "currency": { @@ -4066,6 +4484,17 @@ ], "title": "Error" }, + "failed_step": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failed Step" + }, "finished_at": { "anyOf": [ { @@ -4087,6 +4516,20 @@ "title": "Started At", "type": "string" }, + "step_timings": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Step Timings" + }, "trigger": { "title": "Trigger", "type": "string" @@ -4097,7 +4540,9 @@ "started_at", "finished_at", "trigger", - "error" + "error", + "failed_step", + "step_timings" ], "title": "RefreshLogOut", "type": "object" @@ -4649,6 +5094,139 @@ "title": "SampleEventOut", "type": "object" }, + "ScopeCardOut": { + "description": "One tile of the home screen: a scope with what a glance needs, from the metric tables.", + "properties": { + "as_of": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "As Of" + }, + "day_change_pct": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Day Change Pct" + }, + "day_change_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Day Change Rub" + }, + "income_year_pct": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Income Year Pct" + }, + "income_year_rub": { + "description": "decimal as string", + "title": "Income Year Rub", + "type": "string" + }, + "invested_rub": { + "description": "decimal as string", + "title": "Invested Rub", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "pnl_pct": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pnl Pct" + }, + "pnl_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pnl Rub" + }, + "scope": { + "title": "Scope", + "type": "string" + }, + "total_rub": { + "description": "decimal as string", + "title": "Total Rub", + "type": "string" + }, + "xirr": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Xirr" + } + }, + "required": [ + "scope", + "name", + "kind", + "as_of", + "total_rub", + "invested_rub", + "pnl_rub", + "pnl_pct", + "day_change_rub", + "day_change_pct", + "xirr", + "income_year_rub", + "income_year_pct" + ], + "title": "ScopeCardOut", + "type": "object" + }, "ScopeOut": { "description": "One reportable set of accounts: `all`, `account:` or `portfolio:`.", "properties": { @@ -6201,6 +6779,51 @@ "tags": [ "accounts" ] + }, + "post": { + "description": "Create the broker account a report import will write into.\n\n`source_id` is the agreement number the report prints: it is how the import finds the\naccount on its own the next time.", + "operationId": "accounts_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create", + "tags": [ + "accounts" + ] } }, "/api/v1/accounts/{account_id}": { @@ -6514,6 +7137,47 @@ ] } }, + "/api/v1/analytics/overview": { + "get": { + "description": "One card per scope for the home screen, in a single round trip.\n\n`all` first, then the portfolios, then the accounts by value. A scope holding nothing at\nall is left out: a card of zeros is noise, not an answer.", + "operationId": "analytics_overview", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ScopeCardOut" + }, + "title": "Response Analytics Overview", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Overview", + "tags": [ + "analytics" + ] + } + }, "/api/v1/analytics/returns": { "get": { "description": "XIRR and TWR per period, shortest first.", @@ -7413,6 +8077,92 @@ "tags": [ "events" ] + }, + "post": { + "description": "Enter an event by hand. It is `confirmed` at once: it is the user's own statement, so\nit is never shadowed by the account's primary feed.", + "operationId": "events_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualEventCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create", + "tags": [ + "events" + ] + } + }, + "/api/v1/events/{event_id}": { + "delete": { + "description": "Delete an event that was entered by hand. Broker events are evidence, not ours to erase:\nthe next sync or import would bring them straight back.", + "operationId": "events_delete", + "parameters": [ + { + "in": "path", + "name": "event_id", + "required": true, + "schema": { + "title": "Event Id", + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete", + "tags": [ + "events" + ] } }, "/api/v1/goals": { @@ -7655,6 +8405,7 @@ }, "/api/v1/health": { "get": { + "description": "503 when the database cannot be reached, so a container healthcheck can trust the code.", "operationId": "health_check", "responses": { "200": { @@ -8544,6 +9295,62 @@ "tags": [ "instruments" ] + }, + "patch": { + "description": "Correct an instrument by hand: name, board, lot, asset class, sector.\n\nThe lot only rounds the quantities the rebalancing suggests; the asset class decides the\nbucket it lands in. Neither is refreshed here — call `POST /metrics/refresh` after, same\nas `/rules/apply`.", + "operationId": "instruments_patch", + "parameters": [ + { + "in": "path", + "name": "instrument_id", + "required": true, + "schema": { + "title": "Instrument Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstrumentPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstrumentOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Patch", + "tags": [ + "instruments" + ] } }, "/api/v1/instruments/{instrument_id}/prices": { @@ -8731,14 +9538,14 @@ }, "/api/v1/metrics/refresh": { "post": { - "description": "Rebuild every metric_* table inline (seconds at personal volumes).", + "description": "Queue a rebuild of every metric_* table; the worker runs it. Poll `/metrics/status`\nuntil `refreshing` is false.\n\nA request while one is already waiting shares it. One that arrives while a rebuild is\nRUNNING queues another, because the running one may have read the data before the change\nthat prompted this call.", "operationId": "metrics_refresh", "responses": { "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RefreshLogOut" + "$ref": "#/components/schemas/SyncJobOut" } } }, @@ -8768,22 +9575,14 @@ }, "/api/v1/metrics/status": { "get": { - "description": "When the metric tables were last rebuilt, and whether it failed.", + "description": "When the metric tables were last rebuilt, whether they are one consistent snapshot,\nand whether another rebuild is on its way.", "operationId": "metrics_status", "responses": { "200": { "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/RefreshLogOut" - }, - { - "type": "null" - } - ], - "title": "Response Metrics Status" + "$ref": "#/components/schemas/MetricsStatusOut" } } }, @@ -8925,6 +9724,242 @@ ] } }, + "/api/v1/portfolios": { + "get": { + "operationId": "portfolios_list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PortfolioOut" + }, + "title": "Response Portfolios List", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List", + "tags": [ + "portfolios" + ] + }, + "post": { + "operationId": "portfolios_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PortfolioCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PortfolioOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create", + "tags": [ + "portfolios" + ] + } + }, + "/api/v1/portfolios/{portfolio_id}": { + "delete": { + "operationId": "portfolios_delete", + "parameters": [ + { + "in": "path", + "name": "portfolio_id", + "required": true, + "schema": { + "title": "Portfolio Id", + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete", + "tags": [ + "portfolios" + ] + }, + "patch": { + "operationId": "portfolios_patch", + "parameters": [ + { + "in": "path", + "name": "portfolio_id", + "required": true, + "schema": { + "title": "Portfolio Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PortfolioPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PortfolioOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Patch", + "tags": [ + "portfolios" + ] + } + }, + "/api/v1/portfolios/{portfolio_id}/accounts": { + "put": { + "operationId": "portfolios_set_accounts", + "parameters": [ + { + "in": "path", + "name": "portfolio_id", + "required": true, + "schema": { + "title": "Portfolio Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PortfolioAccountsIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PortfolioOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Set Accounts", + "tags": [ + "portfolios" + ] + } + }, "/api/v1/portfolios/{portfolio_id}/rebalance": { "get": { "operationId": "rebalance_rebalance",