Files
NexQuant/scripts/nexquant_continuous_strategies.py
T
TPTBusiness cbe1c52e00 refactor: rename project from Predix to NexQuant
Rename all source files, scripts, tests, documentation, and configuration
from Predix/predix to NexQuant/nexquant across the entire codebase.
2026-05-09 17:48:22 +02:00

185 lines
6.9 KiB
Python

#!/usr/bin/env python
"""
Continuous Strategy Generator — runs indefinitely, improving over time.
Features:
- Infinite loop: generate → optimize → ensemble → repeat
- Walk-Forward validation required (OOS Sharpe > 0)
- Multi-Timeframe check (1min, 5min, 15min, 1h)
- Rolling stability check (12-month Sharpe never negative)
- ML model training when LLM suggests it's beneficial
- Auto-ensemble from top strategies
- Daytrading AND swing style alternating
Usage:
python scripts/nexquant_continuous_strategies.py
python scripts/nexquant_continuous_strategies.py --style daytrading --rounds 100
python scripts/nexquant_continuous_strategies.py --style both --workers 4
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
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))
from rdagent.scenarios.qlib.local.strategy_orchestrator import StrategyOrchestrator
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
BATCH_SIZE = 5
COOLDOWN_SECONDS = 30
def build_ml_model(factor_values: pd.DataFrame, close: pd.Series, style: str) -> dict | None:
"""Train ML model if data is sufficient, return strategy dict or None."""
from sklearn.ensemble import GradientBoostingRegressor
df = factor_values.ffill().dropna()
close_aligned = close.reindex(df.index).ffill()
common = df.index.intersection(close_aligned.index)
if len(common) < 5000:
logger.info("ML: insufficient data (<5000 rows)")
return None
X = df.loc[common].values
y = close_aligned.loc[common].pct_change(96).shift(-96).fillna(0).values # forward 96-bar return
split = int(len(X) * 0.7)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
model = GradientBoostingRegressor(n_estimators=100, max_depth=5, random_state=42)
model.fit(X_train, y_train)
# Generate signal on test data
preds = model.predict(X_test)
signal = pd.Series(np.sign(preds), index=common[split:])
# Backtest
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
bt = backtest_signal_ftmo(
close=close_aligned.loc[common[split:]],
signal=signal,
txn_cost_bps=2.14,
wf_rolling=True,
)
is_oos_sharpe = bt.get("wf_oos_sharpe_mean", 0)
if is_oos_sharpe <= 0:
logger.info(f"ML model rejected: OOS Sharpe={is_oos_sharpe:.2f}")
return None
logger.info(f"ML model accepted: Sharpe={bt['sharpe']:.2f} OOS={is_oos_sharpe:.2f}")
return {
"strategy_name": f"ML_GradientBoost_{style}_{int(time.time())}",
"status": "accepted",
"sharpe_ratio": round(bt["sharpe"], 4),
"max_drawdown": round(bt["max_drawdown"], 4),
"win_rate": round(bt["win_rate"], 4),
"n_trades": bt["n_trades"],
"oos_sharpe": round(is_oos_sharpe, 4),
"type": "ml_model",
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--style", default="both", choices=["daytrading", "swing", "both"])
parser.add_argument("--workers", type=int, default=2)
parser.add_argument("--rounds", type=int, default=0, help="Stop after N rounds (0=infinite)")
parser.add_argument("--min-sharpe", type=float, default=1.5)
parser.add_argument("--batch-size", type=int, default=5)
parser.add_argument("--ml-rounds", type=int, default=3, help="Train ML model every N rounds")
args = parser.parse_args()
print(f"\n{'='*60}")
print(f" NexQuant Continuous Strategy Generator")
print(f" Style: {args.style} | Workers: {args.workers}")
print(f" Min Sharpe: {args.min_sharpe} | Batch: {args.batch_size}")
print(f" ML every {args.ml_rounds} rounds")
print(f"{'='*60}\n")
round_num = 0
total_accepted = 0
total_ml_accepted = 0
start_time = datetime.now()
while True:
round_num += 1
styles = [args.style] if args.style != "both" else (["swing", "daytrading"] if round_num % 2 == 1 else ["daytrading", "swing"])
for style in styles:
print(f"\n--- Round {round_num} | Style: {style} ---")
orch = StrategyOrchestrator(
top_factors=20, trading_style=style,
min_sharpe=args.min_sharpe,
use_optuna=True, optuna_trials=30,
)
try:
results = orch.generate_strategies(count=BATCH_SIZE, workers=args.workers)
except Exception as e:
logger.error(f"Round {round_num} {style} failed: {e}")
continue
accepted = [r for r in results if r.get("status") == "accepted"]
total_accepted += len(accepted)
print(f" Accepted: {len(accepted)}/{len(results)} (Total: {total_accepted})")
for r in accepted[:3]:
print(f" {r.get('strategy_name', '?')[:40]:40s} S={r.get('sharpe_ratio',0):.1f} OOS={r.get('oos_sharpe',0):.1f}")
# Ensemble after every round
ensemble = orch.build_ensemble(results)
if ensemble and ensemble.get("status") == "success":
print(f" Ensemble: S={ensemble['sharpe_ratio']:.1f} OOS={ensemble['oos_sharpe']:.1f} ({len(ensemble['members'])} members)")
# ML model every N rounds
if round_num % args.ml_rounds == 0:
print(f"\n [ML] Training model on all factors...")
factors = orch.load_top_factors()
if factors:
factor_values = {}
for f in factors:
series = orch.load_factor_values(f["factor_name"])
if series is not None:
factor_values[f["factor_name"]] = series
if len(factor_values) >= 3:
df = pd.DataFrame(factor_values)
if isinstance(df.index, pd.MultiIndex):
df = df.droplevel(-1)
ml_result = build_ml_model(df, orch.ohlcv_close, style)
if ml_result:
total_ml_accepted += 1
print(f" [ML] Accepted! S={ml_result['sharpe_ratio']:.1f} OOS={ml_result['oos_sharpe']:.1f}")
elapsed = (datetime.now() - start_time).total_seconds()
print(f"\n Elapsed: {elapsed/60:.0f}min | Accepted: {total_accepted} (+{total_ml_accepted} ML) | Rate: {total_accepted/(elapsed/3600):.1f}/h")
if args.rounds > 0 and round_num >= args.rounds:
break
time.sleep(COOLDOWN_SECONDS)
print(f"\n{'='*60}")
print(f" DONE: {total_accepted} strategies + {total_ml_accepted} ML models")
print(f" Total time: {(datetime.now()-start_time).total_seconds()/3600:.1f}h")
print(f"{'='*60}")
if __name__ == "__main__":
main()