Scripts
Write, backtest, and deploy Market Lab scripts in JavaScript.
Scripts
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 or ctx.cancel.
market data -> onData(ctx, input, history) -> ctx.trade / ctx.cancelscript backtestsimulates execution over historical records.script rundeploys an immutable script snapshot as a detachedmlabdjob.script run --venue bulkarms live BULK execution. Market data can still come from MMT, BULK, or both.script run --duration 3600runs for at most one hour. Without--duration, the job runs until stopped or completed by another cause.
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 withstring,number, orbooleantypes.
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 candles@binancef@mmt:timeframe=60
--source orderbook@bulk:depth=20Market 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('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 valueMarket 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 candles@binancef@mmt:timeframe=60
--source candles@hyperliquid@mmt:timeframe=30
--source orderbook@bulk:depth=20MMT selectors use source@exchange@mmt. BULK selectors use source@bulk. The concrete selector is also the key used with history.source.
Script params are flat:
--param fast=20
--param slow=50
--param max_spread_bps=3Inside the hook they are available as:
ctx.params.fast
ctx.params.max_spread_bpsUnknown 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('candles@binancef@mmt')
const current = history.source('candles@binancef@mmt', 0)
const previous = history.source('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 selectorLive runs and backtests provide current positions for the script symbol:
input.positions.openEvery 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') returnReturn Value and Logs
An execution script does not need to return anything:
export function onData(ctx, input, history) {
const candle = history.source('candles@binancef@mmt', 0)
if (!candle) return
ctx.trade({
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 and ctx.cancel are the only 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 \
--symbol BTC/USDT \
--from 1780307559000 \
--to 1780523645331 \
--source candles@binancef@mmt:timeframe=60 \
--param fast=20 \
--param slow=50 \
--param margin=1000 \
--param max_spread_bps=3The 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
--sourceorder determines delivery order. - History is updated before
onDataruns, andinput.sourceidentifies the event that advanced it. - The first configured selector with usable candle, orderbook, or volume prices becomes the simulator's reference-price source.
ctx.trademarket orders fill at the latest price from that reference source.- opening operations add to the matching net side; they cannot silently reverse an opposite position.
close-longandclose-shortare reduce-only; omittingsizecloses the full matching position.- 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.cancelcancels a pending simulated order by stable order ID or trade key.slandtpclose 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 \
--symbol BTC/USDT \
--source candles@binancef@mmt:timeframe=5 \
--source orderbook@bulk:depth=20 \
--param fast=20 \
--param slow=50 \
--param margin=100 \
--param max_spread_bps=3Add --venue bulk to enable ctx.trade and ctx.cancel:
mlab script run ./scripts/ema-cross.js \
--symbol BTC/USDT \
--source candles@binancef@mmt:timeframe=5 \
--source orderbook@bulk:depth=20 \
--param fast=20 \
--param slow=50 \
--param margin=100 \
--param max_spread_bps=3 \
--venue bulkFor a time-bounded session such as market making, add a duration in seconds:
mlab script run ./scripts/market-maker.js \
--symbol BTC/USDT \
--source orderbook@bulk:depth=20 \
--venue bulk \
--duration 3600Expiry 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.