# Scripts (/scripting/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`, `ctx.order`, or `ctx.cancel`.

```txt
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 [#symbols-belong-to-sources]

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

```text
<symbol>@<source>@<provider>
<symbol>@<source>@<exchange>@<provider>
```

For example:

```bash
--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:

```text
btc -> BTC
zec -> ZEC
```

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

```text
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:

```js
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 [#manifest]

Every script exports a manifest using base source kinds:

```js
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` [#understanding-lookback]

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

With this manifest:

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

and these runtime sources:

```bash
--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`:

```js
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:

```txt
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:

```bash
--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:

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

Inside the hook they are available as:

```js
ctx.params.fast
ctx.params.max_spread_bps
```

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

## onData [#ondata]

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

```js
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:

```js
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:

```js
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:

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

For a live trade source, use its exact selector:

```js
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](/scripting/sources/trades) for the full live-only contract.

## Return Value and Logs [#return-value-and-logs]

An execution script does not need to return anything:

```js
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:

```js
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 [#backtest]

```bash
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](/scripting/sources/candles) for the difference between live and backtest timeframes.

## Live Jobs [#live-jobs]

Deploy an analysis-only job:

```bash
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`:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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](/scripting/execution) before arming a live script.
