From 3727419506f5588a9fd8265a81ba09739b753276 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 18 Sep 2026 14:20:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B8=D0=BD=D0=B2=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D1=86=D0=B8=D0=B9,=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /analytics/{scopes,summary,value-series,holdings,returns,allocation}, /events с фильтрами и /instruments/{id}. На запрос ничего не считается — это чтение metric_*, благодаря чему экраны читаются быстро и показывают одно и то же число. scope (all | account: | portfolio:) резолвится через ту же функцию, что его построила, valuation.account_scopes: scope, для которого метрик нет, отдаёт 404, а не пустой график, который читался бы как пустой портфель. asset_class уходит наружу строкой, а не енумом. Одно из его значений — index, а Dart-енум не может назвать член index: он конфликтует с Enum.index, и сгенерированный клиент перестаёт компилироваться. flutter analyze это пропускает, flutter test ловит. Фильтр /events?external_flow=false сравнивает meta через is_not_distinct_from, а не через равенство: у события без meta сравнение даёт NULL, NOT NULL это тоже NULL, и равенство выбрасывало бы такие события из ОБЕИХ половин фильтра. --- backend/src/fintracker/api/app.py | 6 + .../src/fintracker/api/routers/analytics.py | 274 +++ backend/src/fintracker/api/routers/events.py | 142 ++ .../src/fintracker/api/routers/instruments.py | 153 ++ .../src/fintracker/api/schemas/analytics.py | 199 ++ backend/src/fintracker/api/scopes.py | 62 + backend/tests/api/test_analytics_api.py | 175 ++ openapi/openapi.json | 1953 +++++++++++++++++ 8 files changed, 2964 insertions(+) create mode 100644 backend/src/fintracker/api/routers/analytics.py create mode 100644 backend/src/fintracker/api/routers/events.py create mode 100644 backend/src/fintracker/api/routers/instruments.py create mode 100644 backend/src/fintracker/api/schemas/analytics.py create mode 100644 backend/src/fintracker/api/scopes.py create mode 100644 backend/tests/api/test_analytics_api.py diff --git a/backend/src/fintracker/api/app.py b/backend/src/fintracker/api/app.py index 84a81f0..168c72f 100644 --- a/backend/src/fintracker/api/app.py +++ b/backend/src/fintracker/api/app.py @@ -16,10 +16,13 @@ from fintracker import __version__ from fintracker.api.errors import install_error_handlers from fintracker.api.routers import ( accounts, + analytics, auth, cashflow, categories, + events, health, + instruments, metrics, networth, rules, @@ -80,6 +83,9 @@ def create_app() -> FastAPI: app.include_router(rules.router, prefix=API_PREFIX) app.include_router(networth.router, prefix=API_PREFIX) app.include_router(cashflow.router, prefix=API_PREFIX) + app.include_router(analytics.router, prefix=API_PREFIX) + app.include_router(events.router, prefix=API_PREFIX) + app.include_router(instruments.router, prefix=API_PREFIX) app.include_router(metrics.router, prefix=API_PREFIX) if settings.web_dir is not None: mount_web(app, settings.web_dir, API_PREFIX) diff --git a/backend/src/fintracker/api/routers/analytics.py b/backend/src/fintracker/api/routers/analytics.py new file mode 100644 index 0000000..20d0edc --- /dev/null +++ b/backend/src/fintracker/api/routers/analytics.py @@ -0,0 +1,274 @@ +"""Investment analytics: what the portfolio is worth, what it earned, how it is split. + +Everything here is a read of a `metric_*` table that `fintracker metrics refresh` has already +built — no computation happens per request, which is what keeps the screens under 100 ms and +makes every screen show the same number (plan §3). +""" + +from __future__ import annotations + +from datetime import date, timedelta +from decimal import Decimal +from typing import Annotated + +from fastapi import APIRouter, Query +from sqlalchemy import func, select + +from fintracker.api.deps import CurrentUser, SessionDep +from fintracker.api.schemas.analytics import ( + AllocationBucket, + HoldingOut, + ReturnsOut, + ScopeOut, + SummaryOut, + ValueDay, +) +from fintracker.api.scopes import DEFAULT_SCOPE, list_scopes, resolve_scope +from fintracker.models import ( + AllocationDimension, + Instrument, + MetricAllocation, + MetricHolding, + MetricPortfolioValueDaily, + MetricRefreshLog, + MetricReturns, +) + +router = APIRouter(prefix="/analytics", tags=["analytics"]) + +DEFAULT_WINDOW_DAYS = 365 +ZERO = Decimal(0) + +#: Order the periods are shown in; the table stores them unordered. +PERIOD_ORDER = {p: i for i, p in enumerate(("1m", "3m", "6m", "ytd", "1y", "3y", "all"))} + +ScopeParam = Annotated[str, Query(description="all | account: | portfolio:")] + + +@router.get("/scopes", name="scopes") +async def scopes(session: SessionDep, _: CurrentUser) -> list[ScopeOut]: + """Every set of accounts the metrics were built for.""" + return await list_scopes(session) + + +@router.get("/value-series", name="value_series") +async def value_series( + session: SessionDep, + _: CurrentUser, + scope: ScopeParam = DEFAULT_SCOPE, + date_from: Annotated[date | None, Query(alias="from")] = None, + date_to: Annotated[date | None, Query(alias="to")] = None, +) -> list[ValueDay]: + """Daily portfolio value; defaults to the last 365 days.""" + await resolve_scope(session, scope) + conditions = [MetricPortfolioValueDaily.scope == scope] + if date_to is not None: + conditions.append(MetricPortfolioValueDaily.d <= date_to) + if date_from is not None: + conditions.append(MetricPortfolioValueDaily.d >= date_from) + elif date_to is None: + last = ( + await session.execute( + select(func.max(MetricPortfolioValueDaily.d)).where( + MetricPortfolioValueDaily.scope == scope + ) + ) + ).scalar_one_or_none() + if last is not None: + conditions.append( + MetricPortfolioValueDaily.d >= last - timedelta(days=DEFAULT_WINDOW_DAYS) + ) + rows = ( + await session.execute( + select(MetricPortfolioValueDaily) + .where(*conditions) + .order_by(MetricPortfolioValueDaily.d) + ) + ).scalars() + return [_value_day(r) for r in rows] + + +@router.get("/holdings", name="holdings") +async def holdings( + session: SessionDep, _: CurrentUser, scope: ScopeParam = DEFAULT_SCOPE +) -> list[HoldingOut]: + """Open positions, most valuable first; unpriced ones last with null values.""" + await resolve_scope(session, scope) + return await load_holdings(session, scope) + + +@router.get("/returns", name="returns") +async def returns( + session: SessionDep, _: CurrentUser, scope: ScopeParam = DEFAULT_SCOPE +) -> list[ReturnsOut]: + """XIRR and TWR per period, shortest first.""" + await resolve_scope(session, scope) + return await load_returns(session, scope) + + +@router.get("/allocation", name="allocation") +async def allocation( + session: SessionDep, + _: CurrentUser, + scope: ScopeParam = DEFAULT_SCOPE, + dimension: AllocationDimension | None = None, +) -> list[AllocationBucket]: + """Buckets of one dimension (or all four), largest first.""" + await resolve_scope(session, scope) + conditions = [MetricAllocation.scope == scope] + if dimension is not None: + conditions.append(MetricAllocation.dimension == dimension) + rows = ( + await session.execute( + select(MetricAllocation) + .where(*conditions) + .order_by(MetricAllocation.dimension, MetricAllocation.value_rub.desc()) + ) + ).scalars() + return [ + AllocationBucket( + dimension=r.dimension, + bucket=r.bucket, + value_rub=r.value_rub, + weight=r.weight, + holding_count=r.holding_count, + ) + for r in rows + ] + + +@router.get("/summary", name="summary") +async def summary( + session: SessionDep, _: CurrentUser, scope: ScopeParam = DEFAULT_SCOPE +) -> SummaryOut: + """One screen's worth: the latest day, the totals it adds up to, and the returns.""" + await resolve_scope(session, scope) + latest = ( + await session.execute( + select(MetricPortfolioValueDaily) + .where(MetricPortfolioValueDaily.scope == scope) + .order_by(MetricPortfolioValueDaily.d.desc()) + .limit(1) + ) + ).scalar_one_or_none() + rows = list( + (await session.execute(select(MetricHolding).where(MetricHolding.scope == scope))) + .scalars() + .all() + ) + computed_at = ( + await session.execute( + select(MetricRefreshLog.finished_at) + .where(MetricRefreshLog.finished_at.is_not(None), MetricRefreshLog.error.is_(None)) + .order_by(MetricRefreshLog.finished_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + return SummaryOut( + scope=scope, + as_of=latest.d if latest is not None else None, + computed_at=computed_at, + market_value_rub=latest.market_value_rub if latest is not None else ZERO, + cash_rub=latest.cash_rub if latest is not None else ZERO, + total_rub=latest.total_rub if latest is not None else ZERO, + invested_net_rub=latest.invested_net_rub if latest is not None else ZERO, + pnl_total_rub=latest.pnl_total_rub if latest is not None else None, + realized_pnl_rub=sum((r.realized_pnl_rub or ZERO for r in rows), start=ZERO), + income_rub=sum((r.income_rub or ZERO for r in rows), start=ZERO), + holding_count=len(rows), + unpriced_count=sum(1 for r in rows if r.price_status == "missing"), + stale_count=sum(1 for r in rows if r.price_status == "stale"), + returns=await load_returns(session, scope), + ) + + +async def load_holdings( + session: SessionDep, scope: str, *, instrument_id: int | None = None +) -> list[HoldingOut]: + """Holdings of a scope, joined with the instrument facts the screens need.""" + conditions = [MetricHolding.scope == scope] + if instrument_id is not None: + conditions.append(MetricHolding.instrument_id == instrument_id) + rows = ( + await session.execute( + select(MetricHolding, Instrument) + .join(Instrument, Instrument.id == MetricHolding.instrument_id) + .where(*conditions) + # nulls last: an unpriced position belongs at the bottom, not at the top + .order_by(MetricHolding.value_rub.desc().nullslast(), Instrument.ticker) + ) + ).all() + return [_holding(h, i) for h, i in rows] + + +async def load_returns(session: SessionDep, scope: str) -> list[ReturnsOut]: + rows = list( + (await session.execute(select(MetricReturns).where(MetricReturns.scope == scope))) + .scalars() + .all() + ) + rows.sort(key=lambda r: PERIOD_ORDER.get(r.period, len(PERIOD_ORDER))) + return [ + ReturnsOut( + period=r.period, + date_from=r.date_from, + date_to=r.date_to, + value_start_rub=r.value_start_rub, + value_end_rub=r.value_end_rub, + external_flow_rub=r.external_flow_rub, + abs_pnl_rub=r.abs_pnl_rub, + xirr=r.xirr, + twr=r.twr, + twr_annualized=r.twr_annualized, + twr_days_skipped=r.twr_days_skipped, + ) + for r in rows + ] + + +def _value_day(row: MetricPortfolioValueDaily) -> ValueDay: + return ValueDay( + d=row.d, + market_value_rub=row.market_value_rub, + accrued_interest_rub=row.accrued_interest_rub, + cash_rub=row.cash_rub, + total_rub=row.total_rub, + external_flow_rub=row.external_flow_rub, + invested_net_rub=row.invested_net_rub, + pnl_total_rub=row.pnl_total_rub, + stale_price_count=row.stale_price_count, + missing_price_count=row.missing_price_count, + missing_fx_count=row.missing_fx_count, + ) + + +def _holding(row: MetricHolding, instrument: Instrument) -> HoldingOut: + return HoldingOut( + instrument_id=row.instrument_id, + ticker=instrument.ticker, + name=instrument.name, + asset_class=instrument.asset_class.value, + board=instrument.board, + currency=instrument.currency, + qty=row.qty, + avg_cost=row.avg_cost, + cost_currency=row.cost_currency, + cost_total_rub=row.cost_total_rub, + market_price=row.market_price, + price_currency=row.price_currency, + price_date=row.price_date, + price_status=row.price_status, + value_native=row.value_native, + value_rub=row.value_rub, + accrued_interest_rub=row.accrued_interest_rub, + unrealized_pnl_native=row.unrealized_pnl_native, + unrealized_pnl_rub=row.unrealized_pnl_rub, + realized_pnl_rub=row.realized_pnl_rub, + income_rub=row.income_rub, + weight=row.weight, + xirr=row.xirr, + first_buy_date=row.first_buy_date, + days_held=row.days_held, + ldv_eligible_qty=row.ldv_eligible_qty, + ) diff --git a/backend/src/fintracker/api/routers/events.py b/backend/src/fintracker/api/routers/events.py new file mode 100644 index 0000000..bbe9cb2 --- /dev/null +++ b/backend/src/fintracker/api/routers/events.py @@ -0,0 +1,142 @@ +"""The broker event ledger, read-only for now (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. +""" + +from __future__ import annotations + +from datetime import date +from typing import Annotated + +from fastapi import APIRouter, Query +from sqlalchemy import Select, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from fintracker.api.deps import CurrentUser, SessionDep +from fintracker.api.schemas.analytics import EventOut, EventPage +from fintracker.models import Event, EventKind, EventStatus, Instrument +from fintracker.models.ledger import EXTERNAL_FLOW_KINDS +from fintracker.pricing.fx import FxTable + +router = APIRouter(prefix="/events", tags=["events"]) + + +@router.get("", name="list") +async def list_events( + session: SessionDep, + _: CurrentUser, + date_from: Annotated[date | None, Query(alias="from")] = None, + date_to: Annotated[date | None, Query(alias="to")] = None, + account_id: int | None = None, + instrument_id: int | None = None, + kind: EventKind | None = None, + status: EventStatus | None = None, + external_flow: Annotated[ + bool | None, Query(description="only the events XIRR reads as boundary flows") + ] = None, + q: Annotated[str | None, Query(description="substring of description or ticker")] = None, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=500)] = 50, +) -> EventPage: + """One page of ledger events, newest first, with RUB amounts at each own date's rate.""" + conditions = [] + if date_from is not None: + conditions.append(Event.trade_date >= date_from) + if date_to is not None: + conditions.append(Event.trade_date <= date_to) + if account_id is not None: + conditions.append(Event.account_id == account_id) + if instrument_id is not None: + conditions.append(Event.instrument_id == instrument_id) + if kind is not None: + conditions.append(Event.kind == kind) + if status is not None: + conditions.append(Event.status == status) + if external_flow is not None: + # a card-funded trade is a boundary flow too, though its kind is buy or sell. + # `is_not_distinct_from` rather than `==`: an event with no meta at all compares + # NULL, and `NOT NULL` is NULL, so plain equality would drop those rows from BOTH + # sides of the filter instead of putting them on the "not a flow" side. + is_flow = or_( + Event.kind.in_(EXTERNAL_FLOW_KINDS), + Event.meta["card_funded"].as_string().is_not_distinct_from("true"), + ) + conditions.append(is_flow if external_flow else ~is_flow) + if q: + pattern = f"%{q}%" + conditions.append(or_(Event.description.ilike(pattern), Instrument.ticker.ilike(pattern))) + + total = ( + await session.execute(_joined(select(func.count()).select_from(Event)).where(*conditions)) + ).scalar_one() + rows = ( + await session.execute( + _joined(select(Event, Instrument.ticker)) + .where(*conditions) + .order_by(Event.trade_date.desc(), Event.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).all() + + fx = await FxTable.load(session) + return EventPage( + items=[event_out(e, ticker, fx) for e, ticker in rows], + total=total, + page=page, + page_size=page_size, + ) + + +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) + + +def event_out(event: Event, ticker: str | None, fx: FxTable) -> EventOut: + return EventOut( + id=event.id, + account_id=event.account_id, + instrument_id=event.instrument_id, + ticker=ticker, + kind=event.kind, + status=event.status, + trade_date=event.trade_date, + ts=event.ts, + quantity=event.quantity, + price=event.price, + price_currency=event.price_currency, + amount=event.amount, + amount_rub=fx.to_rub(event.amount, event.currency, event.trade_date), + currency=event.currency, + fee=event.fee, + tax=event.tax, + accrued_interest=event.accrued_interest, + description=event.description, + external_flow=is_external_flow(event), + ) + + +def is_external_flow(event: Event) -> bool: + if event.kind in EXTERNAL_FLOW_KINDS: + return True + meta = event.meta + return bool(meta.get("card_funded")) if isinstance(meta, dict) else False + + +async def events_of_instrument( + session: AsyncSession, instrument_id: int, *, limit: int = 500 +) -> list[EventOut]: + """Every event touching one instrument, newest first — the instrument card's history.""" + rows = ( + await session.execute( + _joined(select(Event, Instrument.ticker)) + .where(Event.instrument_id == instrument_id) + .order_by(Event.trade_date.desc(), Event.id.desc()) + .limit(limit) + ) + ).all() + fx = await FxTable.load(session) + return [event_out(e, ticker, fx) for e, ticker in rows] diff --git a/backend/src/fintracker/api/routers/instruments.py b/backend/src/fintracker/api/routers/instruments.py new file mode 100644 index 0000000..c5cd160 --- /dev/null +++ b/backend/src/fintracker/api/routers/instruments.py @@ -0,0 +1,153 @@ +"""The instrument master and the instrument card (plan §4). + +The card is where a position stops being a row and becomes an account of itself: which lots +are open, at what cost, every event that touched it, and the price history behind the value. +""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import Annotated + +from fastapi import APIRouter, Query +from sqlalchemy import select + +from fintracker.analytics import today_local +from fintracker.api.deps import CurrentUser, SessionDep +from fintracker.api.errors import Problem +from fintracker.api.routers.analytics import load_holdings +from fintracker.api.routers.events import events_of_instrument +from fintracker.api.schemas.analytics import ( + InstrumentDetail, + InstrumentOut, + LotOut, + PricePoint, +) +from fintracker.api.scopes import DEFAULT_SCOPE, resolve_scope +from fintracker.models import AssetClass, Instrument, Lot, PriceDaily + +router = APIRouter(prefix="/instruments", tags=["instruments"]) + +PRICE_WINDOW_DAYS = 365 + + +@router.get("", name="list") +async def list_instruments( + session: SessionDep, + _: CurrentUser, + q: Annotated[str | None, Query(description="substring of ticker, name or ISIN")] = None, + asset_class: Annotated[ + str | None, Query(description="share | bond | etf | fund | currency | index | …") + ] = None, + held_only: Annotated[bool, Query(description="only instruments with an open lot")] = False, + limit: Annotated[int, Query(ge=1, le=500)] = 100, +) -> list[InstrumentOut]: + conditions = [] + if q: + pattern = f"%{q}%" + conditions.append( + Instrument.ticker.ilike(pattern) + | Instrument.name.ilike(pattern) + | Instrument.isin.ilike(pattern) + ) + if asset_class is not None: + try: + conditions.append(Instrument.asset_class == AssetClass(asset_class)) + except ValueError: + known = ", ".join(a.value for a in AssetClass) + raise Problem( + 400, "Bad Request", f"Неизвестный класс актива {asset_class!r}; есть: {known}" + ) from None + if held_only: + conditions.append( + Instrument.id.in_(select(Lot.instrument_id).where(Lot.qty_remaining != 0)) + ) + rows = ( + await session.execute( + select(Instrument) + .where(*conditions) + .order_by(Instrument.ticker, Instrument.name) + .limit(limit) + ) + ).scalars() + return [_instrument(r) for r in rows] + + +@router.get("/{instrument_id}", name="get") +async def get_instrument( + session: SessionDep, + _: CurrentUser, + instrument_id: int, + scope: Annotated[str, Query(description="all | account: | portfolio:")] = DEFAULT_SCOPE, + price_from: Annotated[date | None, Query(alias="prices_from")] = None, +) -> InstrumentDetail: + """One instrument with its position, open lots, events and price history.""" + instrument = await session.get(Instrument, instrument_id) + if instrument is None: + raise Problem(404, "Not Found", f"Нет инструмента #{instrument_id}") + resolved = await resolve_scope(session, scope) + + holdings = await load_holdings(session, scope, instrument_id=instrument_id) + lots = ( + await session.execute( + select(Lot) + .where(Lot.instrument_id == instrument_id, Lot.account_id.in_(resolved.account_ids)) + .order_by(Lot.open_date, Lot.id) + ) + ).scalars() + since = price_from or today_local() - timedelta(days=PRICE_WINDOW_DAYS) + prices = ( + await session.execute( + select(PriceDaily) + .where(PriceDaily.instrument_id == instrument_id, PriceDaily.d >= since) + .order_by(PriceDaily.d) + ) + ).scalars() + + return InstrumentDetail( + instrument=_instrument(instrument), + holding=holdings[0] if holdings else None, + lots=[ + LotOut( + id=lot.id, + account_id=lot.account_id, + open_date=lot.open_date, + qty_open=lot.qty_open, + qty_remaining=lot.qty_remaining, + cost_per_unit=lot.cost_per_unit, + cost_currency=lot.cost_currency, + cost_total_rub=lot.cost_total_rub, + closed_at=lot.closed_at, + ) + for lot in lots + ], + events=await events_of_instrument(session, instrument_id), + prices=[ + PricePoint( + d=p.d, close=p.close, currency=p.currency, accrued_interest=p.accrued_interest + ) + for p in prices + ], + ) + + +def _instrument(row: Instrument) -> InstrumentOut: + return InstrumentOut( + id=row.id, + asset_class=row.asset_class.value, + isin=row.isin, + figi=row.figi, + ticker=row.ticker, + board=row.board, + exchange=row.exchange, + name=row.name, + issuer=row.issuer, + currency=row.currency, + lot=row.lot, + nominal=row.nominal, + nominal_currency=row.nominal_currency, + maturity_date=row.maturity_date, + sector=row.sector, + country=row.country, + is_active=row.is_active, + ) diff --git a/backend/src/fintracker/api/schemas/analytics.py b/backend/src/fintracker/api/schemas/analytics.py new file mode 100644 index 0000000..16415fe --- /dev/null +++ b/backend/src/fintracker/api/schemas/analytics.py @@ -0,0 +1,199 @@ +"""Schemas for the investment analytics endpoints (plan §4). + +Every RUB figure that can be unknown is nullable, and it is null for exactly one reason: +something had no price or no rate on the day it was needed. The client must show that as +"нет цены", never as 0 ₽ — a zero would quietly understate the portfolio. +""" + +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel + +from fintracker.api.schemas.common import Money, MoneyOpt +from fintracker.models import AllocationDimension, EventKind, EventStatus + + +class ScopeOut(BaseModel): + """One reportable set of accounts: `all`, `account:` or `portfolio:`.""" + + scope: str + name: str + account_ids: list[int] + + +class ValueDay(BaseModel): + d: date + market_value_rub: Money + accrued_interest_rub: Money + """The НКД part of `market_value_rub`, broken out.""" + cash_rub: Money + total_rub: Money + external_flow_rub: Money + invested_net_rub: Money + pnl_total_rub: MoneyOpt + """Null on a day where a price or a rate was missing, so the total is incomplete.""" + stale_price_count: int + missing_price_count: int + missing_fx_count: int + + +class HoldingOut(BaseModel): + instrument_id: int + ticker: str | None + name: str + asset_class: str + """Stable key: share | bond | etf | fund | currency | index | deposit | …""" + board: str | None + currency: str + qty: Money + """Signed: negative while a short position is open.""" + avg_cost: MoneyOpt + cost_currency: str | None + cost_total_rub: MoneyOpt + market_price: MoneyOpt + price_currency: str | None + price_date: date | None + price_status: str + """ok | stale | missing — `missing` is why the value fields are null.""" + value_native: MoneyOpt + value_rub: MoneyOpt + accrued_interest_rub: MoneyOpt + unrealized_pnl_native: MoneyOpt + unrealized_pnl_rub: MoneyOpt + realized_pnl_rub: MoneyOpt + income_rub: MoneyOpt + weight: MoneyOpt + xirr: MoneyOpt + """Money-weighted return of this instrument; null under 30 days of history.""" + first_buy_date: date | None + days_held: int | None + ldv_eligible_qty: Money + + +class ReturnsOut(BaseModel): + period: str + """1m | 3m | 6m | ytd | 1y | 3y | all""" + date_from: date + date_to: date + value_start_rub: Money + value_end_rub: Money + external_flow_rub: Money + abs_pnl_rub: Money + xirr: MoneyOpt + twr: MoneyOpt + twr_annualized: MoneyOpt + twr_days_skipped: int + """Days left out of the TWR chain because the portfolio could not be valued in full.""" + + +class AllocationBucket(BaseModel): + dimension: AllocationDimension + bucket: str + """A key, not a label: an asset class, sector, country code, currency, `cash`, `unknown`.""" + value_rub: Money + weight: Money + holding_count: int + + +class SummaryOut(BaseModel): + """The dashboard's one-screen answer for a scope.""" + + scope: str + as_of: date | None + """Null when no metrics have been built yet.""" + computed_at: datetime | None + market_value_rub: Money + cash_rub: Money + total_rub: Money + invested_net_rub: Money + pnl_total_rub: MoneyOpt + realized_pnl_rub: Money + income_rub: Money + holding_count: int + unpriced_count: int + """Positions with no price at all: their value is missing from the totals above.""" + stale_count: int + returns: list[ReturnsOut] + + +class LotOut(BaseModel): + id: int + account_id: int + open_date: date + qty_open: Money + qty_remaining: Money + cost_per_unit: Money + cost_currency: str + cost_total_rub: MoneyOpt + closed_at: date | None + + +class EventOut(BaseModel): + id: int + account_id: int + instrument_id: int | None + ticker: str | None + kind: EventKind + status: EventStatus + trade_date: date + ts: datetime + quantity: MoneyOpt + price: MoneyOpt + price_currency: str | None + amount: Money + amount_rub: MoneyOpt + """Converted at the rate of the trade date; null when that day has no rate.""" + currency: str + fee: MoneyOpt + tax: MoneyOpt + accrued_interest: MoneyOpt + description: str | None + external_flow: bool + """True when this event moved money across the portfolio boundary (XIRR reads these).""" + + +class EventPage(BaseModel): + items: list[EventOut] + total: int + page: int + page_size: int + + +class PricePoint(BaseModel): + d: date + close: Money + currency: str + accrued_interest: MoneyOpt + + +class InstrumentOut(BaseModel): + id: int + asset_class: str + """Stable key, not an enum on the wire: one of its values is `index`, and a generated + Dart client cannot name an enum member that — it collides with `Enum.index`.""" + isin: str | None + figi: str | None + ticker: str | None + board: str | None + exchange: str | None + name: str + issuer: str | None + currency: str + lot: int + nominal: MoneyOpt + nominal_currency: str | None + maturity_date: date | None + sector: str | None + country: str | None + is_active: bool + + +class InstrumentDetail(BaseModel): + instrument: InstrumentOut + holding: HoldingOut | None + """Null when nothing is held in this scope right now.""" + lots: list[LotOut] + events: list[EventOut] + prices: list[PricePoint] diff --git a/backend/src/fintracker/api/scopes.py b/backend/src/fintracker/api/scopes.py new file mode 100644 index 0000000..1728472 --- /dev/null +++ b/backend/src/fintracker/api/scopes.py @@ -0,0 +1,62 @@ +"""Resolving the `scope` query parameter that every analytics endpoint takes. + +A scope is the reporting unit the metric tables are keyed by: `all`, `account:` or +`portfolio:`. The strings are built by `analytics/valuation.py`, so the API resolves them +through the same function rather than re-deriving the rules — a scope the metrics never built +must 404, not return an empty chart that looks like an empty portfolio. +""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from fintracker.analytics.valuation import account_scopes +from fintracker.api.errors import Problem +from fintracker.api.schemas.analytics import ScopeOut +from fintracker.models import Account, Event, EventStatus, Portfolio + +DEFAULT_SCOPE = "all" +ALL_NAME = "Все счета" + + +async def _ledger_account_ids(session: AsyncSession) -> set[int]: + """Accounts the event ledger actually covers — the ones metrics were built for.""" + return set( + ( + await session.execute( + select(Event.account_id).where(Event.status == EventStatus.confirmed).distinct() + ) + ) + .scalars() + .all() + ) + + +async def _names(session: AsyncSession) -> dict[str, str]: + accounts = (await session.execute(select(Account.id, Account.name))).all() + portfolios = (await session.execute(select(Portfolio.id, Portfolio.name))).all() + out = {DEFAULT_SCOPE: ALL_NAME} + out |= {f"account:{r.id}": r.name for r in accounts} + out |= {f"portfolio:{r.id}": r.name for r in portfolios} + return out + + +async def list_scopes(session: AsyncSession) -> list[ScopeOut]: + """Every scope the metrics were built for, `all` first and the rest by name.""" + scopes = await account_scopes(session, await _ledger_account_ids(session)) + names = await _names(session) + out = [ + ScopeOut(scope=scope, name=names.get(scope, scope), account_ids=sorted(ids)) + for scope, ids in scopes.items() + ] + out.sort(key=lambda s: (s.scope != DEFAULT_SCOPE, s.name)) + return out + + +async def resolve_scope(session: AsyncSession, scope: str) -> ScopeOut: + """The scope, or a 404 naming what does exist.""" + for candidate in await list_scopes(session): + if candidate.scope == scope: + return candidate + raise Problem(404, "Not Found", f"Нет такого scope: {scope}") diff --git a/backend/tests/api/test_analytics_api.py b/backend/tests/api/test_analytics_api.py new file mode 100644 index 0000000..1884f80 --- /dev/null +++ b/backend/tests/api/test_analytics_api.py @@ -0,0 +1,175 @@ +"""The investment analytics endpoints over a small real portfolio.""" + +from datetime import timedelta + +import pytest +from httpx import AsyncClient + +from factories import ( + make_account, + make_event, + make_instrument, + make_price, + refresh, +) +from fintracker.analytics import today_local +from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind + + +@pytest.fixture +async def portfolio(app) -> dict[str, int]: + """One broker account: a priced share, an unpriced bond and some leftover cash.""" + t = today_local() + bought = t - timedelta(days=40) + account = await make_account( + name="Брокерский", + kind=AccountKind.broker, + role=AccountRole.investment, + balance=None, + include_in_net_worth=False, + source="tinvest", + ) + gazp = await make_instrument(ticker="GAZP", name="Газпром", asset_class=AssetClass.share) + silent = await make_instrument( + ticker="SIBN6P4", name="Газпром Нефть", asset_class=AssetClass.bond, board="SPBRUBND" + ) + + await make_event(bought, account_id=account, kind=EventKind.deposit, amount="20000") + await make_event( + bought, + account_id=account, + kind=EventKind.buy, + instrument_id=gazp, + quantity="100", + price="100", + amount="-10000", + ) + await make_event( + bought, + account_id=account, + kind=EventKind.buy, + instrument_id=silent, + quantity="5", + price="1000", + amount="-5000", + ) + await make_event( + t - timedelta(days=10), + account_id=account, + kind=EventKind.dividend, + instrument_id=gazp, + amount="700", + ) + d = bought + while d <= t: + await make_price(d, instrument_id=gazp, close="110") + d += timedelta(days=1) + await refresh() + return {"account": account, "gazp": gazp, "silent": silent} + + +async def test_scopes_list_all_and_each_account( + client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int] +): + r = await client.get("/api/v1/analytics/scopes", headers=auth_headers) + assert r.status_code == 200, r.text + scopes = {s["scope"]: s for s in r.json()} + assert scopes["all"]["name"] == "Все счета" + assert scopes[f"account:{portfolio['account']}"]["name"] == "Брокерский" + + +async def test_summary_reports_totals_and_names_what_is_missing( + client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int] +): + r = await client.get("/api/v1/analytics/summary", headers=auth_headers) + assert r.status_code == 200, r.text + body = r.json() + assert body["market_value_rub"] == "11000.0000000000" # the bond has no price at all + assert body["cash_rub"] == "5700.0000000000" + assert body["invested_net_rub"] == "20000.0000000000" + assert body["pnl_total_rub"] is None # incomplete, so not reported as a number + assert body["income_rub"] == "700.0000000000" + assert body["holding_count"] == 2 + assert body["unpriced_count"] == 1 + assert [p["period"] for p in body["returns"]][:1] == ["1m"] + + +async def test_holdings_put_the_unpriced_position_last_with_nulls( + client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int] +): + r = await client.get("/api/v1/analytics/holdings", headers=auth_headers) + assert r.status_code == 200, r.text + rows = r.json() + assert [row["ticker"] for row in rows] == ["GAZP", "SIBN6P4"] + assert rows[0]["value_rub"] == "11000.0000000000" + assert rows[0]["unrealized_pnl_rub"] == "1000.0000000000" + assert rows[1]["price_status"] == "missing" + assert rows[1]["value_rub"] is None + assert rows[1]["weight"] is None + + +async def test_allocation_covers_the_same_total_in_every_dimension( + client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int] +): + r = await client.get("/api/v1/analytics/allocation", headers=auth_headers) + assert r.status_code == 200, r.text + per_dimension: dict[str, list[dict]] = {} + for row in r.json(): + per_dimension.setdefault(row["dimension"], []).append(row) + + totals = { + dimension: sum(float(row["value_rub"]) for row in rows) + for dimension, rows in per_dimension.items() + } + assert set(totals) == {"asset_class", "sector", "country", "currency"} + assert len(set(totals.values())) == 1 # 11000 of shares + 5700 of cash, four ways + + by_bucket = {row["bucket"]: row for row in per_dimension["asset_class"]} + assert by_bucket["share"]["holding_count"] == 1 + assert by_bucket["cash"]["holding_count"] == 0 + + +async def test_value_series_defaults_to_the_last_year( + client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int] +): + r = await client.get("/api/v1/analytics/value-series", headers=auth_headers) + assert r.status_code == 200, r.text + rows = r.json() + assert rows[-1]["d"] == today_local().isoformat() + assert rows[-1]["missing_price_count"] == 1 + + +async def test_an_unknown_scope_is_a_problem_not_an_empty_chart( + client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int] +): + r = await client.get("/api/v1/analytics/summary?scope=account:999", headers=auth_headers) + assert r.status_code == 404 + assert r.headers["content-type"].startswith("application/problem+json") + + +async def test_the_instrument_card_carries_lots_events_and_prices( + client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int] +): + r = await client.get(f"/api/v1/instruments/{portfolio['gazp']}", headers=auth_headers) + assert r.status_code == 200, r.text + body = r.json() + assert body["instrument"]["ticker"] == "GAZP" + assert body["holding"]["qty"] == "100.0000000000" + assert len(body["lots"]) == 1 + assert {e["kind"] for e in body["events"]} == {"buy", "dividend"} + assert body["prices"][0]["close"] == "110.0000000000" + + +async def test_events_can_be_filtered_down_to_the_external_flows( + client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int] +): + r = await client.get("/api/v1/events?external_flow=true", headers=auth_headers) + assert r.status_code == 200, r.text + body = r.json() + assert body["total"] == 1 + assert body["items"][0]["kind"] == "deposit" + assert body["items"][0]["external_flow"] is True + + r = await client.get("/api/v1/events?external_flow=false", headers=auth_headers) + kinds = {item["kind"] for item in r.json()["items"]} + assert kinds == {"buy", "dividend"} diff --git a/openapi/openapi.json b/openapi/openapi.json index e18006d..61f6e72 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -283,6 +283,51 @@ "title": "AccountRole", "type": "string" }, + "AllocationBucket": { + "properties": { + "bucket": { + "title": "Bucket", + "type": "string" + }, + "dimension": { + "$ref": "#/components/schemas/AllocationDimension" + }, + "holding_count": { + "title": "Holding Count", + "type": "integer" + }, + "value_rub": { + "description": "decimal as string", + "title": "Value Rub", + "type": "string" + }, + "weight": { + "description": "decimal as string", + "title": "Weight", + "type": "string" + } + }, + "required": [ + "dimension", + "bucket", + "value_rub", + "weight", + "holding_count" + ], + "title": "AllocationBucket", + "type": "object" + }, + "AllocationDimension": { + "description": "How a portfolio can be sliced. Every dimension covers the SAME total — securities\nplus cash — so the weights of any one of them add up to 1 and the charts agree.", + "enum": [ + "asset_class", + "sector", + "country", + "currency" + ], + "title": "AllocationDimension", + "type": "string" + }, "Broker": { "enum": [ "tinvest", @@ -477,6 +522,240 @@ "title": "DataQualityRow", "type": "object" }, + "EventKind": { + "enum": [ + "buy", + "sell", + "dividend", + "coupon", + "interest", + "tax", + "tax_refund", + "commission", + "deposit", + "withdrawal", + "transfer_in", + "transfer_out", + "split", + "amortization", + "repayment", + "fx_exchange", + "other" + ], + "title": "EventKind", + "type": "string" + }, + "EventOut": { + "properties": { + "account_id": { + "title": "Account Id", + "type": "integer" + }, + "accrued_interest": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accrued Interest" + }, + "amount": { + "description": "decimal as string", + "title": "Amount", + "type": "string" + }, + "amount_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Amount Rub" + }, + "currency": { + "title": "Currency", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "external_flow": { + "title": "External Flow", + "type": "boolean" + }, + "fee": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Fee" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "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" + }, + "price_currency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Price Currency" + }, + "quantity": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Quantity" + }, + "status": { + "$ref": "#/components/schemas/EventStatus" + }, + "tax": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tax" + }, + "ticker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ticker" + }, + "trade_date": { + "format": "date", + "title": "Trade Date", + "type": "string" + }, + "ts": { + "format": "date-time", + "title": "Ts", + "type": "string" + } + }, + "required": [ + "id", + "account_id", + "instrument_id", + "ticker", + "kind", + "status", + "trade_date", + "ts", + "quantity", + "price", + "price_currency", + "amount", + "amount_rub", + "currency", + "fee", + "tax", + "accrued_interest", + "description", + "external_flow" + ], + "title": "EventOut", + "type": "object" + }, + "EventPage": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/EventOut" + }, + "title": "Items", + "type": "array" + }, + "page": { + "title": "Page", + "type": "integer" + }, + "page_size": { + "title": "Page Size", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "EventPage", + "type": "object" + }, "EventSource": { "description": "Which feed is the truth for an account's ledger; others become `shadow` events.", "enum": [ @@ -488,6 +767,16 @@ "title": "EventSource", "type": "string" }, + "EventStatus": { + "enum": [ + "confirmed", + "pending", + "shadow", + "ignored" + ], + "title": "EventStatus", + "type": "string" + }, "FlowType": { "enum": [ "income", @@ -524,6 +813,512 @@ "title": "Health", "type": "object" }, + "HoldingOut": { + "properties": { + "accrued_interest_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accrued Interest Rub" + }, + "asset_class": { + "title": "Asset Class", + "type": "string" + }, + "avg_cost": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Avg Cost" + }, + "board": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Board" + }, + "cost_currency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Currency" + }, + "cost_total_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Total Rub" + }, + "currency": { + "title": "Currency", + "type": "string" + }, + "days_held": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Days Held" + }, + "first_buy_date": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "First Buy Date" + }, + "income_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Income Rub" + }, + "instrument_id": { + "title": "Instrument Id", + "type": "integer" + }, + "ldv_eligible_qty": { + "description": "decimal as string", + "title": "Ldv Eligible Qty", + "type": "string" + }, + "market_price": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Market Price" + }, + "name": { + "title": "Name", + "type": "string" + }, + "price_currency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Price Currency" + }, + "price_date": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Price Date" + }, + "price_status": { + "title": "Price Status", + "type": "string" + }, + "qty": { + "description": "decimal as string", + "title": "Qty", + "type": "string" + }, + "realized_pnl_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Realized Pnl Rub" + }, + "ticker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ticker" + }, + "unrealized_pnl_native": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unrealized Pnl Native" + }, + "unrealized_pnl_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unrealized Pnl Rub" + }, + "value_native": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value Native" + }, + "value_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value Rub" + }, + "weight": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Weight" + }, + "xirr": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Xirr" + } + }, + "required": [ + "instrument_id", + "ticker", + "name", + "asset_class", + "board", + "currency", + "qty", + "avg_cost", + "cost_currency", + "cost_total_rub", + "market_price", + "price_currency", + "price_date", + "price_status", + "value_native", + "value_rub", + "accrued_interest_rub", + "unrealized_pnl_native", + "unrealized_pnl_rub", + "realized_pnl_rub", + "income_rub", + "weight", + "xirr", + "first_buy_date", + "days_held", + "ldv_eligible_qty" + ], + "title": "HoldingOut", + "type": "object" + }, + "InstrumentDetail": { + "properties": { + "events": { + "items": { + "$ref": "#/components/schemas/EventOut" + }, + "title": "Events", + "type": "array" + }, + "holding": { + "anyOf": [ + { + "$ref": "#/components/schemas/HoldingOut" + }, + { + "type": "null" + } + ] + }, + "instrument": { + "$ref": "#/components/schemas/InstrumentOut" + }, + "lots": { + "items": { + "$ref": "#/components/schemas/LotOut" + }, + "title": "Lots", + "type": "array" + }, + "prices": { + "items": { + "$ref": "#/components/schemas/PricePoint" + }, + "title": "Prices", + "type": "array" + } + }, + "required": [ + "instrument", + "holding", + "lots", + "events", + "prices" + ], + "title": "InstrumentDetail", + "type": "object" + }, + "InstrumentOut": { + "properties": { + "asset_class": { + "title": "Asset Class", + "type": "string" + }, + "board": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Board" + }, + "country": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country" + }, + "currency": { + "title": "Currency", + "type": "string" + }, + "exchange": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exchange" + }, + "figi": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Figi" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "is_active": { + "title": "Is Active", + "type": "boolean" + }, + "isin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Isin" + }, + "issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Issuer" + }, + "lot": { + "title": "Lot", + "type": "integer" + }, + "maturity_date": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Maturity Date" + }, + "name": { + "title": "Name", + "type": "string" + }, + "nominal": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nominal" + }, + "nominal_currency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nominal Currency" + }, + "sector": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sector" + }, + "ticker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ticker" + } + }, + "required": [ + "id", + "asset_class", + "isin", + "figi", + "ticker", + "board", + "exchange", + "name", + "issuer", + "currency", + "lot", + "nominal", + "nominal_currency", + "maturity_date", + "sector", + "country", + "is_active" + ], + "title": "InstrumentOut", + "type": "object" + }, "JobStatus": { "enum": [ "queued", @@ -555,6 +1350,79 @@ "title": "LoginRequest", "type": "object" }, + "LotOut": { + "properties": { + "account_id": { + "title": "Account Id", + "type": "integer" + }, + "closed_at": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Closed At" + }, + "cost_currency": { + "title": "Cost Currency", + "type": "string" + }, + "cost_per_unit": { + "description": "decimal as string", + "title": "Cost Per Unit", + "type": "string" + }, + "cost_total_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Total Rub" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "open_date": { + "format": "date", + "title": "Open Date", + "type": "string" + }, + "qty_open": { + "description": "decimal as string", + "title": "Qty Open", + "type": "string" + }, + "qty_remaining": { + "description": "decimal as string", + "title": "Qty Remaining", + "type": "string" + } + }, + "required": [ + "id", + "account_id", + "open_date", + "qty_open", + "qty_remaining", + "cost_per_unit", + "cost_currency", + "cost_total_rub", + "closed_at" + ], + "title": "LotOut", + "type": "object" + }, "NetWorthBreakdown": { "properties": { "accounts": { @@ -684,6 +1552,44 @@ "title": "NetWorthDay", "type": "object" }, + "PricePoint": { + "properties": { + "accrued_interest": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accrued Interest" + }, + "close": { + "description": "decimal as string", + "title": "Close", + "type": "string" + }, + "currency": { + "title": "Currency", + "type": "string" + }, + "d": { + "format": "date", + "title": "D", + "type": "string" + } + }, + "required": [ + "d", + "close", + "currency", + "accrued_interest" + ], + "title": "PricePoint", + "type": "object" + }, "Problem": { "description": "RFC 7807 error body (application/problem+json)", "properties": { @@ -771,6 +1677,99 @@ "title": "RefreshRequest", "type": "object" }, + "ReturnsOut": { + "properties": { + "abs_pnl_rub": { + "description": "decimal as string", + "title": "Abs Pnl Rub", + "type": "string" + }, + "date_from": { + "format": "date", + "title": "Date From", + "type": "string" + }, + "date_to": { + "format": "date", + "title": "Date To", + "type": "string" + }, + "external_flow_rub": { + "description": "decimal as string", + "title": "External Flow Rub", + "type": "string" + }, + "period": { + "title": "Period", + "type": "string" + }, + "twr": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Twr" + }, + "twr_annualized": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Twr Annualized" + }, + "twr_days_skipped": { + "title": "Twr Days Skipped", + "type": "integer" + }, + "value_end_rub": { + "description": "decimal as string", + "title": "Value End Rub", + "type": "string" + }, + "value_start_rub": { + "description": "decimal as string", + "title": "Value Start Rub", + "type": "string" + }, + "xirr": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Xirr" + } + }, + "required": [ + "period", + "date_from", + "date_to", + "value_start_rub", + "value_end_rub", + "external_flow_rub", + "abs_pnl_rub", + "xirr", + "twr", + "twr_annualized", + "twr_days_skipped" + ], + "title": "ReturnsOut", + "type": "object" + }, "RuleCreate": { "additionalProperties": false, "properties": { @@ -1063,6 +2062,33 @@ "title": "RunwayOut", "type": "object" }, + "ScopeOut": { + "description": "One reportable set of accounts: `all`, `account:` or `portfolio:`.", + "properties": { + "account_ids": { + "items": { + "type": "integer" + }, + "title": "Account Ids", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "scope": { + "title": "Scope", + "type": "string" + } + }, + "required": [ + "scope", + "name", + "account_ids" + ], + "title": "ScopeOut", + "type": "object" + }, "SourceStatus": { "properties": { "cursor": { @@ -1203,6 +2229,118 @@ "title": "SpendingRow", "type": "object" }, + "SummaryOut": { + "description": "The dashboard's one-screen answer for a scope.", + "properties": { + "as_of": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "As Of" + }, + "cash_rub": { + "description": "decimal as string", + "title": "Cash Rub", + "type": "string" + }, + "computed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Computed At" + }, + "holding_count": { + "title": "Holding Count", + "type": "integer" + }, + "income_rub": { + "description": "decimal as string", + "title": "Income Rub", + "type": "string" + }, + "invested_net_rub": { + "description": "decimal as string", + "title": "Invested Net Rub", + "type": "string" + }, + "market_value_rub": { + "description": "decimal as string", + "title": "Market Value Rub", + "type": "string" + }, + "pnl_total_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pnl Total Rub" + }, + "realized_pnl_rub": { + "description": "decimal as string", + "title": "Realized Pnl Rub", + "type": "string" + }, + "returns": { + "items": { + "$ref": "#/components/schemas/ReturnsOut" + }, + "title": "Returns", + "type": "array" + }, + "scope": { + "title": "Scope", + "type": "string" + }, + "stale_count": { + "title": "Stale Count", + "type": "integer" + }, + "total_rub": { + "description": "decimal as string", + "title": "Total Rub", + "type": "string" + }, + "unpriced_count": { + "title": "Unpriced Count", + "type": "integer" + } + }, + "required": [ + "scope", + "as_of", + "computed_at", + "market_value_rub", + "cash_rub", + "total_rub", + "invested_net_rub", + "pnl_total_rub", + "realized_pnl_rub", + "income_rub", + "holding_count", + "unpriced_count", + "stale_count", + "returns" + ], + "title": "SummaryOut", + "type": "object" + }, "SyncJobOut": { "properties": { "id": { @@ -1632,6 +2770,84 @@ ], "title": "UserOut", "type": "object" + }, + "ValueDay": { + "properties": { + "accrued_interest_rub": { + "description": "decimal as string", + "title": "Accrued Interest Rub", + "type": "string" + }, + "cash_rub": { + "description": "decimal as string", + "title": "Cash Rub", + "type": "string" + }, + "d": { + "format": "date", + "title": "D", + "type": "string" + }, + "external_flow_rub": { + "description": "decimal as string", + "title": "External Flow Rub", + "type": "string" + }, + "invested_net_rub": { + "description": "decimal as string", + "title": "Invested Net Rub", + "type": "string" + }, + "market_value_rub": { + "description": "decimal as string", + "title": "Market Value Rub", + "type": "string" + }, + "missing_fx_count": { + "title": "Missing Fx Count", + "type": "integer" + }, + "missing_price_count": { + "title": "Missing Price Count", + "type": "integer" + }, + "pnl_total_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pnl Total Rub" + }, + "stale_price_count": { + "title": "Stale Price Count", + "type": "integer" + }, + "total_rub": { + "description": "decimal as string", + "title": "Total Rub", + "type": "string" + } + }, + "required": [ + "d", + "market_value_rub", + "accrued_interest_rub", + "cash_rub", + "total_rub", + "external_flow_rub", + "invested_net_rub", + "pnl_total_rub", + "stale_price_count", + "missing_price_count", + "missing_fx_count" + ], + "title": "ValueDay", + "type": "object" } }, "securitySchemes": { @@ -1746,6 +2962,368 @@ ] } }, + "/api/v1/analytics/allocation": { + "get": { + "description": "Buckets of one dimension (or all four), largest first.", + "operationId": "analytics_allocation", + "parameters": [ + { + "description": "all | account: | portfolio:", + "in": "query", + "name": "scope", + "required": false, + "schema": { + "default": "all", + "description": "all | account: | portfolio:", + "title": "Scope", + "type": "string" + } + }, + { + "in": "query", + "name": "dimension", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AllocationDimension" + }, + { + "type": "null" + } + ], + "title": "Dimension" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AllocationBucket" + }, + "title": "Response Analytics Allocation", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Allocation", + "tags": [ + "analytics" + ] + } + }, + "/api/v1/analytics/holdings": { + "get": { + "description": "Open positions, most valuable first; unpriced ones last with null values.", + "operationId": "analytics_holdings", + "parameters": [ + { + "description": "all | account: | portfolio:", + "in": "query", + "name": "scope", + "required": false, + "schema": { + "default": "all", + "description": "all | account: | portfolio:", + "title": "Scope", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/HoldingOut" + }, + "title": "Response Analytics Holdings", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Holdings", + "tags": [ + "analytics" + ] + } + }, + "/api/v1/analytics/returns": { + "get": { + "description": "XIRR and TWR per period, shortest first.", + "operationId": "analytics_returns", + "parameters": [ + { + "description": "all | account: | portfolio:", + "in": "query", + "name": "scope", + "required": false, + "schema": { + "default": "all", + "description": "all | account: | portfolio:", + "title": "Scope", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ReturnsOut" + }, + "title": "Response Analytics Returns", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Returns", + "tags": [ + "analytics" + ] + } + }, + "/api/v1/analytics/scopes": { + "get": { + "description": "Every set of accounts the metrics were built for.", + "operationId": "analytics_scopes", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ScopeOut" + }, + "title": "Response Analytics Scopes", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Scopes", + "tags": [ + "analytics" + ] + } + }, + "/api/v1/analytics/summary": { + "get": { + "description": "One screen's worth: the latest day, the totals it adds up to, and the returns.", + "operationId": "analytics_summary", + "parameters": [ + { + "description": "all | account: | portfolio:", + "in": "query", + "name": "scope", + "required": false, + "schema": { + "default": "all", + "description": "all | account: | portfolio:", + "title": "Scope", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SummaryOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Summary", + "tags": [ + "analytics" + ] + } + }, + "/api/v1/analytics/value-series": { + "get": { + "description": "Daily portfolio value; defaults to the last 365 days.", + "operationId": "analytics_value_series", + "parameters": [ + { + "description": "all | account: | portfolio:", + "in": "query", + "name": "scope", + "required": false, + "schema": { + "default": "all", + "description": "all | account: | portfolio:", + "title": "Scope", + "type": "string" + } + }, + { + "in": "query", + "name": "from", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "in": "query", + "name": "to", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "To" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ValueDay" + }, + "title": "Response Analytics Value Series", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Value Series", + "tags": [ + "analytics" + ] + } + }, "/api/v1/auth/login": { "post": { "operationId": "auth_login", @@ -2035,6 +3613,202 @@ ] } }, + "/api/v1/events": { + "get": { + "description": "One page of ledger events, newest first, with RUB amounts at each own date's rate.", + "operationId": "events_list", + "parameters": [ + { + "in": "query", + "name": "from", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "in": "query", + "name": "to", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "in": "query", + "name": "account_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Account Id" + } + }, + { + "in": "query", + "name": "instrument_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Instrument Id" + } + }, + { + "in": "query", + "name": "kind", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventKind" + }, + { + "type": "null" + } + ], + "title": "Kind" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventStatus" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "description": "only the events XIRR reads as boundary flows", + "in": "query", + "name": "external_flow", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "only the events XIRR reads as boundary flows", + "title": "External Flow" + } + }, + { + "description": "substring of description or ticker", + "in": "query", + "name": "q", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "substring of description or ticker", + "title": "Q" + } + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "title": "Page", + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "required": false, + "schema": { + "default": 50, + "maximum": 500, + "minimum": 1, + "title": "Page Size", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventPage" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List", + "tags": [ + "events" + ] + } + }, "/api/v1/health": { "get": { "operationId": "health_check", @@ -2066,6 +3840,185 @@ ] } }, + "/api/v1/instruments": { + "get": { + "operationId": "instruments_list", + "parameters": [ + { + "description": "substring of ticker, name or ISIN", + "in": "query", + "name": "q", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "substring of ticker, name or ISIN", + "title": "Q" + } + }, + { + "description": "share | bond | etf | fund | currency | index | …", + "in": "query", + "name": "asset_class", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "share | bond | etf | fund | currency | index | …", + "title": "Asset Class" + } + }, + { + "description": "only instruments with an open lot", + "in": "query", + "name": "held_only", + "required": false, + "schema": { + "default": false, + "description": "only instruments with an open lot", + "title": "Held Only", + "type": "boolean" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/InstrumentOut" + }, + "title": "Response Instruments List", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List", + "tags": [ + "instruments" + ] + } + }, + "/api/v1/instruments/{instrument_id}": { + "get": { + "description": "One instrument with its position, open lots, events and price history.", + "operationId": "instruments_get", + "parameters": [ + { + "in": "path", + "name": "instrument_id", + "required": true, + "schema": { + "title": "Instrument Id", + "type": "integer" + } + }, + { + "description": "all | account: | portfolio:", + "in": "query", + "name": "scope", + "required": false, + "schema": { + "default": "all", + "description": "all | account: | portfolio:", + "title": "Scope", + "type": "string" + } + }, + { + "in": "query", + "name": "prices_from", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prices From" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstrumentDetail" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get", + "tags": [ + "instruments" + ] + } + }, "/api/v1/metrics/refresh": { "post": { "description": "Rebuild every metric_* table inline (seconds at personal volumes).",