Execution Engine
Package:
packages/execution-engineDepends 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 + fillEach 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:
- Engine tracks realized + unrealized PnL since UTC midnight in
agent_state - If PnL drops below
-dailyLossHaltPct * equity_at_day_start, the engine:- Rejects all new opening actions
- Auto-emits
close_positionorders for all open positions (no agent involvement) - Sets
deployments.status = halteduntil manually resumed by deployer
- 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_notionalmodel - 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_credentialsdecrypted 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 decides | Engine enforces |
|---|---|
| Direction (long / short / close) | Cap on position size |
| Sizing proposal | Cap on leverage |
| Reasoning + confidence | Cap 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:
- Snapshot tests on the validation pipeline: feed in synthetic
ProposedActions, assert the expectedRuleIdtriggers. - Property-based tests on risk caps: generate random portfolios and proposed actions, assert no accepted action violates caps.
- 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 credentials →
packages/db/credentials(engine receives a decrypted broker instance) - Tracking equity over time →
agent_stateupdates happen in the live runner after each engine result