ML predictions Phase 5: Monte Carlo contribution fix and milestone table
Fix contribution compounding: monthly contributions are now added to asset values before each GBM step so they grow with market returns, rather than being summed as a static lump at each period. Add year-by-year milestone table below the fan chart showing P10/P50/P90 portfolio values at each annual checkpoint up to the selected horizon. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
514d177b7b
commit
2940b120e0
3 changed files with 56 additions and 3 deletions
|
|
@ -100,14 +100,15 @@ def run_monte_carlo(
|
||||||
for sim in range(n_sims):
|
for sim in range(n_sims):
|
||||||
asset_values = current_values.copy()
|
asset_values = current_values.copy()
|
||||||
for t in range(n_months):
|
for t in range(n_months):
|
||||||
|
# Add monthly contribution before GBM step so it compounds
|
||||||
|
if monthly_contribution > 0:
|
||||||
|
asset_values = asset_values + weights * monthly_contribution
|
||||||
Z = rng.standard_normal(n_assets)
|
Z = rng.standard_normal(n_assets)
|
||||||
corr_Z = L @ Z
|
corr_Z = L @ Z
|
||||||
# GBM step for each asset
|
|
||||||
asset_values = asset_values * np.exp(
|
asset_values = asset_values * np.exp(
|
||||||
(mus - 0.5 * sigmas ** 2) * DT + sigmas * np.sqrt(DT) * corr_Z
|
(mus - 0.5 * sigmas ** 2) * DT + sigmas * np.sqrt(DT) * corr_Z
|
||||||
)
|
)
|
||||||
port_val = float(asset_values.sum()) + monthly_contribution * (t + 1)
|
portfolio_paths[sim, t] = max(0.0, float(asset_values.sum()))
|
||||||
portfolio_paths[sim, t] = max(0.0, port_val)
|
|
||||||
|
|
||||||
# Compute percentile paths
|
# Compute percentile paths
|
||||||
pcts = {
|
pcts = {
|
||||||
|
|
@ -122,6 +123,18 @@ def run_monte_carlo(
|
||||||
prob_gain = float(np.mean(final_values > total_value))
|
prob_gain = float(np.mean(final_values > total_value))
|
||||||
expected_value = float(np.median(final_values))
|
expected_value = float(np.median(final_values))
|
||||||
|
|
||||||
|
# Year-by-year milestones (at each 12-month boundary)
|
||||||
|
milestones = []
|
||||||
|
for yr in range(1, years + 1):
|
||||||
|
idx = min(yr * 12 - 1, n_months - 1)
|
||||||
|
milestones.append({
|
||||||
|
"year": yr,
|
||||||
|
"date": future_dates[idx],
|
||||||
|
"p10": round(float(np.percentile(portfolio_paths[:, idx], 10)), 2),
|
||||||
|
"p50": round(float(np.percentile(portfolio_paths[:, idx], 50)), 2),
|
||||||
|
"p90": round(float(np.percentile(portfolio_paths[:, idx], 90)), 2),
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"dates": future_dates,
|
"dates": future_dates,
|
||||||
"percentiles": {
|
"percentiles": {
|
||||||
|
|
@ -131,5 +144,6 @@ def run_monte_carlo(
|
||||||
"current_value": round(total_value, 2),
|
"current_value": round(total_value, 2),
|
||||||
"expected_value": round(expected_value, 2),
|
"expected_value": round(expected_value, 2),
|
||||||
"probability_of_gain": round(prob_gain, 3),
|
"probability_of_gain": round(prob_gain, 3),
|
||||||
|
"milestones": milestones,
|
||||||
"insufficient_data": False,
|
"insufficient_data": False,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ export interface PercentilePath {
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MonteCarloMilestone {
|
||||||
|
year: number;
|
||||||
|
date: string;
|
||||||
|
p10: number;
|
||||||
|
p50: number;
|
||||||
|
p90: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonteCarloResponse {
|
export interface MonteCarloResponse {
|
||||||
dates: string[];
|
dates: string[];
|
||||||
percentiles: {
|
percentiles: {
|
||||||
|
|
@ -40,6 +48,7 @@ export interface MonteCarloResponse {
|
||||||
current_value: number;
|
current_value: number;
|
||||||
expected_value: number;
|
expected_value: number;
|
||||||
probability_of_gain: number;
|
probability_of_gain: number;
|
||||||
|
milestones: MonteCarloMilestone[];
|
||||||
insufficient_data: boolean;
|
insufficient_data: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -525,6 +525,36 @@ function MonteCarloTab() {
|
||||||
style={{ width: "100%", height: "360px" }}
|
style={{ width: "100%", height: "360px" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Year-by-year milestone table */}
|
||||||
|
{data.milestones?.length > 0 && (
|
||||||
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<p className="text-sm font-semibold">Year-by-Year Projections</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">P10 = pessimistic · P50 = median · P90 = optimistic</p>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-secondary/30">
|
||||||
|
<th className="text-left px-5 py-2 text-xs text-muted-foreground font-medium">Year</th>
|
||||||
|
<th className="text-right px-5 py-2 text-xs text-destructive font-medium">P10</th>
|
||||||
|
<th className="text-right px-5 py-2 text-xs text-foreground font-medium">P50</th>
|
||||||
|
<th className="text-right px-5 py-2 text-xs text-success font-medium">P90</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.milestones.map((m, i) => (
|
||||||
|
<tr key={m.year} className={cn("border-b border-border last:border-0", i % 2 === 0 ? "" : "bg-secondary/10")}>
|
||||||
|
<td className="px-5 py-2.5 font-medium">Year {m.year}</td>
|
||||||
|
<td className="px-5 py-2.5 text-right tabular-nums text-destructive">{formatCurrency(m.p10, "GBP")}</td>
|
||||||
|
<td className="px-5 py-2.5 text-right tabular-nums font-semibold">{formatCurrency(m.p50, "GBP")}</td>
|
||||||
|
<td className="px-5 py-2.5 text-right tabular-nums text-success">{formatCurrency(m.p90, "GBP")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue