Files
fin-tracker/backend/src/fintracker/analytics/goals.py
T
Dmitry 15f5812ea4 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).
2026-09-19 10:42:50 +03:00

437 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""How far a goal has come, and when it gets there (plan §Фаза 4).
A goal is an amount, a scope and optionally a date. Everything on the screen is derived:
where the scope stands today (`metric_portfolio_value_daily`), what it has been earning
(`metric_returns`), and what the user said they would keep adding
(`goal.monthly_contribution`).
**The projected date is allowed to be NULL, and often should be.** A portfolio that is flat
or falling and receives nothing new never reaches a target above its current value. The
honest output for that is «not on this trend», not a date in 2087 — a far date looks like an
answer without being one, and a client cannot tell the two apart. So the projection runs a
month at a time over a bounded horizon (`MAX_HORIZON_MONTHS`) and returns NULL if the target
is still out of reach at the end of it.
**Which rate is assumed, and when a rate is assumed at all** — that is what `basis` names:
* `xirr` — the scope's own trailing money-weighted return, taken from `metric_returns`.
Only from a period spanning at least `MIN_XIRR_HISTORY_DAYS` (180): an annualised rate is
a per-year figure, and a goal projection compounds it over years, so a rate extracted from
six weeks of a single position moving would be multiplied into a decade of fiction. (The
30-day floor `analytics/returns.py` uses for a per-instrument XIRR is a display threshold;
this one feeds a forecast, which is a stronger claim.) Among the qualifying periods the
**shortest** is preferred, because a goal is about what the portfolio is doing now, not in
2019 — `1y` before `3y` before `all`.
* `contribution` — no usable trailing return, but the user plans to add money every month.
The projection then assumes zero growth: what is planned is a deposit, not a yield.
* `none` — neither. There is no trend to extrapolate, so `projected_date` is NULL.
A negative trailing XIRR is used as it is, not clamped to zero. If the goal also receives
contributions it may still be reached, slower; if it does not, the projection returns NULL,
which is the requirement.
**`monthly_needed_rub`** answers the other direction: given the deadline, the current value
and the assumed rate, how much has to go in every month. NULL without a deadline, and NULL
once the deadline has passed — there is no «per month» in zero months left.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from sqlalchemy import delete, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import FINDINGS, today_local
from fintracker.models import (
Goal,
MetricGoalProgress,
MetricPortfolioValueDaily,
MetricReturns,
)
from fintracker.pricing.fx import FxTable
log = logging.getLogger(__name__)
ZERO = Decimal(0)
ONE = Decimal(1)
MONTHS_IN_YEAR = Decimal(12)
RATE_PLACES = Decimal("0.000001")
MONEY_PLACES = Decimal("0.01")
MIN_XIRR_HISTORY_DAYS = 180
"""Shortest trailing window whose annualised return may be compounded into a forecast."""
MAX_HORIZON_MONTHS = 360
"""30 years. Past this the projection stops being information and starts being a shrug, so
the answer becomes NULL instead of a date nobody would plan around."""
#: Preference order among the periods `metric_returns` stores: the recent trend first.
PERIOD_PREFERENCE = ("1y", "3y", "all", "ytd", "6m", "3m", "1m")
BASIS_XIRR = "xirr"
BASIS_CONTRIBUTION = "contribution"
BASIS_NONE = "none"
@dataclass(frozen=True)
class Projection:
projected_date: date | None
basis: str
assumed_rate: Decimal | None
@dataclass(frozen=True)
class GoalProgress:
goal_id: int
as_of: date
current_value_rub: Decimal
target_amount_rub: Decimal
progress: Decimal
projected_date: date | None
basis: str
assumed_rate: Decimal | None
monthly_needed_rub: Decimal | None
on_track: bool | None
def monthly_rate(annual: Decimal) -> Decimal:
"""The monthly rate that compounds to `annual` over twelve months.
Decimal all the way: `(1 + r) ** (1/12)` is exactly what `decimal` computes correctly
rounded, so no float ever touches a number that ends up in a stored column.
"""
base = ONE + annual
if base <= ZERO:
# a loss of 100 % or worse has no real twelfth root; treat it as total loss
return Decimal(-1)
return base ** (ONE / MONTHS_IN_YEAR) - ONE
def add_months(d: date, months: int) -> date:
total = d.year * 12 + (d.month - 1) + months
year, month = divmod(total, 12)
day = min(d.day, _days_in_month(year, month + 1))
return date(year, month + 1, day)
def _days_in_month(year: int, month: int) -> int:
if month == 12:
return 31
return (date(year, month + 1, 1) - date(year, month, 1)).days
def months_between(start: date, end: date) -> int:
"""Whole months from `start` to `end`, never negative."""
months = (end.year - start.year) * 12 + (end.month - start.month)
if end.day < start.day:
months -= 1
return max(0, months)
def project(
*,
current: Decimal,
target: Decimal,
as_of: date,
annual_rate: Decimal | None,
monthly_contribution: Decimal,
basis: str,
) -> Projection:
"""Step the balance forward a month at a time until it reaches the target, or give up.
Iteration rather than a closed-form solve because the closed form needs a logarithm (a
float) and has to special-case a zero rate, a negative rate and a contribution that
exactly offsets the drawdown. A loop of at most 360 Decimal multiplications is cheap,
exact, and impossible to read wrong.
"""
if basis == BASIS_NONE:
return Projection(None, BASIS_NONE, None)
if current >= target:
return Projection(as_of, basis, annual_rate)
rate = annual_rate if annual_rate is not None else ZERO
step = monthly_rate(rate)
value = current
for month in range(1, MAX_HORIZON_MONTHS + 1):
value = value * (ONE + step) + monthly_contribution
if value >= target:
return Projection(add_months(as_of, month), basis, annual_rate)
return Projection(None, basis, annual_rate)
def monthly_needed(
*, current: Decimal, target: Decimal, months: int, annual_rate: Decimal | None
) -> Decimal | None:
"""Contribution per month that lands exactly on `target` after `months` months.
Same iteration argument as `project`: the annuity formula divides by the rate and breaks
at zero, so the required payment is found by bisection on a function that is monotone in
the payment. Returns 0 when the goal is already met on the trend alone.
"""
if months <= 0:
return None
rate = annual_rate if annual_rate is not None else ZERO
step = monthly_rate(rate)
def final(payment: Decimal) -> Decimal:
value = current
for _ in range(months):
value = value * (ONE + step) + payment
return value
if final(ZERO) >= target:
return ZERO
low, high = ZERO, max(target, ONE)
while final(high) < target:
high *= 2
if high > target * 1000:
return None
for _ in range(60):
mid = (low + high) / 2
if final(mid) < target:
low = mid
else:
high = mid
return high.quantize(MONEY_PLACES)
def pick_rate(rows: Sequence[Mapping[str, object]]) -> tuple[Decimal | None, str]:
"""Trailing XIRR to project with, and the period it came from.
`rows` are `metric_returns` rows of the goal's scope as mappings with `period`,
`date_from`, `date_to` and `xirr`.
"""
usable = {}
for row in rows:
xirr = row.get("xirr")
date_from, date_to = row.get("date_from"), row.get("date_to")
if xirr is None or not isinstance(date_from, date) or not isinstance(date_to, date):
continue
if (date_to - date_from).days < MIN_XIRR_HISTORY_DAYS:
continue
usable[str(row.get("period"))] = Decimal(str(xirr))
for period in PERIOD_PREFERENCE:
if period in usable:
return usable[period], period
return None, ""
def evaluate(
*,
goal_id: int,
as_of: date,
current: Decimal,
target: Decimal,
target_date: date | None,
monthly_contribution: Decimal | None,
trailing_xirr: Decimal | None,
) -> GoalProgress:
"""Everything the progress row holds, from numbers already in hand."""
contribution = monthly_contribution or ZERO
if trailing_xirr is not None:
basis, rate = BASIS_XIRR, trailing_xirr
elif contribution > ZERO:
basis, rate = BASIS_CONTRIBUTION, ZERO
else:
basis, rate = BASIS_NONE, None
projection = project(
current=current,
target=target,
as_of=as_of,
annual_rate=rate,
monthly_contribution=contribution,
basis=basis,
)
progress = (current / target).quantize(RATE_PLACES) if target > ZERO else ZERO
progress = max(ZERO, progress)
needed: Decimal | None = None
on_track: bool | None = None
if target_date is not None:
needed = monthly_needed(
current=current,
target=target,
months=months_between(as_of, target_date),
annual_rate=rate,
)
projected = projection.projected_date
on_track = projected is not None and projected <= target_date
return GoalProgress(
goal_id=goal_id,
as_of=as_of,
current_value_rub=current,
target_amount_rub=target,
progress=progress,
projected_date=projection.projected_date,
basis=projection.basis,
assumed_rate=(
projection.assumed_rate.quantize(RATE_PLACES)
if projection.assumed_rate is not None
else None
),
monthly_needed_rub=needed,
on_track=on_track,
)
# --------------------------------------------------------------------------- loading
async def _scope_values(session: AsyncSession) -> dict[str, Decimal]:
"""Latest total per scope, from the daily value series."""
rows = (
await session.execute(
select(
MetricPortfolioValueDaily.scope,
MetricPortfolioValueDaily.d,
MetricPortfolioValueDaily.total_rub,
).order_by(MetricPortfolioValueDaily.scope, MetricPortfolioValueDaily.d)
)
).all()
out: dict[str, Decimal] = {}
for r in rows:
out[r.scope] = Decimal(r.total_rub)
return out
async def _scope_rates(session: AsyncSession) -> dict[str, Decimal]:
"""Trailing XIRR per scope, chosen by `pick_rate`."""
rows = (
await session.execute(
select(
MetricReturns.scope,
MetricReturns.period,
MetricReturns.date_from,
MetricReturns.date_to,
MetricReturns.xirr,
)
)
).all()
per_scope: dict[str, list[dict[str, object]]] = {}
for r in rows:
per_scope.setdefault(r.scope, []).append(
{
"period": r.period,
"date_from": r.date_from,
"date_to": r.date_to,
"xirr": r.xirr,
}
)
out: dict[str, Decimal] = {}
for scope, scope_rows in per_scope.items():
rate, _ = pick_rate(scope_rows)
if rate is not None:
out[scope] = rate
return out
async def compute_goal_progress(session: AsyncSession, goal: Goal) -> GoalProgress | None:
"""Progress of one goal; None when its target cannot be expressed in RUB."""
as_of = today_local()
values = await _scope_values(session)
rates = await _scope_rates(session)
return await _evaluate_goal(session, goal, as_of, values, rates)
async def _evaluate_goal(
session: AsyncSession,
goal: Goal,
as_of: date,
values: Mapping[str, Decimal],
rates: Mapping[str, Decimal],
) -> GoalProgress | None:
target = Decimal(goal.target_amount)
if (goal.currency or "RUB").upper() != "RUB":
fx = await FxTable.load(session)
converted = fx.to_rub(target, goal.currency, as_of)
if converted is None:
FINDINGS.add(
"goal_without_fx",
"warn",
f"Цель «{goal.name}» задана в {goal.currency}, но курса на {as_of} нет — "
f"прогресс не посчитан",
ref={"goal_id": goal.id},
)
return None
target = converted
if target <= ZERO:
FINDINGS.add(
"goal_without_target",
"warn",
f"У цели «{goal.name}» неположительная сумма — прогресс не посчитан",
ref={"goal_id": goal.id},
)
return None
current = values.get(goal.scope)
if current is None:
FINDINGS.add(
"goal_scope_without_metrics",
"warn",
f"Для цели «{goal.name}» нет метрик по scope {goal.scope} — прогресс нулевой",
ref={"goal_id": goal.id, "scope": goal.scope},
)
current = ZERO
return evaluate(
goal_id=goal.id,
as_of=as_of,
current=current,
target=target,
target_date=goal.target_date,
monthly_contribution=(
Decimal(goal.monthly_contribution) if goal.monthly_contribution is not None else None
),
trailing_xirr=rates.get(goal.scope),
)
# --------------------------------------------------------------------------- refresh step
async def rebuild_goal_progress(session: AsyncSession) -> None:
"""Replace `metric_goal_progress` for every goal that is not archived."""
await session.execute(delete(MetricGoalProgress))
goals = (
(await session.execute(select(Goal).where(Goal.archived.is_(False)).order_by(Goal.id)))
.scalars()
.all()
)
if not goals:
return
as_of = today_local()
values = await _scope_values(session)
rates = await _scope_rates(session)
rows: list[dict[str, object]] = []
for goal in goals:
progress = await _evaluate_goal(session, goal, as_of, values, rates)
if progress is None:
continue
rows.append(
{
"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,
}
)
if rows:
await session.execute(insert(MetricGoalProgress), rows)
log.info("goals: %s rows", len(rows))