Agentic Trading
Architecture

Execution Engine

Package: packages/execution-engine Depends on: packages/skill-schema, packages/brokers/*, packages/db

The one-paragraph idea

The Execution Engine is the platform's trust boundary. AI agents never talk to exchanges directly. They emit a structured ProposedAction, which the engine validates against the Skill's risk caps and global platform guards. Only validated actions reach a broker adapter. The same engine module runs in sim (paper broker) and live (Hyperliquid adapter) — sim is a faithful preview of live by construction.

Why this exists

LLMs are non-deterministic and can be prompt-injected, hallucinate sizes, or rationalize unsafe trades. Risk controls implemented inside the agent's prompt are not controls — they're suggestions. The engine treats every proposed action as untrusted input from an external system.

Public API

// packages/execution-engine/src/index.ts
import type { Skill, ProposedAction } from '@repo/skill-schema';
import type { BrokerAdapter } from '@repo/brokers';

export type ProcessInput = {
  skill: Skill;
  deploymentId: string;
  proposedAction: ProposedAction | null;   // null = no-op tick
  broker: BrokerAdapter;
  portfolioSnapshot: PortfolioSnapshot;
};

export type EngineResult =
  | { kind: 'noop'; reason: 'agent_proposed_nothing' }
  | { kind: 'rejected'; rule: RuleId; detail: string }
  | { kind: 'executed'; orderId: string; fill: Fill | null };

export async function process(input: ProcessInput): Promise<EngineResult>;

Validation pipeline

The engine runs proposed actions through a fixed pipeline. Each stage can reject. The first rejection short-circuits the rest.

ProposedAction


1. SHAPE      — zod schema valid? required fields present?


2. SCOPE      — symbol allowed by Skill? action type allowed?


3. POSITION   — resulting NOTIONAL exceed max_position_pct (per-symbol) or
                max_total_exposure_pct? below venue min? (measures resulting
                notionals, so scale-ins are checked correctly)


4. LEVERAGE   — resulting leverage exceeds max_leverage / exchange per-symbol cap?


5. RATE       — orders-per-DAY backstop exceeded for this deployment?


6. HALT       — daily loss halt tripped? deployment paused/stopped?


7. SANITY     — wildly off-market limit price? inverted protective order
                (long stop above price / short stop below, validated against
                the broker's current mark + stop-vs-TP ordering)?


8. RECORD     — write decision snapshot intent


9. EXECUTE    — call broker adapter. A broker-ledger rejection (insufficient
                margin, missing mark, malformed bracket) throws a
                BrokerRejectionError → caught and recorded as R9_BROKER_REJECT,
                never an uncaught throw that aborts the run. (Leverage is
                position state: scale-ins / flips inherit the position's
                leverage; only a fresh open sets it.)


10. RECORD    — write engine result + fill

Each rule has a stable RuleId (e.g. R3_POSITION_CAP) so audit and Skill-author UX can show "rejected by R3" with a human-readable reason.

Proposed action schema

// packages/skill-schema/src/proposed-action.ts
export const ProposedAction = z.discriminatedUnion('action', [
  z.object({
    action: z.literal('open_long'),
    symbol: z.string(),
    sizeUsd: z.number().positive(),     // notional in USD
    leverage: z.number().min(1).optional(),  // defaults to skill default
    orderType: z.enum(['market', 'limit']).default('market'),
    limitPrice: z.number().positive().optional(),
    reason: z.string().max(500),
    confidence: z.number().min(0).max(1).optional(),
  }),
  z.object({
    action: z.literal('open_short'),
    /* same shape */
  }),
  z.object({
    action: z.literal('close_position'),
    symbol: z.string(),
    fraction: z.number().min(0).max(1).default(1),  // partial close support
    reason: z.string().max(500),
  }),
  z.object({
    action: z.literal('adjust_position'),
    symbol: z.string(),
    targetSizeUsd: z.number(),          // signed; negative = short
    reason: z.string().max(500),
  }),
  z.object({
    action: z.literal('cancel_order'),
    orderId: z.string(),
    reason: z.string().max(500),
  }),
  z.object({
    action: z.literal('no_op'),
    reason: z.string().max(500),
  }),
]);

The propose_order tool's input schema is this. The agent's tool call → ProposedAction is a zero-cost handoff.

Risk caps (Skill-defined, engine-enforced)

type RiskCaps = {
  // Position sizing — both NOTIONAL (notional / equity), leverage-independent
  maxPositionPct: number;          // single position notional % of equity (per-symbol concentration, ≤ maxTotalExposurePct)
  maxTotalExposurePct: number;     // sum of all open position notionals
  maxLeverage: number;             // hard cap regardless of agent request
  minOrderUsd: number;             // skip tiny orders (fee drag)
  // Rate limiting — daily backstop only (runaway-loop circuit breaker;
  // per-order size and intra-hour cadence are the agent's job, not engine caps)
  maxOrdersPerDay: number;
  // Loss management
  dailyLossHaltPct: number;        // halt + flatten if daily PnL drops below -X%
  maxDrawdownHaltPct: number;      // halt + flatten if equity drops X% from peak
  // Symbol allowlist
  allowedSymbols: string[];
};

These are validated at Skill save time (cannot save a Skill with insane caps like 100x leverage). The engine reads them at every process() call — Skill updates take effect on the next tick.

Daily loss halt

A special engine guard, not just a risk cap:

  1. Engine tracks realized + unrealized PnL since UTC midnight in agent_state
  2. If PnL drops below -dailyLossHaltPct * equity_at_day_start, the engine:
    • Rejects all new opening actions
    • Auto-emits close_position orders for all open positions (no agent involvement)
    • Sets deployments.status = halted until manually resumed by deployer
  3. The agent is informed via context assembly: "Trading halted at <time> due to daily loss limit. You cannot open new positions."

The agent cannot override this. Only the deployer can clear the halt via a manual command.

Broker adapter interface

// packages/brokers/src/types.ts
export interface BrokerAdapter {
  readonly kind: 'paper' | 'hyperliquid-mainnet';

  placeOrder(req: PlaceOrderRequest): Promise<PlaceOrderResponse>;
  cancelOrder(orderId: string): Promise<CancelResponse>;
  getPositions(): Promise<Position[]>;
  getOpenOrders(): Promise<Order[]>;
  getEquity(): Promise<EquitySnapshot>;
}

The engine knows nothing about Hyperliquid. It calls broker.placeOrder(...). Adding a new exchange means writing a new adapter.

Paper broker semantics

For sim and dev mode:

  • Market orders fill at the next bar's open by default (configurable to close or mid)
  • Limit orders fill if a subsequent bar trades through the limit price
  • Configurable slippage: bps_per_million_notional model
  • Configurable fees: maker / taker bps
  • Partial fills disabled by default (configurable)
  • Returns deterministic order IDs

Documented assumptions explicit in code, surfaced in sim reports so authors know what they're trusting.

Hyperliquid adapter

  • Wraps @nktkas/hyperliquid
  • Translates ProposedAction → Hyperliquid order params (handles tick size, lot size rounding)
  • Returns the SDK's order response, normalized to PlaceOrderResponse
  • Re-uses one signed wallet per deployment, held in engine memory (loaded from exchange_credentials decrypted at runner boot)

Audit trail

Every process() call writes a row to decision_snapshots:

decision_snapshots (
  id              uuid pk,
  deployment_id   uuid,
  sim_run_id      uuid null,
  tick_at         timestamptz,
  context_json    jsonb,
  steps_json      jsonb,        -- model's tool-call sequence
  final_text      text,
  proposed_action jsonb null,
  engine_rule     text null,    -- e.g. 'R3_POSITION_CAP' if rejected
  engine_result   jsonb,        -- {kind, orderId?, fill?, rejectDetail?}
  cost_usd        numeric,
  created_at      timestamptz
)

This is what the chat agent reads when explaining past decisions, and what compliance / debugging needs. Every decision, including no-ops and rejections, has a row.

Engine vs agent — division of responsibility

Agent decidesEngine enforces
Direction (long / short / close)Cap on position size
Sizing proposalCap on leverage
Reasoning + confidenceCap on order rate
Symbol selection (within Skill allowlist)Daily loss halt + auto-flatten
Order type (market / limit)Symbol allowlist
When to act (or skip the tick)Sanity checks (off-market price etc.)
Audit log

The agent's reason field is preserved in the snapshot regardless of accept/reject — useful when investigating why the engine rejected a series of attempts.

Testing

The engine is pure (modulo the broker adapter). It is tested by:

  1. Snapshot tests on the validation pipeline: feed in synthetic ProposedActions, assert the expected RuleId triggers.
  2. Property-based tests on risk caps: generate random portfolios and proposed actions, assert no accepted action violates caps.
  3. Adapter conformance tests: every broker adapter must pass a shared test suite.

What lives outside this package

  • Generating the proposed action → agent runtime
  • Persisting the snapshot → caller (live runner writes synchronously after engine returns; simulator writes in batches)
  • Encrypting / decrypting credentialspackages/db/credentials (engine receives a decrypted broker instance)
  • Tracking equity over timeagent_state updates happen in the live runner after each engine result

On this page