Risk Controls
┌─────────────────────────────────────────────────────┐
Defense-in-depth for live trading. Every layer assumes the layer above it failed.
Layered model
┌─────────────────────────────────────────────────────┐
│ L1 — Skill author intent (system prompt + caps) │ weakest, easiest to bypass
├─────────────────────────────────────────────────────┤
│ L2 — Agent self-restraint (LLM following prompt) │ non-deterministic
├─────────────────────────────────────────────────────┤
│ L3 — Tool whitelist (only listed tools available) │ hard
├─────────────────────────────────────────────────────┤
│ L4 — Execution Engine validation pipeline │ hard
├─────────────────────────────────────────────────────┤
│ L5 — Engine-imposed halts (daily loss, drawdown) │ hard, automatic
├─────────────────────────────────────────────────────┤
│ L6 — Deployer commands (manual flatten, stop, kill) │ human, immediate
├─────────────────────────────────────────────────────┤
│ L7 — Platform-wide kill switch │ ops, last resort
└─────────────────────────────────────────────────────┘Each layer must be sufficient on its own — we don't rely on L2 ("the AI will follow instructions") as a control.
L1 — Skill author intent
The Skill author sets risk caps in the editor. These are validated at save time (e.g., maxLeverage <= 25, dailyLossHaltPct <= 50). Insane values are rejected by zod, so authors cannot save a Skill with maxLeverage: 1000.
Cap fields:
| Cap | Default | Hard max | Effect |
|---|---|---|---|
maxPositionPct | 25 | min(2500, maxTotalExposurePct) | Single position notional / equity — per-symbol concentration limit. Leverage-independent; constrained ≤ maxTotalExposurePct. |
maxTotalExposurePct | 25 | min(2500, maxLeverage × 100) | Sum of all position notionals / equity — caps total market exposure across the portfolio. The dynamic ceiling against maxLeverage prevents configuring exposure higher than leverage can physically deliver (e.g. maxLeverage: 10 → up to 1000%; maxLeverage: 25 → up to 2500%). |
maxLeverage | 3 | 25 | Position leverage |
minOrderUsd | 10 | — | Skip dust orders |
maxOrdersPerDay | 50 | 500 | Daily order backstop (runaway-loop circuit breaker) |
dailyLossHaltPct | 5 | 25 | Halt + flatten threshold |
maxDrawdownHaltPct | 15 | 50 | All-time drawdown halt |
allowedSymbols | — | — | Whitelist; empty = none allowed |
Both position caps are notional (solvency rails, not strategy dials)
maxPositionPct and maxTotalExposurePct are both notional (position notional ÷ equity), so they share one unit and don't depend on leverage:
maxPositionPctis the per-symbol concentration limit — how much of the book one symbol can be.maxTotalExposurePctis the aggregate exposure limit. Per-position ≤ total is enforced by zod.
A per-position loss is adverse % move × notional, leverage-independent — so notional is the honest basis for a position cap. Leverage's own danger (liquidation distance) is capped separately by maxLeverage + the exchange's per-symbol max. (Earlier the per-position cap was margin-based — notional ÷ leverage — which made it leverage-dependent and false-rejected scale-ins, since adjust_position carries no leverage. Removed in favor of notional.)
What is deliberately not an engine cap: per-order size (maxOrderUsd) and intra-hour trade frequency (maxOrdersPerHour) were removed. They encoded trading style, not solvency, and produced false rejections of legitimate trades. Per-order size is bounded by the exposure ceiling anyway (an order can't push notional past maxTotalExposurePct); trade frequency is bounded by the cron cadence plus the daily backstop. Sizing and intra-day pacing are now the agent's job, shaped by its prompt.
L2 — Agent self-restraint
The system prompt should be written to encourage prudent behavior, but we do not depend on it. A well-prompted agent might still hallucinate a 100x leverage trade. That's why L3+ exist.
We do, however, give the agent the information it needs to behave well:
- Context includes current risk metrics (leverage used, % drawdown, daily PnL)
- Context includes the engine-enforced risk caps themselves under
## Risk caps (engine-enforced)— max position notional %, max total exposure, max leverage, venue min order, daily order backstop, halt thresholds, allowed symbols. The agent reads these to size proposals correctly the first time and to choose leverage strategically (proportional to conviction, never above the cap). Per-order size and intra-day pacing are explicitly the agent's judgment, not engine caps. Seepackages/agent-runtime/src/context.ts→formatRiskCaps. - Halt status is included in context if a halt is active ("Trading halted due to daily loss limit; you may not open new positions")
- The previous tick's engine result (executed / rejected with rule code / noop) is surfaced in the next tick under
## Last decision, closing the rejection-feedback loop - The system prompt explicitly notes the engine will reject violations and frames leverage as a strategic dial, not a default
L3 — Tool whitelist
The agent only has access to tools its Skill whitelists. Specifically:
- Chat agent has no
propose_ordertool — cannot place orders by any path - A Skill that doesn't whitelist
fetch_news_sentimentcan't read news - MCP tools are only loaded if the Skill explicitly references them in
tools.mcpServers
Hydration enforces this at runtime; there's no way to invoke a non-hydrated tool.
L4 — Execution Engine validation pipeline
The engine validates every proposed action through a fixed pipeline (full detail in execution-engine.md):
- SHAPE — schema-valid?
- SCOPE — symbol allowed? action type allowed?
- POSITION — would breach
maxPositionPct(notional) ormaxTotalExposurePct, or fall below the venue min? (measures resulting notionals, so scale-ins are checked correctly) - LEVERAGE — would breach
maxLeverageor the exchange per-symbol cap? - RATE — exceeded
maxOrdersPerDay(daily backstop)? - HALT — daily loss halt tripped?
- SANITY — wildly off-market limit price? inverted protective order (a long's stop above the current price, etc.)? (the broker's at-fill check stays as the last-resort backstop)
- BROKER_REJECT (R9) — post-validation: if the broker refuses the order on its own ledger (insufficient free margin, missing mark, malformed bracket) it throws a
BrokerRejectionError, which the engine catches and converts to a cleanR9_BROKER_REJECTrejection rather than letting it abort the tick / backtest run. Any other error type is a genuine fault and propagates.
Any failure short-circuits and records a decision_snapshots row with engine_rule = 'R<N>_<NAME>'. The agent gets feedback on its next tick ("Your last 3 proposals were rejected by R3_POSITION_CAP").
Broker-side margin guard (paper broker)
In addition to the L4 pipeline above, the paper broker rejects orders whose initial-margin requirement (notional / leverage) exceeds the account's free margin. This is the broker's own ledger speaking — it has the final word on whether an order is physically representable, and runs after the engine's checks. It throws a BrokerRejectionError, which the engine converts to R9_BROKER_REJECT (see above). The Hyperliquid mainnet adapter delegates this to the exchange (testnet deprecated in ADR-0015). See ADR-0014.
Leverage is position state (matches Hyperliquid). The order that opens a position from flat sets its per-symbol leverage; every later order against that position — scale-in, reduce, or flip ("Reverse") — inherits it, and the order's own leverage field is ignored once a position exists. So an adjust_position scale-in (which carries no leverage) is charged addNotional / leverage, not the full notional at 1×, and a flip keeps the prior leverage. (Previously the broker defaulted adds to 1×, over-charging margin and silently de-levering positions on every scale-in.)
The paper broker also models cross-margin liquidation matching Hyperliquid: account equity backs all positions, and liquidation fires at the account level when equity < Σ position_notional × maintenanceMarginRate. On trigger, all positions are force-closed at current marks. This is not a control — it's market reality — but it's noted here because the agent's downside on leveraged positions depends on it being modeled at all. Without it, the agent could underestimate how risky a given leverage choice is on a sim that never wipes the position. Live brokers source liquidation state directly from the exchange API; nothing in the paper broker should be ported into live code paths.
L5 — Engine-imposed automatic halts
Three automatic, engine-controlled halts:
Daily loss halt
If realized + unrealized PnL since UTC midnight falls below -dailyLossHaltPct * dayStartEquity:
- Engine sets
deployments.status = halted, recordshalt_reason - Engine emits
close_positionorders for all open positions (the agent is bypassed) - New opening actions are rejected by rule R6
- The agent is informed in context: "Trading halted at <ts> due to daily loss limit."
- Only the deployer can clear the halt via
clear_haltcommand
Max drawdown halt
Same mechanism as daily loss halt, but measured from all-time peak equity. Triggers if (peak - current) / peak > maxDrawdownHaltPct.
Liquidation imminent (sanity)
If the engine sees that an open position is within 10% of liquidation distance, it:
- Logs a warning (
agent_logs) - Does not auto-flatten (the agent might rationally choose to let it ride or add margin)
- Surfaces a prominent banner on the deployment detail page
We deliberately do not auto-flatten on this — false positives would cost more than letting the agent handle it. Phase 3 may add an opt-in auto-flatten cap.
L6 — Deployer commands
The deployer always has the steering wheel. Available commands:
| Command | Effect | Reversible? |
|---|---|---|
pause | Stop ticking, keep position open | Yes (resume) |
resume | Re-enable ticking | n/a |
flatten | Close all positions immediately; then pause | Position-irreversible; can resume after |
stop | Drain in-flight, close WS, exit process, destroy machine | Re-deploy required |
kill | Immediate process exit, no drain (use only if stop hangs) | Re-deploy required |
clear_halt | Manually clear an engine-imposed halt (after investigating) | n/a |
snapshot | Force a fresh agent_state write | n/a |
Commands are inserted into agent_commands and delivered to the runner via Postgres LISTEN/NOTIFY. Latency target: < 1 second from click to runner action.
All commands write an agent_logs entry with the requesting user, the command, and the result. Full audit trail.
L7 — Platform-wide kill switch
A platform admin can:
- Globally halt new deployments (
DEPLOYMENTS_DISABLED=trueenv flag) - Force-stop all running deployments (admin tool calls Fly API to stop all machines)
- Disable
propose_orderglobally (engine returns rejection for all opens; existing positions can still be closed)
Used for: confirmed exchange outage, vendor security incident, suspected platform compromise. Logged and time-bounded — admin must clear within 24h or the flag self-expires.
This is not a substitute for the per-Skill controls — it's the "stop the world" lever for true emergencies.
What we deliberately do not do
- Position sizing recommendations — the engine enforces caps; sizing within caps is the agent's decision
- Cross-Skill portfolio management — each Skill is independent in MVP; if a deployer runs two Skills that both go long BTC at 10% of equity, that's 20% total. The deployer is responsible for understanding this.
- Auto-flatten on liquidation distance — false positives expensive; deployer + chat agent is the right pairing here
- Lock funds — we don't custody; users manage their own exchange wallets
Testing
Engine validation pipeline tests
packages/execution-engine ships with:
- Snapshot tests for every
RuleId— feed a synthetic proposed action that should violate rule N, assert rejection with the right rule - Property tests — generate random portfolios + random proposed actions, assert no accepted action ever violates a cap
- Integration tests with the paper broker for full sim path
Chaos / halt tests
- Simulate "agent goes rogue" by injecting proposed actions that try every variant of cap violation; assert engine rejects all
- Simulate daily loss accumulation; assert halt fires at the right threshold and flattens
- Simulate command-during-tick race; assert no command is lost
Sim/live drift
Phase 3: a job that picks a recent live deployment, replays the same Skill against the same date range in sim, and compares fill behavior. Drift > X% raises an issue.
Incident response
If something goes wrong:
- Pause the affected deployment (via web UI; cheap, instant)
- Inspect
decision_snapshots,agent_logs,agent_state— the audit trail tells the story - Determine cause: agent reasoning error? engine bug? data feed issue? exchange issue?
- If broad / multi-deployment: hit the platform-wide kill switch
- Document in a postmortem (we'll add a template under
docs/incidents/when the first one happens)
Trading platform incidents will happen. The system is designed to make them recoverable, not to pretend they won't.