mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 23:47:46 +00:00
d4611b530e
- 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
279 lines
9.9 KiB
Python
279 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
||
"""Daily Strategy Generator — Kronos factors at daily resolution.
|
||
|
||
Daily timeframe eliminates 1-min noise and transaction cost overhead.
|
||
Factors with daily IC translate directly to daily trading edge.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
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")))
|
||
|
||
MIN_MONTHLY = 5.0 # Raw backtest target (conservative for daily)
|
||
MIN_SHARPE = 1.0
|
||
MAX_DD = -0.20
|
||
MIN_TRADES = 30
|
||
|
||
|
||
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 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 {
|
||
"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 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 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 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()})")
|
||
|
||
# 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()}
|
||
|
||
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()
|
||
|
||
# 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)
|
||
|
||
# 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)
|
||
|
||
# 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)
|
||
|
||
# Filter & sort
|
||
print(f"\n{'=' * 60}")
|
||
print(f" Total evaluations: {len(results)} Time: {time.time()-t0:.0f}s")
|
||
print(f"{'=' * 60}")
|
||
|
||
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]
|
||
|
||
valid.sort(key=lambda r: r["monthly_pct"], reverse=True)
|
||
|
||
print(f"\n Meeting: Sharpe≥{MIN_SHARPE} DD≥{MAX_DD} Tr≥{MIN_TRADES} Mon≥{MIN_MONTHLY}%")
|
||
print(f" → {len(valid)} strategies\n")
|
||
|
||
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%}'))
|
||
|
||
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']}")
|
||
|
||
# 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__":
|
||
main()
|