187 lines
6.1 KiB
TypeScript
187 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>
|
|||
|
|
);
|
|||
|
|
}
|