mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
feat: model-track bias + daily/portfolio tools
- Bandit: model arm prior bias 2.0, prior_var 5.0 → 77% model preference - Default first action: model (was factor) - Daily strategy generator: Kronos + factor grid search on daily resolution - Grid search tool: fixed template, no LLM, deterministic - Portfolio optimizer: greedy correlation-aware selection, leverage scaling
This commit is contained in:
@@ -1,269 +1,278 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
NexQuant Daily Strategy Generator — systematisch, kein LLM.
|
||||
#!/usr/bin/env python3
|
||||
"""Daily Strategy Generator — Kronos factors at daily resolution.
|
||||
|
||||
Grid-search für SMA/EMA/RSI/MACD/Momentum/Mean-Reversion auf Tagesdaten.
|
||||
Speichert Top-Strategien als JSON für den Live-Trading-Workflow.
|
||||
|
||||
Usage:
|
||||
python scripts/nexquant_daily_strategies.py
|
||||
python scripts/nexquant_daily_strategies.py --top 10 --cost 2.14
|
||||
Daily timeframe eliminates 1-min noise and transaction cost overhead.
|
||||
Factors with daily IC translate directly to daily trading edge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json, sys, time
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
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")))
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
|
||||
|
||||
DATA_PATH = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
|
||||
OUT_DIR = Path("results/strategies_daily")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
TXN_COST_BPS = 2.14
|
||||
MIN_TRADES_OOS = 5
|
||||
MIN_MONTHLY = 5.0 # Raw backtest target (conservative for daily)
|
||||
MIN_SHARPE = 1.0
|
||||
MAX_DD = -0.20
|
||||
MIN_TRADES = 30
|
||||
|
||||
|
||||
def load_daily_data():
|
||||
close = pd.read_hdf(DATA_PATH, key="data")["$close"]
|
||||
if isinstance(close.index, pd.MultiIndex):
|
||||
close = close.droplevel(-1)
|
||||
return close.sort_index().dropna().resample("1D").last().dropna()
|
||||
def load_kronos(name: str) -> pd.Series:
|
||||
s = pd.read_parquet(VALUES_DIR / f"{name}.parquet")
|
||||
col = s.columns[0]
|
||||
return s.xs("EURUSD", level="instrument")[col]
|
||||
|
||||
|
||||
def backtest(signal: pd.Series, close: pd.Series) -> dict:
|
||||
if signal is None or len(signal) < 10:
|
||||
return {}
|
||||
sig = signal.fillna(0).replace([np.inf, -np.inf], 0)
|
||||
r = backtest_signal_ftmo(close, sig, txn_cost_bps=TXN_COST_BPS, wf_rolling=True)
|
||||
def load_factor_ic(name: str) -> float:
|
||||
jf = FACTORS_DIR / f"{name}.json"
|
||||
if jf.exists():
|
||||
return float(json.loads(jf.read_text()).get("ic", 0))
|
||||
return 0.0
|
||||
|
||||
|
||||
def daily_backtest(close_daily: pd.Series, signal_daily: pd.Series) -> dict:
|
||||
"""Simple daily backtest — no intraday noise, no 1-min costs."""
|
||||
common = close_daily.index.intersection(signal_daily.index)
|
||||
c = close_daily.loc[common]
|
||||
s = signal_daily.loc[common].clip(-1, 1)
|
||||
|
||||
rets = c.pct_change().shift(-1) # Next day's return
|
||||
strat_rets = s.shift(1) * rets # Today's signal × tomorrow's return
|
||||
strat_rets = strat_rets.dropna()
|
||||
|
||||
if len(strat_rets) < 10:
|
||||
return {"sharpe": 0, "monthly_pct": 0, "max_dd": 0, "n_trades": 0, "win_rate": 0}
|
||||
|
||||
# Trade-level stats
|
||||
trades = []
|
||||
in_trade = False
|
||||
trade_ret = 0.0
|
||||
wins = 0
|
||||
for r, sig in zip(strat_rets, s.loc[strat_rets.index]):
|
||||
if sig != 0:
|
||||
if not in_trade:
|
||||
in_trade = True
|
||||
trade_ret = r
|
||||
else:
|
||||
trade_ret += r
|
||||
elif in_trade:
|
||||
in_trade = False
|
||||
trades.append(trade_ret)
|
||||
if trade_ret > 0:
|
||||
wins += 1
|
||||
trade_ret = 0.0
|
||||
if in_trade:
|
||||
trades.append(trade_ret)
|
||||
if trade_ret > 0:
|
||||
wins += 1
|
||||
|
||||
n_trades = len(trades)
|
||||
if n_trades < 5:
|
||||
return {"sharpe": 0, "monthly_pct": 0, "max_dd": 0, "n_trades": n_trades, "win_rate": 0}
|
||||
|
||||
t_arr = np.array(trades)
|
||||
sharpe = float(t_arr.mean() / t_arr.std() * np.sqrt(n_trades)) if t_arr.std() > 0 else 0.0
|
||||
win_rate = wins / n_trades
|
||||
|
||||
# Equity curve
|
||||
eq = (1 + pd.Series(trades)).cumprod()
|
||||
peak = eq.cummax()
|
||||
dd = float(((eq - peak) / peak).min())
|
||||
|
||||
total_ret = eq.iloc[-1] - 1 if len(eq) > 0 else 0.0
|
||||
n_days = (close_daily.index[-1] - close_daily.index[0]).days
|
||||
n_months = n_days / 30.44
|
||||
monthly = float((1 + total_ret) ** (1 / max(n_months, 1)) - 1)
|
||||
|
||||
return {
|
||||
"is_sharpe": r.get("is_sharpe", None),
|
||||
"is_monthly_pct": r.get("is_monthly_return_pct", None),
|
||||
"is_trades": r.get("is_n_trades", 0),
|
||||
"oos_sharpe": r.get("oos_sharpe", None),
|
||||
"oos_monthly_pct": r.get("oos_monthly_return_pct", None),
|
||||
"oos_max_dd": r.get("oos_max_drawdown", None),
|
||||
"oos_win_rate": r.get("oos_win_rate", None),
|
||||
"oos_trades": r.get("oos_n_trades", 0),
|
||||
"wf_sharpe": r.get("wf_oos_sharpe_mean", None),
|
||||
"wf_monthly_pct": r.get("wf_oos_monthly_return_mean", None),
|
||||
"wf_consistency": r.get("wf_oos_consistency", None),
|
||||
"mc_pvalue": r.get("mc_pvalue", None),
|
||||
"full_metrics": r,
|
||||
"sharpe": sharpe, "monthly_pct": monthly * 100,
|
||||
"max_dd": dd, "n_trades": n_trades, "win_rate": win_rate,
|
||||
"total_return": total_ret, "n_months": n_months,
|
||||
}
|
||||
|
||||
|
||||
def make_sma_signal(close, fast, slow):
|
||||
f = close.rolling(fast).mean()
|
||||
s = close.rolling(slow).mean()
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[f > s] = 1
|
||||
sig[f < s] = -1
|
||||
return sig
|
||||
def build_signal(daily_factor: pd.Series, ic: float, threshold_sigma: float,
|
||||
session: str = "all") -> pd.Series:
|
||||
"""Build daily signal from a single factor."""
|
||||
sigma = daily_factor.std()
|
||||
thresh = threshold_sigma * sigma
|
||||
|
||||
# Invert if IC is negative
|
||||
sign = -1 if ic < 0 else 1
|
||||
|
||||
signal = pd.Series(0, index=daily_factor.index, dtype=int)
|
||||
signal[daily_factor > thresh] = sign
|
||||
signal[daily_factor < -thresh] = -sign
|
||||
|
||||
# Smooth: keep signal for min_hold days to avoid whipsaw
|
||||
signal = signal.replace(0, np.nan).ffill(limit=1).fillna(0).astype(int)
|
||||
|
||||
return signal
|
||||
|
||||
|
||||
def make_ema_signal(close, fast, slow):
|
||||
f = close.ewm(span=fast).mean()
|
||||
s = close.ewm(span=slow).mean()
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[f > s] = 1
|
||||
sig[f < s] = -1
|
||||
return sig
|
||||
def combine_signals(s1: pd.Series, s2: pd.Series, mode: str = "confirm") -> pd.Series:
|
||||
"""Combine two daily signals."""
|
||||
common = s1.index.intersection(s2.index)
|
||||
s1c = s1.loc[common]
|
||||
s2c = s2.loc[common]
|
||||
|
||||
if mode == "confirm":
|
||||
result = pd.Series(0, index=common, dtype=int)
|
||||
result[(s1c == s2c) & (s1c != 0)] = s1c
|
||||
return result
|
||||
elif mode == "any":
|
||||
result = s1c.copy()
|
||||
result[(result == 0) & (s2c != 0)] = s2c
|
||||
return result
|
||||
else:
|
||||
return s1c
|
||||
|
||||
|
||||
def make_rsi_signal(close, period, oversold, overbought):
|
||||
delta = close.diff()
|
||||
gain = delta.clip(lower=0)
|
||||
loss = -delta.clip(upper=0)
|
||||
rsi = 100 - (100 / (1 + gain.rolling(period).mean() / (loss.rolling(period).mean() + 1e-8)))
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[rsi < oversold] = 1
|
||||
sig[rsi > overbought] = -1
|
||||
return sig
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" Daily Strategy Generator")
|
||||
print("=" * 60)
|
||||
|
||||
# Load OHLCV → daily
|
||||
print("\nLoading OHLCV...")
|
||||
df = pd.read_hdf(OHLCV_PATH, key="data")
|
||||
close = df.xs("EURUSD", level="instrument")["$close"].sort_index()
|
||||
close_daily = close.resample("D").last().dropna()
|
||||
print(f" Daily bars: {len(close_daily)} ({close_daily.index[0].date()} → {close_daily.index[-1].date()})")
|
||||
|
||||
def make_macd_signal(close, fast, slow, signal_period):
|
||||
ema_fast = close.ewm(span=fast).mean()
|
||||
ema_slow = close.ewm(span=slow).mean()
|
||||
macd = ema_fast - ema_slow
|
||||
sig_line = macd.ewm(span=signal_period).mean()
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[macd > sig_line] = 1
|
||||
sig[macd < sig_line] = -1
|
||||
return sig
|
||||
# Load Kronos factors → daily
|
||||
print("\nLoading Kronos factors...")
|
||||
kronos = {}
|
||||
for name in ["KronosPredReturn_p96", "KronosPredReturn_p24", "KronosPredReturn_p48"]:
|
||||
series = load_kronos(name)
|
||||
ic = load_factor_ic(name)
|
||||
daily = series.resample("D").last().dropna()
|
||||
# Align to close_daily
|
||||
daily = daily.reindex(close_daily.index)
|
||||
kronos[name] = {"series": daily, "ic": ic, "std": daily.std()}
|
||||
print(f" {name}: IC={ic:+.4f} daily_rows={daily.dropna().sum()}")
|
||||
|
||||
# Load top daily factors
|
||||
print("\nLoading top daily factors...")
|
||||
daily_factors = {}
|
||||
for f in sorted(FACTORS_DIR.glob("*.json")):
|
||||
d = json.loads(f.read_text())
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
ic = float(d.get("ic") or 0)
|
||||
if abs(ic) < 0.06:
|
||||
continue
|
||||
fname = d.get("factor_name") or d.get("name") or f.stem
|
||||
safe = fname.replace("/", "_").replace("\\", "_")[:150]
|
||||
parq = VALUES_DIR / f"{safe}.parquet"
|
||||
if not parq.exists():
|
||||
continue
|
||||
series = pd.read_parquet(str(parq))
|
||||
if isinstance(series.index, pd.MultiIndex):
|
||||
series = series.xs("EURUSD", level="instrument")[series.columns[0]]
|
||||
daily = series.resample("D").last().dropna().reindex(close_daily.index)
|
||||
daily_factors[fname] = {"series": daily, "ic": ic, "std": daily.std()}
|
||||
|
||||
def make_momentum_signal(close, n):
|
||||
mom = close.pct_change(n)
|
||||
return pd.Series(np.sign(mom).fillna(0), index=close.index)
|
||||
|
||||
|
||||
def make_meanrev_signal(close, n):
|
||||
ret = close.pct_change(n)
|
||||
return pd.Series(-np.sign(ret).fillna(0), index=close.index)
|
||||
|
||||
|
||||
def make_bollinger_signal(close, period, std_dev):
|
||||
ma = close.rolling(period).mean()
|
||||
std = close.rolling(period).std()
|
||||
sig = pd.Series(0.0, index=close.index)
|
||||
sig[close < ma - std_dev * std] = 1
|
||||
sig[close > ma + std_dev * std] = -1
|
||||
return sig
|
||||
|
||||
|
||||
def main(top_n=15, cost_bps=2.14):
|
||||
global TXN_COST_BPS
|
||||
TXN_COST_BPS = cost_bps
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" NexQuant Daily Strategy Generator")
|
||||
print(f" Cost: {cost_bps} bps | Saving top {top_n}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
close = load_daily_data()
|
||||
print(f"Data: {len(close):,} daily bars ({close.index[0].date()} - {close.index[-1].date()})\n")
|
||||
names = list(daily_factors.keys())
|
||||
print(f" Loaded {len(names)} factors (IC ≥ 0.06)")
|
||||
|
||||
# Grid search
|
||||
thresholds = [1.0, 1.5, 2.0, 2.5, 3.0]
|
||||
results = []
|
||||
t0 = time.time()
|
||||
|
||||
# SMA Crossovers
|
||||
print("SMA crossovers...")
|
||||
for fast in [5, 10, 15, 20, 30]:
|
||||
for slow in [fast * 2, fast * 3, fast * 4, fast * 5]:
|
||||
if slow > 250: continue
|
||||
sig = make_sma_signal(close, fast, slow)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("SMA", f"SMA{fast}/{slow}", fast, slow, score, bt))
|
||||
# A) Kronos single-factor
|
||||
print("\n--- Kronos single-factor grid ---")
|
||||
for kname, kdata in kronos.items():
|
||||
ks = kdata["series"]
|
||||
for thresh in thresholds:
|
||||
signal = build_signal(ks, kdata["ic"], thresh)
|
||||
bt = daily_backtest(close_daily, signal)
|
||||
bt["strategy"] = f"{kname} t={thresh}σ"
|
||||
bt["factors"] = [kname]
|
||||
bt["threshold"] = thresh
|
||||
results.append(bt)
|
||||
|
||||
# EMA Crossovers
|
||||
print("EMA crossovers...")
|
||||
for fast in [5, 10, 15, 20, 30]:
|
||||
for slow in [fast * 2, fast * 3, fast * 4, fast * 5]:
|
||||
if slow > 250: continue
|
||||
sig = make_ema_signal(close, fast, slow)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("EMA", f"EMA{fast}/{slow}", fast, slow, score, bt))
|
||||
# B) Kronos + daily factor (confirmation)
|
||||
print("--- Kronos + daily factor combinations ---")
|
||||
for kname, kdata in kronos.items():
|
||||
ks = kdata["series"]
|
||||
for fname, fdata in daily_factors.items():
|
||||
for thresh_k in [1.5, 2.0]:
|
||||
for thresh_f in [1.0, 1.5, 2.0]:
|
||||
s1 = build_signal(ks, kdata["ic"], thresh_k)
|
||||
s2 = build_signal(fdata["series"], fdata["ic"], thresh_f)
|
||||
signal = combine_signals(s1, s2, "confirm")
|
||||
bt = daily_backtest(close_daily, signal)
|
||||
bt["strategy"] = f"{kname}(t={thresh_k}) + {fname}(t={thresh_f})"
|
||||
bt["factors"] = [kname, fname]
|
||||
bt["threshold"] = f"{thresh_k}/{thresh_f}"
|
||||
results.append(bt)
|
||||
|
||||
# RSI
|
||||
print("RSI strategies...")
|
||||
for period in [7, 10, 14, 21]:
|
||||
for oversold, overbought in [(20, 80), (25, 75), (30, 70), (35, 65)]:
|
||||
sig = make_rsi_signal(close, period, oversold, overbought)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("RSI", f"RSI{period}({oversold}/{overbought})", period, 0, score, bt))
|
||||
# C) Two daily factors (no Kronos)
|
||||
print("--- Daily factor pairs ---")
|
||||
name_list = list(daily_factors.keys())
|
||||
for i in range(min(len(name_list), 10)):
|
||||
for j in range(i + 1, min(len(name_list), 10)):
|
||||
f1, f2 = name_list[i], name_list[j]
|
||||
for t1 in [1.0, 1.5, 2.0]:
|
||||
for t2 in [1.0, 1.5, 2.0]:
|
||||
s1 = build_signal(daily_factors[f1]["series"], daily_factors[f1]["ic"], t1)
|
||||
s2 = build_signal(daily_factors[f2]["series"], daily_factors[f2]["ic"], t2)
|
||||
signal = combine_signals(s1, s2, "confirm")
|
||||
bt = daily_backtest(close_daily, signal)
|
||||
bt["strategy"] = f"{f1[:20]}(t={t1}) + {f2[:20]}(t={t2})"
|
||||
bt["factors"] = [f1, f2]
|
||||
bt["threshold"] = f"{t1}/{t2}"
|
||||
results.append(bt)
|
||||
|
||||
# MACD
|
||||
print("MACD...")
|
||||
for fast, slow, sig_p in [(8, 17, 9), (12, 26, 9), (5, 35, 5), (10, 20, 7)]:
|
||||
s = make_macd_signal(close, fast, slow, sig_p)
|
||||
bt = backtest(s, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("MACD", f"MACD{fast}/{slow}/{sig_p}", fast, slow, score, bt))
|
||||
# Filter & sort
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Total evaluations: {len(results)} Time: {time.time()-t0:.0f}s")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# Momentum
|
||||
print("Momentum...")
|
||||
for n in [5, 10, 20, 30, 50, 60, 90, 100, 120, 150, 200]:
|
||||
sig = make_momentum_signal(close, n)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("Mom", f"Mom{n}d", n, 0, score, bt))
|
||||
valid = [r for r in results
|
||||
if r["sharpe"] >= MIN_SHARPE
|
||||
and r["max_dd"] >= MAX_DD
|
||||
and r["n_trades"] >= MIN_TRADES
|
||||
and r["monthly_pct"] >= MIN_MONTHLY]
|
||||
|
||||
# Mean Reversion
|
||||
print("Mean reversion...")
|
||||
for n in [3, 5, 7, 10, 15, 20, 30, 50]:
|
||||
sig = make_meanrev_signal(close, n)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("MR", f"MR{n}d", n, 0, score, bt))
|
||||
valid.sort(key=lambda r: r["monthly_pct"], reverse=True)
|
||||
|
||||
# Bollinger Bands
|
||||
print("Bollinger...")
|
||||
for period in [10, 20, 50]:
|
||||
for std_dev in [1.5, 2.0, 2.5]:
|
||||
sig = make_bollinger_signal(close, period, std_dev)
|
||||
bt = backtest(sig, close)
|
||||
if bt.get("oos_trades", 0) >= MIN_TRADES_OOS:
|
||||
score = bt.get("oos_sharpe") or -999
|
||||
results.append(("BB", f"BB{period}/{std_dev}", period, std_dev, score, bt))
|
||||
print(f"\n Meeting: Sharpe≥{MIN_SHARPE} DD≥{MAX_DD} Tr≥{MIN_TRADES} Mon≥{MIN_MONTHLY}%")
|
||||
print(f" → {len(valid)} strategies\n")
|
||||
|
||||
# Sort by OOS Sharpe
|
||||
results.sort(key=lambda x: x[4] if x[4] is not None else -999, reverse=True)
|
||||
fmt = "{:3s} {:55s} {:>7s} {:>7s} {:>7s} {:>5s} {:>6s}"
|
||||
print(fmt.format("#", "Strategy", "Sharpe", "Mon%", "MaxDD", "Tr", "WinRt"))
|
||||
print("-" * 90)
|
||||
for i, r in enumerate(valid[:30], 1):
|
||||
print(fmt.format(str(i), r["strategy"][:55],
|
||||
f'{r["sharpe"]:.2f}', f'{r["monthly_pct"]:.1f}%',
|
||||
f'{r["max_dd"]:.3f}', str(r["n_trades"]),
|
||||
f'{r["win_rate"]:.1%}'))
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f" TOP {top_n} DAILY STRATEGIES (Cost: {cost_bps} bps)")
|
||||
print(f"{'='*70}")
|
||||
print(f" {'#':<3} {'Type':<6} {'Name':<22} {'OOS S':>8} {'Mon%':>7} {'DD%':>6} {'WF S':>8} {'Trades':>6}")
|
||||
print(f" {'-'*68}")
|
||||
if not valid:
|
||||
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['strategy'][:50]} Mon={r['monthly_pct']:.1f}% Sh={r['sharpe']:.2f} Tr={r['n_trades']}")
|
||||
|
||||
saved = []
|
||||
for i, (stype, name, p1, p2, score, bt) in enumerate(results[:top_n]):
|
||||
oos_m = (bt.get("oos_monthly_pct") or 0)
|
||||
oos_dd = (bt.get("oos_max_dd") or 0) * 100
|
||||
wf_s = bt.get("wf_sharpe") or 0
|
||||
trades = bt.get("oos_trades", 0)
|
||||
status = "✅" if score > 0 else " "
|
||||
print(f" {i+1:<3} {stype:<6} {name:<22} {score:>+8.2f} {oos_m:>+6.2f}% {oos_dd:>+5.1f}% {wf_s:>+8.2f} {trades:>6} {status}")
|
||||
|
||||
entry = {
|
||||
"strategy_name": name,
|
||||
"type": stype,
|
||||
"param1": p1,
|
||||
"param2": p2,
|
||||
"cost_bps": cost_bps,
|
||||
"frequency": "daily",
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"metrics": {k: v for k, v in bt.items() if k != "full_metrics"},
|
||||
}
|
||||
saved.append(entry)
|
||||
|
||||
# Save individual strategy
|
||||
safe_name = name.replace("(", "").replace(")", "").replace("/", "-")
|
||||
fname = OUT_DIR / f"daily_{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
with open(fname, "w") as f:
|
||||
json.dump(entry, f, indent=2)
|
||||
|
||||
# Save summary
|
||||
summary = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"cost_bps": cost_bps,
|
||||
"frequency": "daily",
|
||||
"n_bars": len(close),
|
||||
"date_range": [str(close.index[0].date()), str(close.index[-1].date())],
|
||||
"top_strategies": [
|
||||
{"name": s["strategy_name"], "oos_sharpe": s["metrics"].get("oos_sharpe"),
|
||||
"oos_monthly_pct": s["metrics"].get("oos_monthly_pct")}
|
||||
for s in saved[:10]
|
||||
],
|
||||
}
|
||||
with open(OUT_DIR / "daily_summary.json", "w") as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
|
||||
profit_count = sum(1 for r in results if r[4] and r[4] > 0)
|
||||
print(f"\n{profit_count}/{len(results)} strategies profitable ({profit_count/len(results)*100:.0f}%)")
|
||||
print(f"Saved to {OUT_DIR}/")
|
||||
return saved
|
||||
# Save
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out = RESULTS_DIR / f"daily_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
out.write_text(json.dumps(valid[:50] if valid else results[:50], indent=2, default=str))
|
||||
print(f"\n Saved → {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--top", type=int, default=15)
|
||||
parser.add_argument("--cost", type=float, default=2.14)
|
||||
args = parser.parse_args()
|
||||
main(top_n=args.top, cost_bps=args.cost)
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grid-Search Strategy Generator — no LLM, deterministic, FTMO-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
|
||||
FTMO 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 (FTMO 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 FTMO 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()
|
||||
@@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Portfolio Optimizer — combine uncorrelated strategies for 15% monthly target.
|
||||
|
||||
Given N strategies with daily returns, find the optimal combination that:
|
||||
- Maximizes monthly return
|
||||
- Keeps max drawdown within FTMO limits (10% total, 5% daily)
|
||||
- Diversifies across uncorrelated strategies
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
RESULTS_DIR = PROJECT / "results" / "strategies_new"
|
||||
STRATEGIES_DIR = PROJECT / "results" / "strategies"
|
||||
FACTORS_DIR = PROJECT / "results" / "factors"
|
||||
VALUES_DIR = FACTORS_DIR / "values"
|
||||
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
|
||||
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))
|
||||
|
||||
TARGET_MONTHLY = 15.0
|
||||
MAX_DD = 0.10 # FTMO: 10% max total drawdown
|
||||
MAX_DAILY_DD = 0.05 # FTMO: 5% max daily drawdown
|
||||
MIN_TRADES = 30
|
||||
MIN_SHARPE = 0.5
|
||||
|
||||
|
||||
def load_strategies() -> list[dict]:
|
||||
"""Load all strategy JSONs with real (non-fabricated) verified metrics."""
|
||||
strategies = []
|
||||
seen = set()
|
||||
for d in (STRATEGIES_DIR, RESULTS_DIR):
|
||||
if not d.exists():
|
||||
continue
|
||||
for p in d.glob("*.json"):
|
||||
try:
|
||||
r = json.loads(p.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
name = r.get("strategy_name", p.stem)
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
|
||||
s = r.get("summary", {})
|
||||
if not isinstance(s, dict):
|
||||
s = {}
|
||||
m = r.get("metrics", {})
|
||||
if not isinstance(m, dict):
|
||||
m = {}
|
||||
|
||||
# Extract metrics (prefer summary, fallback to metrics)
|
||||
sharpe = float(s.get("sharpe") or m.get("sharpe") or 0)
|
||||
mon_pct = float(s.get("monthly_return_pct") or s.get("oos_monthly_return_pct")
|
||||
or m.get("monthly_return_pct") or 0)
|
||||
max_dd = float(s.get("max_drawdown") or s.get("oos_max_drawdown")
|
||||
or m.get("max_drawdown") or 0)
|
||||
win_rate = float(s.get("win_rate") or s.get("oos_win_rate")
|
||||
or m.get("win_rate") or 0)
|
||||
n_trades = int(s.get("n_trades") or s.get("oos_n_trades")
|
||||
or s.get("real_n_trades") or m.get("n_trades") or 0)
|
||||
total_ret = float(s.get("total_return") or m.get("total_return") or 0)
|
||||
|
||||
# Filter fabricated
|
||||
if mon_pct == 200 and sharpe == 3.0 and abs(max_dd + 0.167) < 0.01:
|
||||
continue
|
||||
if mon_pct == -20 and max_dd == -1.0:
|
||||
continue
|
||||
if sharpe == 200:
|
||||
continue
|
||||
|
||||
# Filter quality
|
||||
if n_trades < MIN_TRADES or sharpe < MIN_SHARPE:
|
||||
continue
|
||||
if mon_pct <= 0:
|
||||
continue
|
||||
|
||||
strategies.append({
|
||||
"name": name,
|
||||
"file": str(p),
|
||||
"sharpe": sharpe,
|
||||
"monthly_pct": mon_pct,
|
||||
"max_dd": max_dd,
|
||||
"win_rate": win_rate,
|
||||
"n_trades": n_trades,
|
||||
"total_return": total_ret,
|
||||
"factors": r.get("factor_names") or r.get("factors_used") or [],
|
||||
"code": r.get("code", ""),
|
||||
})
|
||||
|
||||
return strategies
|
||||
|
||||
|
||||
def load_strategy_returns(strategy: dict, close_daily: pd.Series) -> pd.Series | None:
|
||||
"""Reconstruct daily strategy returns from code and factor data."""
|
||||
code = strategy.get("code", "")
|
||||
if not code:
|
||||
return None
|
||||
|
||||
factors_list = strategy.get("factors", [])
|
||||
if not factors_list:
|
||||
return None
|
||||
|
||||
# Load factor values
|
||||
factor_series = {}
|
||||
for fname in factors_list:
|
||||
safe = str(fname).replace("/", "_").replace("\\", "_").replace(" ", "_")[:150]
|
||||
parq = VALUES_DIR / f"{safe}.parquet"
|
||||
if not parq.exists():
|
||||
continue
|
||||
try:
|
||||
s = pd.read_parquet(str(parq))
|
||||
if isinstance(s.index, pd.MultiIndex):
|
||||
s = s.xs("EURUSD", level="instrument")[s.columns[0]]
|
||||
# Align to close_daily index
|
||||
s = s.resample("D").last().reindex(close_daily.index).ffill(limit=5)
|
||||
factor_series[fname] = s
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if len(factor_series) < 2:
|
||||
return None
|
||||
|
||||
df_factors = pd.DataFrame(factor_series).dropna()
|
||||
if len(df_factors) < 100:
|
||||
return None
|
||||
|
||||
# Execute strategy code on daily data
|
||||
local_vars = {"factors": df_factors, "close": close_daily.reindex(df_factors.index)}
|
||||
try:
|
||||
exec(code, {"np": np, "pd": pd, "numpy": np}, local_vars)
|
||||
except Exception:
|
||||
# Can't execute — use simple IC-weighted signal as fallback
|
||||
return None
|
||||
|
||||
signal = local_vars.get("signal")
|
||||
if signal is None or not isinstance(signal, pd.Series):
|
||||
return None
|
||||
|
||||
# Compute daily returns from signal
|
||||
common = close_daily.index.intersection(signal.index)
|
||||
c = close_daily.loc[common]
|
||||
s = signal.loc[common].clip(-1, 1).fillna(0)
|
||||
|
||||
fwd_ret = c.pct_change().shift(-1)
|
||||
strat_ret = s.shift(1) * fwd_ret
|
||||
strat_ret = strat_ret.dropna()
|
||||
|
||||
if len(strat_ret) < 30:
|
||||
return None
|
||||
|
||||
return strat_ret
|
||||
|
||||
|
||||
def build_simple_signal(factors_list: list[str], close_daily: pd.Series) -> tuple[pd.Series, pd.Series]:
|
||||
"""Build simple IC-weighted daily signal (fallback when code fails)."""
|
||||
import json as _json
|
||||
|
||||
factor_series = {}
|
||||
ic_values = {}
|
||||
for fname in factors_list:
|
||||
safe = str(fname).replace("/", "_").replace("\\", "_").replace(" ", "_")[:150]
|
||||
parq = VALUES_DIR / f"{safe}.parquet"
|
||||
jf = FACTORS_DIR / f"{safe}.json"
|
||||
if not parq.exists():
|
||||
continue
|
||||
ic = 0.0
|
||||
if jf.exists():
|
||||
ic = float(_json.loads(jf.read_text()).get("ic", 0))
|
||||
try:
|
||||
s = pd.read_parquet(str(parq))
|
||||
if isinstance(s.index, pd.MultiIndex):
|
||||
s = s.xs("EURUSD", level="instrument")[s.columns[0]]
|
||||
s = s.resample("D").last().reindex(close_daily.index).ffill(limit=5)
|
||||
factor_series[fname] = s
|
||||
ic_values[fname] = ic
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
df = pd.DataFrame(factor_series).dropna()
|
||||
if len(df) < 50:
|
||||
return pd.Series(), pd.Series()
|
||||
|
||||
# z-score composite
|
||||
window = 20
|
||||
z = (df - df.rolling(window).mean()) / (df.rolling(window).std() + 1e-8)
|
||||
|
||||
composite = pd.Series(0.0, index=df.index)
|
||||
total_ic = sum(abs(v) for v in ic_values.values())
|
||||
if total_ic == 0:
|
||||
total_ic = 1.0
|
||||
for col in df.columns:
|
||||
ic = ic_values.get(col, 0)
|
||||
w = abs(ic) / total_ic
|
||||
sign = -1 if ic < 0 else 1
|
||||
composite += sign * w * z[col]
|
||||
|
||||
signal = pd.Series(0, index=df.index)
|
||||
signal[composite > 0.5] = 1
|
||||
signal[composite < -0.5] = -1
|
||||
|
||||
# Compute returns
|
||||
common = close_daily.index.intersection(signal.index)
|
||||
c = close_daily.loc[common]
|
||||
s = signal.loc[common].clip(-1, 1).fillna(0)
|
||||
fwd_ret = c.pct_change().shift(-1)
|
||||
strat_ret = s.shift(1) * fwd_ret
|
||||
return signal, strat_ret.dropna()
|
||||
|
||||
|
||||
def compute_portfolio_metrics(returns: list[pd.Series], weights: list[float],
|
||||
close_daily: pd.Series) -> dict:
|
||||
"""Compute portfolio-level metrics from weighted strategy returns."""
|
||||
if not returns:
|
||||
return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}
|
||||
|
||||
# Align all return series
|
||||
common_idx = returns[0].index
|
||||
for r in returns[1:]:
|
||||
common_idx = common_idx.intersection(r.index)
|
||||
if len(common_idx) < 50:
|
||||
return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}
|
||||
|
||||
aligned = pd.DataFrame({i: r.loc[common_idx] for i, r in enumerate(returns)}).dropna()
|
||||
if len(aligned) < 30:
|
||||
return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}
|
||||
|
||||
# Weighted portfolio return
|
||||
port_ret = pd.Series(0.0, index=aligned.index)
|
||||
for i in range(len(returns)):
|
||||
port_ret += weights[i] * aligned[i]
|
||||
|
||||
# Equity curve
|
||||
eq = (1 + port_ret).cumprod()
|
||||
peak = eq.cummax()
|
||||
max_dd = float(((eq - peak) / peak).min())
|
||||
|
||||
total_ret = float(eq.iloc[-1] - 1)
|
||||
n_days = (port_ret.index[-1] - port_ret.index[0]).days
|
||||
n_months = max(n_days / 30.44, 1)
|
||||
monthly = float((1 + total_ret) ** (1 / n_months) - 1)
|
||||
|
||||
sharpe = float(port_ret.mean() / port_ret.std() * np.sqrt(252)) if port_ret.std() > 0 else 0
|
||||
daily_dd = float(port_ret.min()) # Worst daily return
|
||||
|
||||
return {
|
||||
"monthly_pct": monthly * 100,
|
||||
"max_dd": max_dd,
|
||||
"sharpe": sharpe,
|
||||
"daily_worst": daily_dd,
|
||||
"n_days": len(port_ret),
|
||||
"n_months": n_months,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" Portfolio Optimizer — 15% Monthly Target")
|
||||
print("=" * 60)
|
||||
|
||||
# Load OHLCV daily
|
||||
print("\nLoading data...")
|
||||
df = pd.read_hdf(OHLCV_PATH, key="data")
|
||||
close = df.xs("EURUSD", level="instrument")["$close"].sort_index()
|
||||
close_daily = close.resample("D").last().dropna()
|
||||
print(f" Daily bars: {len(close_daily)}")
|
||||
|
||||
# Load strategies
|
||||
strategies = load_strategies()
|
||||
print(f" Real strategies: {len(strategies)}")
|
||||
|
||||
# Build daily returns for each strategy
|
||||
print("\nBuilding strategy returns...")
|
||||
strat_returns = []
|
||||
strat_names = []
|
||||
for s in strategies[:50]: # Limit to top 50 for speed
|
||||
rets = load_strategy_returns(s, close_daily)
|
||||
if rets is None or len(rets) < 30:
|
||||
# Use simple signal as fallback
|
||||
_, rets = build_simple_signal(s["factors"], close_daily)
|
||||
if rets is not None and len(rets) >= 30:
|
||||
strat_returns.append(rets)
|
||||
strat_names.append(s["name"])
|
||||
print(f" [{len(strat_returns)}] {s['name'][:40]:40s} "
|
||||
f"Sh={s['sharpe']:.1f} Mon={s['monthly_pct']:.1f}% Tr={s['n_trades']}")
|
||||
|
||||
if len(strat_returns) < 2:
|
||||
print("\n Not enough valid strategies.")
|
||||
return
|
||||
|
||||
print(f"\n Valid return series: {len(strat_returns)}")
|
||||
|
||||
# Find best portfolio via greedy selection (low correlation, high return)
|
||||
print("\n--- Greedy Portfolio Selection ---")
|
||||
print(f" Target: {TARGET_MONTHLY}% monthly | Max DD: {MAX_DD:.0%} | Max Daily DD: {MAX_DAILY_DD:.0%}")
|
||||
print()
|
||||
|
||||
# Compute individual metrics
|
||||
individual = []
|
||||
for i, (rets, name) in enumerate(zip(strat_returns, strat_names)):
|
||||
eq = (1 + rets).cumprod()
|
||||
dd = float(((eq - eq.cummax()) / eq.cummax()).min())
|
||||
total = float(eq.iloc[-1] - 1)
|
||||
n = max((rets.index[-1] - rets.index[0]).days / 30.44, 1)
|
||||
mon = float((1 + total) ** (1 / n) - 1) * 100
|
||||
individual.append({"idx": i, "name": name, "monthly": mon, "dd": dd, "n": len(rets)})
|
||||
|
||||
individual.sort(key=lambda x: x["monthly"], reverse=True)
|
||||
|
||||
# Greedy: add strategies one by one if they don't increase correlation too much
|
||||
selected = []
|
||||
selected_rets = []
|
||||
|
||||
for s in individual:
|
||||
if len(selected) >= 8:
|
||||
break
|
||||
# Check correlation with existing portfolio
|
||||
new_ret = strat_returns[s["idx"]]
|
||||
if selected_rets:
|
||||
common = new_ret.index
|
||||
for r in selected_rets:
|
||||
common = common.intersection(r.index)
|
||||
if len(common) < 30:
|
||||
continue
|
||||
cors = []
|
||||
for r in selected_rets:
|
||||
aligned_new = new_ret.loc[common]
|
||||
aligned_r = r.loc[common]
|
||||
if len(aligned_new) >= 30:
|
||||
cors.append(abs(aligned_new.corr(aligned_r)))
|
||||
if cors and max(cors) > 0.5:
|
||||
print(f" SKIP {s['name'][:40]} (max_corr={max(cors):.2f})")
|
||||
continue
|
||||
|
||||
selected.append(s)
|
||||
selected_rets.append(new_ret)
|
||||
print(f" ADD {s['name'][:40]:40s} Mon={s['monthly']:+.1f}% DD={s['dd']:.3f} corr<0.5")
|
||||
|
||||
# Evaluate portfolio
|
||||
if len(selected) >= 2:
|
||||
print(f"\n Portfolio: {len(selected)} strategies")
|
||||
weights = [1.0 / len(selected)] * len(selected)
|
||||
rets = [strat_returns[s["idx"]] for s in selected]
|
||||
pm = compute_portfolio_metrics(rets, weights, close_daily)
|
||||
|
||||
print(f" Equal-weight metrics:")
|
||||
print(f" Monthly return: {pm['monthly_pct']:.2f}%")
|
||||
print(f" Max drawdown: {pm['max_dd']:.3f}")
|
||||
print(f" Sharpe: {pm['sharpe']:.2f}")
|
||||
print(f" Worst day: {pm['daily_worst']:.3%}")
|
||||
print(f" Period: {pm['n_months']:.1f} months ({pm['n_days']} days)")
|
||||
|
||||
# Leverage scaling
|
||||
max_safe_lev = min(
|
||||
MAX_DD / abs(pm["max_dd"]) if pm["max_dd"] != 0 else 30,
|
||||
MAX_DAILY_DD / abs(pm["daily_worst"]) if pm["daily_worst"] != 0 else 30,
|
||||
30,
|
||||
)
|
||||
leveraged_monthly = pm["monthly_pct"] * max_safe_lev
|
||||
print(f"\n Max safe leverage: {max_safe_lev:.1f}× (limited by max DD {MAX_DD:.0%})")
|
||||
print(f" Leveraged monthly: {leveraged_monthly:.1f}%")
|
||||
|
||||
if leveraged_monthly >= TARGET_MONTHLY:
|
||||
print(f"\n ✓ MEETS TARGET! {leveraged_monthly:.1f}% ≥ {TARGET_MONTHLY}%")
|
||||
else:
|
||||
gap = TARGET_MONTHLY - leveraged_monthly
|
||||
needed_strategies = int(np.ceil(len(selected) * TARGET_MONTHLY / max(leveraged_monthly, 0.1)))
|
||||
print(f"\n ✗ Below target. Need ~{needed_strategies} strategies or {TARGET_MONTHLY/max(pm['monthly_pct'],0.01):.1f}× better monthly.")
|
||||
|
||||
# Save portfolio config
|
||||
out = {
|
||||
"target_monthly": TARGET_MONTHLY,
|
||||
"selected": [{"name": s["name"], "monthly": s["monthly"], "dd": s["dd"]} for s in selected],
|
||||
"portfolio": pm if len(selected) >= 2 else {},
|
||||
}
|
||||
out_path = RESULTS_DIR / "portfolio_config.json"
|
||||
out_path.write_text(json.dumps(out, indent=2, default=str))
|
||||
print(f"\n Saved → {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user