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>
This commit is contained in:
1 parent
9677c79db4
commit
14cccc5d30
3 files changed
+136
-1
No files matched your search
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Backfill historical minute + daily bars for a universe of sub-$20 names, so
|
||||||
|
learn_params.py has enough real sessions to optimize over.
|
||||||
|
|
||||||
|
Pulls the current sub-$20 most-actives (liquid names that gap often) plus any
|
||||||
|
symbols already in the DB, then fetches ~3 weeks of 1-minute bars (one paginated,
|
||||||
|
batched range request) and 60 days of daily bars for the gap/RVOL baseline.
|
||||||
|
|
||||||
|
PYTHONPATH=. /home/philip/.venvs/momentum/bin/python backfill_history.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from alpaca_paper.client import AlpacaPaperClient
|
||||||
|
from alpaca_paper.settings import AlpacaPaperSettings
|
||||||
|
from research.ingestion.discovery import screen_universe
|
||||||
|
from research.ingestion.market_data import upsert_daily_bars, upsert_minute_bars
|
||||||
|
from research.multi_schema import open_research_db
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
LOOKBACK_CALENDAR_DAYS = 22 # ~15 trading days
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
client = AlpacaPaperClient(AlpacaPaperSettings.from_env())
|
||||||
|
con = open_research_db("market")
|
||||||
|
|
||||||
|
universe = screen_universe(client, 1.0, 20.0, 40)
|
||||||
|
existing = [r[0] for r in con.execute("SELECT DISTINCT symbol FROM minute_bars").fetchall()]
|
||||||
|
universe = sorted(set(universe) | set(existing))
|
||||||
|
print(f"universe ({len(universe)}): {universe}", flush=True)
|
||||||
|
if not universe:
|
||||||
|
print("no universe (screener empty?) — aborting")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# daily bars (baseline for gap% / RVOL)
|
||||||
|
daily = client.get_daily_bars(
|
||||||
|
universe, start_iso=(now - timedelta(days=60)).date().isoformat() + "T00:00:00Z"
|
||||||
|
)
|
||||||
|
drows = sum(upsert_daily_bars(con, s, b) for s, b in daily.items())
|
||||||
|
print(f"daily bars: {drows} rows", flush=True)
|
||||||
|
|
||||||
|
# minute bars: one paginated range request (the client batches symbols <=100)
|
||||||
|
start = now - timedelta(days=LOOKBACK_CALENDAR_DAYS)
|
||||||
|
end = now - timedelta(minutes=20)
|
||||||
|
print(f"fetching minute bars {start.date()} .. {end.date()} (this can take a few min)...", flush=True)
|
||||||
|
bars = client.get_minute_bars(
|
||||||
|
universe,
|
||||||
|
start_iso=start.isoformat().replace("+00:00", "Z"),
|
||||||
|
end_iso=end.isoformat().replace("+00:00", "Z"),
|
||||||
|
feed="iex",
|
||||||
|
)
|
||||||
|
total = 0
|
||||||
|
for sym, sym_bars in bars.items():
|
||||||
|
if sym_bars:
|
||||||
|
total += upsert_minute_bars(con, sym, sym_bars)
|
||||||
|
have = len([s for s in bars if bars[s]])
|
||||||
|
print(f"minute bars: {total} rows across {have} symbols", flush=True)
|
||||||
|
|
||||||
|
sessions = con.execute("SELECT COUNT(DISTINCT session_date) FROM minute_bars").fetchone()[0]
|
||||||
|
print(f"distinct sessions in DB now: {sessions}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -102,7 +102,8 @@ def evaluate_setup(
|
|||||||
"pullback",
|
"pullback",
|
||||||
structure.setup_type
|
structure.setup_type
|
||||||
in (SetupType.FIRST_PULLBACK, SetupType.BULL_FLAG, SetupType.GAP_AND_GO,
|
in (SetupType.FIRST_PULLBACK, SetupType.BULL_FLAG, SetupType.GAP_AND_GO,
|
||||||
SetupType.HOD_BREAK, SetupType.CONTINUATION_FALLBACK)
|
SetupType.HOD_BREAK, SetupType.CONTINUATION_FALLBACK,
|
||||||
|
SetupType.OPENING_RANGE_BREAK)
|
||||||
and structure.is_valid,
|
and structure.is_valid,
|
||||||
"pullback/consolidation structure",
|
"pullback/consolidation structure",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class SetupType(Enum):
|
|||||||
"""Types of momentum setups."""
|
"""Types of momentum setups."""
|
||||||
|
|
||||||
GAP_AND_GO = "gap_and_go"
|
GAP_AND_GO = "gap_and_go"
|
||||||
|
OPENING_RANGE_BREAK = "opening_range_break"
|
||||||
FIRST_PULLBACK = "first_pullback"
|
FIRST_PULLBACK = "first_pullback"
|
||||||
BULL_FLAG = "bull_flag"
|
BULL_FLAG = "bull_flag"
|
||||||
HOD_BREAK = "hod_break"
|
HOD_BREAK = "hod_break"
|
||||||
@@ -529,6 +530,66 @@ def detect_continuation_fallback(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
def classify_setup(
|
||||||
bars: pd.DataFrame,
|
bars: pd.DataFrame,
|
||||||
premarket_high: float | None = None,
|
premarket_high: float | None = None,
|
||||||
@@ -549,6 +610,7 @@ def classify_setup(
|
|||||||
StructureDetectionResult for best valid setup
|
StructureDetectionResult for best valid setup
|
||||||
"""
|
"""
|
||||||
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),
|
||||||
|
|||||||
Reference in new issue
Block a user