Files
xau-ai-trading-bot/backtests/ml_v2/ml_v2_model.py
T
buckybonez c0976c4518 feat: implement Professor AI recommendations v0.2.2 (5 critical fixes)
Exit Strategy v6.6 "Professor AI Validated" - All recommendations implemented

FIX #1: Remove Misleading Debug Code
- Removed manual trajectory calculation (line 1262-1269)
- Trajectory predictor was CORRECT, debug comparison was WRONG
- Cleaned up false "bug found" warnings

FIX #2: Peak Detection Logic (CHECK 0A.4)
- Detects approaching peak (vel > 0, accel < 0)
- Holds position if peak within 30s and 15%+ profit ahead
- Suppresses fuzzy exits during peak approach
- Target: Peak capture 38% -> 70%+
- Added peak_hold_active field to PositionGuard

FIX #3: London False Breakout Filter
- London session + ATR ratio < 1.2 = whipsaw risk
- Requires ML confidence 70% (instead of 60%)
- Prevents false breakouts during low volatility
- Implemented in main_live.py before signal logic

FIX #4: Enhanced Kelly Partial Exit Strategy
- Active for all profits >= tp_min * 0.5 (not just >$8)
- Recommends partial exits for better peak capture
- Full exit when Kelly suggests >70% close
- Note: Actual partial close needs MT5 volume parameter (TODO)

FIX #5: Unicode Encoding Fixes
- Added UTF-8 encoding to file logger
- Replaced all emoji (⚠️ -> [WARNING]) and arrows (-> -> ->)
- No more UnicodeEncodeError on Windows console
- Fixed in 11 src/*.py files

Expected Performance:
- Peak Capture: 38% -> 70%+ (+84%)
- Avg Profit: $2.00 -> $4.50 (+125%)
- Risk/Reward: 0.49 -> 1.2+ (+145%)
- Win Rate: Maintain 76%

Files Modified:
- src/smart_risk_manager.py (peak detection, Kelly, unicode)
- src/trajectory_predictor.py (unicode arrows)
- main_live.py (London filter, UTF-8 encoding)
- src/*.py (unicode cleanup: 11 files)
- VERSION (0.2.1 -> 0.2.2)
- CHANGELOG.md (comprehensive v0.2.2 docs)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 18:16:34 +07:00

656 lines
22 KiB
Python

"""
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
# Check if model is XGBClassifier (sklearn API) or Booster (low-level API)
if hasattr(self.xgb_model, 'predict_proba'):
# XGBClassifier - use sklearn API directly
preds = self.xgb_model.predict_proba(X)
else:
# Booster - use low-level API with DMatrix
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 probability of class 1 (BUY)
if hasattr(self.xgb_model, 'predict_proba'):
# XGBClassifier returns [prob_class_0, prob_class_1]
return float(preds[0][1])
else:
# Booster returns single probability
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")