"""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))