From 2940b120e0da5b52f5bfd5c6f87426fda8f075a0 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 28 Apr 2026 11:02:41 +0000 Subject: [PATCH] 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 --- backend/app/ml/monte_carlo.py | 20 +++++++++++-- frontend/src/api/predictions.ts | 9 ++++++ .../src/pages/predictions/PredictionsPage.tsx | 30 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/backend/app/ml/monte_carlo.py b/backend/app/ml/monte_carlo.py index 166b6c5..dc0a50c 100644 --- a/backend/app/ml/monte_carlo.py +++ b/backend/app/ml/monte_carlo.py @@ -100,14 +100,15 @@ def run_monte_carlo( for sim in range(n_sims): asset_values = current_values.copy() 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) corr_Z = L @ Z - # GBM step for each asset asset_values = asset_values * np.exp( (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, port_val) + portfolio_paths[sim, t] = max(0.0, float(asset_values.sum())) # Compute percentile paths pcts = { @@ -122,6 +123,18 @@ def run_monte_carlo( prob_gain = float(np.mean(final_values > total_value)) 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 { "dates": future_dates, "percentiles": { @@ -131,5 +144,6 @@ def run_monte_carlo( "current_value": round(total_value, 2), "expected_value": round(expected_value, 2), "probability_of_gain": round(prob_gain, 3), + "milestones": milestones, "insufficient_data": False, } diff --git a/frontend/src/api/predictions.ts b/frontend/src/api/predictions.ts index e1966de..6acc802 100644 --- a/frontend/src/api/predictions.ts +++ b/frontend/src/api/predictions.ts @@ -28,6 +28,14 @@ export interface PercentilePath { value: number; } +export interface MonteCarloMilestone { + year: number; + date: string; + p10: number; + p50: number; + p90: number; +} + export interface MonteCarloResponse { dates: string[]; percentiles: { @@ -40,6 +48,7 @@ export interface MonteCarloResponse { current_value: number; expected_value: number; probability_of_gain: number; + milestones: MonteCarloMilestone[]; insufficient_data: boolean; } diff --git a/frontend/src/pages/predictions/PredictionsPage.tsx b/frontend/src/pages/predictions/PredictionsPage.tsx index ceb6c3b..54f2a68 100644 --- a/frontend/src/pages/predictions/PredictionsPage.tsx +++ b/frontend/src/pages/predictions/PredictionsPage.tsx @@ -525,6 +525,36 @@ function MonteCarloTab() { style={{ width: "100%", height: "360px" }} /> + + {/* Year-by-year milestone table */} + {data.milestones?.length > 0 && ( +
+
+

Year-by-Year Projections

+

P10 = pessimistic · P50 = median · P90 = optimistic

+
+ + + + + + + + + + + {data.milestones.map((m, i) => ( + + + + + + + ))} + +
YearP10P50P90
Year {m.year}{formatCurrency(m.p10, "GBP")}{formatCurrency(m.p50, "GBP")}{formatCurrency(m.p90, "GBP")}
+
+ )} )}