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:
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user