mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
cbe1c52e00
Rename all source files, scripts, tests, documentation, and configuration from Predix/predix to NexQuant/nexquant across the entire codebase.
316 lines
10 KiB
Python
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
|