Второй источник выплат: 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).
437 lines
15 KiB
Python
437 lines
15 KiB
Python
"""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))
|