A strategy is a small program in Starlark, a Python-like language: the syntax you know from Python, deterministic and safe to embed. It has no imports and no file system — it reads the market through ctx, opens one or more trades in a trading session, and manages each of them 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
Four callbacks, plus the builder functions you write yourself. The platform calls the four, and it calls your builders too — you never call any of them directly; you hand a builder to ctx.open_trade. A save requires at least one ctx.open_trade call site, which belongs in on_tick, so in practice a strategy that trades needs on_tick and one builder. The other three callbacks are genuinely optional.
def on_session_start(ctx): # optional — the session is about to begin
pass
def build_spread(ctx): # yours, named by you — construct the trade, place nothing
return None
def on_tick(ctx): # once per second; holds the ctx.open_trade call sites
ctx.open_trade("morning_spread", build_spread)
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. - your builder — construct one trade from the market as it is right now and return it. It places nothing, writes nothing, and the platform may call it more than once, so it must not depend on anything you set elsewhere. Name it whatever you like; a strategy that opens two different trades has two of them.
- on_tick — once per second, from the open. This is where the
ctx.open_tradecall sites live, and where you manage each trade 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 entries 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. It is one window per strategy — a single "start" and "end" — and every trade the session opens must open inside it, however many there are. A strategy that enters at 10:00 and again at 10:30 is therefore one strategy with two trades, in a window that covers both; portfolios remain the way to run different strategies side by side, each funded and attributed separately.
Building a trade
Inside on_tick, one call opens a trade:
def on_tick(ctx):
trade = ctx.open_trade("morning_spread", build_spread)
if trade == None:
return # nothing opened — the window is over, it isn't
# one of my days, or a budget refused it
if trade.status != "open":
return # still working, or already finished
manage(ctx, trade)Opening is not placing. It means "open this trade when you can, inside my window". From that moment the entry belongs to the platform: it calls your builder, places what it returns, re-prices it while it works, tries again if it has to, and withdraws it if the window ends with the order still unfilled. You do not retry it, chase it, or cancel it.
That is why calling it before your window still hands you a trade: it comes back working, with nothing built yet, and the platform builds and places it when the window opens. You get None only when nothing was opened at all — the window has already ended, today is not one of your "days", or a budget refused the request.
ctx.open_trade is idempotent per label: the first call with a given label opens that trade, and every later call with the same label opens nothing and hands the same trade back. So the call can sit unguarded in on_tick and run every second — you need no flag of your own to stop it opening twice. It is the same rule from the other side that makes a second, different trade a second label: give two trades the same one and you have written one trade.The builder you pass returns a list of three things — the legs, the net limit price, and the price effect ("credit" or "debit") — or None when it can't build the structure right now.
def build_spread(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", 1),
ctx.leg(long_put, "buy", 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. - A builder is pure construction. It reads the market and returns a structure. It places nothing and it must not write
ctx.state, because the platform calls it again on its own schedule until the trade is placed.
The handle. ctx.open_trade answers the trade itself, and ctx.trades() answers every trade the session has opened. It is a live view rather than a snapshot: read it whenever you need it and it tells you the truth for that tick. It carries .label, .status, .legs (what the trade holds — each leg with .instrument, .direction, .quantity, .avg_price, .price and .unrealized_pl), .entry_fill_price, .mark, .unrealized_pl, .realized_pl and .id.
Six statuses, and only one of them means "you are holding something".
working— armed, and the entry is being worked. The trade holds nothing yet.open— the entry filled and the trade holds contracts. A resting take-profit or stop does not move it offopen.closing— you handed it over, and that hand-off is still in flight.closed— flat: its own resting close filled, or it settled at the end of the day.stopped— flat, reached by the hand-off.not_filled— the entry never filled; the window ended without it.
The last three are final. Guard your management on trade.status == "open" and the rest takes care of itself.
The budgets. A session may open 50 trades, and hold 12 that are not yet finished at any one time. Past either, the platform refuses the request and says so in the session's record. The two release differently: a finished trade frees a slot against the concurrent 12, so that request can be asked for again — but the 50 a session may open counts every trade it ever opened, and nothing gives one back. A repeated call with a label you already used costs nothing at all, because it opens nothing.
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(trade.legs) # also per share, whatever the contract count
if ctx.cmp(cost, credit * 0.5) <= 0: # half the credit has decayed away
...Legs
ctx.leg(option_or_instrument, action, qty=1) builds one leg, and action is "buy" or "sell". Nothing more: whether the leg opens or closes comes from where you use it. Legs a builder returns open the trade, so "sell" there is a sell to open; legs you pass to ctx.take_profit_order or ctx.stop_loss_order close the trade, so "buy" there is a buy to close.
# Inside a trade's builder, these legs OPEN the trade.
entry_legs = [ctx.leg(short_put, "sell", 1), ctx.leg(long_put, "buy", 1)]
# In a take-profit or a stop-loss order, the same two words CLOSE it.
close_legs = []
for leg in trade.legs:
action = "buy" if leg.direction == "short" else "sell"
close_legs.append(ctx.leg(leg.instrument, action, leg.quantity))
# What you read back is always the long spelling.
def on_order_filled(ctx, order):
if order.legs[0].action == "sell to open":
ctx.log("entry_filled", price=order.fill_price)- Write the action as a literal string. Not a value assembled from pieces — the checks a save runs read the action out of your code, and a spelling they cannot read is one they cannot place.
- The long spellings still work.
"sell to open","buy to open","buy to close"and"sell to close"are accepted everywhere they always were, and they always will be. What they may not do is contradict where they sit: a leg spelled to close returned from a builder is refused, and so is an opening leg passed to a close. - What you read back is always the long spelling.
order.legs[i].actioninon_order_filled, and the legs ofctx.open_orders(), name the action in full — that is the platform describing an order it holds, not an echo of what you typed.
Getting out of a trade
There are exactly two ways out of a trade, and they coexist with no choreography between them.
# One resting close per trade — the profit target, priced at the number that
# triggers it. The trade comes first, so the platform knows whose close this is.
ctx.take_profit_order(trade, close_legs, target, "debit")
# Everything else for THIS trade — a stop, a time exit, a data bail-out — is one call.
ctx.log("exit", reason="stop_loss", trade=trade.label)
ctx.close_trade(trade, "stop_loss")
return
# And to end the whole session, whatever it is holding:
ctx.liquidate("data_gap")- One resting close per trade. 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. The trade is the first argument, and one resting close per trade is the ceiling — a second one while the first is still working is refused.
- Everything else is
ctx.close_trade(trade, reason). A stop condition, a scheduled exit, a data bail-out, any "get me out of this one now" — one call, no legs and no price. The platform sweeps that trade's own working orders, closes what it holds, and finishes it with the reason you gave. Your other trades are untouched and the session carries on. ctx.liquidate(reason)is the whole session. It hands over everything the strategy holds and ends the day. Keep it for what it says: a way out of the session, not out of a trade.
Both calls record the hand-off and return, so the rest of the callback still runs: put them last on their path and return immediately after. Size every close from trade.legs 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. Each function comes with one example call and its response; a response written as option(…), trade(…) or order(…) is a value of that shape — you read its fields with a dot, and you never construct one yourself.
| Time and the session Everything is Eastern Time, and every value is the one for the tick you are on. |
|---|
ctx.now() Returns the time of the current tick, as a time value with .hour, .minute and .second fields. Two reads inside one callback always return the same instant. ctx.now() 2025-06-16T09:42:17-04:00 |
ctx.session_date() Returns the trading day this session runs on, as a "YYYY-MM-DD" string. ctx.session_date() "2025-06-16" |
ctx.session_open() Returns the time of the session's first tick, as a time value. ctx.session_open() 2025-06-16T09:30:00-04:00 |
ctx.session_close() Returns the time the session closes, as a time value. ctx.session_close() 2025-06-16T16:00:00-04:00 |
ctx.minutes_to_close() Returns the number of whole minutes between the current tick and the session close, rounded down — 90 seconds left returns 1 — and negative after the close. Test <= n, never == n: a tick may not land in every minute. ctx.minutes_to_close() 377 |
| The market Day-constant readings come from the session itself; a reading returns None early in the day, before its first value exists. |
ctx.spx() Returns the SPX index level at the time of the call, as a float — or None when no value exists yet at this tick. ctx.spx() 6033.1 |
ctx.spx_change_pct() Returns how far SPX has moved since today's open, as a signed fraction of the open: 0.005 means up 0.5%, so compare against 0.005, not 0.5. Returns None until an open value exists. ctx.spx_change_pct() -0.0042 |
ctx.vix() Returns the VIX level at the time of the call, or None before the day's first value. ctx.vix() 17.42 |
ctx.expected_move() Returns the day's expected move, in index points — not a percentage. Returns None in the opening seconds, before the first value exists. ctx.expected_move() 45.53 |
ctx.spx_open() Returns today's opening SPX level, or None until the session has one. ctx.spx_open() 6058.55 |
ctx.vix_open() Returns today's first VIX value, or None until it exists. ctx.vix_open() 16.98 |
ctx.prev_spx_close() Returns the previous trading session's closing SPX level, or None when there is no previous session to read. ctx.prev_spx_close() 6045.26 |
ctx.prev_vix_close() Returns the previous trading session's closing VIX. It resolves independently of the SPX close, so one can be None while the other returns. ctx.prev_vix_close() 17.16 |
| The option chain option_type is "call" or "put". The strike finders return an integer strike, or None when nothing matches; pass the strike to ctx.option to get the contract. Each finder takes an optional exclude=[…] of strikes to skip, for a leg that must not land on a strike an earlier leg took. |
ctx.find_strike_by_delta(option_type, target, exclude=[]) Returns the strike whose delta magnitude is nearest to target, as an integer. target is a positive number for both types: 0.16 finds the 16-delta put as well as the 16-delta call. Returns None when this tick has no chain to search. ctx.find_strike_by_delta("put", 0.16) 5960 |
ctx.find_strike_by_offset(option_type, points_otm, exclude=[]) Returns the strike nearest to points_otm index points OUT of the money from the current SPX level: above it for a call, below it for a put. A positive number always moves away from the money; a negative one moves into it. ctx.find_strike_by_offset("call", 40) 6075 |
ctx.find_strike_by_premium(option_type, target, direction, exclude=[]) Returns the strike whose quote is nearest to target. direction is required, "buy" or "sell", and picks the side of the market that is compared: the ask when you buy, the bid when you sell. ctx.find_strike_by_premium("put", 1.50, "sell") 5945 |
ctx.find_strike_at(option_type, target_strike, exact=False, exclude=[]) Returns the listed strike nearest to target_strike. With exact=True, only a strike listed exactly at target_strike qualifies, and anything else returns None. ctx.find_strike_at("put", 5957.5) 5960 |
ctx.find_strike_pct_otm(option_type, ref_strike, pct, exclude=[]) Returns the strike nearest to ref_strike moved by pct of the CURRENT SPX LEVEL. pct is a fraction, and it scales the index level rather than ref_strike, so legs hung off different references move by the same number of points. ctx.find_strike_pct_otm("put", 5960, -0.01) 5900 |
ctx.atm_strike(exclude=[]) Returns the listed strike nearest to the current SPX level, or None when this tick has no data. ctx.atm_strike() 6035 |
ctx.option(strike, option_type) Returns one contract's quote at the time of the call — both arguments positional — or None when that strike is not quoted at this tick. The result carries .strike, .option_type, .bid, .ask, .mid, .delta and .instrument. ctx.option(5960, "put") option(strike=5960, option_type="put", bid=1.45, ask=1.55, mid=1.5, delta=-0.16, instrument=…) |
ctx.option_chain() Returns the whole chain at this tick as a list of rows — both types together, ordered by strike and then type, so filter on each row's .option_type. A row carries .strike, .option_type, .mid, .delta and .instrument, and no bid or ask: read those for one strike with ctx.option. Returns None when this tick has no chain. ctx.option_chain() [row(strike=5900, option_type="call", mid=133.85, delta=0.99, …), row(strike=5900, option_type="put", mid=0.55, delta=-0.01, …), …] |
| Trades A trade is one structure you open and manage to its exit. You name it, and the name is how you get it back. |
ctx.open_trade(label, build_fn) Opens the trade named label, built by your function, and returns its handle. Idempotent per label: a later call with the same label opens nothing and returns the same trade. Returns None when nothing was opened at all — the window has already ended, or a budget refused the request. ctx.open_trade("morning_spread", build_spread) trade(label="morning_spread", status="working", …) |
ctx.trades() Returns every trade this session has opened, oldest first — the same handles ctx.open_trade returned. Finished trades stay in the list for the whole session. ctx.trades() [trade(label="morning_spread", status="open", …)] |
ctx.close_trade(trade, reason) Hands one trade to the platform to be closed, whatever it holds, with your reason — a short string. Returns None; the platform sweeps that trade's working orders and closes its legs while the rest of the session carries on. ctx.close_trade(trade, "stop_loss") None |
| Orders Every order is a limit order. An entry is built by you and placed by the platform; the closes you place are limits that rest at a price you name. |
ctx.leg(option_or_instrument, action, qty=1) Builds one order leg and returns it. The first argument is anything that carries a contract — an option, a trade leg, an order leg — and action is a literal "buy" or "sell". ctx.leg(short_put, "sell", 1) leg(action="sell", quantity=1, …) |
ctx.take_profit_order(trade, legs, price, price_effect) Places that trade's profit-target close: a limit order that rests at price until it fills. price_effect is "credit" or "debit". Returns the placed order, or None when the placement was refused — for example when a close is already resting on that trade. ctx.take_profit_order(trade, close_legs, 0.75, "debit") order(id="8f2b…", status="pending", price_effect="debit", legs=[…], …) |
ctx.stop_loss_order(trade, legs, price, price_effect) Places a loss-limiting close for that trade at a price you name — the same arguments, and still a limit order that waits for its price. Returns the placed order, or None when refused. ctx.stop_loss_order(trade, close_legs, 3.00, "debit") order(id="c41d…", status="pending", price_effect="debit", legs=[…], …) |
ctx.cancel(order) Asks for one of your resting closes to be cancelled. Pass the ORDER — one of ctx.open_orders(), or what the placing call returned — never its id. Returns True when the request was accepted and False when it was not; the final outcome arrives in on_order_filled. ctx.cancel(order) True |
ctx.open_orders() Returns your orders that are still working, each with .id, .status, .price_effect and .legs. An empty list means nothing of yours is in flight. ctx.open_orders() [order(id="8f2b…", status="live", price_effect="debit", legs=[…], …)] |
| 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 — the only memory a strategy has. Seed every key in on_session_start: reading a key you never set is an error. ctx.state["entered"] True |
ctx.log(event, **fields) Writes one line into the session's record, with any keyword fields attached. Returns None. ctx.log("entry_filled", price=1.45) None |
ctx.signal(name, **fields) Publishes a structured event of your own to the session's feed. Returns None. ctx.signal("half_out", reason="target") None |
ctx.liquidate(reason) Hands everything the session holds to the platform to be closed, and finishes the session. reason is required — a short string. Returns None. ctx.liquidate("data_gap") None |
ctx.cmp(a, b) Compares two prices as exact decimals and returns -1 when a is lower, 0 when they are equal, and 1 when a is higher. Use it in place of < and > on prices. ctx.cmp(0.70, 1.50) -1 |
ctx.net_mid(legs) Returns what closing those legs would cost right now at mid prices, per share, at the same scale as an order price. Pass a trade's .legs, never a list of ctx.leg(…) values. Returns 0.0 when there is nothing to price, so gate on holding something rather than on this number. ctx.net_mid(trade.legs) 0.85 |
ctx.at("HH:MM") Returns True for every tick inside that minute — or that second, with "HH:MM:SS" — and False the rest of the day. ctx.at("09:45") True |
ctx.between("HH:MM", "HH:MM") Returns True while the current tick is between the two times: from the first, up to but not including the second. ctx.between("09:45", "10:00") True |
ctx.snap_to_tick(price) Rounds a price onto the SPX option tick grid — $0.05 steps below $3.00, $0.10 steps at $3.00 and above — and returns the rounded price. ctx.snap_to_tick(1.37) 1.35 |
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. - Every trade carries a constant, unique label. The label is a plain string written into the call — not computed, not read out of
ctx.state— and no two trades share one. - Defined risk, per trade. A short option must be covered by a long one of the same type inside the same trade — 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.close_trade(trade, reason). - Every leg action can be placed.
"buy"and"sell"are resolved by where the leg is used, so a leg that sits in neither a builder nor a close is refused — and so is one list that mixes opening and closing actions, since an order either opens a trade or closes it. - At most 24 KB of source.
- It has to run. Your code is executed against one or more recent trading sessions 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.
Resource limits
Every callback runs under a budget: a limit on how much work one call may do, and a limit on how much memory it may use — both for that call and for what the strategy keeps between calls. The limits are generous, and ordinary work is nowhere near them: scanning the whole option chain, keeping a handful of numbers in ctx.state, building a few orders. You will not meet one unless something has gone wrong — a loop that never ends, a list that grows on every tick, a string built by doubling it.
Going over is not a warning. The callback fails, and the session fails with it: a trading session in your results is recorded as failed, and a bot stops. Either way the failure names which limit was reached. The limits are fixed and identical everywhere, so a strategy that stays inside them across your results stays inside them live.
A complete strategy
This is the template the editor opens on — a put credit spread with a 50% profit target, handed over to be closed if the cost to close instead reaches 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, managed to a target.
#
# Sell the ~20-delta put, buy the put $25 below it for defined risk, and enter in
# the 09:45-09:50 window. Rest a take-profit at 50% of the credit collected; if
# the cost to close doubles instead, hand the trade 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["closing"] = False # a take-profit order is resting
ctx.state["rearm"] = False # a close came back unfilled; re-read the book
ctx.state["credit"] = None # the entry credit per share, from the fill
def build_spread(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 an
# "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
# "sell" and "buy" say it all here: these are the legs a trade's builder
# returns, so they are the legs that OPEN it.
entry_legs = [
ctx.leg(short_put, "sell", 1),
ctx.leg(long_put, "buy", 1),
]
return [entry_legs, ctx.snap_to_tick(credit), "credit"]
def on_tick(ctx):
# The label is the latch: this opens the trade once, inside the window above,
# and every later tick hands back that same trade instead of opening another.
trade = ctx.open_trade("put_spread", build_spread)
if trade == None:
# Outside the entry window there is no trade to manage yet.
return
_manage(ctx, trade)
def _manage(ctx, trade):
if trade.status != "open":
# "working" means the platform is still working the entry and the trade
# holds nothing yet; any other status means it is already finished.
return
if ctx.state["rearm"]:
# A close came back unfilled. Once the book is clear again, allow a new one.
ctx.state["rearm"] = 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(trade.legs)
if ctx.cmp(cost_to_close, credit * STOP_AT) >= 0:
# Everything that is not the resting profit target is a hand-off: the
# platform sweeps this trade's working orders and closes its contracts.
ctx.log("exit", reason="stop_loss", trade=trade.label)
ctx.close_trade(trade, "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, trade, target)
def _take_profit(ctx, trade, price):
# Size the close from what the trade HOLDS, never from the size the entry
# asked for. These legs go into a take-profit order, so "buy" / "sell" is
# again all it takes: that order closes the trade.
close_legs = []
for leg in trade.legs:
action = "buy" if leg.direction == "short" else "sell"
close_legs.append(ctx.leg(leg.instrument, action, leg.quantity))
# Buying back a credit structure is always a debit — structural, not read off
# this second's quote.
order = ctx.take_profit_order(trade, close_legs, price, "debit")
if order == None:
return
ctx.state["closing"] = True
ctx.log("exit", reason="profit_target", trade=trade.label)
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["rearm"] = 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.
still_open = 0
for trade in ctx.trades():
if trade.status == "open":
still_open = still_open + 1
ctx.log("session_end", held=still_open)