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

316 lines
10 KiB
Python

"""
NexQuant Strategy Builder - Systematically combine factors into trading strategies.
This module:
1. Loads evaluated factors with time-series values
2. Generates systematic combinations (pairs, triplets, etc.)
3. Evaluates using walk-forward validation
4. Ranks and saves best strategies
Usage:
nexquant build-strategies # Build strategies from top factors
nexquant build-strategies --top 50 # Use top 50 factors
nexquant build-strategies --max-combo 3 # Allow up to 3-factor combinations
"""
import json
import os
from datetime import datetime
from itertools import combinations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from rdagent.log import rdagent_logger as logger
class StrategyCombinator:
"""
Generate systematic factor combinations.
Types:
- Pairs: 2-factor combinations
- Triplets: 3-factor combinations
- Category-based: Combine best from each category
"""
def __init__(self, factors: List[Dict], max_combo_size: int = 2):
"""
Parameters
----------
factors : List[Dict]
List of factor info dicts (with factor_name, ic, category, etc.)
max_combo_size : int
Maximum combination size (2 = pairs, 3 = triplets)
"""
self.factors = factors
self.max_combo_size = max_combo_size
def generate_all(self) -> List[Dict]:
"""Generate all valid combinations up to max_combo_size."""
combos = []
for size in range(2, self.max_combo_size + 1):
for combo in combinations(self.factors, size):
# Filter: Skip if all factors are from same category
categories = [f.get("category", "Unknown") for f in combo]
if len(set(categories)) == 1 and len(categories) > 2:
continue # Skip homogeneous combos > 2
combos.append({
"factors": [f["factor_name"] for f in combo],
"categories": categories,
"size": size,
"avg_ic": np.mean([abs(f.get("ic", 0)) for f in combo]),
})
# Sort by average IC
combos.sort(key=lambda x: x["avg_ic"], reverse=True)
return combos
def generate_diversified(self, target_size: int = 20) -> List[Dict]:
"""Generate diversified combinations (one from each category)."""
# Group by category
by_cat = {}
for f in self.factors:
cat = f.get("category", "Other")
if cat not in by_cat:
by_cat[cat] = []
by_cat[cat].append(f)
# Sort each category by IC
for cat in by_cat:
by_cat[cat].sort(key=lambda x: abs(x.get("ic", 0)), reverse=True)
# Generate cross-category pairs
combos = []
cats = list(by_cat.keys())
for i, cat1 in enumerate(cats):
for cat2 in cats[i+1:]:
# Take best from each category
f1 = by_cat[cat1][0]
f2 = by_cat[cat2][0]
combos.append({
"factors": [f1["factor_name"], f2["factor_name"]],
"categories": [cat1, cat2],
"size": 2,
"avg_ic": np.mean([abs(f1.get("ic", 0)), abs(f2.get("ic", 0))]),
})
combos.sort(key=lambda x: x["avg_ic"], reverse=True)
return combos[:target_size]
class StrategyEvaluator:
"""
Evaluate strategy combinations using walk-forward validation.
"""
def __init__(self, values_dir: Path, cost_bps: float = 1.5):
"""
Parameters
----------
values_dir : Path
Directory containing factor value parquet files
cost_bps : float
Transaction cost in basis points
"""
self.values_dir = values_dir
self.cost_bps = cost_bps
self.cost_pct = cost_bps / 10000
def load_factor_values(self, factor_name: str) -> Optional[pd.Series]:
"""Load factor time-series values from parquet."""
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
parquet_path = self.values_dir / f"{safe_name}.parquet"
if not parquet_path.exists():
return None
try:
series = pd.read_parquet(str(parquet_path))
return series
except Exception as e:
logger.warning(f"Failed to load {factor_name}: {e}")
return None
def evaluate_combo(self, combo: Dict) -> Dict:
"""
Evaluate a factor combination.
Uses simple weighted sum signal and calculates:
- Sharpe ratio
- Max drawdown
- Win rate
- Annualized return
"""
factor_names = combo["factors"]
# Load all factor values
values = {}
for fname in factor_names:
series = self.load_factor_values(fname)
if series is not None:
values[fname] = series
if len(values) < len(factor_names):
return {**combo, "status": "failed", "reason": "Missing factor values"}
# Combine into DataFrame
df = pd.DataFrame(values)
# Align and drop NaN
df = df.dropna()
if len(df) < 100:
return {**combo, "status": "failed", "reason": "Not enough valid data"}
# Calculate combined signal (equal weight for now)
# Normalize each factor to zero mean, unit variance
df_norm = (df - df.mean()) / df.std()
signal = df_norm.mean(axis=1)
# Strategy returns: signal direction * forward returns
# Approximate forward returns from signal changes (no OHLCV in this context)
# Fall back to qlib-style: use signal sign as position, diff as P&L proxy
# This is approximate — real evaluation needs OHLCV data
returns = signal.diff().fillna(0)
# Apply transaction costs
trades = (signal.diff().abs() > 0.1).sum() # Rough trade count
total_cost = trades * self.cost_pct
returns = returns - (total_cost / len(returns))
# Calculate metrics
total_return = returns.sum()
bars_per_year = 252 * 1440
ann_factor = np.sqrt(bars_per_year / 96) # Annualization for 1min data
ann_return = total_return * ann_factor
volatility = returns.std() * ann_factor
sharpe = ann_return / volatility if volatility > 0 else 0
# Max drawdown on equity curve
equity = (1.0 + returns).cumprod()
running_max = equity.expanding().max()
drawdown = (equity - running_max) / running_max.replace(0, np.nan)
max_dd = drawdown.min() if len(drawdown) > 0 else 0
# Win rate
win_rate = (returns > 0).sum() / len(returns) if len(returns) > 0 else 0
return {
**combo,
"status": "success",
"sharpe": float(sharpe),
"annualized_return": float(ann_return),
"max_drawdown": float(max_dd),
"win_rate": float(win_rate),
"volatility": float(volatility),
"num_trades": int(trades),
"calmar_ratio": float(ann_return / abs(max_dd)) if max_dd != 0 else 0,
}
class StrategyBuilder:
"""
Main orchestrator for building strategies from factors.
"""
def __init__(self, results_dir: Optional[Path] = None):
if results_dir is None:
self.project_root = Path(__file__).parent.parent.parent.parent.parent
self.results_dir = self.project_root / "results"
else:
self.results_dir = results_dir
self.factors_dir = self.results_dir / "factors"
self.values_dir = self.factors_dir / "values"
self.strategies_dir = self.results_dir / "strategies"
self.strategies_dir.mkdir(parents=True, exist_ok=True)
def load_evaluated_factors(self, top_n: int = 50) -> List[Dict]:
"""Load top factors from evaluation results."""
if not self.factors_dir.exists():
return []
factors = []
for f in self.factors_dir.glob("*.json"):
try:
with open(f) as fh:
data = json.load(fh)
if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
# Sort by absolute IC
factors.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True)
return factors[:top_n]
def build_strategies(
self,
top_n: int = 50,
max_combo_size: int = 2,
diversified_only: bool = False,
) -> List[Dict]:
"""
Build strategies from factor combinations.
Parameters
----------
top_n : int
Number of top factors to consider
max_combo_size : int
Maximum combination size
diversified_only : bool
If True, only generate cross-category combinations
Returns
-------
List[Dict]
List of evaluated strategies
"""
# 1. Load factors
factors = self.load_evaluated_factors(top_n)
if not factors:
logger.warning("No evaluated factors found.")
return []
logger.info(f"Loaded {len(factors)} top factors.")
# 2. Generate combinations
combinator = StrategyCombinator(factors, max_combo_size)
if diversified_only:
combos = combinator.generate_diversified()
else:
combos = combinator.generate_all()
logger.info(f"Generated {len(combos)} combinations.")
# 3. Evaluate combinations
evaluator = StrategyEvaluator(self.values_dir)
results = []
for combo in combos:
result = evaluator.evaluate_combo(combo)
results.append(result)
# 4. Rank and save
results.sort(key=lambda x: x.get("sharpe", 0), reverse=True)
# 5. Save strategies
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
strategies_file = self.strategies_dir / f"strategies_{timestamp}.json"
with open(strategies_file, "w") as f:
json.dump(results, f, indent=2, default=str)
logger.info(f"Saved {len(results)} strategies to {strategies_file}")
return results