mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
1735 lines
64 KiB
Python
1735 lines
64 KiB
Python
|
|
#!/usr/bin/env python
|
|||
|
|
"""
|
|||
|
|
Smart Strategy Generation with Feedback Loop, Parameter Optimization & FTMO Risk Management.
|
|||
|
|
|
|||
|
|
Generates EUR/USD daytrading strategies using LLM with:
|
|||
|
|
- Adaptive feedback loop (IC, trades, drawdown-based suggestions)
|
|||
|
|
- Grid search for optimal parameters (thresholds, SL/TP, trailing stops)
|
|||
|
|
- Mandatory FTMO-compliant risk management layer
|
|||
|
|
- Comprehensive evaluation metrics # nosec
|
|||
|
|
|
|||
|
|
Usage:
|
|||
|
|
python nexquant_smart_strategy_gen.py 10
|
|||
|
|
python nexquant_smart_strategy_gen.py 5 --style daytrading
|
|||
|
|
python nexquant_smart_strategy_gen.py 20 --style swing --max-attempts 200
|
|||
|
|
"""
|
|||
|
|
import os, sys, json, time, math, random, logging, warnings, subprocess # nosec
|
|||
|
|
from pathlib import Path
|
|||
|
|
from datetime import datetime
|
|||
|
|
from itertools import product
|
|||
|
|
from typing import Dict, List, Optional, Tuple, Any
|
|||
|
|
|
|||
|
|
import numpy as np
|
|||
|
|
import pandas as pd
|
|||
|
|
from rich.console import Console
|
|||
|
|
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
|||
|
|
from rich.table import Table
|
|||
|
|
from rich.logging import RichHandler
|
|||
|
|
from dotenv import load_dotenv
|
|||
|
|
|
|||
|
|
warnings.filterwarnings('ignore')
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Configuration & Constants
|
|||
|
|
# ============================================================================
|
|||
|
|
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')
|
|||
|
|
STRATEGIES_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
# Logging setup
|
|||
|
|
LOG_DIR = Path('/home/nico/NexQuant/results/logs')
|
|||
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
log_file = LOG_DIR / f"smart_strategy_gen_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
|||
|
|
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|||
|
|
handlers=[
|
|||
|
|
logging.FileHandler(log_file),
|
|||
|
|
RichHandler(rich_tracebacks=True, show_time=False, show_path=False)
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
logger = logging.getLogger('SmartStrategyGen')
|
|||
|
|
|
|||
|
|
console = Console()
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# FTMO Risk Management Constants
|
|||
|
|
# ============================================================================
|
|||
|
|
class FTMORiskLimits:
|
|||
|
|
"""FTMO-compliant risk management constants."""
|
|||
|
|
MAX_DAILY_LOSS_PCT = 0.05 # 5% max daily loss (FTMO rule)
|
|||
|
|
MAX_PER_TRADE_LOSS_PCT = 0.02 # 2% max per trade
|
|||
|
|
MAX_TOTAL_DRAWDOWN = 0.10 # 10% max overall drawdown
|
|||
|
|
MAX_POSITIONS = 1 # Only 1 position at a time
|
|||
|
|
MIN_RISK_REWARD_RATIO = 2.0 # TP must be at least 2x SL
|
|||
|
|
POSITION_RISK_PCT = 0.01 # 1% risk per trade
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Acceptance Criteria
|
|||
|
|
# ============================================================================
|
|||
|
|
ACCEPTANCE_CRITERIA = {
|
|||
|
|
'daytrading': {
|
|||
|
|
'min_abs_ic': 0.02,
|
|||
|
|
'min_sharpe': 1.0,
|
|||
|
|
'min_trades': 50,
|
|||
|
|
'max_drawdown': -0.15,
|
|||
|
|
'min_win_rate': 0.45,
|
|||
|
|
'min_monthly_return': 0.01,
|
|||
|
|
'max_daily_loss': 0.05,
|
|||
|
|
},
|
|||
|
|
'daytrading': {
|
|||
|
|
'min_abs_ic': 0.02,
|
|||
|
|
'min_sharpe': 0.5,
|
|||
|
|
'min_trades': 10,
|
|||
|
|
'max_drawdown': -0.15,
|
|||
|
|
'min_win_rate': 0.40,
|
|||
|
|
'min_monthly_return': 0.01,
|
|||
|
|
'max_daily_loss': 0.05,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Parameter Grid for Optimization
|
|||
|
|
# ============================================================================
|
|||
|
|
PARAMETER_GRID = {
|
|||
|
|
'threshold_entry': [0.2, 0.3, 0.4, 0.5],
|
|||
|
|
'rolling_window': [10, 20, 30, 60],
|
|||
|
|
'stop_loss': [0.01, 0.015, 0.02], # 1%, 1.5%, 2% (HARD MAX: 2% for FTMO)
|
|||
|
|
'take_profit': [0.02, 0.03, 0.04, 0.06], # 2x-3x SL
|
|||
|
|
'trailing_stop': [0.01, 0.015], # 1%, 1.5% after profit threshold
|
|||
|
|
'trailing_activation': [0.015, 0.02], # Activate trail after 1.5%, 2% profit
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Data Loading (Cached)
|
|||
|
|
# ============================================================================
|
|||
|
|
class DataCache:
|
|||
|
|
"""Thread-safe data cache for OHLCV and factors."""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
self._ohlcv_cache: Optional[pd.Series] = None
|
|||
|
|
self._factors_cache: Optional[List[Dict]] = None
|
|||
|
|
self._factor_data_cache: Dict[str, pd.Series] = {}
|
|||
|
|
|
|||
|
|
def load_ohlcv(self) -> pd.Series:
|
|||
|
|
"""Load OHLCV close prices from HDF5."""
|
|||
|
|
if self._ohlcv_cache is not None:
|
|||
|
|
return self._ohlcv_cache
|
|||
|
|
|
|||
|
|
if not OHLCV_PATH.exists():
|
|||
|
|
raise FileNotFoundError(f"OHLCV data not found: {OHLCV_PATH}")
|
|||
|
|
|
|||
|
|
ohlcv = pd.read_hdf(str(OHLCV_PATH), key='data')
|
|||
|
|
close_col = '$close' if '$close' in ohlcv.columns else 'close' if 'close' in ohlcv.columns else ohlcv.select_dtypes(include=[np.number]).columns[0]
|
|||
|
|
close = ohlcv[close_col].dropna()
|
|||
|
|
|
|||
|
|
# Limit to last 200k bars to avoid OOM during optimization
|
|||
|
|
# (372k bars × 15 combinations = too much memory)
|
|||
|
|
MAX_BARS = 200000
|
|||
|
|
if len(close) > MAX_BARS:
|
|||
|
|
close = close.iloc[-MAX_BARS:]
|
|||
|
|
logger.info(f"Trimmed OHLCV data to last {MAX_BARS:,} bars (from {len(ohlcv[close_col]):,})")
|
|||
|
|
|
|||
|
|
self._ohlcv_cache = close
|
|||
|
|
logger.info(f"Loaded {len(close):,} OHLCV bars")
|
|||
|
|
return close
|
|||
|
|
|
|||
|
|
def load_top_factors(self, top_n: int = 20) -> List[Dict]:
|
|||
|
|
"""Load top factors by IC that have parquet files."""
|
|||
|
|
if self._factors_cache is not None:
|
|||
|
|
return self._factors_cache[:top_n]
|
|||
|
|
|
|||
|
|
factors = []
|
|||
|
|
for f in FACTORS_DIR.glob('*.json'):
|
|||
|
|
try:
|
|||
|
|
data = json.load(open(f))
|
|||
|
|
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})
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.debug(f"Failed to load factor metadata: {f.name} - {e}")
|
|||
|
|
|
|||
|
|
factors.sort(key=lambda x: abs(x['ic']), reverse=True)
|
|||
|
|
self._factors_cache = factors
|
|||
|
|
return factors[:top_n]
|
|||
|
|
|
|||
|
|
def load_factor_timeseries(self, factor_name: str) -> Optional[pd.Series]:
|
|||
|
|
"""Load factor time-series from parquet."""
|
|||
|
|
if factor_name in self._factor_data_cache:
|
|||
|
|
return self._factor_data_cache[factor_name]
|
|||
|
|
|
|||
|
|
safe = factor_name.replace('/', '_').replace('\\', '_')[:150]
|
|||
|
|
pf = FACTORS_DIR / 'values' / f"{safe}.parquet"
|
|||
|
|
|
|||
|
|
if not pf.exists():
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
series = pd.read_parquet(str(pf)).iloc[:, 0]
|
|||
|
|
self._factor_data_cache[factor_name] = series
|
|||
|
|
return series
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.debug(f"Failed to load factor data: {factor_name} - {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
data_cache = DataCache()
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# LLM Setup
|
|||
|
|
# ============================================================================
|
|||
|
|
def setup_llm_env():
|
|||
|
|
"""Setup LLM environment variables with fallback chain."""
|
|||
|
|
load_dotenv(Path(__file__).parent / '.env', override=True)
|
|||
|
|
|
|||
|
|
# Priority 1: OpenRouter (free models with fallback)
|
|||
|
|
router_key = os.getenv('OPENROUTER_API_KEY', '')
|
|||
|
|
if router_key and router_key != 'local':
|
|||
|
|
# Build model fallback chain
|
|||
|
|
models = [
|
|||
|
|
os.getenv('OPENROUTER_MODEL', ''),
|
|||
|
|
os.getenv('OPENROUTER_MODEL_2', ''),
|
|||
|
|
os.getenv('OPENROUTER_MODEL_3', ''),
|
|||
|
|
]
|
|||
|
|
models = [m for m in models if m] # Remove empty
|
|||
|
|
|
|||
|
|
if models:
|
|||
|
|
os.environ['OPENAI_API_KEY'] = router_key
|
|||
|
|
os.environ['OPENAI_API_BASE'] = 'https://openrouter.ai/api/v1'
|
|||
|
|
os.environ['OPENROUTER_MODELS'] = json.dumps(models) # Store for fallback
|
|||
|
|
os.environ['CHAT_MODEL'] = models[0]
|
|||
|
|
logger.info(f"LLM environment configured for OpenRouter: {', '.join(models)}")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Priority 2: Local LLM (llama.cpp)
|
|||
|
|
api_key = os.getenv('OPENAI_API_KEY', '')
|
|||
|
|
api_base = os.getenv('OPENAI_API_BASE', '')
|
|||
|
|
chat_model = os.getenv('CHAT_MODEL', '')
|
|||
|
|
|
|||
|
|
if api_key == 'local' and api_base:
|
|||
|
|
os.environ['OPENAI_API_KEY'] = 'local'
|
|||
|
|
os.environ['OPENAI_API_BASE'] = api_base
|
|||
|
|
os.environ['CHAT_MODEL'] = chat_model or 'openai/qwen3.5-35b'
|
|||
|
|
logger.info(f"LLM environment configured for LOCAL LLM: {api_base}")
|
|||
|
|
else:
|
|||
|
|
logger.warning("No API key found - LLM generation will fail")
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Risk Management Engine
|
|||
|
|
# ============================================================================
|
|||
|
|
class RiskManagementEngine:
|
|||
|
|
"""
|
|||
|
|
FTMO-compliant risk management layer.
|
|||
|
|
|
|||
|
|
Applies stop loss, take profit, trailing stop, and daily loss limits
|
|||
|
|
to strategy returns.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
stop_loss: float = 0.02,
|
|||
|
|
take_profit: float = 0.04,
|
|||
|
|
trailing_stop: float = 0.015,
|
|||
|
|
trailing_activation: float = 0.02,
|
|||
|
|
max_daily_loss: float = 0.05,
|
|||
|
|
max_positions: int = 1,
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Initialize risk management parameters.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
stop_loss : float
|
|||
|
|
Stop loss percentage (default 2%)
|
|||
|
|
take_profit : float
|
|||
|
|
Take profit percentage (default 4%, 2x SL)
|
|||
|
|
trailing_stop : float
|
|||
|
|
Trailing stop distance (default 1.5%)
|
|||
|
|
trailing_activation : float
|
|||
|
|
Profit level to activate trailing stop (default 2%)
|
|||
|
|
max_daily_loss : float
|
|||
|
|
Maximum daily loss percentage (default 5%)
|
|||
|
|
max_positions : int
|
|||
|
|
Maximum concurrent positions (default 1)
|
|||
|
|
"""
|
|||
|
|
# Validate FTMO compliance
|
|||
|
|
if stop_loss > 0.02:
|
|||
|
|
raise ValueError(f"Stop loss {stop_loss:.2%} exceeds FTMO max of 2%")
|
|||
|
|
if take_profit < stop_loss * 2:
|
|||
|
|
raise ValueError(f"Take profit {take_profit:.2%} must be at least 2x SL ({stop_loss*2:.2%})")
|
|||
|
|
if max_daily_loss > 0.05:
|
|||
|
|
raise ValueError(f"Daily loss {max_daily_loss:.2%} exceeds FTMO max of 5%")
|
|||
|
|
|
|||
|
|
self.stop_loss = stop_loss
|
|||
|
|
self.take_profit = take_profit
|
|||
|
|
self.trailing_stop = trailing_stop
|
|||
|
|
self.trailing_activation = trailing_activation
|
|||
|
|
self.max_daily_loss = max_daily_loss
|
|||
|
|
self.max_positions = max_positions
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def risk_reward_ratio(self) -> float:
|
|||
|
|
"""Calculate risk/reward ratio (TP/SL)."""
|
|||
|
|
return self.take_profit / self.stop_loss if self.stop_loss > 0 else 0.0
|
|||
|
|
|
|||
|
|
def apply_risk_management(
|
|||
|
|
self,
|
|||
|
|
signal: pd.Series,
|
|||
|
|
close: pd.Series,
|
|||
|
|
) -> pd.Series:
|
|||
|
|
"""
|
|||
|
|
Apply SL/TP/Trailing stop to signal-based strategy.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
signal : pd.Series
|
|||
|
|
Trading signals (1=LONG, -1=SHORT, 0=NEUTRAL)
|
|||
|
|
close : pd.Series
|
|||
|
|
Close prices
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
pd.Series
|
|||
|
|
Strategy returns after risk management
|
|||
|
|
"""
|
|||
|
|
if len(signal) == 0 or len(close) == 0:
|
|||
|
|
return pd.Series(dtype=float)
|
|||
|
|
|
|||
|
|
# Align indices
|
|||
|
|
common_idx = signal.index.intersection(close.index)
|
|||
|
|
signal = signal.loc[common_idx].fillna(0)
|
|||
|
|
close = close.loc[common_idx]
|
|||
|
|
|
|||
|
|
# Calculate returns
|
|||
|
|
returns = close.pct_change().fillna(0)
|
|||
|
|
strategy_returns = pd.Series(0.0, index=common_idx)
|
|||
|
|
|
|||
|
|
position = 0 # 0=neutral, 1=long, -1=short
|
|||
|
|
entry_price = 0.0
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
daily_pnl = 0.0
|
|||
|
|
current_date = None
|
|||
|
|
|
|||
|
|
for i, idx in enumerate(common_idx):
|
|||
|
|
if i == 0:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Track daily PnL for max daily loss
|
|||
|
|
bar_date = idx.date() if hasattr(idx, 'date') else idx
|
|||
|
|
if current_date is None:
|
|||
|
|
current_date = bar_date
|
|||
|
|
elif bar_date != current_date:
|
|||
|
|
daily_pnl = 0.0 # Reset daily PnL
|
|||
|
|
current_date = bar_date
|
|||
|
|
|
|||
|
|
current_price = close.iloc[i]
|
|||
|
|
prev_price = close.iloc[i - 1]
|
|||
|
|
current_signal = signal.iloc[i]
|
|||
|
|
|
|||
|
|
# Check if we should exit position due to SL/TP/Trailing
|
|||
|
|
if position != 0:
|
|||
|
|
pnl_pct = 0.0
|
|||
|
|
if position == 1: # Long
|
|||
|
|
pnl_pct = (current_price - entry_price) / entry_price
|
|||
|
|
elif position == -1: # Short
|
|||
|
|
pnl_pct = (entry_price - current_price) / entry_price
|
|||
|
|
|
|||
|
|
# Stop Loss hit
|
|||
|
|
if pnl_pct <= -self.stop_loss:
|
|||
|
|
strategy_returns.iloc[i] = -self.stop_loss * position
|
|||
|
|
daily_pnl += -self.stop_loss
|
|||
|
|
position = 0
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Take Profit hit
|
|||
|
|
if pnl_pct >= self.take_profit:
|
|||
|
|
strategy_returns.iloc[i] = self.take_profit * position
|
|||
|
|
daily_pnl += self.take_profit
|
|||
|
|
position = 0
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Trailing Stop (activate after profit threshold)
|
|||
|
|
if pnl_pct >= self.trailing_activation:
|
|||
|
|
highest_profit = max(highest_profit, pnl_pct)
|
|||
|
|
if (highest_profit - pnl_pct) >= self.trailing_stop:
|
|||
|
|
strategy_returns.iloc[i] = pnl_pct * position
|
|||
|
|
daily_pnl += pnl_pct
|
|||
|
|
position = 0
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Normal position PnL
|
|||
|
|
if position == 1:
|
|||
|
|
strategy_returns.iloc[i] = (current_price - prev_price) / prev_price
|
|||
|
|
elif position == -1:
|
|||
|
|
strategy_returns.iloc[i] = -(current_price - prev_price) / prev_price
|
|||
|
|
|
|||
|
|
# Update daily PnL
|
|||
|
|
daily_pnl += strategy_returns.iloc[i]
|
|||
|
|
|
|||
|
|
# Check max daily loss
|
|||
|
|
if daily_pnl <= -self.max_daily_loss:
|
|||
|
|
strategy_returns.iloc[i] = strategy_returns.iloc[i] # Keep the loss
|
|||
|
|
position = 0 # Stop trading for the day
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Enter new position (only if neutral and max positions not exceeded)
|
|||
|
|
if position == 0 and current_signal != 0:
|
|||
|
|
position = int(np.sign(current_signal))
|
|||
|
|
entry_price = current_price
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
|
|||
|
|
return strategy_returns
|
|||
|
|
|
|||
|
|
def get_config(self) -> Dict[str, float]:
|
|||
|
|
"""Return risk management configuration."""
|
|||
|
|
return {
|
|||
|
|
'stop_loss': self.stop_loss,
|
|||
|
|
'take_profit': self.take_profit,
|
|||
|
|
'trailing_stop': self.trailing_stop,
|
|||
|
|
'trailing_activation': self.trailing_activation,
|
|||
|
|
'max_daily_loss': self.max_daily_loss,
|
|||
|
|
'max_positions': self.max_positions,
|
|||
|
|
'risk_reward_ratio': self.take_profit / self.stop_loss,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Strategy Evaluator
|
|||
|
|
# ============================================================================
|
|||
|
|
class StrategyEvaluator:
|
|||
|
|
"""
|
|||
|
|
Comprehensive strategy evaluation with FTMO metrics. # nosec
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, trading_style: str = 'daytrading', forward_bars: int = 96):
|
|||
|
|
self.trading_style = trading_style
|
|||
|
|
self.forward_bars = forward_bars
|
|||
|
|
self.criteria = ACCEPTANCE_CRITERIA.get(trading_style, ACCEPTANCE_CRITERIA['daytrading'])
|
|||
|
|
|
|||
|
|
def evaluate( # nosec
|
|||
|
|
self,
|
|||
|
|
signal: pd.Series,
|
|||
|
|
close: pd.Series,
|
|||
|
|
strategy_returns: pd.Series,
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Evaluate strategy with comprehensive metrics.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
signal : pd.Series
|
|||
|
|
Trading signals
|
|||
|
|
close : pd.Series
|
|||
|
|
Close prices
|
|||
|
|
strategy_returns : pd.Series
|
|||
|
|
Strategy returns after risk management
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
dict
|
|||
|
|
Evaluation metrics dict
|
|||
|
|
"""
|
|||
|
|
if len(strategy_returns) < 10:
|
|||
|
|
return {'status': 'failed', 'reason': 'Insufficient data'}
|
|||
|
|
|
|||
|
|
# Forward returns for IC calculation
|
|||
|
|
fwd_returns = close.pct_change(self.forward_bars).shift(-self.forward_bars)
|
|||
|
|
common_idx = signal.index.intersection(fwd_returns.dropna().index)
|
|||
|
|
|
|||
|
|
if len(common_idx) < 10:
|
|||
|
|
return {'status': 'failed', 'reason': 'Insufficient overlapping data'}
|
|||
|
|
|
|||
|
|
signal_aligned = signal.loc[common_idx]
|
|||
|
|
fwd_aligned = fwd_returns.loc[common_idx]
|
|||
|
|
|
|||
|
|
# IC (Information Coefficient)
|
|||
|
|
ic = signal_aligned.corr(fwd_aligned) if signal_aligned.std() > 0 else 0.0
|
|||
|
|
|
|||
|
|
# Basic metrics
|
|||
|
|
total_bars = len(strategy_returns)
|
|||
|
|
n_signals = int((signal != signal.shift(1)).sum())
|
|||
|
|
n_long = int((signal == 1).sum())
|
|||
|
|
n_short = int((signal == -1).sum())
|
|||
|
|
n_neutral = int((signal == 0).sum())
|
|||
|
|
|
|||
|
|
# Returns metrics
|
|||
|
|
cum_returns = (1 + strategy_returns).cumprod()
|
|||
|
|
total_return = cum_returns.iloc[-1] - 1 if len(cum_returns) > 0 else 0.0
|
|||
|
|
|
|||
|
|
# Annualization factor (assuming 252 trading days, 1440 minutes per day)
|
|||
|
|
bars_per_year = 252 * 1440 / self.forward_bars
|
|||
|
|
n_months = total_bars / (bars_per_year / 12) if total_bars > 0 else 1
|
|||
|
|
|
|||
|
|
if n_months > 0 and (1 + total_return) > 0:
|
|||
|
|
monthly_return = (1 + total_return) ** (1 / n_months) - 1
|
|||
|
|
annual_return = (1 + total_return) ** (12 / n_months) - 1
|
|||
|
|
else:
|
|||
|
|
monthly_return = total_return
|
|||
|
|
annual_return = total_return * 12
|
|||
|
|
|
|||
|
|
# Sharpe Ratio
|
|||
|
|
if strategy_returns.std() > 0:
|
|||
|
|
sharpe = strategy_returns.mean() / strategy_returns.std() * np.sqrt(bars_per_year)
|
|||
|
|
else:
|
|||
|
|
sharpe = 0.0
|
|||
|
|
|
|||
|
|
# Max Drawdown
|
|||
|
|
running_max = cum_returns.expanding().max()
|
|||
|
|
drawdown = (cum_returns - running_max) / running_max.replace(0, np.nan)
|
|||
|
|
max_drawdown = drawdown.min() if len(drawdown) > 0 else 0.0
|
|||
|
|
|
|||
|
|
# Win Rate
|
|||
|
|
active_returns = strategy_returns[strategy_returns != 0]
|
|||
|
|
win_rate = (active_returns > 0).sum() / len(active_returns) if len(active_returns) > 0 else 0.0
|
|||
|
|
|
|||
|
|
# Daily loss analysis (for FTMO compliance)
|
|||
|
|
daily_returns = strategy_returns.groupby(
|
|||
|
|
strategy_returns.index.date if hasattr(strategy_returns.index[0], 'date') else strategy_returns.index
|
|||
|
|
).sum()
|
|||
|
|
max_daily_loss = abs(daily_returns.min()) if len(daily_returns) > 0 else 0.0
|
|||
|
|
|
|||
|
|
# Acceptance check
|
|||
|
|
passed, failed_criteria = self._check_acceptance(
|
|||
|
|
ic=ic if not np.isnan(ic) else 0,
|
|||
|
|
sharpe=sharpe,
|
|||
|
|
n_trades=n_signals,
|
|||
|
|
max_drawdown=max_drawdown,
|
|||
|
|
win_rate=win_rate,
|
|||
|
|
monthly_return=monthly_return,
|
|||
|
|
max_daily_loss=max_daily_loss,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result = {
|
|||
|
|
'status': 'accepted' if passed else 'rejected',
|
|||
|
|
'failed_criteria': failed_criteria,
|
|||
|
|
|
|||
|
|
# Core metrics
|
|||
|
|
'ic': float(ic) if not np.isnan(ic) else 0.0,
|
|||
|
|
'sharpe': float(sharpe),
|
|||
|
|
'max_drawdown': float(max_drawdown),
|
|||
|
|
'win_rate': float(win_rate),
|
|||
|
|
'total_return': float(total_return),
|
|||
|
|
'monthly_return_pct': float(monthly_return * 100),
|
|||
|
|
'annual_return_pct': float(annual_return * 100),
|
|||
|
|
|
|||
|
|
# Trade statistics
|
|||
|
|
'n_trades': n_signals,
|
|||
|
|
'n_long': n_long,
|
|||
|
|
'n_short': n_short,
|
|||
|
|
'n_neutral': n_neutral,
|
|||
|
|
'n_bars': total_bars,
|
|||
|
|
'n_months': float(n_months),
|
|||
|
|
|
|||
|
|
# FTMO compliance
|
|||
|
|
'max_daily_loss': float(max_daily_loss),
|
|||
|
|
'ftmo_compliant': max_daily_loss <= 0.05,
|
|||
|
|
|
|||
|
|
# Signal distribution
|
|||
|
|
'signal_long_pct': n_long / total_bars if total_bars > 0 else 0,
|
|||
|
|
'signal_short_pct': n_short / total_bars if total_bars > 0 else 0,
|
|||
|
|
'signal_neutral_pct': n_neutral / total_bars if total_bars > 0 else 0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
def _check_acceptance(
|
|||
|
|
self,
|
|||
|
|
ic: float,
|
|||
|
|
sharpe: float,
|
|||
|
|
n_trades: int,
|
|||
|
|
max_drawdown: float,
|
|||
|
|
win_rate: float,
|
|||
|
|
monthly_return: float,
|
|||
|
|
max_daily_loss: float,
|
|||
|
|
) -> Tuple[bool, List[str]]:
|
|||
|
|
"""Check if strategy meets acceptance criteria."""
|
|||
|
|
failed = []
|
|||
|
|
|
|||
|
|
if abs(ic) < self.criteria['min_abs_ic']:
|
|||
|
|
failed.append(f"IC too low: {ic:.4f} < {self.criteria['min_abs_ic']}")
|
|||
|
|
|
|||
|
|
if sharpe < self.criteria['min_sharpe']:
|
|||
|
|
failed.append(f"Sharpe too low: {sharpe:.3f} < {self.criteria['min_sharpe']}")
|
|||
|
|
|
|||
|
|
if n_trades < self.criteria['min_trades']:
|
|||
|
|
failed.append(f"Too few trades: {n_trades} < {self.criteria['min_trades']}")
|
|||
|
|
|
|||
|
|
if max_drawdown < self.criteria['max_drawdown']:
|
|||
|
|
failed.append(f"Max drawdown exceeded: {max_drawdown:.1%} < {self.criteria['max_drawdown']}")
|
|||
|
|
|
|||
|
|
if win_rate < self.criteria['min_win_rate']:
|
|||
|
|
failed.append(f"Win rate too low: {win_rate:.1%} < {self.criteria['min_win_rate']}")
|
|||
|
|
|
|||
|
|
if monthly_return < self.criteria['min_monthly_return']:
|
|||
|
|
failed.append(f"Monthly return too low: {monthly_return:.2%} < {self.criteria['min_monthly_return']}")
|
|||
|
|
|
|||
|
|
if max_daily_loss > self.criteria['max_daily_loss']:
|
|||
|
|
failed.append(f"Daily loss exceeded: {max_daily_loss:.2%} > {self.criteria['max_daily_loss']}")
|
|||
|
|
|
|||
|
|
return len(failed) == 0, failed
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Feedback Generator
|
|||
|
|
# ============================================================================
|
|||
|
|
class FeedbackGenerator:
|
|||
|
|
"""
|
|||
|
|
Generate intelligent feedback for LLM strategy improvement.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def generate_feedback(
|
|||
|
|
evaluation: Dict[str, Any], # nosec
|
|||
|
|
factor_list: List[Dict],
|
|||
|
|
attempt: int,
|
|||
|
|
param_config: Optional[Dict] = None,
|
|||
|
|
) -> str:
|
|||
|
|
"""
|
|||
|
|
Generate actionable feedback based on strategy performance.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
evaluation : dict # nosec
|
|||
|
|
Strategy evaluation metrics # nosec
|
|||
|
|
factor_list : list
|
|||
|
|
Available factors with IC values
|
|||
|
|
attempt : int
|
|||
|
|
Current attempt number
|
|||
|
|
param_config : dict, optional
|
|||
|
|
Current parameter configuration
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
str
|
|||
|
|
Feedback string for LLM
|
|||
|
|
"""
|
|||
|
|
ic = evaluation.get('ic', 0) # nosec
|
|||
|
|
sharpe = evaluation.get('sharpe', 0) # nosec
|
|||
|
|
trades = evaluation.get('n_trades', 0) # nosec
|
|||
|
|
dd = evaluation.get('max_drawdown', 0) # nosec
|
|||
|
|
win_rate = evaluation.get('win_rate', 0) # nosec
|
|||
|
|
monthly_ret = evaluation.get('monthly_return_pct', 0) # nosec
|
|||
|
|
failed = evaluation.get('failed_criteria', []) # nosec
|
|||
|
|
|
|||
|
|
feedback_parts = [f"Attempt {attempt} results:"]
|
|||
|
|
|
|||
|
|
# Performance summary
|
|||
|
|
feedback_parts.append(f"IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}, WinRate={win_rate:.1%}, Monthly={monthly_ret:.2f}%")
|
|||
|
|
|
|||
|
|
# Specific suggestions based on failures
|
|||
|
|
if failed:
|
|||
|
|
feedback_parts.append("\nIssues found:")
|
|||
|
|
|
|||
|
|
if any('IC' in f for f in failed):
|
|||
|
|
# Suggest top factors
|
|||
|
|
top_factors = sorted(factor_list, key=lambda x: abs(x['ic']), reverse=True)[:5]
|
|||
|
|
top_factor_names = [f['name'] for f in top_factors]
|
|||
|
|
feedback_parts.append(
|
|||
|
|
f"\n- IC too low ({ic:.4f}). Try different factors. Top factors by IC: {', '.join(top_factor_names)}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if any('trades' in f.lower() for f in failed):
|
|||
|
|
feedback_parts.append(
|
|||
|
|
f"\n- Too few trades ({trades}). Lower thresholds (try 0.2-0.3), use more sensitive factors, or reduce rolling window (10-20 bars)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if any('drawdown' in f.lower() for f in failed):
|
|||
|
|
feedback_parts.append(
|
|||
|
|
f"\n- High drawdown ({dd:.1%}). Add filters (volatility, trend), reduce position size, or tighten stop loss"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if any('sharpe' in f.lower() for f in failed):
|
|||
|
|
feedback_parts.append(
|
|||
|
|
f"\n- Low Sharpe ({sharpe:.2f}). Improve signal quality: combine momentum + mean reversion, add regime filters"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if any('win rate' in f.lower() for f in failed):
|
|||
|
|
feedback_parts.append(
|
|||
|
|
f"\n- Low win rate ({win_rate:.1%}). Try higher take profit (4-6%), or add confirmation filters"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if any('monthly return' in f.lower() for f in failed):
|
|||
|
|
feedback_parts.append(
|
|||
|
|
f"\n- Low monthly return ({monthly_ret:.2%}). Increase signal frequency or use higher-IC factors"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
else:
|
|||
|
|
# Strategy passed - suggest optimization
|
|||
|
|
feedback_parts.append("\n✓ Strategy meets all criteria!")
|
|||
|
|
|
|||
|
|
if sharpe < 1.5:
|
|||
|
|
feedback_parts.append(
|
|||
|
|
f"\nTry optimizing: 1) Test SL=1.5% vs 2% 2) Test TP=3% vs 4% 3) Add trailing stop at 1.5%"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if abs(ic) < 0.05:
|
|||
|
|
top_factors = sorted(factor_list, key=lambda x: abs(x['ic']), reverse=True)[:3]
|
|||
|
|
feedback_parts.append(
|
|||
|
|
f"\nIC could be higher. Consider adding: {', '.join(f['name'] for f in top_factors)}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if param_config:
|
|||
|
|
feedback_parts.append(
|
|||
|
|
f"\nCurrent params: threshold={param_config.get('threshold_entry', 'N/A')}, "
|
|||
|
|
f"window={param_config.get('rolling_window', 'N/A')}, "
|
|||
|
|
f"SL={param_config.get('stop_loss', 'N/A'):.1%}, "
|
|||
|
|
f"TP={param_config.get('take_profit', 'N/A'):.1%}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return " ".join(feedback_parts)
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# LLM Strategy Generator
|
|||
|
|
# ============================================================================
|
|||
|
|
class LLMStrategyGenerator:
|
|||
|
|
"""
|
|||
|
|
Generate trading strategies using LLM with feedback loop.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
setup_llm_env()
|
|||
|
|
|
|||
|
|
def generate(
|
|||
|
|
self,
|
|||
|
|
factor_subset: List[Dict],
|
|||
|
|
feedback: Optional[str] = None,
|
|||
|
|
trading_style: str = 'daytrading',
|
|||
|
|
forward_bars: int = 96,
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Generate a single strategy via qwen CLI.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
factor_subset : list
|
|||
|
|
List of factor dicts with 'name' and 'ic'
|
|||
|
|
feedback : str, optional
|
|||
|
|
Previous feedback for improvement
|
|||
|
|
trading_style : str
|
|||
|
|
'daytrading' or 'swing'
|
|||
|
|
forward_bars : int
|
|||
|
|
Forward return horizon
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
dict
|
|||
|
|
Strategy dict with 'status', 'strategy', 'error'
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
import subprocess # nosec B404
|
|||
|
|
import re
|
|||
|
|
|
|||
|
|
factor_list = ", ".join([f"{f['name']} (IC={f['ic']:.4f})" for f in factor_subset])
|
|||
|
|
factor_names = ", ".join([f['name'] for f in factor_subset])
|
|||
|
|
|
|||
|
|
feedback_text = f" Vorheriges Feedback: {feedback}" if feedback else " Erster Versuch - sei kreativ!"
|
|||
|
|
|
|||
|
|
prompt = f"""Du bist ein quantitativer Trading-Experte. Erzeuge eine EUR/USD Daytrading-Strategie als JSON.
|
|||
|
|
|
|||
|
|
Faktoren: {factor_list}
|
|||
|
|
|
|||
|
|
⚠️ WICHTIG - DU MUSST VIELE SIGNALE GENERIEREN! ⚠️
|
|||
|
|
Die Strategie MUSS mindestens 50+ Trades über den Datensatz erzeugen.
|
|||
|
|
Verwende DESHALB diese Regeln:
|
|||
|
|
1. Schwellenwerte MÜSSEN niedrig sein: 0.1 bis 0.25 (NICHT höher!)
|
|||
|
|
2. Verwende Z-Score Normalisierung mit FENSTERN VON 10-20 Bars (kurz!)
|
|||
|
|
3. Erstelle Signale für JEDE Bar wo der Z-Score den Schwellenwert überschreitet
|
|||
|
|
4. Vermeide zu strenge Filter - die Strategie soll AKTIV traden!
|
|||
|
|
5. Kombiniere 2-4 Faktoren mit GEWICHTEN für diversifizierte Signale
|
|||
|
|
|
|||
|
|
BEISPIEL für gute Signal-Logik:
|
|||
|
|
```python
|
|||
|
|
z = (factor - factor.rolling(15).mean()) / factor.rolling(15).std()
|
|||
|
|
signal = pd.Series(0, index=close.index)
|
|||
|
|
signal[z > 0.15] = 1 # NIEDRIGER Schwellenwert = VIELE Signale!
|
|||
|
|
signal[z < -0.15] = -1 # Auch negative Signale für Shorts
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
❌ SCHLECHT: signal[composite > 0.5] = 1 (zu streng, nur 1 Trade!)
|
|||
|
|
✅ GUT: signal[composite > 0.15] = 1 (niedrig, viele Trades!)
|
|||
|
|
|
|||
|
|
Anforderungen:
|
|||
|
|
- Trading-Stil: Daytrading mit {forward_bars}-Bar Forward Returns
|
|||
|
|
- ZIEL: 50-200+ Trades gesamt (sehr aktiv!)
|
|||
|
|
- Schwellenwerte: 0.1-0.25 (sehr niedrig!)
|
|||
|
|
- Rolling Windows: 10-20 Bars (kurz!)
|
|||
|
|
- Erstelle signal Series mit Werten 1, -1, 0
|
|||
|
|
|
|||
|
|
{feedback_text}
|
|||
|
|
|
|||
|
|
WICHTIG: Das JSON MUSS diese Felder haben:
|
|||
|
|
{{
|
|||
|
|
"strategy_name": "kurzer_Name",
|
|||
|
|
"factor_names": ["faktor1", "faktor2"],
|
|||
|
|
"description": "Ein Satz Beschreibung",
|
|||
|
|
"code": "Python Code der signal Series erzeugt"
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
Der Python Code MUSS mit DataFrame 'factors' und Series 'close' arbeiten und eine Series 'signal' erzeugen.
|
|||
|
|
|
|||
|
|
Antworte NUR mit dem JSON Objekt!"""
|
|||
|
|
|
|||
|
|
# Call qwen CLI
|
|||
|
|
logger.info(f"Calling qwen CLI with prompt ({len(prompt)} chars)...")
|
|||
|
|
result = subprocess.run( # nosec B603
|
|||
|
|
['qwen', '-p', prompt],
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
timeout=120,
|
|||
|
|
cwd=str(Path(__file__).parent)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result.returncode != 0:
|
|||
|
|
logger.error(f"qwen CLI failed: {result.stderr[:300]}")
|
|||
|
|
return {'status': 'error', 'error': f'qwen CLI failed: {result.stderr[:200]}'}
|
|||
|
|
|
|||
|
|
response = result.stdout.strip()
|
|||
|
|
logger.info(f"qwen CLI response ({len(response)} chars)")
|
|||
|
|
|
|||
|
|
# Extract JSON from response
|
|||
|
|
# qwen CLI might output to file OR stdout
|
|||
|
|
# Check if a file was created in results/strategies_new/
|
|||
|
|
import glob
|
|||
|
|
new_files = glob.glob(str(STRATEGIES_DIR / '*.json'))
|
|||
|
|
if new_files:
|
|||
|
|
latest = max(new_files, key=os.path.getmtime)
|
|||
|
|
if os.path.getmtime(latest) > time.time() - 120: # Created in last 120s
|
|||
|
|
logger.info(f"Strategy file found: {latest}")
|
|||
|
|
with open(latest) as f:
|
|||
|
|
raw_data = json.load(f)
|
|||
|
|
# Convert qwen CLI format to our format
|
|||
|
|
strategy_data = self._convert_qwen_output(raw_data, factor_subset)
|
|||
|
|
if strategy_data:
|
|||
|
|
return {'status': 'generated', 'strategy': strategy_data}
|
|||
|
|
|
|||
|
|
# Otherwise parse JSON from stdout
|
|||
|
|
# Try to find JSON object in response
|
|||
|
|
json_match = re.search(r'\{[^{}]*"strategy_name"[^{}]*\}', response, re.DOTALL)
|
|||
|
|
if json_match:
|
|||
|
|
strategy_str = json_match.group()
|
|||
|
|
raw_data = json.loads(strategy_str)
|
|||
|
|
else:
|
|||
|
|
# Try to parse entire response as JSON
|
|||
|
|
raw_data = json.loads(response)
|
|||
|
|
|
|||
|
|
# Convert to our format
|
|||
|
|
strategy_data = self._convert_qwen_output(raw_data, factor_subset)
|
|||
|
|
if not strategy_data:
|
|||
|
|
return {'status': 'invalid', 'error': 'Could not convert qwen output'}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'status': 'generated',
|
|||
|
|
'strategy': strategy_data,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
except subprocess.TimeoutExpired: # nosec
|
|||
|
|
return {'status': 'error', 'error': 'qwen CLI timeout (120s)'}
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"qwen CLI generation failed: {e}")
|
|||
|
|
return {'status': 'error', 'error': str(e)[:300]}
|
|||
|
|
|
|||
|
|
def _convert_qwen_output(self, raw_data: Dict, factors: List[Dict]) -> Optional[Dict]:
|
|||
|
|
"""
|
|||
|
|
Convert qwen CLI output format to our standard format.
|
|||
|
|
|
|||
|
|
qwen CLI may output:
|
|||
|
|
- code as string with literal \n
|
|||
|
|
- Different field names (name vs strategy_name)
|
|||
|
|
- Nested structures
|
|||
|
|
|
|||
|
|
We need:
|
|||
|
|
- strategy_name: str
|
|||
|
|
- factor_names: List[str]
|
|||
|
|
- description: str
|
|||
|
|
- code: str (executable Python with real newlines) # nosec
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# Extract strategy name
|
|||
|
|
strategy_name = raw_data.get('strategy_name') or raw_data.get('name', 'UnknownStrategy')
|
|||
|
|
|
|||
|
|
# Extract factor names
|
|||
|
|
factor_names = raw_data.get('factor_names', [])
|
|||
|
|
if not factor_names:
|
|||
|
|
# Use factors from the generation request
|
|||
|
|
factor_names = [f['name'] for f in factors[:3]]
|
|||
|
|
|
|||
|
|
# Extract description
|
|||
|
|
description = raw_data.get('description', raw_data.get('desc', 'Generated strategy'))
|
|||
|
|
|
|||
|
|
# Extract and clean code
|
|||
|
|
code = raw_data.get('code', '')
|
|||
|
|
if not code:
|
|||
|
|
# Try to find code in nested structures
|
|||
|
|
if 'strategy' in raw_data:
|
|||
|
|
code = raw_data['strategy'].get('code', '')
|
|||
|
|
elif 'logic' in raw_data:
|
|||
|
|
code = raw_data['logic'].get('code', '')
|
|||
|
|
|
|||
|
|
# Unescape code (convert literal \n to real newlines)
|
|||
|
|
if code:
|
|||
|
|
code = code.replace('\\n', '\n').replace('\\"', '"').replace('\\\\', '\\')
|
|||
|
|
# Remove leading/trailing quotes if present
|
|||
|
|
if code.startswith('"') and code.endswith('"'):
|
|||
|
|
code = code[1:-1]
|
|||
|
|
if code.startswith("'") and code.endswith("'"):
|
|||
|
|
code = code[1:-1]
|
|||
|
|
# Ensure variable name consistency: factors_df → factors
|
|||
|
|
code = code.replace('factors_df', 'factors')
|
|||
|
|
|
|||
|
|
# Validate we have what we need
|
|||
|
|
if not code or not strategy_name:
|
|||
|
|
logger.warning(f"Missing required fields: name={strategy_name}, code={'yes' if code else 'no'}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'strategy_name': strategy_name,
|
|||
|
|
'factor_names': factor_names,
|
|||
|
|
'description': description,
|
|||
|
|
'code': code,
|
|||
|
|
}
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Failed to convert qwen output: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Backtest Runner
|
|||
|
|
# ============================================================================
|
|||
|
|
class BacktestRunner:
|
|||
|
|
"""
|
|||
|
|
Run backtests in isolated subprocess with risk management. # nosec
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def run(
|
|||
|
|
close: pd.Series,
|
|||
|
|
factors_df: pd.DataFrame,
|
|||
|
|
strategy_code: str,
|
|||
|
|
risk_config: Dict[str, float],
|
|||
|
|
forward_bars: int = 96,
|
|||
|
|
) -> Optional[Dict[str, Any]]:
|
|||
|
|
"""
|
|||
|
|
Run strategy backtest with risk management.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
close : pd.Series
|
|||
|
|
Close prices
|
|||
|
|
factors_df : pd.DataFrame
|
|||
|
|
Factor values DataFrame
|
|||
|
|
strategy_code : str
|
|||
|
|
Python code string for signal generation
|
|||
|
|
risk_config : dict
|
|||
|
|
Risk management configuration (SL, TP, trailing, etc.)
|
|||
|
|
forward_bars : int
|
|||
|
|
Forward return horizon
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
dict or None
|
|||
|
|
Backtest results dict or None on failure
|
|||
|
|
"""
|
|||
|
|
# Build backtest script with risk management
|
|||
|
|
risk_code = f"""
|
|||
|
|
# Risk Management Configuration
|
|||
|
|
STOP_LOSS = {risk_config['stop_loss']}
|
|||
|
|
TAKE_PROFIT = {risk_config['take_profit']}
|
|||
|
|
TRAILING_STOP = {risk_config['trailing_stop']}
|
|||
|
|
TRAILING_ACTIVATION = {risk_config['trailing_activation']}
|
|||
|
|
MAX_DAILY_LOSS = {risk_config['max_daily_loss']}
|
|||
|
|
MAX_POSITIONS = {risk_config['max_positions']}
|
|||
|
|
|
|||
|
|
def apply_risk_management_with_params(signal, close_prices, sl, tp, trailing, trail_activation):
|
|||
|
|
\"\"\"Apply SL/TP/Trailing stop to signals.\"\"\"
|
|||
|
|
if len(signal) == 0 or len(close_prices) == 0:
|
|||
|
|
return pd.Series(0.0, index=signal.index)
|
|||
|
|
|
|||
|
|
common_idx = signal.index.intersection(close_prices.index)
|
|||
|
|
sig = signal.loc[common_idx].fillna(0)
|
|||
|
|
prices = close_prices.loc[common_idx]
|
|||
|
|
|
|||
|
|
strategy_returns = pd.Series(0.0, index=common_idx)
|
|||
|
|
position = 0
|
|||
|
|
entry_price = 0.0
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
daily_pnl = 0.0
|
|||
|
|
current_date = None
|
|||
|
|
|
|||
|
|
for i, idx in enumerate(common_idx):
|
|||
|
|
if i == 0:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
bar_date = idx.date() if hasattr(idx, 'date') else idx
|
|||
|
|
if current_date is None:
|
|||
|
|
current_date = bar_date
|
|||
|
|
elif bar_date != current_date:
|
|||
|
|
daily_pnl = 0.0
|
|||
|
|
current_date = bar_date
|
|||
|
|
|
|||
|
|
current_price = prices.iloc[i]
|
|||
|
|
prev_price = prices.iloc[i - 1]
|
|||
|
|
current_signal = sig.iloc[i]
|
|||
|
|
|
|||
|
|
if position != 0:
|
|||
|
|
pnl_pct = 0.0
|
|||
|
|
if position == 1:
|
|||
|
|
pnl_pct = (current_price - entry_price) / entry_price
|
|||
|
|
elif position == -1:
|
|||
|
|
pnl_pct = (entry_price - current_price) / entry_price
|
|||
|
|
|
|||
|
|
# Stop Loss
|
|||
|
|
if pnl_pct <= -sl:
|
|||
|
|
strategy_returns.iloc[i] = -sl * position
|
|||
|
|
daily_pnl += -sl
|
|||
|
|
position = 0
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Take Profit
|
|||
|
|
if pnl_pct >= tp:
|
|||
|
|
strategy_returns.iloc[i] = tp * position
|
|||
|
|
daily_pnl += tp
|
|||
|
|
position = 0
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Trailing Stop
|
|||
|
|
if pnl_pct >= trail_activation:
|
|||
|
|
highest_profit = max(highest_profit, pnl_pct)
|
|||
|
|
if (highest_profit - pnl_pct) >= trailing:
|
|||
|
|
strategy_returns.iloc[i] = pnl_pct * position
|
|||
|
|
daily_pnl += pnl_pct
|
|||
|
|
position = 0
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Normal PnL
|
|||
|
|
if position == 1:
|
|||
|
|
strategy_returns.iloc[i] = (current_price - prev_price) / prev_price
|
|||
|
|
elif position == -1:
|
|||
|
|
strategy_returns.iloc[i] = -(current_price - prev_price) / prev_price
|
|||
|
|
|
|||
|
|
daily_pnl += strategy_returns.iloc[i]
|
|||
|
|
|
|||
|
|
# Max daily loss
|
|||
|
|
if daily_pnl <= -{risk_config['max_daily_loss']}:
|
|||
|
|
position = 0
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Enter position
|
|||
|
|
if position == 0 and current_signal != 0:
|
|||
|
|
position = int(np.sign(current_signal))
|
|||
|
|
entry_price = current_price
|
|||
|
|
highest_profit = 0.0
|
|||
|
|
|
|||
|
|
return strategy_returns
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
script = f"""
|
|||
|
|
import pandas as pd
|
|||
|
|
import numpy as np
|
|||
|
|
import json
|
|||
|
|
import sys
|
|||
|
|
|
|||
|
|
close = pd.read_pickle('close.pkl') # nosec
|
|||
|
|
factors = pd.read_pickle('factors.pkl') # nosec
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
{chr(10).join(' ' + line for line in strategy_code.split(chr(10)))}
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"ERROR: Strategy execution failed: {{e}}", file=sys.stderr) # nosec
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
if 'signal' not in dir():
|
|||
|
|
print("ERROR: No signal variable created", file=sys.stderr)
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# Apply risk management
|
|||
|
|
{risk_code}
|
|||
|
|
|
|||
|
|
signal = signal.fillna(0)
|
|||
|
|
strategy_returns = apply_risk_management_with_params(signal, close, STOP_LOSS, TAKE_PROFIT, TRAILING_STOP, TRAILING_ACTIVATION)
|
|||
|
|
|
|||
|
|
# Calculate metrics
|
|||
|
|
common_idx = close.index.intersection(signal.index)
|
|||
|
|
close_aligned = close.loc[common_idx]
|
|||
|
|
signal_aligned = signal.loc[common_idx]
|
|||
|
|
fwd_returns = close_aligned.pct_change({forward_bars}).shift(-{forward_bars})
|
|||
|
|
|
|||
|
|
ic = signal_aligned.corr(fwd_returns.dropna()) if signal_aligned.std() > 0 else 0
|
|||
|
|
total_return = (1 + strategy_returns).prod() - 1
|
|||
|
|
cum_returns = (1 + strategy_returns).cumprod()
|
|||
|
|
running_max = cum_returns.expanding().max()
|
|||
|
|
drawdown = (cum_returns - running_max) / running_max.replace(0, np.nan)
|
|||
|
|
max_dd = drawdown.min() if len(drawdown) > 0 else 0
|
|||
|
|
|
|||
|
|
active_returns = strategy_returns[strategy_returns != 0]
|
|||
|
|
win_rate = (active_returns > 0).sum() / len(active_returns) if len(active_returns) > 0 else 0
|
|||
|
|
n_trades = int((signal_aligned != signal_aligned.shift(1)).sum())
|
|||
|
|
|
|||
|
|
bars_per_year = 252 * 1440 / {forward_bars}
|
|||
|
|
if strategy_returns.std() > 0:
|
|||
|
|
sharpe = strategy_returns.mean() / strategy_returns.std() * np.sqrt(bars_per_year)
|
|||
|
|
else:
|
|||
|
|
sharpe = 0
|
|||
|
|
|
|||
|
|
n_bars = len(strategy_returns)
|
|||
|
|
n_months = n_bars / (bars_per_year / 12) if n_bars > 0 else 1
|
|||
|
|
|
|||
|
|
if n_months > 0 and (1 + total_return) > 0:
|
|||
|
|
monthly_return = (1 + total_return) ** (1 / n_months) - 1
|
|||
|
|
annual_return = (1 + total_return) ** (12 / n_months) - 1
|
|||
|
|
else:
|
|||
|
|
monthly_return = total_return
|
|||
|
|
annual_return = total_return * 12
|
|||
|
|
|
|||
|
|
# Daily loss check
|
|||
|
|
daily_returns = strategy_returns.groupby(
|
|||
|
|
strategy_returns.index.date if hasattr(strategy_returns.index[0], 'date') else strategy_returns.index
|
|||
|
|
).sum()
|
|||
|
|
max_daily_loss = abs(daily_returns.min()) if len(daily_returns) > 0 else 0
|
|||
|
|
|
|||
|
|
result = {{
|
|||
|
|
"status": "success",
|
|||
|
|
"ic": float(ic) if not np.isnan(ic) else 0,
|
|||
|
|
"sharpe": float(sharpe),
|
|||
|
|
"max_drawdown": float(max_dd) if not np.isnan(max_dd) else 0,
|
|||
|
|
"win_rate": float(win_rate),
|
|||
|
|
"n_trades": n_trades,
|
|||
|
|
"total_return": float(total_return),
|
|||
|
|
"monthly_return_pct": float(monthly_return * 100),
|
|||
|
|
"annual_return_pct": float(annual_return * 100),
|
|||
|
|
"n_bars": int(n_bars),
|
|||
|
|
"n_months": float(n_months),
|
|||
|
|
"n_long": int((signal_aligned == 1).sum()),
|
|||
|
|
"n_short": int((signal_aligned == -1).sum()),
|
|||
|
|
"n_neutral": int((signal_aligned == 0).sum()),
|
|||
|
|
"max_daily_loss": float(max_daily_loss),
|
|||
|
|
"ftmo_compliant": max_daily_loss <= 0.05,
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
def sanitize_val(v):
|
|||
|
|
if isinstance(v, (np.integer,)): return int(v)
|
|||
|
|
if isinstance(v, (np.floating,)): return float(v)
|
|||
|
|
if isinstance(v, np.bool_): return bool(v)
|
|||
|
|
if isinstance(v, float):
|
|||
|
|
import math
|
|||
|
|
if math.isnan(v): return 0.0
|
|||
|
|
if math.isinf(v): return -999.0 if v < 0 else 999.0
|
|||
|
|
return v
|
|||
|
|
|
|||
|
|
result = {{k: sanitize_val(v) for k, v in result.items()}}
|
|||
|
|
print(json.dumps(result))
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import tempfile
|
|||
|
|
with tempfile.TemporaryDirectory() as td:
|
|||
|
|
td_path = Path(td)
|
|||
|
|
close.to_pickle(str(td_path / 'close.pkl')) # nosec
|
|||
|
|
factors_df.to_pickle(str(td_path / 'factors.pkl')) # nosec
|
|||
|
|
(td_path / 'run.py').write_text(script)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run( # nosec B603
|
|||
|
|
[sys.executable, str(td_path / 'run.py')],
|
|||
|
|
capture_output=True, text=True, timeout=300,
|
|||
|
|
cwd=str(td_path)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result.returncode != 0:
|
|||
|
|
logger.warning(f"Backtest failed: {result.stderr[:200] or result.stdout[:200]}")
|
|||
|
|
return {'status': 'failed', 'reason': result.stderr[:200] or result.stdout[:200]}
|
|||
|
|
|
|||
|
|
for line in result.stdout.strip().split('\n'):
|
|||
|
|
try:
|
|||
|
|
return json.loads(line)
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
return {'status': 'failed', 'reason': 'No valid JSON output'}
|
|||
|
|
|
|||
|
|
except subprocess.TimeoutExpired: # nosec
|
|||
|
|
return {'status': 'failed', 'reason': 'Timeout (90s)'}
|
|||
|
|
except Exception as e:
|
|||
|
|
return {'status': 'failed', 'reason': str(e)[:200]}
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Parameter Optimizer
|
|||
|
|
# ============================================================================
|
|||
|
|
class ParameterOptimizer:
|
|||
|
|
"""
|
|||
|
|
Grid search for optimal strategy parameters.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, max_combinations: int = 50):
|
|||
|
|
"""
|
|||
|
|
Initialize optimizer.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
max_combinations : int
|
|||
|
|
Maximum parameter combinations to test
|
|||
|
|
"""
|
|||
|
|
self.max_combinations = max_combinations
|
|||
|
|
|
|||
|
|
def optimize(
|
|||
|
|
self,
|
|||
|
|
close: pd.Series,
|
|||
|
|
factors_df: pd.DataFrame,
|
|||
|
|
strategy_code: str,
|
|||
|
|
forward_bars: int = 96,
|
|||
|
|
) -> Tuple[Dict[str, float], Dict[str, Any]]:
|
|||
|
|
"""
|
|||
|
|
Optimize strategy parameters via grid search.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
close : pd.Series
|
|||
|
|
Close prices
|
|||
|
|
factors_df : pd.DataFrame
|
|||
|
|
Factor values
|
|||
|
|
strategy_code : str
|
|||
|
|
Strategy Python code
|
|||
|
|
forward_bars : int
|
|||
|
|
Forward return horizon
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
tuple
|
|||
|
|
(best_params, best_result)
|
|||
|
|
"""
|
|||
|
|
# Generate parameter combinations (sample if too many)
|
|||
|
|
all_combinations = list(product(
|
|||
|
|
PARAMETER_GRID['threshold_entry'],
|
|||
|
|
PARAMETER_GRID['rolling_window'],
|
|||
|
|
PARAMETER_GRID['stop_loss'],
|
|||
|
|
PARAMETER_GRID['take_profit'],
|
|||
|
|
PARAMETER_GRID['trailing_stop'],
|
|||
|
|
PARAMETER_GRID['trailing_activation'],
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# Filter invalid combinations (TP must be >= 2x SL)
|
|||
|
|
valid_combinations = [
|
|||
|
|
c for c in all_combinations
|
|||
|
|
if c[3] >= c[2] * 2 # take_profit >= 2 * stop_loss
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Sample if too many
|
|||
|
|
if len(valid_combinations) > self.max_combinations:
|
|||
|
|
valid_combinations = random.sample(valid_combinations, self.max_combinations)
|
|||
|
|
|
|||
|
|
logger.info(f"Testing {len(valid_combinations)} parameter combinations...")
|
|||
|
|
|
|||
|
|
best_result = None
|
|||
|
|
best_params = None
|
|||
|
|
best_score = -np.inf
|
|||
|
|
|
|||
|
|
runner = BacktestRunner()
|
|||
|
|
|
|||
|
|
for idx, (threshold, window, sl, tp, trail, trail_act) in enumerate(valid_combinations):
|
|||
|
|
# Modify strategy code with current parameters
|
|||
|
|
param_code = self._inject_parameters(strategy_code, threshold, window)
|
|||
|
|
|
|||
|
|
# Risk config for this combination
|
|||
|
|
risk_config = {
|
|||
|
|
'stop_loss': sl,
|
|||
|
|
'take_profit': tp,
|
|||
|
|
'trailing_stop': trail,
|
|||
|
|
'trailing_activation': trail_act,
|
|||
|
|
'max_daily_loss': 0.05,
|
|||
|
|
'max_positions': 1,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Run backtest
|
|||
|
|
result = runner.run(close, factors_df, param_code, risk_config, forward_bars)
|
|||
|
|
|
|||
|
|
if result and result.get('status') == 'success':
|
|||
|
|
# Score: prioritize IC and Sharpe, penalize drawdown and low trades
|
|||
|
|
score = (
|
|||
|
|
abs(result.get('ic', 0)) * 10 +
|
|||
|
|
result.get('sharpe', 0) * 2 -
|
|||
|
|
abs(result.get('max_drawdown', 0)) * 5 +
|
|||
|
|
min(result.get('n_trades', 0) / 100, 2)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if score > best_score:
|
|||
|
|
best_score = score
|
|||
|
|
best_params = {
|
|||
|
|
'threshold_entry': threshold,
|
|||
|
|
'rolling_window': window,
|
|||
|
|
'stop_loss': sl,
|
|||
|
|
'take_profit': tp,
|
|||
|
|
'trailing_stop': trail,
|
|||
|
|
'trailing_activation': trail_act,
|
|||
|
|
}
|
|||
|
|
best_result = result
|
|||
|
|
|
|||
|
|
if (idx + 1) % 10 == 0:
|
|||
|
|
logger.info(f" Tested {idx + 1}/{len(valid_combinations)} combinations, best score={best_score:.3f}")
|
|||
|
|
|
|||
|
|
if best_result is None:
|
|||
|
|
logger.warning("No successful backtests found, using default parameters")
|
|||
|
|
best_params = {
|
|||
|
|
'threshold_entry': 0.3,
|
|||
|
|
'rolling_window': 20,
|
|||
|
|
'stop_loss': 0.02,
|
|||
|
|
'take_profit': 0.04,
|
|||
|
|
'trailing_stop': 0.015,
|
|||
|
|
'trailing_activation': 0.02,
|
|||
|
|
}
|
|||
|
|
best_result = {'status': 'failed', 'reason': 'No valid parameters found'}
|
|||
|
|
|
|||
|
|
return best_params, best_result
|
|||
|
|
|
|||
|
|
def _inject_parameters(
|
|||
|
|
self,
|
|||
|
|
strategy_code: str,
|
|||
|
|
threshold: float,
|
|||
|
|
window: int,
|
|||
|
|
) -> str:
|
|||
|
|
"""
|
|||
|
|
Inject parameters into strategy code - DISABLED for stability.
|
|||
|
|
qwen CLI generates code with its own thresholds which work better.
|
|||
|
|
"""
|
|||
|
|
# Don't modify qwen CLI generated code - it already has good parameters
|
|||
|
|
return strategy_code
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# Smart Strategy Generator (Main Class)
|
|||
|
|
# ============================================================================
|
|||
|
|
class SmartStrategyGenerator:
|
|||
|
|
"""
|
|||
|
|
Main strategy generator with feedback loop, optimization, and risk management.
|
|||
|
|
|
|||
|
|
Usage:
|
|||
|
|
generator = SmartStrategyGenerator(trading_style='daytrading')
|
|||
|
|
strategies = generator.generate_strategies(target_count=10)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
trading_style: str = 'daytrading',
|
|||
|
|
forward_bars: Optional[int] = None,
|
|||
|
|
max_attempts: int = 100,
|
|||
|
|
enable_optimization: bool = True,
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Initialize strategy generator.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
trading_style : str
|
|||
|
|
'daytrading' or 'swing'
|
|||
|
|
forward_bars : int, optional
|
|||
|
|
Forward return horizon (auto-detected from style)
|
|||
|
|
max_attempts : int
|
|||
|
|
Maximum generation attempts
|
|||
|
|
enable_optimization : bool
|
|||
|
|
Enable parameter grid search
|
|||
|
|
"""
|
|||
|
|
self.trading_style = trading_style
|
|||
|
|
self.forward_bars = forward_bars or (12 if trading_style == 'daytrading' else 96)
|
|||
|
|
self.max_attempts = max_attempts
|
|||
|
|
self.enable_optimization = enable_optimization
|
|||
|
|
|
|||
|
|
self.llm_generator = LLMStrategyGenerator()
|
|||
|
|
self.evaluator = StrategyEvaluator(trading_style, self.forward_bars) # nosec
|
|||
|
|
self.feedback_gen = FeedbackGenerator()
|
|||
|
|
self.optimizer = ParameterOptimizer(max_combinations=15)
|
|||
|
|
self.backtest_runner = BacktestRunner()
|
|||
|
|
|
|||
|
|
self.factors = data_cache.load_top_factors(20)
|
|||
|
|
self.close = data_cache.load_ohlcv()
|
|||
|
|
|
|||
|
|
# Load factor time-series
|
|||
|
|
self.factor_data = {}
|
|||
|
|
for f_info in self.factors:
|
|||
|
|
series = data_cache.load_factor_timeseries(f_info['name'])
|
|||
|
|
if series is not None:
|
|||
|
|
self.factor_data[f_info['name']] = series
|
|||
|
|
|
|||
|
|
# Align data
|
|||
|
|
all_series = [self.factor_data[n] for n in self.factor_data]
|
|||
|
|
if not all_series:
|
|||
|
|
raise ValueError("No factor data loaded!")
|
|||
|
|
|
|||
|
|
self.df_factors = pd.DataFrame({n: self.factor_data[n] for n in self.factor_data})
|
|||
|
|
self.common_idx = self.close.index.intersection(self.df_factors.dropna(how='all').index)
|
|||
|
|
self.close_aligned = self.close.loc[self.common_idx]
|
|||
|
|
self.df_aligned = self.df_factors.loc[self.common_idx]
|
|||
|
|
|
|||
|
|
self.accepted_strategies: List[Dict] = []
|
|||
|
|
self.feedback_history: List[str] = []
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"SmartStrategyGenerator initialized: style={trading_style}, "
|
|||
|
|
f"forward_bars={self.forward_bars}, factors={len(self.factor_data)}, "
|
|||
|
|
f"bars={len(self.close_aligned):,}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def generate_strategy(
|
|||
|
|
self,
|
|||
|
|
attempt_idx: int,
|
|||
|
|
factor_subset: Optional[List[Dict]] = None,
|
|||
|
|
feedback: Optional[str] = None,
|
|||
|
|
) -> Optional[Dict]:
|
|||
|
|
"""
|
|||
|
|
Generate a single strategy with feedback loop.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
attempt_idx : int
|
|||
|
|
Attempt number (for logging)
|
|||
|
|
factor_subset : list, optional
|
|||
|
|
Subset of factors to use (random if None)
|
|||
|
|
feedback : str, optional
|
|||
|
|
Previous feedback
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
dict or None
|
|||
|
|
Strategy dict or None if failed
|
|||
|
|
"""
|
|||
|
|
# Select factor subset
|
|||
|
|
if factor_subset is None:
|
|||
|
|
n_factors = random.randint(2, min(5, len(self.factors)))
|
|||
|
|
factor_subset = random.sample(self.factors, n_factors)
|
|||
|
|
|
|||
|
|
# Generate strategy via LLM
|
|||
|
|
gen_result = self.llm_generator.generate(
|
|||
|
|
factor_subset=factor_subset,
|
|||
|
|
feedback=feedback,
|
|||
|
|
trading_style=self.trading_style,
|
|||
|
|
forward_bars=self.forward_bars,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if gen_result['status'] != 'generated':
|
|||
|
|
logger.warning(f"Attempt {attempt_idx}: LLM generation failed - {gen_result.get('error', 'Unknown')}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
strategy = gen_result['strategy']
|
|||
|
|
factor_names = strategy.get('factor_names', [])
|
|||
|
|
|
|||
|
|
# Build factors DataFrame
|
|||
|
|
valid_factors = [f for f in factor_names if f in self.df_aligned.columns]
|
|||
|
|
if len(valid_factors) < 2:
|
|||
|
|
logger.warning(f"Attempt {attempt_idx}: Insufficient valid factors ({len(valid_factors)})")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
factors_df = self.df_aligned[valid_factors]
|
|||
|
|
|
|||
|
|
# Default risk config
|
|||
|
|
risk_config = {
|
|||
|
|
'stop_loss': 0.02,
|
|||
|
|
'take_profit': 0.04,
|
|||
|
|
'trailing_stop': 0.015,
|
|||
|
|
'trailing_activation': 0.02,
|
|||
|
|
'max_daily_loss': 0.05,
|
|||
|
|
'max_positions': 1,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Parameter optimization (if enabled)
|
|||
|
|
if self.enable_optimization:
|
|||
|
|
logger.info(f"Attempt {attempt_idx}: Running parameter optimization...")
|
|||
|
|
best_params, opt_result = self.optimizer.optimize(
|
|||
|
|
self.close_aligned, factors_df, strategy['code'], self.forward_bars
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if opt_result.get('status') == 'success':
|
|||
|
|
risk_config.update(best_params)
|
|||
|
|
logger.info(
|
|||
|
|
f" Best params: threshold={best_params['threshold_entry']}, "
|
|||
|
|
f"window={best_params['rolling_window']}, "
|
|||
|
|
f"SL={best_params['stop_loss']:.1%}, TP={best_params['take_profit']:.1%}"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
logger.warning(f" Optimization failed, using default parameters")
|
|||
|
|
|
|||
|
|
# Run final backtest with optimized/default risk config
|
|||
|
|
bt_result = self.backtest_runner.run(
|
|||
|
|
self.close_aligned, factors_df, strategy['code'], risk_config, self.forward_bars
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if bt_result is None or bt_result.get('status') != 'success':
|
|||
|
|
logger.warning(f"Attempt {attempt_idx}: Backtest failed - {bt_result.get('reason', 'Unknown') if bt_result else 'No result'}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# Evaluate strategy
|
|||
|
|
# Reconstruct signal from backtest (approximate)
|
|||
|
|
signal_approx = pd.Series(0, index=self.close_aligned.index[:bt_result.get('n_bars', len(self.close_aligned))])
|
|||
|
|
evaluation = self.evaluator.evaluate( # nosec
|
|||
|
|
signal=signal_approx,
|
|||
|
|
close=self.close_aligned.iloc[:len(signal_approx)],
|
|||
|
|
strategy_returns=pd.Series(dtype=float), # Already computed in backtest
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Use backtest metrics directly for evaluation # nosec
|
|||
|
|
evaluation = { # nosec
|
|||
|
|
'ic': bt_result.get('ic', 0),
|
|||
|
|
'sharpe': bt_result.get('sharpe', 0),
|
|||
|
|
'max_drawdown': bt_result.get('max_drawdown', 0),
|
|||
|
|
'win_rate': bt_result.get('win_rate', 0),
|
|||
|
|
'n_trades': bt_result.get('n_trades', 0),
|
|||
|
|
'monthly_return': bt_result.get('monthly_return_pct', 0) / 100.0,
|
|||
|
|
'max_daily_loss': bt_result.get('max_daily_loss', 0),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Check acceptance
|
|||
|
|
passed, failed_criteria = self.evaluator._check_acceptance(**evaluation) # nosec
|
|||
|
|
evaluation['status'] = 'accepted' if passed else 'rejected' # nosec
|
|||
|
|
evaluation['failed_criteria'] = failed_criteria # nosec
|
|||
|
|
|
|||
|
|
# Generate feedback
|
|||
|
|
feedback = self.feedback_gen.generate_feedback(
|
|||
|
|
evaluation=evaluation, # nosec
|
|||
|
|
factor_list=self.factors,
|
|||
|
|
attempt=attempt_idx,
|
|||
|
|
param_config=risk_config,
|
|||
|
|
)
|
|||
|
|
self.feedback_history.append(feedback)
|
|||
|
|
|
|||
|
|
# Store strategy
|
|||
|
|
strategy['metrics'] = bt_result
|
|||
|
|
strategy['risk_config'] = risk_config
|
|||
|
|
strategy['evaluation'] = evaluation # nosec
|
|||
|
|
strategy['feedback'] = feedback
|
|||
|
|
|
|||
|
|
if passed:
|
|||
|
|
logger.info(
|
|||
|
|
f"✓ Strategy #{len(self.accepted_strategies)+1} ACCEPTED: "
|
|||
|
|
f"IC={evaluation['ic']:.4f}, Sharpe={evaluation['sharpe']:.2f}, " # nosec
|
|||
|
|
f"Trades={evaluation['n_trades']}, DD={evaluation['max_drawdown']:.1%}" # nosec
|
|||
|
|
)
|
|||
|
|
self.accepted_strategies.append(strategy)
|
|||
|
|
else:
|
|||
|
|
logger.info(
|
|||
|
|
f"✗ Strategy REJECTED: {', '.join(failed_criteria[:3])}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return strategy
|
|||
|
|
|
|||
|
|
def generate_strategies(self, target_count: int = 10) -> List[Dict]:
|
|||
|
|
"""
|
|||
|
|
Generate multiple strategies with feedback loop.
|
|||
|
|
|
|||
|
|
Parameters
|
|||
|
|
----------
|
|||
|
|
target_count : int
|
|||
|
|
Number of accepted strategies to generate
|
|||
|
|
|
|||
|
|
Returns
|
|||
|
|
-------
|
|||
|
|
list
|
|||
|
|
List of accepted strategy dicts
|
|||
|
|
"""
|
|||
|
|
console.print(f"\n[bold cyan]🧠 Smart Strategy Generation[/bold cyan]")
|
|||
|
|
console.print(f" Style: {self.trading_style}")
|
|||
|
|
console.print(f" Forward bars: {self.forward_bars}")
|
|||
|
|
console.print(f" Target: {target_count} accepted strategies")
|
|||
|
|
console.print(f" Factors: {len(self.factor_data)}")
|
|||
|
|
console.print(f" Data points: {len(self.close_aligned):,}\n")
|
|||
|
|
|
|||
|
|
max_attempts = min(self.max_attempts, target_count * 15)
|
|||
|
|
accepted = []
|
|||
|
|
|
|||
|
|
with Progress(
|
|||
|
|
SpinnerColumn(),
|
|||
|
|
TextColumn("[bold blue]{task.description}"),
|
|||
|
|
BarColumn(),
|
|||
|
|
TextColumn("[bold green]{task.completed}/{task.total}"),
|
|||
|
|
TimeElapsedColumn(),
|
|||
|
|
) as progress:
|
|||
|
|
task = progress.add_task(f"Generating {self.trading_style} strategies...", total=max_attempts)
|
|||
|
|
|
|||
|
|
for attempt in range(max_attempts):
|
|||
|
|
if len(accepted) >= target_count:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
progress.update(task, description=f"Attempt {attempt+1}...")
|
|||
|
|
|
|||
|
|
# Get feedback from last attempt
|
|||
|
|
feedback = self.feedback_history[-1] if self.feedback_history and random.random() < 0.7 else None
|
|||
|
|
|
|||
|
|
strategy = self.generate_strategy(attempt, feedback=feedback)
|
|||
|
|
|
|||
|
|
if strategy and strategy['evaluation']['status'] == 'accepted': # nosec
|
|||
|
|
accepted.append(strategy)
|
|||
|
|
|
|||
|
|
# Save strategy
|
|||
|
|
self._save_strategy(strategy)
|
|||
|
|
|
|||
|
|
console.print(
|
|||
|
|
f"[green]✓ Strategy #{len(accepted)}:[/green] {strategy['strategy_name']} "
|
|||
|
|
f"IC={strategy['metrics'].get('ic', 0):.4f}, "
|
|||
|
|
f"Sharpe={strategy['metrics'].get('sharpe', 0):.3f}, "
|
|||
|
|
f"Trades={strategy['metrics'].get('n_trades', 0)}, "
|
|||
|
|
f"DD={strategy['metrics'].get('max_drawdown', 0):.1%}, "
|
|||
|
|
f"Monthly={strategy['metrics'].get('monthly_return_pct', 0):.2f}%"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
progress.update(task, advance=1)
|
|||
|
|
|
|||
|
|
# Summary
|
|||
|
|
console.print(f"\n[bold green]✓ Generated {len(accepted)}/{target_count} accepted strategies[/bold green]\n")
|
|||
|
|
|
|||
|
|
if accepted:
|
|||
|
|
accepted.sort(key=lambda x: x['metrics'].get('ic', 0), reverse=True)
|
|||
|
|
|
|||
|
|
table = Table(title=f"Top {len(accepted)} Accepted Strategies")
|
|||
|
|
table.add_column("#", justify="right")
|
|||
|
|
table.add_column("Name")
|
|||
|
|
table.add_column("IC", justify="right")
|
|||
|
|
table.add_column("Sharpe", justify="right")
|
|||
|
|
table.add_column("Trades", justify="right")
|
|||
|
|
table.add_column("Max DD", justify="right")
|
|||
|
|
table.add_column("Monthly %", justify="right")
|
|||
|
|
table.add_column("FTMO", justify="center")
|
|||
|
|
|
|||
|
|
for i, s in enumerate(accepted, 1):
|
|||
|
|
m = s['metrics']
|
|||
|
|
table.add_row(
|
|||
|
|
str(i),
|
|||
|
|
s['strategy_name'],
|
|||
|
|
f"{m.get('ic', 0):.4f}",
|
|||
|
|
f"{m.get('sharpe', 0):.3f}",
|
|||
|
|
str(m.get('n_trades', 0)),
|
|||
|
|
f"{m.get('max_drawdown', 0):.1%}",
|
|||
|
|
f"{m.get('monthly_return_pct', 0):.2f}%",
|
|||
|
|
"✅" if m.get('ftmo_compliant', False) else "❌",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
console.print(table)
|
|||
|
|
|
|||
|
|
return accepted
|
|||
|
|
|
|||
|
|
def _save_strategy(self, strategy: Dict) -> None:
|
|||
|
|
"""Save strategy to JSON file."""
|
|||
|
|
fname = f"{int(time.time())}_{strategy['strategy_name'].replace(' ', '_')[:50]}.json"
|
|||
|
|
fpath = STRATEGIES_DIR / fname
|
|||
|
|
|
|||
|
|
# Convert numpy types for JSON serialization
|
|||
|
|
def convert_numpy(obj):
|
|||
|
|
if isinstance(obj, (np.integer,)):
|
|||
|
|
return int(obj)
|
|||
|
|
elif isinstance(obj, (np.floating,)):
|
|||
|
|
return float(obj)
|
|||
|
|
elif isinstance(obj, np.ndarray):
|
|||
|
|
return obj.tolist()
|
|||
|
|
return obj
|
|||
|
|
|
|||
|
|
strategy_serializable = {k: convert_numpy(v) for k, v in strategy.items()}
|
|||
|
|
|
|||
|
|
with open(fpath, 'w') as f:
|
|||
|
|
json.dump(strategy_serializable, f, indent=2, ensure_ascii=False)
|
|||
|
|
|
|||
|
|
# Generate PDF report if available
|
|||
|
|
try:
|
|||
|
|
from nexquant_strategy_report import StrategyPerformanceReporter
|
|||
|
|
reporter = StrategyPerformanceReporter(strategy)
|
|||
|
|
reporter.generate_report()
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.debug(f"Failed to generate report: {e}")
|
|||
|
|
|
|||
|
|
logger.info(f"Saved strategy: {fpath}")
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# CLI Interface
|
|||
|
|
# ============================================================================
|
|||
|
|
def parse_args():
|
|||
|
|
"""Parse command line arguments."""
|
|||
|
|
import argparse
|
|||
|
|
|
|||
|
|
parser = argparse.ArgumentParser(
|
|||
|
|
description='Smart Strategy Generation with Feedback & Optimization',
|
|||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|||
|
|
epilog="""
|
|||
|
|
Examples:
|
|||
|
|
python nexquant_smart_strategy_gen.py 10
|
|||
|
|
python nexquant_smart_strategy_gen.py 5 --style daytrading
|
|||
|
|
python nexquant_smart_strategy_gen.py 20 --style swing --max-attempts 200
|
|||
|
|
python nexquant_smart_strategy_gen.py 10 --no-optimization
|
|||
|
|
""",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
parser.add_argument(
|
|||
|
|
'count',
|
|||
|
|
type=int,
|
|||
|
|
nargs='?',
|
|||
|
|
default=10,
|
|||
|
|
help='Number of strategies to generate (default: 10)',
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
'--style',
|
|||
|
|
choices=['daytrading', 'swing'],
|
|||
|
|
default='daytrading',
|
|||
|
|
help='Trading style (default: daytrading)',
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
'--forward-bars',
|
|||
|
|
type=int,
|
|||
|
|
default=None,
|
|||
|
|
help='Forward return bars (auto: 12 for daytrading, 96 for swing)',
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
'--max-attempts',
|
|||
|
|
type=int,
|
|||
|
|
default=150,
|
|||
|
|
help='Maximum generation attempts (default: 150)',
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
'--no-optimization',
|
|||
|
|
action='store_true',
|
|||
|
|
help='Disable parameter grid search',
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
'--factors',
|
|||
|
|
type=int,
|
|||
|
|
default=20,
|
|||
|
|
help='Number of top factors to consider (default: 20)',
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return parser.parse_args()
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
"""Main entry point."""
|
|||
|
|
args = parse_args()
|
|||
|
|
|
|||
|
|
console.print(f"\n[bold magenta]{'='*70}[/bold magenta]")
|
|||
|
|
console.print(f"[bold]🤖 PREDIX Smart Strategy Generator[/bold]")
|
|||
|
|
console.print(f"[bold magenta]{'='*70}[/bold magenta]\n")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Initialize generator
|
|||
|
|
generator = SmartStrategyGenerator(
|
|||
|
|
trading_style=args.style,
|
|||
|
|
forward_bars=args.forward_bars,
|
|||
|
|
max_attempts=args.max_attempts,
|
|||
|
|
enable_optimization=not args.no_optimization,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Generate strategies
|
|||
|
|
strategies = generator.generate_strategies(target_count=args.count)
|
|||
|
|
|
|||
|
|
if strategies:
|
|||
|
|
console.print(f"\n[bold green]✓ Success! {len(strategies)} strategies saved to:[/bold green]")
|
|||
|
|
console.print(f" {STRATEGIES_DIR}\n")
|
|||
|
|
else:
|
|||
|
|
console.print(f"\n[bold yellow]⚠ No strategies met acceptance criteria[/bold yellow]")
|
|||
|
|
console.print(f" Try: --max-attempts 200 or --style swing\n")
|
|||
|
|
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
console.print("\n[yellow]Interrupted by user[/yellow]")
|
|||
|
|
sys.exit(0)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.exception(f"Fatal error: {e}")
|
|||
|
|
console.print(f"\n[red]✗ Fatal error: {e}[/red]")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
main()
|