2026-05-09 21:48:36 +00:00
|
|
|
|
"""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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 21:48:36 +00:00
|
|
|
|
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)
|
2026-05-09 21:48:36 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 11:11:51 +00:00
|
|
|
|
# ── 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.
|
|
|
|
|
|
|
2026-05-10 11:27:22 +00:00
|
|
|
|
`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.
|
2026-05-10 11:11:51 +00:00
|
|
|
|
"""
|
|
|
|
|
|
months: int
|
|
|
|
|
|
window_start: str # "YYYY-MM" (oldest month included)
|
|
|
|
|
|
window_end: str # "YYYY-MM" (newest)
|
|
|
|
|
|
excluded_groups: list[str]
|
2026-05-10 11:27:22 +00:00
|
|
|
|
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)
|
2026-05-10 11:11:51 +00:00
|
|
|
|
monthly: list[SpendingMonth]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 21:48:36 +00:00
|
|
|
|
# ── 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)$"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 21:48:36 +00:00
|
|
|
|
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"
|
2026-05-09 21:48:36 +00:00
|
|
|
|
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)
|
2026-05-09 21:48:36 +00:00
|
|
|
|
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)
|
2026-05-09 21:48:36 +00:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 21:48:36 +00:00
|
|
|
|
# ── simulate / compare ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
engine+api: plumb life events into the simulator
Until now life events were stored but ignored by the engine — pure
metadata. Now they actually move portfolios.
Engine:
- simulator.simulate() takes optional cashflow_adjustments: a (n_years,)
real-GBP array applied each year *after* savings + return but
*before* withdrawal. Positive = inflow, negative = outflow.
- New fire_planner/life_events.py with EventInput dataclass +
events_to_cashflow_array(events, horizon). Handles ranged deltas,
one-time amounts, disabled events, year clipping past horizon,
negative year_start (clipped to 0), and summing multiple events.
API:
- /simulate accepts optional life_events list. Server converts each
to EventInput, builds cashflow_adjustments, passes to simulate().
- Frontend Run-now on scenario detail now fetches the scenario's
life events and includes them in the request — projections finally
reflect "retire at 50, kid born at y3, inheritance at y22".
Tests: 11 events helper + 4 end-to-end engine + 1 API integration =
16 new tests. 188 total (was 172). mypy strict + ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:30:33 +00:00
|
|
|
|
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)
|
engine+api: plumb life events into the simulator
Until now life events were stored but ignored by the engine — pure
metadata. Now they actually move portfolios.
Engine:
- simulator.simulate() takes optional cashflow_adjustments: a (n_years,)
real-GBP array applied each year *after* savings + return but
*before* withdrawal. Positive = inflow, negative = outflow.
- New fire_planner/life_events.py with EventInput dataclass +
events_to_cashflow_array(events, horizon). Handles ranged deltas,
one-time amounts, disabled events, year clipping past horizon,
negative year_start (clipped to 0), and summing multiple events.
API:
- /simulate accepts optional life_events list. Server converts each
to EventInput, builds cashflow_adjustments, passes to simulate().
- Frontend Run-now on scenario detail now fetches the scenario's
life events and includes them in the request — projections finally
reflect "retire at 50, kid born at y3, inheritance at y22".
Tests: 11 events helper + 4 end-to-end engine + 1 API integration =
16 new tests. 188 total (was 172). mypy strict + ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:30:33 +00:00
|
|
|
|
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):
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 21:48:36 +00:00
|
|
|
|
class SimulateRequest(BaseModel):
|
2026-05-10 01:51:24 +00:00
|
|
|
|
"""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.
|
|
|
|
|
|
"""
|
2026-05-09 21:48:36 +00:00
|
|
|
|
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
|
engine+api: plumb life events into the simulator
Until now life events were stored but ignored by the engine — pure
metadata. Now they actually move portfolios.
Engine:
- simulator.simulate() takes optional cashflow_adjustments: a (n_years,)
real-GBP array applied each year *after* savings + return but
*before* withdrawal. Positive = inflow, negative = outflow.
- New fire_planner/life_events.py with EventInput dataclass +
events_to_cashflow_array(events, horizon). Handles ranged deltas,
one-time amounts, disabled events, year clipping past horizon,
negative year_start (clipped to 0), and summing multiple events.
API:
- /simulate accepts optional life_events list. Server converts each
to EventInput, builds cashflow_adjustments, passes to simulate().
- Frontend Run-now on scenario detail now fetches the scenario's
life events and includes them in the request — projections finally
reflect "retire at 50, kid born at y3, inheritance at y22".
Tests: 11 events helper + 4 end-to-end engine + 1 API integration =
16 new tests. 188 total (was 172). mypy strict + ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:30:33 +00:00
|
|
|
|
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")
|
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
|
2026-05-09 21:48:36 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
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
|
2026-05-09 21:48:36 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CompareRequest(BaseModel):
|
|
|
|
|
|
scenarios: list[SimulateRequest] = Field(min_length=2, max_length=5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CompareResult(BaseModel):
|
|
|
|
|
|
results: list[SimulateResult]
|