Strategy: ORB-first + STRATEGY_SETUPS config; harness gapper filter + per-setup attribution

Sweep over 172 real gapper sessions (16 days) with a per-setup breakdown found
the opening-range breakout is the ONLY profitable setup (+0.25R); every slow
pattern loses (bull_flag -$78k @ ~42min is the worst). Running ORB-ONLY flips
the book from -$95k to +$7.4k AND enters ~11 min earlier (23 vs 34 min avg).

- classify_setup prefers the ORB when valid and honors STRATEGY_SETUPS env
  (comma-sep setup values) to restrict active setups; .env defaults to
  STRATEGY_SETUPS=opening_range_break (ORB-only).
- BacktestEngine Trade.setup_type captured for per-setup attribution.
- learn_params load_sessions filters to gapper sessions (mimics live scanner) so
  the backtest trades the same universe the system would.

75/75 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-16 18:44:03 -04:00
1 parent 14cccc5d30
commit 92985938e1
4 files changed
+42 -14

No files matched your search

+3
View File
@@ -72,6 +72,9 @@ WATCHER_SYMBOLS=AAPL,TSLA,NVDA,AMD,SOFI
# Price band for auto-discovered candidates (Ross Cameron default 1-20). # Price band for auto-discovered candidates (Ross Cameron default 1-20).
WATCHER_PRICE_MIN=1 WATCHER_PRICE_MIN=1
WATCHER_PRICE_MAX=20 WATCHER_PRICE_MAX=20
# Active strategy setups (comma-separated). The backtest sweep found the
# opening-range breakout is the only profitable one; empty = all setups.
STRATEGY_SETUPS=opening_range_break
# Live minute-bar ingestion (feeds the watcher with active market data) # Live minute-bar ingestion (feeds the watcher with active market data)
LIVE_BARS_ENABLED=1 LIVE_BARS_ENABLED=1
+17 -7
View File
@@ -49,9 +49,19 @@ def load_sessions(con):
bars = bars[bars["is_regular_hours"] == True].reset_index(drop=True) # noqa: E712 bars = bars[bars["is_regular_hours"] == True].reset_index(drop=True) # noqa: E712
if len(bars) < 20: if len(bars) < 20:
continue continue
out.append((symbol, sess, bars, pc = query_previous_close(con, symbol, sess)
query_previous_close(con, symbol, sess), adv = query_avg_daily_volume(con, symbol, sess)
query_avg_daily_volume(con, symbol, sess))) # Mimic the LIVE scanner: only keep sessions that actually gapped up or
# traded elevated volume. Otherwise the backtest trades non-movers the
# live discovery would never surface, burying the real gappers' edge.
try:
gap = (float(bars["open"].iloc[0]) - pc) / pc if pc else 0.0
day_rvol = (float(bars["volume"].sum()) / adv) if adv else 0.0
except Exception: # noqa: BLE001
gap, day_rvol = 0.0, 0.0
if not (gap >= 0.03 or day_rvol >= 2.0):
continue
out.append((symbol, sess, bars, pc, adv))
return out return out
@@ -99,10 +109,10 @@ def main():
"Backfill more RTH sessions for a robust optimum.\n") "Backfill more RTH sessions for a robust optimum.\n")
grid = { grid = {
"warmup": [3, 5, 8, 12], "warmup": [3, 6, 12],
"min_bars": [3, 5, 8], "min_bars": [3, 6],
"eval_every": [1, 3], "eval_every": [1, 5],
"ready": [50, 60], "ready": [55, 65],
"gap_min": [0.05], "gap_min": [0.05],
"rvol_min": [2.0], "rvol_min": [2.0],
} }
+2
View File
@@ -30,6 +30,7 @@ class Trade:
exit_reason: str | None = None exit_reason: str | None = None
realized_pnl: float | None = None realized_pnl: float | None = None
r_multiple: float | None = None r_multiple: float | None = None
setup_type: str | None = None
@dataclass @dataclass
@@ -175,6 +176,7 @@ class BacktestEngine:
stop_price=round(stop, 4), stop_price=round(stop, 4),
target_price=round(entry + self.target_r * risk, 4), target_price=round(entry + self.target_r * risk, 4),
quantity=sizing.position_size, quantity=sizing.position_size,
setup_type=setup.get("setup_type"),
) )
open_trade.realized_pnl = -commission open_trade.realized_pnl = -commission
i += 1 # consumed next bar for entry i += 1 # consumed next bar for entry
+20 -7
View File
@@ -4,6 +4,7 @@ Implements bull flag, first pullback, and gap-and-go pattern detection
based on Ross Cameron momentum strategy specifications. based on Ross Cameron momentum strategy specifications.
""" """
import os
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Literal from typing import Literal
@@ -609,24 +610,36 @@ def classify_setup(
Returns: Returns:
StructureDetectionResult for best valid setup StructureDetectionResult for best valid setup
""" """
# The opening-range breakout is the early, profitable setup — the parameter
# sweep over real gapper sessions found it is the ONLY setup with positive
# expectancy (+0.25R), while the slower patterns (bull_flag, continuation,
# gap_and_go) lose and fire ~15-20 min later. So prefer it whenever it's
# valid, rather than letting a higher-"quality"-but-slower pattern override
# an early breakout.
# STRATEGY_SETUPS (comma-separated setup values) restricts which setups may
# fire. The sweep showed opening_range_break is the only profitable one, so
# set STRATEGY_SETUPS=opening_range_break to trade ORB-only. Empty = all.
enabled = {s.strip() for s in os.environ.get("STRATEGY_SETUPS", "").split(",") if s.strip()}
orb = detect_opening_range_breakout(bars)
if (orb.is_valid and orb.quality_score >= min_quality_score
and (not enabled or "opening_range_break" in enabled)):
return orb
detectors = [ detectors = [
detect_opening_range_breakout(bars),
detect_bull_flag(bars, min_impulse_pct=0.015), detect_bull_flag(bars, min_impulse_pct=0.015),
detect_first_pullback(bars, max_pullback_depth_pct=0.6), detect_first_pullback(bars, max_pullback_depth_pct=0.6),
detect_gap_and_go(bars, premarket_high, min_gap_pct=0.02), detect_gap_and_go(bars, premarket_high, min_gap_pct=0.02),
detect_hod_break(bars, session_high), detect_hod_break(bars, session_high),
detect_continuation_fallback(bars), detect_continuation_fallback(bars),
] ]
# Filter valid setups meeting quality threshold
valid_setups = [ valid_setups = [
s for s in detectors if s.is_valid and s.quality_score >= min_quality_score s for s in detectors
if s.is_valid and s.quality_score >= min_quality_score
and (not enabled or s.setup_type.value in enabled)
] ]
if not valid_setups: if not valid_setups:
return StructureDetectionResult( return StructureDetectionResult(
setup_type=SetupType.NONE, is_valid=False, reason="No valid setup detected" 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) return max(valid_setups, key=lambda s: s.quality_score)