Research

Writing strategy code

Write the strategy yourself. This is the complete contract a hand-written strategy is held to — the callbacks the platform calls, everything ctx gives you, and the handful of rules a save enforces. The form builder and the AI assistant produce a strategy of exactly this shape; here you write it directly.

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.state key 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 before

Arming 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.

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:

  • None means "ask me again". A missing strike or a nonsensical price is a moment of thin data, not a refusal to trade — return None and 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_WINDOW is 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()))