Files

330 lines
14 KiB
Python
Raw Permalink Normal View History

#!/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()