fire-planner/fire_planner/goals_eval.py
Viktor Barzin 9cc781a8d6
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
income streams, Sankey cashflow, progress overlay, settings sub-pages

Wave 1 (9 features across 4 streams):

Stream A — dashboard skeleton
  1.A.1 ScenarioShell with top tabs (Plan/Cash Flow/Tax Analytics/Compare/
        Reports/Estate/Settings) + left Sidebar with Plans switcher.
  1.A.2 GET /scenarios/{id}/year-stats?year=N returning per-year metrics
        (NW, Δ NW, taxable income, taxes, eff. rate, spending, contribs,
        investment growth). YearScrubber + YearStatsPanel render the
        right-hand sidebar; URL ?year= preserves selection.
  1.A.3 FanChart gains optional `milestones` prop (lib/milestone.ts maps
        life_event.kind → emoji) + selectedYear marker line.

Stream B — goals + progress
  1.B.1 New goals_eval module: target_nw_by_year / never_run_out /
        target_real_income probability evaluation. Wired into POST
        /simulate (exact, per-path) and GET /scenarios/{id}/projection
        (approximated from persisted fan via percentile interpolation).
        GoalsSection renders pass/fail badges.
  1.B.2 GET /scenarios/{id}/progress overlays AccountSnapshot totals on
        the projection fan; ProgressPage shows variance side-panel.

Stream C — income + cashflow
  1.C.1 New IncomeStream model + alembic 0003 + CRUD endpoints. Engine
        aggregates streams into per-year inflows + taxable arrays;
        income tax routes through the jurisdiction tax engine.
        IncomeStreamsSection on Plan tab.
  1.C.2 GET /scenarios/{id}/cashflow?year=N returns sources/sinks for
        an ECharts Sankey (sums conserve). CashflowTab body.

Stream D — settings
  1.D.1 SettingsTab + sub-nav (Milestones/Rates/Dividends/Bonds/Tax/
        Metrics/Other/Notes); placeholder cards for unbuilt sub-pages.
  1.D.2 LifeEventsSection relocated to /scenarios/:id/settings.
  1.D.3 RatesSettings (Fixed/Historical/Advanced segmented + per-asset
        cards). SimulateRequest gains rates_mode, inflation_pct,
        stocks/bonds growth + dividend, stocks_allocation. New
        build_fixed_paths() in simulator. Real-return arithmetic
        verified against (1+g+d)/(1+i)−1 ≈ 5.4%.
  1.D.4 NotesSettings — markdown textarea, save-on-blur, stored in
        scenario.config_json.notes.

Backend: 238 pytest pass (+19 new), mypy + ruff clean.
Frontend: typecheck + 7 unit tests + production build clean.

Roadmap for Wave 2-N is documented in the implementation plan.
2026-05-10 12:49:44 +00:00

130 lines
4.3 KiB
Python

"""Evaluate retirement goals against Monte Carlo simulation results.
Goal-kind contract (v1):
- ``target_nw_by_year`` — at year ``target_year`` the real portfolio must
satisfy ``comparator target_amount_gbp`` (e.g. ``>= 2_000_000``).
Probability = fraction of paths that hit the comparator at that year.
- ``never_run_out`` — portfolio must stay > 0 at every year up to
``target_year`` (or the full horizon if ``target_year`` is None).
Probability = fraction of paths that never hit zero in the window.
- ``target_real_income`` — median (over the window ``target_year`` ..
horizon) real withdrawal must satisfy ``comparator target_amount_gbp``.
Probability = fraction of paths whose median window withdrawal hits.
Goal kinds the v1 evaluator does not yet recognise return probability=0.0
and ``passed=False`` so the API surface stays uniform — frontend can
render an "unsupported kind" hint.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol
import numpy as np
from fire_planner.simulator import SimulationResult
class _GoalLike(Protocol):
"""Duck-typed goal — ORM `RetirementGoal` and unsaved goals both fit."""
kind: str
name: str
target_amount_gbp: Decimal | None
target_year: int | None
comparator: str
success_threshold: Decimal
@dataclass(frozen=True)
class GoalEvaluation:
goal_id: int | None
name: str
kind: str
probability: float
threshold: float
passed: bool
_COMPARATORS = {
">=": np.greater_equal,
">": np.greater,
"<=": np.less_equal,
"<": np.less,
"=": np.equal,
}
def _comparator_fn(op: str): # type: ignore[no-untyped-def]
return _COMPARATORS.get(op, np.greater_equal)
def _eval_one(
result: SimulationResult,
goal: _GoalLike,
horizon_years: int,
) -> tuple[float, bool]:
threshold = float(goal.success_threshold)
if goal.kind == "target_nw_by_year":
if goal.target_year is None or goal.target_amount_gbp is None:
return 0.0, False
# portfolio_real has columns 0..n_years (year 0 = seed). Clip.
col = max(0, min(int(goal.target_year), horizon_years))
target = float(goal.target_amount_gbp)
cmp_fn = _comparator_fn(goal.comparator)
hits = cmp_fn(result.portfolio_real[:, col], target)
prob = float(np.mean(hits))
return prob, prob >= threshold
if goal.kind == "never_run_out":
# Stay > 0 across years 1..target_year (excludes seed year 0).
end = int(goal.target_year) if goal.target_year is not None else horizon_years
end = max(1, min(end, horizon_years))
# portfolio_real has n_years+1 cols; index 1..end inclusive.
survived = (result.portfolio_real[:, 1:end + 1] > 0.0).all(axis=1)
prob = float(np.mean(survived))
return prob, prob >= threshold
if goal.kind == "target_real_income":
if goal.target_amount_gbp is None:
return 0.0, False
start_y = int(goal.target_year) if goal.target_year is not None else 0
start_y = max(0, min(start_y, horizon_years - 1))
window = result.withdrawal_real[:, start_y:]
if window.size == 0:
return 0.0, False
path_medians = np.median(window, axis=1)
target = float(goal.target_amount_gbp)
cmp_fn = _comparator_fn(goal.comparator)
hits = cmp_fn(path_medians, target)
prob = float(np.mean(hits))
return prob, prob >= threshold
return 0.0, False
def evaluate_goals(
result: SimulationResult,
goals: Iterable[_GoalLike],
horizon_years: int | None = None,
) -> list[GoalEvaluation]:
"""Compute probability + pass/fail for every enabled goal."""
H = horizon_years if horizon_years is not None else result.n_years
out: list[GoalEvaluation] = []
for goal in goals:
if not getattr(goal, "enabled", True):
continue
prob, passed = _eval_one(result, goal, H)
out.append(
GoalEvaluation(
goal_id=getattr(goal, "id", None),
name=goal.name,
kind=goal.kind,
probability=prob,
threshold=float(goal.success_threshold),
passed=passed,
))
return out