MarketLab Docs
Scripting

Script Execution

Simulate and execute strategy scripts with ctx.trade, ctx.cancel, and onExecution.

Script Execution

Strategy scripts do not ask another layer to interpret a returned signal. They call a concrete execution API:

onData -> ctx.trade / ctx.cancel -> simulator or mlabd

The runtime depends on the command:

CommandExecution target
script backtestHistorical in-process simulator
script runExecution disabled
script run --venue bulkLive BULK execution through mlabd

Market data and execution are independent. A job can read MMT and execute on BULK, or use BULK for both.

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 \
  --provider bulk \
  --symbol BTC/USDT \
  --source candles:timeframe=60 \
  --venue bulk \
  --param candles:armed=true

--venue bulk is the explicit switch that enables ctx.trade and ctx.cancel. Omitting it keeps an analysis-only job unable to trade.

Live execution requires an authorized BULK agent:

mlab auth set bulk

ctx.trade

Place a market or limit order:

const entry = ctx.trade({
  key: 'btc-entry-v1',
  side: 'long',
  notional: 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 agent credential is loaded and the signed order is submitted.

Request fields:

FieldRequiredContract
keyYesNon-empty strategy idempotency key, at most 128 bytes
sideYeslong or short
sizeOne ofPositive base quantity
notionalOne ofPositive quote notional
leverageNoAt least 1; defaults to 1
order.typeNomarket or limit; defaults to market
order.priceLimit onlyPositive limit price
order.tifLimit onlygtc, ioc, or alo; defaults to gtc
reduceOnlyNoReduce-only execution; defaults to false
slNoNative stop-loss trigger price
tpNoNative take-profit trigger price

Set exactly one of size or notional. Market, price, leverage, lot-size, tick-size, and minimum-notional rules are validated before signing.

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({
  key: `ema-cross-${candle.t}`,
  side: 'long',
  notional: 100,
  order: { type: 'market' },
})

Native SL and TP

sl and tp are attached to the parent order using BULK native on-fill actions.

  • sl creates native stop protection.
  • tp creates native take-profit protection.
  • both create a native OCO range where one trigger cancels its sibling.
  • protection activates after the parent entry fills.
  • Market Lab does not poll prices locally to emulate triggers.

Protection cannot be attached to a reduce-only order. 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 BULK returns its order ID, mlabd records order.cancel_requested; no venue cancellation is sent at that moment.

onExecution

Live jobs 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,
      },
    }
  }
}

Event envelope:

{
  "seq": 7,
  "jobId": "job_...",
  "tsMs": 1780000000000,
  "type": "order.fill",
  "orderId": "ord_...",
  "key": "btc-entry-v1",
  "venue": "bulk",
  "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

onExecution uses the same persistent QuickJS session as onData, so module state remains available. It may call ctx.trade 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.

onExecution is a live job hook. The historical simulator applies fills and protection internally and exposes resulting positions through input.positions.open instead.

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

Stop or restart the immutable snapshot:

mlab script stop <JOB_ID>
mlab script restart <JOB_ID>

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. successful hook output is appended as script.execution.result

The strategy never receives the agent private key. Signing, nonce sequencing, account streaming, order correlation, and event journaling remain inside mlabd.

On this page