Market LabDocs

Scripts

Write, backtest, and deploy Market Lab scripts in JavaScript.

A Market Lab script runs inside one persistent QuickJS session. Market Lab supplies normalized market history and execution APIs; the script decides when to call ctx.trade, ctx.order, or ctx.cancel.

market data -> onData(ctx, input, history) -> ctx.trade / ctx.order / ctx.cancel
  • script backtest simulates execution over historical records.
  • script backtest can use standalone Binance Spot or USD-M candles and volume bars without an API key. These Binance selectors are historical-only.
  • script run deploys an immutable script snapshot as a detached mlabd job.
  • script run can consume live trades through symbol-, exchange-, and provider-qualified selectors such as btc@trades@bulkf, btc@trades@hyperliquidf, tsla@trades@hyperliquidf-xyz, sndk@trades@hyperliquidf-io, 1009:0@trades@hyperliquid, and btc@trades@binancef@mmt.
  • script run --venue bulkf, --venue hyperliquidf, --venue hyperliquidf-{dex}, --venue hyperlinkf, or --venue hyperliquid arms live execution on that venue. Hyperliquid defaults to mainnet; HyperLink is mainnet-only. Market data remains independent and can come from MMT, BULK, or Hyperliquid.
  • script run --duration 3600 runs for at most one hour. Without --duration, the job runs until stopped or completed by another cause.

Symbols belong to sources

Scripts do not have a global --symbol flag. Every source declares the symbol whose data it provides:

<symbol>@<source>@<provider>
<symbol>@<source>@<exchange>@<provider>

For example:

--source btc@candles@binancef:timeframe=900
--source btc@trades@bulkf
--source btc@oi@hyperliquidf
--source zec@candles@binancef@mmt:timeframe=900
--source '1009:0@orderbook@hyperliquid:depth=20'

Short symbols are normalized internally:

btc -> BTC
zec -> ZEC

Those compact symbols are for futures. Spot selectors keep the exact pair:

hype/usdc@candles@hyperliquid
hype/usdc@orderbook@hyperliquid
hype/usdc@trades@hyperliquid

hype/usdc identifies HYPE/USDC. A bare hype is invalid for spot because it does not identify the quote asset.

Use the complete selector with history.source. input.symbol, input.source, and input.source_type identify the event that triggered the hook.

One symbol can produce a signal while another is traded. The traded symbol must be declared by at least one source:

const btc = history.source('btc@candles@binancef', 0)
const zec = history.source('zec@candles@binancef', 0)

if (btc && zec && btc.c > btc.o && zec.c < zec.o) {
  ctx.trade({
    key: `zec-entry-${zec.t}`,
    symbol: 'zec',
    position: 'open-long',
    margin: 100,
  })
}

Live execution rejects a symbol that is not declared by the script's sources. A live job still has one --venue; every symbol traded by that job executes there.

Manifest

Every script exports a manifest using base source kinds:

export const script = {
  name: 'ema-cross',
  version: '1',
  sources: ['candles', 'orderbook'],
  lookback: 100,
  params: {
    fast: { type: 'number', required: false, default: 20 },
    slow: { type: 'number', required: false, default: 50 },
    margin: { type: 'number', required: false, default: 100 },
    max_spread_bps: { type: 'number', required: true },
  },
}

Required fields are name, version, and sources.

Optional fields:

  • description: a human-readable summary of the script.
  • lookback: the maximum records retained at one time for each exact source selector.
  • params: flat script parameters with string, number, or boolean types.

Understanding lookback

lookback is a history-retention limit, not a timeframe, backtest range, or request to preload historical data.

With this manifest:

export const script = {
  name: 'sma-with-book',
  version: '1',
  sources: ['candles', 'orderbook'],
  lookback: 9,
  params: {},
}

and these runtime sources:

--source btc@candles@binancef@mmt:timeframe=60
--source btc@orderbook@bulkf:depth=20

Market Lab retains up to nine Binance candles and nine BULK orderbook snapshots. Each selector owns an independent history buffer.

History begins empty when a live script starts. lookback: 9 allows nine records to accumulate; it does not fetch nine older records before onData begins. Backtests also warm the history one event at a time so the script cannot see future data.

An eight-period SMA needs eight candles for latest, but it needs nine to calculate both latest and previous:

export function onData(ctx, input, history) {
  const candles = history.source('btc@candles@binancef@mmt')
  if (candles.length < 9) return

  const slow = ctx.study.sma(candles, { window: 8 })
  // 8 records for slow.latest, plus one older record for slow.previous.
}

A practical rule is:

largest study window + 1 when the script reads the study's previous value

Market Lab retains at least two records so indexes 0 and 1 remain available. If lookback is omitted, the current default is 5,000 records per selector; 5,000 is also the maximum. Prefer the smallest value that covers the script's actual calculations, especially when retaining orderbooks.

The manifest declares that a source kind is required. Runtime source flags choose the concrete provider and exchange:

--source btc@candles@binancef@mmt:timeframe=60
--source btc@candles@hyperliquidf@mmt:timeframe=30
--source btc@orderbook@bulkf:depth=20
--source btc@orderbook@hyperliquidf:depth=20
--source btc@trades@bulkf

MMT selectors use symbol@source@exchange@mmt. Standalone selectors use symbol@source@exchange. The complete selector is also the key used with history.source.

Outcome-side symbols keep their colon inside the selector, for example 1009:0@candles@hyperliquid:timeframe=60. Source options are parsed after the final exchange or provider segment, so the two colons are unambiguous.

Script params are flat:

--param fast=20
--param slow=50
--param max_spread_bps=3

Inside the hook they are available as:

ctx.params.fast
ctx.params.max_spread_bps

Unknown params, missing required params, and invalid values are rejected before the first hook runs.

onData

Market records are read through history.source in both live runs and backtests:

export function onData(ctx, input, history) {
  const candles = history.source('btc@candles@binancef@mmt')
  const current = history.source('btc@candles@binancef@mmt', 0)
  const previous = history.source('btc@candles@binancef@mmt', 1)

  if (!previous) return

  const fast = ctx.study.sma(candles, { window: ctx.params.fast })
  const slow = ctx.study.sma(candles, { window: ctx.params.slow })
  // strategy logic
}

Calling history.source(selector) without an index returns the retained list from oldest to newest. Supplying an index returns one record: 0 is current, 1 is previous, and so on.

input contains runtime metadata, not duplicated market records.

Common fields:

input.source // exact selector that triggered onData
input.source_type // base kind, such as "candles"
input.provider // provider that produced the event
input.exchange // exchange that produced the event
input.symbol
input.source_configs // config keyed by exact selector

Live runs and backtests provide current script positions:

input.positions.open

Every accepted source update invokes onData. Use input.source or input.source_type when logic should run only for a particular event:

if (input.source_type !== 'candles') return

For a live trade source, use its exact selector:

export function onData(ctx, input, history) {
  const latest = history.source('btc@trades@bulkf', 0)
  if (!latest) return

  // latest contains only price and size.
}

One trade invokes one onData call. See Trades Source for the full live-only contract.

Return Value and Logs

An execution script does not need to return anything:

export function onData(ctx, input, history) {
  const candle = history.source('btc@candles@binancef@mmt', 0)
  if (!candle) return

  ctx.trade({
    symbol: 'btc',
    key: `entry-${candle.t}`,
    position: 'open-long',
    margin: 100,
    order: { type: 'market' },
  })
}

signal and intent return objects are not part of the scripting contract. ctx.trade, ctx.order, and ctx.cancel are the execution instructions.

Returning nothing, null, or {} does not append a routine script.run.result entry. This keeps long-running job logs from growing with empty JSON on every market update.

Return diagnostics only when they are useful:

return {
  metrics: {
    close: candle.c,
    spread_bps: spread.spread_bps,
  },
  meta: {
    regime: 'trend',
  },
}

Only metrics and meta are accepted, and both must be objects. Execution lifecycle events and execution errors remain in job logs because they describe real state changes.

Backtest

mlab script backtest ./scripts/ema-cross.js \
  --from "2026-06-01 09:52:39" \
  --to "2026-06-03 21:54:05" \
  --source btc@candles@binancef:timeframe=60 \
  --param fast=20 \
  --param slow=50 \
  --param margin=1000 \
  --param max_spread_bps=3

This example uses the standalone Binance USD-M source. Use btc@candles@binancef@mmt instead when the data should come through MMT.

Raw trades sources are not available in backtests. Configuring one returns a live-only source error. Use a historical candle selector for backtest logic.

The simulator processes the same calls used by live scripts:

  • Historical records from every configured selector are merged by timestamp and delivered one event at a time.
  • When events share a timestamp, their --source order determines delivery order.
  • Prices for every symbol at the same timestamp are loaded before script hooks run.
  • History is updated before onData runs, and input.symbol and input.source identify the event that advanced it.
  • Every traded symbol needs its own price-bearing source, such as candles or orderbook.
  • When a symbol has several price-bearing sources, the first configured one is its deterministic backtest reference.
  • ctx.trade and ctx.order fill from the latest reference price for their own symbol. Data for one symbol is never used as another symbol's execution price.
  • Simulated trades, positions, and execution events include their symbol.
  • opening operations add to the matching net side; they cannot silently reverse an opposite position.
  • close-long and close-short are reduce-only; omitting size closes the full matching position.
  • ctx.order raw buys and sells follow one-way venue netting: an opposite fill reduces, closes, or flips the net position.
  • raw reduceOnly orders may reduce to flat but cannot increase or flip inventory.
  • OI and VD events advance the script but are never treated as execution prices.
  • limit orders can fill only on a later reference-source event that touches their price.
  • candle limits use the low for buy-side operations (open-long, close-short) and the high for sell-side operations (open-short, close-long).
  • ctx.cancel cancels a pending simulated order by stable order ID or trade key.
  • simulated pending, accepted, fill, filled, and cancelled events are delivered through onExecution.
  • sl and tp close simulated positions when later records touch their trigger.
  • if one OHLC candle touches both protection prices, the simulator chooses the stop first.

A returned object is diagnostic only and cannot create a simulated order. If no event satisfies the script's entry conditions, the backtest completes successfully with zero trades.

Historical intervals are limited to data stored by each provider. See Candles Source for the difference between live and backtest timeframes.

Live Jobs

Deploy an analysis-only job:

mlab script run ./scripts/ema-cross.js \
  --source btc@candles@binancef@mmt:timeframe=5 \
  --source btc@orderbook@bulkf:depth=20 \
  --param fast=20 \
  --param slow=50 \
  --param margin=100 \
  --param max_spread_bps=3

Add --venue bulkf to enable ctx.trade, ctx.order, and ctx.cancel:

mlab script run ./scripts/ema-cross.js \
  --source btc@candles@binancef@mmt:timeframe=5 \
  --source btc@orderbook@bulkf:depth=20 \
  --param fast=20 \
  --param slow=50 \
  --param margin=100 \
  --param max_spread_bps=3 \
  --venue bulkf

The same script can execute on Hyperliquid mainnet without changing its JavaScript:

mlab script run ./scripts/ema-cross.js \
  --source btc@candles@hyperliquidf:timeframe=5 \
  --source btc@orderbook@hyperliquidf:depth=20 \
  --param fast=20 \
  --param slow=50 \
  --param margin=100 \
  --param max_spread_bps=3 \
  --venue hyperliquidf

Add --testnet to the command to execute it on Hyperliquid testnet. The JavaScript does not change:

mlab script run ./scripts/ema-cross.js \
  --source btc@candles@hyperliquidf:timeframe=5 \
  --source btc@orderbook@hyperliquidf:depth=20 \
  --param fast=20 \
  --param slow=50 \
  --param margin=100 \
  --param max_spread_bps=3 \
  --venue hyperliquidf \
  --testnet

Outcome scripts use the same live job model with a dynamic market symbol:

mlab script run ./scripts/outcome.js \
  --source '1009:0@orderbook@hyperliquid' \
  --venue hyperliquid

Add --testnet to discover and execute against testnet outcomes. Mainnet and testnet use different outcome IDs, so select the symbol on the same network as the job.

The same runtime can execute an XYZ market by changing the declared sources and venue. Use the normalized base symbol without the xyz: wire prefix:

mlab script run ./scripts/ema-cross.js \
  --source tsla@candles@hyperliquidf-xyz:timeframe=60 \
  --source tsla@orderbook@hyperliquidf-xyz:depth=20 \
  --param fast=20 \
  --param slow=50 \
  --param margin=100 \
  --venue hyperliquidf-xyz

For a time-bounded session such as market making, add a duration in seconds:

mlab script run ./scripts/market-maker.js \
  --source btc@orderbook@bulkf:depth=20 \
  --venue bulkf \
  --duration 3600

Expiry stops the worker normally and records the job as completed. It is a runtime boundary, not a take-profit or stop-loss condition. Omitting --duration means forever.

Manage the detached job with:

mlab script jobs
mlab script status <JOB_ID>
mlab script logs <JOB_ID> --follow
mlab script stop <JOB_ID>
mlab script restart <JOB_ID>

See Script Execution before arming a live script.

On this page