#!/usr/bin/env python3 """Grid-Search Strategy Generator — no LLM, deterministic, RiskMgmt-verified. Core idea: Instead of LLM-generated code, use a fixed signal template and grid-search the parameters. Factors are aligned to daily resolution (where they have actual predictive power), signal is forward-filled to 1-min for RiskMgmt backtest execution. Template: z-score → IC-weighted composite → asymmetric thresholds → signal """ import json import os import time from datetime import datetime from pathlib import Path import numpy as np import pandas as pd # ── Paths ──────────────────────────────────────────────────────────────────── PROJECT = Path(__file__).resolve().parent.parent FACTORS_DIR = PROJECT / "results" / "factors" VALUES_DIR = FACTORS_DIR / "values" RESULTS_DIR = PROJECT / "results" / "strategies_new" OHLCV_PATH = Path( os.getenv("PREDIX_OHLCV_PATH", str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")) ) # ── Target ─────────────────────────────────────────────────────────────────── MIN_MONTHLY_RETURN_PCT = 1.0 # Raw backtest target (RiskMgmt will reduce ~50%) MIN_SHARPE = 0.5 MAX_DRAWDOWN = -0.30 MIN_WIN_RATE = 0.35 MIN_TRADES = 20 # ── Grid ───────────────────────────────────────────────────────────────────── PARAM_GRID = { "window": [5, 10, 20, 30], "entry_thresh": [0.5, 0.8, 1.0, 1.5, 2.0], # Higher = fewer, higher-conviction trades "exit_thresh": [0.2, 0.5], } # Total: 5 × 4 × 3 = 60 combinations per factor pair # ═══════════════════════════════════════════════════════════════════════════════ # Factor loading # ═══════════════════════════════════════════════════════════════════════════════ def load_top_factors(min_ic: float = 0.04, top_n: int = 50) -> list[dict]: """Load factor metadata sorted by |IC| descending.""" factors = [] for f in sorted(FACTORS_DIR.glob("*.json")): data = json.loads(f.read_text()) if not isinstance(data, dict): continue fname = data.get("factor_name") or data.get("name") or f.stem ic = data.get("ic") or data.get("real_ic") or 0.0 try: ic = float(ic) except (TypeError, ValueError): continue if abs(ic) < min_ic: continue safe = fname.replace("/", "_").replace("\\", "_").replace(" ", "_")[:150] parq = VALUES_DIR / f"{safe}.parquet" if not parq.exists(): continue factors.append({"name": fname, "ic": ic, "parquet": parq}) factors.sort(key=lambda x: abs(x["ic"]), reverse=True) return factors[:top_n] def load_factor_series(factor: dict) -> pd.Series | None: """Load factor time series, extracting the EURUSD slice.""" try: df = pd.read_parquet(str(factor["parquet"])) if df.empty: return None col = df.columns[0] if isinstance(df.index, pd.MultiIndex): return df.xs("EURUSD", level="instrument")[col] return df[col] except Exception: return None # ═══════════════════════════════════════════════════════════════════════════════ # Signal generation # ═══════════════════════════════════════════════════════════════════════════════ def build_signal( daily_factors: pd.DataFrame, ic_values: dict[str, float], window: int = 10, entry_thresh: float = 0.5, exit_thresh: float = 0.2, ) -> pd.Series: """ Fixed signal template: z-score → IC-weighted composite → thresholds. Parameters ---------- daily_factors : DataFrame Factor values at daily resolution, columns = factor names. ic_values : dict Factor name → IC value (used for sign/direction, not weight). window : int Rolling window for z-score in days. entry_thresh : float Composite z-score threshold for entry. exit_thresh : float Composite z-score threshold for exit (flatten position). """ eps = 1e-8 z = (daily_factors - daily_factors.rolling(window).mean()) / ( daily_factors.rolling(window).std() + eps ) # IC-weighted composite: invert negative-IC factors, weight by |IC| composite = pd.Series(0.0, index=daily_factors.index) total_abs_ic = sum(abs(ic) for ic in ic_values.values()) if total_abs_ic == 0: total_abs_ic = 1.0 for col in daily_factors.columns: ic = ic_values.get(col, 0.0) w = abs(ic) / total_abs_ic sign = 1.0 if ic >= 0 else -1.0 composite += sign * w * z[col] # Asymmetric thresholds signal = pd.Series(0, index=daily_factors.index) signal[composite > entry_thresh] = 1 signal[composite < -entry_thresh] = -1 signal[abs(composite) < exit_thresh] = 0 signal = signal.rolling(2, min_periods=1).mean().round().astype(int) signal = signal.clip(-1, 1) signal.name = "signal" return signal # ═══════════════════════════════════════════════════════════════════════════════ # Evaluation # ═══════════════════════════════════════════════════════════════════════════════ def evaluate_one(args: tuple) -> dict | None: """Evaluate one parameter combination on one factor pair.""" ( f1_name, f1_ic, f1_series, f2_name, f2_ic, f2_series, close_1min, window, entry, exit_th, ) = args try: # Align factors to 1-min close factors_1min = pd.DataFrame({ f1_name: f1_series.reindex(close_1min.index).ffill(limit=2880), f2_name: f2_series.reindex(close_1min.index).ffill(limit=2880), }) # Resample to daily daily_factors = factors_1min.resample("D").last().dropna() if len(daily_factors) < 50: return None # Not enough daily data daily_close = close_1min.resample("D").last().reindex(daily_factors.index) # Build signal ic_values = {f1_name: f1_ic, f2_name: f2_ic} daily_signal = build_signal(daily_factors, ic_values, window, entry, exit_th) # Forward-fill to 1-min for backtest signal_1min = daily_signal.reindex(close_1min.index).ffill().fillna(0).astype(int).clip(-1, 1) # Fast backtest (no RiskMgmt mask, no walk-forward — <1s per eval) from rdagent.components.backtesting.vbt_backtest import backtest_signal bt = backtest_signal( close=close_1min, signal=signal_1min, ) if bt.get("status") != "success": return None sharpe = bt.get("sharpe", 0) or 0 max_dd = bt.get("max_drawdown", 0) or 0 win_rate = bt.get("win_rate", 0) or 0 n_trades = bt.get("n_trades", 0) or 0 monthly_pct = bt.get("monthly_return_pct", 0) or 0 return { "f1": f1_name, "f2": f2_name, "window": window, "entry": entry, "exit": exit_th, "sharpe": round(sharpe, 4), "max_dd": round(max_dd, 4), "win_rate": round(win_rate, 4), "n_trades": n_trades, "monthly_pct": round(monthly_pct, 2), } except Exception: return None def main(): print("═" * 60) print(" Grid-Search Strategy Generator (no LLM)") print("═" * 60) # ── Load OHLCV ──────────────────────────────────────────────────────── print(f"\nLoading OHLCV: {OHLCV_PATH}") df = pd.read_hdf(OHLCV_PATH, key="data") close_1min = df.xs("EURUSD", level="instrument")["$close"].sort_index() print(f" 1-min bars: {len(close_1min):,} ({close_1min.index[0].date()} → {close_1min.index[-1].date()})") # ── Load factors ─────────────────────────────────────────────────────── print(f"\nLoading factors (|IC| ≥ 0.04)...") top_n = int(os.getenv("GS_TOP_N", "10")) factors = load_top_factors(min_ic=0.04, top_n=top_n) print(f" Loaded {len(factors)} factors") factor_series = {} for f in factors: s = load_factor_series(f) if s is not None and len(s) > 100: factor_series[f["name"]] = (f["ic"], s) names = list(factor_series.keys()) print(f" Valid series: {len(names)}") # ── Generate factor pairs ────────────────────────────────────────────── import itertools pairs = list(itertools.combinations(names, 2)) print(f" Factor pairs: {len(pairs)}") # ── Generate parameter combinations ──────────────────────────────────── param_combos = list(itertools.product( PARAM_GRID["window"], PARAM_GRID["entry_thresh"], PARAM_GRID["exit_thresh"], )) # Filter: exit < entry param_combos = [(w, e, x) for w, e, x in param_combos if x < e] print(f" Parameter combos: {len(param_combos)}") # ── Build work items ─────────────────────────────────────────────────── work_items = [] for f1_name, f2_name in pairs: f1_ic, f1_series = factor_series[f1_name] f2_ic, f2_series = factor_series[f2_name] for window, entry, exit_th in param_combos: work_items.append(( f1_name, f1_ic, f1_series, f2_name, f2_ic, f2_series, close_1min, window, entry, exit_th, )) total = len(work_items) print(f" Total evaluations: {total:,}") # ── Run sequentially ─────────────────────────────────────────────────── t0 = time.time() results = [] for i, item in enumerate(work_items): r = evaluate_one(item) if r is not None: results.append(r) if (i + 1) % 100 == 0 or i == total - 1: elapsed = time.time() - t0 rate = (i + 1) / elapsed if elapsed > 0 else 0 eta = (total - i - 1) / rate if rate > 0 else 0 print(f" {i+1}/{total} ({(i+1)/total*100:.1f}%) " f"{len(results)} valid {rate:.1f}/s eta {eta:.0f}s") # ── Filter and sort ──────────────────────────────────────────────────── print(f"\n{'═' * 60}") print(f" Total evaluated: {total:,} Valid results: {len(results):,}") print(f"{'═' * 60}") valid = [r for r in results if r["sharpe"] >= MIN_SHARPE and r["max_dd"] >= MAX_DRAWDOWN and r["win_rate"] >= MIN_WIN_RATE and r["n_trades"] >= MIN_TRADES and r["monthly_pct"] >= MIN_MONTHLY_RETURN_PCT] valid.sort(key=lambda r: r["monthly_pct"], reverse=True) print(f"\n Meeting criteria (Sharpe≥{MIN_SHARPE}, DD≥{MAX_DRAWDOWN}, " f"WR≥{MIN_WIN_RATE}, Trades≥{MIN_TRADES}, Mon≥{MIN_MONTHLY_RETURN_PCT}%):") print(f" → {len(valid)} strategies") print() if valid: print(f"{'#':<3s} {'Factor 1':>30s} + {'Factor 2':>30s} {'w':>3s} {'ent':>4s} {'ex':>4s} {'Sharpe':>7s} {'MaxDD':>7s} {'WinRt':>6s} {'Tr':>4s} {'Mon%':>7s}") print("-" * 135) for i, r in enumerate(valid[:30], 1): print(f"{i:<3d} {r['f1'][:30]:>30s} + {r['f2'][:30]:>30s} " f"{r['window']:>3d} {r['entry']:>4.1f} {r['exit']:>4.1f} " f"{r['sharpe']:>7.3f} {r['max_dd']:>7.3f} {r['win_rate']:>6.1%} " f"{r['n_trades']:>4d} {r['monthly_pct']:>7.2f}%") else: print(" No strategies meet the criteria.") if results: results.sort(key=lambda r: r["monthly_pct"], reverse=True) print("\n Top 10 by monthly return:") for i, r in enumerate(results[:10], 1): print(f" {i:2d}. {r['f1'][:25]} + {r['f2'][:25]} " f"Mon={r['monthly_pct']:.2f}% Sh={r['sharpe']:.3f} " f"DD={r['max_dd']:.3f} Tr={r['n_trades']}") # ── Save top results ─────────────────────────────────────────────────── RESULTS_DIR.mkdir(parents=True, exist_ok=True) out_path = RESULTS_DIR / f"gridsearch_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" out_path.write_text(json.dumps(valid[:50] if valid else results[:50], indent=2, default=str)) print(f"\n Top results saved → {out_path}") print(f" Runtime: {time.time() - t0:.0f}s") if __name__ == "__main__": main()