Skip to content

Strategy What-If Simulation Endpoint (Dry-Run Before Apply) #344

Description

@devsimze

Problem Statement

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.tsresolveEffectiveConfig(own, followedConfig) merges a user's own config with a followed strategy, clamping riskCeiling to the stricter of the two.
  • src/agent/router.tscompareProtocols, estimateRebalanceCosts, executeRebalanceIfNeeded (the last one has the on-chain side effect via enqueueOutboxOp + dispatchInBackground).
  • src/agent/strategies.tsMaxYieldStrategy / 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 reads ProtocolRate history.
  • src/agent/strategyMetrics.ts + src/jobs/strategyMetrics.ts — computes Sharpe/return stats for published strategies (Strategy Marketplace / Opt-In Copy-Trading #285).
  • prisma/schema.prismaUser.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, 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:
    • rebalanceCount, turnoverRatio, totalFeesPaid, endingValue
    • timeSeries of simulated portfolio value
    • counterfactual: the same positions held with no strategy change, for a side-by-side

2. 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
}
  • Response: { immediate: DecisionTrace, historical: HistoricalResult, dataCaveats: [...] }.
  • 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 same estimateRebalanceCosts the live agent uses (or its successor from the fee-aware rebalancing issue), so the simulation cannot flatter itself with cheaper fees than production.
  • TARGET_ALLOCATION weights must sum to 100 (reject otherwise, matching Strategy Marketplace / Opt-In Copy-Trading #285 assumption 7).
  • 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.

Out of Scope

  • Monte Carlo / probabilistic outcomes (that is Monte Carlo Simulation & Goal Attainment Probability Module #319; this issue is a single deterministic historical replay).
  • 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

  1. src/agent/simulate.tssimulateImmediate (stubbed side effects, reuses router/strategy code) + unit tests asserting parity with the live decision trace and zero side effects.
  2. simulateHistorical — hour/day stepping over ProtocolRate, Decimal accrual, same cost model + counterfactual; determinism tests.
  3. POST /api/v1/strategy/simulate route + validator (weights sum to 100, window cap, mutually-exclusive follow vs. inline).
  4. Short-TTL cache keyed by simulationToken (reuse backtest cache).
  5. Optional confirmSimulationToken on PUT /api/v1/strategy.
  6. docs/openapi.yaml + docs/STRATEGY_MARKETPLACE.md / ASSUMPTIONS.md updates.

Acceptance Criteria

  • 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Stellar WaveIssues in the Stellar wave program

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions