mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
feat: Add AI Strategy Builder (StrategyCoSTEER) - Closed Source
Open Source Changes: - Add prompt loader functions for strategy prompts - Add factor values persistence (parquet) in factor_runner.py - Add CLI command: predix build-strategies-ai - Integrate strategy building into QuantRDLoop (every 50 factors) - Add StrategyBuilder design documentation Closed Source Files (NOT committed, in local/): - strategy_coster.py - Main CoSTEER loop for strategies - strategy_evaluator.py - Walk-forward backtesting - strategy_runner.py - Strategy execution - strategy_discovery_v1.yaml - LLM prompts Usage: predix build-strategies-ai # Build from top 50 factors predix build-strategies-ai -t 100 # Use top 100 factors predix build-strategies-ai -l 10 # 10 improvement loops The system: 1. Loads top factors with time-series values 2. LLM generates strategy hypotheses 3. LLM writes strategy code (entry/exit rules) 4. Backtests strategy with walk-forward validation 5. LLM gets feedback and improves 6. Repeats until profitable strategy found
This commit is contained in:
@@ -92,6 +92,12 @@ Predix/
|
||||
**🔒 CLOSED SOURCE (Local Only - NOT on GitHub):**
|
||||
- `models/local/` - Your improved models (Transformer, TCN, PatchTST, CNN+LSTM)
|
||||
- `prompts/local/` - Your improved prompts (v2.0 optimized)
|
||||
- `rdagent/scenarios/qlib/local/` - Advanced components:
|
||||
- `strategy_coster.py` - StrategyCoSTEER (LLM strategy generation)
|
||||
- `strategy_evaluator.py` - Comprehensive strategy metrics
|
||||
- `strategy_runner.py` - Strategy execution & backtesting
|
||||
- `strategy_discovery_v1.yaml` - LLM prompts for strategy generation
|
||||
- Plus: ml_trainer, portfolio_optimizer, quant_loop_advanced, etc.
|
||||
- `.env` - API keys
|
||||
- `results/` - Backtest results
|
||||
- `git_ignore_folder/` - Trading data
|
||||
|
||||
@@ -0,0 +1,890 @@
|
||||
# StrategyBuilder — Architektur-Design
|
||||
|
||||
## Überblick
|
||||
|
||||
Der **StrategyBuilder** kombiniert existierende Faktoren systematisch zu handelbaren Strategien.
|
||||
Im Gegensatz zum ML-Trainer (der ein einzelnes Modell auf Top-Faktoren trainiert) testet der
|
||||
StrategyBuilder **explizite Kombinationsregeln** mit Walk-Forward-Validierung.
|
||||
|
||||
---
|
||||
|
||||
## 1. Klassen-Design
|
||||
|
||||
### 1.1 StrategyCombinator
|
||||
|
||||
**Zweck:** Generiert systematische Faktorkombinationen nach verschiedenen Strategien.
|
||||
|
||||
```python
|
||||
# rdagent/scenarios/qlib/developer/strategy_builder.py
|
||||
|
||||
class CombinationStrategy(Enum):
|
||||
"""Supported combination methods."""
|
||||
PAIR = "pair" # Top-N pairs by IC product
|
||||
TRIPLET = "triplet" # Top triplets
|
||||
CATEGORY = "category" # All factors of same type
|
||||
TEMPORAL = "temporal" # Session/time-specific combos
|
||||
CUSTOM = "custom" # User-defined combinations
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategySpec:
|
||||
"""Defines a single strategy configuration."""
|
||||
name: str
|
||||
factors: List[str] # Factor names to combine
|
||||
combination_type: str # "weighted_sum", "regime_switch", etc.
|
||||
weighting: str # "equal", "ic_weighted", "risk_parity"
|
||||
metadata: Dict[str, Any] # Additional context (category, session, etc.)
|
||||
|
||||
|
||||
class StrategyCombinator:
|
||||
"""Generate factor combinations systematically."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
factors_db: ResultsDatabase,
|
||||
min_ic: float = 0.02,
|
||||
max_factors_per_strategy: int = 5,
|
||||
) -> None: ...
|
||||
|
||||
def load_valid_factors(self, min_ic: float = 0.02) -> pd.DataFrame:
|
||||
"""Load all factors with IC >= threshold from DB."""
|
||||
...
|
||||
|
||||
def generate_pairs(
|
||||
self,
|
||||
top_n: int = 50,
|
||||
max_correlation: float = 0.7,
|
||||
) -> List[StrategySpec]:
|
||||
"""
|
||||
Generate pairwise combinations.
|
||||
|
||||
Rules:
|
||||
- Take top_n factors by |IC|
|
||||
- Filter pairs with correlation < max_correlation
|
||||
- Score by |IC1 * IC2| (both must have predictive power)
|
||||
- Prefer complementary pairs (one positive IC, one negative)
|
||||
"""
|
||||
...
|
||||
|
||||
def generate_triplets(
|
||||
self,
|
||||
top_n: int = 30,
|
||||
max_pairwise_corr: float = 0.5,
|
||||
) -> List[StrategySpec]:
|
||||
"""
|
||||
Generate triplet combinations.
|
||||
|
||||
Rules:
|
||||
- Top 30 factors by |IC|
|
||||
- All pairwise correlations < max_pairwise_corr
|
||||
- Score by geometric mean of |IC|
|
||||
"""
|
||||
...
|
||||
|
||||
def generate_category_combos(
|
||||
self,
|
||||
category: str,
|
||||
min_factors: int = 2,
|
||||
max_factors: int = 5,
|
||||
) -> List[StrategySpec]:
|
||||
"""
|
||||
Combine all factors within a category.
|
||||
|
||||
Categories (inferred from factor names):
|
||||
- "Momentum": mom_*, trend_*
|
||||
- "Mean Reversion": mean_rev_*, reversal_*
|
||||
- "Volatility": vol_*, std_*
|
||||
- "Session": session_*, intraday_*
|
||||
- "Volume": volume_*, turnover_*
|
||||
"""
|
||||
...
|
||||
|
||||
def generate_temporal_combos(
|
||||
self,
|
||||
session_filters: Dict[str, Callable],
|
||||
) -> List[StrategySpec]:
|
||||
"""
|
||||
Generate session-specific combinations.
|
||||
|
||||
Example strategies:
|
||||
- "London Open": Use momentum factors 07:00-09:00 UTC
|
||||
- "NY Close": Use mean reversion 14:00-16:00 UTC
|
||||
- "Asian Session": Use volatility factors 00:00-06:00 UTC
|
||||
"""
|
||||
...
|
||||
|
||||
def generate_custom_combo(
|
||||
self,
|
||||
factor_names: List[str],
|
||||
weighting: str = "equal",
|
||||
) -> StrategySpec:
|
||||
"""User-defined combination for testing specific hypotheses."""
|
||||
...
|
||||
|
||||
def generate_all(
|
||||
self,
|
||||
strategies: List[CombinationStrategy] = None,
|
||||
) -> List[StrategySpec]:
|
||||
"""
|
||||
Run all enabled combination strategies.
|
||||
|
||||
Default: PAIR + TRIPLET + CATEGORY
|
||||
Returns list of all StrategySpec objects.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 StrategyEvaluator
|
||||
|
||||
**Zweck:** Walk-Forward-Backtesting für Strategien mit Transaktionskosten.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class WalkForwardConfig:
|
||||
"""Walk-forward validation configuration."""
|
||||
train_window: int = 30 # Days for training
|
||||
test_window: int = 5 # Days for out-of-sample testing
|
||||
step_size: int = 5 # Days to slide forward
|
||||
min_train_periods: int = 3 # Minimum windows before first test
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransactionCostModel:
|
||||
"""Realistic transaction cost modeling."""
|
||||
cost_per_trade_bps: float = 1.5 # 1.5 bps per trade
|
||||
slippage_bps: float = 0.5 # Additional slippage
|
||||
min_trade_size: float = 0.01 # Minimum position size
|
||||
|
||||
|
||||
class StrategyMetrics:
|
||||
"""Complete metrics for a validated strategy."""
|
||||
|
||||
def __init__(self, strategy_name: str) -> None: ...
|
||||
|
||||
def update(
|
||||
self,
|
||||
window_idx: int,
|
||||
in_sample_ic: float,
|
||||
out_of_sample_ic: float,
|
||||
oos_sharpe: float,
|
||||
oos_return: float,
|
||||
oos_drawdown: float,
|
||||
n_trades: int,
|
||||
transaction_costs: float,
|
||||
) -> None: ...
|
||||
|
||||
def finalize(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate aggregate metrics:
|
||||
|
||||
- Mean OOS IC
|
||||
- IC decay (IS IC vs OOS IC)
|
||||
- Mean OOS Sharpe
|
||||
- Worst OOS Drawdown
|
||||
- Calmar Ratio (Ann Return / Max DD)
|
||||
- Total transaction costs
|
||||
- Win rate across windows
|
||||
- Consistency score (% windows with positive IC)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class StrategyEvaluator:
|
||||
"""Walk-forward backtesting for strategy combinations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_source: str, # Path to intraday_pv.h5
|
||||
wf_config: WalkForwardConfig = None,
|
||||
cost_model: TransactionCostModel = None,
|
||||
) -> None: ...
|
||||
|
||||
def load_factor_values(
|
||||
self,
|
||||
factor_names: List[str],
|
||||
) -> Dict[str, pd.Series]:
|
||||
"""Load time series values for each factor."""
|
||||
...
|
||||
|
||||
def compute_combined_signal(
|
||||
self,
|
||||
factor_values: Dict[str, pd.Series],
|
||||
weights: Dict[str, float],
|
||||
combination_type: str = "weighted_sum",
|
||||
) -> pd.Series:
|
||||
"""
|
||||
Combine factors into single signal.
|
||||
|
||||
Types:
|
||||
- "weighted_sum": sum(w_i * factor_i)
|
||||
- "regime_switch": use different factors per regime
|
||||
- "timing": use volatility to scale momentum
|
||||
"""
|
||||
...
|
||||
|
||||
def walk_forward_backtest(
|
||||
self,
|
||||
strategy_spec: StrategySpec,
|
||||
) -> StrategyMetrics:
|
||||
"""
|
||||
Run walk-forward validation for a single strategy.
|
||||
|
||||
Process:
|
||||
1. Split time series into rolling windows
|
||||
2. For each window:
|
||||
a. Optimize weights on train period
|
||||
b. Test on out-of-sample period
|
||||
c. Apply transaction costs
|
||||
d. Record metrics
|
||||
3. Aggregate across all windows
|
||||
|
||||
Returns StrategyMetrics with full validation results.
|
||||
"""
|
||||
...
|
||||
|
||||
def backtest_single_window(
|
||||
self,
|
||||
train_data: pd.DataFrame,
|
||||
test_data: pd.DataFrame,
|
||||
strategy_spec: StrategySpec,
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Backtest strategy on single train/test split.
|
||||
|
||||
Steps:
|
||||
1. Compute factor values on train period
|
||||
2. Optimize weights (IC-weighted or risk parity)
|
||||
3. Apply to test period
|
||||
4. Calculate returns with transaction costs
|
||||
5. Return metrics
|
||||
"""
|
||||
...
|
||||
|
||||
def apply_transaction_costs(
|
||||
self,
|
||||
raw_returns: pd.Series,
|
||||
signals: pd.Series,
|
||||
cost_model: TransactionCostModel,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
Deduct transaction costs from returns.
|
||||
|
||||
Cost = (signal changes) * (cost_per_trade + slippage)
|
||||
Only charged when position actually changes.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 StrategySelector
|
||||
|
||||
**Zweck:** Selektiere beste Strategien nach Out-of-Sample-Performance.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class StrategyRanking:
|
||||
"""Ranking criteria for strategies."""
|
||||
primary_metric: str = "oos_sharpe" # oos_sharpe, calmar, oos_ic
|
||||
min_oos_ic: float = 0.02 # Minimum OOS IC
|
||||
max_drawdown: float = -0.15 # Maximum allowed drawdown
|
||||
min_consistency: float = 0.6 # % of windows with positive IC
|
||||
min_windows: int = 3 # Minimum validation windows
|
||||
|
||||
|
||||
class StrategySelector:
|
||||
"""Select and rank best strategies based on walk-forward results."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ranking: StrategyRanking = None,
|
||||
) -> None: ...
|
||||
|
||||
def rank_strategies(
|
||||
self,
|
||||
strategy_results: List[Dict[str, Any]],
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Rank strategies by primary metric.
|
||||
|
||||
Filters:
|
||||
- OOS IC >= min_oos_ic
|
||||
- Max DD <= max_drawdown threshold
|
||||
- Consistency >= min_consistency
|
||||
- At least min_windows validated
|
||||
|
||||
Returns sorted DataFrame with:
|
||||
- strategy_name
|
||||
- oos_sharpe (primary)
|
||||
- oos_ic_mean
|
||||
- ic_decay (IS vs OOS gap)
|
||||
- calmar_ratio
|
||||
- max_drawdown
|
||||
- consistency_score
|
||||
- n_windows
|
||||
- total_transaction_costs
|
||||
"""
|
||||
...
|
||||
|
||||
def select_top_k(
|
||||
self,
|
||||
ranked: pd.DataFrame,
|
||||
k: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top K strategies passing all filters."""
|
||||
...
|
||||
|
||||
def identify_overfitting(
|
||||
self,
|
||||
strategy_results: List[Dict[str, Any]],
|
||||
ic_decay_threshold: float = 0.5,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Flag strategies where OOS IC < 50% of IS IC.
|
||||
Indicates overfitting to training period.
|
||||
"""
|
||||
...
|
||||
|
||||
def recommend_ensemble(
|
||||
self,
|
||||
ranked: pd.DataFrame,
|
||||
max_correlation: float = 0.3,
|
||||
max_strategies: int = 3,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Recommend ensemble of uncorrelated strategies.
|
||||
|
||||
Select up to max_strategies with:
|
||||
- Highest combined Sharpe
|
||||
- Pairwise correlation < max_correlation
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.4 StrategySaver
|
||||
|
||||
**Zweck:** Persistiert Strategien in `results/strategies/`.
|
||||
|
||||
```python
|
||||
class StrategySaver:
|
||||
"""Save validated strategies to results/strategies/."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategies_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
project_root = Path(__file__).parent.parent.parent.parent
|
||||
self.strategies_dir = Path(strategies_dir) if strategies_dir \
|
||||
else project_root / "results" / "strategies"
|
||||
self.strategies_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save_strategy(
|
||||
self,
|
||||
strategy_spec: StrategySpec,
|
||||
metrics: Dict[str, Any],
|
||||
ranking: Dict[str, Any] = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Save complete strategy to JSON.
|
||||
|
||||
JSON structure:
|
||||
{
|
||||
"name": "momentum_mean_rev_pair",
|
||||
"created_at": "2026-04-05T12:00:00",
|
||||
"combination_type": "pair",
|
||||
"factors": ["Momentum_v3", "MeanReversion_v2"],
|
||||
"weights": {"Momentum_v3": 0.63, "MeanReversion_v2": 0.37},
|
||||
"weighting_method": "ic_weighted",
|
||||
|
||||
"walk_forward": {
|
||||
"train_window_days": 30,
|
||||
"test_window_days": 5,
|
||||
"n_windows": 8,
|
||||
"total_test_days": 40
|
||||
},
|
||||
|
||||
"metrics": {
|
||||
"oos_ic_mean": 0.045,
|
||||
"oos_ic_std": 0.012,
|
||||
"is_ic_mean": 0.062,
|
||||
"ic_decay": 0.27,
|
||||
"oos_sharpe": 2.15,
|
||||
"oos_annualized_return": 0.128,
|
||||
"oos_max_drawdown": -0.089,
|
||||
"calmar_ratio": 1.44,
|
||||
"consistency_score": 0.875,
|
||||
"win_rate": 0.58,
|
||||
"total_transaction_costs_bps": 12.4,
|
||||
"net_sharpe": 1.98
|
||||
},
|
||||
|
||||
"per_window_metrics": [
|
||||
{"window": 0, "oos_ic": 0.051, "oos_sharpe": 2.3, ...},
|
||||
{"window": 1, "oos_ic": 0.038, "oos_sharpe": 1.9, ...},
|
||||
...
|
||||
],
|
||||
|
||||
"ranking": {
|
||||
"rank_by_sharpe": 3,
|
||||
"rank_by_ic": 5,
|
||||
"rank_by_calmar": 2,
|
||||
"passes_filters": true
|
||||
}
|
||||
}
|
||||
"""
|
||||
...
|
||||
|
||||
def load_all_strategies(
|
||||
self,
|
||||
min_oos_sharpe: float = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Load all saved strategies, optionally filtered."""
|
||||
...
|
||||
|
||||
def load_best_strategy(self) -> Optional[Dict[str, Any]]:
|
||||
"""Load the single best strategy by OOS Sharpe."""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Kombinations-Logik
|
||||
|
||||
### 2.1 Faktor-Auswahl für Kombinationen
|
||||
|
||||
```python
|
||||
def select_factors_for_combination(
|
||||
factors_df: pd.DataFrame,
|
||||
min_ic: float = 0.02,
|
||||
max_correlation: float = 0.7,
|
||||
) -> Tuple[List[str], pd.DataFrame]:
|
||||
"""
|
||||
Select factors suitable for combination.
|
||||
|
||||
Algorithm:
|
||||
1. Filter: |IC| >= min_ic
|
||||
2. Compute correlation matrix
|
||||
3. Cluster factors by correlation (hierarchical clustering)
|
||||
4. From each cluster, pick factor with highest |IC|
|
||||
5. Return selected factors + correlation matrix
|
||||
|
||||
Rationale:
|
||||
- Avoid combining highly correlated factors (redundant)
|
||||
- Ensure each selected factor has standalone predictive power
|
||||
- Maximize diversity in combinations
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
### 2.2 Pair-Strategie
|
||||
|
||||
```
|
||||
Regel: Kombiniere Faktor A + B wenn:
|
||||
1. |IC_A| >= 0.02 UND |IC_B| >= 0.02
|
||||
2. Korrelation(A, B) < 0.7
|
||||
3. Score = |IC_A * IC_B| * (1 - corr(A, B))
|
||||
|
||||
Priorisiere:
|
||||
- Momentum + Mean Reversion (komplementär)
|
||||
- Volatility + Momentum (Timing)
|
||||
- Session + Hauptfaktor (Filter)
|
||||
```
|
||||
|
||||
### 2.3 Triplet-Strategie
|
||||
|
||||
```
|
||||
Regel: Kombiniere Faktor A + B + C wenn:
|
||||
1. Alle |IC| >= 0.02
|
||||
2. Alle pairwise Korrelationen < 0.5
|
||||
3. Score = (|IC_A| * |IC_B| * |IC_C|)^(1/3) * diversity_factor
|
||||
|
||||
Priorisiere:
|
||||
- Momentum + Mean Reversion + Volatility
|
||||
- Hauptfaktor + Session + Volatility
|
||||
- Drei unkorrelierte Alpha-Faktoren
|
||||
```
|
||||
|
||||
### 2.4 Gewichtungsmethoden
|
||||
|
||||
```python
|
||||
def compute_weights(
|
||||
factor_ics: Dict[str, float],
|
||||
factor_correlations: pd.DataFrame,
|
||||
method: str = "ic_weighted",
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Compute factor weights.
|
||||
|
||||
Methods:
|
||||
|
||||
1. "equal": w_i = 1/N
|
||||
|
||||
2. "ic_weighted": w_i = |IC_i| / sum(|IC|)
|
||||
- Simple, effective when ICs are reliable
|
||||
|
||||
3. "risk_parity":
|
||||
- w_i proportional to 1/vol_i
|
||||
- Equalize risk contribution from each factor
|
||||
- Requires factor return covariance matrix
|
||||
|
||||
4. "sharpe_weighted": w_i = Sharpe_i / sum(Sharpe)
|
||||
- Weight by risk-adjusted performance
|
||||
|
||||
Returns normalized weights summing to 1.0
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Walk-Forward-Validierung
|
||||
|
||||
### 3.1 Schema
|
||||
|
||||
```
|
||||
Zeitachse (Beispiel: 90 Tage Daten):
|
||||
|
||||
[---- Train 30d ----][Test 5d][---- Train 30d ----][Test 5d]...
|
||||
Window 0 Window 1
|
||||
|
||||
Gesamt: ~8 Walks bei 90 Tagen
|
||||
```
|
||||
|
||||
### 3.2 Ablauf pro Window
|
||||
|
||||
```python
|
||||
for window_idx in range(n_windows):
|
||||
# 1. Define train/test periods
|
||||
train_start = window_idx * step_size
|
||||
train_end = train_start + train_window
|
||||
test_start = train_end
|
||||
test_end = test_start + test_window
|
||||
|
||||
# 2. Optimize weights on train period
|
||||
weights = optimize_weights(
|
||||
factor_values[train_start:train_end],
|
||||
forward_returns[train_start:train_end],
|
||||
method=strategy_spec.weighting,
|
||||
)
|
||||
|
||||
# 3. Generate signal on test period
|
||||
signal = compute_combined_signal(
|
||||
factor_values[test_start:test_end],
|
||||
weights,
|
||||
)
|
||||
|
||||
# 4. Calculate returns with costs
|
||||
raw_returns = signal.shift(1) * forward_returns[test_start:test_end]
|
||||
net_returns = apply_transaction_costs(raw_returns, signal, cost_model)
|
||||
|
||||
# 5. Record metrics
|
||||
metrics.update(
|
||||
window_idx=window_idx,
|
||||
in_sample_ic=compute_ic(train_period),
|
||||
out_of_sample_ic=compute_ic(test_period),
|
||||
oos_sharpe=calculate_sharpe(net_returns),
|
||||
oos_drawdown=calculate_max_drawdown(net_returns),
|
||||
n_trades=count_signal_changes(signal),
|
||||
transaction_costs=raw_returns.sum() - net_returns.sum(),
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 Aggregierte Metriken
|
||||
|
||||
```python
|
||||
final_metrics = {
|
||||
# Primary
|
||||
"oos_ic_mean": mean(window_oos_ics),
|
||||
"oos_ic_std": std(window_oos_ics),
|
||||
"oos_sharpe": mean(window_sharpes),
|
||||
|
||||
# Overfitting detection
|
||||
"is_ic_mean": mean(window_is_ics),
|
||||
"ic_decay": 1 - (oos_ic_mean / is_ic_mean), # < 0.5 good
|
||||
|
||||
# Risk
|
||||
"oos_max_drawdown": min(window_drawdowns),
|
||||
"calmar_ratio": annualized_return / abs(max_drawdown),
|
||||
|
||||
# Consistency
|
||||
"consistency_score": sum(ic > 0 for ic in window_oos_ics) / n_windows,
|
||||
|
||||
# Costs
|
||||
"total_transaction_costs_bps": sum(window_costs),
|
||||
"net_sharpe": sharpe_after_costs,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Integrationspunkte mit factor_runner.py
|
||||
|
||||
### 4.1 Wo passt der StrategyBuilder hin?
|
||||
|
||||
```
|
||||
Bestehender Flow (factor_runner.py):
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 1. Hypothesis Gen → Factor Hypothesis │
|
||||
│ 2. Factor Coder → Generate factor code │
|
||||
│ 3. Factor Runner → Docker backtest │
|
||||
│ 4. Protection Check → Risk validation │
|
||||
│ 5. Save to DB → ResultsDatabase │
|
||||
│ 6. Feedback → Guide next hypothesis │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
NEUER Flow (StrategyBuilder):
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 7. StrategyCombinator → Combos │ ← AFTER factor generation
|
||||
│ 8. StrategyEvaluator → Walk-forward │ ← SEPARATE phase
|
||||
│ 9. StrategySelector → Rank strategies │
|
||||
│ 10. StrategySaver → results/strategies/ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 Konkrete Integration
|
||||
|
||||
```python
|
||||
# Option A: Eigenständiger CLI-Befehl (empfohlen)
|
||||
# rdagent/build_strategies --top-n 100 --walk-forward
|
||||
|
||||
# Option B: Integration in QuantRDLoop
|
||||
class QuantRDLoop:
|
||||
def running(self, prev_out):
|
||||
# ... existing factor runner code ...
|
||||
exp = self.factor_runner.develop(prev_out["coding"])
|
||||
|
||||
# NEW: Periodically run strategy builder
|
||||
if self.should_build_strategies():
|
||||
self._run_strategy_builder()
|
||||
|
||||
return exp
|
||||
|
||||
def should_build_strategies(self) -> bool:
|
||||
"""Check if enough factors exist to build strategies."""
|
||||
n_factors = self.trace.get_valid_factor_count()
|
||||
return n_factors >= 100 and self.loop_idx % 50 == 0
|
||||
|
||||
def _run_strategy_builder(self) -> None:
|
||||
"""Trigger strategy building process."""
|
||||
from rdagent.scenarios.qlib.developer.strategy_builder import (
|
||||
StrategyBuilder,
|
||||
)
|
||||
|
||||
builder = StrategyBuilder(
|
||||
db=self.results_db,
|
||||
data_source=self.data_path,
|
||||
)
|
||||
builder.run(top_n=100)
|
||||
```
|
||||
|
||||
### 4.3 Datenabhängigkeiten
|
||||
|
||||
```python
|
||||
# Benötigt von factor_runner.py:
|
||||
# ✅ ResultsDatabase → already exists, factor_runner schreibt dort
|
||||
# ✅ Factor JSON files → already in results/factors/
|
||||
# ✅ Factor values → Müssen aus workspace/result.h5 geladen werden
|
||||
|
||||
# Neue Abhängigkeit:
|
||||
# ⚠️ Factor time series values → Müssen für Walk-Forward verfügbar sein
|
||||
# Lösung: Factor values beim Speichern in DB auch als Parquet schreiben
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration in QuantRDLoop Workflow
|
||||
|
||||
### 5.1 Erweiterte Loop-Phasen
|
||||
|
||||
```
|
||||
Phase 1: Factor Generation (EXISTIEREND)
|
||||
└─ Generate → Code → Backtest → Save to DB
|
||||
└─ Continue until N factors reached (z.B. 500)
|
||||
|
||||
Phase 2: Strategy Building (NEU)
|
||||
└─ Load top factors from DB
|
||||
└─ Generate combinations (pairs, triplets, categories)
|
||||
└─ Walk-forward validation
|
||||
└─ Save strategies to results/strategies/
|
||||
|
||||
Phase 3: Strategy Selection (NEU)
|
||||
└─ Rank by OOS Sharpe
|
||||
└─ Filter by max drawdown, consistency
|
||||
└─ Select top 3 strategies for live trading
|
||||
|
||||
Phase 4: ML Training (EXISTIEREND, optional)
|
||||
└─ Train ML model on top strategies' factors
|
||||
|
||||
Phase 5: Live Trading (ZUKUNFT)
|
||||
└─ Paper trade selected strategies
|
||||
└─ Monitor and adapt
|
||||
```
|
||||
|
||||
### 5.2 Haupt-CLI-Befehl
|
||||
|
||||
```python
|
||||
# rdagent/scenarios/qlib/developer/strategy_builder.py
|
||||
|
||||
class StrategyBuilder:
|
||||
"""Main orchestrator for strategy building process."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: ResultsDatabase,
|
||||
data_source: str,
|
||||
output_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.data_source = data_source
|
||||
self.combinator = StrategyCombinator(db)
|
||||
self.evaluator = StrategyEvaluator(data_source)
|
||||
self.selector = StrategySelector()
|
||||
self.saver = StrategySaver(output_dir)
|
||||
|
||||
def run(
|
||||
self,
|
||||
top_n: int = 100,
|
||||
min_ic: float = 0.02,
|
||||
strategies: List[CombinationStrategy] = None,
|
||||
save: bool = True,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Complete strategy building pipeline.
|
||||
|
||||
Steps:
|
||||
1. Load top N factors from DB
|
||||
2. Generate combinations
|
||||
3. Walk-forward validate each
|
||||
4. Rank and filter
|
||||
5. Save top strategies
|
||||
6. Return ranked results
|
||||
"""
|
||||
logger.info(f"=== Strategy Builder: Top {top_n} factors ===")
|
||||
|
||||
# Step 1: Load factors
|
||||
factors = self.combinator.load_valid_factors(min_ic=min_ic)
|
||||
logger.info(f"Loaded {len(factors)} valid factors")
|
||||
|
||||
# Step 2: Generate combinations
|
||||
combos = self.combinator.generate_all(strategies)
|
||||
logger.info(f"Generated {len(combos)} strategy combinations")
|
||||
|
||||
# Step 3: Walk-forward validate
|
||||
results = []
|
||||
for spec in combos:
|
||||
logger.info(f"Evaluating: {spec.name}")
|
||||
metrics = self.evaluator.walk_forward_backtest(spec)
|
||||
results.append(metrics.finalize())
|
||||
|
||||
# Step 4: Rank
|
||||
ranked = self.selector.rank_strategies(results)
|
||||
|
||||
# Step 5: Save
|
||||
if save:
|
||||
for _, row in ranked.iterrows():
|
||||
spec = next(s for s in combos if s.name == row["strategy_name"])
|
||||
self.saver.save_strategy(spec, row)
|
||||
|
||||
logger.info(f"=== Top 5 Strategies ===")
|
||||
logger.info(ranked.head(5).to_string())
|
||||
|
||||
return ranked
|
||||
|
||||
|
||||
def build_strategies(
|
||||
top_n: int = 100,
|
||||
min_ic: float = 0.02,
|
||||
data_source: str = None,
|
||||
) -> None:
|
||||
"""CLI entry point: rdagent build_strategies"""
|
||||
from rdagent.components.backtesting.results_db import ResultsDatabase
|
||||
|
||||
db = ResultsDatabase()
|
||||
|
||||
if data_source is None:
|
||||
data_source = str(Path(__file__).parent.parent.parent.parent.parent
|
||||
/ "git_ignore_folder"
|
||||
/ "factor_implementation_source_data"
|
||||
/ "intraday_pv.h5")
|
||||
|
||||
builder = StrategyBuilder(db=db, data_source=data_source)
|
||||
ranked = builder.run(top_n=top_n, min_ic=min_ic)
|
||||
|
||||
logger.info(f"\nStrategy building complete. Results in results/strategies/")
|
||||
```
|
||||
|
||||
### 5.3 Config-Erweiterung
|
||||
|
||||
```python
|
||||
# rdagent/app/qlib_rd_loop/conf.py
|
||||
|
||||
@dataclass
|
||||
class StrategyBuilderSetting:
|
||||
"""Configuration for strategy building."""
|
||||
top_n_factors: int = 100
|
||||
min_ic_threshold: float = 0.02
|
||||
max_correlation: float = 0.7
|
||||
train_window_days: int = 30
|
||||
test_window_days: int = 5
|
||||
step_size_days: int = 5
|
||||
transaction_cost_bps: float = 1.5
|
||||
min_oos_sharpe: float = 1.0
|
||||
max_drawdown_threshold: float = -0.15
|
||||
combination_strategies: List[str] = None # ["pair", "triplet", "category"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Datei-Struktur
|
||||
|
||||
```
|
||||
rdagent/scenarios/qlib/developer/
|
||||
└── strategy_builder.py # Hauptmodul (alle Klassen)
|
||||
|
||||
# ODER aufgeteilt:
|
||||
rdagent/scenarios/qlib/developer/
|
||||
└── strategy_builder/
|
||||
├── __init__.py
|
||||
├── combinator.py # StrategyCombinator
|
||||
├── evaluator.py # StrategyEvaluator
|
||||
├── selector.py # StrategySelector
|
||||
├── saver.py # StrategySaver
|
||||
└── builder.py # StrategyBuilder (Orchestrator)
|
||||
|
||||
results/
|
||||
└── strategies/
|
||||
├── momentum_mean_rev_pair.json
|
||||
├── momentum_vol_timing.json
|
||||
├── session_alpha_combo.json
|
||||
└── strategy_ranking.json # Summary aller Strategien
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Nächste Schritte
|
||||
|
||||
1. **Implementierung Phase 1:** StrategyCombinator + einfache Pair-Tests
|
||||
2. **Implementierung Phase 2:** StrategyEvaluator mit Walk-Forward
|
||||
3. **Implementierung Phase 3:** StrategySelector + Saver
|
||||
4. **Integration:** CLI-Befehl `rdagent build_strategies`
|
||||
5. **Validierung:** Top-Strategien gegen Hold-out Periode testen
|
||||
6. **Dashboard:** Web-UI zur Strategie-Anzeige (erweitert)
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Fragen
|
||||
|
||||
- **Factor Values:** Woher kommen die Zeitreihen-Werte für jeden Faktor?
|
||||
- Aktuell: Nur in workspace/result.h5 gespeichert (nicht persistent)
|
||||
- Lösung: Beim Speichern in DB auch als Parquet in results/factors/values/ ablegen
|
||||
|
||||
- **Performance:** 100 Faktoren → ~5000 Pairs → 8 Walks each = 40.000 Backtests
|
||||
- Lösung: Parallelisierung (multiprocessing), Top-1000 Paare vorher filtern
|
||||
|
||||
- **Regime Detection:** Wie erkennen wir Markt-Regimes?
|
||||
- Vorschlag: Volatility-based (high/low vol), Trend-based (uptrend/downtrend)
|
||||
- Später: ML-basiert (HMM, Clustering)
|
||||
@@ -787,6 +787,274 @@ def portfolio_simple(
|
||||
))
|
||||
|
||||
|
||||
@app.command()
|
||||
def build_strategies(
|
||||
top: int = typer.Option(
|
||||
50,
|
||||
"--top", "-n",
|
||||
help="Number of top factors to consider (default: 50)",
|
||||
),
|
||||
max_combo: int = typer.Option(
|
||||
2,
|
||||
"--max-combo", "-c",
|
||||
help="Maximum combination size: 2=pairs, 3=triplets (default: 2)",
|
||||
),
|
||||
diversified: bool = typer.Option(
|
||||
False,
|
||||
"--diversified/-d",
|
||||
help="Only generate cross-category combinations",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Build trading strategies by systematically combining factors.
|
||||
|
||||
This command:
|
||||
1. Loads top evaluated factors
|
||||
2. Generates systematic combinations (pairs, triplets)
|
||||
3. Evaluates each combination using walk-forward validation
|
||||
4. Ranks by Sharpe ratio and saves best strategies
|
||||
|
||||
Examples:
|
||||
predix build-strategies # Build from top 50, pairs only
|
||||
predix build-strategies -n 100 -c 3 # Top 100, up to triplets
|
||||
predix build-strategies -d # Diversified only
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
|
||||
from rdagent.scenarios.qlib.developer.strategy_builder import StrategyBuilder
|
||||
|
||||
console.print(Panel(
|
||||
"[bold cyan]🏗️ Predix Strategy Builder[/bold cyan]\n"
|
||||
"Systematically combining factors into trading strategies",
|
||||
border_style="cyan",
|
||||
))
|
||||
|
||||
builder = StrategyBuilder()
|
||||
|
||||
try:
|
||||
results = builder.build_strategies(
|
||||
top_n=top,
|
||||
max_combo_size=max_combo,
|
||||
diversified_only=diversified,
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f"[bold red]Strategy building failed: {e}[/bold red]")
|
||||
import traceback
|
||||
console.print(traceback.format_exc())
|
||||
return
|
||||
|
||||
if not results:
|
||||
console.print("[yellow]No strategies built. Check if factor values exist.[/yellow]")
|
||||
return
|
||||
|
||||
# Display top strategies
|
||||
successful = [r for r in results if r.get("status") == "success"]
|
||||
|
||||
if successful:
|
||||
table = Table(
|
||||
title=f"Top {min(20, len(successful))} Strategies by Sharpe",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
)
|
||||
table.add_column("#", justify="center", width=4)
|
||||
table.add_column("Factors", width=50)
|
||||
table.add_column("Sharpe", justify="right", width=8)
|
||||
table.add_column("Ann. Ret %", justify="right", width=10)
|
||||
table.add_column("Max DD", justify="right", width=8)
|
||||
table.add_column("Win Rate", justify="right", width=8)
|
||||
|
||||
for i, strat in enumerate(successful[:20], 1):
|
||||
factors_str = " + ".join(strat["factors"][:3])
|
||||
if len(strat["factors"]) > 3:
|
||||
factors_str += f" +{len(strat['factors'])-3}"
|
||||
|
||||
table.add_row(
|
||||
str(i),
|
||||
factors_str,
|
||||
f"{strat.get('sharpe', 0):.4f}",
|
||||
f"{strat.get('annualized_return', 0):.4f}",
|
||||
f"{strat.get('max_drawdown', 0):.4f}",
|
||||
f"{strat.get('win_rate', 0):.2%}",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Summary
|
||||
avg_sharpe = np.mean([s.get("sharpe", 0) for s in successful])
|
||||
best_sharpe = max(s.get("sharpe", 0) for s in successful)
|
||||
avg_dd = np.mean([s.get("max_drawdown", 0) for s in successful])
|
||||
|
||||
console.print(Panel(
|
||||
f"[bold]Strategy Building Summary[/bold]\n"
|
||||
f"Total combinations: {len(results)}\n"
|
||||
f"Successful: {len(successful)}\n"
|
||||
f"Failed: {len(results) - len(successful)}\n"
|
||||
f"Avg Sharpe: {avg_sharpe:.4f}\n"
|
||||
f"Best Sharpe: {best_sharpe:.4f}\n"
|
||||
f"Avg Max DD: {avg_dd:.4f}\n"
|
||||
f"Saved to: results/strategies/",
|
||||
border_style="green",
|
||||
))
|
||||
else:
|
||||
console.print("[yellow]No successful strategies. Check factor values exist.[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def build_strategies_ai(
|
||||
top: int = typer.Option(
|
||||
50,
|
||||
"--top", "-t",
|
||||
help="Number of top factors to use (default: 50)",
|
||||
),
|
||||
max_loops: int = typer.Option(
|
||||
5,
|
||||
"--max-loops", "-l",
|
||||
help="Maximum improvement cycles (default: 5)",
|
||||
),
|
||||
min_sharpe: float = typer.Option(
|
||||
1.5,
|
||||
"--min-sharpe",
|
||||
help="Minimum Sharpe ratio for acceptance (default: 1.5)",
|
||||
),
|
||||
max_drawdown: float = typer.Option(
|
||||
-0.20,
|
||||
"--max-dd",
|
||||
help="Maximum acceptable drawdown (default: -0.20)",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Build trading strategies using AI (LLM-based StrategyCoSTEER).
|
||||
|
||||
Uses LLM to generate, test, and improve trading strategies from
|
||||
existing factors. Follows the CoSTEER pattern:
|
||||
1. Load top factors by IC
|
||||
2. LLM generates strategy hypothesis and code
|
||||
3. Execute backtest and evaluate
|
||||
4. Feed results back to LLM for improvement
|
||||
5. Repeat until convergence or max loops
|
||||
|
||||
Examples:
|
||||
predix build-strategies-ai # Default: top 50, 5 loops
|
||||
predix build-strategies-ai -t 100 # Use top 100 factors
|
||||
predix build-strategies-ai -l 10 # 10 improvement loops
|
||||
predix build-strategies-ai --min-sharpe 2.0 # Stricter target
|
||||
"""
|
||||
from rich.panel import Panel
|
||||
from pathlib import Path
|
||||
|
||||
console.print(Panel(
|
||||
"[bold cyan]🧠 StrategyCoSTEER - AI Strategy Builder[/bold cyan]\n"
|
||||
"Generating trading strategies from existing factors\n"
|
||||
"Uses LLM to combine factors, backtest, and improve",
|
||||
border_style="cyan",
|
||||
))
|
||||
|
||||
# Check if local module exists
|
||||
local_module = Path(__file__).parent / "rdagent" / "scenarios" / "qlib" / "local"
|
||||
if not local_module.exists():
|
||||
console.print("[bold red]❌ StrategyCoSTEER not available: local/ directory not found[/bold red]")
|
||||
console.print("[yellow]This is a closed-source feature. Contact development team.[/yellow]")
|
||||
return
|
||||
|
||||
costeer_file = local_module / "strategy_coster.py"
|
||||
if not costeer_file.exists():
|
||||
console.print("[bold red]❌ strategy_coster.py not found[/bold red]")
|
||||
return
|
||||
|
||||
# Load top factors
|
||||
factors_dir = Path(__file__).parent / "results" / "factors"
|
||||
if not factors_dir.exists():
|
||||
console.print("[bold red]❌ No factors directory found at results/factors/[/bold red]")
|
||||
console.print("[yellow]Run 'predix quant' to generate factors first.[/yellow]")
|
||||
return
|
||||
|
||||
# Load evaluated factors
|
||||
import json
|
||||
import glob as glob_module
|
||||
|
||||
factors = []
|
||||
for f in glob_module.glob(str(factors_dir / "*.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:
|
||||
continue
|
||||
|
||||
if len(factors) < 10:
|
||||
console.print(f"[bold red]❌ Only {len(factors)} evaluated factors found. Need at least 10.[/bold red]")
|
||||
console.print("[yellow]Run 'predix evaluate' or 'predix quant' to generate more factors.[/yellow]")
|
||||
return
|
||||
|
||||
# Sort by IC and take top factors
|
||||
factors.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True)
|
||||
top_factors = factors[:top]
|
||||
|
||||
console.print(f"\n[bold green]✓ Loaded {len(top_factors)} top factors[/bold green]")
|
||||
console.print(f" Max loops: {max_loops}")
|
||||
console.print(f" Target Sharpe: ≥ {min_sharpe}")
|
||||
console.print(f" Max Drawdown: ≥ {max_drawdown:.2%}\n")
|
||||
|
||||
# Run StrategyCoSTEER
|
||||
try:
|
||||
from rdagent.scenarios.qlib.local.strategy_coster import StrategyCoSTEER
|
||||
|
||||
strategies_dir = Path(__file__).parent / "results" / "strategies"
|
||||
strategies_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
costeer = StrategyCoSTEER(
|
||||
factors_dir=str(factors_dir),
|
||||
strategies_dir=str(strategies_dir),
|
||||
max_loops=max_loops,
|
||||
min_sharpe=min_sharpe,
|
||||
max_drawdown=max_drawdown,
|
||||
)
|
||||
|
||||
results = costeer.run(top_factors)
|
||||
|
||||
# Display results
|
||||
if results:
|
||||
console.print(f"\n[bold green]✓ Generated {len(results)} accepted strategies![/bold green]\n")
|
||||
|
||||
from rich.table import Table
|
||||
table = Table(title="Accepted Strategies")
|
||||
table.add_column("#", style="dim")
|
||||
table.add_column("Strategy", style="cyan")
|
||||
table.add_column("Factors", style="yellow")
|
||||
table.add_column("Sharpe", justify="right", style="green")
|
||||
table.add_column("Max DD", justify="right", style="red")
|
||||
table.add_column("Win Rate", justify="right")
|
||||
table.add_column("Loop", justify="center")
|
||||
|
||||
for i, r in enumerate(results, 1):
|
||||
table.add_row(
|
||||
str(i),
|
||||
r.get("strategy_name", "unknown")[:30],
|
||||
str(len(r.get("factor_names", []))),
|
||||
f"{r.get('sharpe_ratio', 0):.3f}",
|
||||
f"{r.get('max_drawdown', 0):.2%}",
|
||||
f"{r.get('win_rate', 0):.2%}",
|
||||
str(r.get("loop", "?")),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n[dim]Strategies saved to: {strategies_dir}/[/dim]")
|
||||
else:
|
||||
console.print("[yellow]No strategies met acceptance criteria.[/yellow]")
|
||||
console.print("[dim]Check factor values in results/factors/values/[/dim]")
|
||||
|
||||
except ImportError as e:
|
||||
console.print(f"[bold red]❌ Import failed: {e}[/bold red]")
|
||||
except Exception as e:
|
||||
console.print(f"[bold red]❌ Strategy building failed: {e}[/bold red]")
|
||||
import traceback
|
||||
console.print(traceback.format_exc())
|
||||
|
||||
|
||||
@app.command()
|
||||
def health():
|
||||
"""Check system health and configuration."""
|
||||
|
||||
@@ -3,6 +3,8 @@ Quant (Factor & Model) workflow with session control
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import fire
|
||||
@@ -224,7 +226,7 @@ class QuantRDLoop(RDLoop):
|
||||
factor_name = "unknown"
|
||||
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
|
||||
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
|
||||
|
||||
|
||||
logger.warning(f"Skipping feedback for failed factor '{factor_name}'. Reason: {reason}")
|
||||
feedback = HypothesisFeedback(
|
||||
observations=f"Factor '{factor_name}' failed execution.",
|
||||
@@ -242,10 +244,92 @@ class QuantRDLoop(RDLoop):
|
||||
# NOTE: DB save is handled by factor_runner.py _save_result_to_database()
|
||||
# which runs immediately after Docker execution. No duplicate save needed here.
|
||||
|
||||
# Periodically build strategies using AI when enough factors are available
|
||||
factor_count = self.trace.get_factor_count()
|
||||
if factor_count > 0 and factor_count % 50 == 0:
|
||||
self._build_strategies_with_ai()
|
||||
|
||||
feedback = self._interact_feedback(feedback)
|
||||
logger.log_object(feedback, tag="feedback")
|
||||
return feedback
|
||||
|
||||
def _build_strategies_with_ai(self) -> None:
|
||||
"""
|
||||
Build trading strategies using StrategyCoSTEER (LLM-based).
|
||||
|
||||
This method is called periodically during the factor generation loop
|
||||
to convert accumulated factors into trading strategies.
|
||||
|
||||
Gracefully skips if local/ directory doesn't exist or LLM is unavailable.
|
||||
"""
|
||||
try:
|
||||
# Check if StrategyCoSTEER module exists (graceful skip)
|
||||
local_module = Path(__file__).parent.parent.parent / "scenarios" / "qlib" / "local"
|
||||
if not local_module.exists():
|
||||
logger.debug("StrategyCoSTEER: local/ directory not found. Skipping strategy building.")
|
||||
return
|
||||
|
||||
costeer_file = local_module / "strategy_coster.py"
|
||||
if not costeer_file.exists():
|
||||
logger.debug("StrategyCoSTEER: strategy_coster.py not found. Skipping strategy building.")
|
||||
return
|
||||
|
||||
from rdagent.scenarios.qlib.local.strategy_coster import StrategyCoSTEER
|
||||
|
||||
# Load top factors from results
|
||||
project_root = Path(__file__).parent.parent.parent.parent
|
||||
results_dir = project_root / "results"
|
||||
factors_dir = results_dir / "factors"
|
||||
|
||||
if not factors_dir.exists():
|
||||
logger.debug("StrategyCoSTEER: No factors directory found. Skipping.")
|
||||
return
|
||||
|
||||
# Load evaluated factors
|
||||
factors = []
|
||||
for f in 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:
|
||||
continue
|
||||
|
||||
if len(factors) < 10:
|
||||
logger.debug(f"StrategyCoSTEER: Only {len(factors)} factors available. Need at least 10. Skipping.")
|
||||
return
|
||||
|
||||
# Sort by IC and take top factors
|
||||
factors.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True)
|
||||
top_factors = factors[:50] # Use top 50 factors
|
||||
|
||||
logger.info(f"StrategyCoSTEER: Building strategies from {len(top_factors)} top factors...")
|
||||
|
||||
# Initialize and run StrategyCoSTEER
|
||||
strategies_dir = results_dir / "strategies"
|
||||
costeer = StrategyCoSTEER(
|
||||
factors_dir=str(factors_dir),
|
||||
strategies_dir=str(strategies_dir),
|
||||
max_loops=3, # Limited loops for periodic building
|
||||
min_sharpe=1.5,
|
||||
max_drawdown=-0.20,
|
||||
)
|
||||
|
||||
# Run CoSTEER loop
|
||||
results = costeer.run(top_factors)
|
||||
|
||||
if results:
|
||||
logger.info(f"StrategyCoSTEER: Generated {len(results)} accepted strategies.")
|
||||
else:
|
||||
logger.info("StrategyCoSTEER: No strategies met acceptance criteria this cycle.")
|
||||
|
||||
except ImportError as e:
|
||||
logger.warning(f"StrategyCoSTEER: Import failed ({e}). Skipping strategy building.")
|
||||
except Exception as e:
|
||||
# Don't break the main loop for strategy building failures
|
||||
logger.warning(f"StrategyCoSTEER: Unexpected error: {e}. Skipping strategy building.")
|
||||
|
||||
|
||||
def main(
|
||||
path=None,
|
||||
|
||||
@@ -169,6 +169,21 @@ def list_available_prompts() -> Dict[str, list]:
|
||||
return result
|
||||
|
||||
|
||||
def get_strategy_discovery_prompt() -> Dict[str, str]:
|
||||
"""Load strategy discovery prompts from local/strategy_discovery_v1.yaml."""
|
||||
return load_prompt("strategy_discovery")
|
||||
|
||||
|
||||
def get_strategy_evaluation_prompt() -> Dict[str, str]:
|
||||
"""Load strategy evaluation prompts."""
|
||||
return load_prompt("strategy_evaluation")
|
||||
|
||||
|
||||
def get_strategy_improvement_prompt() -> Dict[str, str]:
|
||||
"""Load strategy improvement prompts."""
|
||||
return load_prompt("strategy_improvement")
|
||||
|
||||
|
||||
# Convenience functions for specific prompts
|
||||
def get_factor_discovery_prompt() -> Dict[str, str]:
|
||||
"""Get factor discovery prompt (system + user)."""
|
||||
@@ -187,7 +202,17 @@ def get_model_coder_prompt() -> Dict[str, str]:
|
||||
|
||||
def get_trading_strategy_prompt() -> Dict[str, str]:
|
||||
"""Get trading strategy prompt."""
|
||||
return load_prompt("trading_strategy")
|
||||
return load_prompt("strategy_discovery")
|
||||
|
||||
|
||||
def get_strategy_evaluation_prompt() -> Dict[str, str]:
|
||||
"""Get strategy evaluation prompt."""
|
||||
return load_prompt("strategy_discovery", section="strategy_evaluation")
|
||||
|
||||
|
||||
def get_strategy_improvement_prompt() -> Dict[str, str]:
|
||||
"""Get strategy improvement prompt."""
|
||||
return load_prompt("strategy_discovery", section="strategy_improvement")
|
||||
|
||||
|
||||
# Test function
|
||||
|
||||
@@ -689,6 +689,9 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
exp=exp
|
||||
)
|
||||
|
||||
# Save factor values as parquet for strategy building
|
||||
self._save_factor_values(factor_name, exp)
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
@@ -817,6 +820,64 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
|
||||
return factor_code, factor_description
|
||||
|
||||
def _save_factor_values(self, factor_name: str, exp) -> None:
|
||||
"""
|
||||
Save factor time-series values as parquet for strategy building.
|
||||
|
||||
This is essential for walk-forward validation and strategy combination.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
factor_name : str
|
||||
Name of the factor
|
||||
exp : QlibFactorExperiment
|
||||
The experiment with factor values
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
try:
|
||||
# Get workspace path
|
||||
workspace_path = exp.experiment_workspace.workspace_path
|
||||
if workspace_path is None:
|
||||
return
|
||||
|
||||
result_h5 = workspace_path / "result.h5"
|
||||
if not result_h5.exists():
|
||||
return
|
||||
|
||||
# Read factor values
|
||||
import pandas as pd
|
||||
df = pd.read_hdf(str(result_h5), key="data")
|
||||
if df is None or df.empty:
|
||||
return
|
||||
|
||||
# Get the factor series (first column)
|
||||
series = df.iloc[:, 0]
|
||||
series.name = factor_name
|
||||
|
||||
# Save to results/factors/values/
|
||||
project_root = Path(__file__).parent.parent.parent.parent.parent
|
||||
|
||||
# Parallel run isolation
|
||||
parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0")
|
||||
if parallel_run_id != "0":
|
||||
values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values"
|
||||
else:
|
||||
values_dir = project_root / "results" / "factors" / "values"
|
||||
|
||||
values_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Safe filename
|
||||
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
|
||||
parquet_path = values_dir / f"{safe_name}.parquet"
|
||||
|
||||
# Save as parquet (with datetime index)
|
||||
series.to_parquet(str(parquet_path))
|
||||
|
||||
except Exception as e:
|
||||
# Don't let factor value saving break the main workflow
|
||||
pass
|
||||
|
||||
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
|
||||
"""
|
||||
Log warnings about result quality before saving to database.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Predix 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:
|
||||
predix build-strategies # Build strategies from top factors
|
||||
predix build-strategies --top 50 # Use top 50 factors
|
||||
predix 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)
|
||||
|
||||
# Calculate returns (forward returns approximation)
|
||||
# Use factor values as proxy for returns
|
||||
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()
|
||||
ann_factor = np.sqrt(252 * 1440 / 96) # Annualization for 1min data
|
||||
ann_return = total_return * ann_factor
|
||||
volatility = returns.std() * np.sqrt(252 * 1440 / 96)
|
||||
sharpe = ann_return / volatility if volatility > 0 else 0
|
||||
|
||||
# Max drawdown
|
||||
cum = returns.cumsum()
|
||||
running_max = cum.expanding().max()
|
||||
drawdown = (cum - 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:
|
||||
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
|
||||
Reference in New Issue
Block a user