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).
WATCHER_PRICE_MIN=1
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_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
if len(bars) < 20:
continue
out.append((symbol, sess, bars,
query_previous_close(con, symbol, sess),
query_avg_daily_volume(con, symbol, sess)))
pc = query_previous_close(con, symbol, sess)
adv = 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
@@ -99,10 +109,10 @@ def main():
"Backfill more RTH sessions for a robust optimum.\n")
grid = {
"warmup": [3, 5, 8, 12],
"min_bars": [3, 5, 8],
"eval_every": [1, 3],
"ready": [50, 60],
"warmup": [3, 6, 12],
"min_bars": [3, 6],
"eval_every": [1, 5],
"ready": [55, 65],
"gap_min": [0.05],
"rvol_min": [2.0],
}
+2
View File
@@ -30,6 +30,7 @@ class Trade:
exit_reason: str | None = None
realized_pnl: float | None = None
r_multiple: float | None = None
setup_type: str | None = None
@dataclass
@@ -175,6 +176,7 @@ class BacktestEngine:
stop_price=round(stop, 4),
target_price=round(entry + self.target_r * risk, 4),
quantity=sizing.position_size,
setup_type=setup.get("setup_type"),
)
open_trade.realized_pnl = -commission
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.
"""
import os
from dataclasses import dataclass
from enum import Enum
from typing import Literal
@@ -609,24 +610,36 @@ def classify_setup(
Returns:
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 = [
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
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:
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)