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:
GifariKemal
2026-02-09 05:46:54 +07:00
co-authored by Claude Opus 4.6
parent b2dc2dacd7
commit e8355b3f62
230 changed files with 69573 additions and 5673 deletions
+284
View File
@@ -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`
+26
View File
@@ -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",
]
+756
View File
@@ -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! ✓")
+644
View File
@@ -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")
+331
View File
@@ -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")
+379
View File
@@ -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}")