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:
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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}")
|
||||
@@ -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"}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user