fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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.
This commit is contained in:
Viktor Barzin 2026-05-10 12:49:44 +00:00
parent e12e8f9290
commit 9cc781a8d6
42 changed files with 3765 additions and 80 deletions

View file

@ -146,6 +146,33 @@ def jurisdiction_schedule(
return fn
def build_fixed_paths(
n_paths: int,
n_years: int,
inflation_pct: float,
stocks_growth_pct: float,
stocks_dividend_pct: float,
bonds_growth_pct: float,
bonds_dividend_pct: float,
) -> npt.NDArray[np.float64]:
"""Build a deterministic ``(n_paths, n_years, 3)`` paths cube with
constant nominal stock + bond returns and inflation. The nominal
return per asset is ``growth + dividend``; the simulator's existing
``(1 + nominal) / (1 + cpi) 1`` arithmetic then turns it into the
real return path.
Used by the Wave 1.D.3 fixed Rates mode same shape as the Shiller
bootstrap, so the rest of the engine doesn't change.
"""
nominal_stock = stocks_growth_pct + stocks_dividend_pct
nominal_bond = bonds_growth_pct + bonds_dividend_pct
paths = np.zeros((n_paths, n_years, 3), dtype=np.float64)
paths[:, :, STOCK] = nominal_stock
paths[:, :, BOND] = nominal_bond
paths[:, :, CPI] = inflation_pct
return paths
def simulate(
paths: npt.NDArray[np.float64],
initial_portfolio: float,
@ -157,6 +184,8 @@ def simulate(
annual_savings: npt.NDArray[np.float64] | None = None,
cashflow_adjustments: npt.NDArray[np.float64] | None = None,
bucket_split: _BucketSplit = default_bucket_split,
income_inflows: npt.NDArray[np.float64] | None = None,
income_taxable: npt.NDArray[np.float64] | None = None,
) -> SimulationResult:
"""Run the MC simulation. `paths` shape: (n_paths, n_years, 3).
@ -190,6 +219,10 @@ def simulate(
annual_savings = np.zeros(n_years, dtype=np.float64)
if cashflow_adjustments is None:
cashflow_adjustments = np.zeros(n_years, dtype=np.float64)
if income_inflows is None:
income_inflows = np.zeros(n_years, dtype=np.float64)
if income_taxable is None:
income_taxable = np.zeros(n_years, dtype=np.float64)
for y in range(n_years):
alloc = glide(y)
@ -201,11 +234,18 @@ def simulate(
real_bond = (1 + nominal_bond) / (1 + cpi) - 1
port_return = alloc * real_stock + (1 - alloc) * real_bond
# Add savings at year start, apply year's return, then apply
# life-event cashflow adjustments. Adjustments don't compound
# this year's returns (they're treated as end-of-year events).
portfolio = (portfolio + annual_savings[y]) * (1 + port_return)
# Add savings + recurring income inflows at year start, apply
# year's return, then apply life-event cashflow adjustments.
# Adjustments don't compound this year's returns (they're
# treated as end-of-year events). Income tax on `income_taxable[y]`
# is collected once outside the per-path loop below — it's path
# invariant when the regime is the same for all paths.
portfolio = (portfolio + annual_savings[y] + income_inflows[y]) * (1 + port_return)
portfolio = portfolio + cashflow_adjustments[y]
if income_taxable[y] > 0.0:
income_tax_breakdown = regime_at(y).compute_tax(
TaxInputs(earned_income=Decimal(str(round(float(income_taxable[y]), 2)))))
portfolio = portfolio - float(income_tax_breakdown.total)
# Strategy is per-path Python — 600k iterations at 60y × 10k paths.
# Profiled: ~3 seconds for the full Trinity / GK / VPW set.