diagnose_entries.py buckets backtested trades by feature: cheap stocks ($1-5 +0.5R) crush $10-20 (-0.09R), rvol>=5 (+0.79R) beats <5x (+0.15R), and blow-off gaps >150% fade. sweep_filters.py verifies price/volume/gap-cap combos train-vs-test: px<=7 + gap<=60% ~doubles OOS expectancy (+0.76 vs +0.39R, win 86% vs 71%). Add a gap cap to the armed-trigger eligibility (TRIGGER_GAP_MAX) so blow-off gappers can be excluded; price cap (WATCHER_PRICE_MAX) and volume floor (TRIGGER_RVOL_MIN) already exist. Final values pending OOS verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
4.9 KiB
Python
126 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Sweep ENTRY filters (price cap / volume floor / gap cap) train-vs-test.
|
|
|
|
The diagnostic showed clear edges — cheap stocks, high relative volume, and
|
|
non-blowoff gaps win; $10-20 names lose. This verifies which filter combo lifts
|
|
the out-of-sample expectancy without overfitting. Entries+exit are the verified
|
|
live config; only the universe selection (which symbol-days qualify) varies.
|
|
|
|
Fast: entries are extracted ONCE (with their price/gap/rvol tagged), then each
|
|
filter combo just selects from that list — no re-running the engine per combo.
|
|
|
|
PYTHONPATH=. python sweep_filters.py # ranked table (train+test)
|
|
PYTHONPATH=. python sweep_filters.py --json # for the verify workflow
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import itertools
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
sys.path.insert(0, ".")
|
|
load_dotenv()
|
|
os.environ["STRATEGY_SETUPS"] = "opening_range_break"
|
|
|
|
from config import Config
|
|
from learn_params import load_sessions
|
|
from research.multi_schema import open_research_db
|
|
from strategy.backtest.engine import BacktestEngine
|
|
from strategy.exits import ExitConfig, TRAIL_PRIOR_LOW, parse_profit_tiers
|
|
from strategy.models import SetupCriteria
|
|
from sweep_exits import ENTRY, run_variant
|
|
|
|
CFG = ExitConfig(target_r=10.0, trail_mode=TRAIL_PRIOR_LOW, trail_after_r=1.0,
|
|
profit_lock_tiers=parse_profit_tiers("8:3,15:9,25:18,40:30"))
|
|
|
|
GRID = {
|
|
"price_max": [5.0, 7.0, 10.0, 20.0],
|
|
"rvol_min": [2.0, 4.0, 6.0],
|
|
"gap_max": [60.0, 120.0, 5000.0], # 5000 = off
|
|
}
|
|
BASE = {"price_max": 20.0, "rvol_min": 2.0, "gap_max": 5000.0}
|
|
|
|
|
|
def extract_with_features(sessions):
|
|
"""[{entry, stop, qty, ba, price, gap, rvol}] — engine run ONCE per session."""
|
|
ref = BacktestEngine(
|
|
Config(), target_r=2.0, warmup_bars=ENTRY["warmup"], eval_every=ENTRY["eval_every"],
|
|
ready_score_pct=ENTRY["ready"], min_bars=ENTRY["min_bars"],
|
|
exit_config=ExitConfig(target_r=2.0),
|
|
criteria=SetupCriteria(gap_pct_min=ENTRY["gap_min"], relative_volume_min=ENTRY["rvol_min"]))
|
|
out = []
|
|
for sym, _sess, bars, pc, adv in sessions:
|
|
br = bars.reset_index(drop=True)
|
|
if not len(br):
|
|
continue
|
|
gap = (float(br["open"].iloc[0]) / pc - 1) * 100 if pc else 0.0
|
|
rvol = (float(br["volume"].sum()) / adv) if adv else 0.0
|
|
for t in ref.run(bars, sym, previous_close=pc, avg_daily_volume=adv).trades:
|
|
if t.entry_index is None:
|
|
continue
|
|
out.append({"entry": t.entry_price, "stop": t.stop_price, "qty": t.quantity,
|
|
"ba": br.iloc[t.entry_index + 1:], "gap": gap, "rvol": rvol})
|
|
return out
|
|
|
|
|
|
def run_combo(feats, p):
|
|
sel = [(f["entry"], f["stop"], f["qty"], f["ba"]) for f in feats
|
|
if 1.0 <= f["entry"] <= p["price_max"] and 3.0 <= f["gap"] <= p["gap_max"]
|
|
and f["rvol"] >= p["rvol_min"]]
|
|
if not sel:
|
|
return {"trades": 0, "win": 0.0, "avgR": 0.0, "pnl": 0.0}
|
|
return run_variant(sel, CFG)
|
|
|
|
|
|
def split_by_date(sessions, which):
|
|
days = sorted({s[1] for s in sessions})
|
|
cut = days[int(len(days) * 0.7)] if len(days) >= 4 else (days[-1] if days else None)
|
|
if which == "train":
|
|
return [s for s in sessions if cut and s[1] < cut] or sessions
|
|
if which == "test":
|
|
return [s for s in sessions if cut and s[1] >= cut]
|
|
return sessions
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--json", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
con = open_research_db("market")
|
|
sessions = load_sessions(con)
|
|
feat_train = extract_with_features(split_by_date(sessions, "train"))
|
|
feat_test = extract_with_features(split_by_date(sessions, "test"))
|
|
|
|
rows = []
|
|
for c in itertools.product(*GRID.values()):
|
|
p = dict(zip(GRID, c))
|
|
tr, te = run_combo(feat_train, p), run_combo(feat_test, p)
|
|
rows.append({
|
|
"filter": f"px<={p['price_max']:g} rvol>={p['rvol_min']:g} gap<={p['gap_max']:g}",
|
|
"params": p,
|
|
"train_avgR": tr["avgR"], "train_trades": tr["trades"],
|
|
"test_avgR": te["avgR"], "test_trades": te["trades"], "test_win": te["win"],
|
|
})
|
|
rows.sort(key=lambda r: r["test_avgR"], reverse=True)
|
|
|
|
if args.json:
|
|
print(json.dumps({"results": rows}))
|
|
return
|
|
base = run_combo(feat_test, BASE)
|
|
base_tr = run_combo(feat_train, BASE)
|
|
print(f"BASELINE (px<=20 rvol>=2 gap-off): train {base_tr['avgR']:+.3f} ({base_tr['trades']}t) "
|
|
f"TEST {base['avgR']:+.3f} ({base['trades']}t) win {base['win'] * 100:.0f}%\n")
|
|
print(f"{'filter':>32} | {'train avgR':>10} {'n':>4} | {'TEST avgR':>10} {'n':>4} {'win%':>5}")
|
|
for r in rows[:12]:
|
|
print(f"{r['filter']:>32} | {r['train_avgR']:>+10.3f} {r['train_trades']:>4} | "
|
|
f"{r['test_avgR']:>+10.3f} {r['test_trades']:>4} {r['test_win'] * 100:>4.0f}%")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|