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:
@@ -0,0 +1,436 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user