AI-Only Opening Range Breakout¶
ai_opening_range_breakout.py is a minimal AI-only equity strategy. Python
creates one trading agent and runs it each iteration. Entry, exit, and sizing
rules live in the prompt, while the built-in stock-trading skill provides
the reusable market-evidence, stock-order, and verification workflow.
How it works¶
The agent scans the configured universe with batch prices and history.
It builds the range only from completed regular-session bars beginning at 09:30 ET.
It requires a completed close outside the range, then sizes from the stop distance.
It manages exits and enforces the daily-entry and maximum-position rules.
Verified backtest evidence¶
The earlier five-day mechanical run completed with four fills across NVIDIA and AMD and a 0.44% total return, but required 104 agent calls. The refactor moved reusable stock mechanics into the runtime skill and changed the default decision cadence to hourly while retaining minute evidence. A bounded current run reached its third trading day with real position changes before the ten-minute wall-clock guard stopped it. The production-gated ORB eval passes three consecutive real-model repetitions and verifies completed 09:30 ET opening bars, a completed breakout close, current price evidence, one submission, and post-order state.
The example is therefore qualified for mechanics and bounded model behavior, not for expected returns. If minute bars for the true opening window are unavailable, the agent must skip the symbol instead of inventing a range.
export GEMINI_API_KEY="your-key"
export DATADOWNLOADER_BASE_URL="https://data.example.test"
export DATADOWNLOADER_API_KEY="your-data-key"
export BACKTESTING_DATA_SOURCE="ThetaData"
python -m lumibot.example_strategies.ai_opening_range_breakout
Use AI_ORB_UNIVERSE for a smaller universe during local qualification and
AI_ORB_* variables for other policy overrides.
1"""AI-only multi-ticker opening-range breakout strategy.
2
3Python only creates and runs a LumiBot agent. All entry, exit, sizing, and
4ticker selection live in the system prompt. Prefer minute bars when available.
5
6Local backtest:
7 GEMINI_API_KEY=... BACKTESTING_DATA_SOURCE=ThetaData \
8 python -m lumibot.example_strategies.ai_opening_range_breakout
9
10Optional env overrides (AI_ORB_*):
11 AI_ORB_UNIVERSE=SPY,QQQ,AAPL,...
12 AI_ORB_OPENING_RANGE_MINUTES=15
13 AI_ORB_RISK_FRACTION=0.01
14 AI_ORB_MAX_SHARES=200
15 AI_ORB_MAX_POSITIONS=1
16 AI_ORB_PROFIT_R_MULTIPLE=1.5
17 AI_ORB_SLEEPTIME=1H
18"""
19
20import os
21from datetime import datetime, timedelta
22from pathlib import Path
23
24from lumibot.strategies.strategy import Strategy
25
26# Default liquid US mega/large-cap + major ETFs (~100 names) for ORB scanning.
27_DEFAULT_ORB_UNIVERSE = (
28 "SPY,QQQ,IWM,DIA,XLK,XLF,XLE,XLI,XLV,XLY,XLP,XLU,XLB,XLRE,XLC,"
29 "AAPL,MSFT,NVDA,AMZN,GOOGL,GOOG,META,TSLA,BRK.B,JPM,V,UNH,XOM,JNJ,WMT,"
30 "MA,PG,HD,CVX,MRK,ABBV,PEP,KO,COST,AVGO,LLY,BAC,TMO,CRM,MCD,CSCO,ACN,"
31 "AMD,ADBE,NFLX,TXN,INTC,QCOM,INTU,AMAT,NOW,ORCL,IBM,UBER,ABT,DHR,PFE,"
32 "PM,WFC,MS,GS,BLK,SCHW,AXP,C,BA,CAT,GE,HON,UPS,RTX,DE,LMT,UNP,LOW,"
33 "NKE,SBUX,TGT,MDT,ISRG,SYK,GILD,AMGN,VRTX,BKNG,TJX,CMCSA,DIS,"
34 "T,VZ,NEE,SO,DUK,LIN,COP,SLB,PLD,AMT,EQIX,SPGI,CME,ICE,PYPL,SHOP"
35)
36
37
38def _parse_universe(raw: str | None) -> list[str]:
39 text = str(raw or _DEFAULT_ORB_UNIVERSE)
40 symbols: list[str] = []
41 seen: set[str] = set()
42 for part in text.replace("\n", ",").split(","):
43 symbol = part.strip().upper()
44 if not symbol or symbol in seen:
45 continue
46 seen.add(symbol)
47 symbols.append(symbol)
48 return symbols or ["SPY"]
49
50
51def build_orb_system_prompt(params: dict) -> str:
52 universe = params.get("universe") or _parse_universe(None)
53 if isinstance(universe, str):
54 universe = _parse_universe(universe)
55 universe = [str(symbol).strip().upper() for symbol in universe if str(symbol).strip()]
56 if not universe:
57 universe = ["SPY"]
58 universe_csv = ",".join(universe)
59 universe_count = len(universe)
60 opening_range_minutes = int(params.get("opening_range_minutes", 15))
61 risk_fraction = float(params.get("risk_fraction", 0.01))
62 max_shares = int(params.get("max_shares", 200))
63 max_positions = int(params.get("max_positions", 1))
64 profit_r_multiple = float(params.get("profit_r_multiple", 1.5))
65 return f"""
66You are the complete decision-maker for an AI-only multi-ticker opening-range
67breakout strategy inside LumiBot. There is no Python trading logic outside you.
68
69STRATEGY PARAMETERS:
70- universe ({universe_count} symbols): {universe_csv}
71- opening_range_minutes: {opening_range_minutes}
72- risk_fraction: {risk_fraction}
73- max_shares: {max_shares}
74- max_positions: {max_positions}
75- profit_r_multiple: {profit_r_multiple}
76
77Rules:
781. Scan the full provided universe and build each symbol's opening range from the
79 first {opening_range_minutes} completed minutes of the regular US cash session,
80 beginning at 09:30 ET. Skip symbols whose true opening window is unavailable.
812. A valid long breakout requires the latest completed bar's close to be strictly
82 greater than that symbol's opening-range high (close > OR high), with confirming
83 volume when available. A close equal to or below the OR high is not a breakout.
84 Prefer the strongest valid breakout by percent extension above the range high
85 and liquidity. Short only when shorting is allowed and evidence is equally clear.
863. Hold at most {max_positions} positions. If already at max_positions, manage exits
87 only; do not open another name.
884. Size so approximate stop risk is at most {risk_fraction:.2%} of portfolio value,
89 capped at {max_shares} shares. Stop is the opposite side of that symbol's range.
905. Take profit near {profit_r_multiple}R or exit on a close back inside the range.
916. Open at most one new position per symbol per trading day.
92
93Use only evidence available at the current runtime datetime. A no-trade decision
94is valid when no universe member has a complete opening range and valid breakout.
95""".strip()
96
97
98class AIOpeningRangeBreakoutStrategy(Strategy):
99 parameters = {
100 "universe": _parse_universe(_DEFAULT_ORB_UNIVERSE),
101 "opening_range_minutes": 15,
102 "risk_fraction": 0.01,
103 "max_shares": 200,
104 "max_positions": 1,
105 "profit_r_multiple": 1.5,
106 # The agent still analyzes minute bars, but hourly decisions avoid needless calls.
107 "sleeptime": "1H",
108 }
109
110 def initialize(self):
111 self.sleeptime = str(self.parameters.get("sleeptime", "1H"))
112 self.agents.create(
113 name="orb",
114 model="gemini-3.5-flash-lite",
115 allow_trading=True,
116 system_prompt=build_orb_system_prompt(self.parameters),
117 rules_path=Path(__file__).with_name("agent_rules") / "ai_opening_range_breakout.rules.json",
118 )
119
120 def on_trading_iteration(self):
121 params = dict(self.parameters)
122 universe = params.get("universe") or []
123 if isinstance(universe, str):
124 universe = _parse_universe(universe)
125 universe_count = len(universe) if isinstance(universe, list) else 0
126 self.agents["orb"].run(
127 task_prompt=f"Run the opening-range breakout workflow across the {universe_count}-symbol universe.",
128 context={
129 "current_datetime": self.get_datetime().isoformat(),
130 "strategy_parameters": params,
131 },
132 )
133
134
135def _parameters_from_env(defaults: dict) -> dict:
136 """Override strategy parameters from AI_ORB_* environment variables when set."""
137 params = dict(defaults)
138 if os.environ.get("AI_ORB_UNIVERSE"):
139 params["universe"] = _parse_universe(os.environ["AI_ORB_UNIVERSE"])
140 if os.environ.get("AI_ORB_OPENING_RANGE_MINUTES"):
141 params["opening_range_minutes"] = int(os.environ["AI_ORB_OPENING_RANGE_MINUTES"])
142 if os.environ.get("AI_ORB_RISK_FRACTION"):
143 params["risk_fraction"] = float(os.environ["AI_ORB_RISK_FRACTION"])
144 if os.environ.get("AI_ORB_MAX_SHARES"):
145 params["max_shares"] = int(os.environ["AI_ORB_MAX_SHARES"])
146 if os.environ.get("AI_ORB_MAX_POSITIONS"):
147 params["max_positions"] = int(os.environ["AI_ORB_MAX_POSITIONS"])
148 if os.environ.get("AI_ORB_PROFIT_R_MULTIPLE"):
149 params["profit_r_multiple"] = float(os.environ["AI_ORB_PROFIT_R_MULTIPLE"])
150 if os.environ.get("AI_ORB_SLEEPTIME"):
151 params["sleeptime"] = os.environ["AI_ORB_SLEEPTIME"].strip()
152 return params
153
154
155if __name__ == "__main__":
156 backtesting_end = datetime.fromisoformat(os.environ.get("BACKTESTING_END", datetime.now().date().isoformat()))
157 backtesting_start = datetime.fromisoformat(
158 os.environ.get("BACKTESTING_START", (backtesting_end - timedelta(days=5)).date().isoformat())
159 )
160 AIOpeningRangeBreakoutStrategy.backtest(
161 None,
162 backtesting_start=backtesting_start,
163 backtesting_end=backtesting_end,
164 benchmark_asset="SPY",
165 budget=100_000,
166 parameters=_parameters_from_env(AIOpeningRangeBreakoutStrategy.parameters),
167 )