You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""
|
|
Afternoon Momentum Diagnostic (Option 2)
|
|
|
|
Hypothesis: stocks that form a midday consolidation (10:00-12:30 ET) and then break out
|
|
in the afternoon (12:30-15:00 ET) have positive edge that is regime-orthogonal to V23.
|
|
|
|
Setup:
|
|
- Universe: midlarge tickers with intraday cache
|
|
- Morning/midday range: high of bars 9:30-12:30 ET
|
|
- Afternoon breakout: first bar close above morning range high after 12:30 ET
|
|
- Volume filter: breakout bar volume > 1.5x mean of prior 12 bars (~1 hour)
|
|
- Entry: open of the bar following the breakout bar (next bar)
|
|
- Stop: ATR14 * 0.75 below entry (ATR14 approximated from 5-min ATR * sqrt(78))
|
|
- Exit: EOD (15:55 ET bar close)
|
|
- Risk per trade: 5% of $10,000 = $500 (same as V23)
|
|
- Max simultaneous: 3 (same as V23)
|
|
|
|
Gates:
|
|
- WR ≥ 50%
|
|
- avg_win / avg_loss ≥ 1.0
|
|
- Pearson corr with V23 daily PnL ≤ 0.25
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import yaml
|
|
|
|
# ── Config ─────────────────────────────────────────────────────────────────
|
|
|
|
CACHE_DIR = "data/cache/intraday"
|
|
UNIVERSE_FILE = "configs/symbols_midlarge_snapshot_exact.yaml"
|
|
V23_BASELINE_RUN = "runs/intraday_orb/intraday_20260420_205136_5597b16d.json"
|
|
|
|
MIN_PRICE = 10.0
|
|
MIN_AVG_VOL_BARS = 5 # minimum bars in midday range
|
|
MIDDAY_END_HOUR = 12 # 12:30 ET
|
|
MIDDAY_END_MIN = 30
|
|
PM_ENTRY_HOUR = 12
|
|
PM_ENTRY_MIN = 30
|
|
PM_CUTOFF_HOUR = 15 # no new entries after 15:00 ET
|
|
EXIT_HOUR = 15
|
|
EXIT_MIN = 55
|
|
VOL_CONFIRM_MULT = 1.5 # breakout bar vol > 1.5x prior-hour avg
|
|
RISK_PER_TRADE = 500.0 # $500 = 5% of $10k
|
|
ATR_STOP_MULT = 0.75
|
|
MAX_SIMULTANEOUS = 3
|
|
|
|
|
|
def load_universe() -> list[str]:
|
|
with open(UNIVERSE_FILE) as f:
|
|
data = yaml.safe_load(f)
|
|
return data.get("symbols", data) if isinstance(data, dict) else data
|
|
|
|
|
|
def load_bars(ticker: str, date: str) -> pd.DataFrame | None:
|
|
path = f"{CACHE_DIR}/{ticker}/{date}.parquet"
|
|
if not os.path.exists(path):
|
|
return None
|
|
try:
|
|
df = pd.read_parquet(path)
|
|
if len(df) < 20:
|
|
return None
|
|
df["ts"] = pd.to_datetime(df["timestamp"]).dt.tz_convert("US/Eastern")
|
|
df["hour"] = df["ts"].dt.hour
|
|
df["minute"] = df["ts"].dt.minute
|
|
df = df[(df["hour"] >= 9) & ((df["hour"] < 16))].copy()
|
|
df = df.sort_values("ts").reset_index(drop=True)
|
|
return df
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def approximate_atr(df: pd.DataFrame) -> float:
|
|
"""Estimate daily ATR from 5-min bars: sqrt(78) * avg 5-min range."""
|
|
if len(df) < 10:
|
|
return 0.0
|
|
ranges = df["high"] - df["low"]
|
|
avg_5min_range = ranges.mean()
|
|
return avg_5min_range * math.sqrt(78)
|
|
|
|
|
|
def simulate_day(date: str, tickers: list[str]) -> dict:
|
|
"""Run afternoon momentum simulation for one day."""
|
|
day_trades: list[dict] = []
|
|
open_positions: list[dict] = []
|
|
|
|
for ticker in tickers:
|
|
bars = load_bars(ticker, date)
|
|
if bars is None or len(bars) < 20:
|
|
continue
|
|
|
|
# Quality: price
|
|
first_bar = bars.iloc[0]
|
|
if first_bar["open"] < MIN_PRICE:
|
|
continue
|
|
|
|
# Split into morning/midday and afternoon
|
|
midday_mask = (
|
|
(bars["hour"] < MIDDAY_END_HOUR) |
|
|
((bars["hour"] == MIDDAY_END_HOUR) & (bars["minute"] < MIDDAY_END_MIN))
|
|
)
|
|
morning_bars = bars[midday_mask]
|
|
afternoon_bars = bars[
|
|
(bars["hour"] > PM_ENTRY_HOUR) |
|
|
((bars["hour"] == PM_ENTRY_HOUR) & (bars["minute"] >= PM_ENTRY_MIN))
|
|
]
|
|
|
|
if len(morning_bars) < MIN_AVG_VOL_BARS or len(afternoon_bars) < 2:
|
|
continue
|
|
|
|
# Morning range high (consolidation ceiling)
|
|
morning_high = morning_bars["high"].max()
|
|
|
|
# ATR estimate for stop
|
|
atr = approximate_atr(bars)
|
|
if atr <= 0:
|
|
continue
|
|
|
|
# Scan for breakout in afternoon
|
|
breakout_idx = None
|
|
for i in range(len(afternoon_bars) - 1):
|
|
bar = afternoon_bars.iloc[i]
|
|
# Stop scanning if too late
|
|
if bar["hour"] >= PM_CUTOFF_HOUR:
|
|
break
|
|
# Already have a position in this ticker?
|
|
if any(p["ticker"] == ticker for p in open_positions):
|
|
break
|
|
# Check if close above morning high
|
|
if bar["close"] > morning_high:
|
|
# Volume confirmation: compare to prior 12 bars
|
|
bar_idx = afternoon_bars.index[i]
|
|
prior_start = max(0, bar_idx - 12)
|
|
prior_vol = bars.loc[prior_start:bar_idx - 1, "volume"].mean()
|
|
if prior_vol > 0 and bar["volume"] < VOL_CONFIRM_MULT * prior_vol:
|
|
continue
|
|
# Entry on next bar open
|
|
next_bar = afternoon_bars.iloc[i + 1]
|
|
entry_price = next_bar["open"]
|
|
stop_price = entry_price - ATR_STOP_MULT * atr
|
|
if stop_price <= 0:
|
|
continue
|
|
shares = RISK_PER_TRADE / (entry_price - stop_price)
|
|
if shares <= 0 or shares * entry_price > 50000:
|
|
continue
|
|
breakout_idx = i + 1
|
|
open_positions.append({
|
|
"ticker": ticker,
|
|
"entry_price": entry_price,
|
|
"stop_price": stop_price,
|
|
"shares": shares,
|
|
"entry_bar_idx": afternoon_bars.index[breakout_idx],
|
|
"bars": afternoon_bars,
|
|
"atr": atr,
|
|
})
|
|
break
|
|
|
|
# Limit simultaneous positions
|
|
open_positions = open_positions[:MAX_SIMULTANEOUS]
|
|
|
|
# Resolve positions: scan remaining afternoon bars for stop or EOD exit
|
|
for pos in open_positions:
|
|
bars_slice = pos["bars"]
|
|
entry_bar_idx = pos["entry_bar_idx"]
|
|
remaining = bars_slice[bars_slice.index > entry_bar_idx]
|
|
|
|
exit_price = pos["entry_price"]
|
|
exit_reason = "eod"
|
|
|
|
for _, bar in remaining.iterrows():
|
|
# Stop hit
|
|
if bar["low"] <= pos["stop_price"]:
|
|
exit_price = min(pos["stop_price"], bar["open"])
|
|
exit_reason = "stop"
|
|
break
|
|
# EOD exit
|
|
if bar["hour"] == EXIT_HOUR and bar["minute"] == EXIT_MIN:
|
|
exit_price = bar["close"]
|
|
exit_reason = "eod"
|
|
break
|
|
# Last bar fallback
|
|
if bar["hour"] >= 15 and bar["minute"] >= 50:
|
|
exit_price = bar["close"]
|
|
exit_reason = "eod"
|
|
break
|
|
|
|
pnl = (exit_price - pos["entry_price"]) * pos["shares"]
|
|
day_trades.append({
|
|
"ticker": pos["ticker"],
|
|
"date": date,
|
|
"entry": pos["entry_price"],
|
|
"exit": exit_price,
|
|
"shares": pos["shares"],
|
|
"pnl": pnl,
|
|
"win": pnl > 0,
|
|
"exit_reason": exit_reason,
|
|
})
|
|
|
|
day_pnl = sum(t["pnl"] for t in day_trades)
|
|
return {"date": date, "trades": day_trades, "day_pnl": day_pnl}
|
|
|
|
|
|
def main() -> None:
|
|
print("=== Afternoon Momentum Diagnostic ===\n")
|
|
|
|
# Load V23 baseline
|
|
with open(V23_BASELINE_RUN) as f:
|
|
v23_data = json.load(f)
|
|
v23_dates = [d["date"] for d in v23_data["daily_summary"]]
|
|
v23_pnl = {d["date"]: d["daily_pnl"] for d in v23_data["daily_summary"]}
|
|
|
|
universe = load_universe()
|
|
print(f"Universe: {len(universe)} tickers")
|
|
print(f"V23 window: {v23_dates[0]} → {v23_dates[-1]} ({len(v23_dates)} days)\n")
|
|
|
|
all_results = []
|
|
for i, date in enumerate(v23_dates):
|
|
result = simulate_day(date, universe)
|
|
all_results.append(result)
|
|
if (i + 1) % 20 == 0:
|
|
print(f" {i+1}/{len(v23_dates)} days processed...", flush=True)
|
|
|
|
# Aggregate
|
|
all_trades = [t for r in all_results for t in r["trades"]]
|
|
total_trades = len(all_trades)
|
|
wins = sum(1 for t in all_trades if t["win"])
|
|
win_rate = wins / total_trades if total_trades else 0.0
|
|
avg_win = np.mean([t["pnl"] for t in all_trades if t["pnl"] > 0]) if wins > 0 else 0.0
|
|
avg_loss = np.mean([abs(t["pnl"]) for t in all_trades if t["pnl"] <= 0]) if total_trades - wins > 0 else 0.0
|
|
total_pnl = sum(t["pnl"] for t in all_trades)
|
|
|
|
# Correlation with V23
|
|
pm_daily = {r["date"]: r["day_pnl"] for r in all_results}
|
|
pm_series = [pm_daily.get(d, 0.0) for d in v23_dates]
|
|
v23_series = [v23_pnl.get(d, 0.0) for d in v23_dates]
|
|
corr = float(np.corrcoef(pm_series, v23_series)[0, 1]) if len(v23_dates) > 1 else 0.0
|
|
|
|
trade_days = sum(1 for r in all_results if r["trades"])
|
|
|
|
print("=" * 55)
|
|
print(f" Total trades: {total_trades}")
|
|
print(f" Trade days: {trade_days} / {len(v23_dates)}")
|
|
print(f" Win rate: {win_rate*100:.1f}% (gate: ≥50%)")
|
|
print(f" Avg win: ${avg_win:.2f}")
|
|
print(f" Avg loss: ${avg_loss:.2f}")
|
|
print(f" Win/Loss ratio: {avg_win/avg_loss:.2f} (gate: ≥1.0)" if avg_loss > 0 else " Win/Loss ratio: N/A")
|
|
print(f" Total PnL: ${total_pnl:+.2f}")
|
|
print(f" Corr vs V23: {corr:.3f} (gate: ≤0.25)")
|
|
print("=" * 55)
|
|
|
|
# Gate verdict
|
|
g1 = win_rate >= 0.50
|
|
g2 = (avg_win / avg_loss >= 1.0) if avg_loss > 0 else False
|
|
g3 = corr <= 0.25
|
|
g4 = total_trades >= 30
|
|
print(f"\nGate G1 (WR ≥ 50%): {'PASS' if g1 else 'FAIL'}")
|
|
print(f"Gate G2 (W/L ≥ 1.0): {'PASS' if g2 else 'FAIL'}")
|
|
print(f"Gate G3 (corr ≤ 0.25): {'PASS' if g3 else 'FAIL'}")
|
|
print(f"Gate G4 (trades ≥ 30): {'PASS' if g4 else 'FAIL'}")
|
|
verdict = "PROCEED TO ENGINE BUILD" if (g1 and g2 and g3 and g4) else "ABORT — edge not confirmed"
|
|
print(f"\nVERDICT: {verdict}")
|
|
|
|
# Save results
|
|
out = {
|
|
"total_trades": total_trades,
|
|
"trade_days": trade_days,
|
|
"win_rate": win_rate,
|
|
"avg_win": avg_win,
|
|
"avg_loss": avg_loss,
|
|
"total_pnl": total_pnl,
|
|
"corr_v23": corr,
|
|
"trades": all_trades,
|
|
"daily_pnl": [{"date": r["date"], "pnl": r["day_pnl"]} for r in all_results],
|
|
}
|
|
out_path = "runs/intraday_orb/diag_afternoon_momentum.json"
|
|
import json as _j
|
|
with open(out_path, "w") as f:
|
|
_j.dump(out, f, indent=2, default=str)
|
|
print(f"\nResults saved to {out_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
|
|
main()
|