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.
370 lines
12 KiB
Python
370 lines
12 KiB
Python
"""
|
|
Simple Fixed 2%/2% Exit ORB Diagnostic
|
|
|
|
Hypothesis: V23의 gainers pool (같은 필터) + max 5 포지션 + 고정 2% TP / 2% SL.
|
|
레짐 필터 없음 (아무런 정보 없이), ATR 기반 trailing stop 없음.
|
|
|
|
Entry: ORB 고점 돌파 (bullish breakout)
|
|
Exit:
|
|
- Take profit: entry * 1.02 (+2%)
|
|
- Stop loss: entry * 0.98 (-2%)
|
|
- EOD: 15:55 ET
|
|
|
|
V23과 비교:
|
|
- Same gainers pool (gap ≥2%, rvol ≥1.5, atr_pct ≥4%, dvol ≥25M, premarket_dvol ≥1.5M)
|
|
- No QQQ regime filter
|
|
- Top 5 instead of V23's dynamic scoring + max 3
|
|
- $500 risk per trade (5% of $10k)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import yaml
|
|
|
|
CACHE_DIR = "data/cache/intraday"
|
|
DAILY_CACHE_DIR = "data/cache/daily"
|
|
UNIVERSE_FILE = "configs/symbols_midlarge_snapshot_exact.yaml"
|
|
V23_BASELINE_RUN = "runs/intraday_orb/intraday_20260420_205136_5597b16d.json"
|
|
|
|
MIN_PRICE = 10.0
|
|
MIN_GAP_UP = 0.02 # ≥2% gap up
|
|
MAX_GAP_UP = 0.25 # ≤25% gap up (exclude extreme movers only)
|
|
MIN_AVG_DOLLAR_VOL = 25_000_000
|
|
MIN_RVOL = 1.5
|
|
MIN_ATR_PCT = 0.0 # no ATR% filter (simple strategy)
|
|
RISK_PER_TRADE = 500.0 # $500 = 5% of $10k
|
|
MAX_SIMULTANEOUS = 5 # 5개 (user request)
|
|
EXIT_HOUR = 15
|
|
EXIT_MIN = 55
|
|
TP_PCT = 0.02 # +2% take profit
|
|
SL_PCT = 0.02 # -2% stop loss
|
|
|
|
|
|
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_daily_cache(ticker: str) -> pd.DataFrame | None:
|
|
path = f"{DAILY_CACHE_DIR}/{ticker}.parquet"
|
|
if not os.path.exists(path):
|
|
return None
|
|
try:
|
|
return pd.read_parquet(path)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
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) < 10:
|
|
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:
|
|
return (df["high"] - df["low"]).mean() * math.sqrt(78)
|
|
|
|
|
|
def simulate_day(date: str, tickers: list[str],
|
|
daily_data: dict[str, pd.DataFrame]) -> dict:
|
|
candidates = []
|
|
|
|
for ticker in tickers:
|
|
if ticker not in daily_data:
|
|
continue
|
|
df = daily_data[ticker]
|
|
idx = df.index[df["date"] == date].tolist()
|
|
if not idx or idx[0] == 0:
|
|
continue
|
|
row_idx = idx[0]
|
|
today = df.iloc[row_idx]
|
|
prev = df.iloc[row_idx - 1]
|
|
if prev["close"] <= 0:
|
|
continue
|
|
|
|
gap = (today["open"] - prev["close"]) / prev["close"]
|
|
if gap < MIN_GAP_UP or gap > MAX_GAP_UP:
|
|
continue
|
|
|
|
avg_dvol = (
|
|
df["close"].iloc[max(0, row_idx - 20):row_idx].mean() *
|
|
df["volume"].iloc[max(0, row_idx - 20):row_idx].mean()
|
|
)
|
|
if avg_dvol < MIN_AVG_DOLLAR_VOL:
|
|
continue
|
|
|
|
candidates.append({
|
|
"ticker": ticker,
|
|
"gap": gap,
|
|
"prev_close": prev["close"],
|
|
"today_open": today["open"],
|
|
"avg_dvol": avg_dvol,
|
|
})
|
|
|
|
if not candidates:
|
|
return {"date": date, "trades": [], "day_pnl": 0.0}
|
|
|
|
day_trades: list[dict] = []
|
|
open_positions = 0
|
|
|
|
# rank by gap (biggest gapper first) as simple proxy for rvol ranking
|
|
# secondary: avg_dvol
|
|
candidates.sort(key=lambda x: (-x["gap"], -x["avg_dvol"]))
|
|
|
|
for cand in candidates:
|
|
if open_positions >= MAX_SIMULTANEOUS:
|
|
break
|
|
|
|
ticker = cand["ticker"]
|
|
gap = cand["gap"]
|
|
prev_close = cand["prev_close"]
|
|
|
|
bars = load_bars(ticker, date)
|
|
if bars is None or len(bars) < 10:
|
|
continue
|
|
|
|
if bars.iloc[0]["open"] < MIN_PRICE:
|
|
continue
|
|
|
|
orb_bars = bars[(bars["hour"] == 9) & (bars["minute"] >= 30) & (bars["minute"] < 35)]
|
|
if len(orb_bars) == 0:
|
|
continue
|
|
orb = orb_bars.iloc[0]
|
|
|
|
# RVOL check
|
|
avg_daily = daily_data[ticker]["volume"].mean() if ticker in daily_data else 0
|
|
first_vol = bars.iloc[0]["volume"]
|
|
rvol = first_vol / (avg_daily / 78) if avg_daily > 0 else 0
|
|
if rvol < MIN_RVOL:
|
|
continue
|
|
|
|
atr = approximate_atr(bars)
|
|
if atr <= 0:
|
|
continue
|
|
|
|
orb_high = orb["high"]
|
|
entry_trigger = orb_high
|
|
|
|
# Fixed position sizing: $500 risk at 2% SL
|
|
stop_dist = entry_trigger * SL_PCT
|
|
shares = RISK_PER_TRADE / stop_dist
|
|
if shares <= 0 or shares * entry_trigger > 50_000:
|
|
continue
|
|
|
|
take_profit: float | None = None
|
|
stop_price: float | None = None
|
|
|
|
post_orb = bars[bars.index > orb_bars.index[0]]
|
|
entry_price: float | None = None
|
|
exit_price: float | None = None
|
|
exit_reason = "no_entry"
|
|
|
|
for _, bar in post_orb.iterrows():
|
|
if entry_price is None:
|
|
if bar["high"] >= entry_trigger:
|
|
entry_price = max(entry_trigger, bar["open"])
|
|
take_profit = entry_price * (1.0 + TP_PCT)
|
|
stop_price = entry_price * (1.0 - SL_PCT)
|
|
# same-bar check
|
|
if bar["low"] <= stop_price:
|
|
exit_price = stop_price
|
|
exit_reason = "stop"
|
|
break
|
|
continue
|
|
else:
|
|
if bar["hour"] >= EXIT_HOUR and bar["minute"] >= EXIT_MIN:
|
|
exit_price = bar["close"]
|
|
exit_reason = "eod"
|
|
break
|
|
if bar["low"] <= stop_price:
|
|
exit_price = min(stop_price, bar["open"])
|
|
exit_price = max(exit_price, bar["low"])
|
|
exit_reason = "stop"
|
|
break
|
|
if bar["high"] >= take_profit:
|
|
exit_price = max(take_profit, bar["open"])
|
|
exit_reason = "target"
|
|
break
|
|
|
|
if entry_price is None:
|
|
continue
|
|
if exit_price is None:
|
|
exit_price = bars.iloc[-1]["close"]
|
|
exit_reason = "eod"
|
|
|
|
pnl = (exit_price - entry_price) * shares
|
|
|
|
day_trades.append({
|
|
"ticker": ticker,
|
|
"date": date,
|
|
"gap": gap,
|
|
"rvol": rvol,
|
|
"entry": entry_price,
|
|
"exit": exit_price,
|
|
"pnl": pnl,
|
|
"win": pnl > 0,
|
|
"exit_reason": exit_reason,
|
|
})
|
|
open_positions += 1
|
|
|
|
day_pnl = sum(t["pnl"] for t in day_trades)
|
|
return {"date": date, "trades": day_trades, "day_pnl": day_pnl}
|
|
|
|
|
|
def equity_curve(daily_pnl_series: list[float], initial: float = 10000.0) -> list[float]:
|
|
curve = [initial]
|
|
for pnl in daily_pnl_series:
|
|
curve.append(curve[-1] + pnl)
|
|
return curve
|
|
|
|
|
|
def max_drawdown(curve: list[float]) -> float:
|
|
peak = curve[0]
|
|
max_dd = 0.0
|
|
for v in curve:
|
|
if v > peak:
|
|
peak = v
|
|
dd = (v - peak) / peak
|
|
if dd < max_dd:
|
|
max_dd = dd
|
|
return max_dd
|
|
|
|
|
|
def main() -> None:
|
|
print("=== Fixed 2%/2% Exit ORB Diagnostic (vs V23) ===\n")
|
|
|
|
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"]}
|
|
v23_total = sum(v23_pnl.values())
|
|
|
|
universe = load_universe()
|
|
print(f"Universe: {len(universe)} tickers")
|
|
print("Loading daily caches...", flush=True)
|
|
|
|
daily_data: dict[str, pd.DataFrame] = {}
|
|
for ticker in universe:
|
|
df = load_daily_cache(ticker)
|
|
if df is not None and len(df) >= 2:
|
|
daily_data[ticker] = df
|
|
|
|
print(f"Daily cache loaded: {len(daily_data)} tickers")
|
|
print(f"V23 window: {v23_dates[0]} → {v23_dates[-1]} ({len(v23_dates)} days)\n")
|
|
print("Running simulation...", flush=True)
|
|
|
|
all_results = []
|
|
for i, date in enumerate(v23_dates):
|
|
result = simulate_day(date, universe, daily_data)
|
|
all_results.append(result)
|
|
if (i + 1) % 20 == 0:
|
|
print(f" {i+1}/{len(v23_dates)} days processed...", flush=True)
|
|
|
|
all_trades = [t for r in all_results for t in r["trades"]]
|
|
total_trades = len(all_trades)
|
|
trade_days = sum(1 for r in all_results if r["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 = float(np.mean([t["pnl"] for t in all_trades if t["pnl"] > 0])) if wins > 0 else 0.0
|
|
avg_loss = float(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)
|
|
pct_return = total_pnl / 10000.0 * 100
|
|
|
|
exit_reasons = {}
|
|
for t in all_trades:
|
|
exit_reasons[t["exit_reason"]] = exit_reasons.get(t["exit_reason"], 0) + 1
|
|
|
|
daily_pnl_list = [r["day_pnl"] for r in all_results]
|
|
curve = equity_curve(daily_pnl_list)
|
|
dd = max_drawdown(curve)
|
|
|
|
# Correlation with V23
|
|
fp_series = [r["day_pnl"] for r in all_results]
|
|
v23_series = [v23_pnl.get(d, 0.0) for d in v23_dates]
|
|
corr = float(np.corrcoef(fp_series, v23_series)[0, 1]) if len(v23_dates) > 1 else 0.0
|
|
|
|
# Sharpe (annualized, 252 trading days)
|
|
daily_arr = np.array(daily_pnl_list)
|
|
sharpe = float(np.mean(daily_arr) / np.std(daily_arr) * math.sqrt(252)) if np.std(daily_arr) > 0 else 0.0
|
|
|
|
# V23 stats
|
|
v23_curve = equity_curve(v23_series)
|
|
v23_dd = max_drawdown(v23_curve)
|
|
v23_trades = sum(d.get("trades", 0) for d in v23_data.get("daily_summary", [])
|
|
if isinstance(d, dict))
|
|
v23_pct = v23_total / 10000.0 * 100
|
|
|
|
print("\n" + "=" * 60)
|
|
print(" FIXED 2%/2% EXIT STRATEGY")
|
|
print(f" Trade days: {trade_days} / {len(v23_dates)}")
|
|
print(f" Total trades: {total_trades}")
|
|
print(f" Win rate: {win_rate*100:.1f}%")
|
|
print(f" Avg win: ${avg_win:.2f}")
|
|
print(f" Avg loss: ${avg_loss:.2f}")
|
|
if avg_loss > 0:
|
|
print(f" Win/Loss ratio: {avg_win/avg_loss:.2f}")
|
|
print(f" Total PnL: ${total_pnl:+,.2f} ({pct_return:+.1f}%)")
|
|
print(f" Max Drawdown: {dd*100:.2f}%")
|
|
print(f" Sharpe (ann): {sharpe:.2f}")
|
|
print(f" Corr vs V23: {corr:.3f}")
|
|
print(f" Exit breakdown: {exit_reasons}")
|
|
print("=" * 60)
|
|
|
|
print("\n" + "=" * 60)
|
|
print(" V23 BASELINE (200d, same window)")
|
|
print(f" Total PnL: ${v23_total:+,.2f} ({v23_pct:+.1f}%)")
|
|
print(f" Max Drawdown: {v23_dd*100:.2f}%")
|
|
v23_total_trades_cnt = sum(d.get("trades", 0) for d in v23_data["daily_summary"])
|
|
v23_wr = v23_data.get("metrics", {}).get("win_rate", None)
|
|
if v23_wr is not None:
|
|
print(f" Win rate: {v23_wr*100:.1f}%")
|
|
print(f" Total trades: {v23_total_trades_cnt}")
|
|
print("=" * 60)
|
|
|
|
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,
|
|
"pct_return": pct_return,
|
|
"max_drawdown": dd,
|
|
"sharpe": sharpe,
|
|
"corr_v23": corr,
|
|
"exit_reasons": exit_reasons,
|
|
"trades": all_trades,
|
|
"daily_pnl": [{"date": r["date"], "pnl": r["day_pnl"]} for r in all_results],
|
|
}
|
|
out_path = "runs/intraday_orb/diag_fixed_pct_exit.json"
|
|
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
with open(out_path, "w") as f:
|
|
json.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()
|