A strategy is a small program in a Python-like language. It has no imports and no file system — it reads the market through ctx, opens one trade per trading session, and manages that trade to its exit. The same code runs your historical results and, if you point a bot at it, today's live session.
The shape of a strategy
Five callbacks. The platform calls them; you never call them yourself. build_entry is required outright, and a save also requires exactly one ctx.enter() call site — which belongs in on_tick, so in practice a strategy that trades needs both. The other three are genuinely optional.
def on_session_start(ctx): # optional — the session is about to begin
pass
def build_entry(ctx): # REQUIRED — construct the trade, place nothing
return None
def on_tick(ctx): # once per second; holds the one ctx.enter() call site
ctx.enter()
def on_order_filled(ctx, order): # optional — an order reached a final outcome
pass
def on_session_end(ctx): # optional — the session is over
pass- on_session_start — the session is about to begin. Seed every
ctx.statekey here: reading a key you never set is an error. - build_entry — construct the trade from the market as it is right now and return it. It places nothing, writes nothing, and may be called more than once, so it must not depend on anything you set elsewhere.
- on_tick — once per second, from the open. This is where the one
ctx.enter()call site lives, and where you manage the position afterwards. - on_order_filled — an order reached a final outcome: filled, cancelled or rejected. Guard on
order.status == "filled"before you treat anything as a fill, and only ever set flags here — the same outcome can be delivered more than once. - on_session_end — the session is over. Anything still open cash-settles at the close.
The entry window
Every strategy declares, at the top of the file, the window in which its entry may execute. The declaration is required, and it is what the platform enforces — so the strategy itself never watches the clock to decide whether it may trade.
# Enter between 09:45 and 09:50 ET, every trading day.
PLATFORM_ENTRY_WINDOW = {"start": "09:45", "end": "09:50"}
# Mondays, Wednesdays and Fridays only (Monday = 0 … Sunday = 6).
PLATFORM_ENTRY_WINDOW = {"start": "14:00", "end": "14:30", "days": [0, 2, 4]}"end" is exclusive: a 09:45–09:50 window covers 09:45:00 through 09:49:59. "days" is optional; leave it out to trade every session.
Inside on_tick, one call arms the entry:
def on_tick(ctx):
if ctx.state["armed"]:
return
ctx.enter() # arm the session's one entry
ctx.state["armed"] = True # latch AFTER the call, never beforeArming is not placing. It means "open this when you can, inside my window". From that moment the entry belongs to the platform: it builds the structure, places it, re-prices it while it works, tries again if it has to, and withdraws it if the window ends with the order unfilled. You do not retry it, chase it, or cancel it — and there is no way to disarm, so a condition that was true for one second has committed the session's trade.
ctx.enter() can raise — a chain read that faults inside build_entry, for instance. Set your latch after the call: latching first would leave the strategy believing it had armed something when it hadn't, and it would sit out the rest of the day in silence.Building the entry
build_entry returns a list of three things — the legs, the net limit price, and whether that price is a "credit" or a "debit" — or None when it can't build the structure right now.
def build_entry(ctx):
strike = ctx.find_strike_by_delta("put", 0.20)
if strike == None:
return None # ask me again next time
short_put = ctx.option(strike, "put")
long_put = ctx.option(strike - 25, "put")
if short_put == None or long_put == None:
return None
credit = short_put.mid - long_put.mid
if ctx.cmp(credit, 0.0) <= 0:
return None # a credit spread always collects a credit
entry_legs = [
ctx.leg(short_put, "sell to open", 1),
ctx.leg(long_put, "buy to open", 1),
]
return [entry_legs, ctx.snap_to_tick(credit), "credit"]Three habits keep it out of trouble:
Nonemeans "ask me again". A missing strike or a nonsensical price is a moment of thin data, not a refusal to trade — returnNoneand the platform tries again inside the window.- The price effect is structural. A credit spread always collects a credit; a debit spread always pays one. Never flip the effect because this second's quotes came back the other way round — that is bad data, and the answer is
None. - Every leg opens. Entry legs are
"sell to open"/"buy to open", always as literal strings, in a list with a name of its own.
Prices are per share — multiply by 100 for one contract's dollar value — and that stays true at any contract count. A ten-lot spread hits its 50% target at exactly the same per-share number a one-lot does.
credit = 4.50 # $450 for one contract — store it PER SHARE
cost = ctx.net_mid(ctx.positions()) # also per share, whatever the contract count
if ctx.cmp(cost, credit * 0.5) <= 0: # half the credit has decayed away
...Getting out of the trade
There are exactly two ways out, and they coexist with no choreography between them.
# One resting close — the profit target, priced at the number that triggers it.
close_legs = []
for p in ctx.positions():
action = "buy to close" if p.direction == "short" else "sell to close"
close_legs.append(ctx.leg(p.instrument, action, p.quantity))
ctx.take_profit_order(close_legs, target, "debit")
# Everything else — a stop, a time exit, a data bail-out — is one call.
ctx.log("exit", reason="stop_loss")
ctx.liquidate("stop_loss")
return- One resting close. A profit target is a price you are willing to wait at, so you place it: compute the target, snap it onto the tick grid, and rest a single close there. At most one resting close per session.
- Everything else is
ctx.liquidate(reason). A stop condition, a scheduled exit, a data bail-out, any "get me out now" — one call, no legs and no price. The platform sweeps your working orders, closes the whole position and finishes the session for you.
ctx.liquidate records the handoff and returns, so the rest of the callback still runs: put it last on its path and return immediately after it. Size every close from ctx.positions() rather than from the size the entry asked for — a fill can be smaller than you intended.
What ctx gives you
This is the whole surface. Anything not listed here is not available to a strategy, and a save will tell you so by name.
Time and the session
Everything is Eastern Time, and every value is the one for the tick you are on.
ctx.now()- the current second
ctx.session_date()- the trading day, as "YYYY-MM-DD"
ctx.session_open()- the session's first instant
ctx.session_close()- the session's closing instant
ctx.minutes_to_close()- whole minutes left before the close, rounded down and negative after it — so test <= n, never == n
The market
Day-constant readings come from the session itself; a reading can be None early in the day, before its first value exists.
ctx.spx()- the SPX level right now
ctx.spx_change_pct()- the move since today's open as a FRACTION — 0.005 is +0.5%, so compare against 0.005 and not 0.5
ctx.vix()- the VIX level
ctx.expected_move()- the day's expected move, in index POINTS
ctx.expected_move_points()- the same number under a clearer name — one value, two spellings, no conversion between them
ctx.spx_open()- today's opening SPX level
ctx.vix_open()- today's first VIX reading
ctx.prev_spx_close()- the previous session's SPX close
ctx.prev_vix_close()- the previous session's VIX close
The option chain
option_type is "call" or "put". The strike finders return an integer strike, or None when nothing matches; pass it to ctx.option to get the contract. Each takes an optional exclude=[…] of strikes to skip, for a leg that must not land on one an earlier leg took.
ctx.find_strike_by_delta(option_type, target, exclude=[])- the strike whose delta is nearest target — a positive magnitude for both types, so 0.16 finds the 16-delta put as well as the 16-delta call
ctx.find_strike_by_offset(option_type, points_otm, exclude=[])- the strike that many points OUT of the money from spot: above spot for a call, below it for a put, so a positive number always moves away from the money
ctx.find_strike_by_premium(option_type, target, direction, exclude=[])- the strike quoting nearest target. direction is required, "buy" or "sell": it decides which side of the market is compared — the ask when you buy, the bid when you sell
ctx.find_strike_at(option_type, target_strike, exact=False, exclude=[])- the listed strike nearest an absolute strike price; with exact=True only a strike listed exactly there qualifies, and anything else is None
ctx.find_strike_pct_otm(option_type, ref_strike, pct, exclude=[])- a strike pct of SPOT away from ref_strike — pct is a fraction, and it scales spot rather than ref_strike, so legs hung off different references move by the same points (spot 5000, pct -0.01 targets ref_strike - 50)
ctx.atm_strike(exclude=[])- the listed strike nearest spot
ctx.option(strike, option_type)- one contract, both arguments positional: .strike, .option_type, .bid, .ask, .mid, .delta, .instrument
ctx.option_chain()- the whole chain at this tick — BOTH types together, ordered by strike then type, so filter on each row's .option_type. Rows carry .strike, .option_type, .mid, .delta and .instrument, and no bid/ask: point-read those with ctx.option. None when this tick has no chain
Orders
Every order is a limit order. The entry is armed, never placed by you; the closes you place are limits that rest at a price you name.
ctx.leg(option_or_instrument, action, qty=1)- one leg. The first argument is anything carrying a contract — an option, a position, an order leg — and the action is a literal "sell to open" / "buy to open" / "buy to close" / "sell to close"
ctx.enter()- arm the session's one entry; takes no arguments
ctx.take_profit_order(legs, price, price_effect)- rest the profit-target close. price_effect is "credit" or "debit"
ctx.stop_loss_order(legs, price, price_effect)- rest a loss-limiting close at a price you name — same arguments, and still a limit order
ctx.cancel(order)- ask for a close of yours to be cancelled. Pass the ORDER — one of ctx.open_orders(), or what the placing call returned — not its id; it answers True/False, and the outcome arrives in on_order_filled
What you hold
Read on a later tick than the one that placed an order — the book is the only truth about what you actually hold.
ctx.positions()- open positions: .instrument, .direction ("long"/"short"), .quantity (always positive), .avg_price, .unrealized_pl, .realized_pl
ctx.open_orders()- the orders still working, each with .id, .status, .price_effect and .legs — an empty list means nothing of yours is in flight
Keeping track, and getting out
Prices are decimals, so compare them with ctx.cmp rather than with < and >.
ctx.state- a dictionary that survives from tick to tick — seed every key in on_session_start
ctx.log(event, **fields)- write a line into the session's record
ctx.signal(name, **fields)- publish a structured event of your own
ctx.liquidate(reason)- hand the whole position over to be closed, and finish the session. reason is required — a short string
ctx.cmp(a, b)- compare two prices: -1, 0 or 1
ctx.net_mid(positions)- what closing them would cost now, per share, at the same scale as an order price — and 0.0 when there is nothing to price, so gate on holding a position rather than on this number
ctx.at("HH:MM")- true during that minute — or that second, with "HH:MM:SS"
ctx.between("HH:MM", "HH:MM")- true from the first time up to (not including) the second
ctx.snap_to_tick(price)- round a price onto the SPX tick grid
ctx.greeks(option)- the delta of anything carrying a contract — a value with one field, .delta, or None when this tick has no greek for it
ctx.tick_index()- the current tick, counted from 0 — and -1 inside on_session_start, before the first one
ctx.day_index()- this session's position among ALL trading sessions, so it means the same thing however the results were run
What a save enforces
Validate and Save run the same checks, and both report the first problem they find, in plain words. There is nothing to discover by trial and error:
- The entry window is declared. A source without
PLATFORM_ENTRY_WINDOWis rejected. - One trade per session. Exactly one
ctx.enter()call site, and one entry per session. - Defined risk only. A short option must be covered by a long one of the same type — a naked short is refused, as it is everywhere else on the platform.
- Limit orders only. A resting close is a limit at a price you name; there is no market order and no stop-triggered order type. Everything that isn't a resting limit is
ctx.liquidate(reason). - At most 24 KB of source.
- It has to run. Your code is executed against one real trading session before it can be saved. Code that raises there is rejected, with the error it raised.
A 200 from Validate is a promise: the same bytes will not be rejected by the save. What it is not is a claim that the strategy is any good — that is what results across every trading session are for, and they start the moment you save.
A complete strategy
This is the template the editor opens on — a put credit spread with a 50% profit target and a stop at twice the credit. It passes every check above as it stands, so you can save it unchanged and then start moving numbers around.
# A 0DTE SPX put credit spread.
#
# Sell the ~20-delta put, buy the put $25 below it for defined risk, and enter
# in the 09:45-09:50 window. Take profit at 50% of the credit collected; if the
# cost to close instead doubles, hand the position to the platform to close.
#
# Every number below is yours to change — the strikes, the width, the window,
# the targets. Press Validate to check the code, Save to publish it.
# The entry window, ET. "end" is EXCLUSIVE, so this window is 09:45:00 through
# 09:49:59. Add "days": [0, 2, 4] to trade Mondays, Wednesdays and Fridays only
# (Monday = 0). The platform places the entry inside this window and withdraws
# it if it never fills, so the strategy never watches the clock itself.
PLATFORM_ENTRY_WINDOW = {"start": "09:45", "end": "09:50"}
SHORT_DELTA = 0.20 # sell the put nearest this delta
WIDTH = 25 # dollars between the short strike and the long wing
PROFIT_AT = 0.50 # take profit once 50% of the credit has decayed away
STOP_AT = 2.00 # give up once closing costs 2x the credit collected
def on_session_start(ctx):
# Seed every key the rest of the session reads — reading an unset key raises.
ctx.state["armed"] = False # the one entry has been armed
ctx.state["closing"] = False # a take-profit order is resting
ctx.state["recheck"] = False # a close came back unfilled; re-read the book
ctx.state["credit"] = None # the entry credit per share, from the fill
def build_entry(ctx):
# Construct the spread from the CURRENT market and return it. The platform
# calls this itself, possibly several times, so it must not write state or
# place anything. Return None whenever the data isn't there yet — that is a
# "ask me again", not a refusal.
short_strike = ctx.find_strike_by_delta("put", SHORT_DELTA)
if short_strike == None:
return None
short_put = ctx.option(short_strike, "put")
long_put = ctx.option(short_strike - WIDTH, "put")
if short_put == None or long_put == None:
return None
credit = short_put.mid - long_put.mid
if ctx.cmp(credit, 0.0) <= 0:
# A credit spread always collects a credit; a non-positive one means the
# quotes are unusable this second.
return None
entry_legs = [
ctx.leg(short_put, "sell to open", 1),
ctx.leg(long_put, "buy to open", 1),
]
return [entry_legs, ctx.snap_to_tick(credit), "credit"]
def on_tick(ctx):
if not ctx.state["armed"]:
# Arm the session's one entry, THEN latch — if the call raises, the next
# tick tries again instead of leaving the day silently disarmed.
ctx.enter()
ctx.state["armed"] = True
return
_manage(ctx)
def _manage(ctx):
positions = ctx.positions()
if len(positions) == 0:
# Either the entry is still being worked or the trade is already closed.
return
if ctx.state["recheck"]:
# A close came back unfilled. Once the book is clear again, allow a new one.
ctx.state["recheck"] = False
if len(ctx.open_orders()) == 0:
ctx.state["closing"] = False
credit = ctx.state["credit"]
if credit == None:
# No confirmed fill price yet — nothing to measure a target against.
return
cost_to_close = ctx.net_mid(positions)
if ctx.cmp(cost_to_close, credit * STOP_AT) >= 0:
# Everything that is not a resting profit target is a liquidation: the
# platform sweeps the working orders and closes the whole position.
ctx.log("exit", reason="stop_loss")
ctx.liquidate("stop_loss")
return
if ctx.state["closing"]:
return
tick = 0.10 if credit >= 3.0 else 0.05
target = ctx.snap_to_tick((1.0 - PROFIT_AT) * credit)
if target > credit - tick:
# A tiny target snaps back onto the entry premium — step past it so the
# close is strictly profitable.
target = ctx.snap_to_tick(credit - tick)
if target < 0.05:
target = 0.05
if ctx.cmp(cost_to_close, target) <= 0:
_take_profit(ctx, positions, target)
def _take_profit(ctx, positions, price):
# Size the close from the BOOK, never from the size the entry asked for.
close_legs = []
for p in positions:
action = "buy to close" if p.direction == "short" else "sell to close"
close_legs.append(ctx.leg(p.instrument, action, p.quantity))
# Buying back a credit structure is always a debit — structural, not read off
# this second's quote.
order = ctx.take_profit_order(close_legs, price, "debit")
if order == None:
return
ctx.state["closing"] = True
ctx.log("exit", reason="profit_target")
def on_order_filled(ctx, order):
# Fires on every terminal outcome, not just fills — and the same outcome can
# be delivered more than once, so only set flags here, never count.
if order.status != "filled":
if ctx.state["closing"]:
ctx.state["recheck"] = True
return
if ctx.state["credit"] == None and order.fill_price != None:
# The first fill is the entry. Its price per share is the credit every
# exit target is measured against.
ctx.state["credit"] = order.fill_price
def on_session_end(ctx):
# Anything still open here cash-settles at 16:00 ET.
ctx.log("session_end", held=len(ctx.positions()))