Files
momentum-system/strategy/evaluation/structure.py
T
momentum devandClaude Opus 4.8 14cccc5d30 Add opening-range-breakout setup (the EARLY trigger) + history backfill
- structure.detect_opening_range_breakout: break of the first ~5 RTH bars' high
  on >= OR-average volume; entry=OR high, stop=OR low. Placed first in
  classify_setup so it fires minutes after the open instead of waiting for a
  pullback to form. Verified: RGNT triggers at bar 6 (~9:36 ET) q=1.0.
- evaluate_setup counts OPENING_RANGE_BREAK toward the structure criterion.
- backfill_history.py: pull ~3 weeks of minute + 60d daily bars for the sub-$20
  universe so learn_params optimizes over real sessions.

75/75 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:22:43 -04:00

633 lines
20 KiB
Python

"""Market structure detection for momentum setups.
Implements bull flag, first pullback, and gap-and-go pattern detection
based on Ross Cameron momentum strategy specifications.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Literal
import numpy as np
import pandas as pd
class SetupType(Enum):
"""Types of momentum setups."""
GAP_AND_GO = "gap_and_go"
OPENING_RANGE_BREAK = "opening_range_break"
FIRST_PULLBACK = "first_pullback"
BULL_FLAG = "bull_flag"
HOD_BREAK = "hod_break"
CONTINUATION_FALLBACK = "continuation_fallback"
NONE = "none"
@dataclass
class StructureDetectionResult:
"""Result of structure detection."""
setup_type: SetupType
is_valid: bool
impulse_start_idx: int | None = None
impulse_end_idx: int | None = None
consolidation_start_idx: int | None = None
consolidation_end_idx: int | None = None
breakout_level: float | None = None
stop_level: float | None = None
quality_score: float = 0.0
reason: str | None = None
def detect_bull_flag(
bars: pd.DataFrame,
min_impulse_bars: int = 3,
max_consolidation_bars: int = 10,
min_impulse_pct: float = 0.03,
) -> StructureDetectionResult:
"""Detect bull flag pattern.
Pattern: Strong impulse leg up, followed by consolidation
with contracting range, then breakout.
Args:
bars: OHLCV DataFrame
min_impulse_bars: Minimum bars for impulse leg
max_consolidation_bars: Maximum bars for consolidation
min_impulse_pct: Minimum impulse percentage
Returns:
StructureDetectionResult with pattern details
"""
if len(bars) < min_impulse_bars + 2:
return StructureDetectionResult(
setup_type=SetupType.BULL_FLAG,
is_valid=False,
reason="Insufficient bars for pattern detection",
)
highs = bars["high"].values
lows = bars["low"].values
closes = bars["close"].values
# Find impulse leg (strong upward movement)
impulse_end = None
for i in range(min_impulse_bars, len(bars)):
# Calculate gain over last N bars
gain = (closes[i] - closes[i - min_impulse_bars]) / closes[i - min_impulse_bars]
if gain >= min_impulse_pct:
impulse_end = i
break
if impulse_end is None:
return StructureDetectionResult(
setup_type=SetupType.BULL_FLAG,
is_valid=False,
reason="No impulse leg found",
)
impulse_start = max(0, impulse_end - min_impulse_bars)
# Look for consolidation after impulse
consolidation_start = impulse_end + 1
if consolidation_start >= len(bars):
return StructureDetectionResult(
setup_type=SetupType.BULL_FLAG,
is_valid=False,
reason="No consolidation phase after impulse",
)
# Find consolidation end (contracting range)
consolidation_end = consolidation_start
max_consolidation_idx = min(
consolidation_start + max_consolidation_bars, len(bars) - 1
)
prev_range = highs[impulse_end] - lows[impulse_end]
for i in range(consolidation_start, max_consolidation_idx):
current_range = highs[i] - lows[i]
# Range should be contracting or stable
if current_range <= prev_range * 1.2: # Allow 20% expansion
consolidation_end = i
prev_range = min(prev_range, current_range)
else:
break
consolidation_bars = consolidation_end - consolidation_start + 1
if consolidation_bars < 2:
return StructureDetectionResult(
setup_type=SetupType.BULL_FLAG,
is_valid=False,
reason="Consolidation too short",
)
# Calculate quality score
impulse_pct = (closes[impulse_end] - closes[impulse_start]) / closes[impulse_start]
consolidation_depth = (
highs[consolidation_start : consolidation_end + 1].max()
- lows[consolidation_start : consolidation_end + 1].min()
) / closes[impulse_end]
# Quality factors
impulse_quality = min(1.0, impulse_pct / 0.10) # Max at 10% impulse
consolidation_quality = max(0, 1.0 - (consolidation_bars / max_consolidation_bars))
depth_quality = max(
0, 1.0 - (consolidation_depth / 0.05)
) # Penalty for deep pullback
quality_score = (
impulse_quality * 0.4 + consolidation_quality * 0.3 + depth_quality * 0.3
)
# Breakout level is the high of the consolidation
breakout_level = highs[consolidation_start : consolidation_end + 1].max()
# Stop level is the low of the consolidation or impulse
stop_level = min(
lows[impulse_start : consolidation_end + 1].min(),
lows[consolidation_start] * 0.995,
)
return StructureDetectionResult(
setup_type=SetupType.BULL_FLAG,
is_valid=True,
impulse_start_idx=impulse_start,
impulse_end_idx=impulse_end,
consolidation_start_idx=consolidation_start,
consolidation_end_idx=consolidation_end,
breakout_level=float(breakout_level),
stop_level=float(stop_level),
quality_score=float(quality_score),
reason=None,
)
def detect_first_pullback(
bars: pd.DataFrame,
max_pullback_bars: int = 5,
max_pullback_depth_pct: float = 0.40,
impulse_search_bars: int = 120,
) -> StructureDetectionResult:
"""Detect first pullback after impulse.
Pattern: Strong move up, shallow pullback (not more than 40% of impulse),
then continuation.
Args:
bars: OHLCV DataFrame
max_pullback_bars: Maximum bars for pullback
max_pullback_depth_pct: Maximum pullback as % of impulse
Returns:
StructureDetectionResult with pattern details
"""
if len(bars) < 5:
return StructureDetectionResult(
setup_type=SetupType.FIRST_PULLBACK,
is_valid=False,
reason="Insufficient bars",
)
highs = bars["high"].values
lows = bars["low"].values
closes = bars["close"].values
# Find impulse high in early session window (avoid late-day highs)
search_end = min(len(highs), impulse_search_bars)
if search_end < 5:
return StructureDetectionResult(
setup_type=SetupType.FIRST_PULLBACK,
is_valid=False,
reason="Insufficient early-session bars",
)
impulse_high_idx = int(np.argmax(highs[:search_end]))
impulse_high = highs[impulse_high_idx]
# Calculate impulse from recent low
lookback = min(impulse_high_idx, 10)
if lookback < 3:
return StructureDetectionResult(
setup_type=SetupType.FIRST_PULLBACK,
is_valid=False,
reason="Impulse too short",
)
impulse_low = lows[impulse_high_idx - lookback : impulse_high_idx].min()
impulse_range = impulse_high - impulse_low
if impulse_range / impulse_low < 0.03: # Need at least 3% impulse
return StructureDetectionResult(
setup_type=SetupType.FIRST_PULLBACK,
is_valid=False,
reason="Impulse too small",
)
# Look for pullback after impulse
if impulse_high_idx >= len(bars) - 1:
return StructureDetectionResult(
setup_type=SetupType.FIRST_PULLBACK,
is_valid=False,
reason="No pullback yet",
)
pullback_start = impulse_high_idx + 1
pullback_end = min(pullback_start + max_pullback_bars - 1, len(bars) - 1)
pullback_low = lows[pullback_start : pullback_end + 1].min()
pullback_depth = impulse_high - pullback_low
pullback_pct = pullback_depth / impulse_range
if pullback_pct > max_pullback_depth_pct:
return StructureDetectionResult(
setup_type=SetupType.FIRST_PULLBACK,
is_valid=False,
reason=f"Pullback too deep: {pullback_pct:.1%}",
)
# Quality scoring
depth_quality = max(0, 1.0 - (pullback_pct / max_pullback_depth_pct))
pullback_bars = pullback_end - pullback_start + 1
speed_quality = max(0, 1.0 - (pullback_bars / max_pullback_bars))
quality_score = depth_quality * 0.6 + speed_quality * 0.4
# Entry on reclaim of pullback high
breakout_level = impulse_high
stop_level = pullback_low * 0.995
return StructureDetectionResult(
setup_type=SetupType.FIRST_PULLBACK,
is_valid=True,
impulse_start_idx=impulse_high_idx - lookback,
impulse_end_idx=impulse_high_idx,
consolidation_start_idx=pullback_start,
consolidation_end_idx=pullback_end,
breakout_level=float(breakout_level),
stop_level=float(stop_level),
quality_score=float(quality_score),
reason=None,
)
def detect_gap_and_go(
bars: pd.DataFrame,
premarket_high: float | None = None,
min_gap_pct: float = 0.03,
) -> StructureDetectionResult:
"""Detect gap-and-go pattern.
Pattern: Stock gaps up, opens strong, and continues higher
breaking premarket or opening range highs.
Args:
bars: OHLCV DataFrame (including premarket)
premarket_high: High of premarket session
min_gap_pct: Minimum gap percentage
Returns:
StructureDetectionResult with pattern details
"""
if len(bars) < 5:
return StructureDetectionResult(
setup_type=SetupType.GAP_AND_GO, is_valid=False, reason="Insufficient bars"
)
opens = bars["open"].values
highs = bars["high"].values
closes = bars["close"].values
# Check for gap (first bar open vs some reference)
# For simplicity, compare first bar open to recent lows
reference_price = bars["low"].iloc[: min(5, len(bars))].min()
gap_pct = (opens[0] - reference_price) / reference_price
if gap_pct < min_gap_pct:
return StructureDetectionResult(
setup_type=SetupType.GAP_AND_GO,
is_valid=False,
reason=f"Gap too small: {gap_pct:.1%}",
)
# Determine breakout level
if premarket_high:
breakout_level = premarket_high
else:
# Use first few bars high
breakout_level = highs[: min(3, len(bars))].max()
# Check if we've broken out
current_price = closes[-1]
if current_price < breakout_level * 0.97:
return StructureDetectionResult(
setup_type=SetupType.GAP_AND_GO,
is_valid=False,
reason="Waiting for breakout confirmation",
)
# Quality based on gap size and trend strength
gap_quality = min(1.0, gap_pct / 0.15) # Max at 15% gap
# Check if price is holding above VWAP or continuing up
recent_trend = (closes[-1] - closes[min(len(closes) - 1, 3)]) / closes[
min(len(closes) - 1, 3)
]
trend_quality = 1.0 if recent_trend > 0 else 0.5
quality_score = gap_quality * 0.6 + trend_quality * 0.4
# Stop below recent pullback or opening range
stop_level = bars["low"].iloc[: min(10, len(bars))].min() * 0.995
return StructureDetectionResult(
setup_type=SetupType.GAP_AND_GO,
is_valid=True,
breakout_level=float(breakout_level),
stop_level=float(stop_level),
quality_score=float(quality_score),
reason=None,
)
def detect_hod_break(
bars: pd.DataFrame,
session_high: float | None = None,
) -> StructureDetectionResult:
"""Detect high-of-day breakout.
Pattern: Stock consolidates under the HOD, then breaks through
with volume confirmation.
Args:
bars: OHLCV DataFrame
session_high: Current session high (if known)
Returns:
StructureDetectionResult with pattern details
"""
if len(bars) < 10:
return StructureDetectionResult(
setup_type=SetupType.HOD_BREAK, is_valid=False, reason="Insufficient bars"
)
highs = bars["high"].values
closes = bars["close"].values
# Determine HOD
if session_high:
hod = session_high
else:
hod = highs.max()
# Find consolidation under HOD
hod_idx = np.where(highs == hod)[0][0] if hod in highs else -1
if hod_idx < 0 or hod_idx < len(bars) - 5:
return StructureDetectionResult(
setup_type=SetupType.HOD_BREAK,
is_valid=False,
reason="No recent HOD to break",
)
# Look for consolidation before HOD
consolidation_start = max(0, hod_idx - 10)
consolidation_zone = highs[consolidation_start:hod_idx]
# Quality: More touches of the level = better setup
touches = np.sum(consolidation_zone > hod * 0.995)
consolidation_quality = min(1.0, touches / 3) # Max at 3 touches
# Check if we broke and holding
current_price = closes[-1]
if current_price < hod * 0.99:
return StructureDetectionResult(
setup_type=SetupType.HOD_BREAK,
is_valid=False,
reason="Waiting for HOD break",
)
quality_score = consolidation_quality * 0.7 + 0.3 # Base score for breaking
stop_level = bars["low"].iloc[max(0, len(bars) - 5) :].min() * 0.995
return StructureDetectionResult(
setup_type=SetupType.HOD_BREAK,
is_valid=True,
breakout_level=float(hod),
stop_level=float(stop_level),
quality_score=float(quality_score),
reason=None,
)
def detect_continuation_fallback(
bars: pd.DataFrame,
min_bars: int = 40,
impulse_window: int = 25,
min_impulse_pct: float = 0.015,
max_pullback_pct: float = 0.65,
) -> StructureDetectionResult:
if len(bars) < min_bars:
return StructureDetectionResult(
setup_type=SetupType.CONTINUATION_FALLBACK,
is_valid=False,
reason="Insufficient bars for fallback detection",
)
highs = bars["high"].values
lows = bars["low"].values
closes = bars["close"].values
search_end = min(len(closes), max(min_bars, 120))
if search_end < impulse_window + 5:
return StructureDetectionResult(
setup_type=SetupType.CONTINUATION_FALLBACK,
is_valid=False,
reason="Insufficient early-session window",
)
best_start = 0
best_end = impulse_window
best_gain = -1.0
for i in range(0, search_end - impulse_window):
j = i + impulse_window
start = closes[i]
end = closes[j]
if start <= 0:
continue
gain = (end - start) / start
if gain > best_gain:
best_gain = gain
best_start = i
best_end = j
if best_gain < min_impulse_pct:
return StructureDetectionResult(
setup_type=SetupType.CONTINUATION_FALLBACK,
is_valid=False,
reason="No sufficient impulse for fallback",
)
impulse_high = highs[best_start : best_end + 1].max()
impulse_low = lows[best_start : best_end + 1].min()
impulse_range = impulse_high - impulse_low
if impulse_range <= 0:
return StructureDetectionResult(
setup_type=SetupType.CONTINUATION_FALLBACK,
is_valid=False,
reason="Invalid impulse range",
)
pullback_start = min(best_end + 1, len(bars) - 1)
pullback_end = min(pullback_start + 20, len(bars) - 1)
if pullback_end <= pullback_start:
return StructureDetectionResult(
setup_type=SetupType.CONTINUATION_FALLBACK,
is_valid=False,
reason="No pullback window",
)
pullback_low = lows[pullback_start : pullback_end + 1].min()
pullback_depth = max(0.0, impulse_high - pullback_low)
pullback_pct = pullback_depth / impulse_range
if pullback_pct > max_pullback_pct:
return StructureDetectionResult(
setup_type=SetupType.CONTINUATION_FALLBACK,
is_valid=False,
reason=f"Fallback pullback too deep: {pullback_pct:.1%}",
)
breakout_level = float(impulse_high)
stop_level = float(pullback_low * 0.995)
if stop_level >= breakout_level:
return StructureDetectionResult(
setup_type=SetupType.CONTINUATION_FALLBACK,
is_valid=False,
reason="Invalid fallback risk geometry",
)
impulse_quality = min(1.0, best_gain / 0.05)
pullback_quality = max(0.0, 1.0 - (pullback_pct / max_pullback_pct))
current_price = closes[-1]
proximity = max(0.0, 1.0 - abs((breakout_level - current_price) / breakout_level))
quality_score = float(
impulse_quality * 0.45 + pullback_quality * 0.35 + proximity * 0.20
)
return StructureDetectionResult(
setup_type=SetupType.CONTINUATION_FALLBACK,
is_valid=True,
impulse_start_idx=int(best_start),
impulse_end_idx=int(best_end),
consolidation_start_idx=int(pullback_start),
consolidation_end_idx=int(pullback_end),
breakout_level=breakout_level,
stop_level=stop_level,
quality_score=quality_score,
reason=None,
)
def detect_opening_range_breakout(
bars: pd.DataFrame,
orb_bars: int = 5,
min_range_pct: float = 0.004,
min_break_pct: float = 0.0,
) -> StructureDetectionResult:
"""Opening-range breakout — the EARLY trigger.
Fires minutes after the open instead of waiting for a pullback to form: the
opening range is the high/low of the first ``orb_bars`` regular-hours bars
(09:30-09:3x); a later bar closing above that high, on at least opening-range
average volume, is the breakout. Entry = OR high, stop = OR low.
"""
if "is_regular_hours" in bars.columns:
rth = bars[bars["is_regular_hours"] == True] # noqa: E712
if rth.empty:
return StructureDetectionResult(
SetupType.OPENING_RANGE_BREAK, False, reason="no regular-hours bars")
rth = rth.reset_index(drop=True)
else:
rth = bars.reset_index(drop=True)
if len(rth) < orb_bars + 1:
return StructureDetectionResult(
SetupType.OPENING_RANGE_BREAK, False, reason="opening range not complete")
orb = rth.iloc[:orb_bars]
orb_high = float(orb["high"].max())
orb_low = float(orb["low"].min())
if orb_high <= 0 or (orb_high - orb_low) / orb_high < min_range_pct:
return StructureDetectionResult(
SetupType.OPENING_RANGE_BREAK, False, reason="opening range too tight")
after = rth.iloc[orb_bars:]
last_close = float(after["close"].iloc[-1])
if last_close < orb_high * (1.0 + min_break_pct):
return StructureDetectionResult(
SetupType.OPENING_RANGE_BREAK, False, reason="no opening-range break yet")
orb_vol = float(orb["volume"].mean()) if "volume" in orb.columns else 0.0
break_vol = float(after["volume"].iloc[-1]) if "volume" in after.columns else 0.0
vol_ok = orb_vol <= 0 or break_vol >= 0.8 * orb_vol
stop = orb_low * 0.995
if stop >= orb_high:
return StructureDetectionResult(
SetupType.OPENING_RANGE_BREAK, False, reason="invalid ORB risk geometry")
extension = (last_close - orb_high) / orb_high
quality = 0.4 + min(0.4, extension / 0.02 * 0.4) + (0.2 if vol_ok else 0.0)
return StructureDetectionResult(
setup_type=SetupType.OPENING_RANGE_BREAK,
is_valid=True,
breakout_level=orb_high,
stop_level=float(stop),
quality_score=float(max(0.0, min(1.0, quality))),
reason=None,
)
def classify_setup(
bars: pd.DataFrame,
premarket_high: float | None = None,
session_high: float | None = None,
min_quality_score: float = 0.2,
) -> StructureDetectionResult:
"""Classify which setup type is present.
Tests all setup types and returns the best valid one.
Args:
bars: OHLCV DataFrame
premarket_high: High of premarket session
session_high: Current session high
min_quality_score: Minimum quality to accept setup
Returns:
StructureDetectionResult for best valid setup
"""
detectors = [
detect_opening_range_breakout(bars),
detect_bull_flag(bars, min_impulse_pct=0.015),
detect_first_pullback(bars, max_pullback_depth_pct=0.6),
detect_gap_and_go(bars, premarket_high, min_gap_pct=0.02),
detect_hod_break(bars, session_high),
detect_continuation_fallback(bars),
]
# Filter valid setups meeting quality threshold
valid_setups = [
s for s in detectors if s.is_valid and s.quality_score >= min_quality_score
]
if not valid_setups:
return StructureDetectionResult(
setup_type=SetupType.NONE, is_valid=False, reason="No valid setup detected"
)
# Return highest quality setup
return max(valid_setups, key=lambda s: s.quality_score)