Lifecycle & Callbacks
How Pyx invokes your script and the callbacks you can define.
Task lifecycle
When a script task starts, the following happens in order:
- A sandboxed Luau VM is created with only
math,string, andtablelibraries - The
pyxAPI module is injected as a global table - Your script is compiled and executed (top-level code runs once)
- Background services start: balance refresh, order timeout sweeper, event listener
- If
tick_interval_msis configured, a timer begins callingon_tick - For each subscribed market, price updates trigger
on_price - For each subscribed market, book updates trigger
on_book - For each subscribed spot symbol, BBO updates trigger
on_spot_price - Execution events (fills, placements, cancellations, split completions, merge completions) trigger their respective callbacks
After startup, a script task can move through these runtime states:
running- the VM is active and your strategy can buy or sellpaused- pending orders are canceled, callbacks stop firing, task-specific external inputs are unsubscribed, and VM state is preserved for resumedraining- new buys are blocked, but sells, order updates, exchange events, and resolution handling continue until the task is flatstopped- the VM is torn down and state is losterror- the task could not continue because of repeated runtime failures or a restart failure
User-triggered actions
- Pause moves a task from
running -> paused - Resume moves a task from
paused -> running - Drain moves a task from
running -> drainingorpaused -> draining - Force-stop uses the
stopaction to market-close open positions, then tear the task down
Use drain for the normal user-facing stop flow. A draining task automatically transitions to stopped once there are no active positions and no active or in-flight orders.
Resolved positions count as done during drain when auto_redeem_on_resolve = false. If auto_redeem_on_resolve = true, the task waits for the normal auto-redeem flow before stopping.
When you force-stop a task, the VM is destroyed and all state is lost after open positions are closed at market.
Automatic error stop
If a task encounters 5 Luau errors within 10 seconds, it is automatically moved to error:
- Task status is set to
error - Open orders are cancelled on a best-effort basis
This is a safety mechanism to prevent repeated failing execution loops.
Callbacks
Define any of these as global functions in your script. All are optional - only define what you need.
All callbacks have strict runtime limits. Current timeouts:
on_price,on_spot_price,on_book: 25mson_tick: 150ms- execution callbacks (
on_fill,on_placed,on_cancelled,on_split_complete,on_merge_complete): 50ms
on_tick(ctx)
Called on a fixed timer interval. Configure tick_interval_ms when creating the task.
function on_tick(ctx)
pyx.log("tick at " .. ctx.timestamp)
endContext fields:
| Field | Type | Description |
|---|---|---|
ctx.timestamp | number | Current time in milliseconds since epoch |
Use on_tick for strategies that operate on a schedule rather than reacting to every price update. Good for periodic rebalancing, inventory checks, or slow-moving strategies where you don't need sub-second reaction times.
on_price(ctx)
Called on every price update for your subscribed markets.
function on_price(ctx)
if ctx.spread < 0.02 then
pyx.buy(ctx.token_id, ctx.condition_id, ctx.ask, 0.10)
end
endContext fields:
| Field | Type | Description |
|---|---|---|
ctx.token_id | string | The token that was updated |
ctx.condition_id | string | The market condition ID |
ctx.bid | number | Best bid price |
ctx.ask | number | Best ask price |
ctx.last | number | Last trade price |
ctx.mid | number | Midpoint (bid + ask) / 2 |
ctx.spread | number | Spread ask - bid |
ctx.timestamp | number | Polymarket price-change timestamp in milliseconds since epoch |
ctx.received_at | number | Time Pyx received this update in milliseconds since epoch |
ctx.slug | string? | Market slug |
ctx.question | string? | Market question text |
ctx.description | string? | Market description |
ctx.end_date_ms | number? | Market end date timestamp in milliseconds |
ctx.start_date_ms | number? | Market start date timestamp in milliseconds |
ctx.event_start_ms | number? | Event start timestamp in milliseconds |
ctx.outcomes | {string} | Array of outcome labels, e.g. "Up", "Down" |
ctx.token_ids | {string} | Array of outcome token IDs (empty if unset) |
ctx.outcome | string? | Outcome label for this token (e.g. "Yes", "No") |
This callback is intentionally strict: on_price has a 25ms timeout per invocation. Keep logic minimal and avoid allocations in hot paths.
Market metadata fields (slug, question, description, end_date_ms, start_date_ms, event_start_ms, outcome) should always be present but are technically optional - check before using.
To detect delayed exchange updates, compare ctx.received_at - ctx.timestamp. Polymarket can occasionally emit updates that are ~1-2 seconds old.
on_book(ctx)
Called on every subscribed market order book update.
function on_book(ctx)
if ctx.spread <= 0.01 and ctx.total_bid_size > ctx.total_ask_size then
pyx.log(string.format("book imbalance %s spread=%.4f", ctx.token_id, ctx.spread))
end
endContext fields:
| Field | Type | Description |
|---|---|---|
ctx.token_id | string | Token that received a book update |
ctx.condition_id | string | Market condition ID |
ctx.timestamp | number | Book event timestamp (ms since epoch) |
ctx.best_bid | number | Best bid price |
ctx.best_ask | number | Best ask price |
ctx.spread | number | Spread best_ask - best_bid |
ctx.total_bid_size | number | Sum of all bid sizes in the book payload |
ctx.total_ask_size | number | Sum of all ask sizes in the book payload |
ctx.bid_levels | number | Number of bid levels in ctx.bids |
ctx.ask_levels | number | Number of ask levels in ctx.asks |
ctx.bids | { { price: number, size: number } } | Full bid ladder (best first) |
ctx.asks | { { price: number, size: number } } | Full ask ladder (best first) |
This callback is intentionally strict: on_book has a 25ms timeout per invocation. Keep logic minimal and avoid allocations in hot paths.
on_spot_price(ctx)
Called on each Binance BBO update for subscribed spot symbols. Throttled to ~10 updates/sec per symbol.
pyx.subscribe_spot({"BTC", "SOL"})
function on_spot_price(ctx)
if ctx.symbol == "BTC" and ctx.mid > 100000 then
pyx.log("BTC above 100k: " .. ctx.mid)
end
endContext fields:
| Field | Type | Description |
|---|---|---|
ctx.symbol | string | "BTC", "ETH", "SOL", or "XRP" |
ctx.bid | number | Best bid price |
ctx.ask | number | Best ask price |
ctx.bid_qty | number | Best bid size |
ctx.ask_qty | number | Best ask size |
ctx.mid | number | Midpoint (bid + ask) / 2 |
ctx.timestamp | number | Receipt time (ms since epoch) |
You must call pyx.subscribe_spot(symbols) in your script body (top-level code) before this callback will fire. This callback fires frequently - keep logic fast.
on_fill(fill)
Called when one of your orders is filled (partially or fully).
function on_fill(fill)
pyx.log(string.format(
"FILLED: %s %.4f @ %.4f (%s)",
fill.side, fill.qty, fill.price, fill.liquidity
))
endFill fields:
| Field | Type | Description |
|---|---|---|
fill.order_id | string | The exchange order ID |
fill.token_id | string | Token that was filled |
fill.condition_id | string | Market/ condition ID |
fill.side | string | "buy" or "sell" |
fill.price | number | Fill price |
fill.qty | number | Quantity filled |
fill.cumulative_qty | number | The total quantity filled for this order |
fill.original_size | number | The initial/ original size of the order |
fill.fully_filled | boolean | If the order is fully filled |
fill.position_qty | number | Total share qty of token position |
fill.position_avg_cost | number | Avg. cost of token position |
fill.realized_pnl | number | Realized P&L from position |
fill.liquidity | string | "maker" or "taker" |
Use this to react to fills - hedge positions, update internal state, log P&L, or trigger follow-up orders.
on_placed(order)
Called when your order is confirmed on the exchange order book.
function on_placed(order)
pyx.log("Order live: " .. order.order_id)
endOrder fields:
| Field | Type | Description |
|---|---|---|
order.order_id | string | The exchange order ID |
order.token_id | string | Token |
order.condition_id | string | Market/ condition ID |
order.side | string | "buy" or "sell" |
order.price | number | Limit price |
order.size | number | Order size |
on_cancelled(order)
Called when an order is cancelled (by you, by timeout, or by the exchange).
function on_cancelled(order)
pyx.log("Cancelled: " .. order.order_id)
endOrder fields:
| Field | Type | Description |
|---|---|---|
order.order_id | string | The cancelled order ID |
order.token_id | string | Token |
order.side | string | "buy" or "sell" |
order.filled_qty | number | Quantity that was filled |
order.unfilled_qty | number | Quantity unfilled |
on_split_complete(ctx)
Called when a split requested via pyx.split() completes.
function on_split_complete(ctx)
if not ctx.success then
pyx.error("Split failed")
return
end
pyx.log(string.format(
"Split complete: %s amount=%.4f",
ctx.condition_id,
ctx.amount
))
endContext fields:
| Field | Type | Description |
|---|---|---|
ctx.condition_id | string | Market condition ID |
ctx.amount | number | Completed split amount |
ctx.success | boolean | Whether the split was successful |
on_merge_complete(ctx)
Called when a merge requested via pyx.merge() completes.
function on_merge_complete(ctx)
if not ctx.success then
pyx.error("Merge failed")
return
end
pyx.log(string.format(
"Merge complete: %s amount=%.4f",
ctx.condition_id,
ctx.amount
))
endContext fields:
| Field | Type | Description |
|---|---|---|
ctx.condition_id | string | Market condition ID |
ctx.amount | number | Completed merge amount |
ctx.success | boolean | Whether the merge was successful |
Execution order
Within a single tick, price, book, or spot price event cycle:
- Your callback runs (
on_tick,on_price,on_book, oron_spot_price) - If
pyx.cancel_all()was called, all orders are cancelled;pyx.cancel(order_id)cancels only the specified order - All orders enqueued via
pyx.buy()/pyx.sell()will be executed sequentially, immediately after submitting - Any pending execution events (fills, placements, cancellations, split completions, merge completions) are processed, triggering their callbacks
Top-level code
Code outside of callback functions runs once when the VM is created. Use it for initialization:
-- This runs once at task start
local prices: {number} = {}
local tick_count = 0
local MAX_POSITION = 5.0
pyx.log("Strategy initialized")
-- This runs on every price update
function on_price(ctx)
tick_count += 1
table.insert(prices, ctx.mid)
-- ...
endAvoid heavy computation in top-level code - if compilation + initialization exceeds 5 seconds, the task will fail to start.