feat: add 5 dashboard features — dark mode, trade history, backtests, model insights, alerts
- Dark mode: class-based theme toggle with localStorage persistence and flash prevention - Trade History (/trades): paginated table, stats cards, equity curve chart with DB API endpoints - Backtest Viewer (/backtests): log parser for 35 backtest results, sidebar + detail + comparison tabs - Model Insights: dashboard card + dialog showing feature importance, regime distribution, training history - Alert/Signal Log (/alerts): signal stats, filterable table with execution tracking - API: 8 new endpoints with psycopg2 DB connection pool - Dark mode sweep across books page, about dialog, and all dashboard components - Architecture docs rewritten with Mermaid diagrams (23 docs) - README and FEATURES.md rewritten bilingual (Indonesian + English) - main_live.py: write model_metrics.json on startup and retrain Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b2dc2dacd7
commit
e8355b3f62
@@ -0,0 +1,284 @@
|
||||
# ML V2 — Full ML Overhaul
|
||||
|
||||
**Problem:** Current ML model has AUC ~0.696 (barely better than random). Too noisy, limited features, single model.
|
||||
|
||||
**Solution:** 3-phase improvement:
|
||||
1. Better target (multi-bar + ATR threshold)
|
||||
2. 23 new features (H1, continuous SMC, regime, price action)
|
||||
3. Ensemble models (XGBoost + LightGBM)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
backtests/ml_v2/
|
||||
├── __init__.py # Package init
|
||||
├── ml_v2_target.py # Better target variables (Step 1)
|
||||
├── ml_v2_feature_eng.py # 23 new features (Step 2)
|
||||
├── ml_v2_model.py # Multi-model support (Step 3)
|
||||
├── ml_v2_train.py # Training pipeline + walk-forward CV
|
||||
└── README.md # This file
|
||||
|
||||
backtests/backtest_36_ml_v2.py # Main backtest script
|
||||
backtests/36_ml_v2_results/ # Output directory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `ml_v2_target.py` — Better Targets (Highest Impact)
|
||||
|
||||
**Problem:** Current target predicts 1-bar ahead with threshold=0 → captures noise.
|
||||
|
||||
**Solutions:**
|
||||
- **Multi-bar target** (primary): Look 3 bars ahead, filter moves < 0.3 * ATR (~$3.6)
|
||||
- **3-class target**: BUY/SELL/HOLD explicit classes
|
||||
- **Baseline target**: V1 reproduction for comparison
|
||||
|
||||
**Expected impact:** AUC +0.05 to +0.10 (biggest single improvement)
|
||||
|
||||
---
|
||||
|
||||
### 2. `ml_v2_feature_eng.py` — 23 New Features
|
||||
|
||||
Adds 23 features on top of base 37:
|
||||
|
||||
**H1 Multi-Timeframe (8 features):**
|
||||
- `h1_market_structure`: H1 trend direction
|
||||
- `h1_ema20_distance`: Price vs H1 EMA20 / ATR
|
||||
- `h1_trend_strength`: H1 BOS count
|
||||
- `h1_swing_proximity`: Distance to H1 swing / ATR
|
||||
- `h1_fvg_active`: Inside H1 FVG zone?
|
||||
- `h1_ob_proximity`: Distance to H1 OB / ATR
|
||||
- `h1_atr_ratio`: H1 ATR / M15 ATR
|
||||
- `h1_rsi`: H1 RSI value
|
||||
|
||||
**Continuous SMC (7 features):**
|
||||
- `fvg_gap_size_atr`: FVG gap / ATR (bigger = more reliable)
|
||||
- `fvg_age_bars`: Bars since last FVG
|
||||
- `ob_width_atr`: OB width / ATR
|
||||
- `ob_distance_atr`: Distance to OB / ATR
|
||||
- `bos_recency`: Bars since last BOS
|
||||
- `confluence_score`: Count SMC signals in last 10 bars
|
||||
- `swing_distance_atr`: Distance to swing / ATR
|
||||
|
||||
**Regime Conditioning (4 features):**
|
||||
- `regime_duration_bars`: Consecutive bars in regime
|
||||
- `regime_transition_prob`: 1 / duration
|
||||
- `volatility_zscore`: (ATR - mean) / std
|
||||
- `crisis_proximity`: ATR / (mean * 2.5)
|
||||
|
||||
**Price Action (4 features):**
|
||||
- `wick_ratio`: (upper + lower wick) / range
|
||||
- `body_ratio`: |close - open| / range
|
||||
- `gap_from_prev_close`: Gap / ATR
|
||||
- `consecutive_direction`: # candles same direction
|
||||
|
||||
**Total:** 37 (base) + 23 (new) = **60 features**
|
||||
|
||||
---
|
||||
|
||||
### 3. `ml_v2_model.py` — Multi-Model Support
|
||||
|
||||
**Model types:**
|
||||
- `XGBOOST_BINARY`: Binary classification (UP/DOWN)
|
||||
- `XGBOOST_3CLASS`: 3-class (BUY/SELL/HOLD)
|
||||
- `LIGHTGBM_BINARY`: LightGBM binary
|
||||
- `ENSEMBLE`: Average XGBoost + LightGBM probabilities
|
||||
|
||||
**Features:**
|
||||
- Backward compatible with V1 TradingModel
|
||||
- Same anti-overfitting philosophy (depth 3, heavy regularization)
|
||||
- Saves/loads as `.pkl`
|
||||
- Can load V1 models via `load_legacy_v1()`
|
||||
|
||||
---
|
||||
|
||||
### 4. `ml_v2_train.py` — Training Pipeline
|
||||
|
||||
**Purged Walk-Forward CV:**
|
||||
- 5 folds
|
||||
- 5000 train / 1000 test / 50 gap per fold
|
||||
- Gap prevents temporal leakage
|
||||
- Reports mean ± std AUC, overfitting ratio
|
||||
|
||||
**Experiment Configs:**
|
||||
| Config | Target | Features | Model |
|
||||
|--------|--------|----------|-------|
|
||||
| Baseline | 1-bar (V1) | 37 base | XGBoost |
|
||||
| **A** | 3-bar + ATR | 37 base | XGBoost |
|
||||
| **B** | 3-bar + ATR | 37 + 8 H1 = 45 | XGBoost |
|
||||
| **C** | 3-bar + ATR | 45 + 7 SMC = 52 | XGBoost |
|
||||
| **D** | 3-bar + ATR | 52 + 8 regime/PA = 60 | XGBoost |
|
||||
| **E** | 3-bar + ATR | 60 | XGB + LGBM ensemble |
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Run Main Backtest
|
||||
|
||||
```bash
|
||||
python backtests/backtest_36_ml_v2.py
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Fetch XAUUSD M15 + H1 data from MT5
|
||||
2. Calculate all features (base + V2)
|
||||
3. Create all targets (baseline, multi-bar, 3-class)
|
||||
4. Train all 6 configs (Baseline, A, B, C, D, E)
|
||||
5. Run 5-fold purged walk-forward CV for each
|
||||
6. Print comparison table
|
||||
7. Save models to `backtests/36_ml_v2_results/model_*.pkl`
|
||||
|
||||
**Expected runtime:** 10-20 minutes (depends on CV depth)
|
||||
|
||||
---
|
||||
|
||||
### Standalone Usage
|
||||
|
||||
```python
|
||||
from backtests.ml_v2 import TargetBuilder, MLV2FeatureEngineer, TradingModelV2, ModelType
|
||||
|
||||
# 1. Create better targets
|
||||
builder = TargetBuilder()
|
||||
df = builder.create_multi_bar_target(df, lookahead=3, threshold_atr_mult=0.3)
|
||||
|
||||
# 2. Add V2 features
|
||||
fe_v2 = MLV2FeatureEngineer()
|
||||
df = fe_v2.add_all_v2_features(df_m15, df_h1)
|
||||
|
||||
# 3. Train model
|
||||
model = TradingModelV2(model_type=ModelType.XGBOOST_BINARY)
|
||||
model.fit(df, feature_cols, target_col="multi_bar_target")
|
||||
|
||||
# 4. Predict
|
||||
pred = model.predict(df)
|
||||
print(f"Signal: {pred.signal}, Confidence: {pred.confidence}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Results
|
||||
|
||||
**Baseline (V1):**
|
||||
- Train AUC: ~0.75, Test AUC: ~0.70 (overfitting)
|
||||
- Actual: ~0.696 (from live model)
|
||||
|
||||
**Config A (Better Target):**
|
||||
- Expected: Test AUC +0.05 to +0.10 vs Baseline
|
||||
- Why: Filters noise, focuses on tradeable moves
|
||||
|
||||
**Config B (+H1 Features):**
|
||||
- Expected: Test AUC +0.02 to +0.05 vs A
|
||||
- Why: Higher timeframe context
|
||||
|
||||
**Config C (+Continuous SMC):**
|
||||
- Expected: Test AUC +0.01 to +0.03 vs B
|
||||
- Why: SMC strength (gap size, confluence)
|
||||
|
||||
**Config D (+All Features):**
|
||||
- Expected: Test AUC +0.01 to +0.02 vs C
|
||||
- Why: Regime transitions, price action patterns
|
||||
|
||||
**Config E (Ensemble):**
|
||||
- Expected: Test AUC +0.00 to +0.02 vs D
|
||||
- Why: Ensemble reduces variance
|
||||
|
||||
**Target:** Test AUC > 0.75 (from 0.696)
|
||||
|
||||
---
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
After running backtest, check:
|
||||
|
||||
1. **AUC Improvement**: Each config should improve or maintain test AUC
|
||||
2. **Overfitting Ratio**: Train AUC / Test AUC < 1.2 (acceptable)
|
||||
3. **Feature Importance**: Check if new features are used (not ignored)
|
||||
4. **Nulls**: Verify no excessive nulls in V2 features
|
||||
5. **Baseline Match**: Baseline config should reproduce V1 results (~0.70 AUC)
|
||||
|
||||
---
|
||||
|
||||
## Integration Plan (If Successful)
|
||||
|
||||
If Config D or E shows significant improvement (test AUC > 0.75):
|
||||
|
||||
1. **Copy best model** to `models/xgboost_model_v2.pkl`
|
||||
2. **Update `src/ml_model.py`** to load V2 by default
|
||||
3. **Modify `main_live.py`** to:
|
||||
- Add V2 feature calculation (H1 data fetch required)
|
||||
- Use V2 model for predictions
|
||||
4. **Run forward test** on demo account for 1 week
|
||||
5. **Compare metrics** vs V1 (WR, PnL, Sharpe)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
All dependencies already in `requirements.txt`:
|
||||
- `xgboost>=2.0.0` (core)
|
||||
- `polars>=0.20.0` (data processing)
|
||||
- `scikit-learn>=1.3.0` (metrics)
|
||||
- `lightgbm>=4.0.0` (optional, for ensemble Config E)
|
||||
|
||||
If `lightgbm` not installed, ensemble will fall back to XGBoost-only.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **No live code changes**: All files in `backtests/ml_v2/` (isolated)
|
||||
- **Backward compatible**: Can load V1 models via `load_legacy_v1()`
|
||||
- **Windows compatible**: Tested on Windows 11, Python 3.11+
|
||||
- **Polars-first**: All data processing uses Polars (not Pandas)
|
||||
- **Anti-overfitting**: Same regularization philosophy as V1
|
||||
|
||||
---
|
||||
|
||||
## File Sizes
|
||||
|
||||
- `ml_v2_target.py`: ~9 KB (target builder)
|
||||
- `ml_v2_feature_eng.py`: ~22 KB (23 features)
|
||||
- `ml_v2_model.py`: ~19 KB (multi-model support)
|
||||
- `ml_v2_train.py`: ~9 KB (training pipeline)
|
||||
- `backtest_36_ml_v2.py`: ~11 KB (main backtest)
|
||||
|
||||
**Total package**: ~70 KB (5 files)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Import Error:**
|
||||
```bash
|
||||
# Ensure you're in project root
|
||||
cd "C:/Users/Administrator/Videos/Smart Automatic Trading BOT + AI"
|
||||
python backtests/backtest_36_ml_v2.py
|
||||
```
|
||||
|
||||
**LightGBM Not Found:**
|
||||
- Config E will skip LightGBM and use XGBoost-only ensemble
|
||||
- Optional: `pip install lightgbm>=4.0.0`
|
||||
|
||||
**MT5 Connection Failed:**
|
||||
- Check `.env` credentials
|
||||
- Ensure MT5 terminal is running
|
||||
|
||||
**Low AUC (<0.65):**
|
||||
- Check feature nulls: `df[feature_cols].null_count()`
|
||||
- Verify target distribution: `df['multi_bar_target'].value_counts()`
|
||||
- Inspect feature importance: Are new features used?
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Plan**: See plan mode transcript (`1a09c953-bc49-4062-b130-8dd676f7eb1f.jsonl`)
|
||||
- **V1 Model**: `src/ml_model.py`
|
||||
- **Base Features**: `src/feature_eng.py`
|
||||
- **SMC**: `src/smc_polars.py`
|
||||
- **Regime**: `src/regime_detector.py`
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
ML V2 Package
|
||||
==============
|
||||
Full ML overhaul with better target variables, enhanced features, and ensemble models.
|
||||
|
||||
Components:
|
||||
- ml_v2_target.py: Improved target variables (multi-bar + ATR threshold)
|
||||
- ml_v2_feature_eng.py: 23 new features (H1 MTF, continuous SMC, regime, price action)
|
||||
- ml_v2_model.py: Multi-model support (XGBoost, LightGBM, ensemble)
|
||||
- ml_v2_train.py: Training pipeline with purged walk-forward CV
|
||||
- backtest_36_ml_v2.py: Main backtest (configs A/B/C/D/E)
|
||||
"""
|
||||
|
||||
from .ml_v2_target import TargetBuilder
|
||||
from .ml_v2_feature_eng import MLV2FeatureEngineer
|
||||
from .ml_v2_model import TradingModelV2, ModelType
|
||||
from .ml_v2_train import ExperimentConfig, MLV2Trainer
|
||||
|
||||
__all__ = [
|
||||
"TargetBuilder",
|
||||
"MLV2FeatureEngineer",
|
||||
"TradingModelV2",
|
||||
"ModelType",
|
||||
"ExperimentConfig",
|
||||
"MLV2Trainer",
|
||||
]
|
||||
@@ -0,0 +1,756 @@
|
||||
"""
|
||||
ML V2 Feature Engineering
|
||||
==========================
|
||||
23 new features on top of the base 37 features.
|
||||
|
||||
New feature categories:
|
||||
1. H1 Multi-Timeframe (8 features) - Higher timeframe context
|
||||
2. Continuous SMC (7 features) - SMC as continuous values instead of binary
|
||||
3. Regime Conditioning (4 features) - Regime-based features
|
||||
4. Price Action (4 features) - Candle patterns and momentum
|
||||
|
||||
Total: 37 (base) + 23 (new) = 60 features
|
||||
"""
|
||||
|
||||
import polars as pl
|
||||
import numpy as np
|
||||
from typing import List, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class MLV2FeatureEngineer:
|
||||
"""
|
||||
V2 Feature Engineer with 23 additional features.
|
||||
|
||||
Builds on top of base FeatureEngineer (37 features).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize V2 feature engineer."""
|
||||
pass
|
||||
|
||||
# =========================================================================
|
||||
# H1 MULTI-TIMEFRAME FEATURES (8 features)
|
||||
# =========================================================================
|
||||
|
||||
def add_h1_features(
|
||||
self,
|
||||
df_m15: pl.DataFrame,
|
||||
df_h1: pl.DataFrame,
|
||||
) -> pl.DataFrame:
|
||||
"""
|
||||
Add H1 (higher timeframe) features to M15 data.
|
||||
|
||||
Uses join_asof to merge H1 data into M15 without lookahead bias.
|
||||
|
||||
Features added (8 total):
|
||||
- h1_market_structure: H1 BOS-based trend (1/-1/0)
|
||||
- h1_ema20_distance: (M15 close - H1 EMA20) / ATR
|
||||
- h1_trend_strength: Count of H1 BOS in same direction
|
||||
- h1_swing_proximity: Distance to nearest H1 swing / ATR
|
||||
- h1_fvg_active: 1 if price inside H1 FVG zone
|
||||
- h1_ob_proximity: Distance to H1 order block / ATR
|
||||
- h1_atr_ratio: H1 ATR / M15 ATR
|
||||
- h1_rsi: H1 RSI value
|
||||
|
||||
Args:
|
||||
df_m15: M15 DataFrame (must have 'time', 'close', 'atr')
|
||||
df_h1: H1 DataFrame (must have indicators calculated)
|
||||
|
||||
Returns:
|
||||
M15 DataFrame with H1 features added
|
||||
"""
|
||||
if df_h1 is None or len(df_h1) == 0:
|
||||
logger.warning("H1 data empty, skipping H1 features")
|
||||
return df_m15
|
||||
|
||||
# Ensure both have time column
|
||||
if "time" not in df_m15.columns or "time" not in df_h1.columns:
|
||||
logger.error("Both DataFrames must have 'time' column")
|
||||
return df_m15
|
||||
|
||||
# Calculate H1 EMA20
|
||||
if "close" in df_h1.columns:
|
||||
df_h1 = df_h1.with_columns([
|
||||
pl.col("close")
|
||||
.ewm_mean(span=20, adjust=False)
|
||||
.alias("h1_ema20"),
|
||||
])
|
||||
|
||||
# Select H1 columns to join
|
||||
h1_cols = ["time"]
|
||||
h1_features = {}
|
||||
|
||||
# H1 market structure
|
||||
if "market_structure" in df_h1.columns:
|
||||
h1_cols.append("market_structure")
|
||||
h1_features["h1_market_structure"] = "market_structure"
|
||||
|
||||
# H1 EMA20
|
||||
if "h1_ema20" in df_h1.columns:
|
||||
h1_cols.append("h1_ema20")
|
||||
|
||||
# H1 ATR
|
||||
if "atr" in df_h1.columns:
|
||||
h1_cols.append("atr")
|
||||
h1_features["h1_atr"] = "atr"
|
||||
|
||||
# H1 RSI
|
||||
if "rsi" in df_h1.columns:
|
||||
h1_cols.append("rsi")
|
||||
h1_features["h1_rsi"] = "rsi"
|
||||
|
||||
# H1 swing levels
|
||||
if "last_swing_high" in df_h1.columns and "last_swing_low" in df_h1.columns:
|
||||
h1_cols.extend(["last_swing_high", "last_swing_low"])
|
||||
|
||||
# H1 FVG
|
||||
if "fvg_top" in df_h1.columns and "fvg_bottom" in df_h1.columns:
|
||||
h1_cols.extend(["fvg_top", "fvg_bottom"])
|
||||
|
||||
# H1 OB
|
||||
if "ob_top" in df_h1.columns and "ob_bottom" in df_h1.columns:
|
||||
h1_cols.extend(["ob_top", "ob_bottom"])
|
||||
|
||||
# H1 BOS for trend strength
|
||||
if "bos" in df_h1.columns:
|
||||
h1_cols.append("bos")
|
||||
|
||||
# Prepare H1 data for join
|
||||
df_h1_join = df_h1.select([c for c in h1_cols if c in df_h1.columns])
|
||||
|
||||
# Join H1 to M15 using join_asof (backward looking, no lookahead)
|
||||
df_m15 = df_m15.join_asof(
|
||||
df_h1_join,
|
||||
on="time",
|
||||
strategy="backward", # Use most recent H1 bar
|
||||
suffix="_h1",
|
||||
)
|
||||
|
||||
# === Feature 1: H1 Market Structure ===
|
||||
if "market_structure_h1" in df_m15.columns:
|
||||
df_m15 = df_m15.rename({"market_structure_h1": "h1_market_structure"})
|
||||
elif "h1_market_structure" not in df_m15.columns:
|
||||
df_m15 = df_m15.with_columns([
|
||||
pl.lit(0).alias("h1_market_structure"),
|
||||
])
|
||||
|
||||
# === Feature 2: H1 EMA20 Distance (normalized by ATR) ===
|
||||
if "h1_ema20" in df_m15.columns and "atr" in df_m15.columns:
|
||||
df_m15 = df_m15.with_columns([
|
||||
((pl.col("close") - pl.col("h1_ema20")) / pl.col("atr"))
|
||||
.alias("h1_ema20_distance"),
|
||||
])
|
||||
else:
|
||||
df_m15 = df_m15.with_columns([pl.lit(0.0).alias("h1_ema20_distance")])
|
||||
|
||||
# === Feature 3: H1 Trend Strength (BOS count in last 10 H1 bars) ===
|
||||
# We can't do rolling sum on joined data, so use a proxy:
|
||||
# Check if H1 BOS is present
|
||||
if "bos_h1" in df_m15.columns:
|
||||
# Simplification: just use current H1 BOS value as proxy
|
||||
df_m15 = df_m15.with_columns([
|
||||
pl.col("bos_h1").fill_null(0).alias("h1_trend_strength"),
|
||||
])
|
||||
else:
|
||||
df_m15 = df_m15.with_columns([pl.lit(0).alias("h1_trend_strength")])
|
||||
|
||||
# === Feature 4: H1 Swing Proximity ===
|
||||
if "last_swing_high_h1" in df_m15.columns and "last_swing_low_h1" in df_m15.columns:
|
||||
df_m15 = df_m15.with_columns([
|
||||
# Distance to nearest swing (high or low)
|
||||
pl.min_horizontal(
|
||||
(pl.col("last_swing_high_h1") - pl.col("close")).abs(),
|
||||
(pl.col("close") - pl.col("last_swing_low_h1")).abs()
|
||||
).alias("_swing_dist"),
|
||||
])
|
||||
|
||||
# Normalize by ATR and create final feature
|
||||
if "atr" in df_m15.columns:
|
||||
df_m15 = df_m15.with_columns([
|
||||
(pl.col("_swing_dist") / pl.col("atr")).alias("h1_swing_proximity"),
|
||||
]).drop(["_swing_dist"])
|
||||
else:
|
||||
df_m15 = df_m15.rename({"_swing_dist": "h1_swing_proximity"})
|
||||
else:
|
||||
df_m15 = df_m15.with_columns([pl.lit(0.0).alias("h1_swing_proximity")])
|
||||
|
||||
# === Feature 5: H1 FVG Active ===
|
||||
if "fvg_top_h1" in df_m15.columns and "fvg_bottom_h1" in df_m15.columns:
|
||||
df_m15 = df_m15.with_columns([
|
||||
pl.when(
|
||||
(pl.col("close") >= pl.col("fvg_bottom_h1")) &
|
||||
(pl.col("close") <= pl.col("fvg_top_h1"))
|
||||
)
|
||||
.then(1)
|
||||
.otherwise(0)
|
||||
.alias("h1_fvg_active"),
|
||||
])
|
||||
else:
|
||||
df_m15 = df_m15.with_columns([pl.lit(0).alias("h1_fvg_active")])
|
||||
|
||||
# === Feature 6: H1 OB Proximity ===
|
||||
if "ob_top_h1" in df_m15.columns and "ob_bottom_h1" in df_m15.columns:
|
||||
df_m15 = df_m15.with_columns([
|
||||
# Distance to OB center
|
||||
(((pl.col("ob_top_h1") + pl.col("ob_bottom_h1")) / 2 - pl.col("close")).abs())
|
||||
.alias("_ob_dist"),
|
||||
])
|
||||
|
||||
if "atr" in df_m15.columns:
|
||||
df_m15 = df_m15.with_columns([
|
||||
(pl.col("_ob_dist") / pl.col("atr")).alias("h1_ob_proximity"),
|
||||
])
|
||||
else:
|
||||
df_m15 = df_m15.rename({"_ob_dist": "h1_ob_proximity"})
|
||||
|
||||
df_m15 = df_m15.drop(["_ob_dist"])
|
||||
else:
|
||||
df_m15 = df_m15.with_columns([pl.lit(0.0).alias("h1_ob_proximity")])
|
||||
|
||||
# === Feature 7: H1 ATR Ratio ===
|
||||
if "atr_h1" in df_m15.columns and "atr" in df_m15.columns:
|
||||
df_m15 = df_m15.with_columns([
|
||||
(pl.col("atr_h1") / pl.col("atr")).fill_null(1.0).alias("h1_atr_ratio"),
|
||||
])
|
||||
else:
|
||||
df_m15 = df_m15.with_columns([pl.lit(1.0).alias("h1_atr_ratio")])
|
||||
|
||||
# === Feature 8: H1 RSI ===
|
||||
if "rsi_h1" in df_m15.columns:
|
||||
df_m15 = df_m15.rename({"rsi_h1": "h1_rsi"})
|
||||
else:
|
||||
df_m15 = df_m15.with_columns([pl.lit(50.0).alias("h1_rsi")])
|
||||
|
||||
# Clean up temporary H1 columns
|
||||
cols_to_drop = [
|
||||
c for c in df_m15.columns
|
||||
if c.endswith("_h1") and c not in ["h1_market_structure", "h1_rsi"]
|
||||
]
|
||||
if cols_to_drop:
|
||||
df_m15 = df_m15.drop(cols_to_drop)
|
||||
|
||||
logger.debug("H1 features added (8 features)")
|
||||
return df_m15
|
||||
|
||||
# =========================================================================
|
||||
# CONTINUOUS SMC FEATURES (7 features)
|
||||
# =========================================================================
|
||||
|
||||
def add_continuous_smc_features(self, df: pl.DataFrame) -> pl.DataFrame:
|
||||
"""
|
||||
Convert binary SMC signals to continuous features.
|
||||
|
||||
Features added (7 total):
|
||||
- fvg_gap_size_atr: FVG gap size / ATR
|
||||
- fvg_age_bars: Bars since last FVG (fresher = better)
|
||||
- ob_width_atr: OB width / ATR
|
||||
- ob_distance_atr: Distance to nearest OB / ATR
|
||||
- bos_recency: Bars since last BOS
|
||||
- confluence_score: Count of SMC signals in last 10 bars
|
||||
- swing_distance_atr: Distance to swing level / ATR
|
||||
|
||||
Args:
|
||||
df: DataFrame with SMC columns
|
||||
|
||||
Returns:
|
||||
DataFrame with continuous SMC features added
|
||||
"""
|
||||
# === Feature 1: FVG Gap Size ===
|
||||
if "fvg_top" in df.columns and "fvg_bottom" in df.columns and "atr" in df.columns:
|
||||
df = df.with_columns([
|
||||
((pl.col("fvg_top") - pl.col("fvg_bottom")) / pl.col("atr"))
|
||||
.fill_null(0.0)
|
||||
.alias("fvg_gap_size_atr"),
|
||||
])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(0.0).alias("fvg_gap_size_atr")])
|
||||
|
||||
# === Feature 2: FVG Age (bars since last FVG) ===
|
||||
if "fvg_signal" in df.columns:
|
||||
# Create row number index
|
||||
df = df.with_row_count("_row_idx")
|
||||
|
||||
# Find last FVG index for each row
|
||||
df = df.with_columns([
|
||||
pl.when(pl.col("fvg_signal") != 0)
|
||||
.then(pl.col("_row_idx"))
|
||||
.otherwise(None)
|
||||
.alias("_last_fvg_idx"),
|
||||
])
|
||||
|
||||
# Forward fill last FVG index
|
||||
df = df.with_columns([
|
||||
pl.col("_last_fvg_idx").forward_fill().alias("_last_fvg_idx_ff"),
|
||||
])
|
||||
|
||||
# Calculate age
|
||||
df = df.with_columns([
|
||||
(pl.col("_row_idx") - pl.col("_last_fvg_idx_ff"))
|
||||
.fill_null(999)
|
||||
.alias("fvg_age_bars"),
|
||||
])
|
||||
|
||||
df = df.drop(["_row_idx", "_last_fvg_idx", "_last_fvg_idx_ff"])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(999).alias("fvg_age_bars")])
|
||||
|
||||
# === Feature 3: OB Width ===
|
||||
if "ob_top" in df.columns and "ob_bottom" in df.columns and "atr" in df.columns:
|
||||
df = df.with_columns([
|
||||
((pl.col("ob_top") - pl.col("ob_bottom")) / pl.col("atr"))
|
||||
.fill_null(0.0)
|
||||
.alias("ob_width_atr"),
|
||||
])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(0.0).alias("ob_width_atr")])
|
||||
|
||||
# === Feature 4: OB Distance ===
|
||||
if "ob_top" in df.columns and "ob_bottom" in df.columns and "atr" in df.columns:
|
||||
df = df.with_columns([
|
||||
# Distance to OB center
|
||||
(((pl.col("ob_top") + pl.col("ob_bottom")) / 2 - pl.col("close")).abs() / pl.col("atr"))
|
||||
.fill_null(999.0)
|
||||
.alias("ob_distance_atr"),
|
||||
])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(999.0).alias("ob_distance_atr")])
|
||||
|
||||
# === Feature 5: BOS Recency ===
|
||||
if "bos" in df.columns:
|
||||
df = df.with_row_count("_row_idx")
|
||||
|
||||
df = df.with_columns([
|
||||
pl.when(pl.col("bos") != 0)
|
||||
.then(pl.col("_row_idx"))
|
||||
.otherwise(None)
|
||||
.alias("_last_bos_idx"),
|
||||
])
|
||||
|
||||
df = df.with_columns([
|
||||
pl.col("_last_bos_idx").forward_fill().alias("_last_bos_idx_ff"),
|
||||
])
|
||||
|
||||
df = df.with_columns([
|
||||
(pl.col("_row_idx") - pl.col("_last_bos_idx_ff"))
|
||||
.fill_null(999)
|
||||
.alias("bos_recency"),
|
||||
])
|
||||
|
||||
df = df.drop(["_row_idx", "_last_bos_idx", "_last_bos_idx_ff"])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(999).alias("bos_recency")])
|
||||
|
||||
# === Feature 6: Confluence Score ===
|
||||
# Count OB + FVG + BOS + CHoCH in last 10 bars
|
||||
smc_signals = []
|
||||
if "ob" in df.columns:
|
||||
smc_signals.append("_ob_signal")
|
||||
df = df.with_columns([
|
||||
(pl.col("ob").abs() > 0).cast(pl.Int8).alias("_ob_signal"),
|
||||
])
|
||||
if "fvg_signal" in df.columns or "is_fvg_bull" in df.columns:
|
||||
if "fvg_signal" in df.columns:
|
||||
smc_signals.append("_fvg_signal")
|
||||
df = df.with_columns([
|
||||
(pl.col("fvg_signal").abs() > 0).cast(pl.Int8).alias("_fvg_signal"),
|
||||
])
|
||||
else:
|
||||
smc_signals.append("_fvg_signal")
|
||||
df = df.with_columns([
|
||||
(pl.col("is_fvg_bull") | pl.col("is_fvg_bear")).cast(pl.Int8).alias("_fvg_signal"),
|
||||
])
|
||||
if "bos" in df.columns:
|
||||
smc_signals.append("_bos_signal")
|
||||
df = df.with_columns([
|
||||
(pl.col("bos").abs() > 0).cast(pl.Int8).alias("_bos_signal"),
|
||||
])
|
||||
if "choch" in df.columns:
|
||||
smc_signals.append("_choch_signal")
|
||||
df = df.with_columns([
|
||||
(pl.col("choch").abs() > 0).cast(pl.Int8).alias("_choch_signal"),
|
||||
])
|
||||
|
||||
if smc_signals:
|
||||
# Sum all signals in rolling window
|
||||
total_expr = pl.lit(0)
|
||||
for sig in smc_signals:
|
||||
total_expr = total_expr + pl.col(sig).rolling_sum(window_size=10, min_periods=1)
|
||||
|
||||
df = df.with_columns([
|
||||
total_expr.alias("confluence_score"),
|
||||
])
|
||||
|
||||
# Drop temp columns
|
||||
df = df.drop(smc_signals)
|
||||
else:
|
||||
df = df.with_columns([pl.lit(0).alias("confluence_score")])
|
||||
|
||||
# === Feature 7: Swing Distance ===
|
||||
# Skip if h1_swing_proximity already exists (from H1 features)
|
||||
if "h1_swing_proximity" not in df.columns:
|
||||
if "last_swing_high" in df.columns and "last_swing_low" in df.columns and "atr" in df.columns:
|
||||
df = df.with_columns([
|
||||
# Distance to nearest swing
|
||||
(pl.min_horizontal(
|
||||
(pl.col("last_swing_high") - pl.col("close")).abs(),
|
||||
(pl.col("close") - pl.col("last_swing_low")).abs()
|
||||
) / pl.col("atr"))
|
||||
.fill_null(999.0)
|
||||
.alias("swing_distance_atr"),
|
||||
])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(999.0).alias("swing_distance_atr")])
|
||||
else:
|
||||
# Use existing h1_swing_proximity as swing_distance_atr
|
||||
df = df.with_columns([
|
||||
pl.col("h1_swing_proximity").alias("swing_distance_atr"),
|
||||
])
|
||||
|
||||
logger.debug("Continuous SMC features added (7 features)")
|
||||
return df
|
||||
|
||||
# =========================================================================
|
||||
# REGIME CONDITIONING FEATURES (4 features)
|
||||
# =========================================================================
|
||||
|
||||
def add_regime_features(self, df: pl.DataFrame) -> pl.DataFrame:
|
||||
"""
|
||||
Add regime-based conditioning features.
|
||||
|
||||
Features added (4 total):
|
||||
- regime_duration_bars: Consecutive bars in current regime
|
||||
- regime_transition_prob: 1 / duration (proxy for change probability)
|
||||
- volatility_zscore: (ATR - mean50) / std50
|
||||
- crisis_proximity: ATR / (mean_ATR * 2.5)
|
||||
|
||||
Args:
|
||||
df: DataFrame with regime and ATR columns
|
||||
|
||||
Returns:
|
||||
DataFrame with regime features added
|
||||
"""
|
||||
# === Feature 1 & 2: Regime Duration & Transition Prob ===
|
||||
if "regime" in df.columns:
|
||||
# Calculate consecutive bars in same regime
|
||||
df = df.with_columns([
|
||||
# Create regime change flag
|
||||
(pl.col("regime") != pl.col("regime").shift(1)).alias("_regime_change"),
|
||||
])
|
||||
|
||||
# Cumsum of changes to create regime groups
|
||||
df = df.with_columns([
|
||||
pl.col("_regime_change").cum_sum().alias("_regime_group"),
|
||||
])
|
||||
|
||||
# Count bars in each group
|
||||
df = df.with_columns([
|
||||
pl.col("_regime_group").count().over("_regime_group").alias("regime_duration_bars"),
|
||||
])
|
||||
|
||||
# Transition probability (inverse of duration)
|
||||
df = df.with_columns([
|
||||
(1.0 / pl.col("regime_duration_bars")).alias("regime_transition_prob"),
|
||||
])
|
||||
|
||||
df = df.drop(["_regime_change", "_regime_group"])
|
||||
else:
|
||||
df = df.with_columns([
|
||||
pl.lit(1).alias("regime_duration_bars"),
|
||||
pl.lit(1.0).alias("regime_transition_prob"),
|
||||
])
|
||||
|
||||
# === Feature 3: Volatility Z-Score ===
|
||||
if "atr" in df.columns:
|
||||
df = df.with_columns([
|
||||
pl.col("atr").rolling_mean(window_size=50, min_periods=1).alias("_atr_mean50"),
|
||||
pl.col("atr").rolling_std(window_size=50, min_periods=1).alias("_atr_std50"),
|
||||
])
|
||||
|
||||
df = df.with_columns([
|
||||
((pl.col("atr") - pl.col("_atr_mean50")) / pl.col("_atr_std50"))
|
||||
.fill_null(0.0)
|
||||
.alias("volatility_zscore"),
|
||||
])
|
||||
|
||||
df = df.drop(["_atr_mean50", "_atr_std50"])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(0.0).alias("volatility_zscore")])
|
||||
|
||||
# === Feature 4: Crisis Proximity ===
|
||||
if "atr" in df.columns:
|
||||
df = df.with_columns([
|
||||
pl.col("atr").rolling_mean(window_size=50, min_periods=1).alias("_atr_mean"),
|
||||
])
|
||||
|
||||
# Crisis threshold: 2.5x mean ATR
|
||||
df = df.with_columns([
|
||||
(pl.col("atr") / (pl.col("_atr_mean") * 2.5))
|
||||
.fill_null(0.0)
|
||||
.alias("crisis_proximity"),
|
||||
])
|
||||
|
||||
df = df.drop(["_atr_mean"])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(0.0).alias("crisis_proximity")])
|
||||
|
||||
logger.debug("Regime features added (4 features)")
|
||||
return df
|
||||
|
||||
# =========================================================================
|
||||
# PRICE ACTION FEATURES (4 features)
|
||||
# =========================================================================
|
||||
|
||||
def add_price_action_features(self, df: pl.DataFrame) -> pl.DataFrame:
|
||||
"""
|
||||
Add price action pattern features.
|
||||
|
||||
Features added (4 total):
|
||||
- wick_ratio: (upper + lower wick) / range
|
||||
- body_ratio: |close - open| / range
|
||||
- gap_from_prev_close: (open - prev close) / ATR
|
||||
- consecutive_direction: # candles in same direction
|
||||
|
||||
Args:
|
||||
df: DataFrame with OHLCV and ATR
|
||||
|
||||
Returns:
|
||||
DataFrame with price action features added
|
||||
"""
|
||||
# === Feature 1: Wick Ratio ===
|
||||
if all(c in df.columns for c in ["open", "high", "low", "close"]):
|
||||
df = df.with_columns([
|
||||
# Upper wick
|
||||
(pl.max_horizontal("open", "close") - pl.col("high")).abs().alias("_upper_wick"),
|
||||
# Lower wick
|
||||
(pl.col("low") - pl.min_horizontal("open", "close")).abs().alias("_lower_wick"),
|
||||
# Range
|
||||
(pl.col("high") - pl.col("low")).alias("_range"),
|
||||
])
|
||||
|
||||
df = df.with_columns([
|
||||
((pl.col("_upper_wick") + pl.col("_lower_wick")) / pl.col("_range"))
|
||||
.fill_null(0.0)
|
||||
.alias("wick_ratio"),
|
||||
])
|
||||
|
||||
# === Feature 2: Body Ratio ===
|
||||
df = df.with_columns([
|
||||
((pl.col("close") - pl.col("open")).abs() / pl.col("_range"))
|
||||
.fill_null(0.0)
|
||||
.alias("body_ratio"),
|
||||
])
|
||||
|
||||
df = df.drop(["_upper_wick", "_lower_wick", "_range"])
|
||||
else:
|
||||
df = df.with_columns([
|
||||
pl.lit(0.0).alias("wick_ratio"),
|
||||
pl.lit(0.0).alias("body_ratio"),
|
||||
])
|
||||
|
||||
# === Feature 3: Gap from Previous Close ===
|
||||
if "open" in df.columns and "close" in df.columns and "atr" in df.columns:
|
||||
df = df.with_columns([
|
||||
((pl.col("open") - pl.col("close").shift(1)) / pl.col("atr"))
|
||||
.fill_null(0.0)
|
||||
.alias("gap_from_prev_close"),
|
||||
])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(0.0).alias("gap_from_prev_close")])
|
||||
|
||||
# === Feature 4: Consecutive Direction ===
|
||||
if "close" in df.columns and "open" in df.columns:
|
||||
# Direction: 1 if bullish, -1 if bearish
|
||||
df = df.with_columns([
|
||||
pl.when(pl.col("close") > pl.col("open"))
|
||||
.then(1)
|
||||
.when(pl.col("close") < pl.col("open"))
|
||||
.then(-1)
|
||||
.otherwise(0)
|
||||
.alias("_direction"),
|
||||
])
|
||||
|
||||
# Count consecutive bars in same direction
|
||||
# Create change flag
|
||||
df = df.with_columns([
|
||||
(pl.col("_direction") != pl.col("_direction").shift(1)).alias("_dir_change"),
|
||||
])
|
||||
|
||||
# Cumsum to create groups
|
||||
df = df.with_columns([
|
||||
pl.col("_dir_change").cum_sum().alias("_dir_group"),
|
||||
])
|
||||
|
||||
# Count within each group
|
||||
df = df.with_columns([
|
||||
pl.col("_dir_group").count().over("_dir_group").alias("consecutive_direction"),
|
||||
])
|
||||
|
||||
df = df.drop(["_direction", "_dir_change", "_dir_group"])
|
||||
else:
|
||||
df = df.with_columns([pl.lit(1).alias("consecutive_direction")])
|
||||
|
||||
logger.debug("Price action features added (4 features)")
|
||||
return df
|
||||
|
||||
# =========================================================================
|
||||
# MAIN INTERFACE
|
||||
# =========================================================================
|
||||
|
||||
def add_all_v2_features(
|
||||
self,
|
||||
df_m15: pl.DataFrame,
|
||||
df_h1: Optional[pl.DataFrame] = None,
|
||||
) -> pl.DataFrame:
|
||||
"""
|
||||
Add all 23 V2 features to M15 data.
|
||||
|
||||
Args:
|
||||
df_m15: M15 DataFrame with base features (37) already calculated
|
||||
df_h1: H1 DataFrame with indicators (optional)
|
||||
|
||||
Returns:
|
||||
M15 DataFrame with all 60 features (37 base + 23 V2)
|
||||
"""
|
||||
logger.info("Adding all V2 features (23 new features)...")
|
||||
|
||||
# H1 features (8)
|
||||
if df_h1 is not None:
|
||||
df_m15 = self.add_h1_features(df_m15, df_h1)
|
||||
else:
|
||||
logger.warning("No H1 data provided, using default H1 features")
|
||||
df_m15 = df_m15.with_columns([
|
||||
pl.col("close").alias("h1_ema20"), # Use M15 close as proxy
|
||||
pl.lit(0).alias("h1_market_structure"),
|
||||
pl.lit(0.0).alias("h1_ema20_distance"),
|
||||
pl.lit(0).alias("h1_trend_strength"),
|
||||
pl.lit(0.0).alias("h1_swing_proximity"),
|
||||
pl.lit(0).alias("h1_fvg_active"),
|
||||
pl.lit(0.0).alias("h1_ob_proximity"),
|
||||
pl.lit(1.0).alias("h1_atr_ratio"),
|
||||
pl.lit(50.0).alias("h1_rsi"),
|
||||
])
|
||||
|
||||
# Continuous SMC (7)
|
||||
df_m15 = self.add_continuous_smc_features(df_m15)
|
||||
|
||||
# Regime conditioning (4)
|
||||
df_m15 = self.add_regime_features(df_m15)
|
||||
|
||||
# Price action (4)
|
||||
df_m15 = self.add_price_action_features(df_m15)
|
||||
|
||||
logger.info("All V2 features added (23 total)")
|
||||
return df_m15
|
||||
|
||||
def get_v2_feature_columns(self) -> List[str]:
|
||||
"""
|
||||
Get list of V2 feature column names (23 features).
|
||||
|
||||
Returns:
|
||||
List of V2 feature names
|
||||
"""
|
||||
return [
|
||||
# H1 features (8)
|
||||
"h1_market_structure",
|
||||
"h1_ema20_distance",
|
||||
"h1_trend_strength",
|
||||
"h1_swing_proximity",
|
||||
"h1_fvg_active",
|
||||
"h1_ob_proximity",
|
||||
"h1_atr_ratio",
|
||||
"h1_rsi",
|
||||
# Continuous SMC (7)
|
||||
"fvg_gap_size_atr",
|
||||
"fvg_age_bars",
|
||||
"ob_width_atr",
|
||||
"ob_distance_atr",
|
||||
"bos_recency",
|
||||
"confluence_score",
|
||||
"swing_distance_atr",
|
||||
# Regime (4)
|
||||
"regime_duration_bars",
|
||||
"regime_transition_prob",
|
||||
"volatility_zscore",
|
||||
"crisis_proximity",
|
||||
# Price action (4)
|
||||
"wick_ratio",
|
||||
"body_ratio",
|
||||
"gap_from_prev_close",
|
||||
"consecutive_direction",
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test V2 features
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
np.random.seed(42)
|
||||
n_m15 = 500
|
||||
n_h1 = 100
|
||||
|
||||
# M15 data
|
||||
prices_m15 = 2000 + np.cumsum(np.random.randn(n_m15) * 2)
|
||||
df_m15 = pl.DataFrame({
|
||||
"time": [datetime.now() - timedelta(minutes=15*i) for i in range(n_m15-1, -1, -1)],
|
||||
"open": prices_m15,
|
||||
"high": prices_m15 + np.abs(np.random.randn(n_m15)) * 2,
|
||||
"low": prices_m15 - np.abs(np.random.randn(n_m15)) * 2,
|
||||
"close": prices_m15 + np.random.randn(n_m15),
|
||||
"atr": np.random.uniform(10, 14, n_m15),
|
||||
"regime": np.random.randint(0, 3, n_m15),
|
||||
"fvg_top": np.where(np.random.random(n_m15) > 0.9, prices_m15 + 5, None),
|
||||
"fvg_bottom": np.where(np.random.random(n_m15) > 0.9, prices_m15 - 5, None),
|
||||
"fvg_signal": np.where(np.random.random(n_m15) > 0.95, np.random.choice([-1, 1]), 0),
|
||||
"ob_top": np.where(np.random.random(n_m15) > 0.9, prices_m15 + 3, None),
|
||||
"ob_bottom": np.where(np.random.random(n_m15) > 0.9, prices_m15 - 3, None),
|
||||
"ob": np.where(np.random.random(n_m15) > 0.95, np.random.choice([-1, 1]), 0),
|
||||
"bos": np.where(np.random.random(n_m15) > 0.95, np.random.choice([-1, 1]), 0),
|
||||
"choch": np.where(np.random.random(n_m15) > 0.98, np.random.choice([-1, 1]), 0),
|
||||
"last_swing_high": prices_m15 + 10,
|
||||
"last_swing_low": prices_m15 - 10,
|
||||
})
|
||||
|
||||
# H1 data
|
||||
prices_h1 = 2000 + np.cumsum(np.random.randn(n_h1) * 5)
|
||||
df_h1 = pl.DataFrame({
|
||||
"time": [datetime.now() - timedelta(hours=i) for i in range(n_h1-1, -1, -1)],
|
||||
"close": prices_h1,
|
||||
"atr": np.random.uniform(11, 13, n_h1),
|
||||
"rsi": np.random.uniform(30, 70, n_h1),
|
||||
"market_structure": np.random.choice([-1, 0, 1], n_h1),
|
||||
"last_swing_high": prices_h1 + 15,
|
||||
"last_swing_low": prices_h1 - 15,
|
||||
"fvg_top": np.where(np.random.random(n_h1) > 0.9, prices_h1 + 8, None),
|
||||
"fvg_bottom": np.where(np.random.random(n_h1) > 0.9, prices_h1 - 8, None),
|
||||
"ob_top": np.where(np.random.random(n_h1) > 0.9, prices_h1 + 5, None),
|
||||
"ob_bottom": np.where(np.random.random(n_h1) > 0.9, prices_h1 - 5, None),
|
||||
"bos": np.where(np.random.random(n_h1) > 0.95, np.random.choice([-1, 1]), 0),
|
||||
})
|
||||
|
||||
# Add V2 features
|
||||
fe_v2 = MLV2FeatureEngineer()
|
||||
df_m15 = fe_v2.add_all_v2_features(df_m15, df_h1)
|
||||
|
||||
print("\n=== ML V2 Feature Engineering Test ===")
|
||||
print(f"Total columns: {len(df_m15.columns)}")
|
||||
print(f"\nV2 feature columns (23):")
|
||||
v2_cols = fe_v2.get_v2_feature_columns()
|
||||
for i, col in enumerate(v2_cols, 1):
|
||||
print(f" {i:2d}. {col}")
|
||||
|
||||
# Show sample
|
||||
print("\n=== Sample Data (Last 5 Rows) ===")
|
||||
sample_cols = ["time", "close", "h1_ema20_distance", "confluence_score", "volatility_zscore", "wick_ratio"]
|
||||
available = [c for c in sample_cols if c in df_m15.columns]
|
||||
print(df_m15.select(available).tail(5))
|
||||
|
||||
# Check for nulls
|
||||
null_counts = {col: df_m15[col].null_count() for col in v2_cols if col in df_m15.columns}
|
||||
print("\n=== Null Counts in V2 Features ===")
|
||||
for col, count in null_counts.items():
|
||||
if count > 0:
|
||||
print(f" {col}: {count} nulls ({count/len(df_m15)*100:.1f}%)")
|
||||
if not any(null_counts.values()):
|
||||
print(" No nulls found! ✓")
|
||||
@@ -0,0 +1,644 @@
|
||||
"""
|
||||
ML V2 Model Module
|
||||
===================
|
||||
Multi-model support: XGBoost, LightGBM, Ensemble.
|
||||
|
||||
Backward compatible with V1 TradingModel for easy comparison.
|
||||
"""
|
||||
|
||||
import polars as pl
|
||||
import numpy as np
|
||||
from typing import Optional, Dict, List, Tuple, Any
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
import pickle
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import xgboost as xgb
|
||||
except ImportError:
|
||||
logger.warning("xgboost not installed")
|
||||
xgb = None
|
||||
|
||||
try:
|
||||
import lightgbm as lgb
|
||||
except ImportError:
|
||||
logger.warning("lightgbm not installed (optional for ensemble)")
|
||||
lgb = None
|
||||
|
||||
|
||||
class ModelType(Enum):
|
||||
"""Supported model types."""
|
||||
XGBOOST_BINARY = "xgboost_binary"
|
||||
XGBOOST_3CLASS = "xgboost_3class"
|
||||
LIGHTGBM_BINARY = "lightgbm_binary"
|
||||
ENSEMBLE = "ensemble"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PredictionResultV2:
|
||||
"""Model prediction result."""
|
||||
signal: str # "BUY", "SELL", "HOLD"
|
||||
probability: float # Probability of UP (binary) or class probabilities (3-class)
|
||||
confidence: float
|
||||
probabilities: Optional[Dict[str, float]] = None # For 3-class
|
||||
|
||||
|
||||
class TradingModelV2:
|
||||
"""
|
||||
V2 Trading Model with multi-model support.
|
||||
|
||||
Features:
|
||||
- XGBoost (binary or 3-class)
|
||||
- LightGBM (binary)
|
||||
- Ensemble (average XGBoost + LightGBM)
|
||||
- Backward compatible with V1 TradingModel
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_type: ModelType = ModelType.XGBOOST_BINARY,
|
||||
confidence_threshold: float = 0.65,
|
||||
model_path: Optional[str] = None,
|
||||
xgb_params: Optional[Dict] = None,
|
||||
lgb_params: Optional[Dict] = None,
|
||||
):
|
||||
"""
|
||||
Initialize V2 trading model.
|
||||
|
||||
Args:
|
||||
model_type: Type of model to use
|
||||
confidence_threshold: Minimum confidence for signal
|
||||
model_path: Path to save/load model (.pkl)
|
||||
xgb_params: XGBoost parameters (optional)
|
||||
lgb_params: LightGBM parameters (optional)
|
||||
"""
|
||||
self.model_type = model_type
|
||||
self.confidence_threshold = confidence_threshold
|
||||
self.model_path = Path(model_path) if model_path else None
|
||||
|
||||
# XGBoost params (anti-overfitting philosophy from V1)
|
||||
self.xgb_params = xgb_params or self._get_default_xgb_params()
|
||||
|
||||
# LightGBM params (equivalent to XGBoost)
|
||||
self.lgb_params = lgb_params or self._get_default_lgb_params()
|
||||
|
||||
# Models
|
||||
self.xgb_model: Optional[xgb.Booster] = None
|
||||
self.lgb_model: Optional[lgb.Booster] = None
|
||||
|
||||
# Metadata
|
||||
self.feature_names: List[str] = []
|
||||
self.fitted = False
|
||||
self._feature_importance: Dict[str, float] = {}
|
||||
self._train_metrics: Dict[str, float] = {}
|
||||
|
||||
def _get_default_xgb_params(self) -> Dict:
|
||||
"""Get default XGBoost params (same anti-overfitting as V1)."""
|
||||
if self.model_type == ModelType.XGBOOST_3CLASS:
|
||||
return {
|
||||
"objective": "multi:softprob",
|
||||
"num_class": 3,
|
||||
"eval_metric": "mlogloss",
|
||||
"max_depth": 3,
|
||||
"learning_rate": 0.05,
|
||||
"tree_method": "hist",
|
||||
"device": "cpu",
|
||||
"min_child_weight": 10,
|
||||
"subsample": 0.7,
|
||||
"colsample_bytree": 0.6,
|
||||
"reg_alpha": 1.0,
|
||||
"reg_lambda": 5.0,
|
||||
"gamma": 1.0,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"objective": "binary:logistic",
|
||||
"eval_metric": "auc",
|
||||
"max_depth": 3,
|
||||
"learning_rate": 0.05,
|
||||
"tree_method": "hist",
|
||||
"device": "cpu",
|
||||
"min_child_weight": 10,
|
||||
"subsample": 0.7,
|
||||
"colsample_bytree": 0.6,
|
||||
"reg_alpha": 1.0,
|
||||
"reg_lambda": 5.0,
|
||||
"gamma": 1.0,
|
||||
"max_delta_step": 1,
|
||||
}
|
||||
|
||||
def _get_default_lgb_params(self) -> Dict:
|
||||
"""Get default LightGBM params (equivalent to XGBoost)."""
|
||||
return {
|
||||
"objective": "binary",
|
||||
"metric": "auc",
|
||||
"num_leaves": 8, # Equivalent to max_depth=3
|
||||
"learning_rate": 0.05,
|
||||
"min_child_weight": 10,
|
||||
"min_child_samples": 20,
|
||||
"subsample": 0.7,
|
||||
"colsample_bytree": 0.6,
|
||||
"reg_alpha": 1.0,
|
||||
"reg_lambda": 5.0,
|
||||
"min_split_gain": 1.0, # Equivalent to gamma
|
||||
"verbose": -1,
|
||||
}
|
||||
|
||||
def fit(
|
||||
self,
|
||||
df: pl.DataFrame,
|
||||
feature_cols: List[str],
|
||||
target_col: str = "multi_bar_target",
|
||||
train_ratio: float = 0.8,
|
||||
num_boost_round: int = 100,
|
||||
early_stopping_rounds: int = 10,
|
||||
) -> "TradingModelV2":
|
||||
"""
|
||||
Train the model on Polars DataFrame.
|
||||
|
||||
Args:
|
||||
df: Polars DataFrame with features and target
|
||||
feature_cols: List of feature column names
|
||||
target_col: Target column name
|
||||
train_ratio: Train/test split ratio
|
||||
num_boost_round: Number of boosting rounds
|
||||
early_stopping_rounds: Early stopping patience
|
||||
|
||||
Returns:
|
||||
Self for chaining
|
||||
"""
|
||||
# Validate features
|
||||
available_features = [f for f in feature_cols if f in df.columns]
|
||||
if len(available_features) < len(feature_cols):
|
||||
missing = set(feature_cols) - set(available_features)
|
||||
logger.warning(f"Missing features (will be skipped): {missing}")
|
||||
|
||||
if target_col not in df.columns:
|
||||
logger.error(f"Target column '{target_col}' not found")
|
||||
return self
|
||||
|
||||
# Drop nulls
|
||||
df_clean = df.select(available_features + [target_col]).drop_nulls()
|
||||
|
||||
if len(df_clean) < 100:
|
||||
logger.warning(f"Insufficient data for training: {len(df_clean)} samples")
|
||||
return self
|
||||
|
||||
self.feature_names = available_features
|
||||
|
||||
# Extract features and target
|
||||
X = df_clean.select(available_features).to_numpy()
|
||||
y = df_clean.select(target_col).to_numpy().ravel()
|
||||
|
||||
# Handle NaN/inf
|
||||
X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
|
||||
# Train/test split with gap (prevent temporal leakage)
|
||||
gap_size = 50
|
||||
split_idx = int(len(X) * train_ratio)
|
||||
|
||||
X_train = X[:split_idx]
|
||||
y_train = y[:split_idx]
|
||||
test_start_idx = min(split_idx + gap_size, len(X) - 1)
|
||||
X_test = X[test_start_idx:]
|
||||
y_test = y[test_start_idx:]
|
||||
|
||||
logger.info(f"Training {self.model_type.value} with {len(X_train)} samples, testing with {len(X_test)} samples")
|
||||
|
||||
# Train based on model type
|
||||
if self.model_type in [ModelType.XGBOOST_BINARY, ModelType.XGBOOST_3CLASS]:
|
||||
self._fit_xgboost(X_train, y_train, X_test, y_test, num_boost_round, early_stopping_rounds)
|
||||
|
||||
elif self.model_type == ModelType.LIGHTGBM_BINARY:
|
||||
if lgb is None:
|
||||
logger.error("LightGBM not installed. Install with: pip install lightgbm")
|
||||
return self
|
||||
self._fit_lightgbm(X_train, y_train, X_test, y_test, num_boost_round, early_stopping_rounds)
|
||||
|
||||
elif self.model_type == ModelType.ENSEMBLE:
|
||||
# Train both models
|
||||
self._fit_xgboost(X_train, y_train, X_test, y_test, num_boost_round, early_stopping_rounds)
|
||||
if lgb is not None:
|
||||
self._fit_lightgbm(X_train, y_train, X_test, y_test, num_boost_round, early_stopping_rounds)
|
||||
else:
|
||||
logger.warning("LightGBM not available, ensemble will use XGBoost only")
|
||||
|
||||
self.fitted = True
|
||||
|
||||
# Auto-save
|
||||
if self.model_path:
|
||||
self.save()
|
||||
|
||||
return self
|
||||
|
||||
def _fit_xgboost(self, X_train, y_train, X_test, y_test, num_boost_round, early_stopping_rounds):
|
||||
"""Fit XGBoost model."""
|
||||
if xgb is None:
|
||||
logger.error("XGBoost not installed")
|
||||
return
|
||||
|
||||
dtrain = xgb.DMatrix(X_train, label=y_train, feature_names=self.feature_names)
|
||||
dtest = xgb.DMatrix(X_test, label=y_test, feature_names=self.feature_names)
|
||||
|
||||
evals = [(dtrain, "train"), (dtest, "eval")]
|
||||
|
||||
self.xgb_model = xgb.train(
|
||||
self.xgb_params,
|
||||
dtrain,
|
||||
num_boost_round=num_boost_round,
|
||||
evals=evals,
|
||||
early_stopping_rounds=early_stopping_rounds,
|
||||
verbose_eval=10,
|
||||
)
|
||||
|
||||
# Feature importance
|
||||
importance = self.xgb_model.get_score(importance_type="gain")
|
||||
self._feature_importance = {
|
||||
feat: importance.get(feat, 0) for feat in self.feature_names
|
||||
}
|
||||
|
||||
# Evaluate
|
||||
train_score = self._evaluate_xgb(dtrain)
|
||||
test_score = self._evaluate_xgb(dtest)
|
||||
|
||||
self._train_metrics["xgb_train_score"] = train_score
|
||||
self._train_metrics["xgb_test_score"] = test_score
|
||||
self._train_metrics["train_samples"] = len(X_train)
|
||||
self._train_metrics["test_samples"] = len(X_test)
|
||||
|
||||
logger.info(f"XGBoost: Train={train_score:.4f}, Test={test_score:.4f}")
|
||||
|
||||
def _fit_lightgbm(self, X_train, y_train, X_test, y_test, num_boost_round, early_stopping_rounds):
|
||||
"""Fit LightGBM model."""
|
||||
if lgb is None:
|
||||
return
|
||||
|
||||
train_data = lgb.Dataset(X_train, label=y_train, feature_name=self.feature_names)
|
||||
test_data = lgb.Dataset(X_test, label=y_test, reference=train_data, feature_name=self.feature_names)
|
||||
|
||||
self.lgb_model = lgb.train(
|
||||
self.lgb_params,
|
||||
train_data,
|
||||
num_boost_round=num_boost_round,
|
||||
valid_sets=[train_data, test_data],
|
||||
valid_names=["train", "eval"],
|
||||
callbacks=[
|
||||
lgb.early_stopping(stopping_rounds=early_stopping_rounds),
|
||||
lgb.log_evaluation(period=10),
|
||||
],
|
||||
)
|
||||
|
||||
# Evaluate
|
||||
train_score = self._evaluate_lgb(X_train, y_train)
|
||||
test_score = self._evaluate_lgb(X_test, y_test)
|
||||
|
||||
self._train_metrics["lgb_train_score"] = train_score
|
||||
self._train_metrics["lgb_test_score"] = test_score
|
||||
|
||||
logger.info(f"LightGBM: Train={train_score:.4f}, Test={test_score:.4f}")
|
||||
|
||||
def _evaluate_xgb(self, dmatrix: xgb.DMatrix) -> float:
|
||||
"""Evaluate XGBoost model."""
|
||||
if self.xgb_model is None:
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
from sklearn.metrics import roc_auc_score, log_loss
|
||||
|
||||
preds = self.xgb_model.predict(dmatrix)
|
||||
labels = dmatrix.get_label()
|
||||
|
||||
if self.model_type == ModelType.XGBOOST_3CLASS:
|
||||
# Multi-class: use log loss
|
||||
return -log_loss(labels, preds) # Negative so higher is better
|
||||
else:
|
||||
# Binary: use AUC
|
||||
return roc_auc_score(labels, preds)
|
||||
except Exception as e:
|
||||
logger.warning(f"XGBoost evaluation error: {e}")
|
||||
return 0.5
|
||||
|
||||
def _evaluate_lgb(self, X, y) -> float:
|
||||
"""Evaluate LightGBM model."""
|
||||
if self.lgb_model is None:
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
from sklearn.metrics import roc_auc_score
|
||||
|
||||
preds = self.lgb_model.predict(X)
|
||||
return roc_auc_score(y, preds)
|
||||
except Exception as e:
|
||||
logger.warning(f"LightGBM evaluation error: {e}")
|
||||
return 0.5
|
||||
|
||||
def predict(
|
||||
self,
|
||||
df: pl.DataFrame,
|
||||
feature_cols: Optional[List[str]] = None,
|
||||
) -> PredictionResultV2:
|
||||
"""
|
||||
Predict trading signal for latest data point.
|
||||
|
||||
Args:
|
||||
df: Polars DataFrame with features
|
||||
feature_cols: Feature columns (uses stored if None)
|
||||
|
||||
Returns:
|
||||
PredictionResultV2 with signal and confidence
|
||||
"""
|
||||
if not self.fitted:
|
||||
logger.warning("Model not fitted, returning HOLD")
|
||||
return PredictionResultV2(
|
||||
signal="HOLD",
|
||||
probability=0.5,
|
||||
confidence=0.0,
|
||||
)
|
||||
|
||||
features = feature_cols or self.feature_names
|
||||
latest = df.tail(1)
|
||||
|
||||
# Extract features
|
||||
try:
|
||||
X = latest.select(features).to_numpy()
|
||||
X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
except Exception as e:
|
||||
logger.error(f"Feature extraction failed: {e}")
|
||||
return PredictionResultV2(signal="HOLD", probability=0.5, confidence=0.0)
|
||||
|
||||
# Predict based on model type
|
||||
if self.model_type == ModelType.ENSEMBLE:
|
||||
prob_up = self._predict_ensemble(X, features)
|
||||
elif self.model_type in [ModelType.XGBOOST_BINARY, ModelType.XGBOOST_3CLASS]:
|
||||
prob_up = self._predict_xgboost(X, features)
|
||||
elif self.model_type == ModelType.LIGHTGBM_BINARY:
|
||||
prob_up = self._predict_lightgbm(X)
|
||||
else:
|
||||
prob_up = 0.5
|
||||
|
||||
# Determine signal
|
||||
if isinstance(prob_up, dict): # 3-class
|
||||
# prob_up = {"BUY": 0.4, "SELL": 0.3, "HOLD": 0.3}
|
||||
max_class = max(prob_up, key=prob_up.get)
|
||||
confidence = prob_up[max_class]
|
||||
|
||||
if confidence > self.confidence_threshold:
|
||||
signal = max_class
|
||||
else:
|
||||
signal = "HOLD"
|
||||
|
||||
return PredictionResultV2(
|
||||
signal=signal,
|
||||
probability=prob_up.get("BUY", 0.0),
|
||||
confidence=confidence,
|
||||
probabilities=prob_up,
|
||||
)
|
||||
else: # Binary
|
||||
prob_down = 1 - prob_up
|
||||
|
||||
if prob_up > self.confidence_threshold:
|
||||
signal = "BUY"
|
||||
confidence = prob_up
|
||||
elif prob_down > self.confidence_threshold:
|
||||
signal = "SELL"
|
||||
confidence = prob_down
|
||||
else:
|
||||
signal = "HOLD"
|
||||
confidence = max(prob_up, prob_down)
|
||||
|
||||
return PredictionResultV2(
|
||||
signal=signal,
|
||||
probability=prob_up,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
def _predict_xgboost(self, X, feature_names: Optional[List[str]] = None) -> float:
|
||||
"""Predict with XGBoost."""
|
||||
if self.xgb_model is None:
|
||||
return 0.5
|
||||
|
||||
names = feature_names or self.feature_names
|
||||
dmatrix = xgb.DMatrix(X, feature_names=names)
|
||||
preds = self.xgb_model.predict(dmatrix)
|
||||
|
||||
if self.model_type == ModelType.XGBOOST_3CLASS:
|
||||
# Multi-class: return dict
|
||||
return {
|
||||
"BUY": float(preds[0][0]),
|
||||
"SELL": float(preds[0][1]),
|
||||
"HOLD": float(preds[0][2]),
|
||||
}
|
||||
else:
|
||||
# Binary
|
||||
return float(preds[0])
|
||||
|
||||
def _predict_lightgbm(self, X) -> float:
|
||||
"""Predict with LightGBM."""
|
||||
if self.lgb_model is None:
|
||||
return 0.5
|
||||
|
||||
preds = self.lgb_model.predict(X)
|
||||
return float(preds[0])
|
||||
|
||||
def _predict_ensemble(self, X, feature_names: Optional[List[str]] = None) -> float:
|
||||
"""Predict with ensemble (average of XGBoost + LightGBM)."""
|
||||
preds = []
|
||||
|
||||
if self.xgb_model is not None:
|
||||
xgb_pred = self._predict_xgboost(X, feature_names)
|
||||
if isinstance(xgb_pred, dict):
|
||||
# Can't ensemble 3-class easily, just use XGBoost
|
||||
return xgb_pred
|
||||
preds.append(xgb_pred)
|
||||
|
||||
if self.lgb_model is not None:
|
||||
lgb_pred = self._predict_lightgbm(X)
|
||||
preds.append(lgb_pred)
|
||||
|
||||
if not preds:
|
||||
return 0.5
|
||||
|
||||
# Average
|
||||
return float(np.mean(preds))
|
||||
|
||||
def save(self, path: Optional[str] = None):
|
||||
"""Save model to .pkl file."""
|
||||
save_path = Path(path) if path else self.model_path
|
||||
|
||||
if save_path is None:
|
||||
logger.warning("No save path provided")
|
||||
return
|
||||
|
||||
save_path = save_path.with_suffix(".pkl")
|
||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
model_data = {
|
||||
"model_type": self.model_type,
|
||||
"xgb_model": self.xgb_model,
|
||||
"lgb_model": self.lgb_model,
|
||||
"feature_names": self.feature_names,
|
||||
"confidence_threshold": self.confidence_threshold,
|
||||
"xgb_params": self.xgb_params,
|
||||
"lgb_params": self.lgb_params,
|
||||
"feature_importance": self._feature_importance,
|
||||
"train_metrics": self._train_metrics,
|
||||
"fitted": self.fitted,
|
||||
}
|
||||
|
||||
with open(save_path, "wb") as f:
|
||||
pickle.dump(model_data, f)
|
||||
|
||||
logger.info(f"Model saved to {save_path}")
|
||||
|
||||
def load(self, path: Optional[str] = None) -> "TradingModelV2":
|
||||
"""Load model from .pkl file."""
|
||||
load_path = Path(path) if path else self.model_path
|
||||
|
||||
if load_path is None:
|
||||
logger.warning("No load path provided")
|
||||
return self
|
||||
|
||||
load_path = load_path.with_suffix(".pkl")
|
||||
|
||||
if not load_path.exists():
|
||||
logger.warning(f"Model file not found: {load_path}")
|
||||
return self
|
||||
|
||||
try:
|
||||
with open(load_path, "rb") as f:
|
||||
model_data = pickle.load(f)
|
||||
|
||||
self.model_type = model_data.get("model_type", ModelType.XGBOOST_BINARY)
|
||||
self.xgb_model = model_data.get("xgb_model")
|
||||
self.lgb_model = model_data.get("lgb_model")
|
||||
self.feature_names = model_data.get("feature_names", [])
|
||||
self.confidence_threshold = model_data.get("confidence_threshold", 0.65)
|
||||
self.xgb_params = model_data.get("xgb_params", {})
|
||||
self.lgb_params = model_data.get("lgb_params", {})
|
||||
self._feature_importance = model_data.get("feature_importance", {})
|
||||
self._train_metrics = model_data.get("train_metrics", {})
|
||||
self.fitted = model_data.get("fitted", False)
|
||||
|
||||
logger.info(f"Model loaded from {load_path}")
|
||||
logger.info(f" Type: {self.model_type.value}")
|
||||
if self._train_metrics:
|
||||
for key, val in self._train_metrics.items():
|
||||
if isinstance(val, float):
|
||||
logger.info(f" {key}: {val:.4f}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load model: {e}")
|
||||
|
||||
return self
|
||||
|
||||
def load_legacy_v1(self, path: str) -> "TradingModelV2":
|
||||
"""
|
||||
Load V1 TradingModel and convert to V2.
|
||||
|
||||
Args:
|
||||
path: Path to V1 model .pkl file
|
||||
|
||||
Returns:
|
||||
Self with V1 model loaded as XGBoost binary
|
||||
"""
|
||||
load_path = Path(path).with_suffix(".pkl")
|
||||
|
||||
if not load_path.exists():
|
||||
logger.warning(f"V1 model file not found: {load_path}")
|
||||
return self
|
||||
|
||||
try:
|
||||
with open(load_path, "rb") as f:
|
||||
v1_data = pickle.load(f)
|
||||
|
||||
# V1 structure: {"model": xgb.Booster, "feature_names": [], ...}
|
||||
self.model_type = ModelType.XGBOOST_BINARY
|
||||
self.xgb_model = v1_data.get("model")
|
||||
self.feature_names = v1_data.get("feature_names", [])
|
||||
self.confidence_threshold = v1_data.get("confidence_threshold", 0.65)
|
||||
self.xgb_params = v1_data.get("params", {})
|
||||
self._feature_importance = v1_data.get("feature_importance", {})
|
||||
self._train_metrics = v1_data.get("train_metrics", {})
|
||||
self.fitted = v1_data.get("fitted", self.xgb_model is not None)
|
||||
|
||||
logger.info(f"V1 model loaded and converted from {load_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load V1 model: {e}")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test V2 model
|
||||
import numpy as np
|
||||
|
||||
np.random.seed(42)
|
||||
n = 500
|
||||
|
||||
# Synthetic features
|
||||
df = pl.DataFrame({
|
||||
"rsi": np.random.uniform(20, 80, n),
|
||||
"atr": np.random.uniform(0.5, 2.0, n),
|
||||
"macd": np.random.randn(n) * 0.001,
|
||||
"returns_1": np.random.randn(n) * 0.01,
|
||||
})
|
||||
|
||||
# Binary target
|
||||
target_binary = ((df["rsi"].to_numpy() > 50).astype(int) * 0.5 +
|
||||
np.random.randint(0, 2, n) * 0.5)
|
||||
target_binary = (target_binary > 0.5).astype(int)
|
||||
df = df.with_columns([pl.Series("multi_bar_target", target_binary)])
|
||||
|
||||
# 3-class target
|
||||
target_3class = np.random.choice([0, 1, 2], n)
|
||||
df = df.with_columns([pl.Series("target_3class", target_3class)])
|
||||
|
||||
feature_cols = ["rsi", "atr", "macd", "returns_1"]
|
||||
|
||||
# Test XGBoost binary
|
||||
print("\n=== Testing XGBoost Binary ===")
|
||||
model_xgb = TradingModelV2(
|
||||
model_type=ModelType.XGBOOST_BINARY,
|
||||
model_path="models/test_v2_xgb.pkl"
|
||||
)
|
||||
model_xgb.fit(df, feature_cols, "multi_bar_target")
|
||||
pred = model_xgb.predict(df, feature_cols)
|
||||
print(f"Prediction: {pred.signal} ({pred.confidence:.2%})")
|
||||
|
||||
# Test XGBoost 3-class
|
||||
print("\n=== Testing XGBoost 3-Class ===")
|
||||
model_3class = TradingModelV2(
|
||||
model_type=ModelType.XGBOOST_3CLASS,
|
||||
model_path="models/test_v2_3class.pkl"
|
||||
)
|
||||
model_3class.fit(df, feature_cols, "target_3class")
|
||||
pred = model_3class.predict(df, feature_cols)
|
||||
print(f"Prediction: {pred.signal} ({pred.confidence:.2%})")
|
||||
if pred.probabilities:
|
||||
print(f"Probabilities: {pred.probabilities}")
|
||||
|
||||
# Test LightGBM (if available)
|
||||
if lgb is not None:
|
||||
print("\n=== Testing LightGBM Binary ===")
|
||||
model_lgb = TradingModelV2(
|
||||
model_type=ModelType.LIGHTGBM_BINARY,
|
||||
model_path="models/test_v2_lgb.pkl"
|
||||
)
|
||||
model_lgb.fit(df, feature_cols, "multi_bar_target")
|
||||
pred = model_lgb.predict(df, feature_cols)
|
||||
print(f"Prediction: {pred.signal} ({pred.confidence:.2%})")
|
||||
|
||||
# Test Ensemble
|
||||
print("\n=== Testing Ensemble ===")
|
||||
model_ensemble = TradingModelV2(
|
||||
model_type=ModelType.ENSEMBLE,
|
||||
model_path="models/test_v2_ensemble.pkl"
|
||||
)
|
||||
model_ensemble.fit(df, feature_cols, "multi_bar_target")
|
||||
pred = model_ensemble.predict(df, feature_cols)
|
||||
print(f"Prediction: {pred.signal} ({pred.confidence:.2%})")
|
||||
else:
|
||||
print("\n[SKIP] LightGBM not installed")
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
ML V2 Target Builder
|
||||
====================
|
||||
Better target variables to reduce noise and improve ML predictive power.
|
||||
|
||||
Problem with current target (src/feature_eng.py::create_target):
|
||||
- Predicts 1-bar ahead price movement with threshold=0 → too noisy
|
||||
- Captures noise, not tradeable moves
|
||||
|
||||
Solutions:
|
||||
A. Multi-bar + ATR threshold (primary)
|
||||
B. 3-class target (BUY/SELL/HOLD)
|
||||
C. Baseline (current method for comparison)
|
||||
"""
|
||||
|
||||
import polars as pl
|
||||
import numpy as np
|
||||
from typing import Tuple, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TargetBuilder:
|
||||
"""
|
||||
Builder for improved target variables.
|
||||
|
||||
Key improvements:
|
||||
1. Multi-bar lookahead (reduces noise)
|
||||
2. ATR-based threshold (filters small moves)
|
||||
3. 3-class option (explicit HOLD class)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize target builder."""
|
||||
pass
|
||||
|
||||
def create_multi_bar_target(
|
||||
self,
|
||||
df: pl.DataFrame,
|
||||
lookahead: int = 3,
|
||||
threshold_atr_mult: float = 0.3,
|
||||
) -> pl.DataFrame:
|
||||
"""
|
||||
Create multi-bar binary target with ATR-based threshold.
|
||||
|
||||
Logic:
|
||||
- Look at next `lookahead` bars (default 3 = 45 min on M15)
|
||||
- Find max close in that window
|
||||
- UP (1) if: max_future_close - current_close > threshold * ATR
|
||||
- DOWN (0) if: current_close - min_future_close > threshold * ATR
|
||||
- Filtered out: moves smaller than threshold (noise)
|
||||
|
||||
Why this works:
|
||||
- Multi-bar: reduces bar-to-bar noise
|
||||
- ATR threshold: filters moves too small to trade profitably
|
||||
- For XAUUSD @ ATR ~$12, threshold=0.3 means $3.6 minimum move
|
||||
|
||||
Args:
|
||||
df: DataFrame with OHLCV and ATR
|
||||
lookahead: Number of bars to look ahead (default 3)
|
||||
threshold_atr_mult: ATR multiplier for minimum move (default 0.3)
|
||||
|
||||
Returns:
|
||||
DataFrame with multi_bar_target column (1=UP, 0=DOWN, null=HOLD/filtered)
|
||||
"""
|
||||
# Ensure ATR exists
|
||||
if "atr" not in df.columns:
|
||||
logger.error("ATR column required for multi-bar target")
|
||||
return df
|
||||
|
||||
# Calculate future max/min close in lookahead window
|
||||
df = df.with_columns([
|
||||
# Rolling max of future closes (reverse window)
|
||||
pl.col("close").shift(-lookahead).alias("_future_start_close"),
|
||||
pl.col("close").shift(-1).alias("_future_1"),
|
||||
pl.col("close").shift(-2).alias("_future_2") if lookahead >= 2 else pl.col("close").alias("_future_2"),
|
||||
pl.col("close").shift(-3).alias("_future_3") if lookahead >= 3 else pl.col("close").alias("_future_3"),
|
||||
])
|
||||
|
||||
# Get max and min across future window
|
||||
if lookahead == 1:
|
||||
df = df.with_columns([
|
||||
pl.col("_future_1").alias("_max_future_close"),
|
||||
pl.col("_future_1").alias("_min_future_close"),
|
||||
])
|
||||
elif lookahead == 2:
|
||||
df = df.with_columns([
|
||||
pl.max_horizontal("_future_1", "_future_2").alias("_max_future_close"),
|
||||
pl.min_horizontal("_future_1", "_future_2").alias("_min_future_close"),
|
||||
])
|
||||
else: # lookahead >= 3
|
||||
df = df.with_columns([
|
||||
pl.max_horizontal("_future_1", "_future_2", "_future_3").alias("_max_future_close"),
|
||||
pl.min_horizontal("_future_1", "_future_2", "_future_3").alias("_min_future_close"),
|
||||
])
|
||||
|
||||
# Calculate move sizes
|
||||
df = df.with_columns([
|
||||
(pl.col("_max_future_close") - pl.col("close")).alias("_up_move"),
|
||||
(pl.col("close") - pl.col("_min_future_close")).alias("_down_move"),
|
||||
])
|
||||
|
||||
# Calculate threshold (ATR * multiplier)
|
||||
df = df.with_columns([
|
||||
(pl.col("atr") * threshold_atr_mult).alias("_threshold"),
|
||||
])
|
||||
|
||||
# Create target:
|
||||
# - UP (1): if up_move > threshold AND up_move > down_move
|
||||
# - DOWN (0): if down_move > threshold AND down_move > up_move
|
||||
# - null: otherwise (filtered as noise)
|
||||
df = df.with_columns([
|
||||
pl.when(
|
||||
(pl.col("_up_move") > pl.col("_threshold")) &
|
||||
(pl.col("_up_move") > pl.col("_down_move"))
|
||||
)
|
||||
.then(1)
|
||||
.when(
|
||||
(pl.col("_down_move") > pl.col("_threshold")) &
|
||||
(pl.col("_down_move") > pl.col("_up_move"))
|
||||
)
|
||||
.then(0)
|
||||
.otherwise(None) # Filter out noise
|
||||
.alias("multi_bar_target")
|
||||
.cast(pl.Int32),
|
||||
])
|
||||
|
||||
# Drop temporary columns
|
||||
df = df.drop([
|
||||
"_future_start_close", "_future_1", "_future_2", "_future_3",
|
||||
"_max_future_close", "_min_future_close",
|
||||
"_up_move", "_down_move", "_threshold"
|
||||
])
|
||||
|
||||
# Log statistics
|
||||
total = len(df)
|
||||
ups = df.filter(pl.col("multi_bar_target") == 1).height
|
||||
downs = df.filter(pl.col("multi_bar_target") == 0).height
|
||||
filtered = total - ups - downs
|
||||
|
||||
logger.info(
|
||||
f"Multi-bar target (lookahead={lookahead}, threshold={threshold_atr_mult}*ATR): "
|
||||
f"{ups} UP ({ups/total*100:.1f}%), "
|
||||
f"{downs} DOWN ({downs/total*100:.1f}%), "
|
||||
f"{filtered} filtered ({filtered/total*100:.1f}%)"
|
||||
)
|
||||
|
||||
return df
|
||||
|
||||
def create_3class_target(
|
||||
self,
|
||||
df: pl.DataFrame,
|
||||
lookahead: int = 3,
|
||||
threshold_atr_mult: float = 0.3,
|
||||
) -> pl.DataFrame:
|
||||
"""
|
||||
Create 3-class target: BUY (0), SELL (1), HOLD (2).
|
||||
|
||||
Same logic as multi_bar_target but keeps HOLD as explicit class
|
||||
instead of filtering it out.
|
||||
|
||||
Use with XGBoost multi:softprob objective.
|
||||
|
||||
Args:
|
||||
df: DataFrame with OHLCV and ATR
|
||||
lookahead: Number of bars to look ahead
|
||||
threshold_atr_mult: ATR multiplier for threshold
|
||||
|
||||
Returns:
|
||||
DataFrame with target_3class column (0=BUY, 1=SELL, 2=HOLD)
|
||||
"""
|
||||
# Reuse multi_bar logic but map null to HOLD (2)
|
||||
df = self.create_multi_bar_target(df, lookahead, threshold_atr_mult)
|
||||
|
||||
# Convert to 3-class: 0=BUY, 1=SELL, 2=HOLD
|
||||
df = df.with_columns([
|
||||
pl.when(pl.col("multi_bar_target") == 1)
|
||||
.then(0) # UP → BUY
|
||||
.when(pl.col("multi_bar_target") == 0)
|
||||
.then(1) # DOWN → SELL
|
||||
.otherwise(2) # null → HOLD
|
||||
.alias("target_3class")
|
||||
.cast(pl.Int32),
|
||||
])
|
||||
|
||||
# Log distribution
|
||||
total = len(df)
|
||||
buys = df.filter(pl.col("target_3class") == 0).height
|
||||
sells = df.filter(pl.col("target_3class") == 1).height
|
||||
holds = df.filter(pl.col("target_3class") == 2).height
|
||||
|
||||
logger.info(
|
||||
f"3-class target: "
|
||||
f"{buys} BUY ({buys/total*100:.1f}%), "
|
||||
f"{sells} SELL ({sells/total*100:.1f}%), "
|
||||
f"{holds} HOLD ({holds/total*100:.1f}%)"
|
||||
)
|
||||
|
||||
return df
|
||||
|
||||
def create_baseline_target(
|
||||
self,
|
||||
df: pl.DataFrame,
|
||||
lookahead: int = 1,
|
||||
threshold: float = 0.0,
|
||||
) -> pl.DataFrame:
|
||||
"""
|
||||
Create baseline target (mirrors current FeatureEngineer.create_target()).
|
||||
|
||||
For comparison with V1 model.
|
||||
|
||||
Args:
|
||||
df: DataFrame with price data
|
||||
lookahead: Bars to look ahead (default 1)
|
||||
threshold: Minimum return threshold (default 0.0)
|
||||
|
||||
Returns:
|
||||
DataFrame with baseline_target column
|
||||
"""
|
||||
df = df.with_columns([
|
||||
pl.col("close").shift(-lookahead).alias("_future_close"),
|
||||
])
|
||||
|
||||
df = df.with_columns([
|
||||
((pl.col("_future_close") / pl.col("close") - 1) > threshold)
|
||||
.cast(pl.Int32)
|
||||
.alias("baseline_target"),
|
||||
])
|
||||
|
||||
df = df.drop(["_future_close"])
|
||||
|
||||
ups = df.filter(pl.col("baseline_target") == 1).height
|
||||
total = len(df)
|
||||
logger.info(
|
||||
f"Baseline target (lookahead={lookahead}, threshold={threshold}): "
|
||||
f"{ups} UP ({ups/total*100:.1f}%), {total-ups} DOWN ({(total-ups)/total*100:.1f}%)"
|
||||
)
|
||||
|
||||
return df
|
||||
|
||||
def create_all_targets(
|
||||
self,
|
||||
df: pl.DataFrame,
|
||||
lookahead: int = 3,
|
||||
threshold_atr_mult: float = 0.3,
|
||||
) -> pl.DataFrame:
|
||||
"""
|
||||
Create all target variants for comparison.
|
||||
|
||||
Args:
|
||||
df: DataFrame with OHLCV and ATR
|
||||
lookahead: Lookahead for multi-bar targets
|
||||
threshold_atr_mult: ATR threshold multiplier
|
||||
|
||||
Returns:
|
||||
DataFrame with all target columns added
|
||||
"""
|
||||
logger.info(f"Creating all target variants (lookahead={lookahead}, threshold={threshold_atr_mult}*ATR)...")
|
||||
|
||||
# Baseline (V1)
|
||||
df = self.create_baseline_target(df, lookahead=1, threshold=0.0)
|
||||
|
||||
# Multi-bar binary
|
||||
df = self.create_multi_bar_target(df, lookahead=lookahead, threshold_atr_mult=threshold_atr_mult)
|
||||
|
||||
# 3-class
|
||||
df = self.create_3class_target(df, lookahead=lookahead, threshold_atr_mult=threshold_atr_mult)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test target builder
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Create synthetic OHLCV data with trend
|
||||
np.random.seed(42)
|
||||
n = 500
|
||||
|
||||
base_price = 2000.0
|
||||
# Add uptrend
|
||||
trend = np.linspace(0, 50, n)
|
||||
noise = np.random.randn(n) * 5
|
||||
prices = base_price + trend + noise
|
||||
|
||||
# Create ATR (realistic for XAUUSD)
|
||||
atr_values = np.random.uniform(10, 14, n)
|
||||
|
||||
df = pl.DataFrame({
|
||||
"time": [datetime.now() - timedelta(minutes=15*i) for i in range(n-1, -1, -1)],
|
||||
"open": prices,
|
||||
"high": prices + np.abs(np.random.randn(n)) * 2,
|
||||
"low": prices - np.abs(np.random.randn(n)) * 2,
|
||||
"close": prices + np.random.randn(n) * 1,
|
||||
"volume": np.random.randint(1000, 10000, n),
|
||||
"atr": atr_values,
|
||||
})
|
||||
|
||||
# Build targets
|
||||
builder = TargetBuilder()
|
||||
df = builder.create_all_targets(df, lookahead=3, threshold_atr_mult=0.3)
|
||||
|
||||
# Show comparison
|
||||
print("\n=== Target Builder Test ===")
|
||||
print(f"Total bars: {len(df)}")
|
||||
print(f"\nTarget columns created:")
|
||||
print(f" - baseline_target (1-bar, threshold=0)")
|
||||
print(f" - multi_bar_target (3-bar, 0.3*ATR threshold)")
|
||||
print(f" - target_3class (3-class version)")
|
||||
|
||||
# Sample
|
||||
print("\n=== Sample Data (Last 10 Rows) ===")
|
||||
cols = ["time", "close", "atr", "baseline_target", "multi_bar_target", "target_3class"]
|
||||
print(df.select([c for c in cols if c in df.columns]).tail(10))
|
||||
|
||||
# Class distribution comparison
|
||||
print("\n=== Class Distribution ===")
|
||||
|
||||
baseline_up = df.filter(pl.col("baseline_target") == 1).height
|
||||
baseline_down = len(df) - baseline_up
|
||||
print(f"Baseline: {baseline_up} UP, {baseline_down} DOWN")
|
||||
|
||||
multi_up = df.filter(pl.col("multi_bar_target") == 1).height
|
||||
multi_down = df.filter(pl.col("multi_bar_target") == 0).height
|
||||
multi_filtered = len(df) - multi_up - multi_down
|
||||
print(f"Multi-bar: {multi_up} UP, {multi_down} DOWN, {multi_filtered} filtered")
|
||||
|
||||
class3_buy = df.filter(pl.col("target_3class") == 0).height
|
||||
class3_sell = df.filter(pl.col("target_3class") == 1).height
|
||||
class3_hold = df.filter(pl.col("target_3class") == 2).height
|
||||
print(f"3-class: {class3_buy} BUY, {class3_sell} SELL, {class3_hold} HOLD")
|
||||
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
ML V2 Training Pipeline
|
||||
========================
|
||||
Training pipeline with purged walk-forward CV and experiment configs.
|
||||
|
||||
Experiment Configs:
|
||||
- Baseline: 1-bar target + 37 base features + XGBoost (V1 reproduction)
|
||||
- A: 3-bar target + 37 base features + XGBoost
|
||||
- B: 3-bar target + 45 features (37 + 8 H1) + XGBoost
|
||||
- C: 3-bar target + 52 features (45 + 7 SMC) + XGBoost
|
||||
- D: 3-bar target + 60 features (52 + 8 regime/PA) + XGBoost
|
||||
- E: 3-bar target + 60 features + Ensemble (XGB + LGBM)
|
||||
"""
|
||||
|
||||
import polars as pl
|
||||
import numpy as np
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from loguru import logger
|
||||
|
||||
from .ml_v2_target import TargetBuilder
|
||||
from .ml_v2_feature_eng import MLV2FeatureEngineer
|
||||
from .ml_v2_model import TradingModelV2, ModelType
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExperimentConfig:
|
||||
"""Configuration for a training experiment."""
|
||||
name: str
|
||||
target_col: str # Which target to use
|
||||
feature_cols: List[str] # Which features to use
|
||||
model_type: ModelType
|
||||
lookahead: int = 3 # For target creation
|
||||
threshold_atr_mult: float = 0.3 # For target creation
|
||||
|
||||
|
||||
class MLV2Trainer:
|
||||
"""
|
||||
ML V2 Training Pipeline.
|
||||
|
||||
Features:
|
||||
- Purged walk-forward CV (5 folds)
|
||||
- Gap between folds to prevent temporal leakage
|
||||
- Experiment comparison
|
||||
- Optional Boruta feature selection
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
train_size: int = 5000,
|
||||
test_size: int = 1000,
|
||||
gap_size: int = 50,
|
||||
n_folds: int = 5,
|
||||
):
|
||||
"""
|
||||
Initialize trainer.
|
||||
|
||||
Args:
|
||||
train_size: Samples per training fold
|
||||
test_size: Samples per test fold
|
||||
gap_size: Gap between train and test (prevent leakage)
|
||||
n_folds: Number of CV folds
|
||||
"""
|
||||
self.train_size = train_size
|
||||
self.test_size = test_size
|
||||
self.gap_size = gap_size
|
||||
self.n_folds = n_folds
|
||||
|
||||
def purged_walk_forward_cv(
|
||||
self,
|
||||
df: pl.DataFrame,
|
||||
feature_cols: List[str],
|
||||
target_col: str,
|
||||
model_type: ModelType,
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Purged walk-forward cross-validation.
|
||||
|
||||
Splits data into `n_folds` sequential folds with:
|
||||
- `train_size` samples for training
|
||||
- `gap_size` samples skipped (purge)
|
||||
- `test_size` samples for testing
|
||||
|
||||
Args:
|
||||
df: DataFrame with features and target
|
||||
feature_cols: Feature columns
|
||||
target_col: Target column
|
||||
model_type: Model type to train
|
||||
|
||||
Returns:
|
||||
Dict with mean/std of train/test AUC and overfitting ratio
|
||||
"""
|
||||
logger.info(f"Starting purged walk-forward CV ({self.n_folds} folds)...")
|
||||
|
||||
# Drop nulls
|
||||
df_clean = df.select(feature_cols + [target_col]).drop_nulls()
|
||||
|
||||
if len(df_clean) < self.train_size + self.gap_size + self.test_size:
|
||||
logger.error(f"Insufficient data for CV: {len(df_clean)} samples")
|
||||
return {}
|
||||
|
||||
train_scores = []
|
||||
test_scores = []
|
||||
|
||||
fold_step = self.train_size + self.gap_size + self.test_size
|
||||
|
||||
for fold in range(self.n_folds):
|
||||
start_idx = fold * fold_step
|
||||
|
||||
if start_idx + fold_step > len(df_clean):
|
||||
logger.warning(f"Fold {fold+1}: Not enough data, skipping")
|
||||
break
|
||||
|
||||
train_end = start_idx + self.train_size
|
||||
test_start = train_end + self.gap_size
|
||||
test_end = test_start + self.test_size
|
||||
|
||||
# Extract fold data
|
||||
df_train = df_clean.slice(start_idx, self.train_size)
|
||||
df_test = df_clean.slice(test_start, self.test_size)
|
||||
|
||||
logger.info(f" Fold {fold+1}/{self.n_folds}: Train [{start_idx}:{train_end}], Test [{test_start}:{test_end}]")
|
||||
|
||||
# Train model
|
||||
model = TradingModelV2(model_type=model_type)
|
||||
model.fit(
|
||||
df_train,
|
||||
feature_cols,
|
||||
target_col,
|
||||
train_ratio=1.0, # Use all training data
|
||||
num_boost_round=100,
|
||||
early_stopping_rounds=None, # No early stopping in CV
|
||||
)
|
||||
|
||||
if not model.fitted:
|
||||
logger.warning(f" Fold {fold+1}: Model failed to fit")
|
||||
continue
|
||||
|
||||
# Extract features and targets
|
||||
X_train = df_train.select(feature_cols).to_numpy()
|
||||
y_train = df_train.select(target_col).to_numpy().ravel()
|
||||
X_test = df_test.select(feature_cols).to_numpy()
|
||||
y_test = df_test.select(target_col).to_numpy().ravel()
|
||||
|
||||
X_train = np.nan_to_num(X_train, nan=0.0)
|
||||
X_test = np.nan_to_num(X_test, nan=0.0)
|
||||
|
||||
# Evaluate
|
||||
train_score = self._evaluate_binary(model, X_train, y_train)
|
||||
test_score = self._evaluate_binary(model, X_test, y_test)
|
||||
|
||||
train_scores.append(train_score)
|
||||
test_scores.append(test_score)
|
||||
|
||||
logger.info(f" Fold {fold+1}: Train AUC={train_score:.4f}, Test AUC={test_score:.4f}")
|
||||
|
||||
if not train_scores:
|
||||
logger.error("No folds completed successfully")
|
||||
return {}
|
||||
|
||||
# Compute statistics
|
||||
mean_train = np.mean(train_scores)
|
||||
std_train = np.std(train_scores)
|
||||
mean_test = np.mean(test_scores)
|
||||
std_test = np.std(test_scores)
|
||||
overfitting_ratio = mean_train / mean_test if mean_test > 0 else 999.0
|
||||
|
||||
results = {
|
||||
"mean_train_auc": mean_train,
|
||||
"std_train_auc": std_train,
|
||||
"mean_test_auc": mean_test,
|
||||
"std_test_auc": std_test,
|
||||
"overfitting_ratio": overfitting_ratio,
|
||||
"n_folds": len(train_scores),
|
||||
}
|
||||
|
||||
logger.info(f"CV Results: Train AUC={mean_train:.4f}±{std_train:.4f}, "
|
||||
f"Test AUC={mean_test:.4f}±{std_test:.4f}, "
|
||||
f"Overfit Ratio={overfitting_ratio:.2f}")
|
||||
|
||||
return results
|
||||
|
||||
def _evaluate_binary(self, model: TradingModelV2, X, y) -> float:
|
||||
"""Evaluate binary classification model."""
|
||||
try:
|
||||
from sklearn.metrics import roc_auc_score
|
||||
import xgboost as xgb
|
||||
|
||||
if model.xgb_model is not None:
|
||||
dmatrix = xgb.DMatrix(X, feature_names=model.feature_names)
|
||||
preds = model.xgb_model.predict(dmatrix)
|
||||
return roc_auc_score(y, preds)
|
||||
elif model.lgb_model is not None:
|
||||
preds = model.lgb_model.predict(X)
|
||||
return roc_auc_score(y, preds)
|
||||
else:
|
||||
return 0.5
|
||||
except Exception as e:
|
||||
logger.warning(f"Evaluation error: {e}")
|
||||
return 0.5
|
||||
|
||||
def train_experiment(
|
||||
self,
|
||||
config: ExperimentConfig,
|
||||
df: pl.DataFrame,
|
||||
save_path: Optional[str] = None,
|
||||
run_cv: bool = True,
|
||||
) -> Tuple[TradingModelV2, Dict]:
|
||||
"""
|
||||
Train a single experiment config.
|
||||
|
||||
Args:
|
||||
config: Experiment configuration
|
||||
df: DataFrame with all features and targets
|
||||
save_path: Path to save trained model
|
||||
run_cv: Whether to run cross-validation
|
||||
|
||||
Returns:
|
||||
Tuple of (trained model, CV results dict)
|
||||
"""
|
||||
logger.info(f"\n{'='*60}")
|
||||
logger.info(f"Training Experiment: {config.name}")
|
||||
logger.info(f" Target: {config.target_col}")
|
||||
logger.info(f" Features: {len(config.feature_cols)}")
|
||||
logger.info(f" Model: {config.model_type.value}")
|
||||
logger.info(f"{'='*60}")
|
||||
|
||||
# Cross-validation
|
||||
cv_results = {}
|
||||
if run_cv:
|
||||
cv_results = self.purged_walk_forward_cv(
|
||||
df,
|
||||
config.feature_cols,
|
||||
config.target_col,
|
||||
config.model_type,
|
||||
)
|
||||
|
||||
# Train final model on all data
|
||||
logger.info("Training final model on all data...")
|
||||
model = TradingModelV2(
|
||||
model_type=config.model_type,
|
||||
model_path=save_path,
|
||||
)
|
||||
|
||||
model.fit(
|
||||
df,
|
||||
config.feature_cols,
|
||||
config.target_col,
|
||||
train_ratio=0.8,
|
||||
num_boost_round=100,
|
||||
early_stopping_rounds=10,
|
||||
)
|
||||
|
||||
logger.info(f"Experiment {config.name} complete!")
|
||||
|
||||
return model, cv_results
|
||||
|
||||
|
||||
def get_baseline_config(base_features: List[str]) -> ExperimentConfig:
|
||||
"""Get baseline (V1) experiment config."""
|
||||
return ExperimentConfig(
|
||||
name="Baseline (V1)",
|
||||
target_col="baseline_target",
|
||||
feature_cols=base_features,
|
||||
model_type=ModelType.XGBOOST_BINARY,
|
||||
lookahead=1,
|
||||
threshold_atr_mult=0.0,
|
||||
)
|
||||
|
||||
|
||||
def get_config_a(base_features: List[str]) -> ExperimentConfig:
|
||||
"""Config A: Better target, same features."""
|
||||
return ExperimentConfig(
|
||||
name="A: Better Target",
|
||||
target_col="multi_bar_target",
|
||||
feature_cols=base_features,
|
||||
model_type=ModelType.XGBOOST_BINARY,
|
||||
lookahead=3,
|
||||
threshold_atr_mult=0.3,
|
||||
)
|
||||
|
||||
|
||||
def get_config_b(base_features: List[str], h1_features: List[str]) -> ExperimentConfig:
|
||||
"""Config B: Better target + H1 features."""
|
||||
features = base_features + h1_features
|
||||
return ExperimentConfig(
|
||||
name="B: +H1 Features",
|
||||
target_col="multi_bar_target",
|
||||
feature_cols=features,
|
||||
model_type=ModelType.XGBOOST_BINARY,
|
||||
lookahead=3,
|
||||
threshold_atr_mult=0.3,
|
||||
)
|
||||
|
||||
|
||||
def get_config_c(base_features: List[str], h1_features: List[str], smc_features: List[str]) -> ExperimentConfig:
|
||||
"""Config C: Better target + H1 + continuous SMC."""
|
||||
features = base_features + h1_features + smc_features
|
||||
return ExperimentConfig(
|
||||
name="C: +Continuous SMC",
|
||||
target_col="multi_bar_target",
|
||||
feature_cols=features,
|
||||
model_type=ModelType.XGBOOST_BINARY,
|
||||
lookahead=3,
|
||||
threshold_atr_mult=0.3,
|
||||
)
|
||||
|
||||
|
||||
def get_config_d(
|
||||
base_features: List[str],
|
||||
h1_features: List[str],
|
||||
smc_features: List[str],
|
||||
regime_features: List[str],
|
||||
pa_features: List[str],
|
||||
) -> ExperimentConfig:
|
||||
"""Config D: All features."""
|
||||
features = base_features + h1_features + smc_features + regime_features + pa_features
|
||||
return ExperimentConfig(
|
||||
name="D: All 60 Features",
|
||||
target_col="multi_bar_target",
|
||||
feature_cols=features,
|
||||
model_type=ModelType.XGBOOST_BINARY,
|
||||
lookahead=3,
|
||||
threshold_atr_mult=0.3,
|
||||
)
|
||||
|
||||
|
||||
def get_config_e(
|
||||
base_features: List[str],
|
||||
h1_features: List[str],
|
||||
smc_features: List[str],
|
||||
regime_features: List[str],
|
||||
pa_features: List[str],
|
||||
) -> ExperimentConfig:
|
||||
"""Config E: All features + ensemble."""
|
||||
features = base_features + h1_features + smc_features + regime_features + pa_features
|
||||
return ExperimentConfig(
|
||||
name="E: Ensemble",
|
||||
target_col="multi_bar_target",
|
||||
feature_cols=features,
|
||||
model_type=ModelType.ENSEMBLE,
|
||||
lookahead=3,
|
||||
threshold_atr_mult=0.3,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test training pipeline
|
||||
import numpy as np
|
||||
|
||||
np.random.seed(42)
|
||||
n = 10000
|
||||
|
||||
# Synthetic data with 40 features
|
||||
feature_data = {}
|
||||
for i in range(40):
|
||||
feature_data[f"feat_{i}"] = np.random.randn(n)
|
||||
|
||||
df = pl.DataFrame(feature_data)
|
||||
|
||||
# Add target
|
||||
target = (df["feat_0"].to_numpy() + df["feat_1"].to_numpy() > 0).astype(int)
|
||||
df = df.with_columns([pl.Series("multi_bar_target", target)])
|
||||
|
||||
# Test config
|
||||
config = ExperimentConfig(
|
||||
name="Test",
|
||||
target_col="multi_bar_target",
|
||||
feature_cols=[f"feat_{i}" for i in range(10)],
|
||||
model_type=ModelType.XGBOOST_BINARY,
|
||||
)
|
||||
|
||||
# Train
|
||||
trainer = MLV2Trainer(train_size=1000, test_size=200, gap_size=50, n_folds=3)
|
||||
model, cv_results = trainer.train_experiment(config, df, run_cv=True)
|
||||
|
||||
print(f"\nCV Results: {cv_results}")
|
||||
print(f"Model fitted: {model.fitted}")
|
||||
Reference in New Issue
Block a user