AI-Only Opening Range Breakout

AI opening-range breakout workflow using LumiBot runtime skills, rules, market evidence, and execution

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    )