2026-05-17 20:09:46 +02:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""Daily Strategy Generator — Kronos factors at daily resolution.
|
2026-05-10 17:58:25 +02:00
|
|
|
|
|
2026-05-17 20:09:46 +02:00
|
|
|
|
Daily timeframe eliminates 1-min noise and transaction cost overhead.
|
|
|
|
|
|
Factors with daily IC translate directly to daily trading edge.
|
2026-05-10 17:58:25 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-17 20:09:46 +02:00
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import time
|
2026-05-10 17:58:25 +02:00
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
2026-05-17 20:09:46 +02:00
|
|
|
|
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)
|
2026-05-10 17:58:25 +02:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-17 20:09:46 +02:00
|
|
|
|
"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,
|
2026-05-10 17:58:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 20:09:46 +02:00
|
|
|
|
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]
|
2026-05-10 17:58:25 +02:00
|
|
|
|
results = []
|
2026-05-17 20:09:46 +02:00
|
|
|
|
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}")
|
2026-05-10 17:58:25 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-05-17 20:09:46 +02:00
|
|
|
|
main()
|