diff --git a/ASSUMPTIONS.md b/ASSUMPTIONS.md index e2191f1..3b75da1 100644 --- a/ASSUMPTIONS.md +++ b/ASSUMPTIONS.md @@ -116,6 +116,43 @@ riskCeiling? }`** — the three keys `src/agent/loop.ts` actually reads. --- +## Issue #344 — Strategy What-If Simulation (`POST /strategies/simulate`) + +1. **The endpoint is a pure read — zero side effects.** It never writes an + `OutboxOp`, `AgentLog`, `Transaction`, `RebalanceDecision`, `User`, + `PublishedStrategy`, or `Position` row. `src/strategy/simulation-service.ts` + enforces this by convention; `src/agent/simulate.ts` enforces it + **structurally** (no `src/stellar/*` import, no db, no outbox/event writer), + verified by a test that reads the module source. +2. **All accrual math is `Prisma.Decimal`.** Daily return is simple + (non-compounding) `apy/100/365.25` applied to the running value — + deliberately not the compounding the backtest engine uses, and never a plain + float. See the #344 request: momentum must never come from float drift. +3. **The historical leg reuses the live cost model with the backtest engine's + amount encoding.** Move amounts are encoded as `value × 10^18` (the + `src/agent/backtest.ts` convention) so `estimateRebalanceCost` divides back + to human units and yields realistic fee percentages. We deliberately do NOT + mirror the latent plain-integer encoding in `src/agent/tools/actionTools.ts:265` + (which would drive network-fee percent toward ~1e15 and block every move). +4. **The counterfactual is a clean hold.** Same starting value in the same + starting protocol for the whole window, never rebalancing, never paying fees. + It answers "what if I had just left it alone?" in direct side-by-side with + the strategy leg. +5. **A protocol with no retained history is treated as unavailable — never + zero-filled or extrapolated.** Missing protocols and a truncated window are + surfaced as `dataCaveats`, not silently hidden. +6. **Replay bounds.** `historyWindowDays` is capped at 180 + (`SIMULATE_MAX_WINDOW_DAYS`) at both the validator and the service, and the + replay series is clamped at both ends to retained observations. +7. **Resolution precedence and risk tightening** follow `resolveEffectiveConfig` + (#285): followed config → submitted inline config → caller's own config, and + the effective risk ceiling is always the stricter of the caller's and any + applied ceiling. A simulation can only tighten exposure. +8. **Short-TTL cache.** Results are cached 120 s under + `strategy-simulate:{userId}:{simulationToken}` where `simulationToken` is a + sha-256 of the canonical config + window, so identical previews are cheap and + never leak across users. + ## Issue #316 — Authenticated Real-Time WebSocket Streaming 1. **The durable stream is Postgres, not a Redis Stream.** `src/config/redis.ts` diff --git a/docs/STRATEGY_MARKETPLACE.md b/docs/STRATEGY_MARKETPLACE.md index b051ad5..00ed5a3 100644 --- a/docs/STRATEGY_MARKETPLACE.md +++ b/docs/STRATEGY_MARKETPLACE.md @@ -271,6 +271,7 @@ deprecated `/api/strategies` alias) via the `apiRoutes` table in `src/index.ts`. | `GET` | `/strategies/following` | The caller's active follow, or `{ follow: null }` | | `POST` | `/strategies/:id/follow` | | | `POST` | `/strategies/:id/unfollow` | Also releases an orphaned follow | +| `POST` | `/strategies/simulate` | #344 dry-run what-if (see § 6.1) | **Not** `/api/agent/strategies/*` as the issue proposed: `src/routes/agent.ts` sits behind `internalAuthGuard`, an operator/machine guard that authenticates by @@ -291,6 +292,60 @@ shape of `src/routes/transactions.ts`. --- +## 6.1 What-If simulation (`POST /strategies/simulate`, #344) + +A **dry-run** of a hypothetical strategy change before the user commits to it. +It is a pure read endpoint — **zero side effects**: it never writes an +`OutboxOp`, `AgentLog`, `Transaction`, `RebalanceDecision`, `User`, +`PublishedStrategy` or `Position` row. Reads only the caller's own +`Position` rows plus the public `ProtocolRate` / `ProtocolRiskScore` tables, +and the same public protocol scan the agent loop uses. + +| Field | Type | Notes | +| ----- | ---- | ----- | +| `strategy` | enum | `MAX_YIELD` \| `TARGET_ALLOCATION` \| `GOAL_TRACKING` | +| `targetAllocations` | map | Protocol → weight (%). Must sum to 100 after resolution. | +| `riskCeiling` | int 0–100 | Strictly clamped against the caller's own / follow ceiling. | +| `followStrategyId` | uuid | Mutually exclusive with an inline `strategy` config. | +| `historyWindowDays` | int 1–180 | Default 90. Capped at `SIMULATE_MAX_WINDOW_DAYS` (180). | +| `assumeInitialDeposit` | bool | When the caller has no positions, replay a nominal $1000. | + +**Resolution precedence** (mirrors `resolveEffectiveConfig`, #285): followed +config (current follow, or the strategy named by `followStrategyId`) → submitted +inline config → the caller's own config. The effective risk ceiling is always +the **stricter** of the caller's own and any applied ceiling — a simulation may +only tighten exposure, never widen it. + +The response returns two legs plus an opaque `simulationToken` (a sha-256 of the +canonical config + window): + +* `immediate` — what the agent would do *right now* on the caller's live + positions (`rebalance` / `hold` / `blocked`) plus a `trace` shape-parity with + the persisted `DecisionTrace`. +* `historical` — a non-compounding daily replay over the retained `ProtocolRate` + history, side-by-side with a **counterfactual** leg (same starting value in + the same holding protocol, never rebalancing, never paying fees). Rebalances + subtract the **same** `estimateRebalanceCost` the live agent uses. + +Rates are computed with `Prisma.Decimal` throughout; the historical leg's move +amounts are encoded as `value × 10^18` (the same convention as `src/agent/backtest.ts`) +so the shared cost model divides back to human units and yields realistic fees — +deliberately *not* the latent plain-integer encoding in `actionTools.ts:265`. + +**Data caveats** surface instead of fabrications: a window is truncated to the +available retained history, and a protocol with no history in the window is +treated as unavailable (never zero-filled) — both reported in `dataCaveats`. + +Short-TTL result cache (120 s) is keyed by +`strategy-simulate:{userId}:{simulationToken}`. + +Errors: `400 SimulationValidationError` (weights ≠ 100, `GOAL_TRACKING` without +an active goal, cap/mutual-exclusion violations), `404 SimulationNotFoundError` +(unknown owner or unpublished follow target), `429` rate-limited +(`simulateRateLimiter`, 6 req/min). + +--- + ## 7. Notifications Two events on the `WEBHOOK_EVENTS` tuple in diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1b0e239..2d14709 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -260,6 +260,165 @@ components: must be an affirmative or negative reply — it is interpreted as the confirmation decision, not a new request. + # ── Strategy What-If simulation (#344) ──────────────────────────────────── + StrategySimulateRequest: + type: object + additionalProperties: false + properties: + strategy: + type: string + enum: [MAX_YIELD, TARGET_ALLOCATION, GOAL_TRACKING] + nullable: true + description: Strategy to simulate. Mutually exclusive with followStrategyId. + targetAllocations: + type: object + additionalProperties: + type: number + minimum: 0 + maximum: 100 + description: | + Protocol name → target weight (%). After config resolution these + must sum to 100 for TARGET_ALLOCATION, enforced in the service. + riskCeiling: + type: integer + minimum: 0 + maximum: 100 + description: | + Risk ceiling to simulate. Always clamped to the stricter of the + caller's own and any applied ceiling. + followStrategyId: + type: string + format: uuid + nullable: true + description: | + Simulate the config of a published strategy to follow. Mutually + exclusive with the inline strategy/targetAllocations/riskCeiling. + historyWindowDays: + type: integer + minimum: 1 + maximum: 180 + default: 90 + description: Historical replay window; capped at 180 days. + assumeInitialDeposit: + type: boolean + description: | + When the caller has no active positions, replay a nominal $1000 + instead of zero. + + StrategySimulateResponse: + type: object + required: [immediate, historical, simulationToken, asOf, effectiveConfig, dataCaveats, label] + properties: + immediate: + $ref: '#/components/schemas/StrategyImmediateDecision' + historical: + $ref: '#/components/schemas/StrategyHistoricalReplay' + simulationToken: + type: string + description: Opaque sha-256 binding the preview to the exact config + window. + asOf: + type: string + format: date-time + effectiveConfig: + type: object + properties: + strategyName: + type: string + enum: [MAX_YIELD, TARGET_ALLOCATION, GOAL_TRACKING] + nullable: true + targetAllocations: + type: object + additionalProperties: + type: number + riskCeiling: + type: integer + nullable: true + dataCaveats: + type: array + items: + type: string + description: Window-truncation and missing-protocol caveats, never silent. + label: + type: string + + StrategyImmediateDecision: + type: object + required: [action, targetProtocol, moves, trace, reasoning] + properties: + action: + type: string + enum: [rebalance, hold, blocked] + targetProtocol: + type: string + nullable: true + moves: + type: array + items: + type: object + properties: + toProtocol: + type: string + fraction: + type: number + trace: + type: object + description: Shape-parity with the persisted DecisionTrace. + reasoning: + type: string + + StrategyHistoricalReplay: + type: object + required: + - rebalanceCount + - turnoverRatio + - totalFeesPaid + - endingValue + - startingValue + - counterfactualEndingValue + - finalProtocol + - timeSeries + - realizedGainPct + - counterfactualGainPct + - dataCaveats + properties: + rebalanceCount: + type: integer + turnoverRatio: + type: number + totalFeesPaid: + type: string + endingValue: + type: string + startingValue: + type: string + counterfactualEndingValue: + type: string + finalProtocol: + type: string + nullable: true + timeSeries: + type: array + items: + type: object + properties: + date: + type: string + format: date + simulatedValue: + type: string + counterfactualValue: + type: string + realizedGainPct: + type: number + nullable: true + counterfactualGainPct: + type: number + nullable: true + dataCaveats: + type: array + items: + type: string + # ── Risk metrics ───────────────────────────────────────────────────────── RiskMetrics: type: object @@ -861,6 +1020,66 @@ paths: '401': description: Missing/invalid bearer token, or no permission on the requested sub-account. + # ── Strategy What-If simulation (#344) ───────────────────────────────────── + + /strategies/simulate: + post: + operationId: simulateStrategy + summary: Dry-run a hypothetical strategy change (what-if) + description: | + Simulates a hypothetical strategy config on the caller's current + positions and public `ProtocolRate` history without applying it. Pure + read — **zero side effects** (no outbox/event/log/transaction/DB write). + + Returns two legs: + * `immediate` — what the agent would do right now (`rebalance` / + `hold` / `blocked`) plus a `trace` shape-parity with the persisted + `DecisionTrace`. + * `historical` — a non-compounding daily replay over retained history, + side-by-side with a counterfactual "held in the starting protocol" + leg; rebalances subtract the live `estimateRebalanceCost`. + + Resolution precedence: followed config (current follow or + `followStrategyId`) → submitted inline config → the caller's own config. + The effective risk ceiling is always the stricter of the caller's and + any applied ceiling. `followStrategyId` is mutually exclusive with an + inline `strategy`/`targetAllocations`/`riskCeiling`. + tags: [Strategy] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StrategySimulateRequest' + examples: + what_if: + summary: Inline what-if + value: + strategy: MAX_YIELD + riskCeiling: 70 + historyWindowDays: 90 + follow_target: + summary: Simulate the config of a followed strategy + value: + followStrategyId: cccccccc-cccc-4ccc-8ccc-cccccccccccc + responses: + '200': + description: The what-if preview. + content: + application/json: + schema: + $ref: '#/components/schemas/StrategySimulateResponse' + '400': + description: SimulationValidationError (weights do not sum to 100, GOAL_TRACKING without an active goal, window over cap, follow/inline conflict). + '401': + description: Missing/invalid bearer token. + '404': + description: SimulationNotFoundError (unknown owner or unpublished follow target). + '429': + description: Rate limited (simulateRateLimiter, 6 req/min). + # ── Risk endpoints ─────────────────────────────────────────────────────────── /analytics/risk: diff --git a/src/agent/simulate.ts b/src/agent/simulate.ts new file mode 100644 index 0000000..ddd05ad --- /dev/null +++ b/src/agent/simulate.ts @@ -0,0 +1,648 @@ +/** + * Strategy What-If Simulation core (#344) — dry-run before apply. + * + * Two pure entry points composed by the owner-scoped service (src/strategy/ + * simulation-service.ts): + * + * simulateImmediate — run the EXACT decision path (strategy.analyze + + * DecisionTrace shaping) the live agent uses, with every + * side effect stubbed: no outbox enqueue, no event + * publish, no AgentLog write, no RebalanceDecision row. + * The returned trace is the same shape the explainable- + * rebalance ledger persists, so a client that previews a + * config sees exactly what the agent WOULD persist. + * + * simulateHistorical — replays the strategy day-by-day over retained + * ProtocolRate history, accruing yield with Decimal + * (per-leg, then reconciled) and subtracting the SAME + * rebalance-cost estimator the live agent uses, so the + * simulation cannot flatter itself with cheaper fees + * than production. + * + * HONESTY RULES (mirror docs/ASSUMPTIONS.md / #285): + * * Yield accrual uses the non-compounding APY convention — never a smoothed + * cumulative column or compound interest. + * * Missing protocol history is treated as UNAVAILABLE for those steps, never + * zero-filled. + * * The result is labelled "simulation — past protocol rates, not a forecast". + * * Deterministic: no wall-clock, no RNG. Identical inputs + identical + * history => identical output. + * + * STRUCTURAL GUARANTEE: this module never imports a Stellar/outbox/event + * writer and never touches db — it is pure decision + arithmetic so the + * zero-side-effects contract is enforced structurally, not by convention. + */ + +import { createHash } from 'crypto' +import { Decimal } from '@prisma/client/runtime/library' +import { + RebalanceStrategy, + StrategyName, + StrategyParams, + RebalanceThresholds, + UserStrategyPreferences, + YieldProtocol, + DecisionTrace, + RankedCandidate, +} from './types' +import { EffectiveStrategyConfig } from './effectiveStrategy' +import { + MaxYieldStrategy, + TargetAllocationStrategy, + GoalTrackingStrategy, +} from './strategies' +import { estimateRebalanceCost } from './rebalanceCost' +import { + buildDailyRateSeries, + DailyRateSnapshot, + RawProtocolRatePoint, +} from './backtest' + +/** Default thresholds when the caller does not supply them. */ +export const DEFAULT_SIMULATE_THRESHOLDS: RebalanceThresholds = { + minimumImprovement: 0.5, + maxGasPercent: 0.1, +} + +/** Hard cap on any single historical replay window (days). */ +export const SIMULATE_MAX_WINDOW_DAYS = 180 + +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const MS_PER_YEAR = 365.25 * MS_PER_DAY + +/** Disclaimer shipped with every simulation result. */ +export const SIMULATION_LABEL = + 'Simulation based on past protocol rates — not a forecast of future returns.' + +/** Resolve the strategy instance for an effective config name (default MAX_YIELD). */ +export function resolveSimulationStrategy( + strategyName: StrategyName | null +): RebalanceStrategy { + switch (strategyName) { + case 'TARGET_ALLOCATION': + return new TargetAllocationStrategy() + case 'GOAL_TRACKING': + return new GoalTrackingStrategy() + case 'MAX_YIELD': + default: + return new MaxYieldStrategy() + } +} + +// ── Immediate decision ─────────────────────────────────────────────────────── + +export interface SimulateImmediateInput { + /** The caller's current ACTIVE positions (reads happen in the service). */ + positions: Array<{ protocolName: string; currentValue: string }> + effectiveConfig: EffectiveStrategyConfig + thresholds?: RebalanceThresholds + /** Protocol risk scores keyed by name; empty when no ceiling is in effect. */ + riskScores?: Record + /** The current scanned protocol universe (sorted by APY, as the agent sees). */ + availableProtocols: YieldProtocol[] + /** Active SavingsGoal driving GOAL_TRACKING; undefined means no active goal. */ + goal?: StrategyParams['goal'] + /** Point-in-time label for the preview; never used in the decision itself. */ + asOf: Date +} + +export interface ImmediateSimulationResult { + action: 'rebalance' | 'hold' | 'blocked' + targetProtocol: string | null + moves: Array<{ toProtocol: string; fraction: number }> + trace: DecisionTrace + reasoning: string +} + +/** + * Rebuild the DecisionTrace exactly as router.ts's buildStrategyTrace does, so + * the preview is shape-identical to the persisted explainable-decision record. + * Pure — reads only the strategy's returned decision + the traced set. + */ +export function buildSimulationTrace( + decision: { + details?: Record + candidates?: RankedCandidate[] + shouldRebalance: boolean + targetProtocol: string + }, + currentApyVal: number | null, + thresholds: RebalanceThresholds +): DecisionTrace { + const details = decision.details ?? {} + const candidates = decision.candidates ?? [] + const chosenName: string | null = decision.shouldRebalance + ? decision.targetProtocol + : null + const chosenCandidate = + candidates.find((c) => c.protocol === chosenName) ?? null + const chosenApy: number | null = + chosenCandidate?.apy ?? + (typeof details.bestApy === 'number' ? details.bestApy : null) + const rawImprovement: number | null = + typeof details.rawImprovement === 'number' ? details.rawImprovement : null + const netImprovement: number | null = + typeof details.netImprovement === 'number' ? details.netImprovement : null + const costBreakdown: Record | null = + (details.costBreakdown as Record | undefined) ?? null + const estCostPercent: number | null = + costBreakdown && typeof costBreakdown.totalCostPct === 'number' + ? costBreakdown.totalCostPct + : typeof details.totalCostPercent === 'number' + ? details.totalCostPercent + : null + return { + currentApy: currentApyVal, + chosenProtocol: chosenName, + chosenApy, + rawImprovement, + netImprovement, + estCostPercent, + costBreakdown, + thresholds, + candidates, + } +} + +/** + * Shape StrategyParams the way the live engine receives them for a single + * (protocol, value) leg — mirroring src/agent/loop.ts's preference shaping + * plus the goal contract. + */ +function buildSimulationParams(args: { + currentProtocol: string + totalAmount: string + currentApy: number + availableProtocols: YieldProtocol[] + effectiveConfig: EffectiveStrategyConfig + thresholds: RebalanceThresholds + riskScores?: Record + goal?: StrategyParams['goal'] + userId: string +}): StrategyParams { + const prefs: UserStrategyPreferences[] = args.effectiveConfig + .targetAllocations + ? [ + { + userId: args.userId, + strategyName: args.effectiveConfig.strategyName, + targetAllocations: args.effectiveConfig.targetAllocations, + riskCeiling: args.effectiveConfig.riskCeiling, + exposureCaps: args.effectiveConfig.exposureCaps, + defaultMaxFraction: args.effectiveConfig.defaultMaxFraction, + }, + ] + : [] + return { + currentProtocol: args.currentProtocol, + totalAmount: args.totalAmount, + currentApy: args.currentApy, + availableProtocols: args.availableProtocols, + thresholds: args.thresholds, + userStrategyPreferences: prefs, + riskCeiling: args.effectiveConfig.riskCeiling, + protocolRiskScores: + args.effectiveConfig.riskCeiling !== undefined && args.riskScores + ? args.riskScores + : undefined, + goal: args.goal, + exposure: undefined, + } +} + +function emptyTrace(thresholds: RebalanceThresholds): DecisionTrace { + return { + currentApy: null, + chosenProtocol: null, + chosenApy: null, + rawImprovement: null, + netImprovement: null, + estCostPercent: null, + costBreakdown: null, + thresholds, + candidates: [], + } +} + +/** + * Run the immediate decision the agent would take on the caller's current + * positions under the supplied effective config. Zero side effects. + * + * For each distinct protocol the user holds the agent would evaluate a move; + * this returns the action for the highest-value leg (the live loop moves one + * protocol's holdings at a time). GOAL_TRACKING without an active goal blocks, + * matching live behavior. + */ +export async function simulateImmediate( + input: SimulateImmediateInput +): Promise { + const thresholds = input.thresholds ?? DEFAULT_SIMULATE_THRESHOLDS + + if (input.effectiveConfig.strategyName === 'GOAL_TRACKING' && !input.goal) { + return { + action: 'blocked', + targetProtocol: null, + moves: [], + trace: emptyTrace(thresholds), + reasoning: 'GOAL_TRACKING requires an active savings goal', + } + } + + if (input.positions.length === 0) { + return { + action: 'hold', + targetProtocol: null, + moves: [], + trace: emptyTrace(thresholds), + reasoning: 'No active positions — nothing for the agent to act on', + } + } + + // Evaluate the highest-value protocol first. + const byProtocol = new Map() + for (const p of input.positions) { + byProtocol.set( + p.protocolName, + (byProtocol.get(p.protocolName) ?? new Decimal(0)).plus(p.currentValue) + ) + } + const currentProtocol = [...byProtocol.entries()].sort((a, b) => + b[1].comparedTo(a[1]) + )[0][0] + + const rate = input.availableProtocols.find((p) => p.name === currentProtocol) + const currentApy = rate?.apy ?? 0 + + const params = buildSimulationParams({ + currentProtocol, + totalAmount: amountToWeiLike(byProtocol.get(currentProtocol)!), + currentApy, + availableProtocols: input.availableProtocols, + effectiveConfig: input.effectiveConfig, + thresholds, + riskScores: input.riskScores, + goal: input.goal, + userId: '', + }) + + const strategy = resolveSimulationStrategy(input.effectiveConfig.strategyName) + const decision = await strategy.analyze(params) + const trace = buildSimulationTrace(decision, currentApy, thresholds) + + if ( + !decision.shouldRebalance || + decision.targetProtocol === currentProtocol + ) { + return { + action: decision.blockedReason ? 'blocked' : 'hold', + targetProtocol: null, + moves: [], + trace, + reasoning: decision.reasoning, + } + } + + return { + action: 'rebalance', + targetProtocol: decision.targetProtocol, + moves: [{ toProtocol: decision.targetProtocol, fraction: 1 }], + trace, + reasoning: decision.reasoning, + } +} + +// ── Historical replay ──────────────────────────────────────────────────────── + +export interface SimulateHistoricalInput { + /** Value being simulated (current position value or a hypothetical deposit). */ + startingAmount: string + /** Protocol the value starts in at the start of the window (null => best). */ + startingProtocol: string | null + effectiveConfig: EffectiveStrategyConfig + thresholds?: RebalanceThresholds + riskScores?: Record + /** Already-built daily rate snapshots (retained history, in date order). */ + dailyRates: DailyRateSnapshot[] + /** Upstream data caveats (window truncation, protocol gaps) to carry through. */ + dataCaveats?: string[] + goal?: StrategyParams['goal'] + userId: string +} + +export interface HistoricalTimeSeriesPoint { + date: string // YYYY-MM-DD + simulatedValue: string + counterfactualValue: string +} + +export interface HistoricalSimulationResult { + rebalanceCount: number + turnoverRatio: number + totalFeesPaid: string + endingValue: string + startingValue: string + counterfactualEndingValue: string + finalProtocol: string | null + timeSeries: HistoricalTimeSeriesPoint[] + realizedGainPct: number | null + counterfactualGainPct: number | null + dataCaveats: string[] +} + +/** + * Replay the strategy over retained history from a starting amount/protocol. + * + * The counterfactual holds the SAME starting value in the SAME starting + * protocol for the whole window (no rebalancing, no fees) — a clean side-by-side + * with the strategy leg. Both legs accrue non-compounding APY in Decimal per + * day; the strategy leg subtracts the live `estimateRebalanceCost` on every + * rebalance. Deterministic given identical inputs + history. + */ +export async function simulateHistorical( + input: SimulateHistoricalInput +): Promise { + const thresholds = input.thresholds ?? DEFAULT_SIMULATE_THRESHOLDS + const strategy = resolveSimulationStrategy(input.effectiveConfig.strategyName) + const startingValue = new Decimal(input.startingAmount) + const caveats = [...(input.dataCaveats ?? [])] + + const firstPopulated = input.dailyRates.findIndex( + (d) => d.protocols.length > 0 + ) + if (startingValue.lte(0)) { + caveats.push('No starting value — historical replay skipped') + return { + rebalanceCount: 0, + turnoverRatio: 0, + totalFeesPaid: '0', + endingValue: '0', + startingValue: startingValue.toString(), + counterfactualEndingValue: '0', + finalProtocol: input.startingProtocol, + timeSeries: [], + realizedGainPct: null, + counterfactualGainPct: null, + dataCaveats: caveats, + } + } + if (firstPopulated === -1) { + caveats.push('Insufficient historical rate data for replay') + return { + rebalanceCount: 0, + turnoverRatio: 0, + totalFeesPaid: '0', + endingValue: startingValue.toString(), + startingValue: startingValue.toString(), + counterfactualEndingValue: startingValue.toString(), + finalProtocol: input.startingProtocol, + timeSeries: [], + realizedGainPct: null, + counterfactualGainPct: null, + dataCaveats: caveats, + } + } + + let currentProtocol = input.startingProtocol + let simValue = startingValue + let counterfactualValue = startingValue + let totalFees = new Decimal(0) + let rebalanceCount = 0 + let turnover = new Decimal(0) + const timeSeries: HistoricalTimeSeriesPoint[] = [] + + for (const day of input.dailyRates) { + if (day.protocols.length === 0) { + // No data at all even after forward-fill — hold both legs flat. + timeSeries.push({ + date: formatDate(day.date), + simulatedValue: simValue.toString(), + counterfactualValue: counterfactualValue.toString(), + }) + continue + } + + const currentRate = currentProtocol + ? day.protocols.find((p) => p.name === currentProtocol) + : undefined + + if (!currentProtocol || !currentRate) { + // No current protocol yet or its rate is unavailable today. Anchor to the + // first day's best-yielding protocol when none is set, else hold at 0 APY + // (missing history treated as unavailable — never a fabricated rate). + currentProtocol = + currentProtocol ?? + day.protocols.reduce((best, p) => (p.apy > best.apy ? p : best)).name + const anchorRate = day.protocols.find((p) => p.name === currentProtocol) + const apy = anchorRate?.apy ?? 0 + const simDaily = nonCompoundingDaily(simValue, apy) + const ctrDaily = nonCompoundingDaily(counterfactualValue, apy) + simValue = simValue.plus(simDaily) + counterfactualValue = counterfactualValue.plus(ctrDaily) + timeSeries.push({ + date: formatDate(day.date), + simulatedValue: simValue.toString(), + counterfactualValue: counterfactualValue.toString(), + }) + continue + } + + const currentApy = currentRate.apy + + // Accrue one day of simple (non-compounding) return on BOTH legs first. + simValue = simValue.plus(nonCompoundingDaily(simValue, currentApy)) + counterfactualValue = counterfactualValue.plus( + nonCompoundingDaily(counterfactualValue, currentApy) + ) + + const params = buildSimulationParams({ + currentProtocol, + totalAmount: amountToWeiLike(simValue), + currentApy, + availableProtocols: day.protocols, + effectiveConfig: input.effectiveConfig, + thresholds, + riskScores: input.riskScores, + goal: input.goal, + userId: input.userId, + }) + const decision = await strategy.analyze(params) + + if ( + decision.shouldRebalance && + decision.targetProtocol !== currentProtocol + ) { + // Subtract the SAME cost model the live agent uses. The agent holds one + // protocol at a time, so a rebalance moves the whole position. + const cost = estimateRebalanceCost({ + fromProtocol: currentProtocol, + toProtocol: decision.targetProtocol, + amount: amountToWeiLike(simValue), + assetSymbol: currentRate.assetSymbol, + sameAsset: true, + feeSnapshot: null, + }) + const fee = new Decimal(cost.totalCostPct / 100).mul(simValue) + simValue = simValue.minus(fee) + totalFees = totalFees.plus(fee) + turnover = turnover.plus(simValue) + rebalanceCount += 1 + currentProtocol = decision.targetProtocol + } + + timeSeries.push({ + date: formatDate(day.date), + simulatedValue: simValue.toString(), + counterfactualValue: counterfactualValue.toString(), + }) + } + + const windowStart = firstStepDate(input.dailyRates) + const windowEnd = lastStepDate(input.dailyRates) + const years = (windowEnd.getTime() - windowStart.getTime()) / MS_PER_YEAR + + const gainPct = (v: Decimal, base: Decimal): number | null => + base.gt(0) && years > 0 ? Number(v.minus(base).div(base).mul(100)) : null + + return { + rebalanceCount, + turnoverRatio: simValue.gt(0) ? Number(turnover.div(simValue)) : 0, + totalFeesPaid: totalFees.toString(), + endingValue: simValue.toString(), + startingValue: startingValue.toString(), + counterfactualEndingValue: counterfactualValue.toString(), + finalProtocol: currentProtocol, + timeSeries, + realizedGainPct: gainPct(simValue, startingValue), + counterfactualGainPct: gainPct(counterfactualValue, startingValue), + dataCaveats: caveats, + } +} + +/** Simple (non-compounding) one-day accrued return. */ +function nonCompoundingDaily(value: Decimal, apy: number): Decimal { + if (!Number.isFinite(apy) || apy <= 0) return new Decimal(0) + return value.mul(new Decimal(apy).div(100)).div(365.25) +} + +/** + * Encode a dollar value as the wei-like string the shared cost model expects. + * Mirrors the backtest engine's encoding (value * 10^18) so that + * `estimateRebalanceCost`/`amountToHumanUnits` divide back to human units and + * produce a realistic fee percentage. + */ +function amountToWeiLike(value: Decimal): string { + const micro = BigInt(Math.round(Number(value.mul(1e6)))) + return (micro * 10n ** 12n).toString() +} + +function formatDate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function firstStepDate(series: DailyRateSnapshot[]): Date { + return series.length > 0 ? series[0].date : new Date() +} + +function lastStepDate(series: DailyRateSnapshot[]): Date { + return series.length > 0 ? series[series.length - 1].date : new Date() +} + +/** + * Build a daily rate series for a window, truncating to available retention and + * surfacing a caveat when the window was shortened. `maxWindowDays` bounds the + * replay's compute (a compute-exhaustion guard enforced here and at the route). + */ +export function buildSimulationRateSeries( + rawPoints: RawProtocolRatePoint[], + windowStart: Date, + windowEnd: Date, + maxWindowDays: number = SIMULATE_MAX_WINDOW_DAYS +): { + series: DailyRateSnapshot[] + dataCaveats: string[] + earliestAvailableDate: Date | null +} { + const caveats: string[] = [] + + const requestedDays = + (windowEnd.getTime() - windowStart.getTime()) / MS_PER_DAY + if (requestedDays > maxWindowDays) { + caveats.push( + `Window longer than the ${maxWindowDays}-day simulation cap — truncated` + ) + } + + if (rawPoints.length === 0) { + return { + series: [], + dataCaveats: ['No retained rate history for this window'], + earliestAvailableDate: null, + } + } + + // Clamp the window to the span of retained observations at BOTH ends so the + // replay only ever prices days we actually have data for — never extrapolates + // before the first observation or after the last one. + const first = rawPoints.reduce( + (earliest, p) => (p.date < earliest ? p.date : earliest), + new Date(8640000000000000) + ) + const last = rawPoints.reduce( + (latest, p) => (p.date > latest ? p.date : latest), + new Date(-8640000000000000) + ) + const effectiveStart = + first.getTime() > windowStart.getTime() ? first : windowStart + const effectiveEnd = last.getTime() < windowEnd.getTime() ? last : windowEnd + + const earliestAvailableDate = + first.getTime() > windowStart.getTime() ? first : null + if (earliestAvailableDate) { + caveats.push( + `Retained history starts ${formatDate(first)} — window truncated to available data` + ) + } + if (last.getTime() < windowEnd.getTime()) { + caveats.push( + `Retained history ends ${formatDate(last)} — window truncated to available data` + ) + } + + const trimmed = rawPoints.filter( + (p) => p.date >= effectiveStart && p.date <= effectiveEnd + ) + const series = buildDailyRateSeries( + trimmed, + effectiveStart, + effectiveEnd + ).series + + return { series, dataCaveats: caveats, earliestAvailableDate } +} + +/** + * Opaque token binding a simulation to the exact submitted config + window so a + * later apply step (out of scope here) can guarantee the user previewed + * precisely what they saved. Pure hash of canonical inputs — no wall-clock. + */ +export function buildSimulationToken( + config: EffectiveStrategyConfig, + historyWindowDays: number, + asOfIso: string +): string { + const canonical = JSON.stringify({ + strategyName: config.strategyName ?? null, + targetAllocations: config.targetAllocations + ? Object.keys(config.targetAllocations) + .sort() + .map((k) => `${k}=${config.targetAllocations![k]}`) + .join(',') + : null, + riskCeiling: config.riskCeiling ?? null, + historyWindowDays, + asOf: asOfIso, + }) + return ( + 'sim:' + createHash('sha256').update(canonical).digest('hex').slice(0, 24) + ) +} diff --git a/src/config/env.ts b/src/config/env.ts index 708905e..ccadf36 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -404,6 +404,16 @@ export const config = { windowMs: parseInt(process.env.OPTIMIZER_RATE_LIMIT_WINDOW_MS || '60000'), max: parseInt(process.env.OPTIMIZER_RATE_LIMIT_MAX || '5'), }, + /** + * Strategy simulate (#344) — a CPU-bound historical replay, like the + * optimizer. Tighter than the global limiter so the replay cannot be used + * as a CPU-exhaustion vector; pairs with the short-TTL result cache so + * identical preview requests are served without recomputing. + */ + simulateRateLimit: { + windowMs: parseInt(process.env.SIMULATE_RATE_LIMIT_WINDOW_MS || '60000'), + max: parseInt(process.env.SIMULATE_RATE_LIMIT_MAX || '6'), + }, /** Public webhook endpoints — resist spoofed / replay floods (e.g. Twilio) */ webhookRateLimit: { windowMs: parseInt(process.env.WEBHOOK_RATE_LIMIT_WINDOW_MS || '60000'), diff --git a/src/controllers/strategy-controller.ts b/src/controllers/strategy-controller.ts index 6da02ad..fd5b844 100644 --- a/src/controllers/strategy-controller.ts +++ b/src/controllers/strategy-controller.ts @@ -27,6 +27,45 @@ import { StrategySelfFollowError, StrategyValidationError, } from '../strategy/service' +import { + simulateStrategy, + SimulationValidationError, + SimulationNotFoundError, +} from '../strategy/simulation-service' + +/** + * POST /api/v1/strategies/simulate + * + * Dry-run a strategy change on the caller's current positions and public rate + * history. Pure read — no side effects (see the service contract). Returns the + * immediate agent decision plus a historical replay with caveats. + */ +export async function simulateStrategyHandler( + req: Request, + res: Response +): Promise { + const userId = req.auth?.userId + if (!userId) { + sendUnauthorized(res) + return + } + + try { + const result = await simulateStrategy(userId, req.body) + res.status(200).json(result) + } catch (error) { + if (error instanceof SimulationValidationError) { + sendError(res, 400, error.message) + return + } + if (error instanceof SimulationNotFoundError) { + sendNotFound(res, 'Strategy') + return + } + logger.error('[Strategy] Failed to run strategy simulation:', error) + sendError(res, 500, 'Failed to run strategy simulation') + } +} /** * POST /api/v1/strategies/publish diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index 57b41e9..b05d3cf 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -220,9 +220,24 @@ export const optimizerRateLimiter = buildRateLimiter({ 'Too many optimization requests. Portfolio optimization is compute-intensive; please try again shortly.', }) +/** + * Strategy simulate rate limiter (#344) — for the CPU-bound what-if historical + * replay. Defaults: 6 req / 1 min (env: SIMULATE_RATE_LIMIT_MAX / + * SIMULATE_RATE_LIMIT_WINDOW_MS). Applied per-endpoint on the simulate route + * only, mirroring the optimizer limiter's reasoning — the marketplace reads on + * the same router must not inherit a tight budget. + */ +export const simulateRateLimiter = buildRateLimiter({ + windowMs: config.security.simulateRateLimit.windowMs, + max: config.security.simulateRateLimit.max, + skip: isTrusted, + limiterType: 'simulate', + message: + 'Too many simulation requests. Historical replay is compute-intensive; please try again shortly.', +}) + /** * Internal / agent rate limiter — higher throughput for service-to-service calls. - * Defaults: 500 req / 1 min (env: INTERNAL_RATE_LIMIT_MAX / INTERNAL_RATE_LIMIT_WINDOW_MS). */ export const internalRateLimiter = buildRateLimiter({ windowMs: config.security.internalRateLimit.windowMs, diff --git a/src/routes/strategies.ts b/src/routes/strategies.ts index 39f168e..df53273 100644 --- a/src/routes/strategies.ts +++ b/src/routes/strategies.ts @@ -22,10 +22,12 @@ import { Router } from 'express' import { requireAuth } from '../middleware/authenticate' import { validate } from '../middleware/validate' +import { simulateRateLimiter } from '../middleware/rateLimiter' import { publishStrategySchema, marketplaceQuerySchema, strategyIdParamSchema, + strategySimulateSchema, } from '../validators/strategy-validators' import { publishStrategyHandler, @@ -34,6 +36,7 @@ import { getFollowingHandler, followStrategyHandler, unfollowStrategyHandler, + simulateStrategyHandler, } from '../controllers/strategy-controller' const router = Router() @@ -41,8 +44,15 @@ const router = Router() // Every route in this file is user-facing and owner-scoped. router.use(requireAuth) -// Literal segments first, so "publish"/"marketplace"/"following" are never -// captured as a strategy id by the /:id/* routes below. +// Literal segments first, so "publish"/"marketplace"/"following"/"simulate" +// are never captured as a strategy id by the /:id/* routes below. +router.post( + '/simulate', + simulateRateLimiter, + validate({ body: strategySimulateSchema }), + simulateStrategyHandler +) + router.post( '/publish', validate({ body: publishStrategySchema }), diff --git a/src/strategy/simulation-service.ts b/src/strategy/simulation-service.ts new file mode 100644 index 0000000..4bc4afb --- /dev/null +++ b/src/strategy/simulation-service.ts @@ -0,0 +1,412 @@ +/** + * Strategy What-If Simulation service (#344) — the owner-scoped DB glue around + * the pure core in src/agent/simulate.ts. + * + * Resolves the caller's own config + active follow + active goal, validates the + * submitted hypothetical config, loads the caller's positions and public + * ProtocolRate history, then composes simulateImmediate + simulateHistorical. + * + * ZERO SIDE EFFECTS is the contract this service is responsible for: it never + * writes an OutboxOp, AgentLog, Transaction, event, or User/PublishedStrategy + * row. The acceptance test asserts no such row is created by a call. + * + * Owner-scoped: reads only the caller's Position rows and public + * ProtocolRate/ProtocolRiskScore data. + */ + +import db from '../db' +import { logger } from '../utils/logger' +import { + parseStrategyConfig, + resolveEffectiveConfig, + EffectiveStrategyConfig, + StrategyConfigShape, +} from '../agent/effectiveStrategy' +import { + simulateImmediate, + simulateHistorical, + buildSimulationRateSeries, + buildSimulationToken, + SIMULATE_MAX_WINDOW_DAYS, + SIMULATION_LABEL, + ImmediateSimulationResult, + HistoricalSimulationResult, +} from '../agent/simulate' +import { scanAllProtocols } from '../agent/scanner' +import { getThresholds } from '../agent/router' +import { StrategyName, StrategyParams } from '../agent/types' +import { cacheGet, cacheSet } from '../config/redis' + +const SIMULATION_CACHE_TTL = 120 // 2 minutes + +export class SimulationValidationError extends Error {} +export class SimulationNotFoundError extends Error {} + +export interface SimulateStrategyRequest { + strategy?: StrategyName | null + targetAllocations?: Record + riskCeiling?: number + followStrategyId?: string | null + historyWindowDays?: number + assumeInitialDeposit?: boolean +} + +export interface SimulateStrategyResponse { + immediate: ImmediateSimulationResult + historical: HistoricalSimulationResult + simulationToken: string + asOf: string + effectiveConfig: Pick< + EffectiveStrategyConfig, + 'strategyName' | 'targetAllocations' | 'riskCeiling' + > + dataCaveats: string[] + label: string +} + +/** Effective risk ceiling an ACTIVE goal imposes (goal wins — #281). */ +type GoalSnapshot = NonNullable & { + riskCeiling: number | null +} + +interface OwnerContext { + /** The caller's own parsed config (pre-follow merge). */ + own: StrategyConfigShape + /** Config applied from the caller's CURRENT active follow, if any. */ + currentFollowApplied: StrategyConfigShape | null +} + +/** + * Load the caller's own config and current follow. Reads only the caller's rows. + */ +async function loadOwnerContext(userId: string): Promise { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { rebalanceStrategy: true, strategyConfig: true }, + }) + if (!user) { + throw new SimulationNotFoundError('User not found') + } + + const own = parseStrategyConfig({ + strategyName: user.rebalanceStrategy ?? null, + ...((user.strategyConfig as Record) ?? {}), + }) + + const follow = await db.strategyFollow.findFirst({ + where: { followerUserId: userId, unfollowedAt: null }, + select: { publishedStrategy: { select: { strategyConfig: true } } }, + }) + const currentFollowApplied = follow + ? parseStrategyConfig((follow as any).publishedStrategy?.strategyConfig) + : null + + return { own, currentFollowApplied } +} + +function stricter( + a: number | undefined, + b: number | undefined +): number | undefined { + if (a === undefined) return b + if (b === undefined) return a + return Math.max(a, b) +} + +/** + * Resolve the effective config for the SIMULATION. + * + * Precedence (mirrors resolveEffectiveConfig / #285): + * 1. A followed config — the caller's current follow, or the strategy named + * by the hypothetical `followStrategyId` when supplied. + * 2. The submitted inline config (the "what if" target). + * 3. The caller's own config. + * The risk ceiling is ALWAYS clamped to the stricter of the caller's own and + * any applied ceiling — a simulation may only ever tighten exposure. + */ +async function resolveSimulationEffectiveConfig( + userId: string, + req: SimulateStrategyRequest, + ctx: OwnerContext +): Promise { + let hypotheticalFollow = ctx.currentFollowApplied + + if (req.followStrategyId) { + const target = await db.publishedStrategy.findUnique({ + where: { id: req.followStrategyId }, + select: { strategyConfig: true, isPublished: true }, + }) + if (!target || !target.isPublished) { + throw new SimulationNotFoundError( + 'Published strategy not found or unpublished' + ) + } + hypotheticalFollow = parseStrategyConfig(target.strategyConfig) + } + + const submitted: EffectiveStrategyConfig = { + strategyName: req.strategy ?? null, + targetAllocations: req.targetAllocations, + riskCeiling: req.riskCeiling, + } + + // No inline config, no follow: simulate the caller's current effective config + // (a "what if nothing changes" baseline). + const hasInline = req.strategy != null || req.targetAllocations !== undefined + const useFollow = Boolean(req.followStrategyId) || !hasInline + + const base = useFollow ? hypotheticalFollow : submitted + const fallbackStrategy = + (hasInline ? submitted.strategyName : base?.strategyName) ?? + ctx.own.strategyName ?? + null + + const merged: EffectiveStrategyConfig = { + strategyName: base?.strategyName ?? fallbackStrategy, + targetAllocations: + base?.targetAllocations ?? + submitted.targetAllocations ?? + ctx.own.targetAllocations, + riskCeiling: stricter( + submitted.riskCeiling, + stricter(base?.riskCeiling, ctx.own.riskCeiling) + ), + } + + return merged +} + +/** Load current protocol risk scores keyed by name (empty when no ceiling). */ +async function loadRiskScores(): Promise> { + const rows = await db.protocolRiskScore.findMany({ + select: { protocolName: true, score: true }, + }) + const map: Record = {} + for (const row of rows as Array<{ protocolName: string; score: number }>) { + map[row.protocolName] = row.score + } + return map +} + +/** + * Run the what-if simulation for a caller. Throws SimulationValidationError for + * rule violations (TARGET_ALLOCATION weights must sum to 100, GOAL_TRACKING + * needs an active goal) and SimulationNotFoundError for missing owners/strategies. + */ +export async function simulateStrategy( + userId: string, + req: SimulateStrategyRequest +): Promise { + const historyWindowDays = Math.min( + req.historyWindowDays ?? 90, + SIMULATE_MAX_WINDOW_DAYS + ) + + const ctx = await loadOwnerContext(userId) + + const [goal] = await Promise.all([ + db.savingsGoal.findFirst({ where: { userId, status: 'ACTIVE' } }), + ]) + const activeGoal: GoalSnapshot | null = goal + ? { + targetAmount: Number(goal.targetAmount), + startingAmount: Number(goal.startingAmount), + targetDate: goal.targetDate, + riskCeiling: goal.riskCeiling, + } + : null + + if (req.strategy === 'GOAL_TRACKING' && !activeGoal) { + throw new SimulationValidationError( + 'GOAL_TRACKING is driven by an active savings goal — none is active for this simulation' + ) + } + + const effectiveConfig = await resolveSimulationEffectiveConfig( + userId, + req, + ctx + ) + + // TARGET_ALLOCATION weights must sum to 100 (assumption 7 in #285). + if ( + effectiveConfig.strategyName === 'TARGET_ALLOCATION' && + effectiveConfig.targetAllocations && + Object.keys(effectiveConfig.targetAllocations).length > 0 + ) { + const total = Object.values(effectiveConfig.targetAllocations).reduce( + (s, v) => s + v, + 0 + ) + if (Math.abs(total - 100) > 0.01) { + throw new SimulationValidationError( + `targetAllocations must sum to 100 (got ${total})` + ) + } + } + + // GOAL_TRACKING with a follow may carry no active goal of its own; the goal + // precedence rule still requires one to simulate. + if (effectiveConfig.strategyName === 'GOAL_TRACKING' && !activeGoal) { + throw new SimulationValidationError( + 'GOAL_TRACKING is driven by an active savings goal — none is active for this simulation' + ) + } + + const asOf = new Date().toISOString() + const simulationToken = buildSimulationToken( + effectiveConfig, + historyWindowDays, + asOf + ) + + const cacheKey = `strategy-simulate:${userId}:${simulationToken}` + const cached = await cacheGet(cacheKey) + if (cached) { + logger.info('[SimulateStrategy] cache hit', { userId }) + return cached + } + + // ── Load current decision inputs ─────────────────────────────────────────── + const positions = await db.position.findMany({ + where: { userId, status: 'ACTIVE' }, + select: { protocolName: true, currentValue: true }, + }) + const availableProtocols = await scanAllProtocols() + const riskScores = await loadRiskScores() + + const goalForStrategy = + effectiveConfig.strategyName === 'GOAL_TRACKING' && activeGoal + ? { + targetAmount: activeGoal.targetAmount, + startingAmount: activeGoal.startingAmount, + targetDate: activeGoal.targetDate, + } + : undefined + + // ── Immediate decision (zero side effects) ───────────────────────────────── + const immediate = await simulateImmediate({ + positions: positions.map((p) => ({ + protocolName: p.protocolName, + currentValue: p.currentValue.toString(), + })), + effectiveConfig, + thresholds: getThresholds(), + riskScores, + availableProtocols, + goal: goalForStrategy, + asOf: new Date(asOf), + }) + + // ── Historical replay (zero side effects) ────────────────────────────────── + const now = new Date() + const windowStart = new Date( + now.getTime() - historyWindowDays * 24 * 60 * 60 * 1000 + ) + const rawRates = await db.protocolRate.findMany({ + where: { fetchedAt: { gte: windowStart, lte: now } }, + select: { + protocolName: true, + assetSymbol: true, + supplyApy: true, + fetchedAt: true, + }, + orderBy: { fetchedAt: 'asc' }, + }) + const rawPoints = rawRates.map((r) => ({ + protocolName: r.protocolName, + assetSymbol: r.assetSymbol, + apy: Number(r.supplyApy), + date: r.fetchedAt, + })) + + const { series: dailyRates, dataCaveats: windowCaveats } = + buildSimulationRateSeries( + rawPoints, + windowStart, + now, + SIMULATE_MAX_WINDOW_DAYS + ) + + // Missing protocol history for protocols in the target set => caveat, treated + // as unavailable (never zero-filled) by the replay. + const protocolsInScope = new Set( + Object.keys(effectiveConfig.targetAllocations ?? {}) + ) + const availableNames = new Set( + dailyRates.flatMap((d) => d.protocols.map((p) => p.name)) + ) + const missingScope = protocolNamesMissing(protocolsInScope, availableNames) + if (missingScope.length > 0) { + windowCaveats.push( + `No retained history for protocol(s): ${missingScope.join(', ')} — treated as unavailable for those steps (never zero-filled)` + ) + } + + // Anchor the replay on the caller's current position value when available; + // otherwise a hypothetical unit deposit per the request flag. The starting + // protocol is the caller's highest-value position (matches the immediate + // decision's anchor). + const anchor = positions.reduce<{ + total: number + top: { protocolName: string; currentValue: number } | null + }>( + (acc, p) => { + const v = Number(p.currentValue) + acc.total += v + if (!acc.top || v > acc.top.currentValue) { + acc.top = { protocolName: p.protocolName, currentValue: v } + } + return acc + }, + { total: 0, top: null } + ) + const startingAmount = + anchor.total > 0 + ? anchor.total.toFixed(6) + : req.assumeInitialDeposit + ? '1000' + : '0' + const startingProtocol = anchor.top?.protocolName ?? null + + const historical = await simulateHistorical({ + startingAmount, + startingProtocol, + effectiveConfig, + thresholds: getThresholds(), + riskScores, + dailyRates, + dataCaveats: windowCaveats, + goal: goalForStrategy, + userId, + }) + + const response: SimulateStrategyResponse = { + immediate, + historical, + simulationToken, + asOf, + effectiveConfig: { + strategyName: effectiveConfig.strategyName, + targetAllocations: effectiveConfig.targetAllocations, + riskCeiling: effectiveConfig.riskCeiling, + }, + dataCaveats: historical.dataCaveats, + label: SIMULATION_LABEL, + } + + await cacheSet(cacheKey, response, SIMULATION_CACHE_TTL) + + return response +} + +function protocolNamesMissing( + inScope: Set, + available: Set +): string[] { + const missing: string[] = [] + for (const name of inScope) { + if (!available.has(name)) missing.push(name) + } + return missing.sort() +} diff --git a/src/validators/strategy-validators.ts b/src/validators/strategy-validators.ts index 2e9e681..f93fd62 100644 --- a/src/validators/strategy-validators.ts +++ b/src/validators/strategy-validators.ts @@ -175,5 +175,56 @@ export const strategyIdParamSchema = z.object({ id: z.string().uuid('Invalid strategy ID'), }) +/** + * POST /strategies/simulate (#344) + * + * Dry-run a hypothetical strategy config. `followStrategyId` is mutually + * exclusive with the inline `strategy`/`targetAllocations`/`riskCeiling`. The + * historical replay window is capped at SIMULATE_MAX_WINDOW_DAYS (180) to bound + * compute. TARGET_ALLOCATION weight-sum-to-100 is enforced in the service once + * the effective config is resolved (a follow may contribute allocations), so it + * is not duplicated here. + */ +export const strategySimulateSchema = z + .object({ + strategy: z + .enum(['MAX_YIELD', 'TARGET_ALLOCATION', 'GOAL_TRACKING']) + .nullable() + .optional(), + targetAllocations: z + .record(z.string().min(1).max(100), z.number().finite().min(0).max(100)) + .optional(), + riskCeiling: z.number().int().min(0).max(100).optional(), + followStrategyId: z + .string() + .uuid('Invalid strategy ID') + .nullable() + .optional(), + historyWindowDays: z + .number() + .int() + .min(1) + .max(180, 'historyWindowDays is capped at 180 days') + .default(90) + .optional(), + assumeInitialDeposit: z.boolean().optional(), + }) + .superRefine((data, ctx) => { + const hasInline = + data.strategy != null || + data.targetAllocations !== undefined || + data.riskCeiling !== undefined + if (data.followStrategyId && hasInline) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['followStrategyId'], + message: + 'followStrategyId is mutually exclusive with inline strategy config', + }) + } + }) + +export type StrategySimulateInput = z.infer + export type PublishStrategyInput = z.infer export type MarketplaceQuery = z.infer diff --git a/tests/integration/rateLimiter.integration.test.ts b/tests/integration/rateLimiter.integration.test.ts index c44ad11..b7f7116 100644 --- a/tests/integration/rateLimiter.integration.test.ts +++ b/tests/integration/rateLimiter.integration.test.ts @@ -21,6 +21,8 @@ jest.mock('../../src/config/env', () => ({ // #322 — every limiter in rateLimiter.ts is constructed at module load, // so a block missing here fails the whole suite at import, not at use. optimizerRateLimit: { windowMs: 60000, max: 5 }, + // #344 — strategy simulate replay limiter (CPU-bound). + simulateRateLimit: { windowMs: 60000, max: 6 }, trustedIps: [], internalServiceToken: '', }, diff --git a/tests/integration/strategies-simulate.integration.test.ts b/tests/integration/strategies-simulate.integration.test.ts new file mode 100644 index 0000000..7f0bf7c --- /dev/null +++ b/tests/integration/strategies-simulate.integration.test.ts @@ -0,0 +1,234 @@ +/** + * #344 — POST /api/v1/strategies/simulate integration test. + * + * Mounts the real router with only auth + DB + colour/scan I/O mocked, so the + * request travels through the REAL validator, the REAL controller, the REAL + * strategy service and the REAL pure simulation core. The acceptance criterion + * this file proves: + * 1. Validation — TARGET_ALLOCATION weights must sum to 100 (post-resolution), + * GOAL_TRACKING requires an active goal, and followStrategyId is mutually + * exclusive with an inline config. + * 2. ZERO SIDE EFFECTS — a successful simulation never writes an OutboxOp, + * AgentLog, Transaction, or any User/PublishedStrategy/Position row. The + * DB mocks assert no write-capable model was touched under the hood. + * 3. Owner scoping — only the caller's rows/config drive the result. + */ +const mockUserId = '11111111-1111-4111-8111-111111111111' +const followId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + +import request from 'supertest' +import express from 'express' + +jest.mock('../../src/middleware/authenticate', () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.userId = mockUserId + req.auth = { userId: mockUserId, walletAddress: 'GWALLET_USER_1' } + next() + }, + enforceUserAccess: (_req: any, _res: any, next: any) => next(), +})) + +jest.mock('../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +jest.mock('../../src/db', () => ({ __esModule: true, default: {} })) + +// The simulate route carries a tight per-endpoint rate limiter (6/min). Stub it +// to a pass-through so the suite can exercise MULTIPLE validation/service +// scenarios in one window without tripping the limiter — the real middleware is +// exercised by the rate-limiter's own tests. +jest.mock('../../src/middleware/rateLimiter', () => { + const actual = jest.requireActual('../../src/middleware/rateLimiter') + return { + ...actual, + simulateRateLimiter: (_req: any, _res: any, next: any) => next(), + } +}) + +// The scanner makes real Stellar/network reads — stub it for deterministic tests. +jest.mock('../../src/agent/scanner', () => ({ + scanAllProtocols: jest.fn().mockResolvedValue([ + { + name: 'Blend', + apy: 5, + assetSymbol: 'USDC', + lastUpdated: new Date('2026-01-10T00:00:00.000Z'), + isAvailable: true, + }, + ]), +})) + +import db from '../../src/db' +import strategiesRouter from '../../src/routes/strategies' + +const mockDb = db as any + +function buildApp() { + const app = express() + app.use(express.json()) + app.use('/api/v1/strategies', strategiesRouter) + return app +} + +const app = buildApp() + +/** Shape rows exactly as the service's selects produce them. */ +function userRow(overrides: Record = {}) { + return { + id: mockUserId, + rebalanceStrategy: 'MAX_YIELD', + strategyConfig: null, + ...overrides, + } +} + +function positionRow(overrides: Record = {}) { + return { + id: 'pos-1', + userId: mockUserId, + protocolName: 'Blend', + currentValue: '5000.000000', + status: 'ACTIVE', + ...overrides, + } +} + +beforeEach(() => { + jest.clearAllMocks() + mockDb.$transaction = jest.fn(async (fn: any) => fn(mockDb)) + mockDb.user = { findUnique: jest.fn().mockResolvedValue(userRow()) } + mockDb.strategyFollow = { findFirst: jest.fn().mockResolvedValue(null) } + mockDb.publishedStrategy = { + findUnique: jest.fn().mockResolvedValue(null), + findFirst: jest.fn().mockResolvedValue(null), + upsert: jest.fn(), + update: jest.fn(), + } + mockDb.savingsGoal = { findFirst: jest.fn().mockResolvedValue(null) } + mockDb.position = { findMany: jest.fn().mockResolvedValue([positionRow()]) } + mockDb.protocolRate = { findMany: jest.fn().mockResolvedValue([]) } + mockDb.protocolRiskScore = { + findMany: jest + .fn() + .mockResolvedValue([{ protocolName: 'Blend', score: 60 }]), + } + // Rate-limiter / write-path models that MUST never be exercised. + mockDb.userApiKey = { findFirst: jest.fn().mockResolvedValue(null) } + mockDb.outbox = { create: jest.fn(), createMany: jest.fn() } + mockDb.agentLog = { create: jest.fn(), createMany: jest.fn() } + mockDb.transaction = { create: jest.fn(), createMany: jest.fn() } + mockDb.rebalanceDecision = { create: jest.fn() } +}) + +async function postSimulate(body: unknown) { + return request(app) + .post('/api/v1/strategies/simulate') + .send(body as object) +} + +describe('POST /api/v1/strategies/simulate — happy path', () => { + it('returns immediate + historical + token for the caller with a position', async () => { + // 30 days of retained history for the replay. + mockDb.protocolRate.findMany.mockResolvedValue([ + { + protocolName: 'Blend', + assetSymbol: 'USDC', + supplyApy: '5', + fetchedAt: new Date('2026-01-02T00:00:00.000Z'), + }, + ]) + + const res = await postSimulate({ historyWindowDays: 30 }) + + expect(res.status).toBe(200) + expect(res.body.immediate).toBeDefined() + expect(res.body.immediate.action).toMatch(/hold|rebalance|blocked/) + expect(res.body.historical).toBeDefined() + expect(res.body.simulationToken).toBeTruthy() + expect(res.body.asOf).toBeTruthy() + expect(res.body.effectiveConfig.strategyName).toBe('MAX_YIELD') + expect(res.body.label).toBeTruthy() + }) + + it('does NOT create any side-effect rows (zero side effects)', async () => { + mockDb.protocolRate.findMany.mockResolvedValue([]) + + const res = await postSimulate({}) + + expect(res.status).toBe(200) + expect(mockDb.outbox.create).not.toHaveBeenCalled() + expect(mockDb.outbox.createMany).not.toHaveBeenCalled() + expect(mockDb.agentLog.create).not.toHaveBeenCalled() + expect(mockDb.transaction.create).not.toHaveBeenCalled() + expect(mockDb.rebalanceDecision.create).not.toHaveBeenCalled() + // No strategy/user/position writes either. + expect(mockDb.publishedStrategy.upsert).not.toHaveBeenCalled() + expect(mockDb.publishedStrategy.update).not.toHaveBeenCalled() + }) + + it('anchors on the caller current-follow config when no inline config is given', async () => { + const published = { + id: followId, + strategyConfig: { + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 100 }, + riskCeiling: 60, + }, + } + mockDb.strategyFollow.findFirst.mockResolvedValue({ + publishedStrategy: published, + }) + + const res = await postSimulate({}) + + expect(res.status).toBe(200) + expect(res.body.effectiveConfig.strategyName).toBe('TARGET_ALLOCATION') + expect(res.body.effectiveConfig.targetAllocations).toEqual({ Blend: 100 }) + expect(res.body.effectiveConfig.riskCeiling).toBe(60) + }) +}) + +describe('POST /api/v1/strategies/simulate — validation', () => { + it('rejects followStrategyId combined with an inline strategy config (400)', async () => { + const res = await postSimulate({ + followStrategyId: followId, + strategy: 'MAX_YIELD', + }) + expect(res.status).toBe(400) + }) + + it('rejects TARGET_ALLOCATION weights that do not sum to 100 (400)', async () => { + const res = await postSimulate({ + strategy: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 40, Luma: 40 }, + riskCeiling: 70, + }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/sum to 100/i) + }) + + it('rejects GOAL_TRACKING with no active savings goal (400)', async () => { + const res = await postSimulate({ + strategy: 'GOAL_TRACKING', + targetAllocations: { Blend: 100 }, + riskCeiling: 70, + }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/GOAL_TRACKING/i) + }) + + it('caps historyWindowDays at the 180-day simulation cap (400)', async () => { + const res = await postSimulate({ + historyWindowDays: 365, + strategy: 'MAX_YIELD', + }) + // Crosses the validator cap -> rejected before the service sees it. + expect(res.status).toBe(400) + }) +}) diff --git a/tests/unit/agent/simulate.test.ts b/tests/unit/agent/simulate.test.ts new file mode 100644 index 0000000..337dd6a --- /dev/null +++ b/tests/unit/agent/simulate.test.ts @@ -0,0 +1,263 @@ +import fs from 'fs' +import path from 'path' +import { + simulateImmediate, + simulateHistorical, + buildSimulationRateSeries, + SIMULATE_MAX_WINDOW_DAYS, + ImmediateSimulationResult, + HistoricalSimulationResult, +} from '../../../src/agent/simulate' +import { + buildDailyRateSeries, + RawProtocolRatePoint, +} from '../../../src/agent/backtest' +import { EffectiveStrategyConfig } from '../../../src/agent/effectiveStrategy' +import { YieldProtocol } from '../../../src/agent/types' + +jest.mock('../../../src/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +const DAY = 24 * 60 * 60 * 1000 + +function d(dateStr: string): Date { + return new Date(dateStr + 'T00:00:00.000Z') +} + +const defaultConfig: EffectiveStrategyConfig = { + strategyName: 'MAX_YIELD', +} + +function protocol( + name: string, + apy: number, + isAvailable = true +): YieldProtocol { + return { + name, + apy, + assetSymbol: 'USDC', + lastUpdated: d('2026-01-01'), + isAvailable, + } +} + +// Flat 5% APY Blend for 10 days. +const flatFixture: RawProtocolRatePoint[] = Array.from( + { length: 10 }, + (_, i) => ({ + protocolName: 'Blend', + assetSymbol: 'USDC', + apy: 5, + date: new Date(d('2026-01-01').getTime() + i * DAY), + }) +) + +describe('simulate.ts — structural guarantee', () => { + it('src/agent/simulate.ts has zero imports from src/stellar', () => { + const source = fs.readFileSync( + path.join(__dirname, '../../../src/agent/simulate.ts'), + 'utf-8' + ) + const importLines = source + .split('\n') + .filter((line) => /^\s*import\b/.test(line)) + const stellarImports = importLines.filter((line) => + /['"].*stellar/i.test(line) + ) + expect(stellarImports).toEqual([]) + }) +}) + +describe('simulateImmediate — MAX_YIELD', () => { + it('holds when the current protocol is already best and appetite matches', async () => { + const result: ImmediateSimulationResult = await simulateImmediate({ + positions: [{ protocolName: 'Blend', currentValue: '5000' }], + availableProtocols: [protocol('Blend', 5)], + effectiveConfig: defaultConfig, + thresholds: { minimumImprovement: 0.5, maxGasPercent: 0.1 }, + riskScores: {}, + asOf: d('2026-01-10'), + }) + expect(result.action).toBe('hold') + expect(result.targetProtocol).toBeNull() + expect(result.moves).toEqual([]) + expect(result.reasoning.length).toBeGreaterThan(0) + }) + + it('recommends a rebalance to a materially higher-yield protocol for a large position', async () => { + const result: ImmediateSimulationResult = await simulateImmediate({ + positions: [{ protocolName: 'Blend', currentValue: '50000' }], + availableProtocols: [protocol('Blend', 5), protocol('Luma', 20)], + effectiveConfig: defaultConfig, + thresholds: { minimumImprovement: 0.5, maxGasPercent: 0.1 }, + riskScores: {}, + asOf: d('2026-01-10'), + }) + expect(result.action).toBe('rebalance') + expect(result.targetProtocol).toBe('Luma') + }) + + it('returns blocked for GOAL_TRACKING with no active goal', async () => { + const result: ImmediateSimulationResult = await simulateImmediate({ + positions: [{ protocolName: 'Blend', currentValue: '5000' }], + availableProtocols: [protocol('Blend', 5)], + effectiveConfig: { ...defaultConfig, strategyName: 'GOAL_TRACKING' }, + thresholds: { minimumImprovement: 0.5, maxGasPercent: 0.1 }, + riskScores: {}, + asOf: d('2026-01-10'), + }) + expect(result.action).toBe('blocked') + expect(result.reasoning).toMatch(/active savings goal/i) + }) + + it('ignores a higher-yield protocol above the risk ceiling', async () => { + const result: ImmediateSimulationResult = await simulateImmediate({ + positions: [{ protocolName: 'Blend', currentValue: '50000' }], + availableProtocols: [protocol('Blend', 5), protocol('Luma', 20)], + effectiveConfig: { ...defaultConfig, riskCeiling: 50 }, + thresholds: { minimumImprovement: 0.5, maxGasPercent: 0.1 }, + riskScores: { Blend: 60, Luma: 20 }, + asOf: d('2026-01-10'), + }) + expect(result.action).toBe('hold') + expect(result.targetProtocol).toBeNull() + const luma = result.trace.candidates?.find((c) => c.protocol === 'Luma') + expect(luma?.rejectionReason).toBe('over_risk_ceiling') + }) + + it('returns hold with reasoning for empty positions', async () => { + const result: ImmediateSimulationResult = await simulateImmediate({ + positions: [], + availableProtocols: [protocol('Blend', 5)], + effectiveConfig: defaultConfig, + thresholds: { minimumImprovement: 0.5, maxGasPercent: 0.1 }, + riskScores: {}, + asOf: d('2026-01-10'), + }) + expect(result.action).toBe('hold') + expect(result.reasoning).toMatch(/no active positions/i) + }) +}) + +describe('simulateHistorical — MAX_YIELD flat rate', () => { + it('is deterministic: identical inputs yield identical results', async () => { + const { series } = buildDailyRateSeries( + flatFixture, + d('2026-01-01'), + d('2026-01-10') + ) + const input = { + dailyRates: series, + effectiveConfig: defaultConfig, + startingAmount: '1000', + startingProtocol: 'Blend', + riskScores: {}, + userId: 'user-1', + } + + const first = await simulateHistorical(input) + const second = await simulateHistorical(input) + expect(second).toEqual(first) + }) + + it('accrues 10 days of simple daily return with no rebalance at a flat 5% APY', async () => { + const { series } = buildDailyRateSeries( + flatFixture, + d('2026-01-01'), + d('2026-01-10') + ) + const result: HistoricalSimulationResult = await simulateHistorical({ + dailyRates: series, + effectiveConfig: defaultConfig, + startingAmount: '1000', + startingProtocol: 'Blend', + riskScores: {}, + userId: 'user-1', + }) + + const dailyReturn = (5 / 100 / 365.25) * 1000 + const expectedFinal = 1000 + dailyReturn * 10 + expect(Number(result.endingValue)).toBeCloseTo(expectedFinal, 2) + expect(result.rebalanceCount).toBe(0) + expect(result.totalFeesPaid).toBe('0') + expect(result.finalProtocol).toBe('Blend') + // No rebalance -> simulated and counterfactual legs are identical. + expect(result.counterfactualEndingValue).toBe(result.endingValue) + expect(result.timeSeries).toHaveLength(10) + }) + + it('records rebalances, fees, and a differing counterfactual when a much better protocol appears', async () => { + const points: RawProtocolRatePoint[] = [ + ...flatFixture, + ...Array.from({ length: 5 }, (_, i) => ({ + protocolName: 'Luma', + assetSymbol: 'USDC', + apy: 20, + date: new Date(d('2026-01-05').getTime() + i * DAY), + })), + ] + const { series } = buildDailyRateSeries( + points, + d('2026-01-01'), + d('2026-01-10') + ) + const result: HistoricalSimulationResult = await simulateHistorical({ + dailyRates: series, + effectiveConfig: defaultConfig, + startingAmount: '100000', + startingProtocol: 'Blend', + riskScores: {}, + userId: 'user-1', + }) + + expect(result.rebalanceCount).toBeGreaterThan(0) + expect(result.finalProtocol).toBe('Luma') + expect(result.totalFeesPaid).not.toBe('0') + // Rebalancing incurs a fee, so by construction the legs differ. + expect(result.counterfactualEndingValue).not.toBe(result.endingValue) + }) + + it('returns zero ending value for a zero starting amount', async () => { + const { series } = buildDailyRateSeries( + flatFixture, + d('2026-01-01'), + d('2026-01-10') + ) + const result: HistoricalSimulationResult = await simulateHistorical({ + dailyRates: series, + effectiveConfig: defaultConfig, + startingAmount: '0', + startingProtocol: 'Blend', + riskScores: {}, + userId: 'user-1', + }) + expect(result.endingValue).toBe('0') + expect(result.counterfactualEndingValue).toBe('0') + }) +}) + +describe('buildSimulationRateSeries — window bounds', () => { + it('truncates the window to available retention and reports a caveat', () => { + const { series, dataCaveats } = buildSimulationRateSeries( + flatFixture, + d('2026-01-01'), + new Date(d('2026-01-01').getTime() + 180 * DAY), + SIMULATE_MAX_WINDOW_DAYS + ) + // Only the 10 retained days are replayed, never extrapolated beyond data. + expect(series.length).toBe(10) + expect(dataCaveats.some((c) => /retained history ends/i.test(c))).toBe(true) + }) + + it('surfaces a cap caveat when the window exceeds the simulation cap', () => { + const { dataCaveats } = buildSimulationRateSeries( + flatFixture, + d('2026-01-01'), + new Date(d('2026-01-01').getTime() + 365 * DAY), + SIMULATE_MAX_WINDOW_DAYS + ) + expect(dataCaveats.some((c) => /simulation cap/i.test(c))).toBe(true) + }) +})