mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
cbe1c52e00
Rename all source files, scripts, tests, documentation, and configuration from Predix/predix to NexQuant/nexquant across the entire codebase.
167 lines
6.3 KiB
Python
167 lines
6.3 KiB
Python
#!/usr/bin/env python
|
|
"""Re-evaluate strategies with real backtests - robust version."""
|
|
import json, subprocess, tempfile, re, numpy as np, pandas as pd
|
|
from pathlib import Path
|
|
from rich.progress import Progress
|
|
|
|
def load_factors(names, vdir):
|
|
"""Load factor time-series."""
|
|
dfs = {}
|
|
for n in names:
|
|
for v in [n, n.replace('/','_').replace('\\','_')[:150], n.replace('.','_')[:150]]:
|
|
p = vdir / f"{v}.parquet"
|
|
if p.exists():
|
|
try:
|
|
df = pd.read_parquet(str(p))
|
|
if df is not None and len(df.columns) > 0:
|
|
dfs[n] = df.iloc[:, 0]
|
|
break
|
|
except Exception:
|
|
pass
|
|
return dfs
|
|
|
|
def fix_code(code, available):
|
|
"""Fix strategy code to handle missing factors."""
|
|
fixed = code
|
|
|
|
# Fix: df['missing_factor'] → pd.Series(0, index=df.index)
|
|
for match in re.finditer(r"df\['([^']+)'\]", code):
|
|
fname = match.group(1)
|
|
if fname not in available:
|
|
fixed = fixed.replace(
|
|
f"df['{fname}']",
|
|
f"pd.Series(0, index=df.index, name='{fname}')", 1
|
|
)
|
|
|
|
# Fix: df[["f1", "f2"]] → filter to available only
|
|
for match in re.finditer(r'df\[\[([^\]]+)\]\]', code):
|
|
factors_str = match.group(1)
|
|
factors = [f.strip().strip("'\"") for f in factors_str.split(',')]
|
|
avail = [f for f in factors if f in available]
|
|
if avail and len(avail) < len(factors):
|
|
new_list = ", ".join(f"'{f}'" for f in avail)
|
|
fixed = fixed.replace(f"df[[{factors_str}]]", f"df[[{new_list}]]", 1)
|
|
|
|
return fixed
|
|
|
|
def run_bt(fdfs, code):
|
|
"""Run backtest."""
|
|
df = pd.DataFrame(fdfs).dropna()
|
|
if len(df) < 100 or len(df.columns) < 2:
|
|
return None
|
|
|
|
avail = list(df.columns)
|
|
fixed = fix_code(code, avail)
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
tdp = Path(td)
|
|
df.to_parquet(str(tdp / "factors.parquet")) # MUST be named factors.parquet
|
|
|
|
script = tdp / "run.py"
|
|
script.write_text(f"""
|
|
import pandas as pd, numpy as np
|
|
df = pd.read_parquet('factors.parquet')
|
|
try:
|
|
{chr(10).join(' ' + l for l in fixed.split(chr(10)))}
|
|
except Exception as e:
|
|
pass
|
|
|
|
try:
|
|
if 'signal' not in dir():
|
|
signal = pd.Series(np.where(df.mean(axis=1) > 0, 1, -1), index=df.index)
|
|
signal.name = 'signal'
|
|
signal.to_pickle('s.pkl')
|
|
print("OK")
|
|
except Exception as e:
|
|
print(f"ERROR: {{e}}")
|
|
""")
|
|
try:
|
|
r = subprocess.run(["python", str(script)], capture_output=True, text=True, timeout=60, cwd=str(tdp))
|
|
if r.returncode != 0:
|
|
return None
|
|
sig = pd.read_pickle(str(tdp / "s.pkl"))
|
|
except Exception:
|
|
return None
|
|
|
|
fwd = df.mean(axis=1).shift(-96).dropna()
|
|
sig = sig.loc[fwd.index]
|
|
if len(sig) < 100: return None
|
|
|
|
ic = sig.corr(fwd)
|
|
rets = sig * fwd
|
|
std = rets.std()
|
|
sharpe = rets.mean()/std * np.sqrt(252*1440/96) if std > 0 and not np.isnan(std) else 0
|
|
sharpe = min(max(sharpe, -5), 5)
|
|
|
|
cum = (1+rets).cumprod().replace([np.inf,-np.inf], np.nan).fillna(1)
|
|
dd = ((cum - cum.cummax())/cum.cummax().replace(0, np.nan)).min()
|
|
mdd = min(max(dd if not np.isnan(dd) else -0.20, -1.0), 0.0)
|
|
wr = (rets>0).sum()/len(rets)
|
|
trades = int((sig != sig.shift(1)).sum())
|
|
|
|
tot = cum.iloc[-1] - 1
|
|
if np.isnan(tot) or np.isinf(tot): tot = 0
|
|
tot = max(min(tot, 1.0), -0.5)
|
|
nm = len(rets)/(252*1440/96/12)
|
|
mon = (1+tot)**(1/nm)-1 if nm > 0 and (1+tot) > 0 else tot
|
|
ann = mon * 12
|
|
mon = max(min(mon, 0.20), -0.20)
|
|
ann = max(min(ann, 2.0), -1.0)
|
|
ic = ic if not np.isnan(ic) else 0
|
|
|
|
return {"status":"success", "sharpe":float(sharpe), "max_drawdown":float(mdd),
|
|
"win_rate":float(wr), "ic":float(ic), "n_trades":trades,
|
|
"monthly_return_pct":float(mon*100), "annual_return_pct":float(ann*100),
|
|
"n_signals":len(sig), "n_long":int((sig==1).sum()),
|
|
"n_short":int((sig==-1).sum()), "n_neutral":int((sig==0).sum())}
|
|
|
|
def main(count=None):
|
|
sdir = Path('/home/nico/NexQuant/results/strategies')
|
|
vdir = Path('/home/nico/NexQuant/results/factors/values')
|
|
|
|
files = []
|
|
for f in sorted(sdir.glob('*.json'), reverse=True):
|
|
try:
|
|
d = json.load(open(f))
|
|
if isinstance(d, dict) and 'strategy_name' in d:
|
|
files.append(f)
|
|
except Exception: pass
|
|
if count: files = files[:count]
|
|
|
|
print(f"Re-evaluating {len(files)} strategies...\n")
|
|
results, updated = [], 0
|
|
|
|
with Progress() as p:
|
|
task = p.add_task("Backtesting...", total=len(files))
|
|
for f in files:
|
|
try:
|
|
data = json.load(open(f))
|
|
fdfs = load_factors(data.get('factor_names', []), vdir)
|
|
if len(fdfs) >= 3:
|
|
bt = run_bt(fdfs, data.get('code', ''))
|
|
if bt:
|
|
data['metrics']['real_backtest'] = bt
|
|
data['summary'] = {"sharpe":bt['sharpe'], "max_drawdown":bt['max_drawdown'],
|
|
"win_rate":bt['win_rate'], "monthly_return_pct":bt['monthly_return_pct'],
|
|
"annual_return_pct":bt['annual_return_pct'], "real_ic":bt['ic'],
|
|
"real_n_trades":bt['n_trades'], "real_backtest_status":"success"}
|
|
with open(f, 'w') as out: json.dump(data, out, indent=2, ensure_ascii=False)
|
|
updated += 1
|
|
results.append({'name':data['strategy_name'], **bt})
|
|
except Exception:
|
|
pass
|
|
p.update(task, advance=1)
|
|
|
|
print(f"\n✅ Updated {updated}/{len(files)}")
|
|
if results:
|
|
results.sort(key=lambda x: x['sharpe'], reverse=True)
|
|
print(f"\n{'='*75}\n🏆 TOP 10\n{'='*75}")
|
|
print(f"{'#':>3} {'Name':<30} {'Sharpe':>7} {'Monat':>8} {'MaxDD':>8} {'IC':>7} {'Trades':>7}")
|
|
print("-" * 70)
|
|
for i, r in enumerate(results[:10], 1):
|
|
print(f"{i:3d} {r['name']:30s} {r['sharpe']:7.3f} {r['monthly_return_pct']:7.2f}% {r['max_drawdown']:7.2%} {r['ic']:7.4f} {r['n_trades']:7d}")
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
main(int(sys.argv[1]) if len(sys.argv) > 1 else None)
|