returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
This commit is contained in:
parent
f2c36bc4a3
commit
00ec874889
6 changed files with 515 additions and 11 deletions
|
|
@ -241,6 +241,8 @@ export interface SimulateRequest {
|
|||
one_time_amount_gbp?: string | null;
|
||||
enabled?: boolean;
|
||||
}>;
|
||||
returns_mode?: 'shiller' | 'manual' | 'wealthfolio';
|
||||
manual_real_return_pct?: string | null;
|
||||
}
|
||||
|
||||
export interface SimulateResult {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,22 @@ import { gbp, pct } from '@/lib/format';
|
|||
const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
|
||||
const STRATEGIES = ['trinity', 'guyton_klinger', 'vpw', 'vpw_floor'];
|
||||
const GLIDES = ['rising', 'static_60_40'];
|
||||
const RETURNS_MODES = ['shiller', 'manual', 'wealthfolio'] as const;
|
||||
|
||||
const RETURNS_MODE_LABELS: Record<string, string> = {
|
||||
shiller: 'Historical (Shiller 1871+)',
|
||||
manual: 'Manual real return %',
|
||||
wealthfolio: 'My Wealthfolio history',
|
||||
};
|
||||
|
||||
const RETURNS_MODE_NOTES: Record<string, string> = {
|
||||
shiller:
|
||||
'Block-bootstrap of US historical real returns (Shiller 1871+). Broadest regime coverage — includes 1929/1973/2000/2008-style bad sequences. Best default for stress-testing.',
|
||||
manual:
|
||||
'Every year, every path returns the % you type. Deterministic — no fan, no volatility. Useful for sanity checks ("what if my real return is exactly 5%?").',
|
||||
wealthfolio:
|
||||
'Block-bootstrap of your actual blended portfolio returns from wealthfolio_sync (~6 years, 2020-present). Reflects your real account mix but biased to the recent regime. Glide path is ignored in this mode.',
|
||||
};
|
||||
|
||||
// Plain-English notes shown next to each dropdown so the user knows
|
||||
// what each option does without leaving the page. Same content gets
|
||||
|
|
@ -57,6 +73,8 @@ const DEFAULTS: SimulateRequest = {
|
|||
floor_gbp: null,
|
||||
n_paths: 5000,
|
||||
seed: 42,
|
||||
returns_mode: 'shiller',
|
||||
manual_real_return_pct: '0.046',
|
||||
};
|
||||
|
||||
export function WhatIf() {
|
||||
|
|
@ -107,6 +125,8 @@ export function WhatIf() {
|
|||
sim.mutate({
|
||||
...form,
|
||||
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
|
||||
manual_real_return_pct:
|
||||
form.returns_mode === 'manual' ? form.manual_real_return_pct : null,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -201,6 +221,37 @@ export function WhatIf() {
|
|||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label="Returns model"
|
||||
hint={RETURNS_MODE_NOTES[form.returns_mode ?? 'shiller']}
|
||||
>
|
||||
<select
|
||||
value={form.returns_mode ?? 'shiller'}
|
||||
onChange={(e) =>
|
||||
update('returns_mode', e.target.value as 'shiller' | 'manual' | 'wealthfolio')
|
||||
}
|
||||
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
>
|
||||
{RETURNS_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{RETURNS_MODE_LABELS[m]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{form.returns_mode === 'manual' && (
|
||||
<Field label="Real return % (e.g. 0.046 = 4.6%)">
|
||||
<input
|
||||
type="number"
|
||||
value={form.manual_real_return_pct ?? '0.046'}
|
||||
step="0.001"
|
||||
min={-0.5}
|
||||
max={1}
|
||||
onChange={(e) => update('manual_real_return_pct', e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Monte Carlo paths">
|
||||
<NumberInput
|
||||
value={form.n_paths ?? 5000}
|
||||
|
|
@ -300,12 +351,14 @@ function AboutTheModel() {
|
|||
not just the lifetime-tax cell.
|
||||
</p>
|
||||
</Section>
|
||||
<Section title="Returns">
|
||||
<p>
|
||||
Real returns are sampled by 5-year block bootstrap from the Shiller 1871+ series
|
||||
(or a synthetic Shiller-calibrated stream). 60/40 long-run real ≈ 4.6%; equities
|
||||
are ~9.5% nominal / 17% volatility, bonds ~5%/8%. Each path resamples blocks
|
||||
independently so sequence-of-returns risk is preserved.
|
||||
<Section title="Returns model">
|
||||
<Term name="Historical (Shiller 1871+)">{RETURNS_MODE_NOTES.shiller}</Term>
|
||||
<Term name="Manual real return">{RETURNS_MODE_NOTES.manual}</Term>
|
||||
<Term name="My Wealthfolio history">{RETURNS_MODE_NOTES.wealthfolio}</Term>
|
||||
<p className="pt-1">
|
||||
Each path resamples blocks independently so sequence-of-returns risk is preserved.
|
||||
Long-run benchmarks for context: 60/40 real ≈ 4.6%; equities ~9.5% nominal /
|
||||
17% volatility; bonds ~5%/8%.
|
||||
</p>
|
||||
</Section>
|
||||
<Section title="Success rate">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue