Scripts
Write custom Market Lab scripts in JavaScript.
Scripts
A script reads one or more Market Lab data sources and returns structured JSON.
Scripts can return only metrics, or they can include signal and intent for strategy-like logic. Market Lab does not execute actions from scripts directly.
Architecture
source data -> onData(ctx, input) -> script resultThe script boundary is strict:
ctx.params + input.<source> -> JSON-serializable resultinput.<source>contains market data grouped by source.ctx.params.<source>contains validated user parameters grouped by source.--source <source>:<key>=<value>configures how Market Lab fetches source data.--param <source>:<key>=<value>configures how the script interprets that source data.
Commands
Backtest a script:
mlab script backtest ./scripts/sma-cross.js \
--provider mmt \
--exchange bybitf \
--symbol BTC/USDT \
--from 1780307559000 \
--to 1780523645331 \
--source candles:timeframe=60 \
--param candles:fast=20 \
--param candles:slow=50 \
--leverage 1Backtest with multiple sources:
mlab script backtest ./scripts/book-filter.js \
--provider mmt \
--exchange bybitf \
--symbol BTC/USDT \
--from 1780307559000 \
--to 1780523645331 \
--source candles:timeframe=60 \
--source orderbook:timeframe=60 \
--source orderbook:depth=100 \
--param candles:fast=20 \
--param candles:slow=50 \
--param orderbook:max_spread_bps=3Stream a candle script:
mlab script run ./scripts/buy-pressure.js \
--provider mmt \
--exchange bybitf \
--symbol BTC/USDT \
--source candles:timeframe=60 \
--param candles:min_vbuy=50000Run a multi-source live script:
mlab script run ./scripts/all-sources-live.js \
--provider mmt \
--exchange hyperliquid \
--symbol HYPE/USDT \
--source candles:timeframe=60 \
--source orderbook:depth=100 \
--source vd:timeframe=60 \
--source vd:bucket=1Manifest
Every script exports a script manifest:
export const script = {
name: "book-filter",
version: "1",
sources: ["candles", "orderbook"],
clock: "candles",
lookback: 100,
params: {
candles: {
fast: { type: "number", required: false, default: 20 },
slow: { type: "number", required: false, default: 50 }
},
orderbook: {
max_spread_bps: { type: "number", required: false, default: 3 }
}
}
}Required fields:
nameversionsources
Optional fields:
clock: source that drives the backtest loop. Defaults to the first source.lookback: maximum recent records passed to each window hook. Maximum is 5,000.params: source-scoped script parameters.
modes is not required. The command decides the mode:
script backtestcallsonDatawithinput.mode === "window"and source arrays.script runcallsonDatawithinput.mode === "stream"and live source records.
If your script only handles one mode, that is fine. A script that assumes input.candles.candles will fail in live mode because stream input uses input.candles.candle.
Current sources are documented under Scripting Sources.
Param types:
stringnumberboolean
Param names must use snake_case and cannot reuse Market Lab runtime/source names like provider, exchange, symbol, timeframe, depth, from, or to.
Input Shape
onData receives grouped source data. See Data Types for exact source payload shapes.
Always check input.mode before reading source payloads if your script may be used with both script backtest and script run.
For live multi-source scripts, also check input.source and guard source payloads. Market Lab calls onData whenever any subscribed source updates, so input.candles, input.orderbook, or input.vd may be missing until that stream has produced its first record.
export function onData(ctx, input) {
if (input.mode === "window") {
const candles = input.candles.candles
// historical/backtest logic
}
if (input.mode === "stream") {
const candle = input.candles.candle
// live logic
}
throw new Error(`unsupported input.mode: ${input.mode}`)
}This is intentional. Market Lab does not guess whether your script expects arrays or live records. A script that reads input.candles.candles in live mode will fail because live candle mode passes input.candles.candle.
Multi-source example:
export function onData(ctx, input) {
const candles = input.candles.candles
const latestBook = input.orderbook.books.at(-1)
const fast = ctx.study.sma(candles, {
field: "c",
window: ctx.params.candles.fast
})
const spread = ctx.study.spread(latestBook)
const allowedSpread = spread.spread_bps <= ctx.params.orderbook.max_spread_bps
return {
metrics: {
candles: candles.length,
spread_bps: spread.spread_bps,
fast_sma: fast.latest
},
signal: {
triggered: allowedSpread,
side: allowedSpread ? "buy" : "neutral"
},
intent: allowedSpread ? { side: "buy" } : {}
}
}Context
The first hook argument is ctx.
ctx.params.candles.fast
ctx.params.orderbook.max_spread_bps
ctx.study.sma(...)
ctx.study.spread(...)Unknown params, missing required params, and invalid param types are rejected before onData runs.
See Built-In Functions for ctx.study helpers.
Return Value
A script must return a JSON-serializable object.
Metrics-only:
return {
metrics: {
spread_bps: 1.2,
candles: 50
}
}Strategy-like:
return {
metrics: {
close: 68120.5,
spread_bps: 1.2
},
signal: {
event: "setup",
side: "buy",
triggered: true,
reason: "conditions passed"
},
intent: {
type: "order",
side: "buy",
order_type: "market",
notional: 1000
}
}Do not return the full Market Lab envelope. Market Lab wraps the result.
Backtest Output
script backtest treats strategy-like output as a trade lifecycle:
signal/intent -> open position -> close position_id -> closed tradeEach open intent creates a separate position. Market Lab assigns deterministic ids:
pos_000001
pos_000002Scripts receive current positions on every backtest hook:
input.positions.openUse this to manage take-profit, stop-loss, grid legs, scale-ins, and independent exits.
Backtest leverage is configured with --leverage. This is an execution/accounting setting, not a script parameter.
intent.notionalremains the position notional.- Margin capital is calculated as
notional / leverage. - PnL is still based on the full notional position.
- Return, drawdown, and Sharpe are calculated from margin-based returns.
- Liquidation, funding, and exchange margin rules are not modeled yet.
Example:
mlab script backtest ./scripts/sma-cross.js \
--provider mmt \
--exchange bybitf \
--symbol BTC/USDT \
--from 1780307559000 \
--to 1780523645331 \
--source candles:timeframe=60 \
--param candles:fast=20 \
--param candles:slow=50 \
--param candles:notional=1000 \
--leverage 5Terminal output is grouped into:
summary: signals, orders, closed trades, open positions, wins/losses, win rateperformance: leverage, margin capital, realized PnL, unrealized PnL, total PnL, total return, gross PnL, profit factor, average/best/worst trade, Sharpe, max drawdownclosed_trades: per-trade entry, exit, notional, margin, PnL, bars held, and exit reasonopen_positions: mark-to-market view for any position still open when the backtest ends
Default terminal output shows the first 10 closed trades. Add --verbose to show all trades and the latest raw script output.
Market Lab currently derives entries/exits from signal and intent:
action: "open", side: "long"opens a new long positionaction: "open", side: "short"opens a new short positionside: "buy"orside: "long"also opens a new long positionside: "sell"orside: "short"also opens a new short positionaction: "close", position_id: "pos_000001"closes that exact positionaction: "close_all"closes every open position
There is no hidden FIFO close and no automatic flip close. If a script opens three positions, all three remain open until the script closes the exact position_id or returns close_all.
Example close-on-target logic:
for (const pos of input.positions.open) {
if (latest.c >= pos.entry_price * 1.02) {
return {
signal: {
event: "take_profit",
side: pos.side,
triggered: true,
reason: `target hit for ${pos.id}`
},
intent: {
action: "close",
position_id: pos.id,
reason: "take profit hit"
}
}
}
}If a backtest ends with an open position, it is reported under open_positions instead of being silently counted as a completed trade. Its unrealized PnL is included in total/net performance so the result is mark-to-market, not closed-trades-only.
Live Run Summary
script run is live mode. When you stop it with Ctrl+C, Market Lab prints a final summary before shutdown:
script run summary
------------------
status: cancelled
updates: 120
signals: 8
intents: 3
hook failures: 0
last ts: 1780523640000The runtime report is also finalized as cancelled in ~/.market-lab/runs.
Runtime Reports
Every script run and script backtest writes a runtime report to:
~/.market-lab/runsList recent runs:
mlab script runs listView one run:
mlab script runs show <run-id>