Script Execution
Simulate and execute strategy scripts with ctx.trade, ctx.cancel, and onExecution.
Script Execution
Strategy scripts do not ask another layer to interpret a returned signal. They call a concrete execution API:
onData -> ctx.trade / ctx.cancel -> simulator or mlabdThe runtime depends on the command:
| Command | Execution target |
|---|---|
script backtest | Historical in-process simulator |
script run | Execution disabled |
script run --venue bulk | Live BULK execution through mlabd |
Market data and execution are independent. A job can read MMT and execute on BULK, or use BULK for both.
Deploy a Live Strategy
script run submits an immutable copy of the script to mlabd and returns immediately:
mlab script run ./scripts/bulk-limit-protected.js \
--symbol BTC/USDT \
--source candles@bulk:timeframe=60 \
--venue bulk \
--param armed=true--venue bulk is the explicit switch that enables ctx.trade and ctx.cancel. Omitting it keeps an analysis-only job unable to trade.
Use --duration <seconds> to bound the live session independently of trading outcomes. For example, --duration 3600 runs for at most one hour. Omit it to run indefinitely. Duration expiry completes the script job; it does not represent TP/SL or close a position automatically.
Live execution requires an authorized BULK agent:
mlab auth set bulkctx.trade
Place a market or limit order:
const entry = ctx.trade({
key: 'btc-entry-v1',
position: 'open-long',
margin: 100,
leverage: 5,
order: {
type: 'limit',
price: 65000,
tif: 'gtc',
},
sl: 63000,
tp: 69000,
})The call validates synchronously and returns a stable local reference:
{ id: "ord_...", key: "btc-entry-v1" }It does not wait for a venue fill. The command is serialized through mlabd, where the agent credential is loaded and the signed order is submitted.
Request fields:
| Field | Required | Contract |
|---|---|---|
key | Yes | Non-empty strategy idempotency key, at most 128 bytes |
position | Yes | open-long, open-short, close-long, or close-short |
size | Open: one of | Positive base quantity; optional on close |
margin | Open: one of | Positive quote collateral; invalid on close |
leverage | Open: no | At least 1; defaults to 1; invalid on close |
order.type | No | market or limit; defaults to market |
order.price | Limit only | Positive limit price |
order.tif | Limit only | gtc, ioc, or alo; defaults to gtc |
sl | Open: no | Native stop-loss trigger price; invalid on close |
tp | Open: no | Native take-profit trigger price; invalid on close |
Opening operations require exactly one of size or margin. Market Lab multiplies margin by leverage to obtain the order exposure, then converts that exposure to lot-aligned size. Market, price, leverage, lot-size, tick-size, and minimum-notional rules are validated before signing.
For example, margin: 100 with leverage: 5 targets approximately $500 of exposure. Studies such as ctx.study.slippage still accept notional because they measure liquidity for the resulting exposure; pass margin * leverage to those studies.
Closing operations are always reduce-only. Omit size to close the complete matching position, or pass size for a partial close:
ctx.trade({
key: `close-long-${candle.t}`,
position: 'close-long',
})Market Lab uses one-way position semantics. open-long can add to an existing long and open-short can add to an existing short. An opening operation cannot silently reverse the opposite position: submit close-long before open-short, or close-short before open-long. Closing the wrong side or more than the current size is rejected.
Idempotency
The key belongs to one script job. Repeating the exact request returns the existing managed order. Reusing the key with different parameters is rejected.
This makes retries safe across repeated data hooks and worker restarts. Derive keys from stable strategy facts rather than the current wall-clock time:
const order = ctx.trade({
key: `ema-cross-${candle.t}`,
position: 'open-long',
margin: 100,
order: { type: 'market' },
})Native SL and TP
sl and tp are attached to the parent order using BULK native on-fill actions.
slcreates native stop protection.tpcreates native take-profit protection.- both create a native OCO range where one trigger cancels its sibling.
- protection activates after the parent entry fills.
- Market Lab does not poll prices locally to emulate triggers.
Protection is valid only on open-long and open-short. Trigger prices must align with the market tick and be on the correct side of the entry.
The backtest simulator uses the same request fields. On candle data, if one bar touches both sl and tp, the simulator chooses the stop first because the intra-bar path is unknown.
ctx.cancel
Cancel a managed order by its stable local ID or original trade key:
ctx.cancel({
key: 'cancel-btc-entry-v1',
order: entry.id,
})The return value confirms that the command was queued:
{
key: "cancel-btc-entry-v1",
order: "ord_...",
status: "queued"
}Cancellation keys are idempotent inside the job. If cancellation is requested before BULK returns its order ID, mlabd records order.cancel_requested; no venue cancellation is sent at that moment.
onExecution
Live jobs may export a second hook for asynchronous order, fill, position, and account events:
export function onExecution(ctx, event) {
if (event.type === 'order.rejected') {
return {
metrics: {
rejected_order: event.orderId,
details: event.data,
},
}
}
if (event.type === 'order.fill') {
return {
metrics: {
order_id: event.orderId,
venue_order_id: event.venueOrderId,
status: event.status,
},
}
}
}onExecution remains a two-argument hook. Live source history is passed only to onData(ctx, input, history).
Event envelope:
{
"seq": 7,
"jobId": "job_...",
"tsMs": 1780000000000,
"type": "order.fill",
"orderId": "ord_...",
"key": "btc-entry-v1",
"venue": "bulk",
"venueOrderId": "...",
"status": "filled",
"terminal": false,
"data": {}
}Event types include:
order.pending,order.accepted,order.terminal, andorder.updatedorder.fill,order.filled,order.cancel_requested, andorder.cancelledorder.rejected,order.cancel_failed, andorder.cancel_rejectedposition.updated,position.closed,position.liquidated, andposition.adlaccount.margin_updated
onExecution uses the same persistent QuickJS session as onData, so module state remains available. It may call ctx.trade or ctx.cancel to react to an event.
Events are journaled per job and acknowledged only after the hook succeeds. An unacknowledged event is replayed after a worker restart. Execution keys keep commands idempotent during replay.
onExecution is a live job hook. Both live runs and backtests expose the current position for the script symbol through input.positions.open; the historical simulator applies fills and protection internally.
Job Operations
List and inspect deployed jobs:
mlab script jobs
mlab script status <JOB_ID>Tail structured worker output:
mlab script logs <JOB_ID> --follow
mlab script logs <JOB_ID> --follow --output jsonlStop or restart the immutable snapshot:
mlab script stop <JOB_ID>
mlab script restart <JOB_ID>Job states are starting, running, stopping, stopped, completed, and failed.
Worker snapshots, output, execution events, and logs are stored under the owner-only runtime directory:
~/.market-lab/execution/jobs/<JOB_ID>/Failure Boundary
Execution commands are committed only after a hook returns successfully. If the hook throws, commands collected during that invocation are cleared.
After a successful hook:
- the worker sends commands to
mlabd - execution errors are appended as
script.execution.error - lifecycle events are delivered to
onExecution - the lifecycle event is appended as
script.execution.eventwhether or notonExecutionis exported; an explicit{ metrics, meta }hook return is included only when present
Ordinary onData calls that return nothing, null, or {} do not append a script.run.result. A non-empty { metrics, meta } return is an explicit diagnostic and is logged. Returned signal and intent objects are not accepted and cannot execute an order.
The strategy never receives the agent private key. Signing, nonce sequencing, account streaming, order correlation, and event journaling remain inside mlabd.