2026-05-17 20:09:46 +02:00
|
|
|
|
#!/usr/bin/env python3
|
2026-05-22 15:10:36 +02:00
|
|
|
|
"""Grid-Search Strategy Generator — no LLM, deterministic, RiskMgmt-verified.
|
2026-05-17 20:09:46 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-22 15:10:36 +02:00
|
|
|
|
RiskMgmt backtest execution.
|
2026-05-17 20:09:46 +02:00
|
|
|
|
|
|
|
|
|
|
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 ───────────────────────────────────────────────────────────────────
|
2026-05-22 15:10:36 +02:00
|
|
|
|
MIN_MONTHLY_RETURN_PCT = 1.0 # Raw backtest target (RiskMgmt will reduce ~50%)
|
2026-05-17 20:09:46 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-22 15:10:36 +02:00
|
|
|
|
# Fast backtest (no RiskMgmt mask, no walk-forward — <1s per eval)
|
2026-05-17 20:09:46 +02:00
|
|
|
|
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()
|