You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A user changes their strategy — rebalanceStrategy from MAX_YIELD to TARGET_ALLOCATION, a new strategyConfig.targetAllocations, a riskCeiling, or follows a published strategy (#285) — and finds out what it does only after the agent acts on real money at the next hourly tick. There is no way to ask "if I make this change, what would the agent do on my current positions, and what would it have done over the last 90 days?" The pieces to answer that already exist — resolveEffectiveConfig, the strategy classes in src/agent/strategies.ts, estimateRebalanceCosts, the ProtocolRate history, and the backtest engine (src/agent/backtest.ts) — they are just never composed into a dry-run. This issue adds a what-if simulation endpoint: submit a hypothetical strategy config, get back the immediate action the agent would take and a historical replay (rebalance count, turnover, fees paid, realized vs. counterfactual return), with zero side effects.
Current State
src/agent/effectiveStrategy.ts — resolveEffectiveConfig(own, followedConfig) merges a user's own config with a followed strategy, clamping riskCeiling to the stricter of the two.
src/agent/router.ts — compareProtocols, estimateRebalanceCosts, executeRebalanceIfNeeded (the last one has the on-chain side effect via enqueueOutboxOp + dispatchInBackground).
1. Pure simulation core (src/agent/simulate.ts, new)
simulateImmediate({ positions, effectiveConfig, thresholds, riskScores, asOf }) → runs the exact router/strategy decision path with all side effects stubbed (no outbox enqueue, no event publish, no AgentLog write). Returns the same decisionTrace shape as the explainable-rebalance issue (#… "Explainable Rebalance Decisions"): chosen protocol, ranked candidates, cost estimate, net improvement, whether it would fire.
simulateHistorical({ startingPositions, effectiveConfig, thresholds, window, priceSeries }) → steps hour-by-hour (or day-by-day) over retained ProtocolRate history, applying the strategy decision at each step, accruing yield per the platform's documented non-compounding APY convention, subtracting modeled rebalance costs, and recording every simulated rebalance. Returns:
Owner-scoped; operates only on the caller's current Position set and public ProtocolRate history. Rate-limited and cost-bounded (the historical replay is capped at SIMULATE_MAX_WINDOW_DAYS, default 180, and truncated with a caveat if retained history is shorter).
3. Honesty rules (consistent with ASSUMPTIONS.md / #285)
Yield accrual uses the same non-compounding APY convention as src/agent/snapshotter.ts / src/goals/service.ts — never the smoothed cumulative column.
Rebalance cost model is the sameestimateRebalanceCosts the live agent uses (or its successor from the fee-aware rebalancing issue), so the simulation cannot flatter itself with cheaper fees than production.
If history is missing for a protocol in the target set for part of the window, that gap is a dataCaveat and the protocol is treated as unavailable for those steps — never zero-filled.
The result is explicitly labeled "simulation — past protocol rates, not a forecast".
4. Optional "apply after preview"
The response includes an opaque simulationToken (hash of the submitted config + asOf); PUT /api/v1/strategy can accept { ...config, confirmSimulationToken } so a client can guarantee the user saw a preview of exactly what they're saving. Purely optional; not enforced.
Edge Cases & Failure Modes
No positions: immediate returns "no action — no active positions"; historical still runs from a hypothetical unit deposit if the client passes assumeInitialDeposit, otherwise returns empty with a caveat.
Window longer than retained data: truncate to available history, return dataCaveats: ["window truncated to N days (retention limit)"], never extrapolate.
Following a strategy that was unpublished: resolve against the last known appliedConfig if available, else 404 with a clear reason.
Goal-tracking simulation with no active goal: reject (GOAL_TRACKING needs a goal), matching live behavior.
Determinism: given the same inputs and the same retained history, two calls must return identical results — no wall-clock or RNG in the core (seed any tie-breaks).
Compute cost: an hour-step 180-day replay is ~4,300 steps × candidate ranking; bound it, run it off the request thread if needed, and cache by simulationToken for a short TTL (reuse src/agent/backtestCache.ts patterns).
Rounding drift over thousands of steps: accumulate in Decimal, not float, and reconcile the final value against the sum of legs.
Security & Privacy Considerations
Simulation reads only the caller's Position rows and public ProtocolRate — never another user's data, never a published strategy's follower list.
No side effects: assert in tests that a simulate call produces zero new OutboxOp, AgentLog, Transaction, or event rows.
followStrategyId simulation exposes only the followed appliedConfig (already visible to followers), not the publisher's identity or performance beyond what docs/STRATEGY_MARKETPLACE.md already exposes.
Rate-limited; the historical replay cost is capped and cached to prevent it being used as a CPU-exhaustion vector.
Simulating other users' portfolios or aggregate platform effects.
Forward-looking price forecasting.
Automatic strategy recommendation ("here's a better config") — this only evaluates a config the user supplies.
Suggested Implementation Plan
src/agent/simulate.ts — simulateImmediate (stubbed side effects, reuses router/strategy code) + unit tests asserting parity with the live decision trace and zero side effects.
simulateHistorical — hour/day stepping over ProtocolRate, Decimal accrual, same cost model + counterfactual; determinism tests.
POST /api/v1/strategy/simulate route + validator (weights sum to 100, window cap, mutually-exclusive follow vs. inline).
Short-TTL cache keyed by simulationToken (reuse backtest cache).
Optional confirmSimulationToken on PUT /api/v1/strategy.
POST /api/v1/strategy/simulate returns the immediate action the agent would take (same trace shape as the explainable-rebalance record) and a historical replay with rebalance count, turnover, fees, ending value, time series, and a no-change counterfactual
Zero side effects — a simulate call creates no outbox ops, agent logs, transactions, or events (asserted by test)
Yield accrual and rebalance costs use the same conventions/estimators as the live agent; results are labeled as simulation, not forecast
Window longer than retained history is truncated with an explicit dataCaveat; missing protocol history is treated as unavailable, never zero-filled
Deterministic: identical inputs + history → identical output
Historical replay is compute-bounded and cached by an opaque simulationToken; owner-scoped and rate-limited
docs/openapi.yaml + strategy docs updated; unit + integration tests green
Problem Statement
A user changes their strategy —
rebalanceStrategyfromMAX_YIELDtoTARGET_ALLOCATION, a newstrategyConfig.targetAllocations, ariskCeiling, or follows a published strategy (#285) — and finds out what it does only after the agent acts on real money at the next hourly tick. There is no way to ask "if I make this change, what would the agent do on my current positions, and what would it have done over the last 90 days?" The pieces to answer that already exist —resolveEffectiveConfig, the strategy classes insrc/agent/strategies.ts,estimateRebalanceCosts, theProtocolRatehistory, and the backtest engine (src/agent/backtest.ts) — they are just never composed into a dry-run. This issue adds a what-if simulation endpoint: submit a hypothetical strategy config, get back the immediate action the agent would take and a historical replay (rebalance count, turnover, fees paid, realized vs. counterfactual return), with zero side effects.Current State
src/agent/effectiveStrategy.ts—resolveEffectiveConfig(own, followedConfig)merges a user's own config with a followed strategy, clampingriskCeilingto the stricter of the two.src/agent/router.ts—compareProtocols,estimateRebalanceCosts,executeRebalanceIfNeeded(the last one has the on-chain side effect viaenqueueOutboxOp+dispatchInBackground).src/agent/strategies.ts—MaxYieldStrategy/TargetAllocationStrategy/GoalTrackingStrategy, pure-ish decision classes.src/agent/backtest.ts+src/agent/backtestCache.ts+src/routes/backtest.ts— a backtesting/simulation sandbox already exists (Strategy Backtesting & Simulation Sandbox #283) and readsProtocolRatehistory.src/agent/strategyMetrics.ts+src/jobs/strategyMetrics.ts— computes Sharpe/return stats for published strategies (Strategy Marketplace / Opt-In Copy-Trading #285).prisma/schema.prisma—User.rebalanceStrategy,User.strategyConfig(Json),SavingsGoal.riskCeiling,Position,ProtocolRate,YieldSnapshot.docs/STRATEGY_MARKETPLACE.md,ASSUMPTIONS.md(Strategy Marketplace / Opt-In Copy-Trading #285 notes on config serialization and the non-compounding APY convention).Proposed Solution
1. Pure simulation core (
src/agent/simulate.ts, new)simulateImmediate({ positions, effectiveConfig, thresholds, riskScores, asOf })→ runs the exact router/strategy decision path with all side effects stubbed (no outbox enqueue, no event publish, noAgentLogwrite). Returns the samedecisionTraceshape as the explainable-rebalance issue (#… "Explainable Rebalance Decisions"): chosen protocol, ranked candidates, cost estimate, net improvement, whether it would fire.simulateHistorical({ startingPositions, effectiveConfig, thresholds, window, priceSeries })→ steps hour-by-hour (or day-by-day) over retainedProtocolRatehistory, applying the strategy decision at each step, accruing yield per the platform's documented non-compounding APY convention, subtracting modeled rebalance costs, and recording every simulated rebalance. Returns:rebalanceCount,turnoverRatio,totalFeesPaid,endingValuetimeSeriesof simulated portfolio valuecounterfactual: the same positions held with no strategy change, for a side-by-side2. Endpoint
POST /api/v1/strategy/simulate— body:{ "strategy": "TARGET_ALLOCATION", // or MAX_YIELD | GOAL_TRACKING "targetAllocations": { "Blend": 50, "Stellar DEX": 30, "Luma": 20 }, "riskCeiling": 40, "followStrategyId": null, // mutually exclusive with the above "historyWindowDays": 90 }{ immediate: DecisionTrace, historical: HistoricalResult, dataCaveats: [...] }.Positionset and publicProtocolRatehistory. Rate-limited and cost-bounded (the historical replay is capped atSIMULATE_MAX_WINDOW_DAYS, default 180, and truncated with a caveat if retained history is shorter).3. Honesty rules (consistent with
ASSUMPTIONS.md/ #285)src/agent/snapshotter.ts/src/goals/service.ts— never the smoothed cumulative column.estimateRebalanceCoststhe live agent uses (or its successor from the fee-aware rebalancing issue), so the simulation cannot flatter itself with cheaper fees than production.TARGET_ALLOCATIONweights must sum to 100 (reject otherwise, matching Strategy Marketplace / Opt-In Copy-Trading #285 assumption 7).dataCaveatand the protocol is treated as unavailable for those steps — never zero-filled.4. Optional "apply after preview"
simulationToken(hash of the submitted config +asOf);PUT /api/v1/strategycan accept{ ...config, confirmSimulationToken }so a client can guarantee the user saw a preview of exactly what they're saving. Purely optional; not enforced.Edge Cases & Failure Modes
immediatereturns "no action — no active positions";historicalstill runs from a hypothetical unit deposit if the client passesassumeInitialDeposit, otherwise returns empty with a caveat.dataCaveats: ["window truncated to N days (retention limit)"], never extrapolate.appliedConfigif available, else 404 with a clear reason.GOAL_TRACKINGneeds a goal), matching live behavior.simulationTokenfor a short TTL (reusesrc/agent/backtestCache.tspatterns).Decimal, not float, and reconcile the final value against the sum of legs.Security & Privacy Considerations
Positionrows and publicProtocolRate— never another user's data, never a published strategy's follower list.OutboxOp,AgentLog,Transaction, or event rows.followStrategyIdsimulation exposes only the followedappliedConfig(already visible to followers), not the publisher's identity or performance beyond whatdocs/STRATEGY_MARKETPLACE.mdalready exposes.Out of Scope
Suggested Implementation Plan
src/agent/simulate.ts—simulateImmediate(stubbed side effects, reuses router/strategy code) + unit tests asserting parity with the live decision trace and zero side effects.simulateHistorical— hour/day stepping overProtocolRate,Decimalaccrual, same cost model + counterfactual; determinism tests.POST /api/v1/strategy/simulateroute + validator (weights sum to 100, window cap, mutually-exclusive follow vs. inline).simulationToken(reuse backtest cache).confirmSimulationTokenonPUT /api/v1/strategy.docs/openapi.yaml+docs/STRATEGY_MARKETPLACE.md/ASSUMPTIONS.mdupdates.Acceptance Criteria
POST /api/v1/strategy/simulatereturns the immediate action the agent would take (same trace shape as the explainable-rebalance record) and a historical replay with rebalance count, turnover, fees, ending value, time series, and a no-change counterfactualdataCaveat; missing protocol history is treated as unavailable, never zero-filledsimulationToken; owner-scoped and rate-limiteddocs/openapi.yaml+ strategy docs updated; unit + integration tests green