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:
1 parent
14cccc5d30
commit
92985938e1
4 files changed
+42
-14
No files matched your search
@@ -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
@@ -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],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user