Script Execution
Simulate and execute strategy scripts with ctx.trade, ctx.order, ctx.cancel, and onExecution.
Script Execution
Strategy scripts do not ask another layer to interpret a returned signal. They call concrete execution APIs:
onData / onExecution -> ctx.trade / ctx.order / 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, ctx.order, 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.
Use ctx.trade when the position transition is intentional. Use ctx.order when the script needs to place a raw buy or sell whose eventual effect depends on the net inventory when it fills.
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' },
})ctx.order
Place a raw buy or sell without declaring an intended position transition:
const ask = ctx.order({
key: 'btc-maker-ask-1',
side: 'sell',
size: 0.01,
leverage: 5,
order: {
type: 'limit',
price: 66000,
tif: 'alo',
},
})buy and sell are the canonical sides. long is accepted as an alias for buy, and short is accepted as an alias for sell. Structured output always serializes the canonical value.
Request fields:
| Field | Required | Contract |
|---|---|---|
key | Yes | Stable idempotency key shared with ctx.trade |
side | Yes | buy/long or sell/short |
size | One of | Positive base quantity |
margin | One of | Positive quote collateral used with leverage to derive size |
leverage | No | At least 1; defaults to 1 |
reduceOnly | No | Defaults to false; prevents an order from increasing or flipping inventory |
order.type | No | market or limit; defaults to market |
order.price | Limit | Positive limit price |
order.tif | Limit | gtc, ioc, or alo; defaults to gtc |
Exactly one of size or margin is required. Raw orders do not accept sl or tp, because their position effect is not known until they fill.
Non-reduce-only raw orders follow execution-venue netting:
Long 10 + sell 4 -> Long 6
Long 10 + sell 10 -> Flat
Long 10 + sell 14 -> Short 4The same rules apply in backtests. Same-side fills increase the net position and update its weighted entry. Opposite-side fills realize the closed quantity and any remainder opens the other side. A reduce-only raw order can reduce to flat but never flip.
ctx.order returns the same stable { id, key } reference as ctx.trade. Pass that ID or key to ctx.cancel.
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
Scripts 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, ctx.order, 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.
Live jobs receive venue lifecycle events. Backtests generate simulated pending, accepted, fill, filled, and cancelled events, allowing the same order-replacement logic to run in both environments. Both expose the current net position for the script symbol through input.positions.open.
Historical OHLC bars cannot reveal the path between multiple limit prices touched in one bar. Market Lab applies previously submitted orders first and then stable local order ID order for orders submitted on the same event, keeping results deterministic without claiming an unknown intra-bar sequence.
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.