MarketLab Docs
Scripting

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 result

The script boundary is strict:

ctx.params + input.<source> -> JSON-serializable result
  • input.<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 1

Backtest 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=3

Stream 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=50000

Run 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=1

Manifest

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:

  • name
  • version
  • sources

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 backtest calls onData with input.mode === "window" and source arrays.
  • script run calls onData with input.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.

SourceBacktestLive run
candlesYesYes
orderbookYesYes
vdYesYes
oiYesYes
volumesYesYes

Param types:

  • string
  • number
  • boolean

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 trade

Each open intent creates a separate position. Market Lab assigns deterministic ids:

pos_000001
pos_000002

Scripts receive current positions on every backtest hook:

input.positions.open

Use 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.notional remains 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 5

Terminal output is grouped into:

  • summary: signals, orders, closed trades, open positions, wins/losses, win rate
  • performance: leverage, margin capital, realized PnL, unrealized PnL, total PnL, total return, gross PnL, profit factor, average/best/worst trade, Sharpe, max drawdown
  • closed_trades: per-trade entry, exit, notional, margin, PnL, bars held, and exit reason
  • open_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 position
  • action: "open", side: "short" opens a new short position
  • side: "buy" or side: "long" also opens a new long position
  • side: "sell" or side: "short" also opens a new short position
  • action: "close", position_id: "pos_000001" closes that exact position
  • action: "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: 1780523640000

The 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/runs

List recent runs:

mlab script runs list

View one run:

mlab script runs show <run-id>

On this page