Script Execution
Simulate and execute strategy scripts with ctx.trade, ctx.order, ctx.cancel, and onExecution.
Strategy scripts do not ask another layer to interpret a returned signal. They call concrete execution APIs:
onData / onExecution -> ctx.trade / ctx.order / ctx.cancel -> simulator or mlabdThe runtime depends on the command:
| Command | Execution target |
|---|---|
script backtest | Historical in-process simulator |
script run | Execution disabled |
script run --venue bulkf | Live BULK mainnet execution through mlabd |
script run --venue bulkf --testnet | Live BULK testnet execution through mlabd |
script run --venue hyperliquidf | Live Hyperliquid mainnet execution through mlabd |
script run --venue hyperliquidf --testnet | Live Hyperliquid testnet execution through mlabd |
script run --venue hyperliquidf-xyz | Live XYZ mainnet execution through mlabd |
script run --venue hyperliquidf-xyz --testnet | Live XYZ testnet execution through mlabd |
script run --venue hyperliquidf-io | Live EntropyIO mainnet execution through mlabd |
script run --venue hyperlinkf | Live HyperLink mainnet execution through mlabd |
script run --venue hyperliquid | Live Hyperliquid Spot or HIP-4 outcome execution through mlabd |
script run --venue hyperliquid --testnet | Live Hyperliquid Spot or HIP-4 testnet execution through mlabd |
Market data and execution are independent. A job can read MMT and execute on Hyperliquid, read Hyperliquid and execute on BULK, or use one standalone venue for both.
Every ctx.trade and ctx.order request names the symbol it affects. That symbol must already be declared by one of the job's sources. One source symbol can produce the signal for an order in another declared symbol, but one live script still executes all of its symbols through a single --venue.
Deploy a Live Strategy
script run submits an immutable copy of the script to mlabd and returns immediately:
mlab script run ./scripts/bulk-limit-protected.js \
--source btc@candles@bulkf:timeframe=60 \
--venue bulkf \
--param armed=true--venue bulkf, --venue hyperliquid, --venue hyperliquidf, --venue hyperliquidf-{dex}, or --venue hyperlinkf is the explicit switch that enables execution calls. BULK and Hyperliquid venues use mainnet by default; --testnet selects testnet when the market exists there. HyperLink is mainnet-only. Omitting the venue keeps an analysis-only job unable to trade.
Outcome scripts declare exact symbols such as 1009:0@orderbook@hyperliquid. Outcome execution is spot-like: it can buy an outcome-side token or sell tokens already held. It does not support naked shorting, leverage, reduce-only orders, attached stop loss or take profit, or close-position semantics. See Hyperliquid outcomes.
Use --duration <seconds> to bound the live session independently of trading outcomes. For example, --duration 3600 runs for at most one hour. Omit it to run indefinitely. Duration expiry completes the script job; it does not represent TP/SL or close a position automatically.
Live execution requires an authorized BULK agent for the selected network:
mlab auth set bulk
mlab auth set bulk --testnetHyperliquid execution requires its network-specific API agents:
mlab auth set hyperliquidThe command configures separate mainnet and testnet agents. A script uses the mainnet agent normally and the testnet agent when its command includes --testnet.
ctx.trade
ctx.trade expresses perpetual position transitions. Hyperliquid Spot scripts use ctx.order with buy or sell instead.
Place a market or limit order:
const entry = ctx.trade({
symbol: 'btc',
key: 'btc-entry-v1',
position: 'open-long',
margin: 100,
leverage: 5,
order: {
type: 'limit',
price: 65000,
tif: 'gtc',
},
sl: 63000,
tp: 69000,
})The call validates synchronously and returns a stable local reference:
{ id: "ord_...", key: "btc-entry-v1" }It does not wait for a venue fill. The command is serialized through mlabd, where the selected venue credential is loaded and the signed order is submitted.
Request fields:
| Field | Required | Contract |
|---|---|---|
symbol | Yes | Short or full symbol declared by one of the script's sources |
key | Yes | Non-empty strategy idempotency key, at most 128 bytes |
position | Yes | open-long, open-short, close-long, or close-short |
size | Open: one of | Positive base quantity; optional on close |
margin | Open: one of | Positive quote collateral; invalid on close |
leverage | Open: no | At least 1; defaults to 1; invalid on close |
order.type | No | market or limit; defaults to market |
order.price | Limit only | Positive limit price |
order.tif | Limit only | gtc, ioc, or alo; defaults to gtc |
sl | Open: no | Native stop-loss trigger price; invalid on close |
tp | Open: no | Native take-profit trigger price; invalid on close |
Opening operations require exactly one of size or margin. Market Lab multiplies margin by leverage to obtain the order exposure, then converts that exposure to lot-aligned size. Market, price, leverage, lot-size, tick-size, and minimum-notional rules are validated before signing.
For example, margin: 100 with leverage: 5 targets approximately $500 of exposure. Studies such as ctx.study.slippage still accept notional because they measure liquidity for the resulting exposure; pass margin * leverage to those studies.
Closing operations are always reduce-only. Omit size to close the complete matching position, or pass size for a partial close:
ctx.trade({
symbol: 'btc',
key: `close-long-${candle.t}`,
position: 'close-long',
})Market Lab uses one-way position semantics. open-long can add to an existing long and open-short can add to an existing short. An opening operation cannot silently reverse the opposite position: submit close-long before open-short, or close-short before open-long. Closing the wrong side or more than the current size is rejected.
Use ctx.trade when the position transition is intentional. Use ctx.order when the script needs to place a raw buy or sell whose eventual effect depends on the net inventory when it fills.
Idempotency
The key belongs to one script job. Repeating the exact request returns the existing managed order. Reusing the key with different parameters is rejected.
This makes retries safe across repeated data hooks and worker restarts. Derive keys from stable strategy facts rather than the current wall-clock time:
const order = ctx.trade({
symbol: 'btc',
key: `ema-cross-${candle.t}`,
position: 'open-long',
margin: 100,
order: { type: 'market' },
})ctx.order
Place a raw buy or sell without declaring an intended position transition:
const ask = ctx.order({
symbol: 'btc',
key: 'btc-maker-ask-1',
side: 'sell',
size: 0.01,
leverage: 5,
order: {
type: 'limit',
price: 66000,
tif: 'alo',
},
})buy and sell are the canonical sides. long is accepted as an alias for buy, and short is accepted as an alias for sell. Structured output always serializes the canonical value.
Request fields:
| Field | Required | Contract |
|---|---|---|
symbol | Yes | Short or full symbol declared by one of the script's sources |
key | Yes | Stable idempotency key shared with ctx.trade |
side | Yes | buy/long or sell/short |
size | One of | Positive base quantity |
margin | One of | Positive quote collateral used with leverage to derive size |
leverage | No | At least 1; defaults to 1 |
reduceOnly | No | Defaults to false; prevents an order from increasing or flipping inventory |
order.type | No | market or limit; defaults to market |
order.price | Limit | Positive limit price |
order.tif | Limit | gtc, ioc, or alo; defaults to gtc |
Exactly one of size or margin is required. Raw orders do not accept sl or tp, because their position effect is not known until they fill.
Non-reduce-only raw orders follow execution-venue netting:
Long 10 + sell 4 -> Long 6
Long 10 + sell 10 -> Flat
Long 10 + sell 14 -> Short 4The same rules apply in backtests. Same-side fills increase the net position and update its weighted entry. Opposite-side fills realize the closed quantity and any remainder opens the other side. A reduce-only raw order can reduce to flat but never flip.
ctx.order returns the same stable { id, key } reference as ctx.trade. Pass that ID or key to ctx.cancel.
Hyperliquid Spot
A spot symbol includes its real quote asset:
ctx.order({
symbol: 'hype/usdc',
key: 'hype-spot-buy',
side: 'buy',
margin: 100,
order: { type: 'market' },
})Run the script with --venue hyperliquid or --venue hyperliquid --testnet. Spot orders support market and limit orders with GTC, IOC, and ALO. They reject leverage, reduce-only, SL/TP, and position-close semantics. For a buy, margin is the quote-asset budget.
Native SL and TP
sl and tp remain native venue orders.
They are perpetual-only. Hyperliquid Spot rejects both fields.
- BULK attaches on-fill protection to the parent. Supplying both creates a native OCO range whose triggered leg cancels its sibling.
- Hyperliquid submits reduce-only market triggers with
normalTpslgrouping. The current adapter does not promise OCO sibling cancellation. - Market Lab does not poll prices locally to emulate triggers.
Protection is valid only on open-long and open-short. Trigger prices must align with the market tick and be on the correct side of the entry.
The backtest simulator uses the same request fields. On candle data, if one bar touches both sl and tp, the simulator chooses the stop first because the intra-bar path is unknown.
ctx.cancel
Cancel a managed order by its stable local ID or original trade key:
ctx.cancel({
key: 'cancel-btc-entry-v1',
order: entry.id,
})The return value confirms that the command was queued:
{
key: "cancel-btc-entry-v1",
order: "ord_...",
status: "queued"
}Cancellation keys are idempotent inside the job. If cancellation is requested before the venue returns its order ID, mlabd records order.cancel_requested; no venue cancellation is sent at that moment.
onExecution
Scripts may export a second hook for asynchronous order, fill, position, and account events:
export function onExecution(ctx, event) {
if (event.type === 'order.rejected') {
return {
metrics: {
rejected_order: event.orderId,
details: event.data,
},
}
}
if (event.type === 'order.fill') {
return {
metrics: {
order_id: event.orderId,
venue_order_id: event.venueOrderId,
status: event.status,
},
}
}
}onExecution remains a two-argument hook. Live source history is passed only to onData(ctx, input, history).
Event envelope:
{
"seq": 7,
"jobId": "job_...",
"tsMs": 1780000000000,
"type": "order.fill",
"orderId": "ord_...",
"key": "btc-entry-v1",
"symbol": "BTC",
"venue": "bulkf",
"venueOrderId": "...",
"status": "filled",
"terminal": false,
"data": {}
}Event types include:
order.pending,order.accepted,order.terminal, andorder.updatedorder.fill,order.filled,order.cancel_requested, andorder.cancelledorder.rejected,order.cancel_failed, andorder.cancel_rejectedposition.updated,position.closed,position.liquidated, andposition.adlaccount.margin_updated
tsMs is the daemon receipt and journal time. Venue payloads retain their own timestamp inside data, which can differ from wall-clock time. Keep the two clocks separate: use the venue timestamp for exchange-event ordering and tsMs for local delivery latency and runtime diagnostics.
Each order, fill, position, and execution event identifies its symbol. Each fill is emitted as order.fill. A partially filled order remains active and may also emit a non-terminal order.updated; only order.filled marks the order fully filled.
onExecution uses the same persistent QuickJS session as onData, so module state remains available. It may call ctx.trade, ctx.order, or ctx.cancel to react to an event.
Events are journaled per job and acknowledged only after the hook succeeds. An unacknowledged event is replayed after a worker restart. Execution keys keep commands idempotent during replay.
Live jobs receive venue lifecycle events. Backtests generate simulated pending, accepted, fill, filled, and cancelled events, allowing the same order-replacement logic to run in both environments. Both expose current script positions through input.positions.open.
During a backtest, every traded symbol needs its own price-bearing source. Market Lab uses the first configured candle, orderbook, or other price-bearing source for that symbol as its deterministic reference. Prices for sources sharing a timestamp are loaded before hooks run, so BTC data cannot accidentally become the execution price for ZEC.
Historical OHLC bars cannot reveal the path between multiple limit prices touched in one bar. Market Lab applies previously submitted orders first and then stable local order ID order for orders submitted on the same event, keeping results deterministic without claiming an unknown intra-bar sequence.
Job Operations
List and inspect deployed jobs:
mlab script jobs
mlab script status <JOB_ID>Tail structured worker output:
mlab script logs <JOB_ID> --follow
mlab script logs <JOB_ID> --follow --output jsonlTerminal output is a compact lifecycle view: resting orders, fills, cancellations, rejections, position-size changes, closes, and errors. Routine pending records, repeated margin snapshots, and unchanged position snapshots are hidden. Use --output jsonl when the complete event payload is required.
Expected venue rejections are shown once with their native status, for example rejectedCrossing. A post-only script should wait for fresh orderbook data before retrying rather than immediately resubmitting the rejected or filled price.
Order state is monotonic. Once an order becomes filled, cancelled, or rejected, delayed account-stream or recovery records cannot move it back to a non-terminal state such as resting.
Live source connections are supervised. If a market-data WebSocket disconnects, the worker remains alive, cancels its non-terminal managed orders, and reconnects with exponential backoff from one to 30 seconds. Heartbeats and execution-event processing continue while the source is unavailable. After reconnecting, the script waits for new source data before onData runs again; trade-built candles discard the new partial startup bucket.
Terminal logs show source disconnects, successful reconnects, cleanup failures, and the final worker error. A transient source disconnect does not change the job to failed; only a terminal worker error does.
Stop or restart the immutable snapshot:
mlab script stop <JOB_ID>
mlab script restart <JOB_ID>Stopping a script, reaching its configured duration, losing its live market-data connection, or failing its worker cancels every non-terminal order managed by that job. This removes resting quotes but does not close an existing position; position exit policy remains the script owner's decision.
Job states are starting, running, stopping, stopped, completed, and failed.
Worker snapshots, output, execution events, and logs are stored under the owner-only runtime directory:
~/.market-lab/execution/jobs/<JOB_ID>/Failure Boundary
Execution commands are committed only after a hook returns successfully. If the hook throws, commands collected during that invocation are cleared.
After a successful hook:
- the worker sends commands to
mlabd - execution errors are appended as
script.execution.error - lifecycle events are delivered to
onExecution - the lifecycle event is appended as
script.execution.eventwhether or notonExecutionis exported; an explicit{ metrics, meta }hook return is included only when present
Ordinary onData calls that return nothing, null, or {} do not append a script.run.result. A non-empty { metrics, meta } return is an explicit diagnostic and is logged. Returned signal and intent objects are not accepted and cannot execute an order.
The strategy never receives the delegated private key. Signing, nonce sequencing, account streaming, order correlation, and event journaling remain inside mlabd. BULK signed mutations use its persistent trading WebSocket; Hyperliquid signed actions use its HTTP exchange endpoint. Scripts do not create or authenticate either connection themselves.