Agentic Trading
Security

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:

CapDefaultHard maxEffect
maxPositionPct25min(2500, maxTotalExposurePct)Single position notional / equity — per-symbol concentration limit. Leverage-independent; constrained ≤ maxTotalExposurePct.
maxTotalExposurePct25min(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%).
maxLeverage325Position leverage
minOrderUsd10Skip dust orders
maxOrdersPerDay50500Daily order backstop (runaway-loop circuit breaker)
dailyLossHaltPct525Halt + flatten threshold
maxDrawdownHaltPct1550All-time drawdown halt
allowedSymbolsWhitelist; 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:

  • maxPositionPct is the per-symbol concentration limit — how much of the book one symbol can be.
  • maxTotalExposurePct is 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. See packages/agent-runtime/src/context.tsformatRiskCaps.
  • 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_order tool — cannot place orders by any path
  • A Skill that doesn't whitelist fetch_news_sentiment can'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):

  1. SHAPE — schema-valid?
  2. SCOPE — symbol allowed? action type allowed?
  3. POSITION — would breach maxPositionPct (notional) or maxTotalExposurePct, or fall below the venue min? (measures resulting notionals, so scale-ins are checked correctly)
  4. LEVERAGE — would breach maxLeverage or the exchange per-symbol cap?
  5. RATE — exceeded maxOrdersPerDay (daily backstop)?
  6. HALT — daily loss halt tripped?
  7. 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)
  8. 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 clean R9_BROKER_REJECT rejection 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:

  1. Engine sets deployments.status = halted, records halt_reason
  2. Engine emits close_position orders for all open positions (the agent is bypassed)
  3. New opening actions are rejected by rule R6
  4. The agent is informed in context: "Trading halted at <ts> due to daily loss limit."
  5. Only the deployer can clear the halt via clear_halt command

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:

CommandEffectReversible?
pauseStop ticking, keep position openYes (resume)
resumeRe-enable tickingn/a
flattenClose all positions immediately; then pausePosition-irreversible; can resume after
stopDrain in-flight, close WS, exit process, destroy machineRe-deploy required
killImmediate process exit, no drain (use only if stop hangs)Re-deploy required
clear_haltManually clear an engine-imposed halt (after investigating)n/a
snapshotForce a fresh agent_state writen/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=true env flag)
  • Force-stop all running deployments (admin tool calls Fly API to stop all machines)
  • Disable propose_order globally (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:

  1. Pause the affected deployment (via web UI; cheap, instant)
  2. Inspect decision_snapshots, agent_logs, agent_state — the audit trail tells the story
  3. Determine cause: agent reasoning error? engine bug? data feed issue? exchange issue?
  4. If broad / multi-deployment: hit the platform-wide kill switch
  5. 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.

On this page