Market LabDocs

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 mlabd

The runtime depends on the command:

CommandExecution target
script backtestHistorical in-process simulator
script runExecution disabled
script run --venue bulkfLive BULK mainnet execution through mlabd
script run --venue bulkf --testnetLive BULK testnet execution through mlabd
script run --venue hyperliquidfLive Hyperliquid mainnet execution through mlabd
script run --venue hyperliquidf --testnetLive Hyperliquid testnet execution through mlabd
script run --venue hyperliquidf-xyzLive XYZ mainnet execution through mlabd
script run --venue hyperliquidf-xyz --testnetLive XYZ testnet execution through mlabd
script run --venue hyperliquidf-ioLive EntropyIO mainnet execution through mlabd
script run --venue hyperlinkfLive HyperLink mainnet execution through mlabd
script run --venue hyperliquidLive Hyperliquid Spot or HIP-4 outcome execution through mlabd
script run --venue hyperliquid --testnetLive 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 --testnet

Hyperliquid execution requires its network-specific API agents:

mlab auth set hyperliquid

The 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:

FieldRequiredContract
symbolYesShort or full symbol declared by one of the script's sources
keyYesNon-empty strategy idempotency key, at most 128 bytes
positionYesopen-long, open-short, close-long, or close-short
sizeOpen: one ofPositive base quantity; optional on close
marginOpen: one ofPositive quote collateral; invalid on close
leverageOpen: noAt least 1; defaults to 1; invalid on close
order.typeNomarket or limit; defaults to market
order.priceLimit onlyPositive limit price
order.tifLimit onlygtc, ioc, or alo; defaults to gtc
slOpen: noNative stop-loss trigger price; invalid on close
tpOpen: noNative 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:

FieldRequiredContract
symbolYesShort or full symbol declared by one of the script's sources
keyYesStable idempotency key shared with ctx.trade
sideYesbuy/long or sell/short
sizeOne ofPositive base quantity
marginOne ofPositive quote collateral used with leverage to derive size
leverageNoAt least 1; defaults to 1
reduceOnlyNoDefaults to false; prevents an order from increasing or flipping inventory
order.typeNomarket or limit; defaults to market
order.priceLimitPositive limit price
order.tifLimitgtc, 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 4

The 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 normalTpsl grouping. 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, and order.updated
  • order.fill, order.filled, order.cancel_requested, and order.cancelled
  • order.rejected, order.cancel_failed, and order.cancel_rejected
  • position.updated, position.closed, position.liquidated, and position.adl
  • account.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 jsonl

Terminal 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:

  1. the worker sends commands to mlabd
  2. execution errors are appended as script.execution.error
  3. lifecycle events are delivered to onExecution
  4. the lifecycle event is appended as script.execution.event whether or not onExecution is 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.

On this page