If you searched for automated options trading with Interactive Brokers in Python, you have probably found the two halves separately: signal content with no execution, and ib_insync tutorials that trade on moving-average crosses. What follows is the joined-up version for options: real dealer-positioning inputs on one side, real order mechanics on the other.
~100
Lines for a complete guarded executor
60 s
Poll cadence - minute-scale signals, not HFT
7497
The paper port. Start here, stay here a while
Architecture
[FlashAlpha API] ->(poll 60s)-> [decide: regime + signal rules] ->(qualified contract)-> [IBKR via ib_insync] ->(fills, brackets)-> [state + kill switch]
Design choices worth stating: this is minute-scale signal trading, not latency-sensitive execution - a 60-second poll against cached-tier analytics is the right cadence, and colocation buys you nothing here (see the latency tax). All strategy state lives in your process; IBKR is the executor, FlashAlpha is the sensor.
Prerequisites
- An IBKR account with a paper login, and TWS or IB Gateway running with API connections enabled. Paper connects on port 7497 (live is 7496/4001 - do not touch live until the paper log says you should).
pip install flashalpha ib_async - ib_async is the community-maintained continuation of the ib_insync library - same API, one import rename away (ib_insync to ib_async).
- A FlashAlpha key: full-chain GEX is Growth tier; the scored flow-signals feed used below is Alpha, which also unlocks historical replay to validate the rule before automating it (see the backtesting guide).
The sensor side, verified
Both reads used below, exactly as the SDK returns them (run today - at the time of writing SPY sat in a short-gamma regime, net GEX -13.5B, flip at 746.17 vs spot 738.55):
from flashalpha import FlashAlpha
fa = FlashAlpha(api_key=API_KEY)
gex = fa.gex("SPY")
# {'symbol': 'SPY', 'underlying_price': 738.545, 'as_of': ..., 'gamma_flip': 746.165,
# 'net_gex': -13463206856.07, 'net_gex_label': ..., 'strikes': [...]}
sigs = fa.flow_signals("SPY")
# {'symbol': 'SPY', 'as_of': ..., 'window_minutes': 240, 'underlying_price': ...,
# 'chain': {...}, 'count': N, 'signals': [{'signal': {'score': 83, 'intent': 1,
# 'tags': ['sweep','opening','golden'], ...}, 'enrichment': {...}}, ...]}
Intent is the scorer's classification (1 = bullish, 2 = bearish); score is 0-100. Whether any given rule on these fields carries edge is YOUR research problem - we have published honest nulls on naive versions (the flow-signal study) - this article is about executing whatever rule survived your validation.
The reference implementation
Download the full script (single file, ~120 lines with comments) or read it below.
"""FlashAlpha -> IBKR options executor. PAPER FIRST (port 7497)."""
import time
from dataclasses import dataclass, field
from datetime import date, datetime
from flashalpha import FlashAlpha
from ib_async import IB, Option, Stock
API_KEY = "YOUR_FLASHALPHA_KEY"
SYMBOL, POLL_S = "SPY", 60
MAX_OPEN, DAILY_LOSS_CAP = 1, -500.0 # concurrent entry order groups, USD
SCORE_MIN = 80
@dataclass
class State:
open_orders: int = 0
day: date = field(default_factory=date.today)
day_pnl: float = 0.0
killed: bool = False
def decide(fa: FlashAlpha) -> str | None:
"""Return 'C' (buy call) / 'P' (buy put) / None. Replace with YOUR validated rule."""
gex = fa.gex(SYMBOL)
sigs = fa.flow_signals(SYMBOL)
hc = [s["signal"] for s in sigs.get("signals", [])
if s["signal"]["score"] >= SCORE_MIN and "opening" in s["signal"].get("tags", [])]
if gex.get("net_gex", 0) < 0: # short-gamma regime: moves extend
if any(s["intent"] == 1 for s in hc):
return "C"
if any(s["intent"] == 2 for s in hc):
return "P"
return None
def pick_contract(ib: IB, right: str, spot: float):
"""Nearest weekly expiry >= 7 DTE, strike nearest spot, SMART-routed."""
chains = ib.reqSecDefOptParams(SYMBOL, "", "STK", ib.qualifyContracts(
Stock(SYMBOL, "SMART", "USD"))[0].conId)
chain = next(c for c in chains if c.exchange == "SMART")
expiry = sorted(e for e in chain.expirations
if (datetime.strptime(e, "%Y%m%d").date() - date.today()).days >= 7)[0]
strike = min(chain.strikes, key=lambda k: abs(k - spot))
opt = Option(SYMBOL, expiry, strike, right, "SMART", currency="USD")
ib.qualifyContracts(opt)
return opt
def place_bracket(ib: IB, opt, qty: int):
[tkr] = ib.reqTickers(opt)
mid = (tkr.bid + tkr.ask) / 2 if tkr.bid and tkr.ask else tkr.marketPrice()
entry = round(mid, 2) # alpha=0: improve to taste
bracket = ib.bracketOrder("BUY", qty, limitPrice=entry,
takeProfitPrice=round(entry * 1.5, 2),
stopLossPrice=round(entry * 0.5, 2))
for o in bracket:
ib.placeOrder(opt, o)
return bracket
def main():
fa = FlashAlpha(api_key=API_KEY)
ib = IB()
ib.connect("127.0.0.1", 7497, clientId=17) # PAPER port
st = State()
def on_exec(trade, fill):
print(f"FILL {fill.contract.localSymbol} {fill.execution.shares} @ {fill.execution.price}")
ib.execDetailsEvent += on_exec
def on_commission(trade, fill, report):
pnl = report.realizedPNL
if pnl and abs(pnl) < 1e300: # IB's unset sentinel is DBL_MAX - filter it
st.day_pnl += pnl
ib.commissionReportEvent += on_commission
while True:
try:
if date.today() != st.day: # new session: reset caps
st = State()
if st.day_pnl <= DAILY_LOSS_CAP and not st.killed:
st.killed = True
for t in ib.openTrades():
if t.order.parentId == 0: # cancel ENTRY orders only -
ib.cancelOrder(t.order) # bracket TP/stops stay working
print("KILL SWITCH: daily loss cap hit; entries cancelled, going flat-only")
open_parents = sum(1 for t in ib.openTrades() if t.order.parentId == 0)
if not st.killed and open_parents < MAX_OPEN:
right = decide(fa)
if right:
spot = fa.gex(SYMBOL)["underlying_price"]
opt = pick_contract(ib, right, spot)
place_bracket(ib, opt, qty=1)
print(f"ORDER {opt.localSymbol} bracket placed")
except ConnectionError:
print("IBKR disconnected; retrying in 30s")
ib.disconnect(); time.sleep(30)
ib.connect("127.0.0.1", 7497, clientId=17)
except Exception as e:
print("loop error (continuing):", e)
ib.sleep(POLL_S) # keeps the event loop serviced
if __name__ == "__main__":
main()
The parts that make it a system
- Bracket on entry, always. The take-profit and stop ride server-side at IBKR, so a crashed script does not leave a naked position unattended.
- Position cap before signal check - the cheapest risk control is refusing to look for trades while one is on.
- Daily kill switch cancels working ENTRY orders (parents only,
parentId == 0) and stops new ones; the bracket TP/stops of filled positions keep working at IBKR. Decide deliberately if you want flatten-on-kill instead.
- Reconnect path: TWS restarts nightly; the loop must survive it.
ib.sleep() (never time.sleep() alone) keeps ib_async's event loop breathing.
- Partial fills: bracket children are linked server-side to their parent at IBKR; verify the scaling behavior on your paper account before trusting it at size - and it is still one more reason to prefer brackets over hand-managed exits.
- Entry price is mid here (alpha = 0) - deliberately optimistic so the fill discipline is a conscious choice. Before believing any backtest of the same rule, price fills honestly: the fill-model reference.
Go-live checklist
- Run on paper (7497) through at least one full expiry cycle including an FOMC or CPI session.
- Read every fill in the TWS log against your script log - order-state surprises show up here first.
- Validate the RULE separately from the plumbing via historical replay - same SDK, base-URL swap.
- Size for the strategy's worst replayed week, not its average.
- Only then move the port.
Educational reference code, not investment advice. Options can lose more than premium when strategies go beyond defined-risk longs; automation multiplies both discipline and mistakes.
The distance from "I have a signal" to "orders execute unattended and nothing catastrophic can happen" is exactly the ~100 lines above: a verified sensor read, an explicit rule slot, contract qualification, brackets, caps, a kill switch, and a reconnect path. The FlashAlpha side is two SDK calls; the IBKR side is standard ib_async; the discipline is the product. Related: automated commentary bots, the QuantConnect LEAN bridge if you would rather live inside a backtest engine, and Kelly-based sizing for the caps. The signals side needs a Growth key for GEX, Alpha for the scored flow feed.