Building a Real-Time Futures Trading Bot in Python with Interactive Brokers
How I turned a simple gap-fill idea into a live Python trading system using SPY, futures confirmation, and Interactive Brokers
Daily market gaps were one of the first patterns that made me want to combine trading, data analysis, and Python automation.
A gap happens when the market opens at a different price from where it closed in the previous session. In this project, I focused on SPY and used the previous regular-session close as the reference point. If SPY opened above the previous close, I treated it as a gap up. If it opened below the previous close, I treated it as a gap down.
The core question behind the project was simple:
When SPY opens away from the previous close, how often does price come back and fill that gap during the same trading day?
Turning that question into a live system required much more than looking at charts. I had to define the signal precisely, study its historical behavior, connect to live market data, and manage entries and exits in real time.
Starting with the historical behavior
Before writing the live script, I looked at historical gap behavior using my SPY gap feature dataset. The analysis covered 25 years, from 07 November 2000 to 30 October 2025.
Across that sample, about 65.87% of days filled the gap intraday. But the headline number was not the most useful insight. Gap size mattered far more.
That changed how I thought about the setup.
Small gaps filled very often, but they were not always worth trading once transaction costs and execution noise were considered. Larger gaps filled much less often.
Direction turned out to matter less than I expected:
That pushed me away from treating gap-up and gap-down days as two separate strategies. Instead, I started thinking in terms of gap size, remaining gap, and intraday confirmation.
The core trading idea
The bot uses SPY as the signal driver, but it does not trade SPY directly.
The logic is:
- If SPY opens above the previous regular-session close, the bot looks for short-side conditions.
- If SPY opens below the previous regular-session close, the bot looks for long-side conditions.
- The previous close becomes the full gap-fill target.
- The bot avoids entering if the full gap has already filled.
- The bot also requires enough gap to remain before entering.
The actual trade is executed in MES futures. I chose MES because it provides smaller contract exposure than ES while still letting the system express an intraday index-direction view.
In code, the gap definition starts simply:
previous_day_close = data_daily[-2].close
today_open = data_daily[-1].opengap_up = today_open > previous_day_close
gap_down = today_open < previous_day_closegap_start = today_open
gap_end = previous_day_close
gap_size = abs(previous_day_close - today_open)
gap_size_percent = ((today_open - previous_day_close) / previous_day_close) * 100
The previous close is the key reference level. For a gap up, the full gap fill happens if price trades back down to the previous close. For a gap down, the full gap fill happens if price trades back up to that level.
Why I added ES and NQ confirmation
One of the first things I learned was that a raw SPY gap signal was not enough on its own.
The bot needed confirmation from the broader futures market.
That is why the script also watches ES and NQ. It calculates VWAP from the current Eastern Time calendar-day futures session and uses the latest completed minute, not a still-forming bar.
Conceptually:
- For a short setup, ES and NQ should both be below VWAP
- For a long setup, ES and NQ should both be above VWAP
The logic is intentionally simple:
short_side_ok = (es_close_local < es_vwap_local) and (nq_close_local < nq_vwap_local)
long_side_ok = (es_close_local > es_vwap_local) and (nq_close_local > nq_vwap_local)This is not a perfect market model. It is a practical filter.
Before entering a trade, the system checks whether SPY’s gap idea is aligned with what ES and NQ are doing.
Get Zuzana Shivram Sami’s stories in your inbox
Join Medium for free to get updates from this writer.
The script was built to be configurable from the start. Key parameters such as minimum gap size, remaining-gap threshold, VWAP confirmation rules, stop behavior, position size, and end-of-day exit settings can be adjusted in the configuration without changing the core strategy logic. The bot also sends Telegram notifications for important events, including entries, exits, and strategy status updates, which makes it easier to monitor in real time.
Building it as an event-driven script
The live version uses ib_insync, a higher-level Python wrapper around the Interactive Brokers API.
I chose ib_insync over the lower-level native IB API because it makes market data handling and order logic much easier to organize.
The script subscribes to live-updating historical bars:
data = ib.reqHistoricalData(
spy_contract,
endDateTime="",
durationStr="1 D",
barSizeSetting="5 secs",
whatToShow="TRADES",
useRTH=True,
keepUpToDate=True,
)The main strategy logic is attached to the data update event:
data.updateEvent += on_new_bar
ib.run()That was one of the most satisfying parts of the build.
Instead of writing a loop that constantly checks prices, the bot reacts to incoming market data. From there, it was no longer just a script polling data. It was reacting to the market in real time.
Entry conditions
The entry logic combines several filters:
- The day has a sufficiently large gap
- The full gap has not already filled
- SPY has moved into the gap
- Enough of the gap still remains
- ES and NQ confirm the direction
- No trade has already been taken that day
The remaining-gap filter was especially important because I did not want the bot entering after most of the move had already happened:
def remaining_gap_pct(current_price, prev_close, gap_up, gap_down):
if gap_up:
rem = current_price - prev_close
elif gap_down:
rem = prev_close - current_price
else:
rem = 0.0
rem = max(rem, 0.0)
return (rem / prev_close) * 100.0The minimum acceptable gap size is defined in the configuration, which makes it easy to tune the entry threshold without rewriting the strategy.
The final entry gate is deliberately rule-based:
if in_gap_now and futures_confirm and enough_remaining:
direction_short = gap_up
_enter_mes_market(
direction_short=direction_short,
qty=int(MES_FULL_SIZE),
order_ref="ENTRY_MES_FULL",
)No prediction magic. Just conditions that either line up or do not.
Exit logic: before and after the gap fill
The exit logic is split into two phases.
Phase 1: before the full gap fill
Before SPY reaches the previous close, the bot uses a VWAP-based invalidation rule. If both ES and NQ move against the trade direction for the configured number of completed minutes, the bot exits.
That logic looks like this:
wrong_es = (es_close_local > es_vwap_local) if short_position else (es_close_local < es_vwap_local)
wrong_nq = (nq_close_local > nq_vwap_local) if short_position else (nq_close_local < nq_vwap_local)
wrong_now = wrong_es and wrong_nqIf both ES and NQ remain on the wrong side of VWAP for the configured number of consecutive completed 2-minute candles, the MES position is closed. In the current setup, that means waiting for two 2-minute candles to close against the trade.
if in_position and (not post_full_mode):
if vwap_consec_wrong >= VWAP_2MIN_CONSEC:
_flatten_mes_market(order_ref="VWAP_2MIN_STOP")Phase 2: after the full gap fill
Once SPY touches the previous close, the bot switches to post-fill management. At that point, the VWAP stop is disabled and a trailing giveback rule takes over.
In code, the switch is tracked with post_full_mode:
if in_position and (not post_full_mode) and full_target_touched_now:
post_full_mode = TrueFrom there, the bot monitors the most favorable move and exits if too much of it is given back:
lock_keep = 1.0 - TRAIL_RETRACE_FRAC
min_profit_kept = peak_favor * lock_keepThere is also an end-of-day flatten rule so the position is not intentionally held beyond the intraday strategy window.
What comes next
The current version is a working live framework, but there is still more to do. The next step is a fuller historical backtest of the complete strategy logic, including entry filters, futures confirmation, and exit behavior. I am also interested in building an options-based variant, where the same gap-fill idea is expressed through SPY options instead of MES futures.
Repository
The project code is available here:
Github link
This project is shared as a portfolio and engineering example. It is not investment advice.









