Three follow-ups to the actualbudget integration:
**Always-fresh autofill.** Drop the one-shot `*AutoFilled` boolean
gates; replace with `nwUserEdited` / `spendingUserEdited` flags. Until
the user types into either field, every refetch (mount, window
focus) updates the form value. Once they edit, we leave it alone. A
small ↻ button next to each anchor input flips the edited flag back
off so the user can re-snap to live data on demand. React Query
configured with staleTime=0 + refetchOnMount='always' +
refetchOnWindowFocus=true so the cache never serves stale numbers.
NW provenance shows the snapshot date.
**Inflation-adjusted spending.** Backend now revalues each trailing
month's nominal pence forward to today's £ using monthly compounding
of `inflation_pct` (default 0.03 ≈ UK CPI 2024-26). Headline
`total_gbp` is the real-£ figure — matches the simulator's
real-GBP convention. Response also includes `nominal_total_gbp` and
`inflation_pct` for transparency. New /spending/annual?inflation_pct=
override param. 10/10 actualbudget tests pass.
**FanChart legend.** The bottom-anchored legend was overlapping the
x-axis label. Moved to top: 8 with itemGap=18 + type=scroll for
narrow viewports; bumped grid top→48 / bottom→56 + xAxis nameGap→28
so nothing collides.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a thin read-only client for the actualbudget HTTP API
(`fire_planner/actualbudget.py`) and a `GET /spending/annual` endpoint
that returns trailing-N-month spending broken out by category group.
Default exclusions ("Investments and Savings", "Budget Reset") strip
out wealth transfers so the headline number reflects actual
consumption — for Viktor's data, ~£41k/yr instead of the raw £210k
total. Caller can pass `?exclude=...` to override.
Frontend uses the headline `total_gbp` to autofill the Annual spending
input (same pattern as nw_seed from networth), with a small
provenance line below the input showing the window + which groups
were excluded.
Auth: 3 new env vars (ACTUALBUDGET_API_URL/KEY/SYNC_ID) sourced from
Vault `secret/fire-planner` via the existing ExternalSecret —
infra/stacks/fire-planner applied separately. Backend silently keeps
the hardcoded default if the upstream is unreachable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
NW seed at £1.5M was nearly clipping in the BigNumber inputs. Widened
the form column 420→480px, switched the anchor row to a weighted
1.4/1.2/0.7 grid so NW seed and Spending get more room than Horizon,
dropped the BigNumber font from text-2xl to text-xl, and tightened
prefix/suffix padding to claw back digits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The What-If form was a 14-field stack with always-visible hint
paragraphs — ~1500px scroll before "Run". The user is single-allocation
(100% stocks), so the glide-path knob was noise. Hardcoded
`static(1.0)` at the API layer; dropped `glide_path` from
`SimulateRequest` (extra field on persisted Scenario rows still
honoured for Cartesian sweeps).
Frontend reorganised into anchor numbers (NW / spend / horizon at
text-2xl), a Plan card (jurisdiction + leave-UK + strategy chips +
conditional Floor/Custom sub-card), a Returns card (3-chip segmented
control with inline manual %), and a folded Advanced section
(savings, MC paths, seed). Verbose hints moved into ⓘ popovers next
to each label. Two new primitives: SegmentedControl + InfoTip.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
The user kept asking what each strategy means. Adding two layers:
1. Inline hint under each dropdown (Jurisdiction, Strategy, Glide
path) — short one-liner that updates as the selected option
changes. So no clicking required to see what trinity vs
guyton_klinger does.
2. <details> panel at the bottom: "About the model" with all
strategies, glide paths, jurisdictions described in plain
English, plus a note on how the engine treats tax (post-fix:
drain = w + tax(w)) and the returns model (Shiller block
bootstrap, 60/40 ≈ 4.6% real long run).
Plain-English originals — Trinity / Guyton-Klinger / VPW are
public-domain finance concepts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
step={1000}/step={10000} on £ inputs blocked any non-multiple value
from passing browser validation. Surfaced when the Wealthfolio
autofill landed `1050195.09` into the NW field — Chrome rejected
it: "the two nearest valid values are 1050000 and 1060000". Same
class as the n_paths bug from earlier today.
- Remove step on spending_gbp / nw_seed_gbp / savings_per_year_gbp /
floor_gbp across What-If, ScenarioNew, ScenarioEdit. Default step=1
(any positive integer).
- Round NW autofill to whole pounds (Math.round) since the user
doesn't think in pence at this scale and the rounded value
satisfies any integer step.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two fixes:
(1) Simulator: portfolio drain is now `w + tax(w)`, not just `w`.
The pre-2026-05-10 engine recorded tax in tax_hist but never
subtracted it from the portfolio, so changing jurisdiction only
moved the median_lifetime_tax cell — the fan chart, success
rate, and ending percentiles were identical for UK vs Cyprus
vs Malaysia. (The PLAYBOOK_VIKTOR.md memo from 2026-04-26
explicitly noted this: "Success rate is regime-independent…
tax doesn't drain the portfolio in this simulator.")
Mental model now: spending_target is what the user takes home;
the tax bill is an additional drag on the same pool. Higher-tax
jurisdictions therefore drain faster and lower the success
rate, which is the user's intuition. Trinity 4% effectively
becomes "4% take-home + tax overhead". 188 tests still pass —
most use Malaysia (0%) or hit the regime-independent code paths.
(2) /what-if and /scenarios/new now pre-fill nw_seed_gbp from
GET /networth on first mount (when the wealthfolio_sync mirror
has data), so opening the form starts from the user's real
portfolio total instead of the £1.5M placeholder. Once the user
edits the field, subsequent NW refetches don't clobber it
(nwAutoFilled latch).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The SPA can't carry a Bearer header — there's no client-side mechanism
to obtain the RECOMPUTE_BEARER_TOKEN, and the value can't safely be
embedded in the JS bundle. Result: every POST/PATCH/DELETE on
scenarios/life-events/goals + every /simulate + /compare returned 401
in prod, breaking the SPA end-to-end.
Strip require_bearer from the routers. Authentik forward-auth on the
SPA path (/) is now the security boundary; /api/* is open at both
ingress + app level. Single-tenant personal tool — the data is
the user's own anonymous numeric projections.
Kept on /recompute (heavy admin batch in app.py) since that's an
operator action, not a user one.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
step=500 with min=100 made valid values 100, 600, 1100, … so typing
a round number like 500 or 1000 tripped browser validation
("Please enter a valid value. The two nearest valid values are 100
and 600."). Found via headless click-through against the deployed
instance — the Run-simulation submit was silently rejected.
step=100 keeps a sensible up/down increment while accepting any
multiple of 100 (100, 200, …, 5000, …, 50000).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
StaticFiles is a Starlette primitive — its 404 raises
starlette.exceptions.HTTPException, NOT fastapi.HTTPException
(which subclasses Starlette's). My initial except clause caught the
subclass and let the base class propagate, so /scenarios still 404'd.
Switch to except StarletteHTTPException so both the parent and any
FastAPI subclass are caught. Verified end-to-end via chrome-service
in the next deploy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
StaticFiles(html=True) only serves index.html for directory paths,
which 404s on /scenarios, /what-if, /scenarios/123 — anything React
Router owns. Subclass StaticFiles to catch the 404 from get_response
and return index.html so the SPA can take over routing client-side.
API routes still match first (under /api/* in prod), so no risk of
shadowing.
Found via headless verification through chrome-service: dashboard
loaded 200 + nav rendered, but /scenarios + /what-if returned 404.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Header gets a third button: "Recompute all". POSTs /recompute
(202-accepted, async background worker drains the queue). Once
work is in flight, the existing /healthz query auto-refetches
every 3s and the button label switches to "Computing… (queue N)";
flips back to idle when the queue drains.
Removes the need to drop to CLI (`python -m fire_planner
recompute-all`) for routine refreshes. Bearer auth still applies in
prod once API_BEARER_TOKEN is set — frontend will need a token
header in that mode (TODO).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Three-stage build:
1. node:22-alpine — `npm ci` + `npm run build` produces frontend/dist
2. python:3.12-slim — poetry installs backend deps into a venv
3. python:3.12-slim — runtime, copies the venv + frontend/dist,
sets FRONTEND_DIST=/app/frontend_dist
Backend gates the API surface on FRONTEND_DIST:
- Unset (dev / tests): routers mount at root (/networth, /scenarios,
…). 172 tests still pass unchanged. The Vite dev server proxies
`/api/*` → backend stripping the prefix.
- Set (prod): routers mount under `/api/*`. The SPA bundle mounts at
`/` with html=True so React Router owns client routing for paths
like `/scenarios`, `/what-if`. Same-origin, no CORS, one deploy.
Operational endpoints (`/healthz`, `/metrics`, `/recompute`) stay at
root in both shapes.
Existing Woodpecker pipeline picks this up unchanged — same context,
same Dockerfile path, just produces a richer image.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three small UX wins:
- /scenarios/:id Run now — POSTs /simulate with the scenario's params
and renders the result in a "Live preview run" card below the
persisted projection. Removes the recompute-or-wait friction.
- /what-if Save as scenario — appears once a simulation has run.
Prompts for a name (with a sensible default), POSTs the form values
to /scenarios as a user scenario, redirects to its detail page.
- /scenarios/:id/edit — PATCH form for user scenarios. Pre-fills from
current scenario; on save invalidates the scenarios query and
navigates back to detail. Backend already rejects PATCH on
cartesian; the UI also hides the Edit button for them.
api.scenarios gained patch(). 7 tests pass, typecheck + build clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new nested CRUD sections on /scenarios/:id, each list + add form
in one card:
- Life events: name, kind (free-text with datalist suggestions —
retirement, kid_born, mortgage_payoff, sabbatical, inheritance...),
year_start, optional year_end (one-time vs ranged), £/year delta.
One-line summary per row; Delete button per item.
- Goals: name, kind (target_nw, never_run_out, inheritance,
spending_floor), comparator (>= < etc), target amount, target year,
success threshold (probability bar). Same list+add+delete layout.
Both wire through the existing FastAPI endpoints (POST/GET on
nested paths, DELETE on flat /life-events/{id} and /goals/{id})
already shipped in Phase 0c. Mutations invalidate per-scenario
queries so the list refreshes immediately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Multi-select on /scenarios — checkbox per row, capped at 5. When ≥2
are picked, a "Compare N" button appears that navigates to
/compare?ids=1,2,3.
/compare page pulls each scenario + its latest projection in
parallel via useQueries. Each scenario gets a distinct hue (5 in
the palette); median lines drawn solid, p10 + p90 dashed at 60%
opacity. Stats table below shows success / p10 / p50 / p90 endings
+ median lifetime tax per scenario, with inline "no run yet" rows
for scenarios missing a projection (404 from /projection treated
as soft state, not error).
7 tests pass (was 5). Bundle still ~470 KB gz.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/scenarios/new — form posts to POST /scenarios with name, description,
jurisdiction, strategy, glide path, leave-UK year, spending, NW seed,
savings, horizon. Required-name validation; on success invalidates the
scenarios query and navigates to the new detail page.
/scenarios/:id — Delete button (user scenarios only; cartesian are
backend-protected). Browser confirm prompt + DELETE /scenarios/{id} +
invalidate + redirect to list.
api.scenarios gains create() and delete(). New ScenarioCreateBody type.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/scenarios — table of all scenarios with filter chips (all/cartesian/
user). Cartesian scenarios get a neutral badge; user-defined get an
emerald accent. Empty-state nudges the user to run /recompute.
/scenarios/:id — params summary + the latest persisted MC projection.
Reuses FanChart so chart code is shared with /what-if. 404 on the
projection endpoint is treated as "no run yet" (don't retry); other
errors surface normally.
Nav grew a Scenarios tab. typecheck + 5 tests + build pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First end-to-end view: ECharts stacked area of net worth by account
over the last 365 days, fed from GET /networth/history.
- NetWorthChart component with empty-state fallback
- Mocked ReactECharts in tests so they run without a DOM canvas
- Dashboard now: headline NW + history chart + per-account cards
Bundle grew to 467 KB gzipped — ECharts is heavy by design. Will
tree-shake via echarts/core imports once the chart surface stabilises.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bare-minimum SPA that wires up to the FastAPI backend:
- Vite 6 + React 19 + TS strict, alias @/* to src/*
- Tailwind v4 via @tailwindcss/vite (no postcss)
- TanStack Query v5 with sane defaults (30s staleTime, no auto-refetch)
- React Router 7 for routing
- ECharts + Recharts available (charts land in Phase 1a)
- Vitest + @testing-library/react for tests
- Dev proxy /api → http://localhost:8080 (FastAPI)
Pages:
- Dashboard — pulls /networth, shows total + per-account cards.
No chart yet (Phase 1a). Empty/error states for "no data" cases
point users to the ingest CLI.
Header shows live API health (queue depth from /healthz). 274 KB JS
gzipped to 87 KB. typecheck + build pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the read+write endpoints the frontend needs to drive a
ProjectionLab-style UX on top of the existing engine.
- /networth, /networth/history — NW total + per-account from
account_snapshot (frontend chart)
- /scenarios CRUD + projection — list/get/create/patch/delete user
scenarios; cartesian read-only
- /scenarios/{id}/life-events — life event CRUD nested under scenario
- /life-events/{id} — patch + delete by id
- /scenarios/{id}/goals,
/goals/{id} — retirement goal CRUD
- /simulate, /compare — sync, no-DB-write what-if endpoints
Auth: Bearer-token dependency on writes + simulate when API_BEARER_TOKEN
is set; reads always open (lock down via Authentik-fronted ingress in
prod). Existing /recompute keeps its bearer auth.
CORS middleware reads FRONTEND_ORIGINS (comma-separated) for the dev
SPA. Lifespan now provisions the SQLAlchemy engine + session_factory
on app.state and disposes them on shutdown.
40 new tests covering happy paths and validation. 172 tests total.
mypy strict + ruff clean (B008 ignore added — Depends() in defaults
is the canonical FastAPI pattern, not a bug).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new tables and three new columns on `scenario` to give the
ProjectionLab-style UI a place to land:
- `scenario` gains `kind` (cartesian | user), `name`, `description`,
`parent_scenario_id`. Existing Cartesian flow keeps `kind='cartesian'`
by default; user-defined scenarios point `parent_scenario_id` at the
base they cloned from (NULL for root).
- `life_event` — timed events on a scenario timeline: retirement, kid
born, mortgage payoff, sabbatical, inheritance, etc. `year_start` and
`year_end` are scenario-relative (year 0 = today).
`delta_gbp_per_year` covers ranged effects; `one_time_amount_gbp`
covers one-shot impacts. `enabled` lets the UI toggle without delete.
- `retirement_goal` — user-defined success criteria (target_nw,
never_run_out, inheritance, ...). `comparator` + `success_threshold`
let the goal say "≥ £2M at year 25 in ≥ 90% of paths".
Migration 0002 adds the columns + tables idempotently.
145 tests; mypy strict + ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous SQLite-direct reader queried `holdings_snapshot` (singular)
and `accounts.type` — both wrong against the live wealthfolio schema
(plural `holdings_snapshots`, column `account_type`). It silently
returned [] via the OperationalError fallback, leaving fire-planner with
stale account snapshots.
Switch to reading from the wealthfolio_sync PG mirror. The pg-sync
sidecar (defined in infra/stacks/wealthfolio) hourly mirrors SQLite to
Postgres with a clean schema. We read from `daily_account_valuation`
which already has total_value, cost_basis, and explicit fx_rate_to_base
per row — no JSON-decoding of position blobs.
CLI ingest no longer takes --db-path (no kubectl-exec gymnastics);
reads WEALTHFOLIO_SYNC_DB_CONNECTION_STRING from env. Falls back to
DB_CONNECTION_STRING for single-DB local dev.
13 new tests covering: latest-per-account, multi-currency FX, explicit
as-of, empty mirror, null cost_basis, full pipeline through upsert.
140 tests pass; mypy strict + ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>