fire-planner/fire_planner/api/simulate.py

388 lines
16 KiB
Python
Raw Normal View History

"""Sync simulate + multi-scenario compare.
Unlike the persisted Cartesian recompute (`/recompute`), these run a
single scenario inline and return the result immediately. The React UI
uses these for what-if exploration no DB write.
Returns a fan-chart series in the same shape as
`GET /scenarios/{id}/projection`, so frontend chart code is shared.
"""
from __future__ import annotations
import asyncio
import logging
import time
from decimal import Decimal
from pathlib import Path
import numpy as np
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from fire_planner.api.dependencies import get_session
from fire_planner.api.schemas import (
CompareRequest,
CompareResult,
ExamplesOverlay,
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
GoalProbability,
ProjectionPoint,
SimulateRequest,
SimulateResult,
)
col: simulator auto-adjusts spending to local prices via Numbeo+Expatistan The Monte Carlo used to compare jurisdictions at a flat London-equivalent spend, which silently overstated the cost-of-living for any move to a cheaper region. Now every cross-jurisdiction simulation auto-scales spending_gbp by the real Numbeo/Expatistan ratio between the user's baseline city and the target city. Architecture: - fire_planner/col/baseline.py — 22 cities with headline Numbeo data (source URLs + snapshot dates embedded) — fallback when scraper fails - col/numbeo.py + col/expatistan.py — httpx async scrapers, regex-parsed, polite 1.1s rate-limit, EUR/USD anchored - col/cache.py — PG-backed cache (col_snapshot table, 1-year TTL) - col/service.py — sync compute_col_ratio() for the simulator; async lookup_city_cached() with source reconciliation for the refresh CronJob - alembic 0005 — col_snapshot table, UNIQUE(city_slug, source_name) Simulator wiring: - SimulateRequest gains col_auto_adjust=True (default), col_baseline_city, col_target_city. Defaults pick the jurisdiction's representative city. - _resolve_col_adjustment scales spending_gbp before path-building. - SimulateResult surfaces col_multiplier_applied + col_adjusted_spending_gbp. CLIs: - python -m fire_planner col-seed — loads BASELINES into col_snapshot (post-migration seed step) - python -m fire_planner col-refresh-stale --within-days 7 — used by the weekly fire-planner-col-refresh CronJob 268 tests pass. Mypy strict + ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:14:57 +00:00
from fire_planner.col import compute_col_ratio, representative_city_for
from fire_planner.examples.service import summary_for_country
fire-planner: Wave 2 chart-first — flex spending, categorised life events, interactive Visx Gantt + spending-profile chart Charts are now the primary editor for life events. The Plan-tab body re-orders to make charts ~80% of viewport real-estate; legacy form sections are collapsed into a drawer. Backend: - alembic 0004: life_event.category enum (essential / discretionary / not_spending). Defaults to essential so existing rows keep their full spending impact. - Simulator gains discretionary_outflows + flex_rules params. Tracks per-path running ATH, applies the deepest applicable cut to discretionary outflows when portfolio drops vs ATH (PLab-style flex spending). Cut amount stays in the portfolio (refund pattern). - New flex_spending module with FlexRule + applicable_cut + cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so users specify cumulative cuts at each tier. - New /scenarios/{id}/spending-profile endpoint returning per-year base / essential / discretionary / flex_cut / total breakdown. - SimulateRequest gains flex_rules + life_event.category roundtrip. - 8 new tests; 246 total pytest pass; mypy + ruff clean. Frontend (Visx + ECharts): - Installed @visx/{scale,shape,group,axis,event,responsive,tooltip} for native SVG drag interactions. - New <SpendingProfileChart> — Visx stacked-area of base/essential/ discretionary with red flex-cut overlay, hover tooltip, click-to- scrub-year. - New <EventGantt> — interactive Visx Gantt: * Click empty space → popover create at that year (default essential spending event) * Click a bar → inline edit popover (name, kind, range, £/y, category) with delete button * Drag bar middle → moves the whole event (year-resolution snap) * Drag bar edges → resizes year_start / year_end * All gestures persist via PATCH /life-events/{id} - New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on- change to scenario.config_json.flex_rules. - Plan-tab redesign: NW fan dominant top with floating stat badges (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending- profile chart middle; Gantt bottom; flex-rules editor; legacy form sections in a collapsed <details> drawer. - Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
from fire_planner.flex_spending import FlexRule as EngineFlexRule
from fire_planner.glide_path import static
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
from fire_planner.goals_eval import evaluate_goals
from fire_planner.income_streams import IncomeStreamInput, streams_to_arrays
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history Adds a "Returns model" picker on /what-if that switches how the simulator's `paths` (n_paths × n_years × 3) is built: 1. shiller (default) — current behaviour, block-bootstrap of the Shiller 1871+ historical series (or its synthetic-calibrated fallback when the CSV isn't mounted). 2. manual — every year of every path = the user's "real return %" input. Deterministic, no fan, useful for sanity checks. New helper `constant_real_return_paths` constructs the (n_paths, n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's `(1+nominal)/(1+cpi)-1` short-circuits to exactly the input. 3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync PG mirror, sums total_value + net_contribution across accounts per day (FX-adjusted), strips contribution deltas to isolate market return, compounds daily returns into per-calendar-year samples, block-bootstraps with block_size=1 (only ~6 distinct samples available, no serial-correlation signal to preserve). Glide path is a no-op in this mode — the user's actual blended portfolio is treated as a single asset. API: SimulateRequest gains `returns_mode` ("shiller"|"manual"| "wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths` dispatches; wealthfolio mode opens a transient session against the mirror DB. UI: new Field on the form (next to Strategy / Glide path) with a contextual hint that explains each option's tradeoff. The "About the model" panel at the bottom now has a "Returns model" section mirroring the same content. The Manual % input only shows when returns_mode='manual'. 10 new tests on the Wealthfolio helper (contribution-stripping, multi-account aggregation, FX, partial-year drop, TOTAL filter, empty-input, plus 3 deterministic-paths tests). 198 backend tests + 7 frontend tests. mypy strict + ruff + tsc strict all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
from fire_planner.ingest.wealthfolio_pg import create_wf_sync_engine_from_env
fire-planner: Wave 2 chart-first — flex spending, categorised life events, interactive Visx Gantt + spending-profile chart Charts are now the primary editor for life events. The Plan-tab body re-orders to make charts ~80% of viewport real-estate; legacy form sections are collapsed into a drawer. Backend: - alembic 0004: life_event.category enum (essential / discretionary / not_spending). Defaults to essential so existing rows keep their full spending impact. - Simulator gains discretionary_outflows + flex_rules params. Tracks per-path running ATH, applies the deepest applicable cut to discretionary outflows when portfolio drops vs ATH (PLab-style flex spending). Cut amount stays in the portfolio (refund pattern). - New flex_spending module with FlexRule + applicable_cut + cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so users specify cumulative cuts at each tier. - New /scenarios/{id}/spending-profile endpoint returning per-year base / essential / discretionary / flex_cut / total breakdown. - SimulateRequest gains flex_rules + life_event.category roundtrip. - 8 new tests; 246 total pytest pass; mypy + ruff clean. Frontend (Visx + ECharts): - Installed @visx/{scale,shape,group,axis,event,responsive,tooltip} for native SVG drag interactions. - New <SpendingProfileChart> — Visx stacked-area of base/essential/ discretionary with red flex-cut overlay, hover tooltip, click-to- scrub-year. - New <EventGantt> — interactive Visx Gantt: * Click empty space → popover create at that year (default essential spending event) * Click a bar → inline edit popover (name, kind, range, £/y, category) with delete button * Drag bar middle → moves the whole event (year-resolution snap) * Drag bar edges → resizes year_start / year_end * All gestures persist via PATCH /life-events/{id} - New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on- change to scenario.config_json.flex_rules. - Plan-tab redesign: NW fan dominant top with floating stat badges (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending- profile chart middle; Gantt bottom; flex-rules editor; legacy form sections in a collapsed <details> drawer. - Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
from fire_planner.life_events import (
EventInput,
events_to_cashflow_array,
events_to_category_outflows,
)
from fire_planner.returns.bootstrap import block_bootstrap
from fire_planner.returns.shiller import load_from_csv, synthetic_returns
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history Adds a "Returns model" picker on /what-if that switches how the simulator's `paths` (n_paths × n_years × 3) is built: 1. shiller (default) — current behaviour, block-bootstrap of the Shiller 1871+ historical series (or its synthetic-calibrated fallback when the CSV isn't mounted). 2. manual — every year of every path = the user's "real return %" input. Deterministic, no fan, useful for sanity checks. New helper `constant_real_return_paths` constructs the (n_paths, n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's `(1+nominal)/(1+cpi)-1` short-circuits to exactly the input. 3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync PG mirror, sums total_value + net_contribution across accounts per day (FX-adjusted), strips contribution deltas to isolate market return, compounds daily returns into per-calendar-year samples, block-bootstraps with block_size=1 (only ~6 distinct samples available, no serial-correlation signal to preserve). Glide path is a no-op in this mode — the user's actual blended portfolio is treated as a single asset. API: SimulateRequest gains `returns_mode` ("shiller"|"manual"| "wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths` dispatches; wealthfolio mode opens a transient session against the mirror DB. UI: new Field on the form (next to Strategy / Glide path) with a contextual hint that explains each option's tradeoff. The "About the model" panel at the bottom now has a "Returns model" section mirroring the same content. The Manual % input only shows when returns_mode='manual'. 10 new tests on the Wealthfolio helper (contribution-stripping, multi-account aggregation, FX, partial-year drop, TOTAL filter, empty-input, plus 3 deterministic-paths tests). 198 backend tests + 7 frontend tests. mypy strict + ruff + tsc strict all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
from fire_planner.returns.wealthfolio_returns import (
compute_annual_returns_from_pg,
constant_real_return_paths,
)
from fire_planner.scenarios import build_regime_schedule, build_strategy
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
from fire_planner.simulator import SimulationResult, build_fixed_paths, simulate
router = APIRouter(tags=["simulate"])
log = logging.getLogger(__name__)
_RETURNS_CSV = Path("/data/shiller_returns.csv")
# Maps `SimulateRequest.jurisdiction` (lowercase slug used throughout the
# planner — e.g. "thailand") to the country name as stored in
# `fire_example.country` (e.g. "Thailand"). The keys mirror
# `JURISDICTION_REPRESENTATIVE_CITY` so the overlay covers every
# jurisdiction with a fixed country. `nomad` has no fixed country and is
# intentionally absent.
_JURISDICTION_COUNTRY: dict[str, str] = {
"uk": "United Kingdom",
"cyprus": "Cyprus",
"bulgaria": "Bulgaria",
"uae": "United Arab Emirates",
"malaysia": "Malaysia",
"thailand": "Thailand",
}
def _resolve_target_country_for_examples(req: SimulateRequest) -> str | None:
return _JURISDICTION_COUNTRY.get(req.jurisdiction.lower())
async def _build_examples_overlay(
session: AsyncSession,
req: SimulateRequest,
) -> ExamplesOverlay | None:
"""Look up real-world Reddit examples for the scenario's target
country. Returns None when the jurisdiction has no fixed country
(e.g. nomad), when no examples are stored, or when the lookup
fails for any reason examples are informational and must never
sink a successful simulation."""
try:
country = _resolve_target_country_for_examples(req)
if country is None:
return None
summary = await summary_for_country(session, country)
if summary.count == 0:
return None
return ExamplesOverlay(
country=summary.country,
count=summary.count,
portfolio_gbp_median=summary.portfolio_gbp.median,
portfolio_gbp_p25=summary.portfolio_gbp.p25,
portfolio_gbp_p75=summary.portfolio_gbp.p75,
annual_exp_gbp_median=summary.annual_exp_gbp.median,
sample_links=summary.sample_links,
)
except Exception:
log.warning("examples_overlay lookup failed", exc_info=True)
return None
col: simulator auto-adjusts spending to local prices via Numbeo+Expatistan The Monte Carlo used to compare jurisdictions at a flat London-equivalent spend, which silently overstated the cost-of-living for any move to a cheaper region. Now every cross-jurisdiction simulation auto-scales spending_gbp by the real Numbeo/Expatistan ratio between the user's baseline city and the target city. Architecture: - fire_planner/col/baseline.py — 22 cities with headline Numbeo data (source URLs + snapshot dates embedded) — fallback when scraper fails - col/numbeo.py + col/expatistan.py — httpx async scrapers, regex-parsed, polite 1.1s rate-limit, EUR/USD anchored - col/cache.py — PG-backed cache (col_snapshot table, 1-year TTL) - col/service.py — sync compute_col_ratio() for the simulator; async lookup_city_cached() with source reconciliation for the refresh CronJob - alembic 0005 — col_snapshot table, UNIQUE(city_slug, source_name) Simulator wiring: - SimulateRequest gains col_auto_adjust=True (default), col_baseline_city, col_target_city. Defaults pick the jurisdiction's representative city. - _resolve_col_adjustment scales spending_gbp before path-building. - SimulateResult surfaces col_multiplier_applied + col_adjusted_spending_gbp. CLIs: - python -m fire_planner col-seed — loads BASELINES into col_snapshot (post-migration seed step) - python -m fire_planner col-refresh-stale --within-days 7 — used by the weekly fire-planner-col-refresh CronJob 268 tests pass. Mypy strict + ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:14:57 +00:00
def _resolve_col_adjustment(
req: SimulateRequest,
) -> tuple[SimulateRequest, Decimal | None, Decimal | None, str | None]:
"""Apply cost-of-living adjustment to `req.spending_gbp` when enabled.
Returns the (possibly modified) request, the multiplier applied (or
None), the post-adjustment spending GBP (or None), and the resolved
target city slug (or None). Skipped silently when:
- col_auto_adjust is False
- the jurisdiction has no representative city (e.g. nomad)
- baseline_city == resolved target city (identity transform)
- either city is unknown to the baseline lookup (degrade gracefully
rather than 400 a future Phase-2 scraper will close the gap)
"""
if not req.col_auto_adjust:
return req, None, None, None
target = req.col_target_city or representative_city_for(req.jurisdiction)
if target is None:
return req, None, None, None
if target == req.col_baseline_city:
return req, None, None, target
try:
ratio = compute_col_ratio(req.col_baseline_city, target)
except KeyError:
return req, None, None, target
adjusted_spend = req.spending_gbp * ratio
adjusted_req = req.model_copy(update={"spending_gbp": adjusted_spend})
return adjusted_req, ratio, adjusted_spend, target
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history Adds a "Returns model" picker on /what-if that switches how the simulator's `paths` (n_paths × n_years × 3) is built: 1. shiller (default) — current behaviour, block-bootstrap of the Shiller 1871+ historical series (or its synthetic-calibrated fallback when the CSV isn't mounted). 2. manual — every year of every path = the user's "real return %" input. Deterministic, no fan, useful for sanity checks. New helper `constant_real_return_paths` constructs the (n_paths, n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's `(1+nominal)/(1+cpi)-1` short-circuits to exactly the input. 3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync PG mirror, sums total_value + net_contribution across accounts per day (FX-adjusted), strips contribution deltas to isolate market return, compounds daily returns into per-calendar-year samples, block-bootstraps with block_size=1 (only ~6 distinct samples available, no serial-correlation signal to preserve). Glide path is a no-op in this mode — the user's actual blended portfolio is treated as a single asset. API: SimulateRequest gains `returns_mode` ("shiller"|"manual"| "wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths` dispatches; wealthfolio mode opens a transient session against the mirror DB. UI: new Field on the form (next to Strategy / Glide path) with a contextual hint that explains each option's tradeoff. The "About the model" panel at the bottom now has a "Returns model" section mirroring the same content. The Manual % input only shows when returns_mode='manual'. 10 new tests on the Wealthfolio helper (contribution-stripping, multi-account aggregation, FX, partial-year drop, TOTAL filter, empty-input, plus 3 deterministic-paths tests). 198 backend tests + 7 frontend tests. mypy strict + ruff + tsc strict all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
def _shiller_paths(seed: int, n_paths: int, n_years: int) -> np.ndarray:
bundle = (load_from_csv(_RETURNS_CSV) if _RETURNS_CSV.exists() else synthetic_returns(seed=42))
rng = np.random.default_rng(seed)
return block_bootstrap(bundle, n_paths=n_paths, n_years=n_years, block_size=5, rng=rng)
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history Adds a "Returns model" picker on /what-if that switches how the simulator's `paths` (n_paths × n_years × 3) is built: 1. shiller (default) — current behaviour, block-bootstrap of the Shiller 1871+ historical series (or its synthetic-calibrated fallback when the CSV isn't mounted). 2. manual — every year of every path = the user's "real return %" input. Deterministic, no fan, useful for sanity checks. New helper `constant_real_return_paths` constructs the (n_paths, n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's `(1+nominal)/(1+cpi)-1` short-circuits to exactly the input. 3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync PG mirror, sums total_value + net_contribution across accounts per day (FX-adjusted), strips contribution deltas to isolate market return, compounds daily returns into per-calendar-year samples, block-bootstraps with block_size=1 (only ~6 distinct samples available, no serial-correlation signal to preserve). Glide path is a no-op in this mode — the user's actual blended portfolio is treated as a single asset. API: SimulateRequest gains `returns_mode` ("shiller"|"manual"| "wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths` dispatches; wealthfolio mode opens a transient session against the mirror DB. UI: new Field on the form (next to Strategy / Glide path) with a contextual hint that explains each option's tradeoff. The "About the model" panel at the bottom now has a "Returns model" section mirroring the same content. The Manual % input only shows when returns_mode='manual'. 10 new tests on the Wealthfolio helper (contribution-stripping, multi-account aggregation, FX, partial-year drop, TOTAL filter, empty-input, plus 3 deterministic-paths tests). 198 backend tests + 7 frontend tests. mypy strict + ruff + tsc strict all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
async def _wealthfolio_paths(seed: int, n_paths: int, n_years: int) -> np.ndarray:
"""Block-bootstrap the user's actual blended real returns. With
typically <10 distinct annual samples, block_size=1 is appropriate
there's no serial-correlation signal to preserve."""
eng = create_wf_sync_engine_from_env()
try:
factory = async_sessionmaker(eng, expire_on_commit=False)
async with factory() as wf_sess:
bundle = await compute_annual_returns_from_pg(wf_sess)
finally:
await eng.dispose()
rng = np.random.default_rng(seed)
return block_bootstrap(bundle, n_paths=n_paths, n_years=n_years, block_size=1, rng=rng)
async def _build_paths(req: SimulateRequest) -> np.ndarray:
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
if req.rates_mode == "fixed":
return build_fixed_paths(
n_paths=req.n_paths,
n_years=req.horizon_years,
inflation_pct=float(req.inflation_pct),
stocks_growth_pct=float(req.stocks_growth_pct),
stocks_dividend_pct=float(req.stocks_dividend_pct),
bonds_growth_pct=float(req.bonds_growth_pct),
bonds_dividend_pct=float(req.bonds_dividend_pct),
)
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history Adds a "Returns model" picker on /what-if that switches how the simulator's `paths` (n_paths × n_years × 3) is built: 1. shiller (default) — current behaviour, block-bootstrap of the Shiller 1871+ historical series (or its synthetic-calibrated fallback when the CSV isn't mounted). 2. manual — every year of every path = the user's "real return %" input. Deterministic, no fan, useful for sanity checks. New helper `constant_real_return_paths` constructs the (n_paths, n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's `(1+nominal)/(1+cpi)-1` short-circuits to exactly the input. 3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync PG mirror, sums total_value + net_contribution across accounts per day (FX-adjusted), strips contribution deltas to isolate market return, compounds daily returns into per-calendar-year samples, block-bootstraps with block_size=1 (only ~6 distinct samples available, no serial-correlation signal to preserve). Glide path is a no-op in this mode — the user's actual blended portfolio is treated as a single asset. API: SimulateRequest gains `returns_mode` ("shiller"|"manual"| "wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths` dispatches; wealthfolio mode opens a transient session against the mirror DB. UI: new Field on the form (next to Strategy / Glide path) with a contextual hint that explains each option's tradeoff. The "About the model" panel at the bottom now has a "Returns model" section mirroring the same content. The Manual % input only shows when returns_mode='manual'. 10 new tests on the Wealthfolio helper (contribution-stripping, multi-account aggregation, FX, partial-year drop, TOTAL filter, empty-input, plus 3 deterministic-paths tests). 198 backend tests + 7 frontend tests. mypy strict + ruff + tsc strict all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
if req.returns_mode == "manual":
if req.manual_real_return_pct is None:
raise HTTPException(
status_code=400,
detail="manual_real_return_pct is required when returns_mode='manual'",
)
return constant_real_return_paths(
n_paths=req.n_paths,
n_years=req.horizon_years,
real_return_pct=float(req.manual_real_return_pct),
)
if req.returns_mode == "wealthfolio":
try:
return await _wealthfolio_paths(req.seed, req.n_paths, req.horizon_years)
except ValueError as e:
raise HTTPException(
status_code=400,
detail=f"Wealthfolio history insufficient: {e}",
) from e
return _shiller_paths(req.seed, req.n_paths, req.horizon_years)
def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, float]:
annual_savings = (np.full(req.horizon_years, float(req.savings_per_year_gbp), dtype=np.float64)
if req.savings_per_year_gbp > 0 else None)
floor = float(req.floor_gbp) if req.floor_gbp is not None else None
cashflow_adjustments = None
fire-planner: Wave 2 chart-first — flex spending, categorised life events, interactive Visx Gantt + spending-profile chart Charts are now the primary editor for life events. The Plan-tab body re-orders to make charts ~80% of viewport real-estate; legacy form sections are collapsed into a drawer. Backend: - alembic 0004: life_event.category enum (essential / discretionary / not_spending). Defaults to essential so existing rows keep their full spending impact. - Simulator gains discretionary_outflows + flex_rules params. Tracks per-path running ATH, applies the deepest applicable cut to discretionary outflows when portfolio drops vs ATH (PLab-style flex spending). Cut amount stays in the portfolio (refund pattern). - New flex_spending module with FlexRule + applicable_cut + cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so users specify cumulative cuts at each tier. - New /scenarios/{id}/spending-profile endpoint returning per-year base / essential / discretionary / flex_cut / total breakdown. - SimulateRequest gains flex_rules + life_event.category roundtrip. - 8 new tests; 246 total pytest pass; mypy + ruff clean. Frontend (Visx + ECharts): - Installed @visx/{scale,shape,group,axis,event,responsive,tooltip} for native SVG drag interactions. - New <SpendingProfileChart> — Visx stacked-area of base/essential/ discretionary with red flex-cut overlay, hover tooltip, click-to- scrub-year. - New <EventGantt> — interactive Visx Gantt: * Click empty space → popover create at that year (default essential spending event) * Click a bar → inline edit popover (name, kind, range, £/y, category) with delete button * Drag bar middle → moves the whole event (year-resolution snap) * Drag bar edges → resizes year_start / year_end * All gestures persist via PATCH /life-events/{id} - New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on- change to scenario.config_json.flex_rules. - Plan-tab redesign: NW fan dominant top with floating stat badges (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending- profile chart middle; Gantt bottom; flex-rules editor; legacy form sections in a collapsed <details> drawer. - Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
discretionary_outflows = None
extra_outflows = None
if req.life_events:
engine_events = [
EventInput(
year_start=ev.year_start,
year_end=ev.year_end,
delta_gbp_per_year=float(ev.delta_gbp_per_year),
one_time_amount_gbp=(float(ev.one_time_amount_gbp)
if ev.one_time_amount_gbp is not None else None),
fire-planner: Wave 2 chart-first — flex spending, categorised life events, interactive Visx Gantt + spending-profile chart Charts are now the primary editor for life events. The Plan-tab body re-orders to make charts ~80% of viewport real-estate; legacy form sections are collapsed into a drawer. Backend: - alembic 0004: life_event.category enum (essential / discretionary / not_spending). Defaults to essential so existing rows keep their full spending impact. - Simulator gains discretionary_outflows + flex_rules params. Tracks per-path running ATH, applies the deepest applicable cut to discretionary outflows when portfolio drops vs ATH (PLab-style flex spending). Cut amount stays in the portfolio (refund pattern). - New flex_spending module with FlexRule + applicable_cut + cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so users specify cumulative cuts at each tier. - New /scenarios/{id}/spending-profile endpoint returning per-year base / essential / discretionary / flex_cut / total breakdown. - SimulateRequest gains flex_rules + life_event.category roundtrip. - 8 new tests; 246 total pytest pass; mypy + ruff clean. Frontend (Visx + ECharts): - Installed @visx/{scale,shape,group,axis,event,responsive,tooltip} for native SVG drag interactions. - New <SpendingProfileChart> — Visx stacked-area of base/essential/ discretionary with red flex-cut overlay, hover tooltip, click-to- scrub-year. - New <EventGantt> — interactive Visx Gantt: * Click empty space → popover create at that year (default essential spending event) * Click a bar → inline edit popover (name, kind, range, £/y, category) with delete button * Drag bar middle → moves the whole event (year-resolution snap) * Drag bar edges → resizes year_start / year_end * All gestures persist via PATCH /life-events/{id} - New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on- change to scenario.config_json.flex_rules. - Plan-tab redesign: NW fan dominant top with floating stat badges (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending- profile chart middle; Gantt bottom; flex-rules editor; legacy form sections in a collapsed <details> drawer. - Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
category=ev.category,
enabled=ev.enabled,
) for ev in req.life_events
]
cashflow_adjustments = events_to_cashflow_array(engine_events, req.horizon_years)
fire-planner: Wave 2 chart-first — flex spending, categorised life events, interactive Visx Gantt + spending-profile chart Charts are now the primary editor for life events. The Plan-tab body re-orders to make charts ~80% of viewport real-estate; legacy form sections are collapsed into a drawer. Backend: - alembic 0004: life_event.category enum (essential / discretionary / not_spending). Defaults to essential so existing rows keep their full spending impact. - Simulator gains discretionary_outflows + flex_rules params. Tracks per-path running ATH, applies the deepest applicable cut to discretionary outflows when portfolio drops vs ATH (PLab-style flex spending). Cut amount stays in the portfolio (refund pattern). - New flex_spending module with FlexRule + applicable_cut + cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so users specify cumulative cuts at each tier. - New /scenarios/{id}/spending-profile endpoint returning per-year base / essential / discretionary / flex_cut / total breakdown. - SimulateRequest gains flex_rules + life_event.category roundtrip. - 8 new tests; 246 total pytest pass; mypy + ruff clean. Frontend (Visx + ECharts): - Installed @visx/{scale,shape,group,axis,event,responsive,tooltip} for native SVG drag interactions. - New <SpendingProfileChart> — Visx stacked-area of base/essential/ discretionary with red flex-cut overlay, hover tooltip, click-to- scrub-year. - New <EventGantt> — interactive Visx Gantt: * Click empty space → popover create at that year (default essential spending event) * Click a bar → inline edit popover (name, kind, range, £/y, category) with delete button * Drag bar middle → moves the whole event (year-resolution snap) * Drag bar edges → resizes year_start / year_end * All gestures persist via PATCH /life-events/{id} - New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on- change to scenario.config_json.flex_rules. - Plan-tab redesign: NW fan dominant top with floating stat badges (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending- profile chart middle; Gantt bottom; flex-rules editor; legacy form sections in a collapsed <details> drawer. - Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
category_outflows = events_to_category_outflows(engine_events, req.horizon_years)
discretionary_outflows = category_outflows.get("discretionary")
# extra_outflows feeds the withdrawal-trace display: total of
# essential + discretionary spending events surfaces alongside
# the strategy's draw on the chart.
essential = category_outflows.get("essential")
if essential is not None and discretionary_outflows is not None:
extra_outflows = essential + discretionary_outflows
fire-planner: Wave 2 chart-first — flex spending, categorised life events, interactive Visx Gantt + spending-profile chart Charts are now the primary editor for life events. The Plan-tab body re-orders to make charts ~80% of viewport real-estate; legacy form sections are collapsed into a drawer. Backend: - alembic 0004: life_event.category enum (essential / discretionary / not_spending). Defaults to essential so existing rows keep their full spending impact. - Simulator gains discretionary_outflows + flex_rules params. Tracks per-path running ATH, applies the deepest applicable cut to discretionary outflows when portfolio drops vs ATH (PLab-style flex spending). Cut amount stays in the portfolio (refund pattern). - New flex_spending module with FlexRule + applicable_cut + cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so users specify cumulative cuts at each tier. - New /scenarios/{id}/spending-profile endpoint returning per-year base / essential / discretionary / flex_cut / total breakdown. - SimulateRequest gains flex_rules + life_event.category roundtrip. - 8 new tests; 246 total pytest pass; mypy + ruff clean. Frontend (Visx + ECharts): - Installed @visx/{scale,shape,group,axis,event,responsive,tooltip} for native SVG drag interactions. - New <SpendingProfileChart> — Visx stacked-area of base/essential/ discretionary with red flex-cut overlay, hover tooltip, click-to- scrub-year. - New <EventGantt> — interactive Visx Gantt: * Click empty space → popover create at that year (default essential spending event) * Click a bar → inline edit popover (name, kind, range, £/y, category) with delete button * Drag bar middle → moves the whole event (year-resolution snap) * Drag bar edges → resizes year_start / year_end * All gestures persist via PATCH /life-events/{id} - New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on- change to scenario.config_json.flex_rules. - Plan-tab redesign: NW fan dominant top with floating stat badges (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending- profile chart middle; Gantt bottom; flex-rules editor; legacy form sections in a collapsed <details> drawer. - Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
engine_flex = [
EngineFlexRule(
from_ath_pct=float(r.from_ath_pct),
cut_discretionary_pct=float(r.cut_discretionary_pct),
) for r in req.flex_rules
] if req.flex_rules else None
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
income_inflows = None
income_taxable = None
if req.income_streams:
engine_streams = [
IncomeStreamInput(
kind=s.kind,
start_year=s.start_year,
end_year=s.end_year,
amount_gbp_per_year=float(s.amount_gbp_per_year),
growth_pct=float(s.growth_pct),
tax_treatment=s.tax_treatment,
enabled=s.enabled,
) for s in req.income_streams
]
income_inflows, income_taxable = streams_to_arrays(engine_streams, req.horizon_years)
strategies: spending input is honoured + new "Custom" preset with guardrails The user noticed the "Annual spending" field was a no-op for Trinity, GK, VPW, VPW+floor — the strategies internally hardcoded the year-0 withdrawal as `initial_portfolio × initial_rate` (4% / 5.5%) and ignored what the user typed. Two fixes: (1) Trinity + GK now use state.initial_withdrawal (= the user's spending_target) as the year-0 draw. GK's guardrail anchor becomes the implied initial rate (initial_withdrawal / initial_portfolio), so the rule shape adapts to the user's chosen rate. Both strategies still fall back to their preset rate × initial_portfolio when initial_withdrawal isn't set (test paths). VPW and VPW+floor stay algorithmic — they're "withdraw-what's-sustainable" by design and don't take a spending input. (2) New "custom" preset (SpendingPlanStrategy) exposing all the knobs: - initial_spend = "Annual spending" input - annual_real_adjust_pct = scale last year's withdrawal by N% each year (0 = constant real £, +0.02 = 2%/yr healthcare creep, -0.005 = -0.5%/yr slow-down with age) - guardrail_threshold_pct = if portfolio falls below X% of starting NW, trigger a cut (None = disabled) - guardrail_cut_pct = cut last year's withdrawal by Y% each triggered year Adjust applies first, then guardrail cut — so a triggered year in +2% adjust mode goes 40k → 40.8k → 36.7k. UI: "custom" added to the strategy dropdown; when selected, three extra fields appear (annual real adjustment %, guardrail trigger threshold, guardrail cut size) with hints. The existing inputs (spending, NW seed) drive year 0 across all strategies that use them. About-the-model panel updated. 10 new tests on SpendingPlanStrategy + adjusted GK tests for the new spending_target-aware behaviour. 209 backend tests + 7 frontend tests. mypy + ruff + tsc all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:21:55 +00:00
strategy = build_strategy(
req.strategy,
floor=floor,
annual_real_adjust_pct=float(req.annual_real_adjust_pct),
guardrail_threshold_pct=(float(req.guardrail_threshold_pct)
if req.guardrail_threshold_pct is not None else None),
guardrail_cut_pct=float(req.guardrail_cut_pct),
)
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
glide_alloc = float(req.stocks_allocation) if req.rates_mode == "fixed" else 1.0
started = time.perf_counter()
result = simulate(
paths=paths,
initial_portfolio=float(req.nw_seed_gbp),
spending_target=float(req.spending_gbp),
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
glide=static(glide_alloc),
strategies: spending input is honoured + new "Custom" preset with guardrails The user noticed the "Annual spending" field was a no-op for Trinity, GK, VPW, VPW+floor — the strategies internally hardcoded the year-0 withdrawal as `initial_portfolio × initial_rate` (4% / 5.5%) and ignored what the user typed. Two fixes: (1) Trinity + GK now use state.initial_withdrawal (= the user's spending_target) as the year-0 draw. GK's guardrail anchor becomes the implied initial rate (initial_withdrawal / initial_portfolio), so the rule shape adapts to the user's chosen rate. Both strategies still fall back to their preset rate × initial_portfolio when initial_withdrawal isn't set (test paths). VPW and VPW+floor stay algorithmic — they're "withdraw-what's-sustainable" by design and don't take a spending input. (2) New "custom" preset (SpendingPlanStrategy) exposing all the knobs: - initial_spend = "Annual spending" input - annual_real_adjust_pct = scale last year's withdrawal by N% each year (0 = constant real £, +0.02 = 2%/yr healthcare creep, -0.005 = -0.5%/yr slow-down with age) - guardrail_threshold_pct = if portfolio falls below X% of starting NW, trigger a cut (None = disabled) - guardrail_cut_pct = cut last year's withdrawal by Y% each triggered year Adjust applies first, then guardrail cut — so a triggered year in +2% adjust mode goes 40k → 40.8k → 36.7k. UI: "custom" added to the strategy dropdown; when selected, three extra fields appear (annual real adjustment %, guardrail trigger threshold, guardrail cut size) with hints. The existing inputs (spending, NW seed) drive year 0 across all strategies that use them. About-the-model panel updated. 10 new tests on SpendingPlanStrategy + adjusted GK tests for the new spending_target-aware behaviour. 209 backend tests + 7 frontend tests. mypy + ruff + tsc all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:21:55 +00:00
strategy=strategy,
regime=build_regime_schedule(req.jurisdiction, req.leave_uk_year),
horizon_years=req.horizon_years,
annual_savings=annual_savings,
cashflow_adjustments=cashflow_adjustments,
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
income_inflows=income_inflows,
income_taxable=income_taxable,
fire-planner: Wave 2 chart-first — flex spending, categorised life events, interactive Visx Gantt + spending-profile chart Charts are now the primary editor for life events. The Plan-tab body re-orders to make charts ~80% of viewport real-estate; legacy form sections are collapsed into a drawer. Backend: - alembic 0004: life_event.category enum (essential / discretionary / not_spending). Defaults to essential so existing rows keep their full spending impact. - Simulator gains discretionary_outflows + flex_rules params. Tracks per-path running ATH, applies the deepest applicable cut to discretionary outflows when portfolio drops vs ATH (PLab-style flex spending). Cut amount stays in the portfolio (refund pattern). - New flex_spending module with FlexRule + applicable_cut + cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so users specify cumulative cuts at each tier. - New /scenarios/{id}/spending-profile endpoint returning per-year base / essential / discretionary / flex_cut / total breakdown. - SimulateRequest gains flex_rules + life_event.category roundtrip. - 8 new tests; 246 total pytest pass; mypy + ruff clean. Frontend (Visx + ECharts): - Installed @visx/{scale,shape,group,axis,event,responsive,tooltip} for native SVG drag interactions. - New <SpendingProfileChart> — Visx stacked-area of base/essential/ discretionary with red flex-cut overlay, hover tooltip, click-to- scrub-year. - New <EventGantt> — interactive Visx Gantt: * Click empty space → popover create at that year (default essential spending event) * Click a bar → inline edit popover (name, kind, range, £/y, category) with delete button * Drag bar middle → moves the whole event (year-resolution snap) * Drag bar edges → resizes year_start / year_end * All gestures persist via PATCH /life-events/{id} - New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on- change to scenario.config_json.flex_rules. - Plan-tab redesign: NW fan dominant top with floating stat badges (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending- profile chart middle; Gantt bottom; flex-rules editor; legacy form sections in a collapsed <details> drawer. - Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
discretionary_outflows=discretionary_outflows,
extra_outflows=extra_outflows,
fire-planner: Wave 2 chart-first — flex spending, categorised life events, interactive Visx Gantt + spending-profile chart Charts are now the primary editor for life events. The Plan-tab body re-orders to make charts ~80% of viewport real-estate; legacy form sections are collapsed into a drawer. Backend: - alembic 0004: life_event.category enum (essential / discretionary / not_spending). Defaults to essential so existing rows keep their full spending impact. - Simulator gains discretionary_outflows + flex_rules params. Tracks per-path running ATH, applies the deepest applicable cut to discretionary outflows when portfolio drops vs ATH (PLab-style flex spending). Cut amount stays in the portfolio (refund pattern). - New flex_spending module with FlexRule + applicable_cut + cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so users specify cumulative cuts at each tier. - New /scenarios/{id}/spending-profile endpoint returning per-year base / essential / discretionary / flex_cut / total breakdown. - SimulateRequest gains flex_rules + life_event.category roundtrip. - 8 new tests; 246 total pytest pass; mypy + ruff clean. Frontend (Visx + ECharts): - Installed @visx/{scale,shape,group,axis,event,responsive,tooltip} for native SVG drag interactions. - New <SpendingProfileChart> — Visx stacked-area of base/essential/ discretionary with red flex-cut overlay, hover tooltip, click-to- scrub-year. - New <EventGantt> — interactive Visx Gantt: * Click empty space → popover create at that year (default essential spending event) * Click a bar → inline edit popover (name, kind, range, £/y, category) with delete button * Drag bar middle → moves the whole event (year-resolution snap) * Drag bar edges → resizes year_start / year_end * All gestures persist via PATCH /life-events/{id} - New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on- change to scenario.config_json.flex_rules. - Plan-tab redesign: NW fan dominant top with floating stat badges (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending- profile chart middle; Gantt bottom; flex-rules editor; legacy form sections in a collapsed <details> drawer. - Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
flex_rules=engine_flex,
)
elapsed = time.perf_counter() - started
return result, elapsed
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
def _to_response(
result: SimulationResult,
elapsed: float,
req: SimulateRequest | None = None,
col: simulator auto-adjusts spending to local prices via Numbeo+Expatistan The Monte Carlo used to compare jurisdictions at a flat London-equivalent spend, which silently overstated the cost-of-living for any move to a cheaper region. Now every cross-jurisdiction simulation auto-scales spending_gbp by the real Numbeo/Expatistan ratio between the user's baseline city and the target city. Architecture: - fire_planner/col/baseline.py — 22 cities with headline Numbeo data (source URLs + snapshot dates embedded) — fallback when scraper fails - col/numbeo.py + col/expatistan.py — httpx async scrapers, regex-parsed, polite 1.1s rate-limit, EUR/USD anchored - col/cache.py — PG-backed cache (col_snapshot table, 1-year TTL) - col/service.py — sync compute_col_ratio() for the simulator; async lookup_city_cached() with source reconciliation for the refresh CronJob - alembic 0005 — col_snapshot table, UNIQUE(city_slug, source_name) Simulator wiring: - SimulateRequest gains col_auto_adjust=True (default), col_baseline_city, col_target_city. Defaults pick the jurisdiction's representative city. - _resolve_col_adjustment scales spending_gbp before path-building. - SimulateResult surfaces col_multiplier_applied + col_adjusted_spending_gbp. CLIs: - python -m fire_planner col-seed — loads BASELINES into col_snapshot (post-migration seed step) - python -m fire_planner col-refresh-stale --within-days 7 — used by the weekly fire-planner-col-refresh CronJob 268 tests pass. Mypy strict + ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:14:57 +00:00
col_multiplier: Decimal | None = None,
col_adjusted_spend: Decimal | None = None,
col_target_city: str | None = None,
examples_overlay: ExamplesOverlay | None = None,
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
) -> SimulateResult:
# portfolio_real has n_years+1 columns (year 0 = seed, year k = end-of-year k).
# withdrawal_real / tax_real have n_years columns (year k = withdrawn in year k+1).
# Yearly point k describes "end of year k+1": portfolio after withdrawal & growth.
pcts = [10, 25, 50, 75, 90]
portfolio_quantiles = {p: np.percentile(result.portfolio_real, p, axis=0) for p in pcts}
median_wd = np.percentile(result.withdrawal_real, 50, axis=0)
median_tax = np.percentile(result.tax_real, 50, axis=0)
n_years = result.n_years
survival_path = (result.success_mask.astype(np.float64).mean(axis=0) if
result.success_mask.ndim == 2 else np.ones(n_years))
yearly = [
ProjectionPoint(
year_idx=y,
p10_portfolio_gbp=Decimal(str(round(float(portfolio_quantiles[10][y + 1]), 2))),
p25_portfolio_gbp=Decimal(str(round(float(portfolio_quantiles[25][y + 1]), 2))),
p50_portfolio_gbp=Decimal(str(round(float(portfolio_quantiles[50][y + 1]), 2))),
p75_portfolio_gbp=Decimal(str(round(float(portfolio_quantiles[75][y + 1]), 2))),
p90_portfolio_gbp=Decimal(str(round(float(portfolio_quantiles[90][y + 1]), 2))),
p50_withdrawal_gbp=Decimal(str(round(float(median_wd[y]), 2))),
p50_tax_gbp=Decimal(str(round(float(median_tax[y]), 2))),
survival_rate=Decimal(str(round(float(survival_path[y]), 4))),
) for y in range(n_years)
]
median_ytr = result.median_years_to_ruin()
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
goals_probability: list[GoalProbability] = []
if req is not None and req.goals:
evaluations = evaluate_goals(result, req.goals, req.horizon_years)
goals_probability = [
GoalProbability(
goal_id=None,
name=ev.name,
kind=ev.kind,
probability=Decimal(str(round(ev.probability, 4))),
threshold=Decimal(str(round(ev.threshold, 4))),
passed=ev.passed,
) for ev in evaluations
]
return SimulateResult(
success_rate=Decimal(str(round(float(result.success_rate), 4))),
p10_ending_gbp=Decimal(str(round(float(result.ending_percentile(10)), 2))),
p50_ending_gbp=Decimal(str(round(float(result.ending_percentile(50)), 2))),
p90_ending_gbp=Decimal(str(round(float(result.ending_percentile(90)), 2))),
median_lifetime_tax_gbp=Decimal(str(round(float(result.median_lifetime_tax()), 2))),
median_years_to_ruin=(Decimal(str(round(float(median_ytr), 2)))
if median_ytr is not None else None),
elapsed_seconds=Decimal(str(round(elapsed, 3))),
yearly=yearly,
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
goals_probability=goals_probability,
col: simulator auto-adjusts spending to local prices via Numbeo+Expatistan The Monte Carlo used to compare jurisdictions at a flat London-equivalent spend, which silently overstated the cost-of-living for any move to a cheaper region. Now every cross-jurisdiction simulation auto-scales spending_gbp by the real Numbeo/Expatistan ratio between the user's baseline city and the target city. Architecture: - fire_planner/col/baseline.py — 22 cities with headline Numbeo data (source URLs + snapshot dates embedded) — fallback when scraper fails - col/numbeo.py + col/expatistan.py — httpx async scrapers, regex-parsed, polite 1.1s rate-limit, EUR/USD anchored - col/cache.py — PG-backed cache (col_snapshot table, 1-year TTL) - col/service.py — sync compute_col_ratio() for the simulator; async lookup_city_cached() with source reconciliation for the refresh CronJob - alembic 0005 — col_snapshot table, UNIQUE(city_slug, source_name) Simulator wiring: - SimulateRequest gains col_auto_adjust=True (default), col_baseline_city, col_target_city. Defaults pick the jurisdiction's representative city. - _resolve_col_adjustment scales spending_gbp before path-building. - SimulateResult surfaces col_multiplier_applied + col_adjusted_spending_gbp. CLIs: - python -m fire_planner col-seed — loads BASELINES into col_snapshot (post-migration seed step) - python -m fire_planner col-refresh-stale --within-days 7 — used by the weekly fire-planner-col-refresh CronJob 268 tests pass. Mypy strict + ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:14:57 +00:00
col_multiplier_applied=(Decimal(str(round(float(col_multiplier), 6)))
if col_multiplier is not None else None),
col_adjusted_spending_gbp=(Decimal(str(round(float(col_adjusted_spend), 2)))
if col_adjusted_spend is not None else None),
col_target_city=col_target_city,
examples_overlay=examples_overlay,
)
@router.post("/simulate", response_model=SimulateResult)
async def simulate_one(
req: SimulateRequest,
session: AsyncSession = Depends(get_session),
) -> SimulateResult:
"""Run one scenario synchronously, no DB write. ~1-3s for 5k paths."""
col: simulator auto-adjusts spending to local prices via Numbeo+Expatistan The Monte Carlo used to compare jurisdictions at a flat London-equivalent spend, which silently overstated the cost-of-living for any move to a cheaper region. Now every cross-jurisdiction simulation auto-scales spending_gbp by the real Numbeo/Expatistan ratio between the user's baseline city and the target city. Architecture: - fire_planner/col/baseline.py — 22 cities with headline Numbeo data (source URLs + snapshot dates embedded) — fallback when scraper fails - col/numbeo.py + col/expatistan.py — httpx async scrapers, regex-parsed, polite 1.1s rate-limit, EUR/USD anchored - col/cache.py — PG-backed cache (col_snapshot table, 1-year TTL) - col/service.py — sync compute_col_ratio() for the simulator; async lookup_city_cached() with source reconciliation for the refresh CronJob - alembic 0005 — col_snapshot table, UNIQUE(city_slug, source_name) Simulator wiring: - SimulateRequest gains col_auto_adjust=True (default), col_baseline_city, col_target_city. Defaults pick the jurisdiction's representative city. - _resolve_col_adjustment scales spending_gbp before path-building. - SimulateResult surfaces col_multiplier_applied + col_adjusted_spending_gbp. CLIs: - python -m fire_planner col-seed — loads BASELINES into col_snapshot (post-migration seed step) - python -m fire_planner col-refresh-stale --within-days 7 — used by the weekly fire-planner-col-refresh CronJob 268 tests pass. Mypy strict + ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:14:57 +00:00
adjusted_req, mult, adj_spend, target_city = _resolve_col_adjustment(req)
paths = await _build_paths(adjusted_req)
try:
col: simulator auto-adjusts spending to local prices via Numbeo+Expatistan The Monte Carlo used to compare jurisdictions at a flat London-equivalent spend, which silently overstated the cost-of-living for any move to a cheaper region. Now every cross-jurisdiction simulation auto-scales spending_gbp by the real Numbeo/Expatistan ratio between the user's baseline city and the target city. Architecture: - fire_planner/col/baseline.py — 22 cities with headline Numbeo data (source URLs + snapshot dates embedded) — fallback when scraper fails - col/numbeo.py + col/expatistan.py — httpx async scrapers, regex-parsed, polite 1.1s rate-limit, EUR/USD anchored - col/cache.py — PG-backed cache (col_snapshot table, 1-year TTL) - col/service.py — sync compute_col_ratio() for the simulator; async lookup_city_cached() with source reconciliation for the refresh CronJob - alembic 0005 — col_snapshot table, UNIQUE(city_slug, source_name) Simulator wiring: - SimulateRequest gains col_auto_adjust=True (default), col_baseline_city, col_target_city. Defaults pick the jurisdiction's representative city. - _resolve_col_adjustment scales spending_gbp before path-building. - SimulateResult surfaces col_multiplier_applied + col_adjusted_spending_gbp. CLIs: - python -m fire_planner col-seed — loads BASELINES into col_snapshot (post-migration seed step) - python -m fire_planner col-refresh-stale --within-days 7 — used by the weekly fire-planner-col-refresh CronJob 268 tests pass. Mypy strict + ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:14:57 +00:00
result, elapsed = await asyncio.to_thread(_project, adjusted_req, paths)
except KeyError as e:
raise HTTPException(status_code=400, detail=f"Unknown name: {e}") from None
overlay = await _build_examples_overlay(session, adjusted_req)
return _to_response(
result, elapsed, adjusted_req, mult, adj_spend, target_city, overlay)
@router.post("/compare", response_model=CompareResult)
async def compare_scenarios(
req: CompareRequest,
session: AsyncSession = Depends(get_session),
) -> CompareResult:
"""Run 2-5 scenarios in parallel, return all results."""
async def one(s: SimulateRequest) -> tuple[SimulationResult, float, SimulateRequest,
Decimal | None, Decimal | None, str | None]:
col: simulator auto-adjusts spending to local prices via Numbeo+Expatistan The Monte Carlo used to compare jurisdictions at a flat London-equivalent spend, which silently overstated the cost-of-living for any move to a cheaper region. Now every cross-jurisdiction simulation auto-scales spending_gbp by the real Numbeo/Expatistan ratio between the user's baseline city and the target city. Architecture: - fire_planner/col/baseline.py — 22 cities with headline Numbeo data (source URLs + snapshot dates embedded) — fallback when scraper fails - col/numbeo.py + col/expatistan.py — httpx async scrapers, regex-parsed, polite 1.1s rate-limit, EUR/USD anchored - col/cache.py — PG-backed cache (col_snapshot table, 1-year TTL) - col/service.py — sync compute_col_ratio() for the simulator; async lookup_city_cached() with source reconciliation for the refresh CronJob - alembic 0005 — col_snapshot table, UNIQUE(city_slug, source_name) Simulator wiring: - SimulateRequest gains col_auto_adjust=True (default), col_baseline_city, col_target_city. Defaults pick the jurisdiction's representative city. - _resolve_col_adjustment scales spending_gbp before path-building. - SimulateResult surfaces col_multiplier_applied + col_adjusted_spending_gbp. CLIs: - python -m fire_planner col-seed — loads BASELINES into col_snapshot (post-migration seed step) - python -m fire_planner col-refresh-stale --within-days 7 — used by the weekly fire-planner-col-refresh CronJob 268 tests pass. Mypy strict + ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:14:57 +00:00
adjusted_s, mult, adj_spend, target_city = _resolve_col_adjustment(s)
paths = await _build_paths(adjusted_s)
result, elapsed = await asyncio.to_thread(_project, adjusted_s, paths)
return result, elapsed, adjusted_s, mult, adj_spend, target_city
try:
projected = await asyncio.gather(*(one(s) for s in req.scenarios))
except KeyError as e:
raise HTTPException(status_code=400, detail=f"Unknown name: {e}") from None
# Overlay lookups must run sequentially — AsyncSession is not safe for
# concurrent use. The lookup is fast (single SELECT) and informational
# only, so per-scenario serial cost is negligible.
results = []
for result, elapsed, adjusted_s, mult, adj_spend, target_city in projected:
overlay = await _build_examples_overlay(session, adjusted_s)
results.append(_to_response(
result, elapsed, adjusted_s, mult, adj_spend, target_city, overlay))
return CompareResult(results=results)