Find and place the best 0DTE (zero-days-to-expiration) credit spreads from Interactive Brokers. Default execution route is the EMA9/EMA21 + VIX/VXN regime strategy (ema_vix_0dte.py), which auto-selects bull_put or bear_call from a bare EMA cross and skips the trade when the vol index is elevated (VXN ≥ 35 for NDX/QQQ, VIX ≥ 20 otherwise). Optional --rr-gate and --time-gate add red→red and bar-timing confirmation. Supports cash-settled indices (SPX, NDX, RUT, VIX) and any optionable stock/ETF. Manual spread type override is available via zero_dte.py. Requires TWS or IB Gateway running locally.
Default execution route: ema_vix_0dte.py — the EMA9/EMA21 + VIX/VXN regime
strategy. It reads the 30-min IB bars, checks the vol index (VXN ≥ 35 for NDX/QQQ,
VIX ≥ 20 otherwise), and auto-selects bull_put or bear_call from a bare EMA cross (or
skips entirely) before delegating to the spread finder. Optional --rr-gate and
--time-gate add confirmation. Use this unless the user explicitly requests a
manual spread type.
zero_dte.py is the manual override when the user specifies --type bear_call,
--type bull_put, or --type iron_condor directly.
Both scripts share the same spread-finding engine (find → propose → execute on
--execute) and all the same flags. All data comes from IBKR.
Supports cash-settled indices (SPX, NDX, RUT, VIX, XSP, DJX) — which trade as
Index contracts on their home exchange — as well as any optionable stock or ETF.
TWS or IB Gateway running locally with the API enabled:
Index options require the appropriate index-options market-data entitlement (separate from equity/ETF data). Without it, index quotes will not populate.
The script checks the vol index, reads the 30-min bars, determines bull_put vs bear_call from the EMA9/EMA21 cross, then calls the spread finder automatically. By default it runs a bare EMA cross with no bar-timing or red→red gate, so it can be run at any time of day. Opt into the confirmation gates per run.
# Dry run (propose only, no order placed) — bare EMA cross.
# Budget auto-sizes to 50% of the account's excess liquidity.
uv run python scripts/ema_vix_0dte.py NDX --account U790497 --port 7496
# With both confirmation gates on (original 10:30-ET behavior)
uv run python scripts/ema_vix_0dte.py NDX --account U790497 --port 7496 \
--rr-gate --time-gate
# Live execution
uv run python scripts/ema_vix_0dte.py NDX --account U790497 --port 7496 --execute
# Explicit budget override (skips the excess-liquidity lookup)
uv run python scripts/ema_vix_0dte.py NDX --budget 50000 --port 7496 \
--account U790497 --execute
# SPX variant
uv run python scripts/ema_vix_0dte.py SPX --account U790497 --port 7496 --executeVol index: NDX/QQQ are gated on VXN (CBOE Nasdaq-100 Volatility Index —
the correct vol gauge for a Nasdaq-100 trade); all other symbols on VIX. The
default cutoff is per-index — VXN 35, VIX 20 (VXN typically prints several
points above VIX for the same regime). The index used is echoed as vol_index in
the output; the vix_* fields hold that index's readings.
The gate is dual: both the intraday reading and the prior-day close must
sit below the cutoff, since a market recovering from a high-vol close is still
fragile. Both come from IB (vix_source: "ib") — one decision is never assembled
from two data sources. Without both readings the run stands down rather than
guessing (signal: "VOL-UNAVAILABLE").
Signal logic (default — exits early with success: false and a reason on skip):
signal: "VOL-UNAVAILABLE")signal: "VIX-SKIP")bull_put (signal: "EMA-Up")bear_call (signal: "EMA-Dn")Optional confirmation gates (both off by default):
--rr-gate — an EMA-down becomes a Bear Call only if today's two most recently
closed bars are both red (signal: "EMA-Dn+RR"); otherwise no trade
("EMA-Dn-no-RR"). The confirmation tracks momentum at the moment of the run, so
it means the same thing at 10:30 as at 15:00. A bar still in progress cannot
confirm anything and is not counted.--time-gate — require today's 9:30 + 10:00 ET bars (run at 10:30 ET or later)
and anchor the EMA-cross lookback to the 10:00 ET bar. Without it the lookback
anchors to the latest available bar.Additional flags:
--vix-threshold N — override the vol-index cutoff (default: per-index — VXN 35, VIX 20)--target-delta 0.12 — short-leg delta target (default: 0.12, ≈1.5% OTM at VIX<20)zero_dte.py flags (--max-width, --gex, --stop-mult, etc.) pass throughOptionally confirm a same-day (0DTE) expiry exists first:
uv run python scripts/zero_dte.py SYMBOL --expiriesThen find the best spreads (dry run — proposes only, places nothing):
uv run python scripts/zero_dte.py SYMBOL --type bear_call --budget 2000Execute the chosen spread (places a live combo order):
uv run python scripts/zero_dte.py SYMBOL --type bear_call --budget 2000 \
--account U1234567 --execute # places the best pick
uv run python scripts/zero_dte.py SYMBOL --budget 2000 \
--account U1234567 --execute --pick 2 # places the 2nd-ranked pickSYMBOL — underlying (e.g. SPX, NDX, RUT, VIX, AAPL, SPY)--type — bear_call (default, bearish/neutral), bull_put (bullish/neutral), or iron_condor (neutral)--budget — max capital at risk in dollars. Caps total max loss; position size is floor(budget / max-loss-per-spread). Default: auto-sized from the live account cushion — see Budget sizing below.--budget-frac — fraction of excess liquidity to deploy when auto-sizing (default: 0.5). Ignored when --budget is passed.--account — IBKR account the trade is committed to. Validated against the connection's managed accounts; echoed in the output. Defaults to the sole managed account when the login has exactly one. Required with --execute when the login manages more than one account.--execute — place the chosen spread as a live combo order. Without it (the default), the tool is a dry run: it proposes but places nothing.--pick N — 1-based rank of the candidate to execute (default: 1 = best).--limit — absolute net-credit limit override. Default: the candidate's combo_ask_credit (the marketable BUY-side of the combo NBBO — sum(short-leg bids) − sum(long-leg asks)), which fills at market. Passing a higher credit here (e.g. mid) is likely to sit at the combo bid and not fill.--limit-frac — walk between the combo NBBO's marketable side and its mid: combo_ask + frac × (net_credit − combo_ask). 0 = fully marketable (same as default), 0.5 = midpoint of combo NBBO, 1.0 = mid credit (rarely fills on multi-leg BAG combos). Computed at execution time, so it stays anchored to the fresh pull's combo quote. Ignored if --limit is set. (Note: IB paper often won't fill multi-leg 0DTE index combos at any marketable price — a paper-sim limitation, not a pricing issue.)--replace — if a live order for this symbol/expiry/type already rests, cancel and re-place it (default: refuse as a duplicate).--stop-mult — premium-cap stop: close when the spread reaches this multiple of the credit (default: 2.0 = lose ~1× credit). 0 disables the premium cap.--stop-buffer — points before the short strike to trigger the level stop (default: 0 = at the strike).--stop-delta — also stop when the short-leg delta reaches this level (optional, e.g. 0.30).--profit-target — buy back after capturing this fraction of the credit, e.g. 0.5 = 50% (0 disables). Default: per-symbol preset, else 0.50.--time-exit — flatten remaining spreads at this ET time, e.g. 15:30 (none disables). Default: per-symbol preset, else 15:30.--fill-timeout — seconds to wait for the entry to fill before cancelling it (default: 60). The bracket needs a fill; if the entry doesn't fill it's cancelled so you're never unprotected.--verify-stops — check that every open 0DTE spread has a resting protective stop, then exit (no symbol required). Add --repair to place a strike-level stop on any unprotected position.--repair — with --verify-stops, auto-place a strike-level stop on unprotected positions.Stop and exit defaults come from per-symbol presets (STOP_PRESETS in zero_dte_stop.py) — each maps mult, buffer, delta, target (profit-take), and time_exit. E.g. NDX uses mult 3.0 + 0.30 delta backstop, 50% target, 15:30 exit; SPX mult 2.5; unlisted symbols mult 2.0. Any explicit flag overrides the preset. Entry short-delta caps are separate (ENTRY_MAX_DELTA: 0.12 index / 0.20 stock). These are starting points; tune them with live data.
--expiry YYYYMMDD — override the expiry (default: today ET, i.e. true 0DTE)--top — number of candidates to return (default: 5)--min-pop — minimum probability of profit, 0–1 (default: 0, no filter)--max-width — cap the strike width in dollars (optional)--delta — cap the |delta| of the short leg(s) at entry. Applies to both verticals and (both short legs of) iron condors. Defaults by class: 0.12 for indexes, 0.20 for stocks (ENTRY_MAX_DELTA in zero_dte.py); pass a value here to override. The effective cap is echoed as max_short_delta in the output.--gex — compute the dealer gamma-exposure profile (net GEX, gamma flip, call/put walls), annotate each candidate against the walls, and gate entry_quality on the regime. See Gamma exposure (GEX) below. Costs an extra chain fetch (it pulls both option sides).--gex-weight — size measure behind each strike: volume (today's prints), oi (prior settlement's open interest), or auto (default: volume once it has printed, else OI).--allow-stale — if IBKR streams no live quotes/greeks (off-hours), price legs from yesterday's settlement close and derive greeks via Black-Scholes. Off by default: greeks come only from IBKR, so a closed market returns no candidates (with a hint) rather than stale, model-computed ones.--no-events — skip the live economic-calendar lookup (falls back to static event guidance). The calendar is fetched by default and needs no API key.--expiries — list available expiries and whether today has a 0DTE--port — IB port (default: 7497 paper; use 7496 for live)When --budget is omitted, capital-at-risk is sized from the account's live margin
cushion: the run reads ExcessLiquidity for the resolved account off the connection
it already holds and sets
budget = ExcessLiquidity × --budget-frac (default 0.5)A defined-risk vertical consumes roughly its max loss in margin, so the budget cap and the margin actually drawn are the same number — deploying the full cushion would leave zero buffer before IB's forced liquidation, which is why the default is half.
The output echoes budget, budget_source (excess_liquidity or explicit),
budget_frac, and the excess_liquidity reading it was derived from. Surface the
cushion and the derived budget when presenting candidates so the sizing is visible.
The lookup uses the same IB connection as the chain fetch — no second connect, so no client-ID collision.
It needs to know which account. Margin does not cross account boundaries, so the
budget can only be sized once an account resolves — automatic on a single-account login,
otherwise --account is required. Rather than fall back to an arbitrary default, the
run aborts with success: false and an explanatory error when:
--account),ExcessLiquidity could not be read for that account, or--budget to override deliberately.Pass --budget N any time you want a fixed number; it wins over the lookup entirely
(including --budget 0, which is honored as a real value, not as "unset").
--execute places the chosen spread as a single native BAG combo — both legs (or
all four, for an iron condor) fill together, so IBKR margins it as one defined-risk
spread. The order is a limit at the net credit (BUY the combo at -credit, which
only fills at or better than that credit), time-in-force DAY (0DTE), tagged
orderRef=ZDTE_<type>_<symbol>_<expiry>, and routed to --account.
Guardrails before an order is sent:
--account, or a sole managed account).--pick must be within the ranked list.--budget.orderRef
(ZDTE_<type>_<symbol>_<expiry>) already rests in the account, the placement is
refused (reports the existing order_id). Pass --replace to cancel and re-place
it instead. Prevents a second --execute from stacking a duplicate spread.The connection is read-only unless --execute is passed, so a plain analysis run
can never place an order. Confirm the proposal with the user before executing.
--execute)Every --execute atomically attaches a full OCA exit bracket — you can never end
up holding an unmanaged 0DTE position:
--fill-timeout).--limit or during liquid hours.(1 − target) × credit.--time-exit ET time.
If bracket placement fails, the position is emergency market-closed immediately.The profit target naturally captures near-worthless winners well before the timer, so the time exit mainly flattens positions still hovering near breakeven into the close.
The stop trigger is level-anchored on the underlying (robust to option-price noise), taking whichever of these fires first:
--stop-buffer) — the "thesis broken" level.--stop-mult) — the underlying level where the loss reaches
mult × credit, computed via Black-Scholes at entry.--stop-delta, optional).Bear call → stops if the index rises; bull put → if it falls; iron condor → two OCA-linked stops (either breach closes the whole condor). The close is a marketable limit capped at the spread width — fills at market but never worse than the defined max loss. A stop reduces the average loss; it does not guarantee the price in a gap, so the budget-capped max loss remains the true floor.
uv run python scripts/zero_dte.py --verify-stops --account U1234567 # report
uv run python scripts/zero_dte.py --verify-stops --repair --account U1234567 # auto-fixScans open 0DTE option positions per account and buckets them into protected
(a resting ZDTE_STOP_… order exists), unprotected, and unrecognized (legs that
don't form a recognized spread). With --repair, each unprotected recognized spread
gets a strike-level stop (± the symbol's preset buffer) — a safety net that needs
no live market data. Run it after entering trades, and periodically, to confirm nothing
is naked.
POP ≈ 1 − |delta|), BS N(d2) fallback.--rv-ratio × implied (default 0.85). This counts partial losses
(breach just past the short is a small loss), not a flat max loss — which fixes the
far-OTM bias of the old binary POP×maxP − (1−POP)×maxL. ev_model is echoed
(expected_pnl_rv0.85); if a leg has no IV it falls back to the binary formula.
--rv-ratio = more credit-hungry (favors richer near-the-cap strikes);
1.0 ≈ fair (EV near zero — don't use). It's an assumption, not a guarantee.--rv-ratio 0.85 actually isIt is an unvalidated prior, not a measured quantity. No backtest in this repo established 0.85; it has not been fitted to SPX/NDX 0DTE data, and it should not be read as one.
The idea behind it is real — the volatility risk premium: implied vol tends to
print above subsequently realized vol, which is why selling premium has an edge at
all. 0.85 says "assume realized comes in 15% under implied". The direction is
well documented; this particular number is a round guess at the magnitude.
Why that matters when reading the output:
--rv-ratio 1.0 expected EV goes to roughly zero.
So every positive ev_total the tool reports is a restatement of the assumption,
not independent evidence that the trade is good. Rank order between candidates is
far more trustworthy than the absolute figures.--gex read and the timing.events warnings rather than trusting it alone.To see how much of a candidate's EV is the assumption, re-run with
--rv-ratio 1.0 and compare. If EV collapses, the edge was the prior.
Treat it as a tunable, and tune it against your own fills — the paper-test report
(scripts/report.py) exists for exactly that.
--target-delta — pin the short leg(s) to a delta (± 0.05) for direct strike
control, e.g. --target-delta 0.15. Still bounded by --delta.Every run is automatically saved to sandbox/ as timestamped JSON (e.g.
NDX_0dte_bear_call_exec_2026-07-10_093015.json), and the path is echoed as
saved_to. This gives a durable trade log — including the order.bracket /
binding details that TWS alone doesn't reconstruct. No flag needed.
JSON with:
underlying_price, expiry, dte, spread_type, budget, asset_type, accountbudget_source (excess_liquidity / explicit), budget_frac, excess_liquidity —
how the budget was arrived at (see Budget sizing)dry_run — true unless --execute was passedtiming — built-in intraday guidance (see Timing & event guidance below)gex — dealer gamma-exposure profile when --gex is passed, else null (see
Gamma exposure (GEX) below): regime, net_gex_bn, flip_level, call_wall,
put_wall, heaviest_strikes, weight_source, coverage, caveats, and a
guidance sub-blockbest — the top-ranked spreadcandidates — top-N spreads, each with legs (action, right, strike, bid, ask,
mid, delta, iv), net_credit (mid credit, used for max_profit/EV/breakevens),
combo_ask_credit (marketable BUY-side of the combo NBBO — the default execution
limit; what fills at market), combo_bid_credit (resting BUY bid — best possible
credit, rarely fillable), width, pop, contracts, per-contract and total
max_profit / max_loss, capital_at_risk, ev_total, breakeven(s), risk_reward,
short_delta, and distance_to_short / distance_to_short_pct (spot-to-short-strike
cushion). Iron condors report short_call_delta / short_put_delta and
call_distance_to_short / put_distance_to_short.picked — the 1-based rank executed (only when --execute)order — the placement result when --execute: order_id, status, filled,
remaining, quantity, limit_price (negative = net credit), account, order_ref,
entry_status, log (IB's status/error messages for this order — surfaces the reject
reason when an entry goes terminal-Cancelled without filling), and bracket (the
attached OCA exit bracket: profit_target (limit_debit), stops (each side's
trigger / binding level / order_id), and time_exit (cutoff); or
{"ok": false, ...} with an emergency_close if bracket placement failed); or
{"ok": false, "error": ...} if a guardrail blocked itPresent the top candidates as a table with columns: strikes, credit, POP,
short delta, distance-to-short (points and %), max profit, max loss, contracts, EV.
Lead with the best pick and state the direction and the price level it needs the
underlying to respect (the short strike / breakeven) — the distance-to-short is the
cushion before the trade starts losing.
Aggregate your saved runs into a summary:
uv run python scripts/report.py # text summary
uv run python scripts/report.py --json # machine-readablesandbox/*_exec_*.json, automatic): trades placed by symbol/type,
entry short-delta range, avg POP, capital at risk, and the stop level placed
(binding) distribution.ib_0dte_paper_test_log_*.md you fill in): win rate,
avg win/loss, expectancy per trade, max drawdown, and the actual closed-by
leg distribution. Realized P&L only exists once a trade resolves, so it comes from the
daily log's P&L / Closed by columns — the entry JSON only captures placement.Watch expectancy, not win rate: a high win rate with one fat loss can still be negative (the report makes that obvious).
--gex adds a gex block estimating how much gamma market makers hold across the
chain, and what their hedging of it does to the tape:
uv run python scripts/zero_dte.py SPX --type bear_call --budget 2000 --gexPer strike, GEX = gamma × size × 100 × spot² × 0.01 — the dollars of dealer delta
that must be re-hedged per 1% move. Net GEX is calls minus puts, on the standard
assumption that dealers are long call gamma / short put gamma (customers buy puts
and sell calls).
regime — positive_gamma: dealers hedge against the move (sell rallies, buy
dips), so vol is suppressed and price mean-reverts — the supportive regime for short
premium. negative_gamma: they hedge with the move, amplifying it — the regime that
runs credit spreads over. neutral_gamma: the book is balanced, no edge either way.flip_level — the spot where net GEX crosses zero, found by re-deriving every
strike's gamma across a ±5% spot grid. Above it you're in the suppressive regime,
below it the amplifying one. guidance.spot_vs_flip says which side you're on.call_wall / put_wall — the heaviest gamma strikes above and below spot. Dealer
hedging tends to defend them, so they act as barriers.guidance.strike_guidance — where to put the short leg: at or beyond the wall
(bear call ≥ call wall, bull put ≤ put wall), so the level dealers defend stands
between spot and your short strike.gex tag (beyond_wall / at_wall / inside_wall per
short leg, with distance_to_wall) and a gex_ok boolean. inside_wall means
price can reach your short strike without ever contesting the wall.negative_gamma read downgrades timing.entry_quality one
notch (best/good → fair → avoid) and records timing.gex_gate with the original
value and the reason. The clock only knows the time of day; the regime knows whether
today's hedging damps moves or feeds them.GEX is advisory only — it never changes strike selection, sizing, or the order path.
Surface gex.regime, the walls, and any guidance.warnings alongside the timing block.
Weighting caveat (read this). IBKR's open interest is the prior settlement's — on
a 0DTE expiry most of the book is opened the same morning, so OI misses the flow that
actually drives today's hedging. auto therefore weights by same-day volume, which
sees today's flow but double-counts round-trips and can't tell an opening trade from a
closing one. weight_source and caveats in the output state which was used. A GEX
number built on the wrong measure can point at the wrong wall entirely — treat it as a
regime filter and a strike-placement prior, not as an edge to lean on hard. The
dealer long-call/short-put assumption itself fails when customers are the ones buying
calls (a squeeze), which inverts the profile.
Every run includes a timing block, computed from the current ET clock:
window — pre_market / opening_bell / morning_prime / midday / afternoon /
power_hour / after_hours / weekendentry_quality — best / good / fair / avoid / closed, tailored to the
spread type (credit spreads favor mid-morning; iron condors favor the midday lull;
the open and the final power hour are avoid)recommendation — one-line plain-English guidance for the current windowevents — event-risk guidance, with source:
source: "nasdaq" (live economic calendar, keyless, via Nasdaq): reports
events_today (each with event, time_et, impact, actual/consensus/previous),
high_impact_today (FOMC/CPI/PPI/NFP/PCE/GDP/retail/ISM + Fed-chair remarks), and
warnings for high-impact and imminent (within ~30 min) releases.source: "static" (fallback when the calendar can't be reached, or --no-events):
recurring intraday-window warnings plus a verify_before_trading checklist.Surface this prominently. Before proposing or (especially) executing, state the
timing.recommendation and any timing.events.warnings; if entry_quality is avoid
or closed, call that out and suggest waiting. Lead with any high-impact event today
(e.g. "FOMC at 14:00 ET") — it can gap the index straight through the short strikes.
The live calendar is fetched by default (no API key needed); pass --no-events to skip
it. Event data is US macro releases; for stock underlyings also confirm earnings.
hint — it will not compute greeks itself.--allow-stale opts into an off-hours fallback: price from yesterday's settlement
close and derive IV/delta via Black-Scholes. Flagged as
data_delay: "stalled - using yesterday's close". Useful for previews only —
those marks are stale, so numbers (especially far-OTM credits) aren't tradeable.All timestamps and time-based calculations use America/New_York. JSON output
includes generated_at (NY time string) and data_delay.
9a1bf57
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.