All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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.
186 lines
6.1 KiB
TypeScript
186 lines
6.1 KiB
TypeScript
/**
|
||
* Settings → Rates (Wave 1.D.3).
|
||
*
|
||
* Stores its state in the scenario's `config_json.rates` blob via PATCH
|
||
* /scenarios/:id. The simulator reads them off the SimulateRequest at
|
||
* /simulate time; the Cartesian recompute path will pick them up in a
|
||
* follow-up wave.
|
||
*/
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { useEffect, useState } from 'react';
|
||
import { useParams } from 'react-router-dom';
|
||
|
||
import { api } from '@/api/client';
|
||
import { RateCard } from '@/components/RateCard';
|
||
import { SegmentedControl } from '@/components/SegmentedControl';
|
||
|
||
type RatesMode = 'fixed' | 'historical' | 'advanced';
|
||
|
||
interface RatesConfig {
|
||
mode: RatesMode;
|
||
inflation_pct: number;
|
||
stocks_growth_pct: number;
|
||
stocks_dividend_pct: number;
|
||
bonds_growth_pct: number;
|
||
bonds_dividend_pct: number;
|
||
stocks_allocation: number;
|
||
}
|
||
|
||
const DEFAULT: RatesConfig = {
|
||
mode: 'historical',
|
||
inflation_pct: 0.03,
|
||
stocks_growth_pct: 0.06,
|
||
stocks_dividend_pct: 0.025,
|
||
bonds_growth_pct: 0.015,
|
||
bonds_dividend_pct: 0.035,
|
||
stocks_allocation: 1,
|
||
};
|
||
|
||
function readConfig(blob: Record<string, unknown>): RatesConfig {
|
||
const raw = (blob?.rates ?? {}) as Partial<RatesConfig>;
|
||
return {
|
||
mode: (raw.mode as RatesMode) ?? DEFAULT.mode,
|
||
inflation_pct: numFallback(raw.inflation_pct, DEFAULT.inflation_pct),
|
||
stocks_growth_pct: numFallback(raw.stocks_growth_pct, DEFAULT.stocks_growth_pct),
|
||
stocks_dividend_pct: numFallback(raw.stocks_dividend_pct, DEFAULT.stocks_dividend_pct),
|
||
bonds_growth_pct: numFallback(raw.bonds_growth_pct, DEFAULT.bonds_growth_pct),
|
||
bonds_dividend_pct: numFallback(raw.bonds_dividend_pct, DEFAULT.bonds_dividend_pct),
|
||
stocks_allocation: numFallback(raw.stocks_allocation, DEFAULT.stocks_allocation),
|
||
};
|
||
}
|
||
|
||
function numFallback(value: unknown, fallback: number): number {
|
||
const n = typeof value === 'number' ? value : Number(value);
|
||
return Number.isFinite(n) ? n : fallback;
|
||
}
|
||
|
||
export function RatesSettings() {
|
||
const params = useParams<{ id: string }>();
|
||
const id = Number(params.id);
|
||
const qc = useQueryClient();
|
||
|
||
const scen = useQuery({
|
||
queryKey: ['scenarios', id],
|
||
queryFn: () => api.scenarios.get(id),
|
||
enabled: Number.isFinite(id),
|
||
});
|
||
const [config, setConfig] = useState<RatesConfig>(DEFAULT);
|
||
|
||
useEffect(() => {
|
||
if (scen.data?.config_json) {
|
||
setConfig(readConfig(scen.data.config_json as Record<string, unknown>));
|
||
}
|
||
}, [scen.data?.config_json]);
|
||
|
||
const save = useMutation({
|
||
mutationFn: (next: RatesConfig) =>
|
||
api.scenarios.patch(id, {
|
||
config_json: {
|
||
...((scen.data?.config_json as Record<string, unknown>) ?? {}),
|
||
rates: next,
|
||
},
|
||
} as never),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ['scenarios', id] }),
|
||
});
|
||
|
||
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
|
||
if (scen.isLoading) return <p className="text-slate-500">Loading…</p>;
|
||
if (!scen.data) return null;
|
||
|
||
const realStock =
|
||
(1 + config.stocks_growth_pct + config.stocks_dividend_pct) /
|
||
(1 + config.inflation_pct) -
|
||
1;
|
||
|
||
const update = (patch: Partial<RatesConfig>) => {
|
||
const next = { ...config, ...patch };
|
||
setConfig(next);
|
||
save.mutate(next);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-5 max-w-2xl">
|
||
<div>
|
||
<p className="text-sm text-slate-500 mb-3">
|
||
Choose how returns are generated. Fixed mode synthesises a deterministic real
|
||
return from the per-asset growth + dividend numbers minus inflation. Historical
|
||
uses the Shiller bootstrap. Advanced is a Wave 2 placeholder.
|
||
</p>
|
||
<SegmentedControl
|
||
options={[
|
||
{ value: 'fixed', label: 'Fixed' },
|
||
{ value: 'historical', label: 'Historical' },
|
||
{ value: 'advanced', label: 'Advanced (Wave 2)', title: 'Coming in Wave 2' },
|
||
]}
|
||
value={config.mode}
|
||
onChange={(v) => update({ mode: v as RatesMode })}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<RateCard
|
||
title="Inflation"
|
||
growth={config.inflation_pct}
|
||
onChange={(n) => update({ inflation_pct: n.growth })}
|
||
/>
|
||
<RateCard
|
||
title="Stocks"
|
||
growth={config.stocks_growth_pct}
|
||
dividend={config.stocks_dividend_pct}
|
||
onChange={(n) =>
|
||
update({
|
||
stocks_growth_pct: n.growth,
|
||
stocks_dividend_pct: n.dividend ?? config.stocks_dividend_pct,
|
||
})
|
||
}
|
||
/>
|
||
<RateCard
|
||
title="Bonds"
|
||
growth={config.bonds_growth_pct}
|
||
dividend={config.bonds_dividend_pct}
|
||
onChange={(n) =>
|
||
update({
|
||
bonds_growth_pct: n.growth,
|
||
bonds_dividend_pct: n.dividend ?? config.bonds_dividend_pct,
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
|
||
<div className="text-xs uppercase tracking-wide text-slate-500">
|
||
Computed real stock return
|
||
</div>
|
||
<div className="text-lg font-semibold tabular-nums text-slate-800 mt-1">
|
||
{(realStock * 100).toFixed(2)}%
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">
|
||
(1 + growth + dividend) / (1 + inflation) − 1
|
||
</div>
|
||
</div>
|
||
|
||
<label className="block text-xs">
|
||
<span className="uppercase tracking-wide text-slate-500">Stocks allocation</span>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={1}
|
||
step={0.05}
|
||
value={config.stocks_allocation}
|
||
onChange={(e) => update({ stocks_allocation: Number(e.target.value) })}
|
||
className="mt-1 w-32 rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||
/>
|
||
<span className="ml-3 text-xs text-slate-500">
|
||
(only used in Fixed mode; bonds = 1 − allocation)
|
||
</span>
|
||
</label>
|
||
|
||
{save.isPending && <p className="text-xs text-slate-500">Saving…</p>}
|
||
{save.isError && (
|
||
<p className="text-xs text-red-700">
|
||
{String((save.error as Error)?.message ?? save.error)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|