diff --git a/.env.example b/.env.example index bcef633..72dc6e3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/learn_params.py b/learn_params.py index 91f534e..0a638fe 100644 --- a/learn_params.py +++ b/learn_params.py @@ -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], } diff --git a/strategy/backtest/engine.py b/strategy/backtest/engine.py index b9843de..fbf201f 100644 --- a/strategy/backtest/engine.py +++ b/strategy/backtest/engine.py @@ -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 diff --git a/strategy/evaluation/structure.py b/strategy/evaluation/structure.py index 20b0000..c2e41b2 100644 --- a/strategy/evaluation/structure.py +++ b/strategy/evaluation/structure.py @@ -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)