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:
TPTBusiness
2026-05-17 20:09:46 +02:00
parent e0000a18d2
commit d4611b530e
5 changed files with 960 additions and 231 deletions
+237 -228
View File
@@ -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()
+329
View File
@@ -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()
+388
View File
@@ -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()