Problem Statement
A savings goal (src/goals/service.ts) tells a user "you need X% APY to hit your target by date D" — a single deterministic line. Real returns are random, and the platform already holds years of ProtocolRate history and YieldSnapshot series it could use to answer the question the line hides: what is the actual probability of hitting this goal? A "required APY" that no realistic sequence achieves is a trap the user walks into with real money. This issue adds a Monte Carlo simulation module that turns goal feasibility and backtest results into probability distributions and confidence intervals, with the same rigor (and the same honest-failure discipline) as the rest of the analytics stack.
Current State
src/goals/service.ts computes required-rate projections using a simple non-compounding APY convention shared with src/agent/snapshotter.ts and src/agent/strategyMetrics.ts. src/agent/backtest.ts is a deterministic historical replay — one path, one answer, with forward-filled daily rate series and a documented gap policy.
ProtocolRate carries the historical APY/TVL record (with rawResponse from the collector); YieldSnapshot carries per-position value history (retained ~90 days). src/agent/riskScoring.ts already computes per-protocol volatility/trend factors — precedent for deriving distribution parameters from history.
- The backtest sandbox (
src/routes/backtest.ts) exposes runBacktest results (final value, max drawdown, realized APY) with no uncertainty banding.
Proposed Solution
1. Pure simulation core (src/analytics/montecarlo.ts or src/agent/)
A zero-I/O module, unit-tested and deterministic under a seed:
- Sampling model — two documented modes:
- Historical bootstrap: resample from the actual period-return series (with the same
<= 0 starting-value skip and gap-robust annualization rules as src/agent/strategyMetrics.ts), so no distributional assumption is imposed.
- Parametric: fit mean/σ to the period returns and draw from a lognormal/Student-t model (choice documented; the codebase's "state the assumption" discipline from
riskFreeRate applies).
- Engine: simulate the same decision loop as
runBacktest (per-day APY accrual, rebalance decisions per RebalanceStrategy.analyze, identical BigInt wei-amount handling and simple-rate conventions) — reusing the existing BacktestRequest/StrategyParams shapes so a Monte Carlo run is a distribution of backtests, not a parallel implementation.
- Outputs: per-path terminal values; mean/median/percentile bands (5/50/95) on terminal value and drawdown; probability of achieving a target amount by a target date (the goal question); convergence diagnostics (recommended iteration count, optional
seed for reproducibility). Report iterations, effectiveSampleSize if computable, and a converged flag — never present a noisy 1,000-path answer as if it were a fact.
2. Goal integration (src/goals/service.ts, src/routes/goals.ts)
- New endpoint
POST /api/v1/goals/:id/simulate (owner-scoped): given the goal's targetAmount, targetDate, startingAmount, and the user's effective strategy/allocation (same effective-config resolution as src/agent/effectiveStrategy.ts, including any active follow and the goal's own riskCeiling), returns:
attainmentProbability (fraction of paths that crossed the target by the target date),
- the median and 5/95 percentile projected balances at the target date,
- required-rate sensitivity (a short table: "at X% you have Y% chance" across the goal's feasible rate range) — so the user can see how much margin the target actually has.
- Validation: target date must be in the future; insufficient history must return an explicit
insufficient_history outcome (same shape as runBacktest's), not a guessed probability.
3. Backtest integration (src/routes/backtest.ts)
- Extend the backtest sandbox with an optional
?simulate=true&iterations=N&seed=S that returns the distribution plus the deterministic path overlaid, so the sandbox's headline number stops being a point estimate.
- Backward compatible: no
simulate param → identical output to today (regression tests).
4. Caching & performance
- Simulations are CPU-heavy and identical inputs are common. Cache results keyed by a canonical hash of the effective inputs (config, seed, rate-series window, iterations) using the Redis layer (
src/config/redis.ts); TTL bounded and configurable. The cache must never serve a stale-parameter answer — the key must include every input that changes the distribution.
- Cap
iterations (configurable max) to bound CPU and protect the sandbox from abuse; document the throughput budget in the PR.
5. Documentation & correctness disclosure
- Update
docs/ (and the goal/backtest docs) to state plainly: simulation assumes historical regimes persist; it is not a forecast or a promise; output is a distribution, not a guarantee. The response payload itself must carry this disclaimer (isSimulation: true, model), following the "stated assumption beats hidden default" precedent.
Edge Cases & Failure Modes
- Zero/negative start: skip-guard as in the rest of the analytics stack;
startingAmount <= 0 is a 400.
- Empty or gap-heavy history: forward-fill policy inherited from
buildDailyRateSeries; if the first observation is after the requested start, return insufficient_history with earliestAvailableDate.
- All paths identical (degenerate series): report zero variance honestly, and
converged: false — do not invent spread.
- Non-convergence at N iterations: surface
converged: false and recommend more iterations rather than hiding it.
- Concurrent simulations for the same user: the cache key + per-user request limits must prevent a burst of identical sims from pinning the process; consider a simple per-user concurrency bound (a semaphore per key), with an explicit
simulation_in_progress (or 429-style) response.
- Goal achieved mid-simulation: a path that crosses the target early still counts as achieved (and stops accruing), matching what a real investor would do.
Security & Privacy Considerations
- Simulations are owner-scoped; the effective-config resolver must not leak a followed publisher's config details into the response beyond what the goal endpoint already exposes.
- The simulation module stays pure (no stellar/db imports), enforced by the same import-graph test pattern as
src/agent/backtest.ts — a simulation must never be able to touch real funds.
- CPU abuse: rate limits on the simulate endpoints, per-user sim concurrency bound, and documented iteration caps.
Out of Scope
- Portfolio optimization (choosing weights) — this module only evaluates probability given a config (see the separate optimization issue if desired).
- Correlated multi-asset sampling beyond what the protocol series already imply (no covariance model in v1; document).
- ML regime models.
Suggested Implementation Plan
- Pure
montecarlo.ts core (bootstrap + parametric modes, seeded, determinism tests, degenerate cases).
- Reuse
runBacktest decision loop per path; verify distribution-of-backtests vs. point-backtest consistency with regression tests.
- Goal simulate endpoint + validation + disclaimer.
- Backtest sandbox
simulate extension (backward compatible).
- Redis caching + concurrency bounds + docs.
Acceptance Criteria
Problem Statement
A savings goal (
src/goals/service.ts) tells a user "you need X% APY to hit your target by date D" — a single deterministic line. Real returns are random, and the platform already holds years ofProtocolRatehistory andYieldSnapshotseries it could use to answer the question the line hides: what is the actual probability of hitting this goal? A "required APY" that no realistic sequence achieves is a trap the user walks into with real money. This issue adds a Monte Carlo simulation module that turns goal feasibility and backtest results into probability distributions and confidence intervals, with the same rigor (and the same honest-failure discipline) as the rest of the analytics stack.Current State
src/goals/service.tscomputes required-rate projections using a simple non-compounding APY convention shared withsrc/agent/snapshotter.tsandsrc/agent/strategyMetrics.ts.src/agent/backtest.tsis a deterministic historical replay — one path, one answer, with forward-filled daily rate series and a documented gap policy.ProtocolRatecarries the historical APY/TVL record (withrawResponsefrom the collector);YieldSnapshotcarries per-position value history (retained ~90 days).src/agent/riskScoring.tsalready computes per-protocol volatility/trend factors — precedent for deriving distribution parameters from history.src/routes/backtest.ts) exposesrunBacktestresults (final value, max drawdown, realized APY) with no uncertainty banding.Proposed Solution
1. Pure simulation core (
src/analytics/montecarlo.tsorsrc/agent/)A zero-I/O module, unit-tested and deterministic under a seed:
<= 0starting-value skip and gap-robust annualization rules assrc/agent/strategyMetrics.ts), so no distributional assumption is imposed.riskFreeRateapplies).runBacktest(per-day APY accrual, rebalance decisions perRebalanceStrategy.analyze, identical BigInt wei-amount handling and simple-rate conventions) — reusing the existingBacktestRequest/StrategyParamsshapes so a Monte Carlo run is a distribution of backtests, not a parallel implementation.seedfor reproducibility). Reportiterations,effectiveSampleSizeif computable, and aconvergedflag — never present a noisy 1,000-path answer as if it were a fact.2. Goal integration (
src/goals/service.ts,src/routes/goals.ts)POST /api/v1/goals/:id/simulate(owner-scoped): given the goal'stargetAmount,targetDate,startingAmount, and the user's effective strategy/allocation (same effective-config resolution assrc/agent/effectiveStrategy.ts, including any active follow and the goal's ownriskCeiling), returns:attainmentProbability(fraction of paths that crossed the target by the target date),insufficient_historyoutcome (same shape asrunBacktest's), not a guessed probability.3. Backtest integration (
src/routes/backtest.ts)?simulate=true&iterations=N&seed=Sthat returns the distribution plus the deterministic path overlaid, so the sandbox's headline number stops being a point estimate.simulateparam → identical output to today (regression tests).4. Caching & performance
src/config/redis.ts); TTL bounded and configurable. The cache must never serve a stale-parameter answer — the key must include every input that changes the distribution.iterations(configurable max) to bound CPU and protect the sandbox from abuse; document the throughput budget in the PR.5. Documentation & correctness disclosure
docs/(and the goal/backtest docs) to state plainly: simulation assumes historical regimes persist; it is not a forecast or a promise; output is a distribution, not a guarantee. The response payload itself must carry this disclaimer (isSimulation: true,model), following the "stated assumption beats hidden default" precedent.Edge Cases & Failure Modes
startingAmount <= 0is a 400.buildDailyRateSeries; if the first observation is after the requested start, returninsufficient_historywithearliestAvailableDate.converged: false— do not invent spread.converged: falseand recommend more iterations rather than hiding it.simulation_in_progress(or 429-style) response.Security & Privacy Considerations
src/agent/backtest.ts— a simulation must never be able to touch real funds.Out of Scope
Suggested Implementation Plan
montecarlo.tscore (bootstrap + parametric modes, seeded, determinism tests, degenerate cases).runBacktestdecision loop per path; verify distribution-of-backtests vs. point-backtest consistency with regression tests.simulateextension (backward compatible).Acceptance Criteria
attainmentProbability, convergence flag; degenerate/non-converged cases reported honestlyPOST /api/v1/goals/:id/simulatereturns probability + sensitivity table + explicitisSimulationdisclaimer;insufficient_historyhandledsimulateextension is fully backward compatible (regression tests)docs/openapi.yamlupdated; unit + integration tests green