Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions ASSUMPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
55 changes: 55 additions & 0 deletions docs/STRATEGY_MARKETPLACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
219 changes: 219 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading