2026-04-06 12:11:49 +02:00
#!/usr/bin/env python
"""
2026-04-09 08:15:49 +02:00
Parallel AI Strategy Generation with REAL OHLCV Backtest.
2026-04-06 19:42:59 +02:00
2026-04-09 08:15:49 +02:00
Generates multiple trading strategies in parallel using LLM,
each with real backtesting on OHLCV data.
2026-04-06 19:42:59 +02:00
Usage:
2026-04-09 08:15:49 +02:00
# Swing trading (96-bar forward returns)
2026-05-09 17:48:22 +02:00
python nexquant_gen_strategies_real_bt.py 10
2026-04-09 08:15:49 +02:00
# Daytrading with FTMO constraints (12-bar forward returns)
2026-05-09 17:48:22 +02:00
TRADING_STYLE=daytrading python nexquant_gen_strategies_real_bt.py 5
2026-04-09 08:15:49 +02:00
# With parallel workers (default: CPU count)
2026-05-09 17:48:22 +02:00
TRADING_STYLE=daytrading WORKERS=4 python nexquant_gen_strategies_real_bt.py 20
2026-04-06 12:11:49 +02:00
"""
2026-05-16 19:06:09 +02:00
import json
import logging
import os
import random
import subprocess
import sys
import time
import warnings
2026-04-09 08:15:49 +02:00
from datetime import datetime
2026-05-16 19:06:09 +02:00
from pathlib import Path
2026-04-09 08:15:49 +02:00
2026-04-06 12:11:49 +02:00
import numpy as np
import pandas as pd
2026-04-06 19:42:59 +02:00
from dotenv import load_dotenv
2026-05-16 19:06:09 +02:00
from rich.console import Console
from rich.progress import BarColumn , Progress , SpinnerColumn , TextColumn , TimeElapsedColumn
2026-04-06 19:42:59 +02:00
2026-04-17 22:52:07 +02:00
# Suppress warnings and noisy loggers that bleed into Rich progress output
2026-05-16 19:06:09 +02:00
warnings . filterwarnings ( "ignore" )
for _noisy in ( "rdagent" , "litellm" , "LiteLLM" , "litellm.utils" ,
"litellm.main" , "httpx" , "httpcore" , "openai" , "urllib3" ):
2026-04-17 22:52:07 +02:00
logging . getLogger ( _noisy ) . setLevel ( logging . CRITICAL )
# Suppress litellm verbose flag if already imported
try :
import litellm as _ll
_ll . suppress_debug_info = True
_ll . verbose = False
_ll . set_verbose = False
except Exception :
pass
2026-04-06 12:11:49 +02:00
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-06 12:11:49 +02:00
# Configuration
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-05-16 19:06:09 +02:00
OHLCV_PATH = Path ( "/home/nico/NexQuant/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5" )
FACTORS_DIR = Path ( "/home/nico/NexQuant/results/factors" )
STRATEGIES_DIR = Path ( "/home/nico/NexQuant/results/strategies_new" )
2026-04-06 12:11:49 +02:00
STRATEGIES_DIR . mkdir ( parents = True , exist_ok = True )
2026-04-09 08:15:49 +02:00
# Trading style
2026-05-16 19:06:09 +02:00
TRADING_STYLE = os . getenv ( "TRADING_STYLE" , "swing" )
N_WORKERS = int ( os . getenv ( "WORKERS" , os . cpu_count () or 4 ))
2026-04-07 21:21:37 +02:00
2026-05-16 19:06:09 +02:00
if TRADING_STYLE == "daytrading" :
FORWARD_BARS = int ( os . getenv ( "FORWARD_BARS" , "12" ))
2026-04-07 21:21:37 +02:00
MIN_IC = 0.02
MIN_SHARPE = 0.5
2026-04-19 12:53:00 +02:00
MIN_TRADES = 300
2026-04-09 08:15:49 +02:00
MAX_DRAWDOWN = - 0.10
2026-05-16 19:06:09 +02:00
MIN_MONTHLY_RETURN_PCT = 15.0
STYLE_EMOJI = "🎯 Daytrading"
STYLE_DESC = "short-term intraday with FTMO compliance"
2026-04-07 21:21:37 +02:00
else :
2026-05-16 19:06:09 +02:00
FORWARD_BARS = int ( os . getenv ( "FORWARD_BARS" , "96" ))
2026-04-07 21:21:37 +02:00
MIN_IC = 0.02
MIN_SHARPE = 0.5
MIN_TRADES = 10
2026-04-17 22:52:07 +02:00
MAX_DRAWDOWN = - 0.30
2026-05-16 19:06:09 +02:00
MIN_MONTHLY_RETURN_PCT = 15.0
STYLE_EMOJI = "📈 Swing"
STYLE_DESC = "medium-term intraday"
2026-04-09 08:15:49 +02:00
2026-04-19 12:53:00 +02:00
# Whether to use raw OHLCV-only strategies (no daily factors)
2026-05-16 19:06:09 +02:00
OHLCV_ONLY = os . getenv ( "OHLCV_ONLY" , "0" ) == "1"
2026-04-19 12:53:00 +02:00
2026-05-16 19:06:09 +02:00
TXN_COST_BPS = float ( os . getenv ( "TXN_COST_BPS" , "2.14" )) # 2.35 pip realistic EUR/USD costs
2026-04-17 22:52:07 +02:00
2026-04-18 15:37:03 +02:00
# ── Logging setup: everything printed goes to log file + stdout ───────────────
_LOG_DIR = Path ( __file__ ) . parent . parent / "git_ignore_folder" / "logs"
_LOG_DIR . mkdir ( parents = True , exist_ok = True )
_log_file_path = _LOG_DIR / f "gen_strategies_ { datetime . now () . strftime ( '%Y%m %d _%H%M%S' ) } .log"
_log_file = open ( _log_file_path , "w" , encoding = "utf-8" , buffering = 1 ) # line-buffered
logging . basicConfig (
level = logging . INFO ,
format = " %(asctime)s [ %(levelname)s ] %(message)s " ,
handlers = [
logging . StreamHandler ( sys . stdout ),
logging . FileHandler ( _log_file_path , encoding = "utf-8" ),
],
)
class _TeeFile :
"""Writes to both stdout and log file — used as Rich Console file."""
def __init__ ( self , * files ):
self . _files = files
def write ( self , data ):
for f in self . _files :
f . write ( data )
def flush ( self ):
for f in self . _files :
f . flush ()
def fileno ( self ):
return self . _files [ 0 ] . fileno ()
console = Console ( file = _TeeFile ( sys . stdout , _log_file ), highlight = False )
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
# LLM Configuration (Process-safe)
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
def setup_llm_env ():
"""Setup LLM environment variables."""
2026-05-16 19:06:09 +02:00
load_dotenv ( Path ( __file__ ) . parent . parent / ".env" )
if os . getenv ( "OPENAI_API_KEY" ) == "local" or os . getenv ( "LLM_BACKEND" , "" ) . lower () == "local" :
2026-04-17 22:52:07 +02:00
return
2026-05-16 19:06:09 +02:00
router_key = os . getenv ( "OPENROUTER_API_KEY" , "" )
2026-04-09 08:15:49 +02:00
if router_key :
2026-05-16 19:06:09 +02:00
os . environ [ "OPENAI_API_KEY" ] = router_key
os . environ [ "OPENAI_API_BASE" ] = "https://openrouter.ai/api/v1"
os . environ [ "CHAT_MODEL" ] = os . getenv ( "OPENROUTER_MODEL" , "openrouter/google/gemma-4-26b-a4b-it:free" )
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
# Factor Loading (cached at module level for each process)
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
_FACTORS_CACHE = None
2026-04-06 12:11:49 +02:00
def load_available_factors ( top_n = 20 ):
"""Load top factors that have parquet time-series files."""
2026-04-09 08:15:49 +02:00
global _FACTORS_CACHE
if _FACTORS_CACHE is not None :
return _FACTORS_CACHE [: top_n ]
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
factors = []
2026-05-16 19:06:09 +02:00
for f in FACTORS_DIR . glob ( "*.json" ):
2026-04-06 12:11:49 +02:00
try :
data = json . load ( open ( f ))
2026-05-16 19:06:09 +02:00
fname = data . get ( "factor_name" , "" )
ic = data . get ( "ic" ) or 0
safe = fname . replace ( "/" , "_" ) . replace ( " \\ " , "_" )[: 150 ]
if ( FACTORS_DIR / "values" / f " { safe } .parquet" ) . exists ():
factors . append ({ "name" : fname , "ic" : ic })
2026-04-06 12:11:49 +02:00
except :
pass
2026-05-16 19:06:09 +02:00
factors . sort ( key = lambda x : abs ( x [ "ic" ]), reverse = True )
2026-04-09 08:15:49 +02:00
_FACTORS_CACHE = factors
2026-04-06 12:11:49 +02:00
return factors [: top_n ]
2026-04-09 08:15:49 +02:00
# ============================================================================
# OHLCV Data Loading (cached at module level)
# ============================================================================
_OHLCV_CACHE = None
2026-04-06 19:42:59 +02:00
2026-04-09 08:15:49 +02:00
def load_ohlcv_data ():
"""Load OHLCV close prices."""
global _OHLCV_CACHE
if _OHLCV_CACHE is not None :
return _OHLCV_CACHE
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
if not OHLCV_PATH . exists ():
raise FileNotFoundError ( f "OHLCV data not found: { OHLCV_PATH } " )
2026-05-16 19:06:09 +02:00
ohlcv = pd . read_hdf ( str ( OHLCV_PATH ), key = "data" )
if "$close" in ohlcv . columns :
close = ohlcv [ "$close" ]
elif "close" in ohlcv . columns :
close = ohlcv [ "close" ]
2026-04-09 08:15:49 +02:00
else :
close = ohlcv . select_dtypes ( include = [ np . number ]) . iloc [:, 0 ]
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
_OHLCV_CACHE = close . dropna ()
return _OHLCV_CACHE
2026-04-06 12:11:49 +02:00
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
# Strategy Generation (LLM call - runs in separate process)
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
def generate_single_strategy ( args ):
"""Generate and backtest ONE strategy. Runs in separate process."""
idx , factor_subset , feedback , attempt = args
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
try :
setup_llm_env ()
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
from rdagent.oai.llm_utils import APIBackend
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
factor_list = " \n " . join ([ f "- { f [ 'name' ] } (IC= { f [ 'ic' ] : .4f } )" for f in factor_subset ])
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
# Optimized prompts for daytrading vs swing
2026-05-16 19:06:09 +02:00
if TRADING_STYLE == "daytrading" and OHLCV_ONLY :
2026-04-19 12:53:00 +02:00
system_prompt = """You are an expert EUR/USD intraday quant. You build strategies that work ONLY on raw price data (OHLCV), computing all indicators directly from the 1-minute close series.
CRITICAL RULES:
1. The code receives ONLY a pandas Series called 'close' (1-minute EUR/USD close prices, UTC timestamps).
2. 'factors' is NOT available — compute everything from 'close' directly.
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral).
4. signal.index MUST match close.index exactly.
5. signal.name must be 'signal'.
6. Use ONLY pandas/numpy — no external libraries.
7. MANDATORY: The signal MUST flip at least 300 times across the full dataset. Use low thresholds.
Allowed intraday techniques (pick 2-3 and combine):
- Session timing: London open (07:00-09:00 UTC), NY open (13:00-15:00 UTC), session overlap
- Short-window RSI (7-14 bars) on 1-min close
- EMA crossovers (fast=5-15 bars, slow=20-60 bars)
- Bollinger Bands (20-bar, 1.5σ ) for mean reversion
- ATR-based volatility breakouts
- VWAP deviation (approximate with rolling mean)
- Time-of-day filters combined with momentum
Output ONLY valid JSON:
{"strategy_name": "short_name", "factor_names": [], "description": "one sentence", "code": "python code"}"""
user_prompt = f """Create a EUR/USD 1-minute intraday strategy using ONLY the raw close price series.
{ f 'Previous feedback: { feedback } ' if feedback else 'First attempt — be creative and combine session timing with a momentum or mean-reversion indicator!' }
Hard requirements:
- Signal must change direction at least 300 times total (~4-8 trades per trading day)
- NEVER use ffill() or forward-fill on the signal — recompute fresh at every bar
- Use RSI thresholds between 35-45 (long) and 55-65 (short) — NOT extreme values like 10/90
- Use EMA crossover thresholds of 0 (cross above/below) for maximum trade frequency
- Use causal indicators only: rolling windows, shift(1) — NO look-ahead bias
- No factor data — compute everything from 'close'
2026-05-16 19:06:09 +02:00
- Keep it simple: 2-3 indicators max
- TARGET MONTHLY RETURN: Generate signals that can achieve >15% OOS monthly return after FTMO costs (2.35 pip/trade). Use high-conviction entries only."""
2026-04-19 12:53:00 +02:00
2026-05-16 19:06:09 +02:00
elif TRADING_STYLE == "daytrading" :
2026-04-09 08:15:49 +02:00
system_prompt = f """You are an expert daytrading quant specializing in EUR/USD scalping and intraday strategies.
2026-04-06 12:11:49 +02:00
2026-04-09 08:15:49 +02:00
CRITICAL RULES for { STYLE_DESC } (forward horizon: { FORWARD_BARS } bars = ~ { FORWARD_BARS } minutes):
2026-04-06 12:11:49 +02:00
1. ONLY use the factors listed below - no others!
2026-04-06 19:42:59 +02:00
2. The code MUST work with a DataFrame called 'factors' and Series called 'close'
2026-04-06 12:11:49 +02:00
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
2026-04-06 19:42:59 +02:00
4. signal.index MUST match close.index
5. signal.name must be 'signal'
2026-04-19 12:53:00 +02:00
6. MANDATORY: signal must flip direction at least 300 times total — use low thresholds (0.1-0.3)
7. Use rolling z-scores with SHORT windows (5-20 bars) and TIGHT thresholds
2026-04-06 12:11:49 +02:00
Output ONLY valid JSON with these fields:
2026-04-09 08:15:49 +02:00
{{ "strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code" }} """
2026-04-19 12:53:00 +02:00
2026-04-09 08:15:49 +02:00
user_prompt = f """Create a EUR/USD DAYTRADING strategy ( { FORWARD_BARS } -minute horizon) using these factors:
2026-04-06 12:11:49 +02:00
{ factor_list }
2026-04-09 08:15:49 +02:00
{ f 'Previous feedback: { feedback } ' if feedback else 'First attempt - be creative!' }
2026-04-06 12:11:49 +02:00
2026-04-19 12:53:00 +02:00
Hard requirements:
- signal must change at least 300 times total (~4 trades/day) — use thresholds of 0.1-0.3
- NEVER use ffill() or forward-fill on the signal — recompute fresh at every bar
- Use rolling z-scores with windows of 5-20 bars (not 50-100), thresholds ±0.2 to ±0.5
- Combine 2 factors: one momentum, one mean-reversion
2026-05-16 19:06:09 +02:00
- NO global mean/std — always use rolling(window).mean() with shift(1) to avoid look-ahead bias
- TARGET MONTHLY RETURN: Generate signals that can achieve >15% OOS monthly return after FTMO costs (2.35 pip/trade). Use high-conviction entries only."""
2026-04-19 12:53:00 +02:00
2026-04-09 08:15:49 +02:00
else :
2026-04-24 20:19:07 +02:00
system_prompt = f """You are a quantitative trading expert specializing in EUR/USD daily swing strategies.
2026-04-09 08:15:49 +02:00
CRITICAL RULES for { STYLE_DESC } (forward horizon: { FORWARD_BARS } bars = ~ { FORWARD_BARS / 60 : .1f } hours):
1. ONLY use the factors listed below - no others!
2. The code MUST work with a DataFrame called 'factors' and Series called 'close'
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
4. signal.index MUST match close.index
5. signal.name must be 'signal'
2026-04-24 20:19:07 +02:00
6. IMPORTANT: factors are DAILY values broadcast to every 1-minute bar — they change once per day.
Use daily-level logic: compare today's factor value to a rolling daily mean (window 5-20 DAYS).
To get daily rolling mean: group by date, take first value per day, compute rolling, then reindex back.
Example: dates = factors[col].index.get_level_values('datetime').normalize()
daily_vals = factors[col].groupby(dates).first()
daily_mean = daily_vals.rolling(10).mean().shift(1)
daily_signal = (daily_vals > daily_mean).astype(int) * 2 - 1
signal = daily_signal.reindex(dates).values (broadcast back to minute bars)
7. The signal should change roughly once per day — this produces ~250-500 trades over 6 years.
8. Keep conditions SIMPLE: one factor above/below its N-day rolling average. Avoid combining 3+ conditions.
2026-04-09 08:15:49 +02:00
Output ONLY valid JSON with these fields:
{{ "strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code" }} """
2026-04-19 12:53:00 +02:00
2026-04-24 20:19:07 +02:00
user_prompt = f """Create a EUR/USD SWING trading strategy (hold ~ { FORWARD_BARS / 60 : .0f } hours) using these factors:
2026-04-09 08:15:49 +02:00
{ factor_list }
2026-04-24 20:19:07 +02:00
{ f 'Previous feedback: { feedback } ' if feedback else 'First attempt - be creative!' }
2026-05-16 19:06:09 +02:00
Use daily-level signal logic (factor above/below rolling daily mean). Signal changes once per day. TARGET MONTHLY RETURN: Generate signals that can achieve >15% OOS monthly return after FTMO costs (2.35 pip/trade)."""
2026-04-06 12:11:49 +02:00
api = APIBackend ()
response = api . build_messages_and_create_chat_completion (
2026-05-16 19:06:09 +02:00
user_prompt = user_prompt , system_prompt = system_prompt , json_mode = True ,
2026-04-06 12:11:49 +02:00
)
2026-04-09 08:15:49 +02:00
strategy_data = json . loads ( response )
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
# Validate response
2026-05-16 19:06:09 +02:00
if "code" not in strategy_data or "factor_names" not in strategy_data :
return { "status" : "invalid" , "reason" : "Missing required fields" , "idx" : idx }
2026-04-09 08:15:49 +02:00
return {
2026-05-16 19:06:09 +02:00
"status" : "generated" ,
"strategy" : strategy_data ,
"idx" : idx ,
2026-04-09 08:15:49 +02:00
}
2026-05-16 19:06:09 +02:00
2026-04-06 12:11:49 +02:00
except Exception as e :
2026-05-16 19:06:09 +02:00
return { "status" : "error" , "reason" : str ( e )[: 200 ], "idx" : idx }
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
# Backtest Runner (runs in main process to avoid re-loading data)
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
def run_backtest ( close , factors_df , strategy_code ):
2026-04-17 22:52:07 +02:00
"""
Execute LLM-generated strategy code in a sandboxed subprocess to produce
the signal, then delegate all metric computation to the unified
``backtest_signal`` engine in the main process.
"""
2026-04-19 12:53:00 +02:00
if close is None :
return None
if not OHLCV_ONLY and ( factors_df is None or len ( factors_df . columns ) < 2 ):
2026-04-06 12:11:49 +02:00
return None
2026-04-17 22:52:07 +02:00
2026-04-19 12:53:00 +02:00
# Flatten MultiIndex — strategy code expects a plain DatetimeIndex
if isinstance ( close . index , pd . MultiIndex ):
close = close . droplevel ( - 1 )
close = close . sort_index ()
2026-04-09 08:15:49 +02:00
import tempfile
2026-04-17 22:52:07 +02:00
# Subprocess stays minimal: it only runs the untrusted strategy code
# and pickles the resulting signal. All numbers come from the shared engine.
2026-04-19 12:53:00 +02:00
factors_line = "" if OHLCV_ONLY else "factors = pd.read_pickle('factors.pkl')"
2026-04-06 19:42:59 +02:00
script = f """
import pandas as pd
import numpy as np
close = pd.read_pickle('close.pkl')
2026-04-19 12:53:00 +02:00
{ factors_line }
2026-04-06 19:42:59 +02:00
2026-04-06 12:11:49 +02:00
try:
2026-04-06 19:42:59 +02:00
{ chr ( 10 ) . join ( ' ' + l for l in strategy_code . split ( chr ( 10 ))) }
2026-04-17 22:52:07 +02:00
except Exception as e:
print(f"ERROR: Strategy execution failed: {{ e }} ")
raise SystemExit(1)
2026-04-06 12:11:49 +02:00
2026-04-06 19:42:59 +02:00
if 'signal' not in dir():
print("ERROR: No signal generated")
2026-04-17 22:52:07 +02:00
raise SystemExit(1)
2026-04-07 06:46:53 +02:00
2026-04-17 22:52:07 +02:00
signal.fillna(0).to_pickle('signal.pkl')
2026-04-06 19:42:59 +02:00
"""
2026-04-17 22:52:07 +02:00
2026-04-06 19:42:59 +02:00
with tempfile . TemporaryDirectory () as td :
tdp = Path ( td )
2026-05-16 19:06:09 +02:00
close . to_pickle ( str ( tdp / "close.pkl" ))
2026-04-19 12:53:00 +02:00
if not OHLCV_ONLY and factors_df is not None :
2026-05-16 19:06:09 +02:00
factors_df . to_pickle ( str ( tdp / "factors.pkl" ))
( tdp / "run.py" ) . write_text ( script )
2026-04-17 22:52:07 +02:00
2026-04-06 19:42:59 +02:00
try :
result = subprocess . run (
2026-05-16 19:06:09 +02:00
[ "python" , "run.py" ],
2026-04-09 08:15:49 +02:00
capture_output = True , text = True , timeout = 60 ,
2026-05-16 19:06:09 +02:00
cwd = str ( tdp ),
2026-04-06 19:42:59 +02:00
)
if result . returncode != 0 :
2026-05-16 19:06:09 +02:00
return { "status" : "failed" , "reason" : ( result . stderr or result . stdout )[: 200 ]}
2026-04-17 22:52:07 +02:00
2026-05-16 19:06:09 +02:00
signal = pd . read_pickle ( tdp / "signal.pkl" )
2026-04-06 19:42:59 +02:00
except subprocess . TimeoutExpired :
2026-05-16 19:06:09 +02:00
return { "status" : "failed" , "reason" : "Timeout (60s)" }
2026-04-06 19:42:59 +02:00
except Exception as e :
2026-05-16 19:06:09 +02:00
return { "status" : "failed" , "reason" : str ( e )[: 200 ]}
2026-04-06 19:42:59 +02:00
2026-04-18 15:21:19 +02:00
# Main process: FTMO-realistic backtest (leverage + daily/total loss limits).
from rdagent.components.backtesting.vbt_backtest import backtest_signal_ftmo
2026-04-17 22:52:07 +02:00
common = close . index . intersection ( signal . index )
if len ( common ) < 100 :
2026-05-16 19:06:09 +02:00
return { "status" : "failed" , "reason" : f "Not enough aligned data ( { len ( common ) } bars)" }
2026-04-17 22:52:07 +02:00
2026-04-18 15:21:19 +02:00
close_a = close . loc [ common ]
2026-04-17 22:52:07 +02:00
signal_a = signal . reindex ( common ) . fillna ( 0 )
fwd_returns = close_a . pct_change ( FORWARD_BARS ) . shift ( - FORWARD_BARS )
2026-04-19 14:54:56 +02:00
from rdagent.components.backtesting.vbt_backtest import OOS_START_DEFAULT
2026-04-18 15:21:19 +02:00
return backtest_signal_ftmo (
2026-04-17 22:52:07 +02:00
close = close_a ,
signal = signal_a ,
txn_cost_bps = TXN_COST_BPS ,
forward_returns = fwd_returns ,
2026-04-19 14:54:56 +02:00
oos_start = OOS_START_DEFAULT ,
2026-04-22 20:51:07 +02:00
wf_rolling = False , # too slow on 2M bars — run via rebacktest script instead
mc_n_permutations = 50 ,
2026-04-17 22:52:07 +02:00
)
2026-04-19 12:53:00 +02:00
# ============================================================================
# Threshold Tuner — relax numeric thresholds until MIN_TRADES is reached
# ============================================================================
def _rescale_thresholds ( code : str , scale : float ) -> str :
"""
Scale numeric literals in the strategy code that look like signal thresholds.
RSI thresholds (30-70 range) are moved toward 50.
Z-score / ratio thresholds (0.0– 3.0 range) are multiplied by scale.
"""
import re
def replace_rsi ( m ):
val = float ( m . group ( 0 ))
# Pull toward 50 by (1-scale) fraction
new_val = 50 + ( val - 50 ) * scale
return f " { new_val : .1f } "
def replace_small ( m ):
val = float ( m . group ( 0 ))
return f " { val * scale : .3f } "
# RSI-style thresholds: integers/floats between 10 and 90
2026-05-16 19:06:09 +02:00
code = re . sub ( r "\b([1-9]\d(?:\.\d+)?)\b" , replace_rsi , code )
2026-04-19 12:53:00 +02:00
# Small float thresholds: 0.05 – 2.99
2026-05-16 19:06:09 +02:00
code = re . sub ( r "\b(0\.\d+|[12]\.\d+)\b" , replace_small , code )
2026-04-19 12:53:00 +02:00
return code
def tune_thresholds ( close , factors_df , code : str ) -> tuple :
"""
Binary-search scale factor (1.0 → 0.05) until n_trades >= MIN_TRADES.
Returns (best_bt_result, tuned_code) where best_bt_result has max Sharpe
among all runs that hit MIN_TRADES.
"""
best_bt , best_code = None , code
for scale in [ 1.0 , 0.7 , 0.5 , 0.35 , 0.2 , 0.1 , 0.05 ]:
tuned = _rescale_thresholds ( code , scale ) if scale < 1.0 else code
bt = run_backtest ( close , factors_df , tuned )
2026-05-16 19:06:09 +02:00
if bt is None or bt . get ( "status" ) != "success" :
2026-04-19 12:53:00 +02:00
continue
2026-05-16 19:06:09 +02:00
trades = bt . get ( "n_trades" , 0 )
sharpe = bt . get ( "sharpe" , - 999 )
2026-04-19 12:53:00 +02:00
if trades >= MIN_TRADES :
2026-05-16 19:06:09 +02:00
if best_bt is None or sharpe > best_bt . get ( "sharpe" , - 999 ):
2026-04-19 12:53:00 +02:00
best_bt = bt
best_code = tuned
break # first scale that hits MIN_TRADES wins (they get looser after this)
return best_bt , best_code
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
# Main Parallel Strategy Generation
2026-04-06 19:42:59 +02:00
# ============================================================================
2026-04-09 08:15:49 +02:00
def main ( target_count = 10 ):
"""Generate strategies in parallel with real backtesting."""
2026-04-16 07:20:08 +02:00
import sys as _sys
_sys . path . insert ( 0 , str ( Path ( __file__ ) . parent . parent ))
from rdagent.log import daily_log as _dlog
_log = _dlog . setup (
"strategies_bt" ,
style = TRADING_STYLE ,
forward_bars = FORWARD_BARS ,
target = target_count ,
workers = N_WORKERS ,
)
2026-04-09 08:15:49 +02:00
console . print ( f " \n [bold cyan] { STYLE_EMOJI } Parallel Strategy Generation[/bold cyan]" )
2026-04-18 15:37:03 +02:00
console . print ( f "[dim]Log: { _log_file_path } [/dim]" )
2026-04-09 08:15:49 +02:00
console . print ( f " Style: { STYLE_DESC } " )
console . print ( f " Forward bars: { FORWARD_BARS } " )
console . print ( f " Target: { target_count } accepted strategies" )
console . print ( f " Workers: { N_WORKERS } \n " )
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
# Load data (main process only)
close = load_ohlcv_data ()
factors = load_available_factors ( 20 )
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
console . print ( f "[green]✓[/green] Loaded { len ( factors ) } factors, { len ( close ) : , } OHLCV bars \n " )
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
# Load factor time-series
factor_data = {}
with Progress ( SpinnerColumn (), TextColumn ( "[bold blue]Loading factors..." ), BarColumn (), TimeElapsedColumn ()) as progress :
task = progress . add_task ( "Loading..." , total = len ( factors ))
for f_info in factors :
2026-05-16 19:06:09 +02:00
safe = f_info [ "name" ] . replace ( "/" , "_" ) . replace ( " \\ " , "_" )[: 150 ]
pf = FACTORS_DIR / "values" / f " { safe } .parquet"
2026-04-09 08:15:49 +02:00
if pf . exists ():
try :
series = pd . read_parquet ( str ( pf )) . iloc [:, 0 ]
2026-05-16 19:06:09 +02:00
factor_data [ f_info [ "name" ]] = series
2026-04-09 08:15:49 +02:00
except :
pass
progress . update ( task , advance = 1 )
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
# Align factors with close prices
all_factor_series = [ factor_data [ n ] for n in factor_data if n in factor_data ]
if not all_factor_series :
console . print ( "[red]✗ No factor data loaded![/red]" )
2026-04-06 19:42:59 +02:00
return
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
df_factors = pd . DataFrame ({ n : factor_data [ n ] for n in factor_data if n in factor_data })
2026-05-16 19:06:09 +02:00
common_idx = close . index . intersection ( df_factors . dropna ( how = "all" ) . index )
2026-04-09 08:15:49 +02:00
close_aligned = close . loc [ common_idx ]
df_aligned = df_factors . loc [ common_idx ]
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
console . print ( f "[green]✓[/green] Aligned { len ( df_aligned ) : , } data points \n " )
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
# Strategy generation loop
accepted = []
feedback_history = []
max_attempts = target_count * 10 # Allow 10x attempts
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
with Progress (
SpinnerColumn (),
TextColumn ( "[bold blue] {task.description} " ),
BarColumn (),
TextColumn ( "[bold green] {task.completed} / {task.total} " ),
TimeElapsedColumn (),
2026-04-17 22:52:07 +02:00
redirect_stdout = True ,
redirect_stderr = True ,
2026-04-09 08:15:49 +02:00
) as progress :
task = progress . add_task ( "Generating..." , total = max_attempts )
2026-05-16 19:06:09 +02:00
2026-04-06 12:11:49 +02:00
for attempt in range ( max_attempts ):
2026-04-09 08:15:49 +02:00
if len ( accepted ) >= target_count :
2026-04-06 12:11:49 +02:00
break
2026-05-16 19:06:09 +02:00
2026-04-19 12:53:00 +02:00
# Select random factor subset (2-5 factors) — empty for OHLCV-only mode
if OHLCV_ONLY :
factor_subset = []
else :
n_factors = random . randint ( 2 , min ( 5 , len ( factors )))
factor_subset = random . sample ( factors , n_factors )
2026-04-09 08:15:49 +02:00
feedback = feedback_history [ - 1 ] if feedback_history and random . random () < 0.7 else None
2026-04-19 12:53:00 +02:00
2026-04-09 08:15:49 +02:00
# Generate in main process (LLM doesn't parallelize well)
gen_result = generate_single_strategy (( attempt , factor_subset , feedback , attempt ))
2026-04-19 12:53:00 +02:00
2026-05-16 19:06:09 +02:00
if gen_result [ "status" ] != "generated" :
2026-04-09 08:15:49 +02:00
progress . update ( task , advance = 1 )
2026-04-06 19:42:59 +02:00
continue
2026-04-19 12:53:00 +02:00
2026-05-16 19:06:09 +02:00
strategy = gen_result [ "strategy" ]
2026-04-19 12:53:00 +02:00
2026-04-09 08:15:49 +02:00
# Backtest (main process - needs data access)
2026-04-19 12:53:00 +02:00
if OHLCV_ONLY :
strat_factors = None
2026-05-16 19:06:09 +02:00
bt_result = run_backtest ( close , None , strategy . get ( "code" , "" ))
2026-04-19 12:53:00 +02:00
else :
2026-05-16 19:06:09 +02:00
strat_factors = df_aligned [[ f for f in strategy . get ( "factor_names" , []) if f in df_aligned . columns ]]
2026-04-19 12:53:00 +02:00
if len ( strat_factors . columns ) < 2 :
progress . update ( task , advance = 1 )
continue
2026-05-16 19:06:09 +02:00
bt_result = run_backtest ( close_aligned , strat_factors , strategy . get ( "code" , "" ))
if bt_result and bt_result . get ( "status" ) == "success" :
ic = bt_result . get ( "ic" , 0 )
sharpe = bt_result . get ( "sharpe" , 0 )
trades = bt_result . get ( "n_trades" , 0 )
dd = bt_result . get ( "max_drawdown" , 0 )
2026-04-19 12:53:00 +02:00
# If too few trades, auto-tune thresholds before giving up
2026-05-16 19:06:09 +02:00
original_code = strategy . get ( "code" , "" )
if trades < MIN_TRADES and bt_result . get ( "status" ) == "success" :
2026-04-19 12:53:00 +02:00
_log . info ( f "TUNING trades= { trades } < { MIN_TRADES } — trying looser thresholds" )
tuned_bt , tuned_code = tune_thresholds (
close if OHLCV_ONLY else close_aligned ,
None if OHLCV_ONLY else strat_factors ,
original_code ,
)
2026-05-16 19:06:09 +02:00
if tuned_bt and tuned_bt . get ( "n_trades" , 0 ) >= MIN_TRADES :
2026-04-19 12:53:00 +02:00
bt_result = tuned_bt
2026-05-16 19:06:09 +02:00
strategy [ "code" ] = tuned_code
ic = bt_result . get ( "ic" , 0 )
sharpe = bt_result . get ( "sharpe" , 0 )
trades = bt_result . get ( "n_trades" , 0 )
dd = bt_result . get ( "max_drawdown" , 0 )
2026-04-19 12:53:00 +02:00
_log . info ( f "TUNED Sharpe= { sharpe : .2f } Trades= { trades } " )
2026-04-19 14:54:56 +02:00
# OOS metrics — mandatory, no fallback to IS values
2026-05-16 19:06:09 +02:00
oos_sharpe = bt_result . get ( "oos_sharpe" )
oos_monthly = bt_result . get ( "oos_monthly_return_pct" )
oos_trades = bt_result . get ( "oos_n_trades" , 0 )
2026-04-19 12:53:00 +02:00
2026-04-19 14:54:56 +02:00
# Reject if OOS data is missing (strategy trained on data without OOS period)
if oos_sharpe is None or oos_monthly is None :
_log . info ( f "REJECTED no OOS data (data ends before { OOS_START_DEFAULT } ?)" )
feedback_history . append ( f "Rejected: no out-of-sample data after { OOS_START_DEFAULT } ." )
progress . update ( task , advance = 1 )
continue
2026-04-19 18:59:00 +02:00
# Monte Carlo p-value (edge significance)
2026-05-16 19:06:09 +02:00
mc_pvalue = bt_result . get ( "mc_pvalue" )
2026-04-19 18:59:00 +02:00
# Rolling walk-forward metrics
2026-05-16 19:06:09 +02:00
wf_consistency = bt_result . get ( "wf_oos_consistency" )
wf_sharpe_mean = bt_result . get ( "wf_oos_sharpe_mean" )
2026-04-19 18:59:00 +02:00
# Check acceptance criteria — OOS must be profitable + statistically significant
mc_ok = mc_pvalue is None or mc_pvalue < 0.20 # lenient: top 20% non-random
wf_ok = wf_consistency is None or wf_consistency >= 0.5 # ≥50% of WF windows profitable
2026-04-22 20:51:07 +02:00
if ( abs ( ic or 0 ) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
2026-05-16 19:06:09 +02:00
and oos_sharpe > 0.0 and oos_monthly > MIN_MONTHLY_RETURN_PCT and mc_ok and wf_ok ):
2026-04-09 08:15:49 +02:00
# ACCEPT
2026-05-16 19:06:09 +02:00
strategy [ "real_backtest" ] = bt_result
strategy [ "metrics" ] = bt_result
strategy [ "summary" ] = {
"sharpe" : sharpe , "max_drawdown" : dd , "win_rate" : bt_result . get ( "win_rate" , 0 ),
"monthly_return_pct" : bt_result . get ( "monthly_return_pct" , 0 ),
"annual_return_pct" : bt_result . get ( "annual_return_pct" , 0 ),
"real_ic" : ic , "real_n_trades" : trades , "real_backtest_status" : "success" ,
"n_bars" : bt_result . get ( "n_bars" , 0 ), "n_months" : bt_result . get ( "n_months" , 0 ),
"trading_style" : TRADING_STYLE ,
"ohlcv_only" : OHLCV_ONLY ,
"engine" : "ftmo_v2" ,
"txn_cost_bps" : TXN_COST_BPS ,
2026-04-19 18:59:00 +02:00
# Walk-forward OOS split
2026-05-16 19:06:09 +02:00
"oos_sharpe" : bt_result . get ( "oos_sharpe" ),
"oos_monthly_return_pct" : bt_result . get ( "oos_monthly_return_pct" ),
"oos_max_drawdown" : bt_result . get ( "oos_max_drawdown" ),
"oos_win_rate" : bt_result . get ( "oos_win_rate" ),
"oos_n_trades" : bt_result . get ( "oos_n_trades" ),
"is_sharpe" : bt_result . get ( "is_sharpe" ),
"is_monthly_return_pct" : bt_result . get ( "is_monthly_return_pct" ),
"oos_start" : bt_result . get ( "oos_start" ),
2026-04-19 18:59:00 +02:00
# Rolling walk-forward
2026-05-16 19:06:09 +02:00
"wf_n_windows" : bt_result . get ( "wf_n_windows" ),
"wf_oos_sharpe_mean" : wf_sharpe_mean ,
"wf_oos_sharpe_std" : bt_result . get ( "wf_oos_sharpe_std" ),
"wf_oos_monthly_return_mean" : bt_result . get ( "wf_oos_monthly_return_mean" ),
"wf_oos_consistency" : wf_consistency ,
2026-04-19 18:59:00 +02:00
# Monte Carlo significance
2026-05-16 19:06:09 +02:00
"mc_pvalue" : mc_pvalue ,
"mc_n_permutations" : bt_result . get ( "mc_n_permutations" ),
2026-04-06 19:42:59 +02:00
}
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
fname = f " { int ( time . time ()) } _ { strategy [ 'strategy_name' ] } .json"
2026-05-16 19:06:09 +02:00
with open ( STRATEGIES_DIR / fname , "w" ) as f :
2026-04-09 08:15:49 +02:00
json . dump ( strategy , f , indent = 2 , ensure_ascii = False )
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
# Generate PDF report
2026-04-07 09:12:04 +02:00
try :
2026-05-09 17:48:22 +02:00
from nexquant_strategy_report import StrategyPerformanceReporter
2026-04-09 08:15:49 +02:00
reporter = StrategyPerformanceReporter ( strategy )
reporter . generate_report ()
except :
pass
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
accepted . append ( strategy )
2026-04-16 07:20:08 +02:00
_log . success ( f "ACCEPTED { strategy [ 'strategy_name' ] } IC= { ic : .4f } Sharpe= { sharpe : .3f } Trades= { trades } DD= { dd : .1% } " )
2026-04-09 08:15:49 +02:00
feedback_history . append ( f "Excellent! IC= { ic : .4f } , Sharpe= { sharpe : .2f } , Trades= { trades } . Try to improve further." )
2026-04-16 07:20:08 +02:00
2026-04-09 08:15:49 +02:00
progress . console . print ( f "[green]✓ Strategy # { len ( accepted ) } :[/green] { strategy [ 'strategy_name' ] } "
f "IC= { ic : .4f } , Sharpe= { sharpe : .3f } , Trades= { trades } , DD= { dd : .1% } " )
2026-04-06 19:42:59 +02:00
else :
2026-04-19 14:54:56 +02:00
oos_info = f "OOS_Sharpe= { oos_sharpe : +.2f } OOS_Mon= { oos_monthly : +.2f } %" if oos_sharpe is not None else ""
2026-04-19 18:59:00 +02:00
mc_info = f " MC_p= { mc_pvalue : .2f } " if mc_pvalue is not None else ""
wf_info = f " WF_consistency= { wf_consistency : .0% } " if wf_consistency is not None else ""
2026-04-23 20:21:21 +02:00
_ic = ic or 0 ; _sh = sharpe or 0 ; _dd = dd or 0
_log . info ( f "REJECTED IC= { _ic : .4f } Sharpe= { _sh : .2f } Trades= { trades } DD= { _dd : .1% } { oos_info }{ mc_info }{ wf_info } " )
2026-04-19 14:54:56 +02:00
feedback_history . append (
2026-04-23 20:21:21 +02:00
f "Failed: IC= { _ic : .4f } , Sharpe= { _sh : .2f } , Trades= { trades } , DD= { _dd : .1% } , "
2026-04-19 18:59:00 +02:00
f "OOS_Sharpe= { oos_sharpe : +.2f } , OOS_Monthly= { oos_monthly : +.2f } %"
+ ( f ", MC_p= { mc_pvalue : .2f } " if mc_pvalue is not None else "" )
+ ( f ", WF_consistency= { wf_consistency : .0% } " if wf_consistency is not None else "" )
+ f ". Need |IC|> { MIN_IC } , Sharpe> { MIN_SHARPE } , Trades> { MIN_TRADES } , "
2026-05-16 19:06:09 +02:00
f "OOS_Sharpe>0, OOS_Monthly> { MIN_MONTHLY_RETURN_PCT } %, MC_p<0.20, WF_consistency≥50%." ,
2026-04-19 14:54:56 +02:00
)
2026-05-16 19:06:09 +02:00
2026-04-09 08:15:49 +02:00
progress . update ( task , advance = 1 )
2026-05-16 19:06:09 +02:00
2026-04-06 12:11:49 +02:00
# Summary
2026-04-16 07:20:08 +02:00
_log . info ( f "DONE accepted= { len ( accepted ) } target= { target_count } " )
2026-05-16 19:06:09 +02:00
for i , s in enumerate ( sorted ( accepted , key = lambda x : x [ "real_backtest" ] . get ( "ic" , 0 ), reverse = True ), 1 ):
bt = s [ "real_backtest" ]
2026-04-16 07:20:08 +02:00
_log . info ( f " # { i } { s [ 'strategy_name' ] } IC= { bt . get ( 'ic' , 0 ) : .4f } Sharpe= { bt . get ( 'sharpe' , 0 ) : .3f } Monthly= { bt . get ( 'monthly_return_pct' , 0 ) : .2f } %" )
2026-04-09 08:15:49 +02:00
console . print ( f " \n [bold green]✓ Generated { len ( accepted ) } / { target_count } accepted strategies[/bold green] \n " )
2026-04-16 07:20:08 +02:00
2026-04-09 08:15:49 +02:00
if accepted :
2026-05-16 19:06:09 +02:00
accepted . sort ( key = lambda x : x [ "real_backtest" ] . get ( "ic" , 0 ), reverse = True )
2026-04-09 08:15:49 +02:00
console . print ( "[bold]Results:[/bold]" )
for i , s in enumerate ( accepted , 1 ):
2026-05-16 19:06:09 +02:00
bt = s [ "real_backtest" ]
2026-04-09 08:15:49 +02:00
console . print ( f " { i } . { s [ 'strategy_name' ] : 30s } IC= { bt . get ( 'ic' , 0 ) : .4f } Sharpe= { bt . get ( 'sharpe' , 0 ) : .3f } "
f "Monthly= { bt . get ( 'monthly_return_pct' , 0 ) : .2f } % Trades= { bt . get ( 'n_trades' , 0 ) } " )
2026-05-16 19:06:09 +02:00
if __name__ == "__main__" :
2026-04-06 12:11:49 +02:00
count = int ( sys . argv [ 1 ]) if len ( sys . argv ) > 1 else 10
main ( count )