fire-planner/fire_planner/api/schemas.py

550 lines
20 KiB
Python
Raw Normal View History

"""Pydantic response/request schemas for the HTTP API.
Mirror the SQLAlchemy ORM but keep them de-coupled the API surface is a
contract for the frontend; we don't want migrations to silently change
JSON shape.
"""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class _Base(BaseModel):
model_config = ConfigDict(from_attributes=True)
# ── scenarios ────────────────────────────────────────────────────────
class ScenarioOut(_Base):
id: int
external_id: str
kind: str
name: str | None
description: str | None
parent_scenario_id: int | None
jurisdiction: str
strategy: str
leave_uk_year: int
glide_path: str
spending_gbp: Decimal
horizon_years: int
nw_seed_gbp: Decimal
savings_per_year_gbp: Decimal
config_json: dict[str, Any]
created_at: datetime
class ScenarioCreate(BaseModel):
"""Body for POST /scenarios — user-defined scenario."""
name: str = Field(min_length=1, max_length=200)
description: str | None = None
parent_scenario_id: int | None = None
jurisdiction: str
strategy: str
leave_uk_year: int = Field(ge=0, le=60)
glide_path: str
spending_gbp: Decimal = Field(gt=0)
horizon_years: int = Field(ge=5, le=100, default=60)
nw_seed_gbp: Decimal = Field(ge=0)
savings_per_year_gbp: Decimal = Field(ge=0, default=Decimal("0"))
config_json: dict[str, Any] = Field(default_factory=dict)
class ScenarioPatch(BaseModel):
"""Body for PATCH /scenarios/{id} — all fields optional."""
name: str | None = None
description: str | None = None
jurisdiction: str | None = None
strategy: str | None = None
leave_uk_year: int | None = None
glide_path: str | None = None
spending_gbp: Decimal | None = None
horizon_years: int | None = None
nw_seed_gbp: Decimal | None = None
savings_per_year_gbp: Decimal | None = None
config_json: dict[str, Any] | None = None
# ── projections ──────────────────────────────────────────────────────
class ProjectionPoint(_Base):
year_idx: int
p10_portfolio_gbp: Decimal
p25_portfolio_gbp: Decimal
p50_portfolio_gbp: Decimal
p75_portfolio_gbp: Decimal
p90_portfolio_gbp: Decimal
p50_withdrawal_gbp: Decimal
p50_tax_gbp: Decimal
survival_rate: Decimal
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
class GoalProbability(BaseModel):
goal_id: int | None
name: str
kind: str
probability: Decimal
threshold: Decimal
passed: bool
class ScenarioProjection(BaseModel):
"""Latest MC run + per-year fan-chart series for a scenario."""
scenario_id: int
external_id: str
mc_run_id: int
run_at: datetime
n_paths: int
success_rate: Decimal
p10_ending_gbp: Decimal
p50_ending_gbp: Decimal
p90_ending_gbp: Decimal
median_lifetime_tax_gbp: Decimal
median_years_to_ruin: Decimal | None
yearly: list[ProjectionPoint]
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] = Field(default_factory=list)
# ── net worth ────────────────────────────────────────────────────────
class AccountSnapshotOut(_Base):
account_id: str
account_name: str
account_type: str
currency: str
snapshot_date: date
market_value: Decimal
market_value_gbp: Decimal
cost_basis_gbp: Decimal | None
class NetWorthCurrent(BaseModel):
"""Snapshot at one point in time (latest by default)."""
snapshot_date: date
total_gbp: Decimal
accounts: list[AccountSnapshotOut]
class NetWorthHistoryPoint(BaseModel):
snapshot_date: date
total_gbp: Decimal
by_account: dict[str, Decimal]
class NetWorthHistory(BaseModel):
"""Per-day NW totals + per-account breakdown for a stacked area chart."""
points: list[NetWorthHistoryPoint]
# ── annual spending (from actualbudget) ──────────────────────────────
class SpendingMonth(BaseModel):
"""One month's outflows (positive £) by category group, after
income groups have been dropped upstream."""
month: str # "YYYY-MM"
by_group: dict[str, Decimal]
total_gbp: Decimal
class AnnualSpending(BaseModel):
"""Aggregated trailing-N-month spending pulled from actualbudget.
`total_gbp` is the headline figure used as the "Annual spending"
default in the WhatIf form. It is **inflation-adjusted to today's
£** (each month's nominal pence revalued forward by
`inflation_pct` compounded monthly), matching the simulator's
real-£ convention.
`nominal_total_gbp` is the same window without inflation
adjustment for transparency / comparison.
`raw_total_gbp` is the nominal sum *including* groups that were
excluded (e.g. investment transfers) useful when you want to
see your full cash outflow.
"""
months: int
window_start: str # "YYYY-MM" (oldest month included)
window_end: str # "YYYY-MM" (newest)
excluded_groups: list[str]
inflation_pct: Decimal # annual rate applied
total_gbp: Decimal # inflation-adjusted, after exclusions
nominal_total_gbp: Decimal # not adjusted, after exclusions
raw_total_gbp: Decimal # nominal, before exclusions
by_group_total_gbp: dict[str, Decimal] # nominal 12-mo group sums (incl. excluded)
monthly: list[SpendingMonth]
# ── life events ──────────────────────────────────────────────────────
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_PATTERN = "^(essential|discretionary|not_spending)$"
class LifeEventOut(_Base):
id: int
scenario_id: int
kind: str
name: str
year_start: int
year_end: int | None
delta_gbp_per_year: Decimal
one_time_amount_gbp: Decimal | 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: str = "essential"
enabled: bool
payload: dict[str, Any] | None
created_at: datetime
class LifeEventCreate(BaseModel):
kind: str
name: str = Field(min_length=1, max_length=200)
year_start: int = Field(ge=0, le=100)
year_end: int | None = Field(default=None, ge=0, le=100)
delta_gbp_per_year: Decimal = Decimal("0")
one_time_amount_gbp: Decimal | None = 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: str = Field(default="essential", pattern=_CATEGORY_PATTERN)
enabled: bool = True
payload: dict[str, Any] | None = None
class LifeEventPatch(BaseModel):
kind: str | None = None
name: str | None = None
year_start: int | None = None
year_end: int | None = None
delta_gbp_per_year: Decimal | None = None
one_time_amount_gbp: Decimal | None = 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: str | None = Field(default=None, pattern=_CATEGORY_PATTERN)
enabled: bool | None = None
payload: dict[str, Any] | None = None
# ── goals ────────────────────────────────────────────────────────────
class GoalOut(_Base):
id: int
scenario_id: int
kind: str
name: str
target_amount_gbp: Decimal | None
target_year: int | None
comparator: str
success_threshold: Decimal
enabled: bool
payload: dict[str, Any] | None
created_at: datetime
class GoalCreate(BaseModel):
kind: str
name: str = Field(min_length=1, max_length=200)
target_amount_gbp: Decimal | None = None
target_year: int | None = Field(default=None, ge=0, le=100)
comparator: str = ">="
success_threshold: Decimal = Field(default=Decimal("0.95"), ge=0, le=1)
enabled: bool = True
payload: dict[str, Any] | 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
# ── income streams ───────────────────────────────────────────────────
class IncomeStreamOut(_Base):
id: int
scenario_id: int
kind: str
name: str
start_year: int
end_year: int | None
amount_gbp_per_year: Decimal
growth_pct: Decimal
tax_treatment: str
enabled: bool
payload: dict[str, Any] | None
created_at: datetime
class IncomeStreamCreate(BaseModel):
kind: str
name: str = Field(min_length=1, max_length=200)
start_year: int = Field(ge=0, le=100, default=0)
end_year: int | None = Field(default=None, ge=0, le=100)
amount_gbp_per_year: Decimal = Field(ge=0, default=Decimal("0"))
growth_pct: Decimal = Field(default=Decimal("0"), ge=-1, le=1)
tax_treatment: str = Field(default="income", pattern="^(income|dividend|cgt|tax_free)$")
enabled: bool = True
payload: dict[str, Any] | None = None
class IncomeStreamPatch(BaseModel):
kind: str | None = None
name: str | None = None
start_year: int | None = None
end_year: int | None = None
amount_gbp_per_year: Decimal | None = None
growth_pct: Decimal | None = None
tax_treatment: str | None = None
enabled: bool | None = None
payload: dict[str, Any] | None = None
class IncomeStreamInput(BaseModel):
"""Engine-level income stream — same shape minus the row metadata."""
kind: str = "salary"
start_year: int = Field(ge=0, le=100, default=0)
end_year: int | None = Field(default=None, ge=0, le=100)
amount_gbp_per_year: Decimal = Field(ge=0, default=Decimal("0"))
growth_pct: Decimal = Field(default=Decimal("0"))
tax_treatment: str = Field(default="income", pattern="^(income|dividend|cgt|tax_free)$")
enabled: bool = True
# ── year stats (per-year scrubber sidebar) ───────────────────────────
class YearStats(BaseModel):
"""Per-year metrics for the right-hand stats sidebar.
Most fields derive from the latest persisted MC run + scenario
config. Liquid NW / Expenses / Savings rate / Portfolio allocations
are stubbed null until the relevant Wave 2 features land
(real-estate split, expense streams, allocation editor).
"""
year_idx: int
calendar_year: int
age: int | None
net_worth_p50: Decimal
change_in_nw: Decimal
taxable_income: Decimal
taxes: Decimal
effective_tax_rate: Decimal
spending: Decimal
contributions: Decimal
investment_growth: Decimal
liquid_nw: Decimal | None = None
expenses: Decimal | None = None
savings_rate: Decimal | None = None
portfolio_allocations: dict[str, Decimal] | None = None
# ── progress overlay (actuals vs projection) ─────────────────────────
class ProgressActualPoint(BaseModel):
snapshot_date: date
total_gbp: Decimal
class ProgressProjectedPoint(BaseModel):
year_idx: int
p10_portfolio_gbp: Decimal
p50_portfolio_gbp: Decimal
p90_portfolio_gbp: Decimal
class ProgressVariancePoint(BaseModel):
year_idx: int
actual_avg_gbp: Decimal
projected_p50_gbp: Decimal
delta_gbp: Decimal
class ProgressResponse(BaseModel):
"""`actual` is daily NW totals from `account_snapshot`. `projected`
is the latest persisted fan in `projection_yearly`. `alignment_anchor`
is the date corresponding to year_idx=0 earliest snapshot or scenario
`created_at` when no snapshots exist yet."""
scenario_id: int
alignment_anchor: date
actual: list[ProgressActualPoint]
projected: list[ProgressProjectedPoint]
variance: list[ProgressVariancePoint]
# ── cashflow Sankey ──────────────────────────────────────────────────
class CashflowResponse(BaseModel):
"""Per-year sources/sinks for a Sankey diagram. Sums conserve at the
Sankey level: total inflow == total outflow, with savings + ending-NW
delta absorbing any leftover."""
scenario_id: int
year: int
sources: dict[str, Decimal]
sinks: dict[str, Decimal]
# ── simulate / compare ───────────────────────────────────────────────
class LifeEventInput(BaseModel):
"""Engine-level event shape — same as the DB row's relevant fields."""
year_start: int = Field(ge=0, le=100)
year_end: int | None = Field(default=None, ge=0, le=100)
delta_gbp_per_year: Decimal = Decimal("0")
one_time_amount_gbp: Decimal | None = 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: str = Field(default="essential", pattern=_CATEGORY_PATTERN)
enabled: bool = True
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
class FlexRule(BaseModel):
"""ProjectionLab-style flex-spending rule.
When the portfolio falls ``from_ath_pct`` below its running all-time-high,
cut discretionary spending by ``cut_discretionary_pct``. Multiple rules
stack via "worst applicable threshold wins" at -30% from ATH a rule
keyed at -10% AND a rule keyed at -25% both apply, but only the deeper
cut takes effect (so users specify *cumulative* cuts, not per-tier).
`from_ath_pct` is the absolute drop magnitude as a positive fraction:
0.30 means "the portfolio is 30% below its ATH". This matches the way
PLab labels its sliders ("if down 30%, cut 60%").
"""
from_ath_pct: Decimal = Field(ge=0, le=1)
cut_discretionary_pct: Decimal = Field(ge=0, le=1)
# ── spending profile ────────────────────────────────────────────────
class SpendingProfilePoint(BaseModel):
year_idx: int
base_gbp: Decimal
essential_gbp: Decimal
discretionary_gbp: Decimal
not_spending_gbp: Decimal
flex_cut_gbp: Decimal
total_gbp: Decimal
class SpendingProfileResponse(BaseModel):
fire-planner: What-If gains the chart-first scenario editor The Plan-tab editors (interactive Gantt for life events, flex spending rules) are now available in What-If too — with local state instead of DB persistence so users can tweak before committing to a scenario. Architecture refactor: - EventGantt is now a controlled component. The `scenarioId` prop + internal useMutation/useQueryClient hooks went away; the component takes a `persister: { create, patch, delete }` prop and delegates every mutation through it. Plan tab wires it to lifeEventsApi + cache invalidation; What-If wires it to React local state with negative ids for new events. - FlexRulesEditor is similarly controlled. Takes `rules + onChange` instead of a `scenario` object. Plan tab wraps it with the PATCH /scenarios/:id mutation; What-If wraps it with setFlexRules. Backend: - New stateless POST /scenarios/spending-profile-preview endpoint takes base_spending_gbp + horizon + life_events + flex_rules in the body and returns the same SpendingProfileResponse shape as the read-only /scenarios/{id}/spending-profile endpoint. Used by What-If to render the stacked-area chart against unsaved events. - SpendingProfileResponse.scenario_id loosened to int | None to support the preview variant. Frontend: - What-If page gains `events` + `flexRules` local state, an EventGantt + FlexRulesEditor wired through them, and a Visx spending-profile chart fed by /spending-profile-preview. - Sim auto-refresh: a 600ms debounced effect re-fires /simulate whenever the form / events / flex rules change. Manual "Run simulation" button stays as an immediate trigger. - "Save as scenario" still works — preserves the scenario params but not yet the life events / flex rules (a Wave-3 follow-up could POST them after the scenario is created). 247 pytest pass; mypy + ruff + frontend typecheck/test/build all clean.
2026-05-12 19:35:28 +00:00
scenario_id: int | None = 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
horizon_years: int
points: list[SpendingProfilePoint]
fire-planner: What-If gains the chart-first scenario editor The Plan-tab editors (interactive Gantt for life events, flex spending rules) are now available in What-If too — with local state instead of DB persistence so users can tweak before committing to a scenario. Architecture refactor: - EventGantt is now a controlled component. The `scenarioId` prop + internal useMutation/useQueryClient hooks went away; the component takes a `persister: { create, patch, delete }` prop and delegates every mutation through it. Plan tab wires it to lifeEventsApi + cache invalidation; What-If wires it to React local state with negative ids for new events. - FlexRulesEditor is similarly controlled. Takes `rules + onChange` instead of a `scenario` object. Plan tab wraps it with the PATCH /scenarios/:id mutation; What-If wraps it with setFlexRules. Backend: - New stateless POST /scenarios/spending-profile-preview endpoint takes base_spending_gbp + horizon + life_events + flex_rules in the body and returns the same SpendingProfileResponse shape as the read-only /scenarios/{id}/spending-profile endpoint. Used by What-If to render the stacked-area chart against unsaved events. - SpendingProfileResponse.scenario_id loosened to int | None to support the preview variant. Frontend: - What-If page gains `events` + `flexRules` local state, an EventGantt + FlexRulesEditor wired through them, and a Visx spending-profile chart fed by /spending-profile-preview. - Sim auto-refresh: a 600ms debounced effect re-fires /simulate whenever the form / events / flex rules change. Manual "Run simulation" button stays as an immediate trigger. - "Save as scenario" still works — preserves the scenario params but not yet the life events / flex rules (a Wave-3 follow-up could POST them after the scenario is created). 247 pytest pass; mypy + ruff + frontend typecheck/test/build all clean.
2026-05-12 19:35:28 +00:00
class SpendingProfilePreviewRequest(BaseModel):
"""Stateless spending-profile preview — used by the What-If page where
the user is editing in-memory life events that aren't persisted yet.
No scenario_id needed; the caller supplies the baseline spending,
horizon, and any flex rules. Flex-cut estimation against a portfolio
fan is skipped (caller already has a /simulate response to pair
with this if they want flex visualisation).
"""
base_spending_gbp: Decimal = Field(ge=0)
horizon_years: int = Field(ge=1, le=100)
life_events: list[LifeEventInput] = Field(default_factory=list)
flex_rules: list[FlexRule] = Field(default_factory=list)
class SimulateRequest(BaseModel):
"""Sync, non-persisted simulate. Used by the React UI for what-if.
Allocation is hardcoded to 100% stocks at the engine layer
(`api/simulate.py::_project`). The UI removed the glide-path knob
in 2026-05; persisted Cartesian scenarios still carry their own
`glide_path` string on the `scenario` table.
"""
jurisdiction: str
strategy: str
leave_uk_year: int = Field(ge=0, le=60)
spending_gbp: Decimal = Field(gt=0)
nw_seed_gbp: Decimal = Field(ge=0)
savings_per_year_gbp: Decimal = Decimal("0")
horizon_years: int = Field(ge=5, le=100, default=60)
floor_gbp: Decimal | None = None
n_paths: int = Field(ge=100, le=50_000, default=5_000)
seed: int = 42
life_events: list[LifeEventInput] = Field(default_factory=list)
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
# Returns model — controls how `paths` (n_paths × n_years × 3) is built:
# "shiller" — block-bootstrap of Shiller 1871+ historical returns
# (or the synthetic Shiller-calibrated stream when the
# CSV isn't mounted). The default; broadest regime
# coverage including 1929/1973/2000/2008.
# "manual" — every year of every path = `manual_real_return_pct`.
# Deterministic, no fan, useful for sanity checks.
# "wealthfolio" — block-bootstrap of the user's actual blended real
# returns derived from wealthfolio_sync. Reflects the
# recent regime only (~6 years). Glide path is moot.
returns_mode: str = Field(default="shiller", pattern="^(shiller|manual|wealthfolio)$")
manual_real_return_pct: Decimal | 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
income_streams: list[IncomeStreamInput] = Field(default_factory=list)
goals: list[GoalCreate] = Field(default_factory=list)
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: list[FlexRule] = Field(default_factory=list)
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
# Rates settings (Wave 1.D.3). When `rates_mode='fixed'`, the engine
# synthesises a deterministic real-return path from the per-asset
# growth + dividend + inflation rates below, weighted by the static
# 100% glide. `historical` falls back to Shiller bootstrap (legacy
# `returns_mode` honoured when rates_mode is not set). `advanced` is
# a Wave 2 stub.
rates_mode: str | None = Field(default=None, pattern="^(fixed|historical|advanced)$")
inflation_pct: Decimal = Field(default=Decimal("0.03"))
stocks_growth_pct: Decimal = Field(default=Decimal("0.06"))
stocks_dividend_pct: Decimal = Field(default=Decimal("0.025"))
bonds_growth_pct: Decimal = Field(default=Decimal("0.015"))
bonds_dividend_pct: Decimal = Field(default=Decimal("0.035"))
stocks_allocation: Decimal = Field(default=Decimal("1.0"), ge=0, le=1)
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
# Custom spending-plan parameters — only consulted when strategy="custom".
# All real-£ / real-fraction. annual_real_adjust_pct = 0 means constant
# real spending (Trinity-shape). Non-zero scales last year's withdrawal
# multiplicatively each year (e.g. -0.005 for slow-down with age,
# +0.02 for healthcare creep). Guardrail cuts spending by
# `guardrail_cut_pct` whenever the portfolio falls below
# `guardrail_threshold_pct` of its starting value; null disables.
annual_real_adjust_pct: Decimal = Decimal("0")
guardrail_threshold_pct: Decimal | None = None
guardrail_cut_pct: Decimal = Decimal("0.10")
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
# Cost-of-living auto-adjust: when `col_auto_adjust=True`, the
# simulator looks up COL ratio (target/baseline) from `fire_planner.col`
# and scales `spending_gbp` BEFORE running paths. Defaults to True so
# cross-jurisdiction comparisons are honest by default — earlier
# comparisons used hand-wave 0.5x/0.75x multipliers, which were
# consistently optimistic vs. actual Numbeo data (Bulgaria is 0.41x,
# not 0.50x; Cyprus 0.67x, not 0.75x).
#
# `col_target_city` defaults to the jurisdiction's representative
# city (uk→london, cyprus→limassol, etc.). Set explicitly to anchor
# on a different city (e.g. `cyprus`+`paphos` if Limassol is too
# expensive a proxy). For `jurisdiction='nomad'` there is no
# representative city and auto-adjust is skipped silently.
col_auto_adjust: bool = True
col_baseline_city: str = "london"
col_target_city: str | None = None
class SimulateResult(BaseModel):
success_rate: Decimal
p10_ending_gbp: Decimal
p50_ending_gbp: Decimal
p90_ending_gbp: Decimal
median_lifetime_tax_gbp: Decimal
median_years_to_ruin: Decimal | None
elapsed_seconds: Decimal
yearly: list[ProjectionPoint]
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] = Field(default_factory=list)
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
# When `col_auto_adjust=True`, surface the applied multiplier + the
# COL-adjusted spending so the user can see what was used. Null when
# auto-adjust was off, jurisdiction had no representative city
# (nomad), or baseline==target (London-to-London).
col_multiplier_applied: Decimal | None = None
col_adjusted_spending_gbp: Decimal | None = None
col_target_city: str | None = None
class CompareRequest(BaseModel):
scenarios: list[SimulateRequest] = Field(min_length=2, max_length=5)
class CompareResult(BaseModel):
results: list[SimulateResult]