feat(analytics): доходы, ребалансировка, налоги, бенчмарки и цели — фаза 4

Второй источник выплат: sources/tinvest/sync_events.py (GetDividends,
GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (ISS bondization +
dividends). Приоритет между ними — pricing/payouts.resolve_payouts, решается
на чтении, а не на записи: corporate_action уникален по (instrument_id, kind,
source, source_id), обе версии сосуществуют, и правило можно поменять без
ресинка истории. Амортизация от MOEX идёт в bond_nominal_schedule, а не
в corporate_action — этим типом безраздельно владеет
ledger/corporate_actions.py.

analytics/income.py — metric_income_monthly (факт) и metric_income_calendar
(прошлое и прогноз) с basis paid/announced/history на каждой строке, три
источника числа не смешиваются. analytics/rebalance.py — сделки по
portfolio_target пропорционально внутри бакета, лоты только вниз, покупки не
занимают у ещё не свершившихся продаж. analytics/tax.py — оценка, не замена
справки брокера: дивиденды/купоны gross, реализованный результат из
lot_disposal с переоценкой каждой ноги на свою дату. analytics/benchmarks.py —
TWR индекса на сетке портфеля, kind (price/total_return) не скрывается.
analytics/goals.py — прогресс цели и нужный взнос по trailing XIRR.

Четыре шага зарегистрированы в register_steps: benchmarks после returns
(общая сетка дат), rebalance после allocation (её веса, не пересчитывает),
income и tax после lots (нужен lot_disposal).
This commit is contained in:
Dmitry
2026-09-19 10:42:50 +03:00
parent ff3b76871d
commit 15f5812ea4
42 changed files with 10607 additions and 3 deletions
+11
View File
@@ -18,17 +18,22 @@ from fintracker.api.routers import (
accounts,
analytics,
auth,
benchmarks,
cashflow,
categories,
events,
goals,
health,
imports,
income,
instruments,
links,
metrics,
networth,
rebalance,
rules,
sync,
tax,
transactions,
)
from fintracker.api.web import mount_web
@@ -95,6 +100,12 @@ def create_app() -> FastAPI:
app.include_router(instruments.router, prefix=API_PREFIX)
app.include_router(links.router, prefix=API_PREFIX)
app.include_router(metrics.router, prefix=API_PREFIX)
app.include_router(goals.router, prefix=API_PREFIX)
app.include_router(income.router, prefix=API_PREFIX)
app.include_router(rebalance.router, prefix=API_PREFIX)
app.include_router(tax.router, prefix=API_PREFIX)
app.include_router(benchmarks.router, prefix=API_PREFIX)
app.include_router(benchmarks.analytics_router, prefix=API_PREFIX)
if settings.web_dir is not None:
mount_web(app, settings.web_dir, API_PREFIX)
return app
@@ -0,0 +1,232 @@
"""Benchmarks: the list the user maintains, and the comparison built from it.
Two routers live here because the two halves belong to two prefixes the contract fixes:
`router` serves `/benchmarks` (CRUD on the user's choice of indices) and `analytics_router`
serves `/analytics/benchmarks` (the read of `metric_benchmark_returns` beside
`metric_returns`). Both are exported for `api/app.py` to include.
The comparison itself is not computed here — `analytics/benchmarks.py` built it during the
refresh, on the portfolio's own date grid. Only `excess` is derived per request, as a
subtraction of two numbers that are already on the same grid.
"""
from __future__ import annotations
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Query, Response, status
from sqlalchemy import delete, func, select
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.errors import Problem
from fintracker.api.schemas.benchmarks import (
BenchmarkComparison,
BenchmarkComparisonOut,
BenchmarkCreate,
BenchmarkOut,
BenchmarkPatch,
BenchmarkReturnOut,
)
from fintracker.api.scopes import DEFAULT_SCOPE, resolve_scope
from fintracker.models import (
Benchmark,
BenchmarkKind,
MetricBenchmarkReturns,
MetricReturns,
PriceDaily,
)
router = APIRouter(prefix="/benchmarks", tags=["benchmarks"])
analytics_router = APIRouter(prefix="/analytics", tags=["benchmarks"])
#: Order the periods are shown in; the tables store 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>")]
def _kind(value: str) -> BenchmarkKind:
try:
return BenchmarkKind(value)
except ValueError:
allowed = ", ".join(k.value for k in BenchmarkKind)
raise Problem(
status.HTTP_422_UNPROCESSABLE_ENTITY, "Unprocessable", f"kind: ожидается {allowed}"
) from None
async def _history(session: SessionDep) -> dict[int, tuple[date, date]]:
"""The price history each benchmark instrument actually has, for the list response."""
rows = (
await session.execute(
select(
PriceDaily.instrument_id, func.min(PriceDaily.d), func.max(PriceDaily.d)
).group_by(PriceDaily.instrument_id)
)
).all()
return {r[0]: (r[1], r[2]) for r in rows}
def _out(benchmark: Benchmark, history: dict[int, tuple[date, date]]) -> BenchmarkOut:
span = history.get(benchmark.instrument_id or 0)
return BenchmarkOut(
id=benchmark.id,
code=benchmark.code,
name=benchmark.name,
kind=str(benchmark.kind),
source=benchmark.source,
currency=benchmark.currency,
is_default=benchmark.is_default,
is_active=benchmark.is_active,
instrument_id=benchmark.instrument_id,
history_from=span[0] if span else None,
history_to=span[1] if span else None,
)
@router.get("", name="list")
async def list_benchmarks(session: SessionDep, _: CurrentUser) -> list[BenchmarkOut]:
"""Every benchmark, defaults first — the order the comparison block shows them in."""
found = await session.execute(
select(Benchmark).order_by(Benchmark.is_default.desc(), Benchmark.code)
)
history = await _history(session)
return [_out(b, history) for b in found.scalars()]
@router.post("", name="create", status_code=status.HTTP_201_CREATED)
async def create_benchmark(
body: BenchmarkCreate, session: SessionDep, _: CurrentUser
) -> BenchmarkOut:
existing = (
await session.execute(select(Benchmark).where(Benchmark.code == body.code))
).scalar_one_or_none()
if existing is not None:
raise Problem(status.HTTP_409_CONFLICT, "Conflict", f"Бенчмарк {body.code} уже есть")
data = body.model_dump()
data["kind"] = _kind(body.kind)
benchmark = Benchmark(**data)
session.add(benchmark)
await session.commit()
await session.refresh(benchmark)
return _out(benchmark, await _history(session))
@router.patch("/{benchmark_id}", name="patch")
async def patch_benchmark(
benchmark_id: int, body: BenchmarkPatch, session: SessionDep, _: CurrentUser
) -> BenchmarkOut:
benchmark = await session.get(Benchmark, benchmark_id)
if benchmark is None:
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"Нет бенчмарка {benchmark_id}")
changes = body.model_dump(exclude_unset=True)
for field in ("code", "name", "kind", "source", "currency", "is_default", "is_active"):
if field in changes and changes[field] is None:
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", f"{field} не может быть null")
if "kind" in changes:
changes["kind"] = _kind(changes["kind"])
for field, value in changes.items():
setattr(benchmark, field, value)
await session.commit()
await session.refresh(benchmark)
return _out(benchmark, await _history(session))
@router.delete("/{benchmark_id}", name="delete", status_code=status.HTTP_204_NO_CONTENT)
async def delete_benchmark(benchmark_id: int, session: SessionDep, _: CurrentUser) -> Response:
benchmark = await session.get(Benchmark, benchmark_id)
if benchmark is None:
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"Нет бенчмарка {benchmark_id}")
# the metric rows go with it: a comparison against an index nobody tracks any more is
# not a number the screens should still be able to find
await session.execute(
delete(MetricBenchmarkReturns).where(MetricBenchmarkReturns.benchmark_id == benchmark_id)
)
await session.delete(benchmark)
await session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@analytics_router.get("/benchmarks", name="benchmarks")
async def compare(
session: SessionDep,
_: CurrentUser,
scope: ScopeParam = DEFAULT_SCOPE,
period: Annotated[
list[str] | None, Query(description="repeatable: 1m 3m 6m ytd 1y 3y all")
] = None,
) -> BenchmarkComparison:
"""The portfolio's TWR beside each benchmark's, on the same grid of days.
`days_skipped` is returned on both sides and is not cosmetic: a non-zero value on either
means the two chains did not cover the same days, and the difference is then an
approximation of an excess return rather than one.
"""
await resolve_scope(session, scope)
conditions = [MetricReturns.scope == scope]
if period:
conditions.append(MetricReturns.period.in_(period))
portfolio = (
await session.execute(
select(
MetricReturns.period,
MetricReturns.date_from,
MetricReturns.date_to,
MetricReturns.twr,
MetricReturns.twr_annualized,
MetricReturns.twr_days_skipped,
).where(*conditions)
)
).all()
rows = (
await session.execute(
select(
MetricBenchmarkReturns.period,
MetricBenchmarkReturns.twr,
MetricBenchmarkReturns.twr_annualized,
MetricBenchmarkReturns.days_skipped,
Benchmark.id,
Benchmark.code,
Benchmark.name,
Benchmark.kind,
)
.join(Benchmark, Benchmark.id == MetricBenchmarkReturns.benchmark_id)
.where(MetricBenchmarkReturns.scope == scope)
.order_by(Benchmark.is_default.desc(), Benchmark.code)
)
).all()
by_period: dict[str, list[BenchmarkReturnOut]] = {}
for r in rows:
by_period.setdefault(r.period, []).append(
BenchmarkReturnOut(
benchmark_id=r.id,
code=r.code,
name=r.name,
kind=str(r.kind),
twr=r.twr,
twr_annualized=r.twr_annualized,
days_skipped=r.days_skipped,
excess=None,
)
)
out = []
for p in sorted(portfolio, key=lambda r: PERIOD_ORDER.get(r.period, 99)):
benchmarks = []
for b in by_period.get(p.period, []):
excess = None if p.twr is None or b.twr is None else p.twr - b.twr
benchmarks.append(b.model_copy(update={"excess": excess}))
out.append(
BenchmarkComparisonOut(
period=p.period,
date_from=p.date_from,
date_to=p.date_to,
portfolio_twr=p.twr,
portfolio_twr_annualized=p.twr_annualized,
portfolio_days_skipped=p.twr_days_skipped,
benchmarks=benchmarks,
)
)
return BenchmarkComparison(rows=out)
+113
View File
@@ -0,0 +1,113 @@
"""Goals: CRUD plus the derived progress (docs/ai/phase4-contract.md §4).
Progress is computed per request through `analytics/goals.compute_goal_progress` for the
same reason the rebalancing endpoint does: a goal created a minute ago has no row in
`metric_goal_progress` yet, and «no data» for a goal the user just typed in reads as a bug.
The refresh step fills the table from the identical function, so the screen and the stored
metric cannot drift apart.
"""
from __future__ import annotations
from fastapi import APIRouter, Response, status
from sqlalchemy import select
from fintracker.analytics.goals import compute_goal_progress
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.errors import Problem
from fintracker.api.schemas.goals import GoalCreate, GoalOut, GoalPatch, GoalProgressOut
from fintracker.api.scopes import resolve_scope
from fintracker.models import Goal
router = APIRouter(prefix="/goals", tags=["goals"])
async def _goal(session: SessionDep, goal_id: int) -> Goal:
goal = await session.get(Goal, goal_id)
if goal is None:
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"Нет цели с id {goal_id}")
return goal
async def _check_name(session: SessionDep, name: str, *, exclude: int | None = None) -> None:
stmt = select(Goal.id).where(Goal.name == name)
if exclude is not None:
stmt = stmt.where(Goal.id != exclude)
if (await session.execute(stmt)).scalar_one_or_none() is not None:
raise Problem(status.HTTP_409_CONFLICT, "Conflict", f"Цель «{name}» уже есть")
@router.get("", name="list")
async def list_goals(
session: SessionDep, _: CurrentUser, include_archived: bool = False
) -> list[GoalOut]:
stmt = select(Goal).order_by(Goal.id)
if not include_archived:
stmt = stmt.where(Goal.archived.is_(False))
rows = (await session.execute(stmt)).scalars().all()
return [GoalOut.model_validate(g, from_attributes=True) for g in rows]
@router.post("", name="create", status_code=status.HTTP_201_CREATED)
async def create_goal(body: GoalCreate, session: SessionDep, _: CurrentUser) -> GoalOut:
await _check_name(session, body.name)
# a goal pointed at a scope the metrics never built would silently read as zero forever
await resolve_scope(session, body.scope)
goal = Goal(**body.model_dump())
session.add(goal)
await session.commit()
await session.refresh(goal)
return GoalOut.model_validate(goal, from_attributes=True)
@router.patch("/{goal_id}", name="patch")
async def patch_goal(goal_id: int, body: GoalPatch, session: SessionDep, _: CurrentUser) -> GoalOut:
goal = await _goal(session, goal_id)
changes = body.model_dump(exclude_unset=True)
for field in ("name", "scope", "target_amount", "currency", "archived"):
if field in changes and changes[field] is None:
raise Problem(
status.HTTP_400_BAD_REQUEST, "Bad request", f"Поле {field} не может быть null"
)
if "name" in changes:
await _check_name(session, changes["name"], exclude=goal_id)
if "scope" in changes:
await resolve_scope(session, changes["scope"])
for field, value in changes.items():
setattr(goal, field, value)
await session.commit()
await session.refresh(goal)
return GoalOut.model_validate(goal, from_attributes=True)
@router.delete("/{goal_id}", name="delete", status_code=status.HTTP_204_NO_CONTENT)
async def delete_goal(goal_id: int, session: SessionDep, _: CurrentUser) -> Response:
goal = await _goal(session, goal_id)
await session.delete(goal)
await session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{goal_id}/progress", name="progress")
async def goal_progress(goal_id: int, session: SessionDep, _: CurrentUser) -> GoalProgressOut:
goal = await _goal(session, goal_id)
progress = await compute_goal_progress(session, goal)
if progress is None:
raise Problem(
status.HTTP_409_CONFLICT,
"Conflict",
f"Прогресс цели «{goal.name}» не считается: нет курса для {goal.currency} "
f"или сумма цели неположительна",
)
return GoalProgressOut(
goal_id=progress.goal_id,
as_of=progress.as_of,
current_value_rub=progress.current_value_rub,
target_amount_rub=progress.target_amount_rub,
progress=progress.progress,
projected_date=progress.projected_date,
basis=progress.basis,
assumed_rate=progress.assumed_rate,
monthly_needed_rub=progress.monthly_needed_rub,
on_track=progress.on_track,
)
@@ -0,0 +1,288 @@
"""`/income` — the dividend and coupon calendar, its history and its forecast (contract §1).
Every endpoint is a read of `metric_income_calendar` / `metric_income_monthly`, which
`analytics/income.py` rebuilt during the last refresh. Nothing is computed per request except
the grouping the screen asks for, so the three tabs of the screen cannot disagree.
The one rule worth repeating here: **future rows are split by `basis` wherever they are
summed.** `total_expected_rub` and each month's `amount_rub` are still handed over as one
number because the screen needs one, but `by_basis` sits next to it every time, so a total
that is mostly `history` can be recognised as mostly guesswork.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import date, timedelta
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Query
from sqlalchemy import select
from fintracker.analytics import today_local
from fintracker.analytics.income import add_months, month_start
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.schemas.income import (
CalendarEntry,
CalendarOut,
ForecastMonth,
ForecastOut,
HistoryOut,
HistoryRow,
HistoryTotals,
)
from fintracker.api.scopes import DEFAULT_SCOPE, resolve_scope
from fintracker.models import (
IncomeBasis,
Instrument,
MetricDataQuality,
MetricIncomeCalendar,
MetricIncomeMonthly,
MetricPortfolioValueDaily,
)
log = logging.getLogger(__name__)
router = APIRouter(prefix="/income", tags=["income"])
ZERO = Decimal(0)
RUB = "RUB"
DEFAULT_FORECAST_MONTHS = 12
#: Findings `analytics/income.py` raises; the forecast repeats them so the screen can show
#: what it could not cover instead of silently under-reporting.
INCOME_CHECKS = ("income_without_history", "income_irregular_history", "income_missing_fx")
ScopeParam = Annotated[str, Query(description="all | account:<id> | portfolio:<id>")]
@router.get("/calendar", name="calendar")
async def calendar(
session: SessionDep,
_: CurrentUser,
scope: ScopeParam = DEFAULT_SCOPE,
date_from: Annotated[date | None, Query(alias="date_from")] = None,
date_to: Annotated[date | None, Query(alias="date_to")] = None,
include_paid: bool = False,
) -> CalendarOut:
"""Payments in a window, one row each; defaults to the next 12 months, forecast only."""
await resolve_scope(session, scope)
as_of = today_local()
start = date_from if date_from is not None else as_of
# the default window is the twelve months AHEAD, half-open: a quarterly payer must give
# four entries, and an inclusive year-end boundary would let a fifth in on some dates
end = (
date_to
if date_to is not None
else add_months(as_of, DEFAULT_FORECAST_MONTHS) - timedelta(days=1)
)
conditions = [
MetricIncomeCalendar.scope == scope,
MetricIncomeCalendar.expected_date >= start,
MetricIncomeCalendar.expected_date <= end,
]
if not include_paid:
conditions.append(MetricIncomeCalendar.basis != IncomeBasis.paid)
rows = (
await session.execute(
select(MetricIncomeCalendar, Instrument)
.join(Instrument, Instrument.id == MetricIncomeCalendar.instrument_id)
.where(*conditions)
.order_by(MetricIncomeCalendar.expected_date, Instrument.ticker)
)
).all()
entries = [_entry(row, instrument) for row, instrument in rows]
by_basis: dict[str, Decimal] = defaultdict(lambda: ZERO)
total = ZERO
for row, _instrument in rows:
if row.basis == IncomeBasis.paid or row.amount_rub is None:
continue
by_basis[str(row.basis.value)] += Decimal(row.amount_rub)
total += Decimal(row.amount_rub)
return CalendarOut(
as_of=as_of,
currency=RUB,
total_expected_rub=total,
entries=entries,
by_basis=dict(sorted(by_basis.items())),
)
@router.get("/history", name="history")
async def history(
session: SessionDep,
_: CurrentUser,
scope: ScopeParam = DEFAULT_SCOPE,
group: Annotated[str, Query(description="month")] = "month",
date_from: Annotated[date | None, Query(alias="date_from")] = None,
date_to: Annotated[date | None, Query(alias="date_to")] = None,
kind: Annotated[str | None, Query(description="dividend | coupon | …")] = None,
) -> HistoryOut:
"""Income actually received, grouped by month, kind and currency.
`group` exists for the contract's sake and accepts only `month`: the table is stored
monthly, and a finer grouping would have to re-read the ledger, which is what the
calendar's `paid` rows already do per payment.
"""
await resolve_scope(session, scope)
conditions = [MetricIncomeMonthly.scope == scope]
if date_from is not None:
conditions.append(MetricIncomeMonthly.month >= month_start(date_from))
if date_to is not None:
conditions.append(MetricIncomeMonthly.month <= date_to)
if kind is not None:
conditions.append(MetricIncomeMonthly.kind == kind)
rows = (
(
await session.execute(
select(MetricIncomeMonthly)
.where(*conditions)
.order_by(
MetricIncomeMonthly.month,
MetricIncomeMonthly.kind,
MetricIncomeMonthly.currency,
)
)
)
.scalars()
.all()
)
total_rub = ZERO
total_tax_rub = ZERO
for r in rows:
if r.amount_rub is None:
continue
total_rub += Decimal(r.amount_rub)
# the tax is stored native; it converts at the same effective rate the month's own
# amount did, which is exact for a single-payment month and right on average otherwise
if r.tax_withheld and r.amount:
total_tax_rub += Decimal(r.tax_withheld) * Decimal(r.amount_rub) / Decimal(r.amount)
return HistoryOut(
rows=[
HistoryRow(
month=r.month,
kind=r.kind,
currency=r.currency,
amount=r.amount,
amount_rub=r.amount_rub,
tax_withheld=r.tax_withheld,
payment_count=r.payment_count,
)
for r in rows
],
totals=HistoryTotals(amount_rub=total_rub, tax_withheld_rub=total_tax_rub),
)
@router.get("/forecast", name="forecast")
async def forecast(
session: SessionDep,
_: CurrentUser,
scope: ScopeParam = DEFAULT_SCOPE,
months: Annotated[int, Query(ge=1, le=36)] = DEFAULT_FORECAST_MONTHS,
) -> ForecastOut:
"""Expected income per month, split by basis, plus the yield it implies."""
await resolve_scope(session, scope)
as_of = today_local()
end = add_months(as_of, months)
rows = (
(
await session.execute(
select(MetricIncomeCalendar).where(
MetricIncomeCalendar.scope == scope,
MetricIncomeCalendar.basis != IncomeBasis.paid,
MetricIncomeCalendar.expected_date >= as_of,
# half-open, like the calendar's default window and for the same reason
MetricIncomeCalendar.expected_date < end,
)
)
)
.scalars()
.all()
)
buckets: dict[date, dict[str, Decimal]] = defaultdict(lambda: defaultdict(lambda: ZERO))
total = ZERO
for r in rows:
if r.amount_rub is None:
continue # unconvertible: counted in `income_missing_fx`, not silently as zero
amount = Decimal(r.amount_rub)
buckets[month_start(r.expected_date)][str(r.basis.value)] += amount
total += amount
out_months = [
ForecastMonth(
month=month,
amount_rub=sum(by_basis.values(), start=ZERO),
by_basis=dict(sorted(by_basis.items())),
)
for month, by_basis in sorted(buckets.items())
]
return ForecastOut(
months=out_months,
total_rub=total,
annual_yield_on_value=await _yield_on_value(session, scope, total, months),
warnings=await _warnings(session),
)
def _entry(row: MetricIncomeCalendar, instrument: Instrument) -> CalendarEntry:
return CalendarEntry(
instrument_id=row.instrument_id,
ticker=instrument.ticker,
name=instrument.name,
kind=row.kind,
expected_date=row.expected_date,
record_date=row.record_date,
qty=row.qty,
per_unit=row.per_unit,
amount=row.amount,
currency=row.currency,
amount_rub=row.amount_rub,
basis=str(row.basis.value),
tax_withheld=row.tax_withheld,
)
async def _yield_on_value(
session: SessionDep, scope: str, total: Decimal, months: int
) -> Decimal | None:
"""Expected income annualised over the scope's latest total value; None without one."""
value = (
await session.execute(
select(MetricPortfolioValueDaily.total_rub)
.where(MetricPortfolioValueDaily.scope == scope)
.order_by(MetricPortfolioValueDaily.d.desc())
.limit(1)
)
).scalar_one_or_none()
if value is None or Decimal(value) <= ZERO or months <= 0:
return None
return total * Decimal(12) / Decimal(months) / Decimal(value)
async def _warnings(session: SessionDep) -> list[str]:
rows = (
(
await session.execute(
select(MetricDataQuality.detail)
.where(MetricDataQuality.check_name.in_(INCOME_CHECKS))
.order_by(MetricDataQuality.check_name)
)
)
.scalars()
.all()
)
return list(rows)
@@ -0,0 +1,231 @@
"""Target weights and rebalancing suggestions (docs/ai/phase4-contract.md §2).
Unlike the read-only `/analytics/*` endpoints, the suggestion here is computed per request
through `analytics/rebalance.compute_rebalance` rather than read from `metric_rebalance`.
Two reasons, and they are the same reason twice: the what-if parameter `cash_available`
changes the answer and therefore cannot come from a stored table, and a target the user just
saved must be visible before the next refresh — a screen that shows yesterday's targets
next to today's prices is a screen that lies. The stored table stays the canonical copy for
everything that reads metrics in bulk, and both come out of the same function, so they
cannot disagree.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Query, status
from sqlalchemy import delete, select
from fintracker.analytics.rebalance import DEFAULT_DIMENSION, compute_rebalance
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.errors import Problem
from fintracker.api.schemas.rebalance import (
RebalanceBucketOut,
RebalanceOut,
TargetOut,
TargetsIn,
TargetsOut,
TradeOut,
)
from fintracker.models import AllocationDimension, Portfolio, PortfolioTarget
router = APIRouter(prefix="/portfolios", tags=["rebalance"])
ZERO = Decimal(0)
ONE = Decimal(1)
WEIGHT_TOLERANCE = Decimal("0.0001")
"""How far the weights of one dimension may add up from 1 before the set is refused.
The server does not normalise: a total of 0.9 is a mistake in the plan, not a scale factor,
and silently stretching it would hide the missing tenth of the portfolio forever."""
DimensionParam = Annotated[str, Query(description="asset_class | sector | country | currency")]
def parse_dimension(value: str) -> AllocationDimension:
try:
return AllocationDimension(value)
except ValueError:
allowed = ", ".join(d.value for d in AllocationDimension)
raise Problem(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"Validation error",
f"Неизвестное измерение: {value}. Допустимые: {allowed}",
) from None
async def _portfolio(session: SessionDep, portfolio_id: int) -> Portfolio:
portfolio = await session.get(Portfolio, portfolio_id)
if portfolio is None:
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"Нет портфеля с id {portfolio_id}")
return portfolio
async def _targets_out(
session: SessionDep, portfolio_id: int, dimension: AllocationDimension
) -> TargetsOut:
rows = (
(
await session.execute(
select(PortfolioTarget)
.where(
PortfolioTarget.portfolio_id == portfolio_id,
PortfolioTarget.dimension == dimension,
)
.order_by(PortfolioTarget.bucket)
)
)
.scalars()
.all()
)
return TargetsOut(
portfolio_id=portfolio_id,
dimension=dimension.value,
targets=[
TargetOut(
bucket=r.bucket,
target_weight=Decimal(r.target_weight),
band=None if r.band is None else Decimal(r.band),
note=r.note,
)
for r in rows
],
weights_sum=sum((Decimal(r.target_weight) for r in rows), start=ZERO),
)
@router.get("/{portfolio_id}/targets", name="targets")
async def get_targets(
portfolio_id: int,
session: SessionDep,
_: CurrentUser,
dimension: DimensionParam = DEFAULT_DIMENSION.value,
) -> TargetsOut:
await _portfolio(session, portfolio_id)
return await _targets_out(session, portfolio_id, parse_dimension(dimension))
@router.put("/{portfolio_id}/targets", name="set_targets")
async def put_targets(
portfolio_id: int, body: TargetsIn, session: SessionDep, _: CurrentUser
) -> TargetsOut:
"""Replace the whole set for one dimension.
The weights must add up to 1 within `WEIGHT_TOLERANCE`; otherwise the request is
refused with the actual sum in the message, so the user can see by how much the plan
misses rather than being handed a silently rescaled one.
"""
await _portfolio(session, portfolio_id)
dimension = parse_dimension(body.dimension)
buckets = [t.bucket for t in body.targets]
duplicates = sorted({b for b in buckets if buckets.count(b) > 1})
if duplicates:
raise Problem(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"Validation error",
f"Бакет указан дважды: {', '.join(duplicates)}",
)
for target in body.targets:
if target.target_weight < ZERO or target.target_weight > ONE:
raise Problem(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"Validation error",
f"Вес бакета «{target.bucket}» вне диапазона 0..1: {target.target_weight}",
)
if target.band is not None and target.band < ZERO:
raise Problem(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"Validation error",
f"Полоса допуска бакета «{target.bucket}» отрицательна: {target.band}",
)
total = sum((t.target_weight for t in body.targets), start=ZERO)
if body.targets and abs(total - ONE) > WEIGHT_TOLERANCE:
raise Problem(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"Validation error",
f"Сумма весов должна быть 1, а не {format(total, 'f')}. "
f"Сервер не нормализует веса — исправьте набор.",
extra={"weights_sum": format(total, "f")},
)
await session.execute(
delete(PortfolioTarget).where(
PortfolioTarget.portfolio_id == portfolio_id,
PortfolioTarget.dimension == dimension,
)
)
for target in body.targets:
session.add(
PortfolioTarget(
portfolio_id=portfolio_id,
dimension=dimension,
bucket=target.bucket,
target_weight=target.target_weight,
band=target.band,
note=target.note,
)
)
await session.commit()
return await _targets_out(session, portfolio_id, dimension)
@router.get("/{portfolio_id}/rebalance", name="rebalance")
async def get_rebalance(
portfolio_id: int,
session: SessionDep,
_: CurrentUser,
dimension: DimensionParam = DEFAULT_DIMENSION.value,
cash_available: Annotated[
Decimal | None, Query(description="переопределяет остаток на счетах для what-if")
] = None,
) -> RebalanceOut:
await _portfolio(session, portfolio_id)
plan = await compute_rebalance(
session,
portfolio_id,
parse_dimension(dimension),
cash_available_rub=cash_available,
)
return RebalanceOut(
portfolio_id=plan.portfolio_id,
dimension=plan.dimension.value,
as_of=plan.as_of,
total_value_rub=plan.total_value_rub,
cash_available_rub=plan.cash_available_rub,
buckets=[
RebalanceBucketOut(
bucket=b.bucket,
current_value_rub=b.current_value_rub,
current_weight=b.current_weight,
target_weight=b.target_weight,
drift=b.drift,
within_band=b.within_band,
delta_value_rub=b.delta_value_rub,
trades=[
TradeOut(
instrument_id=t.instrument_id,
ticker=t.ticker,
name=t.name,
action=t.action,
# inside the band nothing is proposed, so the quantity is NULL
suggested_qty=None if b.within_band else t.qty,
lot=t.lot,
price=t.price,
price_currency=t.price_currency,
amount_rub=t.amount_rub,
blocked_by_cash=t.blocked_by_cash,
)
for t in b.trades
],
)
for b in plan.buckets
],
warnings=plan.warnings,
)
__all__ = ["router"]
+116
View File
@@ -0,0 +1,116 @@
"""Tax: the year's summary, and the open lots with their ЛДВ dates.
`GET /tax` reads `metric_tax_year`, which the refresh built. `GET /tax/lots` is computed per
request on purpose — it depends on today's date and today's price, and a nightly table would
answer `days_to_ldv` as of last night, which is off by one on precisely the day the number
is being consulted.
Both responses carry `estimated: true`, the rate that produced them and a disclaimer. The
broker is the tax agent; see `analytics/tax.py` for what is and is not modelled.
"""
from __future__ import annotations
from dataclasses import asdict
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Query
from sqlalchemy import select
from fintracker.analytics import today_local
from fintracker.analytics.tax import DISCLAIMER, TAX_RATE, money, open_lot_tax
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.schemas.tax import (
TaxAccountOut,
TaxLotOut,
TaxLotsOut,
TaxTotals,
TaxYearOut,
)
from fintracker.models import Account, MetricTaxYear
router = APIRouter(prefix="/tax", tags=["tax"])
ZERO = Decimal(0)
TOTAL_FIELDS = (
"dividends_gross_rub",
"coupons_gross_rub",
"tax_withheld_rub",
"realized_gain_rub",
"realized_loss_rub",
"ldv_exempt_rub",
"taxable_base_rub",
"estimated_tax_rub",
)
@router.get("", name="summary")
async def tax_year(
session: SessionDep,
_: CurrentUser,
year: Annotated[int | None, Query(ge=1990, le=2200)] = None,
account_id: int | None = None,
) -> TaxYearOut:
"""The year's estimated tax position, per account and in total."""
year = year or today_local().year
conditions = [MetricTaxYear.year == year]
if account_id is not None:
conditions.append(MetricTaxYear.account_id == account_id)
rows = (
await session.execute(
select(MetricTaxYear, Account.name)
.join(Account, Account.id == MetricTaxYear.account_id)
.where(*conditions)
.order_by(Account.name)
)
).all()
accounts = [
TaxAccountOut(
account_id=r[0].account_id,
account_name=r[1],
**{f: getattr(r[0], f) for f in TOTAL_FIELDS},
)
for r in rows
]
# the rate is read back from the rows rather than assumed, so a year computed under an
# older rate keeps reporting the rate it was computed with
rate = rows[0][0].tax_rate if rows else TAX_RATE
return TaxYearOut(
year=year,
tax_rate=rate,
accounts=accounts,
totals=TaxTotals(
**{f: money(sum((getattr(a, f) for a in accounts), start=ZERO)) for f in TOTAL_FIELDS}
),
disclaimer=DISCLAIMER,
)
@router.get("/lots", name="lots")
async def tax_lots(
session: SessionDep,
_: CurrentUser,
year: Annotated[int | None, Query(ge=1990, le=2200)] = None,
account_id: int | None = None,
) -> TaxLotsOut:
"""Open lots with the date after which a sale falls under the long-term exemption.
`year` selects the rate the "what if I sold today" figure is computed at; the lots
themselves are always the ones open right now. `lot` is rebuilt from the ledger on every
refresh and holds today's state only, so there is no honest way to answer "which lots
were open on 31 December two years ago" — and inventing one would be worse than the
limitation.
"""
as_of = today_local()
year = year or as_of.year
lots = await open_lot_tax(session, as_of=as_of, account_id=account_id)
return TaxLotsOut(
year=year,
as_of=as_of,
tax_rate=TAX_RATE,
lots=[TaxLotOut(**asdict(lot)) for lot in lots],
disclaimer=DISCLAIMER,
)
@@ -0,0 +1,87 @@
"""Schemas for the benchmark list and the portfolio-vs-index comparison (phase-4 contract §3).
`kind` crosses the wire as a plain string (`price` | `total_return`), like every other stable
key in this API — and unlike a Dart enum, which the generated client cannot always name. The
client is expected to *use* it: a portfolio compared against a price index is being compared
against a series that throws its dividends away, and the screen has to say so.
"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, Field
from fintracker.api.schemas.common import MoneyOpt
class BenchmarkOut(BaseModel):
id: int
code: str
"""MOEX secid or another stable code: IMOEX, MCFTR, RGBITR."""
name: str
kind: str
"""price | total_return — a price index understates a holder's result by its dividends."""
source: str
"""moex | manual"""
currency: str
is_default: bool
is_active: bool
instrument_id: int | None
"""The instrument carrying the index history; null until the index has been synced."""
history_from: date | None
history_to: date | None
"""Range actually present in `price_daily`; null when there is no history at all."""
class BenchmarkCreate(BaseModel):
code: str = Field(min_length=1, max_length=32)
name: str = Field(min_length=1, max_length=128)
kind: str
"""price | total_return"""
source: str = "moex"
currency: str = "RUB"
is_default: bool = False
is_active: bool = True
instrument_id: int | None = None
class BenchmarkPatch(BaseModel):
code: str | None = Field(default=None, min_length=1, max_length=32)
name: str | None = Field(default=None, min_length=1, max_length=128)
kind: str | None = None
source: str | None = None
currency: str | None = None
is_default: bool | None = None
is_active: bool | None = None
instrument_id: int | None = None
class BenchmarkReturnOut(BaseModel):
benchmark_id: int
code: str
name: str
kind: str
twr: MoneyOpt
"""Cumulative over the period; null when the index had no quote to start from."""
twr_annualized: MoneyOpt
days_skipped: int
"""Days of the compared window the index had no quote for. Non-zero means the two series
are not day-for-day comparable, and the client must show it."""
excess: MoneyOpt
"""portfolio_twr - twr; null when either side is unknown."""
class BenchmarkComparisonOut(BaseModel):
period: str
"""1m | 3m | 6m | ytd | 1y | 3y | all"""
date_from: date
date_to: date
portfolio_twr: MoneyOpt
portfolio_twr_annualized: MoneyOpt
portfolio_days_skipped: int
benchmarks: list[BenchmarkReturnOut]
class BenchmarkComparison(BaseModel):
rows: list[BenchmarkComparisonOut]
@@ -0,0 +1,63 @@
"""Wire shapes for goals and their progress (docs/ai/phase4-contract.md §4)."""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, ConfigDict, Field
from fintracker.api.schemas.common import Money, MoneyOpt
class GoalOut(BaseModel):
id: int
name: str
scope: str
target_amount: Money
currency: str
target_date: date | None
monthly_contribution: MoneyOpt
note: str | None
archived: bool
class GoalCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=128)
scope: str = Field(default="all", max_length=32)
target_amount: Money
currency: str = Field(default="RUB", min_length=3, max_length=3)
target_date: date | None = None
monthly_contribution: MoneyOpt = None
note: str | None = None
archived: bool = False
class GoalPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=128)
scope: str | None = Field(default=None, max_length=32)
target_amount: MoneyOpt = None
currency: str | None = Field(default=None, min_length=3, max_length=3)
target_date: date | None = None
monthly_contribution: MoneyOpt = None
note: str | None = None
archived: bool | None = None
class GoalProgressOut(BaseModel):
goal_id: int
as_of: date
current_value_rub: Money
target_amount_rub: Money
progress: Money
projected_date: date | None
"""NULL means the current trend does not reach the target — never a far-future date."""
basis: str
"""xirr | contribution | none"""
assumed_rate: MoneyOpt
monthly_needed_rub: MoneyOpt
"""NULL when the goal has no deadline, or the deadline has already passed."""
on_track: bool | None
@@ -0,0 +1,84 @@
"""Schemas for `/income` — the dividend and coupon calendar (phase-4 contract §1).
`basis` is a plain string here and on every row, never an enum and never optional: a calendar
that shows «12 400 ₽ ожидается» without saying whether that is a published coupon schedule or
an extrapolation from two payments is not a forecast the user can audit. The client is
required to render it.
`amount_rub` is nullable for the usual reason — the payment's date had no CBR rate — and the
client shows that as «нет курса», not as 0 ₽.
"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel
from fintracker.api.schemas.common import Money, MoneyOpt
class CalendarEntry(BaseModel):
instrument_id: int
ticker: str | None
name: str
kind: str
"""dividend | coupon | amortization | repayment"""
expected_date: date
record_date: date | None
qty: Money
"""Position the amount was computed on; 0 on a historical row, which carries its own sum."""
per_unit: MoneyOpt
amount: Money
currency: str
amount_rub: MoneyOpt
"""Null when the date has no rate — never a substituted number."""
basis: str
"""schedule | announced | history | paid — where the number came from."""
tax_withheld: MoneyOpt
class CalendarOut(BaseModel):
as_of: date
currency: str
total_expected_rub: Money
"""Sum of the FUTURE entries only; paid rows never inflate an expectation."""
entries: list[CalendarEntry]
by_basis: dict[str, Money]
"""Expected RUB split by basis. Sums that mix `schedule` with `history` hide the guess."""
class HistoryRow(BaseModel):
month: date
"""First day of the month."""
kind: str
currency: str
amount: Money
amount_rub: MoneyOpt
tax_withheld: Money
payment_count: int
class HistoryTotals(BaseModel):
amount_rub: Money
tax_withheld_rub: Money
class HistoryOut(BaseModel):
rows: list[HistoryRow]
totals: HistoryTotals
class ForecastMonth(BaseModel):
month: date
amount_rub: Money
by_basis: dict[str, Money]
class ForecastOut(BaseModel):
months: list[ForecastMonth]
total_rub: Money
annual_yield_on_value: MoneyOpt
"""Expected 12-month income over the scope's current value; null without a valuation."""
warnings: list[str]
"""What the forecast could not cover, verbatim from the data-quality findings."""
@@ -0,0 +1,87 @@
"""Wire shapes for target weights and rebalancing (docs/ai/phase4-contract.md §2).
`dimension`, `bucket` and `action` are plain strings, never enums: `AssetClass` must not
leak into the generated Dart client (AGENTS.md), and the same rule keeps every stable key
a string. Weights, drifts and quantities are Decimals serialised as strings — a weight is
`"0.6"`, not `60`.
"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, ConfigDict, Field
from fintracker.api.schemas.common import Money, MoneyOpt
class TargetIn(BaseModel):
model_config = ConfigDict(extra="forbid")
bucket: str = Field(min_length=1, max_length=64)
target_weight: Money
band: MoneyOpt = None
"""Tolerance in the same units as the weight: `"0.05"` is ±5 pp, not 5 %."""
note: str | None = None
class TargetsIn(BaseModel):
"""A complete set for one dimension. Partial updates are not supported: weights only
mean anything together, and a half-written set could not be checked against 1."""
model_config = ConfigDict(extra="forbid")
dimension: str = Field(description="asset_class | sector | country | currency")
targets: list[TargetIn]
class TargetOut(BaseModel):
bucket: str
target_weight: Money
band: MoneyOpt
note: str | None
class TargetsOut(BaseModel):
portfolio_id: int
dimension: str
targets: list[TargetOut]
weights_sum: Money
"""What the weights actually add up to. The server never normalises them."""
class TradeOut(BaseModel):
instrument_id: int
ticker: str
name: str
action: str
"""buy | sell"""
suggested_qty: MoneyOpt
"""Whole lots; NULL when the instrument has no usable price."""
lot: int | None
price: MoneyOpt
price_currency: str | None
amount_rub: Money
blocked_by_cash: bool
class RebalanceBucketOut(BaseModel):
bucket: str
current_value_rub: Money
current_weight: Money
target_weight: MoneyOpt
drift: MoneyOpt
"""current - target, in fractions of the whole portfolio."""
within_band: bool
delta_value_rub: Money
trades: list[TradeOut]
class RebalanceOut(BaseModel):
portfolio_id: int
dimension: str
as_of: date
total_value_rub: Money
cash_available_rub: Money
buckets: list[RebalanceBucketOut]
warnings: list[str]
+86
View File
@@ -0,0 +1,86 @@
"""Schemas for the tax screens (phase-4 contract §5).
Every response carries `estimated: true`, the `tax_rate` that produced it and a `disclaimer`,
and none of the three is optional. The broker is the tax agent; these numbers exist so its
certificate can be checked and so the cost of selling before the three-year mark is visible
*before* the sale, not to replace it. A client that drops the marking is showing a number it
is not entitled to show.
"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel
from fintracker.api.schemas.common import Money, MoneyOpt
class TaxTotals(BaseModel):
dividends_gross_rub: Money
coupons_gross_rub: Money
tax_withheld_rub: Money
"""What the broker already held back on those payments."""
realized_gain_rub: Money
"""Sum of the profitable disposals of the year, LDV ones included."""
realized_loss_rub: Money
"""Sum of the losing ones, negative."""
ldv_exempt_rub: Money
"""Net result of the disposals exempt under art. 219.1, taken back out of the base.
Negative if those lots lost money: the exemption removes the whole result, not the
profitable half."""
taxable_base_rub: Money
"""max(0, gain + loss - ldv_exempt). Dividends and coupons are not in it — the agent
withholds on them at source."""
estimated_tax_rub: Money
class TaxAccountOut(TaxTotals):
account_id: int
account_name: str
class TaxYearOut(BaseModel):
year: int
estimated: bool = True
"""Always true. There is no mode in which this endpoint returns an authoritative figure."""
tax_rate: Money
"""The rate actually applied, as a fraction: "0.13", never 13."""
accounts: list[TaxAccountOut]
totals: TaxTotals
disclaimer: str
class TaxLotOut(BaseModel):
lot_id: int
instrument_id: int
ticker: str | None
name: str
account_id: int
account_name: str
open_date: date
qty_remaining: Money
cost_rub: MoneyOpt
"""Cost at the CBR rate of the lot's own open date, prorated to the part still held;
null when that day had no rate."""
market_value_rub: MoneyOpt
"""Null when the instrument has no price — never zero."""
unrealized_gain_rub: MoneyOpt
ldv_eligible: bool
"""Held three full years on an exchange-traded instrument (art. 219.1) — an estimate:
the classic rule only, MOEX papers only, ИИС regimes not modelled."""
ldv_date: date | None
"""First day a sale qualifies; null for an instrument that cannot qualify at all."""
days_to_ldv: int | None
"""Calendar days left, 0 once eligible; null alongside a null `ldv_date`."""
tax_if_sold_now_rub: MoneyOpt
"""0 for an eligible lot; null when the position cannot be valued."""
class TaxLotsOut(BaseModel):
year: int
as_of: date
estimated: bool = True
tax_rate: Money
lots: list[TaxLotOut]
disclaimer: str