I open-sourced my entire SPY 0DTE options bot — code, strategy, the full manual, and a live dashboard so you don't have to take my word for anything
▲ 14 r/algorithmictrading+2 crossposts

I open-sourced my entire SPY 0DTE options bot — code, strategy, the full manual, and a live dashboard so you don't have to take my word for anything

Some background first: I've spent 3+ years building and running automated futures strategies on the IBKR API, and honestly, none of this would exist without that scar tissue. Alpaca's options API is a different beast (in some ways easier, in some ways much weirder), but knowing what a live bot does to you at 3:25 PM on a bad day is transferable knowledge.

So — the whole thing is on GitHub now. Not a framework with the good parts removed — the actual entry logic I run every morning, all the parameters, the risk management, and the 22-page strategy manual with every session post-mortem, losing days included.

Code + manual: github.com/milgar7969/alpaca-options-framework
Live results: milgar7969.github.io/spy-0dte-dashboard — updates itself daily after the close, green or red

It's paper trading, last 30 days are green, May was rough, and the dashboard will tell on me either way.

Half the value is honestly the README and the pdf manual, which document every Alpaca options landmine I stepped on: bracket orders rejected, null Greeks on 0DTE, and cancelled buy orders that fill anyway (ask me how I know).

Credit where it's due: this is a human + AI collaboration. I built and run this with Claude (Anthropic's model) as my pair programmer — it wrote a lot of the code, caught bugs in the logs I'd have missed, and talked me out of some bad ideas.

The strategy calls, the market experience, and the stubbornness are mine. I'd rather be upfront about that than have the haters leave negative comments.

Clone it, break it, tell me what I did right and what I got wrong.
p.s. there are a lot of knowledge, from my years of experience I am sharing for free, so please be nice ;-)

u/Tiny-Ad-7916 — 16 hours ago

I just open-sourced my full SPY 0DTE bot — 3+ weeks of real sessions, honest P&L, and 3 new bugs that nearly broke everything

This software is for educational purposes only. It does not constitute financial advice. Paper trading performance does not guarantee live trading results. Use at your own risk.

Three weeks ago I posted a post-mortem on my SPY 0DTE gamma-explosion bot after a -$309 day exposed five failure patterns. I was holding back the strategy parameters and teasing a paid release. I changed my mind — the full code is now open source, all parameters included, no paywalls.

This post covers what happened since May 29: a volatility regime shift, three new failure modes, two fixes, and why the +$1,287 day in the table below is fake.

Original post: https://www.reddit.com/r/alpacamarkets/comments/1tsw5ej/built_a_live_0dte_spy_options_bot_on_alpaca_heres/
Full code: github.com/milgar7969/alpaca-options-framework


What changed in the market

When I wrote the original post, SPY was around $758 and the 5-day daily ATR was roughly $5. Over the following two weeks:

  • SPY dropped ~$30 into a range around $726–$750
  • The 5-day daily ATR escalated to $13–$15 — nearly 3× the regime the bot was built in

This matters because ATR is baked into strike selection. Every morning the bot computes:

offset = ATR_MULT * baseline_atr   # ATR_MULT = 0.60
call_strike = SPY + offset
put_strike  = SPY - offset

At ATR=5, offset = $3.00 — strikes about $3 OTM, well within reach on a trending day.
At ATR=15, offset = $9.00 — strikes $9 OTM. SPY would need a 1.2% move just to reach the approach zone. On most days, it doesn't.


Failure Mode 6 — Strike calibration silently breaks under high ATR ✅ Fixed

June 12 and June 15 — two consecutive trading days — both had zero trades. Not zero winners. Zero entries. The bot ran all day, processed 390 bars, detected momentum signals, and never once entered.

From the June 12 log:

09:45 startup_atr=15.43  offset=9.26  call_target=762.50  put_target=744.00
10:30 [BAR] spy_close=753.14  atr5=0.312  APPROACH ZONE: NONE  (call@762.50 put@744.00)

SPY spent the entire day between $751–$757. The call target was $762.50 — $9 away. No log errors. No warnings. The bot was technically working; the strike selection was just producing unreachable targets.

Fix: A hard cap on the strike offset:

MAX_STRIKE_OFFSET = 4.50
offset = min(ATR_MULT * baseline_atr, MAX_STRIKE_OFFSET)

The 5-day ATR is still used when it's low. The cap prevents elevated ATR from placing strikes beyond SPY's realized intraday range. June 16 was the first day with the cap live — 13 trades, the bot was active again.


Failure Mode 7 — Ghost position / watchdog restart cascade 🔲 Unresolved

This is the most damaging failure and still isn't fixed.

The bot has a watchdog: if silent for >20 seconds during market hours, it auto-restarts. Works fine for WebSocket drops. Under one specific EOD sequence, it produces a cascade:

  1. Time stop fires at 3:25 PM — close_position() submitted, waiting for fill
  2. MOC period is slow — fill response takes >20 seconds
  3. Watchdog fires, kills the process, restarts
  4. On startup, _recover_open_position() polls Alpaca REST for open positions
  5. Finds positions the prior instance was mid-close on — reconstructs them as "recovered"
  6. New instance holds those positions and hits the time stop again
  7. Loop continues — positions liquidated at random end-of-session prices

June 3: Two cascade exits at 3:25–3:26 PM. $224 in losses from positions the prior instance had already closed.

June 4: The same cascade produced a +$1,831.11 time_stop exit (an untracked C755 got liquidated when C755 happened to be wide at 3:25 PM), plus -$234 and -$252 from two recovered puts.

Raw P&L that day: +$1,287. Clean strategy P&L (excluding the three cascade exits): -$58.

Calling this out explicitly because the table below looks much better without the asterisks. The "recovery" mechanism that's supposed to protect against restarts is the proximate cause of the cascades. Two root causes still under investigation: (1) why the time_stop path hangs >20 seconds; (2) why _recover_open_position() finds positions that were already closed.


Failure Mode 8 — Fixed SPY stop buffer causes 52% false stops ✅ Fixed

The bot has a SPY-level stop: if SPY bar-closes more than SPY_STOP_BUFFER dollars against the position direction, it exits regardless of option price. The original value was $0.10.

After tagging 58 spy_stop exits across the June sessions: 27 of 52 (52%) saw SPY reverse within 3 bars. The stop was cutting winners, not losers. $0.10 is inside normal bar-to-bar noise when atr5 is 0.35–0.45 — the regime after the volatility shift.

Fix: ATR-adaptive buffer using the intrabar ATR already tracked by the momentum engine:

SPY_STOP_ATR_MULT = 0.75   # buffer = 0.75 × atr5 at entry time
SPY_STOP_FLOOR    = 0.10   # minimum buffer regardless of atr5

# at entry, store atr5 on the Position:
pos = Position(..., entry_spy_price=bar.close, entry_atr5=momentum_engine.state.atr5)

# on each bar close:
buf = max(config.SPY_STOP_FLOOR, config.SPY_STOP_ATR_MULT * pos.entry_atr5)

At atr5=0.20: buf = 0.15 — slightly tighter than before
At atr5=0.35: buf = 0.26 — gives the position room to breathe
At atr5=0.45: buf = 0.34 — high-velocity session, real reversals are larger moves

First day live (June 17): 2 spy_stops out of 6 trades vs. 7–11/day the prior week. Both that fired held 5 and 17 bars — actual reversals, not noise.


Full performance log

Days marked * have ghost-position cascade exits excluded from the clean column.

Date Trades Raw P&L Clean P&L Notes
May 20 5 +$64 +$64
May 26 4 -$309 -$309 Post-mortem session
May 27 10 -$159 -$159 First day w/ fixes, trail miscalibrated
May 29 5 +$70 +$70 First full session with ATR gate + trail
Jun 1 4 -$124 -$124
Jun 2 2 -$117 -$117
Jun 3 * 10 -$148 +$76 Ghost cascade: -$224 excluded
Jun 4 * 11 +$1,287 -$58 Ghost cascade: +$1,345 excluded
Jun 5 18 +$178 +$178
Jun 8 15 -$324 -$324 High ATR, elevated spy_stop rate
Jun 9 15 +$265 +$265 Strong bear leg
Jun 10 14 -$79 -$79
Jun 11 5 -$1 -$1
Jun 12 0 $0 $0 ← strike calibration failure
Jun 15 0 $0 $0 ← strike calibration failure
Jun 16 13 -$19 -$19 First day with MAX_STRIKE_OFFSET cap
Jun 17 6 +$82 +$82 First day with adaptive SPY stop

Clean strategy total (May 20 – Jun 17): -$121

The raw total is +$1,007, almost entirely from the Jun 4 ghost position anomaly. -$121 is what the strategy actually produced.


What's still on the list

Three of the original five failure patterns remain unaddressed:

  • VWAP extension cap (|SPY - VWAP| ≤ $1.25)
  • Same-strike 60-minute session cooldown after a stop
  • Post-TP directional cooldown (10 bars)

One new unresolved item:

  • Ghost position / watchdog cascade at EOD

On open-sourcing

The original repo had None placeholders for all strategy parameters with a hint about a paid release. I've dropped that. The full working config is in the repo now — every threshold, multiplier, and stop level we're actually running.

The parameters alone aren't the edge. The edge (if there is one) is in understanding when the strategy fails and why — which is what these posts are for. A bot with the right numbers but no understanding of the ghost cascade bug or the regime sensitivity would still blow up.


Technical stack: Python 3.11, alpaca-py ≥ 0.43, asyncio. Three concurrent WebSocket streams (StockDataStream + OptionDataStream + TradingStream). Quote-driven exits via asyncio.create_task(_evaluate_exit()) — sub-50ms from quote arrival to order. SPY-level stop on bar close using atr5 stored at entry time. Strike offset capped at $4.50 regardless of 5-day ATR.

Full code: github.com/milgar7969/alpaca-options-framework

Still paper trading. Still logging everything. Ghost cascade is next.

reddit.com
u/Tiny-Ad-7916 — 19 days ago

Built a live 0DTE SPY options bot on Alpaca — here's every API limitation I hit and how I worked around them

I've spent the past month building and running a live SPY 0DTE options bot on Alpaca paper trading. This post is for anyone else attempting something similar — specifically the API behaviour that isn't in the documentation and cost me real debugging time.

The bot is fully functional. It streams live quotes, detects momentum setups, sizes positions, enters and exits automatically, and logs every trade to CSV. I'm sharing the architecture and the gotchas so you don't have to rediscover them yourself.


What the bot does (briefly)

Trades same-day expiry (0DTE) SPY call and put options. Selects OTM strikes each morning using a 5-day ATR offset, streams real-time option quotes, enters on momentum + proximity signals, exits via take profit, trailing stop, hard stop, or time stop. Fully autonomous during market hours.

Stack: Python 3.11, alpaca-py >= 0.43.0, asyncio, no paid data feeds.


Limitation 1 — Bracket orders are rejected for options

This was the first wall I hit. The natural way to manage a long option position is a bracket order: entry + TP limit + stop loss in one atomic submission. Alpaca rejects this for options on paper trading with:

error 42210000: complex orders not supported

Sell limit orders are also rejected with:

error 40310000: cannot submit sell order —
no existing position or insufficient qty

Alpaca treats a sell limit on an option you don't yet own as an attempt to open a short position, which requires margin approval. Even when you do own the position, limit sells are unreliable.

The only working exit method is close_position():

await trading_client.close_position(symbol)

This submits a market order to close your existing long. It works consistently. Everything else for options exits on Alpaca paper — bracket legs, sell limits, stop orders — should be considered unsupported until proven otherwise.

The implication: you cannot set-and-forget with a bracket. You need your own exit monitor running in your process. Mine polls every 5 seconds:

async def _exit_monitor():
    while True:
        await asyncio.sleep(5)
        pos = bot_state.position
        if pos is None:
            continue
        quote = bot_state.get_quote(pos.symbol)
        mid   = quote.mid

        if mid >= pos.entry_price * 1.50:
            reason = "tp"
        elif mid <= pos.entry_price * 0.50:
            reason = "stop"
        else:
            continue

        await order_manager.close_position(pos.symbol, pos.qty)

Limitation 2 — Greeks return null for 0DTE contracts

Alpaca computes Greeks server-side using Black-Scholes. At expiry (T=0), the model is mathematically undefined. Every 0DTE contract returns null for delta, gamma, theta, vega. This is not a bug — it's a fundamental limitation of the model at T=0.

If your strategy depends on Greeks for entry filtering, you need to compute them yourself or build a proxy.

I built a proxy delta from the ratio of option price change to SPY price change:

proxy_delta = (option_mid_now - option_mid_prev) / (SPY_now - SPY_prev)

In practice I ended up disabling it (see below) but the framework is there for anyone who needs it.


Limitation 3 — SPY price only updates once per 1-minute bar

This one killed the proxy delta. The OptionDataStream delivers option quotes many times per second. The StockDataStream delivers SPY bars once per minute at the bar close. Between bar closes, SPY_now == SPY_prev, so the denominator is zero and proxy delta stays at 0.0 for most of the minute — which would block every entry if used as a filter.

The workaround for Phase 0: disable proxy delta filtering entirely. Use momentum state from the 1-min bars (EMA crossover, rate of change, consecutive bar count) as the directional gate instead. These only update once per bar too, but that's fine — they're designed for bar-level signals.

For Phase 1 I plan to extract tick-level SPY price from the OptionDataStream itself (the underlying price is embedded in option snapshots) to get sub-minute resolution.


Limitation 4 — Option subscription timing vs. premarket price

If you subscribe to option symbols at bot startup (premarket), the strikes you compute are based on the premarket SPY price. When the market opens, SPY can gap significantly — I had a morning where the bot started at SPY $743 premarket and subscribed puts at $735–$736, then SPY opened at $734. Nine dollars of gap. Wrong strikes all day.

Fix: Defer all option subscriptions until the first 9:30 ET RTH bar.

_market_open_event = asyncio.Event()

async def on_spy_bar(bar):
    bar_time = bar.timestamp.astimezone(ET).time()
    if not _market_open_event.is_set() and bar_time >= time(9, 30):
        strikes = compute_dynamic_strikes(bar.close, baseline_atr)
        _market_open_event.set()

async def _option_subscriber():
    await _market_open_event.wait()
    symbols = compute_strikes(bot_state.spy_price)
    feed.subscribe_options(symbols)

Limitation 5 — Re-subscription for intraday SPY drift

The standard approach of subscribing a fixed window of strikes at open breaks down if SPY moves significantly during the session. By 1 PM, your subscribed strikes may be $10+ from the current price — totally irrelevant.

I run a background watcher that checks SPY price every 10 seconds. If it's moved more than $3.00 from the last subscription anchor, it re-subscribes a new window:

async def _resubscribe_watcher():
    await _market_open_event.wait()
    last_anchor = bot_state.spy_price

    while True:
        await asyncio.sleep(10)
        move = abs(bot_state.spy_price - last_anchor)
        if move >= 3.0:
            new_symbols = compute_strikes(bot_state.spy_price)
            feed.add_option_symbols(new_symbols)
            last_anchor = bot_state.spy_price

One important detail: always pin the symbol of any currently open position in the new subscription. If you drop it during re-subscription, your exit monitor stops receiving quotes and can't close the position.


Limitation 6 — Position recovery on restart

If you restart your process mid-session with an open position, that position still exists on Alpaca. On startup, poll REST for existing option positions and reconstruct your local state:

def _recover_open_position():
    positions = trading_client.get_all_positions()
    for p in positions:
        if p.asset_class == AssetClass.US_OPTION:
            side, strike = parse_occ_symbol(p.symbol)
            recovered = Position(
                symbol      = p.symbol,
                entry_price = float(p.avg_entry_price),
                qty         = int(float(p.qty)),
                order_id    = "recovered",
            )
            bot_state.open_position(recovered)

Without this, a restart abandons a live position with no exit monitoring.


SDK version matters

Use alpaca-py >= 0.43.0. The older alpaca-trade-api package has no options support at all — it predates Alpaca's options offering. If you're hitting import errors or missing option-related classes, check your package version first.

pip install "alpaca-py>=0.43.0"

Does the bot actually work?

Yes — with caveats. Here's the last four active sessions:

Date Trades Net P&L Notes
May 20 5 +$64 Early version, no filters
May 26 4 -$309 Red day, led to two major improvements
May 27 10 -$159 First day with improvements, trail threshold miscalibrated
May 29 5 +$70 First green day with full filter set

The -$309 day was the most analytically useful session. I did a full post-mortem and found five failure patterns — two of which are now fixed (an ATR velocity gate on entries and a peak trailing stop). Happy to go into detail on those if there's interest.


The asyncio task graph, for anyone building something similar:

await asyncio.gather(
    feed.start(),               # StockDataStream + OptionDataStream + TradingStream
    _option_subscriber(),       # waits for 9:30 ET bar → subscribes strikes
    _resubscribe_watcher(),     # re-subscribes if SPY moves ±$3
    _exit_monitor(),            # TP / stop / trail check every 5 seconds
    _time_stop_watcher(),       # force-close everything at 3:25 PM ET
    _status_loop(),             # terminal display
)

Three concurrent WebSocket streams, all managed through alpaca-py's async SDK. The entry logic lives in the option quote handler. The exit logic is completely decoupled in its own task — this separation matters because close_position() cannot be called from inside a stream callback safely.


Happy to share more of the code or go deeper on any of these. If you've hit other Alpaca options limitations I haven't covered, drop them in the comments — would be useful to know what else is lurking.

reddit.com
u/Tiny-Ad-7916 — 1 month ago