Monte-Carlo: zwei Welten, vier Faelle statt widerspruechlicher Modi
Deploy App / deploy (push) Successful in 1m1s
Deploy App / deploy (push) Successful in 1m1s
Zwei Laeufe (historische / geplante Renditen, gemeinsamer Seed), aus jeder Verteilung beide Schwellen abgelesen. Fall 1 und Fall 3 stammen damit aus derselben Verteilung -- ein tieferes Ziel kann nie unwahrscheinlicher sein. Nullpunkt fuer das Urteil ist Fall 2 (nicht 50 %), Toleranzband +/- 5 pp. Neu: MonteCarloResult.finalWealthSorted, probabilityAtLeast. Spezifikation 0.17 (4.12.7 und 9.26 neu gefasst), 3 Tests (121 -> 124). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+220
-238
@@ -13,6 +13,7 @@ import {
|
||||
floorFor,
|
||||
paramsForScenario,
|
||||
plannedReturnOf,
|
||||
probabilityAtLeast,
|
||||
runMonteCarloMulti,
|
||||
INFLATION_VOLATILITY_LEVELS,
|
||||
RETURN_BEARING,
|
||||
@@ -31,14 +32,22 @@ import type { PlanComputed } from "@/lib/calculations";
|
||||
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
|
||||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||||
|
||||
// Die zwei Fragen, die die Simulation beantworten kann (Roadmap Nr. 46).
|
||||
type McMode = "PLAN" | "TARGET" | "BOTH";
|
||||
|
||||
const MODE_OPTIONS: { value: McMode; label: string }[] = [
|
||||
{ value: "PLAN", label: "Planung prüfen" },
|
||||
{ value: "TARGET", label: "Ziel prüfen" },
|
||||
{ value: "BOTH", label: "Beides" },
|
||||
];
|
||||
// Ergebnis je Szenario: ZWEI Welten (historische vs. geplante Renditen), aus jeder werden
|
||||
// ZWEI Schwellen abgelesen (Plan-Endbetrag und Zielbetrag) -- die vier Fälle aus 4.12.7.
|
||||
interface Outcome {
|
||||
scenarioId: string;
|
||||
name: string;
|
||||
planEnd: number;
|
||||
target: number;
|
||||
f1: number; // historische Welt, Schwelle Plan-Endbetrag -> Realismus der Planung
|
||||
f2: number; // geplante Welt, Schwelle Plan-Endbetrag -> Nullpunkt für das Urteil
|
||||
f3: number; // historische Welt, Schwelle Zielbetrag -> Ziel, wenn der Markt wie früher läuft
|
||||
f4: number; // geplante Welt, Schwelle Zielbetrag -> Ziel, wenn der Markt wie geplant läuft
|
||||
medianHist: number;
|
||||
medianPlan: number;
|
||||
ruin: number; // aus der historischen Welt (das ehrliche Risikobild)
|
||||
runs: number;
|
||||
}
|
||||
|
||||
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
|
||||
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
||||
@@ -117,8 +126,7 @@ export function MonteCarloDialog({
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
|
||||
|
||||
const [mode, setMode] = useState<McMode>("PLAN");
|
||||
// Fall 2: EIN Zielbetrag für alle Szenarien.
|
||||
// EIN Zielbetrag für alle Szenarien, vorbelegt mit dem Plan-Endbetrag des offenen Szenarios.
|
||||
const [manualTarget, setManualTarget] = useState<number>(Math.max(0, computed.nachlass));
|
||||
|
||||
const [runs, setRuns] = useState(1000);
|
||||
@@ -129,15 +137,12 @@ export function MonteCarloDialog({
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
|
||||
// Ergebnisse getrennt nach Frage: planA = "gegen Planungs-Endbetrag", targetB = "gegen Zielbetrag".
|
||||
const [resA, setResA] = useState<ScenarioMcResult[] | null>(null);
|
||||
const [resB, setResB] = useState<ScenarioMcResult[] | null>(null);
|
||||
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null);
|
||||
// Fächer/Bänder stammen aus der historischen Welt.
|
||||
const [fanRes, setFanRes] = useState<ScenarioMcResult[] | null>(null);
|
||||
|
||||
const scenarioKey = scenarios.map((s) => s.id).join(",");
|
||||
|
||||
const needsHistorical = mode === "PLAN" || mode === "BOTH"; // Fall 1 braucht historische Mittelwerte
|
||||
const needsTarget = mode === "TARGET" || mode === "BOTH"; // Fall 2 braucht einen Zielbetrag
|
||||
|
||||
useEffect(() => {
|
||||
const missing = scenarios.filter((s) => s.id !== meta.id);
|
||||
if (missing.length === 0) return;
|
||||
@@ -179,8 +184,8 @@ export function MonteCarloDialog({
|
||||
clearResults();
|
||||
}
|
||||
function clearResults() {
|
||||
setResA(null);
|
||||
setResB(null);
|
||||
setOutcomes(null);
|
||||
setFanRes(null);
|
||||
}
|
||||
|
||||
function toggleScenario(id: string) {
|
||||
@@ -192,20 +197,19 @@ export function MonteCarloDialog({
|
||||
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
|
||||
|
||||
const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
|
||||
const missing = (needsHistorical && missingHist) || (needsTarget && manualTarget <= 0);
|
||||
const missing = missingHist || manualTarget <= 0;
|
||||
const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading;
|
||||
|
||||
// Laufzeit-Schätzung: im Kombi-Modus laufen zwei Durchgänge.
|
||||
const passes = mode === "BOTH" ? 2 : 1;
|
||||
const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length) * passes) / 6000));
|
||||
// Immer zwei Durchgänge: historische Welt und geplante Welt.
|
||||
const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length) * 2) / 6000));
|
||||
|
||||
async function run() {
|
||||
setRunning(true);
|
||||
clearResults();
|
||||
const totalPasses = passes;
|
||||
setProgress({ index: 0, count: selected.length * totalPasses, fraction: 0 });
|
||||
setProgress({ index: 0, count: selected.length * 2, fraction: 0 });
|
||||
try {
|
||||
// Streuung (Sigma) je logischem Element -- in beiden Fällen dieselbe.
|
||||
// Streuung (Sigma) je logischem Element -- in BEIDEN Welten dieselbe. Nur der
|
||||
// Mittelpunkt unterscheidet sie.
|
||||
const sigmaByRoot: Record<string, number> = Object.fromEntries(
|
||||
groups.map((g) => [g.rootId, returnSigma(draftFor(g))])
|
||||
);
|
||||
@@ -213,81 +217,88 @@ export function MonteCarloDialog({
|
||||
const rootByMember = new Map<string, string>();
|
||||
for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId);
|
||||
|
||||
// Gemeinsamer Seed für beide Welten und alle Szenarien.
|
||||
const seed = (Math.random() * 2 ** 32) >>> 0;
|
||||
const inflSig = inflationSigma(inflLevel, inflManual);
|
||||
let passIndex = 0;
|
||||
const onProg = (index: number, count: number, fraction: number) =>
|
||||
setProgress({ index: passIndex * selected.length + index, count: selected.length * totalPasses, fraction });
|
||||
const inputs: ScenarioRunInput[] = selected.map((s) => ({
|
||||
scenarioId: s.id,
|
||||
name: s.name,
|
||||
plan: s.plan,
|
||||
// Der mitgegebene Zielbetrag ist nur ein Vorgabewert; die vier Fälle werden
|
||||
// anschliessend direkt aus der Verteilung abgelesen.
|
||||
target: manualTarget,
|
||||
}));
|
||||
const onProg = (pass: number) => (index: number, _count: number, fraction: number) =>
|
||||
setProgress({ index: pass * selected.length + index, count: selected.length * 2, fraction });
|
||||
|
||||
// --- Fall 1: gegen den Planungs-Endbetrag, gewürfelt um HISTORISCHE Mittelwerte ---
|
||||
if (needsHistorical) {
|
||||
const paramByRoot: Record<string, ElementMcParams> = Object.fromEntries(
|
||||
groups.map((g) => [g.rootId, { mean: Number(draftFor(g).mean) || 0, sigma: sigmaByRoot[g.rootId], floor: floorByRoot[g.rootId] }])
|
||||
);
|
||||
const inputs: ScenarioRunInput[] = selected.map((s) => ({
|
||||
scenarioId: s.id,
|
||||
name: s.name,
|
||||
plan: s.plan,
|
||||
target: Math.max(0, s.computed.nachlass), // Planungs-Endbetrag (read-only)
|
||||
}));
|
||||
const res = await runMonteCarloMulti(
|
||||
inputs,
|
||||
{
|
||||
runs,
|
||||
inflationMeanFor: () => Number(inflMean) || 0,
|
||||
inflationSigma: inflSig,
|
||||
seed,
|
||||
elementsFor: (p) => paramsForScenario(p, groups, paramByRoot),
|
||||
// --- Welt A: HISTORISCHE Renditen (das realistische Bild) ---
|
||||
const paramByRoot: Record<string, ElementMcParams> = Object.fromEntries(
|
||||
groups.map((g) => [g.rootId, { mean: Number(draftFor(g).mean) || 0, sigma: sigmaByRoot[g.rootId], floor: floorByRoot[g.rootId] }])
|
||||
);
|
||||
const hist = await runMonteCarloMulti(
|
||||
inputs,
|
||||
{
|
||||
runs,
|
||||
inflationMeanFor: () => Number(inflMean) || 0,
|
||||
inflationSigma: inflSig,
|
||||
seed,
|
||||
elementsFor: (p) => paramsForScenario(p, groups, paramByRoot),
|
||||
},
|
||||
onProg(0)
|
||||
);
|
||||
|
||||
// --- Welt B: GEPLANTE Renditen (die Welt der eigenen Annahmen) ---
|
||||
const planned = await runMonteCarloMulti(
|
||||
inputs,
|
||||
{
|
||||
runs,
|
||||
inflationMeanFor: (p) => p.inflationRateDefault,
|
||||
inflationSigma: inflSig,
|
||||
seed,
|
||||
elementsFor: (p) => {
|
||||
const ids = orderedPhaseIds(p);
|
||||
const out: Record<string, ElementMcParams> = {};
|
||||
for (const e of p.elements) {
|
||||
if (!RETURN_BEARING.includes(e.category)) continue;
|
||||
const root = rootByMember.get(e.id);
|
||||
const sigma = root ? sigmaByRoot[root] : returnSigma({ mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" });
|
||||
out[e.id] = { mean: plannedReturnOf(e, ids), sigma, floor: floorFor(e.category) };
|
||||
}
|
||||
return out;
|
||||
},
|
||||
onProg
|
||||
);
|
||||
setResA(res);
|
||||
passIndex += 1;
|
||||
}
|
||||
},
|
||||
onProg(1)
|
||||
);
|
||||
|
||||
// --- Fall 2: gegen den Zielbetrag, gewürfelt um die GEPLANTEN Werte ---
|
||||
if (needsTarget) {
|
||||
const inputs: ScenarioRunInput[] = selected.map((s) => ({
|
||||
// Vier Fälle aus zwei Verteilungen ablesen. Weil Fall 1 und 3 aus DERSELBEN Verteilung
|
||||
// stammen, gilt zwingend: tieferes Ziel -> nie kleinere Wahrscheinlichkeit.
|
||||
const out: Outcome[] = selected.map((s) => {
|
||||
const h = hist.find((r) => r.scenarioId === s.id)!;
|
||||
const b = planned.find((r) => r.scenarioId === s.id)!;
|
||||
const planEnd = Math.max(0, s.computed.nachlass);
|
||||
return {
|
||||
scenarioId: s.id,
|
||||
name: s.name,
|
||||
plan: s.plan,
|
||||
planEnd,
|
||||
target: manualTarget,
|
||||
}));
|
||||
const res = await runMonteCarloMulti(
|
||||
inputs,
|
||||
{
|
||||
runs,
|
||||
inflationMeanFor: (p) => p.inflationRateDefault, // plan-eigene Inflation
|
||||
inflationSigma: inflSig,
|
||||
seed,
|
||||
elementsFor: (p) => {
|
||||
const ids = orderedPhaseIds(p);
|
||||
const out: Record<string, ElementMcParams> = {};
|
||||
for (const e of p.elements) {
|
||||
if (!RETURN_BEARING.includes(e.category)) continue;
|
||||
const root = rootByMember.get(e.id);
|
||||
const sigma = root ? sigmaByRoot[root] : returnSigma({ mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" });
|
||||
out[e.id] = { mean: plannedReturnOf(e, ids), sigma, floor: floorFor(e.category) };
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
onProg
|
||||
);
|
||||
setResB(res);
|
||||
}
|
||||
f1: probabilityAtLeast(h.finalWealthSorted, planEnd),
|
||||
f2: probabilityAtLeast(b.finalWealthSorted, planEnd),
|
||||
f3: probabilityAtLeast(h.finalWealthSorted, manualTarget),
|
||||
f4: probabilityAtLeast(b.finalWealthSorted, manualTarget),
|
||||
medianHist: h.finalWealthMedian,
|
||||
medianPlan: b.finalWealthMedian,
|
||||
ruin: h.ruinProbability,
|
||||
runs: h.runs,
|
||||
};
|
||||
});
|
||||
setOutcomes(out);
|
||||
setFanRes(hist);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const overallProgress = progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0;
|
||||
const modeHint =
|
||||
mode === "PLAN"
|
||||
? "Wie wahrscheinlich hält deine Planung? Gewürfelt wird um die historischen Renditen; geprüft wird gegen den geplanten Endbetrag."
|
||||
: mode === "TARGET"
|
||||
? "Wie wahrscheinlich erreichst du dein Ziel? Gewürfelt wird um deine geplanten Werte; geprüft wird gegen deinen Zielbetrag."
|
||||
: "Beide Fragen auf einmal: Ist die Planung realistisch (gegen den Planungs-Endbetrag, historische Renditen) UND erreichst du dein Ziel (gegen den Zielbetrag, geplante Werte)?";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
@@ -304,32 +315,6 @@ export function MonteCarloDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modus-Umschalter: die zwei Fragen (Roadmap Nr. 46) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Was möchtest du wissen?
|
||||
<InfoBubble text="Zwei verschiedene Fragen: «Planung prüfen» misst, wie realistisch deine Planung ist (historische Renditen gegen den geplanten Endbetrag). «Ziel prüfen» misst, wie wahrscheinlich du mit deinen eigenen Planannahmen einen frei gewählten Zielbetrag erreichst. «Beides» rechnet beide gleichzeitig." />
|
||||
</div>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5 text-sm">
|
||||
{MODE_OPTIONS.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(m.value);
|
||||
clearResults();
|
||||
}}
|
||||
className={`rounded-md px-3 py-1 font-medium transition-colors ${
|
||||
mode === m.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">{modeHint}</p>
|
||||
</div>
|
||||
|
||||
{/* Erklärung */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||
<p className="mb-2">
|
||||
@@ -337,6 +322,12 @@ export function MonteCarloDialog({
|
||||
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
|
||||
Verläufe</strong> und zeigt, wie oft ein Betrag erreicht wird.
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Zwei Welten, ein Lauf:</strong> Gerechnet wird einmal um die{" "}
|
||||
<strong className="text-fg">historischen</strong> Renditen (wie der Markt bisher lief) und einmal um deine{" "}
|
||||
<strong className="text-fg">geplanten</strong> Werte (wie du rechnest). Aus beiden Verteilungen werden
|
||||
derselbe Plan-Endbetrag und dein Zielbetrag abgelesen – deshalb können sich die Zahlen nicht widersprechen.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-fg">Verteilung:</strong> Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so
|
||||
häufig wie in der Realität, nicht wie in der Glockenkurve), und alle riskanten Anlagen fallen in einem
|
||||
@@ -349,7 +340,7 @@ export function MonteCarloDialog({
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Szenarien
|
||||
<InfoBubble text="Wähle, für welche Szenarien dieses Plans gerechnet werden soll. Die Streuung und (bei «Planung prüfen») die historischen Renditen werden nur einmal je logischem Element erfasst und für alle Szenarien verwendet." />
|
||||
<InfoBubble text="Wähle, für welche Szenarien dieses Plans gerechnet werden soll. Streuung und historische Renditen werden nur einmal je logischem Element erfasst und für alle Szenarien verwendet." />
|
||||
</div>
|
||||
{loading && <p className="text-xs text-muted">Szenarien werden geladen…</p>}
|
||||
{loadError && <p className="text-xs text-danger">{loadError}</p>}
|
||||
@@ -371,7 +362,7 @@ export function MonteCarloDialog({
|
||||
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
|
||||
{!isLoaded && <span className="text-[11px] text-faint">lädt…</span>}
|
||||
</label>
|
||||
{checked && needsHistorical && typeof nachlass === "number" && (
|
||||
{checked && typeof nachlass === "number" && (
|
||||
<span className="text-[11px] text-muted" title="Planungs-Endbetrag dieses Szenarios (read-only)">
|
||||
Planungs-Endbetrag: <strong className="text-fg">{formatChf(Math.max(0, nachlass))}</strong>
|
||||
</span>
|
||||
@@ -383,46 +374,32 @@ export function MonteCarloDialog({
|
||||
{selected.length === 0 && <p className="mt-2 text-xs text-danger">Bitte mindestens ein Szenario auswählen.</p>}
|
||||
</div>
|
||||
|
||||
{/* Zielbetrag (Fall 2) */}
|
||||
{needsTarget && (
|
||||
<div className="sm:max-w-xs">
|
||||
<MoneyField
|
||||
label="Zielbetrag (Endvermögen)"
|
||||
help="Dein frei gewähltes Ziel. Geprüft wird, wie oft die Planung – mit deinen eigenen Annahmen und Schwankung – mindestens diesen Betrag erreicht. Gilt für alle ausgewählten Szenarien."
|
||||
value={manualTarget}
|
||||
onChange={(v) => {
|
||||
setManualTarget(v);
|
||||
clearResults();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Zielbetrag */}
|
||||
<div className="sm:max-w-xs">
|
||||
<MoneyField
|
||||
label="Zielbetrag (Endvermögen)"
|
||||
help="Dein frei gewähltes Ziel, nominal. Geprüft wird, wie oft es in beiden Welten mindestens erreicht wird. Gilt für alle ausgewählten Szenarien."
|
||||
value={manualTarget}
|
||||
onChange={(v) => {
|
||||
setManualTarget(v);
|
||||
clearResults();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inflation */}
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Inflation (Plan-Ebene)</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{needsHistorical ? (
|
||||
<RequiredNumberField
|
||||
label="Ø Inflation letzte 20 J. (%)"
|
||||
help={`Der Mittelpunkt, um den bei «Planung prüfen» gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
|
||||
value={inflMean}
|
||||
onChange={(v) => {
|
||||
setInflMean(v);
|
||||
clearResults();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||
Inflation (aus dem Plan)
|
||||
<InfoBubble text="Bei «Ziel prüfen» wird um die im Plan hinterlegte Inflation gewürfelt – kein historischer Wert nötig." />
|
||||
</label>
|
||||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||
{plan.inflationRateDefault} %
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<RequiredNumberField
|
||||
label="Ø Inflation letzte 20 J. (%)"
|
||||
help={`Der Mittelpunkt der historischen Welt. Die geplante Welt würfelt um deine Planannahme von ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
|
||||
value={inflMean}
|
||||
onChange={(v) => {
|
||||
setInflMean(v);
|
||||
clearResults();
|
||||
}}
|
||||
/>
|
||||
<SelectField
|
||||
label="Streuung"
|
||||
help={INFLATION_HELP}
|
||||
@@ -446,7 +423,7 @@ export function MonteCarloDialog({
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Renditetragende Elemente
|
||||
{selected.length > 1 && (
|
||||
<InfoBubble text="Ein Element, das in mehreren Szenarien vorkommt, erscheint hier nur einmal – erkannt über seine Herkunft beim Kopieren des Szenarios. Bei «Ziel prüfen» kommt der Mittelpunkt aus dem Plan; du wählst nur noch die Streuung." />
|
||||
<InfoBubble text="Ein Element, das in mehreren Szenarien vorkommt, erscheint hier nur einmal – erkannt über seine Herkunft beim Kopieren des Szenarios." />
|
||||
)}
|
||||
</div>
|
||||
{groups.length === 0 && (
|
||||
@@ -475,24 +452,12 @@ export function MonteCarloDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{needsHistorical ? (
|
||||
<RequiredNumberField
|
||||
label="Ø Rendite letzte 20 J. (%)"
|
||||
help="Der Mittelpunkt für «Planung prüfen» – die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
|
||||
value={d.mean}
|
||||
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||
Geplante Rendite (aus dem Plan)
|
||||
<InfoBubble text="Bei «Ziel prüfen» ist der Mittelpunkt die im Plan hinterlegte Rendite dieses Elements (erste Phase). Kein historischer Wert nötig." />
|
||||
</label>
|
||||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||
{planned} %
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<RequiredNumberField
|
||||
label="Ø Rendite letzte 20 J. (%)"
|
||||
help={`Der Mittelpunkt der historischen Welt – die realistische Rendite dieser Anlage. Deine Planung rechnet mit ${planned} %; damit läuft die geplante Welt.`}
|
||||
value={d.mean}
|
||||
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
|
||||
/>
|
||||
<SelectField
|
||||
label="Streuung"
|
||||
help={RETURN_HELP}
|
||||
@@ -530,14 +495,14 @@ export function MonteCarloDialog({
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}
|
||||
{passes > 1 ? "2 Durchgänge · " : ""}geschätzt ~{estSeconds} s
|
||||
2 Welten · geschätzt ~{estSeconds} s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{missing && (
|
||||
<p className="text-xs text-danger">
|
||||
{needsHistorical && missingHist
|
||||
{missingHist
|
||||
? "Bitte alle Pflichtfelder (historische Ø-Werte) ausfüllen."
|
||||
: "Bitte einen Zielbetrag grösser 0 angeben."}
|
||||
</p>
|
||||
@@ -559,15 +524,13 @@ export function MonteCarloDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(resA || resB) && (
|
||||
{outcomes && fanRes && (
|
||||
<MonteCarloResults
|
||||
mode={mode}
|
||||
resA={resA}
|
||||
resB={resB}
|
||||
manualTarget={manualTarget}
|
||||
outcomes={outcomes}
|
||||
fanRes={fanRes}
|
||||
detPoints={
|
||||
resA && resA.length === 1 && loaded[resA[0].scenarioId]
|
||||
? detPointsOf(loaded[resA[0].scenarioId].plan, loaded[resA[0].scenarioId].computed)
|
||||
fanRes.length === 1 && loaded[fanRes[0].scenarioId]
|
||||
? detPointsOf(loaded[fanRes[0].scenarioId].plan, loaded[fanRes[0].scenarioId].computed)
|
||||
: []
|
||||
}
|
||||
/>
|
||||
@@ -591,43 +554,53 @@ function ReadOnlySigma({ value }: { value: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Deutungstext Fall 1: wie realistisch ist die Planung? Weiche Formulierung, weil schon eine
|
||||
// faire Planung wegen des Volatilitäts-Drags leicht unter 50 % landet.
|
||||
function planVerdict(prob: number): { text: string; tone: "danger" | "success" | "muted" } {
|
||||
const pct = Math.round(prob * 100);
|
||||
if (prob < 0.5)
|
||||
// Toleranzband für das Urteil, in Prozentpunkten.
|
||||
const VERDICT_BAND = 5;
|
||||
|
||||
// Urteil über die Planung. Der Nullpunkt ist NICHT 50 %, sondern Fall 2: die Wahrscheinlichkeit,
|
||||
// den Plan-Endbetrag in der eigenen geplanten Welt zu erreichen. Die liegt wegen des
|
||||
// Volatilitäts-Drags systematisch unter 50 % (bei hoher Streuung deutlich). Verglichen wird
|
||||
// also Fall 1 gegen Fall 2 -- beides derselbe Schwellwert, nur andere Renditeannahme.
|
||||
function planVerdict(f1: number, f2: number): { text: string; tone: "danger" | "success" | "muted" } {
|
||||
const p1 = Math.round(f1 * 100);
|
||||
const diff = (f1 - f2) * 100;
|
||||
if (diff > VERDICT_BAND)
|
||||
return {
|
||||
text: `Deine Planung ist tendenziell zu optimistisch: Der geplante Endbetrag wird unter Berücksichtigung der historischen Renditen nur in ${pct} % der Fälle erreicht.`,
|
||||
text: `Deine Planung ist zurückhaltend gerechnet: Mit den historischen Renditen wird dein geplanter Endbetrag in ${p1} % der Fälle erreicht – häufiger als mit deinen eigenen Annahmen.`,
|
||||
tone: "success",
|
||||
};
|
||||
if (diff < -VERDICT_BAND)
|
||||
return {
|
||||
text: `Deine Planung ist tendenziell zu optimistisch: Mit den historischen Renditen wird dein geplanter Endbetrag nur in ${p1} % der Fälle erreicht – seltener als mit deinen eigenen Annahmen.`,
|
||||
tone: "danger",
|
||||
};
|
||||
return {
|
||||
text: `Deine Planung ist tendenziell realistisch bis vorsichtig: Der geplante Endbetrag wird unter Berücksichtigung der historischen Renditen in ${pct} % der Fälle erreicht.`,
|
||||
tone: "success",
|
||||
text: `Deine Planung ist realistisch gerechnet: Mit den historischen Renditen wird dein geplanter Endbetrag in ${p1} % der Fälle erreicht – praktisch gleich häufig wie mit deinen eigenen Annahmen.`,
|
||||
tone: "muted",
|
||||
};
|
||||
}
|
||||
|
||||
// Deutungstext Fall 2: erreiche ich mein Ziel?
|
||||
function targetVerdict(prob: number): { text: string; tone: "danger" | "success" | "muted" } {
|
||||
const pct = Math.round(prob * 100);
|
||||
if (prob > 0.5)
|
||||
return {
|
||||
text: `Mit deiner Planung solltest du dein Ziel tendenziell erreichen: Der gewünschte Zielbetrag wird in ${pct} % der Fälle erreicht.`,
|
||||
tone: "success",
|
||||
};
|
||||
return {
|
||||
text: `Mit deiner Planung erreichst du dein Ziel tendenziell nicht: Der gewünschte Zielbetrag wird nur in ${pct} % der Fälle erreicht.`,
|
||||
tone: "danger",
|
||||
};
|
||||
// Untergeordnetes Fall-3/4-Paar: dieselbe Zielschwelle, einmal in der geplanten und einmal in der
|
||||
// historischen Welt. Die Formulierung dreht automatisch, je nachdem welche Welt besser dasteht.
|
||||
function targetSentence(f3: number, f4: number): string {
|
||||
const p3 = Math.round(f3 * 100);
|
||||
const p4 = Math.round(f4 * 100);
|
||||
const planWord = f4 < f3 ? "schlecht" : "gut";
|
||||
const histWord = f4 < f3 ? "gut" : "zurückhaltend";
|
||||
return (
|
||||
`Wenn der Markt so «${planWord}» läuft, wie du es in deiner Planung vorgibst, liegt die Wahrscheinlichkeit, ` +
|
||||
`deinen Zielbetrag zu erreichen, bei ${p4} %. Läuft er so «${histWord}» wie in der Vergangenheit, liegt sie bei ${p3} %.`
|
||||
);
|
||||
}
|
||||
|
||||
function VerdictCard({ title, prob, verdict }: { title: string; prob: number; verdict: { text: string; tone: "danger" | "success" | "muted" } }) {
|
||||
const color = verdict.tone === "danger" ? "text-danger" : verdict.tone === "success" ? "text-success" : "text-fg";
|
||||
const border = verdict.tone === "danger" ? "border-danger bg-danger-soft" : "border-border bg-surface-2";
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 ${border}`}>
|
||||
<div className={`rounded-xl border p-4 ${border}`}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">{title}</span>
|
||||
<span className={`text-2xl font-semibold ${color}`}>{Math.round(prob * 100)} %</span>
|
||||
<span className={`text-3xl font-semibold ${color}`}>{Math.round(prob * 100)} %</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">{verdict.text}</p>
|
||||
</div>
|
||||
@@ -635,25 +608,18 @@ function VerdictCard({ title, prob, verdict }: { title: string; prob: number; ve
|
||||
}
|
||||
|
||||
function MonteCarloResults({
|
||||
mode,
|
||||
resA,
|
||||
resB,
|
||||
manualTarget,
|
||||
outcomes,
|
||||
fanRes,
|
||||
detPoints,
|
||||
}: {
|
||||
mode: McMode;
|
||||
resA: ScenarioMcResult[] | null; // Fall 1 (gegen Planungs-Endbetrag)
|
||||
resB: ScenarioMcResult[] | null; // Fall 2 (gegen Zielbetrag)
|
||||
manualTarget: number;
|
||||
outcomes: Outcome[];
|
||||
fanRes: ScenarioMcResult[]; // Fächer/Bänder aus der historischen Welt
|
||||
detPoints: { age: number; det: number }[];
|
||||
}) {
|
||||
// Der Fächer stammt aus Fall 1 (historische Renditen = das ehrliche Risikobild). Fehlt Fall 1
|
||||
// (reiner Ziel-Modus), nehmen wir Fall 2.
|
||||
const fanRes = resA ?? resB;
|
||||
const single = !!fanRes && fanRes.length === 1;
|
||||
const single = fanRes.length === 1;
|
||||
|
||||
const singleData = useMemo(() => {
|
||||
if (!fanRes || !single) return [];
|
||||
if (!single) return [];
|
||||
return fanRes[0].bands.map((b) => ({
|
||||
age: b.age,
|
||||
band: [b.p10, b.p90] as [number, number],
|
||||
@@ -663,7 +629,7 @@ function MonteCarloResults({
|
||||
}, [fanRes, single, detPoints]);
|
||||
|
||||
const compareData = useMemo(() => {
|
||||
if (!fanRes || single) return [];
|
||||
if (single) return [];
|
||||
const ages = Array.from(new Set(fanRes.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
|
||||
return ages.map((age) => {
|
||||
const row: Record<string, number | null> = { age };
|
||||
@@ -672,16 +638,32 @@ function MonteCarloResults({
|
||||
});
|
||||
}, [fanRes, single]);
|
||||
|
||||
const byId = (arr: ScenarioMcResult[] | null, id: string) => arr?.find((r) => r.scenarioId === id) ?? null;
|
||||
const rows = (resA ?? resB)!;
|
||||
const one = outcomes[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
||||
{/* Deutungstexte -- nur sinnvoll bei einem einzelnen Szenario; bei mehreren zählt die Tabelle. */}
|
||||
{/* Deutung -- nur bei einem einzelnen Szenario; bei mehreren zählt die Tabelle. */}
|
||||
{single && (
|
||||
<div className={`grid grid-cols-1 gap-3 ${mode === "BOTH" ? "sm:grid-cols-2" : ""}`}>
|
||||
{resA && <VerdictCard title="Realismus der Planung" prob={resA[0].successProbability} verdict={planVerdict(resA[0].successProbability)} />}
|
||||
{resB && <VerdictCard title="Zielerreichung" prob={resB[0].successProbability} verdict={targetVerdict(resB[0].successProbability)} />}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Prominent: Fall 1 */}
|
||||
<VerdictCard title="Realismus der Planung" prob={one.f1} verdict={planVerdict(one.f1, one.f2)} />
|
||||
|
||||
{/* Untergeordnet: Fall 3 und 4 als Paar */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-1 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Dein Zielbetrag {formatChf(one.target)}
|
||||
<InfoBubble text="Dieselbe Zielschwelle in beiden Welten. Fall 3 stammt aus derselben Verteilung wie das Urteil oben – liegt dein Ziel unter dem Plan-Endbetrag, kann seine Wahrscheinlichkeit deshalb nie tiefer sein." />
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted">{targetSentence(one.f3, one.f4)}</p>
|
||||
</div>
|
||||
|
||||
{/* Kleingedruckt: Fall 2 als Referenz plus typischer Ausgang je Welt */}
|
||||
<p className="text-[11px] leading-relaxed text-faint">
|
||||
Referenz: Mit deinen eigenen Planannahmen und Schwankung wird der Plan-Endbetrag von{" "}
|
||||
{formatChf(one.planEnd)} in {Math.round(one.f2 * 100)} % der Fälle erreicht – das ist der Nullpunkt für
|
||||
das Urteil oben, nicht 50 % (Schwankung frisst Rendite). Typischer Ausgang (Median):{" "}
|
||||
{formatChf(one.medianHist)} historisch, {formatChf(one.medianPlan)} nach Plan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -689,15 +671,15 @@ function MonteCarloResults({
|
||||
<div>
|
||||
<div className="mb-1 flex items-center text-xs font-semibold text-fg">
|
||||
Ergebnis je Szenario
|
||||
<InfoBubble text="«Planung erreicht» = P(geplanter Endbetrag wird erreicht) unter historischen Renditen. «Ziel erreicht» = P(Zielbetrag wird erreicht) unter den geplanten Werten. Ruin ist zielunabhängig und stammt aus dem realistischen (historischen) Durchgang." />
|
||||
<InfoBubble text="«Planung» = P(Plan-Endbetrag erreicht) in der historischen Welt (Fall 1), in Klammern derselbe Schwellwert in der geplanten Welt (Fall 2). «Ziel» = P(Zielbetrag erreicht), historisch (Fall 3) und nach Plan (Fall 4). Ruin, Bandbreite und Median stammen aus der historischen Welt." />
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
|
||||
{resA && <th className="px-3 py-2 text-right font-semibold">Planung erreicht</th>}
|
||||
{resB && <th className="px-3 py-2 text-right font-semibold">Ziel erreicht</th>}
|
||||
<th className="px-3 py-2 text-right font-semibold">Planung hist. (Plan)</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Ziel hist. (Plan)</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Ruin</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Pessim. (10 %)</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Median</th>
|
||||
@@ -705,21 +687,23 @@ function MonteCarloResults({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const a = byId(resA, r.scenarioId);
|
||||
const b = byId(resB, r.scenarioId);
|
||||
const fan = a ?? b!;
|
||||
{outcomes.map((o, i) => {
|
||||
const fan = fanRes.find((r) => r.scenarioId === o.scenarioId)!;
|
||||
return (
|
||||
<tr key={r.scenarioId} className="border-t border-border">
|
||||
<tr key={o.scenarioId} className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-2 font-medium text-fg">
|
||||
{!single && <span className="inline-block h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: PALETTE[i % PALETTE.length] }} />}
|
||||
{r.name}
|
||||
{o.name}
|
||||
</span>
|
||||
</td>
|
||||
{resA && <td className="px-3 py-2 text-right font-semibold text-success">{a ? `${Math.round(a.successProbability * 100)} %` : "–"}</td>}
|
||||
{resB && <td className="px-3 py-2 text-right font-semibold text-accent">{b ? `${Math.round(b.successProbability * 100)} %` : "–"}</td>}
|
||||
<td className="px-3 py-2 text-right font-semibold text-danger">{Math.round(fan.ruinProbability * 100)} %</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-success">
|
||||
{Math.round(o.f1 * 100)} % <span className="font-normal text-faint">({Math.round(o.f2 * 100)} %)</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-accent">
|
||||
{Math.round(o.f3 * 100)} % <span className="font-normal text-faint">({Math.round(o.f4 * 100)} %)</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-danger">{Math.round(o.ruin * 100)} %</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP10)}</td>
|
||||
<td className="px-3 py-2 text-right text-fg">{formatChf(fan.finalWealthMedian)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP90)}</td>
|
||||
@@ -730,17 +714,16 @@ function MonteCarloResults({
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
{rows[0].runs.toLocaleString("de-CH")} Läufe je Szenario · gemeinsamer Zufalls-Seed
|
||||
{resB ? ` · Zielbetrag ${formatChf(manualTarget)}` : ""}
|
||||
{one.runs.toLocaleString("de-CH")} Läufe je Szenario und Welt · gemeinsamer Zufalls-Seed · Zielbetrag{" "}
|
||||
{formatChf(one.target)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fächer */}
|
||||
{fanRes && (
|
||||
<div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-fg">
|
||||
{single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median nach Alter (nominal)"}
|
||||
{resA ? " · historische Renditen" : " · geplante Werte"}
|
||||
{" · historische Renditen"}
|
||||
</div>
|
||||
<p className="mb-2 text-[11px] text-muted">
|
||||
{single
|
||||
@@ -774,8 +757,7 @@ function MonteCarloResults({
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
defaultVolatilityLevel,
|
||||
paramsForScenario,
|
||||
plannedReturnOf,
|
||||
probabilityAtLeast,
|
||||
resolveRootElementId,
|
||||
runMonteCarlo,
|
||||
runMonteCarloMulti,
|
||||
@@ -282,6 +283,53 @@ describe("Monte Carlo: mehrere Szenarien", () => {
|
||||
expect(rHi.finalWealthMedian).toBeLessThan(rLo.finalWealthMedian);
|
||||
});
|
||||
|
||||
it("probabilityAtLeast entspricht exakt der Erfolgszählung des Laufs", async () => {
|
||||
const p = basePlan();
|
||||
const det = computePlan(p).nachlass;
|
||||
const r = await runMonteCarlo(p, params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } }, target: det }));
|
||||
// Aus derselben Verteilung abgelesen muss dieselbe Zahl herauskommen.
|
||||
expect(probabilityAtLeast(r.finalWealthSorted, det)).toBe(r.successProbability);
|
||||
// Randfälle.
|
||||
expect(probabilityAtLeast(r.finalWealthSorted, -Infinity)).toBe(1);
|
||||
expect(probabilityAtLeast(r.finalWealthSorted, Infinity)).toBe(0);
|
||||
expect(probabilityAtLeast([], 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("EINE Welt, mehrere Schwellen: tieferes Ziel ist NIE unwahrscheinlicher", async () => {
|
||||
// Das ist die strukturelle Garantie gegen den scheinbaren Widerspruch: Werden Plan-Endbetrag
|
||||
// und Zielbetrag aus DERSELBEN Verteilung abgelesen, kann ein tieferes Ziel nie seltener
|
||||
// erreicht werden als ein höheres.
|
||||
const p = basePlan();
|
||||
const det = computePlan(p).nachlass;
|
||||
const r = await runMonteCarlo(p, params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } }, target: det }));
|
||||
|
||||
const fallPlanEnd = probabilityAtLeast(r.finalWealthSorted, det);
|
||||
const fallZielTiefer = probabilityAtLeast(r.finalWealthSorted, det * 0.8);
|
||||
const fallZielHoeher = probabilityAtLeast(r.finalWealthSorted, det * 1.2);
|
||||
|
||||
expect(fallZielTiefer).toBeGreaterThanOrEqual(fallPlanEnd);
|
||||
expect(fallZielHoeher).toBeLessThanOrEqual(fallPlanEnd);
|
||||
// Zielbetrag == Plan-Endbetrag -> exakt identisch (nicht nur ungefähr).
|
||||
expect(probabilityAtLeast(r.finalWealthSorted, det)).toBe(fallPlanEnd);
|
||||
});
|
||||
|
||||
it("Fall 2 liegt systematisch UNTER 50 % -- der Volatilitäts-Drag", async () => {
|
||||
// Auch wenn die Annahmen exakt zutreffen (Mittelwert = Planrendite), wird der
|
||||
// deterministische Plan-Endbetrag in weniger als der Hälfte der Fälle erreicht. Deshalb ist
|
||||
// Fall 2 der richtige Nullpunkt für das Urteil "optimistisch/konservativ" -- nicht 50 %.
|
||||
const p = basePlan(); // Planrendite 5 %
|
||||
const det = computePlan(p).nachlass;
|
||||
|
||||
const wenigStreuung = await runMonteCarlo(p, params({ elements: { asset: { mean: 5, sigma: 6, floor: -100 } }, target: det }));
|
||||
const vielStreuung = await runMonteCarlo(p, params({ elements: { asset: { mean: 5, sigma: 25, floor: -100 } }, target: det }));
|
||||
|
||||
expect(wenigStreuung.successProbability).toBeLessThan(0.5);
|
||||
expect(vielStreuung.successProbability).toBeLessThan(0.5);
|
||||
// Je mehr Streuung, desto stärker der Drag.
|
||||
expect(vielStreuung.successProbability).toBeLessThan(wenigStreuung.successProbability);
|
||||
expect(vielStreuung.finalWealthMedian).toBeLessThan(wenigStreuung.finalWealthMedian);
|
||||
});
|
||||
|
||||
it("plannedReturnOf: erste gesetzte Phasenrendite, Wertsteigerung bei Immobilien", () => {
|
||||
const orderedIds = ["p1", "p2"];
|
||||
const asset: ElementInput = {
|
||||
|
||||
@@ -77,6 +77,24 @@ export interface MonteCarloResult {
|
||||
finalWealthP90: number;
|
||||
// Fächer über das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert.
|
||||
bands: { age: number; p10: number; p50: number; p90: number }[];
|
||||
// Alle Endvermögen des Laufs, aufsteigend sortiert. Damit lässt sich P(>= X) für JEDE
|
||||
// Schwelle nachträglich ablesen, ohne erneut zu simulieren -- Grundlage dafür, aus EINEM
|
||||
// Lauf mehrere Fragen zu beantworten (Plan-Endbetrag UND Zielbetrag, siehe 4.12.7).
|
||||
finalWealthSorted: number[];
|
||||
}
|
||||
|
||||
// Anteil der Läufe mit Endvermögen >= threshold. Binäre Suche auf der sortierten Liste;
|
||||
// identische Zählweise wie in runMonteCarlo (>=, nicht >).
|
||||
export function probabilityAtLeast(sortedAsc: number[], threshold: number): number {
|
||||
if (sortedAsc.length === 0) return 0;
|
||||
let lo = 0;
|
||||
let hi = sortedAsc.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (sortedAsc[mid] >= threshold) hi = mid;
|
||||
else lo = mid + 1;
|
||||
}
|
||||
return (sortedAsc.length - lo) / sortedAsc.length;
|
||||
}
|
||||
|
||||
// --- Mehrere Szenarien im selben Lauf vergleichen -------------------------------------
|
||||
@@ -377,5 +395,6 @@ export async function runMonteCarlo(
|
||||
finalWealthMedian: percentile(finalWealth, 0.5),
|
||||
finalWealthP90: percentile(finalWealth, 0.9),
|
||||
bands,
|
||||
finalWealthSorted: finalWealth,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user