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

/analytics/{scopes,summary,value-series,holdings,returns,allocation}, /events с
фильтрами и /instruments/{id}. На запрос ничего не считается — это чтение metric_*,
благодаря чему экраны читаются быстро и показывают одно и то же число.

scope (all | account:<id> | portfolio:<id>) резолвится через ту же функцию, что его
построила, 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, и
равенство выбрасывало бы такие события из ОБЕИХ половин фильтра.
This commit is contained in:
Dmitry
2026-09-18 14:20:50 +03:00
parent b58ffb3aac
commit 3727419506
8 changed files with 2964 additions and 0 deletions
+6
View File
@@ -16,10 +16,13 @@ from fintracker import __version__
from fintracker.api.errors import install_error_handlers from fintracker.api.errors import install_error_handlers
from fintracker.api.routers import ( from fintracker.api.routers import (
accounts, accounts,
analytics,
auth, auth,
cashflow, cashflow,
categories, categories,
events,
health, health,
instruments,
metrics, metrics,
networth, networth,
rules, rules,
@@ -80,6 +83,9 @@ def create_app() -> FastAPI:
app.include_router(rules.router, prefix=API_PREFIX) app.include_router(rules.router, prefix=API_PREFIX)
app.include_router(networth.router, prefix=API_PREFIX) app.include_router(networth.router, prefix=API_PREFIX)
app.include_router(cashflow.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) app.include_router(metrics.router, prefix=API_PREFIX)
if settings.web_dir is not None: if settings.web_dir is not None:
mount_web(app, settings.web_dir, API_PREFIX) mount_web(app, settings.web_dir, API_PREFIX)
@@ -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:<id> | portfolio:<id>")]
@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,
)
@@ -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]
@@ -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:<id> | portfolio:<id>")] = 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,
)
@@ -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:<id>` or `portfolio:<id>`."""
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]
+62
View File
@@ -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:<id>` or
`portfolio:<id>`. 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}")
+175
View File
@@ -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"}
+1953
View File
File diff suppressed because it is too large Load Diff