
Signal Classification
- 248 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
signal-classification is a Claude Code skill for training XGBoost/LightGBM trading classifiers with walk-forward validation, SHAP feature importance, and threshold optimization.
About
signal-classification is a Claude Code skill for building supervised machine-learning classifiers that predict whether an asset's price moves up or down over a forward horizon. It covers the full pipeline: label creation, XGBoost/LightGBM training, walk-forward validation with embargo gaps, feature importance, probability calibration, and threshold optimization. A developer uses it when turning trading features into a tested directional signal without introducing lookahead bias.
- XGBoost/LightGBM classifiers for up/down price signals
- Walk-forward validation with embargo to avoid lookahead bias
- Probability calibration and threshold optimization for trading
Signal Classification by the numbers
- 248 all-time installs (skills.sh)
- Ranked #364 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
signal-classification capabilities & compatibility
- Capabilities
- signal classification · walk forward validation · feature importance · threshold optimization · probability calibration
- Use cases
- trading · data analysis
- Pricing
- Free
What signal-classification says it does
Predict whether an asset's price will move up or down over a forward horizon using supervised machine learning classifiers.
**This is the single most important concept in trading ML.** Standard cross-validation randomly shuffles data, which creates lookahead bias.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill signal-classificationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 248 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Build and validate XGBoost/LightGBM classifiers that predict directional price moves for trading signals.
Who is it for?
Predicting up/down price moves from tabular trading features using gradient-boosted trees with proper time-series validation.
Skip if: Datasets far above 100k samples or deep-learning approaches, where the skill notes trees are not always the right tool.
When should I use this skill?
You have trading features and need a validated directional classifier that avoids lookahead bias.
What you get
An out-of-sample-validated directional classifier with calibrated probabilities and an optimized decision threshold.
- A trained directional classifier
- Out-of-sample walk-forward predictions and evaluation metrics
By the numbers
- Supports binary and multi-class labeling
- Default train window 30 days / test 7 days / gap = horizon
- Best for tabular features under 100k samples
Files
Signal Classification
Predict whether an asset's price will move up or down over a forward horizon using supervised machine learning classifiers. This skill covers the full pipeline: label creation, model training, walk-forward validation, feature importance analysis, and threshold optimization for trading applications.
Why Tree-Based Models Dominate Trading ML
XGBoost and LightGBM are the workhorses of quantitative trading ML for good reason:
- Non-linear relationships: Financial features interact in complex, non-linear ways that trees capture naturally
- Robust to feature scale: No need to normalize or standardize inputs — trees split on rank order
- Built-in feature importance: Understand which features drive predictions without separate analysis
- Fast training and inference: Train on thousands of samples in seconds, predict in microseconds
- Handle missing values: Native support for NaN without imputation hacks
- Regularization built in: max_depth, min_child_weight, subsample all prevent overfitting
Linear models and deep learning have their place, but for tabular trading features with fewer than 100k samples, gradient-boosted trees consistently outperform alternatives.
Classification Types
Binary Classification
The simplest and most common setup. Predict whether forward returns exceed a threshold:
- Up signal: forward return > +1%
- Down signal: forward return < -1%
- Neutral (excluded): -1% to +1% — drop these from training to create cleaner labels
import numpy as np
def create_binary_labels(
prices: np.ndarray, horizon: int = 24, threshold: float = 0.01
) -> np.ndarray:
"""Create binary labels from forward returns.
Args:
prices: Array of prices.
horizon: Forward return lookback in bars.
threshold: Minimum return magnitude for a label.
Returns:
Array of labels: 1 (up), 0 (down), NaN (neutral).
"""
fwd_returns = np.roll(prices, -horizon) / prices - 1
fwd_returns[-horizon:] = np.nan
labels = np.where(fwd_returns > threshold, 1,
np.where(fwd_returns < -threshold, 0, np.nan))
return labelsMulti-Class Classification
Three classes for finer signal granularity:
| Class | Condition | Typical threshold |
|---|---|---|
| Strong Up | fwd_return > +2% | High confidence long |
| Mild Up | +0.5% to +2% | Moderate confidence |
| Down | fwd_return < -0.5% | Avoid / short |
Multi-class reduces per-class sample size. Use only with large datasets (1000+ samples per class).
Probability Calibration
Raw model probabilities from XGBoost/LightGBM are not well-calibrated. A predicted 0.7 probability does not mean 70% chance of being correct. Use calibration to fix this:
from sklearn.calibration import CalibratedClassifierCV
calibrated = CalibratedClassifierCV(base_model, cv=5, method="isotonic")
calibrated.fit(X_train, y_train)
probs = calibrated.predict_proba(X_test)[:, 1]Isotonic calibration works better than Platt scaling for tree models.
Walk-Forward Validation
This is the single most important concept in trading ML. Standard cross-validation randomly shuffles data, which creates lookahead bias. Walk-forward validation respects time ordering.
How It Works
Window 1: [===TRAIN===][GAP][=TEST=]
Window 2: [===TRAIN===][GAP][=TEST=]
Window 3: [===TRAIN===][GAP][=TEST=]
Window 4: [===TRAIN===][GAP][=TEST=]Each window: 1. Train on past N bars 2. Skip a gap (embargo) equal to the forward return horizon 3. Predict on next M bars 4. Record out-of-sample predictions 5. Slide forward and repeat
Typical Parameters
| Parameter | Value | Rationale |
|---|---|---|
| Train window | 30 days (720 hourly bars) | Enough data to learn, recent enough to be relevant |
| Test window | 7 days (168 hourly bars) | Enough predictions for statistical significance |
| Step size | 1 day (24 bars) | Overlap test windows for more data points |
| Gap (embargo) | Same as forward horizon | Prevents label leakage |
Walk-Forward Implementation
from typing import Iterator
def walk_forward_splits(
n_samples: int,
train_size: int = 720,
test_size: int = 168,
step_size: int = 24,
gap: int = 24,
) -> Iterator[tuple[np.ndarray, np.ndarray]]:
"""Generate walk-forward train/test index splits.
Args:
n_samples: Total number of samples.
train_size: Number of training samples per window.
test_size: Number of test samples per window.
step_size: Step between successive windows.
gap: Gap between train end and test start.
Yields:
Tuples of (train_indices, test_indices).
"""
start = 0
while start + train_size + gap + test_size <= n_samples:
train_idx = np.arange(start, start + train_size)
test_start = start + train_size + gap
test_idx = np.arange(test_start, test_start + test_size)
yield train_idx, test_idx
start += step_sizeSee references/validation_methods.md for purged CV, CPCV, and evaluation metrics.
Model Training Pipeline
Full Pipeline Overview
1. Feature engineering — compute technical indicators, on-chain metrics, volume features (see feature-engineering skill) 2. Label creation — forward returns with threshold, drop neutral zone 3. Walk-forward split — time-ordered train/test windows with gap 4. Train model — XGBoost or LightGBM on each training window 5. Predict on test — generate out-of-sample probability predictions 6. Aggregate predictions — concatenate all out-of-sample results 7. Evaluate — accuracy, precision, recall, F1, AUC, profit factor
Quick Training Example
from xgboost import XGBClassifier
model = XGBClassifier(
n_estimators=200,
max_depth=4,
learning_rate=0.05,
subsample=0.8,
colsample_bytree=0.8,
eval_metric="logloss",
use_label_encoder=False,
random_state=42,
)
model.fit(
X_train, y_train,
eval_set=[(X_val, y_val)],
verbose=False,
)
probabilities = model.predict_proba(X_test)[:, 1]See references/model_guide.md for parameter recommendations and tuning.
SHAP Feature Importance
SHAP (SHapley Additive exPlanations) provides the gold standard for understanding model predictions.
Global Feature Importance
Which features matter most across all predictions:
import shap
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
# Summary plot (top 15 features)
shap.summary_plot(shap_values, X_test, max_display=15)Local Explanations
Why a specific prediction was made:
# Explain a single prediction
shap.force_plot(explainer.expected_value, shap_values[0], X_test.iloc[0])Temporal Feature Importance
Track how feature importance drifts over walk-forward windows. If a feature's importance drops significantly, the market regime may have shifted.
Threshold Optimization
The default 0.5 probability threshold is almost never optimal for trading.
Why Not 0.5?
- Class imbalance: if 60% of labels are "up", a 0.5 threshold is too aggressive
- Trading costs: marginal signals (0.51 probability) rarely cover transaction costs
- Asymmetric payoffs: precision matters more than recall for trading
Optimize for Profit Factor
def optimize_threshold(
probabilities: np.ndarray,
returns: np.ndarray,
thresholds: np.ndarray | None = None,
) -> tuple[float, float]:
"""Find threshold that maximizes profit factor.
Args:
probabilities: Model predicted probabilities.
returns: Actual forward returns.
thresholds: Thresholds to search over.
Returns:
Tuple of (best_threshold, best_profit_factor).
"""
if thresholds is None:
thresholds = np.arange(0.50, 0.85, 0.01)
best_threshold, best_pf = 0.5, 0.0
for t in thresholds:
signals = probabilities >= t
if signals.sum() < 10:
continue
signal_returns = returns[signals]
wins = signal_returns[signal_returns > 0].sum()
losses = abs(signal_returns[signal_returns < 0].sum())
pf = wins / losses if losses > 0 else 0.0
if pf > best_pf:
best_pf = pf
best_threshold = t
return best_threshold, best_pfTypical finding: optimal threshold is 0.60-0.75 for crypto trading signals.
Crypto-Specific Considerations
Short Training Windows
Crypto market regimes change fast. A model trained on 6 months of data may perform worse than one trained on 30 days. Use shorter training windows and retrain frequently.
Class Imbalance
Most time periods are "flat" (returns within the neutral zone). Strategies to handle this:
- Drop neutral zone: only train on clear up/down labels
- Undersample majority class:
scale_pos_weightin XGBoost - SMOTE: synthetic minority oversampling (use cautiously — can introduce lookahead)
- Adjust threshold: raise the probability threshold to compensate
Transaction Costs
A model with 55% accuracy sounds good, but after 0.5% round-trip costs (slippage + fees), many signals become unprofitable. Always evaluate signals net of costs:
net_return = gross_return - 0.005 # 50 bps round-tripFeature Decay
Features lose predictive power over time as more participants discover and trade on them. Monitor rolling performance and retrain when metrics degrade.
Integration with Other Skills
| Skill | Integration |
|---|---|
feature-engineering | Compute input features for the classifier |
vectorbt | Backtest trading strategies from ML signals |
regime-detection | Train separate models per regime, or use regime as a feature |
position-sizing | Size positions based on classifier confidence |
risk-management | Apply portfolio-level risk limits to ML-generated signals |
Files
References
references/model_guide.md— XGBoost and LightGBM parameter guide, tuning, and ensemblingreferences/validation_methods.md— Walk-forward, purged CV, CPCV, and evaluation metrics
Scripts
scripts/train_classifier.py— Train a signal classifier with walk-forward validation and feature importancescripts/walk_forward_backtest.py— Backtest ML signals vs buy-and-hold with walk-forward validation
Dependencies
# Core (required)
uv pip install pandas numpy scikit-learn
# Optional (recommended)
uv pip install xgboost lightgbm shapKey Takeaways
1. Walk-forward validation is non-negotiable — random CV will give you wildly inflated results 2. Optimize threshold for profit factor, not accuracy — a high-precision, low-recall model beats a high-accuracy one 3. Short training windows for crypto — 30 days beats 6 months in most regimes 4. Monitor feature decay — retrain when rolling metrics drop below baseline 5. Always evaluate net of costs — a 55% accurate model may be unprofitable after fees 6. SHAP over raw feature importance — SHAP gives consistent, theoretically grounded explanations
Signal Classification — Model Guide
XGBoost for Trading
Installation
uv pip install xgboostKey Parameters
| Parameter | Default | Recommended | Purpose |
|---|---|---|---|
n_estimators | 100 | 200 | Number of boosting rounds |
max_depth | 6 | 4 | Maximum tree depth (lower = less overfit) |
learning_rate | 0.3 | 0.05 | Step size shrinkage (lower = needs more rounds) |
subsample | 1.0 | 0.8 | Fraction of samples per tree |
colsample_bytree | 1.0 | 0.8 | Fraction of features per tree |
min_child_weight | 1 | 5 | Minimum sum of instance weight in a child |
gamma | 0 | 0.1 | Minimum loss reduction for a split |
reg_alpha | 0 | 0.01 | L1 regularization |
reg_lambda | 1 | 1.0 | L2 regularization |
scale_pos_weight | 1 | ratio neg/pos | Handles class imbalance |
Recommended Starting Configuration
from xgboost import XGBClassifier
model = XGBClassifier(
n_estimators=200,
max_depth=4,
learning_rate=0.05,
subsample=0.8,
colsample_bytree=0.8,
min_child_weight=5,
gamma=0.1,
reg_alpha=0.01,
reg_lambda=1.0,
eval_metric="logloss",
use_label_encoder=False,
random_state=42,
n_jobs=-1,
)Overfitting Control
- Early stopping: stop training when validation metric stops improving
model.fit(
X_train, y_train,
eval_set=[(X_val, y_val)],
verbose=False,
)- max_depth=3-5: deeper trees memorize noise
- min_child_weight=5-10: prevents splits on tiny groups
- subsample + colsample_bytree < 1.0: adds randomness, reduces variance
Feature Importance
# Gain-based importance (preferred)
importance = model.get_booster().get_score(importance_type="gain")
# Or via sklearn interface
importance = dict(zip(feature_names, model.feature_importances_))LightGBM for Trading
Installation
uv pip install lightgbmKey Parameters
| Parameter | Default | Recommended | Purpose |
|---|---|---|---|
num_leaves | 31 | 31 | Maximum leaves per tree (primary complexity control) |
n_estimators | 100 | 200 | Number of boosting rounds |
learning_rate | 0.1 | 0.05 | Step size shrinkage |
feature_fraction | 1.0 | 0.8 | Fraction of features per tree (= colsample_bytree) |
bagging_fraction | 1.0 | 0.8 | Fraction of data per tree (= subsample) |
bagging_freq | 0 | 5 | Perform bagging every N iterations |
min_child_samples | 20 | 20 | Minimum samples in a leaf |
reg_alpha | 0 | 0.01 | L1 regularization |
reg_lambda | 0 | 1.0 | L2 regularization |
max_depth | -1 | 6 | Max tree depth (-1 = unlimited) |
Recommended Starting Configuration
import lightgbm as lgb
model = lgb.LGBMClassifier(
num_leaves=31,
n_estimators=200,
learning_rate=0.05,
feature_fraction=0.8,
bagging_fraction=0.8,
bagging_freq=5,
min_child_samples=20,
reg_alpha=0.01,
reg_lambda=1.0,
max_depth=6,
random_state=42,
n_jobs=-1,
verbose=-1,
)LightGBM vs XGBoost Key Differences
- LightGBM uses leaf-wise growth (deeper, narrower trees); XGBoost uses level-wise
num_leavescontrols complexity in LightGBM;max_depthcontrols it in XGBoost- LightGBM is typically 2-5x faster on datasets > 10k samples
- Rule of thumb:
num_leavesshould be <2^max_depthto avoid overfitting
Comparison: XGBoost vs LightGBM
| Aspect | XGBoost | LightGBM |
|---|---|---|
| Training speed | Moderate | Fast (2-5x faster) |
| Memory usage | Higher | Lower |
| Accuracy | Very good | Very good (comparable) |
| Small datasets (<5k) | Slightly better | Good |
| Large datasets (>50k) | Good | Better |
| Missing values | Native support | Native support |
| Categorical features | Requires encoding | Native support |
| Community | Very large | Large |
| Interpretability | Good | Good |
Recommendation: Use XGBoost for small trading datasets (<10k samples). Use LightGBM for larger datasets or when training speed matters (e.g., hyperparameter search).
Hyperparameter Tuning with Optuna
Use walk-forward validation as the objective to avoid overfitting the hyperparameters:
import optuna
def objective(trial: optuna.Trial) -> float:
"""Optuna objective using walk-forward profit factor."""
params = {
"n_estimators": trial.suggest_int("n_estimators", 100, 500),
"max_depth": trial.suggest_int("max_depth", 3, 6),
"learning_rate": trial.suggest_float("learning_rate", 0.01, 0.1, log=True),
"subsample": trial.suggest_float("subsample", 0.6, 1.0),
"colsample_bytree": trial.suggest_float("colsample_bytree", 0.6, 1.0),
"min_child_weight": trial.suggest_int("min_child_weight", 1, 10),
}
# Run walk-forward with these params, return avg profit factor
avg_pf = run_walk_forward(X, y, returns, params)
return avg_pf
study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=50)
best_params = study.best_paramsCritical: the walk-forward validation inside the objective must use the same windows you will use in production. Do not optimize on random CV metrics.
Ensemble Methods
Simple Average
Average predictions from XGBoost and LightGBM for more robust signals:
def ensemble_predict(
xgb_model: "XGBClassifier",
lgb_model: "LGBMClassifier",
X: "pd.DataFrame",
weights: tuple[float, float] = (0.5, 0.5),
) -> "np.ndarray":
"""Weighted average of XGBoost and LightGBM predictions.
Args:
xgb_model: Trained XGBoost classifier.
lgb_model: Trained LightGBM classifier.
X: Feature matrix.
weights: Weights for each model (must sum to 1).
Returns:
Blended probability predictions.
"""
xgb_prob = xgb_model.predict_proba(X)[:, 1]
lgb_prob = lgb_model.predict_proba(X)[:, 1]
return weights[0] * xgb_prob + weights[1] * lgb_probStacking
Use out-of-sample predictions from walk-forward as features for a meta-model:
1. Run walk-forward with XGBoost — collect OOS predictions 2. Run walk-forward with LightGBM — collect OOS predictions 3. Train a logistic regression on (xgb_pred, lgb_pred) -> label 4. Use the meta-model for final predictions
Stacking adds complexity. Start with simple averaging and only stack if it demonstrably improves out-of-sample metrics.
Common Mistakes
1. Using random CV instead of walk-forward: inflates metrics by 10-30% 2. Too many estimators without early stopping: memorizes training data 3. Ignoring class imbalance: model predicts majority class for everything 4. Optimizing hyperparameters on test data: double-dipping produces overfit params 5. max_depth > 6: almost always overfits on trading data 6. Not setting random_state: results are not reproducible 7. Forgetting to set verbose=False/n_jobs: noisy output and single-threaded training
Signal Classification — Validation Methods
Walk-Forward Validation
Why Time-Series CV, Not Random CV
Random k-fold cross-validation shuffles data, allowing the model to train on future data and predict the past. This creates lookahead bias — the most common and devastating mistake in trading ML.
Walk-forward validation enforces temporal ordering: the model only ever predicts data it has never seen, in chronological order.
Impact: Random CV typically inflates accuracy by 10-30% compared to walk-forward. A model showing 65% accuracy with random CV may only achieve 52% walk-forward.
Implementation
import numpy as np
from typing import Iterator
def walk_forward_splits(
n_samples: int,
train_size: int = 720,
test_size: int = 168,
step_size: int = 24,
gap: int = 24,
) -> Iterator[tuple[np.ndarray, np.ndarray]]:
"""Generate walk-forward train/test splits respecting time order.
Args:
n_samples: Total number of time-ordered samples.
train_size: Training window length in bars.
test_size: Test window length in bars.
step_size: How far to advance between windows.
gap: Embargo period between train end and test start.
Yields:
Tuples of (train_indices, test_indices).
"""
start = 0
while start + train_size + gap + test_size <= n_samples:
train_end = start + train_size
test_start = train_end + gap
test_end = test_start + test_size
train_idx = np.arange(start, train_end)
test_idx = np.arange(test_start, test_end)
yield train_idx, test_idx
start += step_sizeMetrics Aggregation
Aggregate out-of-sample predictions across all walk-forward windows:
1. Concatenation: combine all OOS predictions, compute metrics once on the full set 2. Per-window averaging: compute metrics per window, report mean and std 3. Weighted averaging: weight each window by number of test samples
Method 1 (concatenation) is preferred — it gives a single set of realistic metrics.
Minimum Requirements
For statistically meaningful results:
- 30+ test periods across all windows combined
- 100+ trades (signals that exceed threshold) total
- 5+ walk-forward windows to assess stability
- Test window > 2x forward return horizon to avoid single-event dominance
Purged Cross-Validation
The Problem
If the forward return horizon is 24 bars, then bars at the boundary between train and test overlap: the label for bar T-24 depends on the price at bar T, which is in the test set. This creates subtle leakage.
The Solution: Embargo Period
Insert a gap between train end and test start equal to the forward return horizon:
[====TRAIN====][--GAP--][===TEST===]
^^^^^^^
embargo periodTypical gap size: same as the forward return horizon. If predicting 24-hour returns, use a 24-bar gap.
Implementation
The gap parameter in the walk-forward function above handles this. Always set gap >= forward_return_horizon.
Combinatorial Purged Cross-Validation (CPCV)
Concept
Standard walk-forward gives one path through the data. CPCV generates multiple train/test paths using combinatorial selection of blocks:
1. Divide data into N contiguous blocks (e.g., N=10) 2. Select k blocks for testing (e.g., k=2) 3. Use remaining blocks for training 4. Apply purging at boundaries 5. Repeat for all C(N, k) combinations
This gives C(10, 2) = 45 different train/test configurations, producing more robust estimates.
When to Use CPCV
- Sufficient data: need at least 1000+ samples
- High-stakes model selection: choosing between model architectures
- Research: validating that a signal is real, not noise
- Computationally feasible: C(N, k) models to train
When Walk-Forward Is Sufficient
- Moderate data: 500-2000 samples
- Rapid iteration: testing many feature combinations
- Production: regular retraining on latest data
Evaluating Trading Classifiers
Why Accuracy Is Misleading
If 60% of your labels are "up" (bull market bias), a model that always predicts "up" achieves 60% accuracy. Accuracy tells you nothing about trading profitability.
Precision
Of all signals the model gives, what fraction are correct?
Precision = True Positives / (True Positives + False Positives)High precision = fewer but more reliable signals. For trading, precision > 0.55 is a reasonable target for crypto.
Recall
Of all actual profitable opportunities, what fraction did the model catch?
Recall = True Positives / (True Positives + False Negatives)High recall = catching most moves, but with more false signals. Less important for trading — missing trades is okay, losing money is not.
F1 Score
Harmonic mean of precision and recall. A balanced metric, but still not trading-specific.
AUC-ROC
Area under the receiver operating characteristic curve. Measures the model's ability to rank positive samples higher than negative ones. AUC > 0.55 is meaningful for trading; AUC > 0.60 is strong.
Trading-Specific Metrics
These matter more than generic ML metrics:
Profit Factor from Signals
def signal_profit_factor(
predictions: np.ndarray,
returns: np.ndarray,
threshold: float = 0.5,
) -> float:
"""Compute profit factor from ML signals.
Args:
predictions: Model predicted probabilities.
returns: Actual forward returns.
threshold: Signal threshold.
Returns:
Profit factor (gross_profit / gross_loss).
"""
signals = predictions >= threshold
if signals.sum() == 0:
return 0.0
signal_returns = returns[signals]
gross_profit = signal_returns[signal_returns > 0].sum()
gross_loss = abs(signal_returns[signal_returns < 0].sum())
return gross_profit / gross_loss if gross_loss > 0 else float("inf")Target: profit factor > 1.3 after costs.
Expected Return per Signal
def expected_return_per_signal(
predictions: np.ndarray,
returns: np.ndarray,
threshold: float = 0.5,
cost: float = 0.005,
) -> float:
"""Average return per signal, net of transaction costs.
Args:
predictions: Model predicted probabilities.
returns: Actual forward returns.
threshold: Signal threshold.
cost: Round-trip transaction cost (default 50bps).
Returns:
Mean return per signal after costs.
"""
signals = predictions >= threshold
if signals.sum() == 0:
return 0.0
return float(np.mean(returns[signals]) - cost)Target: positive expected return net of 50bps round-trip costs.
Overfitting Detection
Train vs Test Metric Gap
Compare metrics on training data vs out-of-sample test data:
| Gap (train - test) | Interpretation |
|---|---|
| < 5% | Healthy — model generalizes well |
| 5-15% | Mild overfit — consider more regularization |
| 15-30% | Significant overfit — reduce complexity |
| > 30% | Severe overfit — model is memorizing noise |
Metric Degradation Over Time
Plot out-of-sample metrics for each walk-forward window chronologically. If metrics decline steadily, the model's features are losing predictive power (feature decay).
Random Signal Baseline
Compare your model against a random signal generator:
random_accuracy = np.mean(np.random.randint(0, 2, size=len(y_test)) == y_test)
random_pf = signal_profit_factor(np.random.rand(len(y_test)), returns_test)Your model should significantly exceed random baseline across all windows, not just on average.
Practical Thresholds
A signal classification model is worth deploying if:
- Walk-forward AUC > 0.55 consistently across windows
- Profit factor > 1.3 at optimal threshold, net of costs
- Expected return per signal > 0 net of costs
- Train-test metric gap < 15%
- Performance is stable (not declining) across walk-forward windows
- Model generates at least 2-3 signals per day for sufficient volume
#!/usr/bin/env python3
"""Train a signal classifier with walk-forward validation and feature importance.
Demonstrates the full ML signal classification pipeline:
1. Generate synthetic features or load provided data
2. Create binary labels from forward returns
3. Walk-forward train/test splitting
4. Model training (XGBoost or sklearn DecisionTree fallback)
5. Out-of-sample evaluation with multiple metrics
6. Feature importance ranking
7. Probability threshold optimization for profit factor
Usage:
python scripts/train_classifier.py
python scripts/train_classifier.py --demo
python scripts/train_classifier.py --samples 500 --features 20
Dependencies:
uv pip install pandas numpy scikit-learn
uv pip install xgboost # optional, falls back to sklearn
uv pip install shap # optional, for SHAP feature importance
Environment Variables:
None required — uses synthetic data by default.
"""
import argparse
import sys
import warnings
from typing import Iterator, Optional
import numpy as np
import pandas as pd
from sklearn.metrics import (
accuracy_score,
f1_score,
precision_score,
recall_score,
roc_auc_score,
)
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
# ── Configuration ───────────────────────────────────────────────────
DEFAULT_SAMPLES = 200
DEFAULT_FEATURES = 15
FORWARD_HORIZON = 12 # bars to compute forward return
RETURN_THRESHOLD = 0.01 # 1% threshold for up/down label
TRAIN_SIZE = 60 # training window in bars
TEST_SIZE = 20 # test window in bars
STEP_SIZE = 10 # step between walk-forward windows
GAP_SIZE = 12 # embargo equal to forward horizon
# ── Synthetic Data Generation ───────────────────────────────────────
def generate_synthetic_data(
n_samples: int = DEFAULT_SAMPLES,
n_features: int = DEFAULT_FEATURES,
signal_strength: float = 0.3,
seed: int = 42,
) -> tuple[pd.DataFrame, np.ndarray, np.ndarray]:
"""Generate synthetic feature matrix with embedded predictive signal.
Creates features where the first few have genuine (noisy) predictive
power for forward returns, and the rest are pure noise. This simulates
a realistic feature matrix where only some features matter.
Args:
n_samples: Number of time-ordered samples to generate.
n_features: Number of features to create.
signal_strength: How strong the embedded signal is (0=none, 1=perfect).
seed: Random seed for reproducibility.
Returns:
Tuple of (feature_dataframe, forward_returns, prices).
"""
rng = np.random.default_rng(seed)
# Generate a price series with trend and mean-reversion
noise = rng.normal(0, 0.02, n_samples)
trend = np.sin(np.linspace(0, 4 * np.pi, n_samples)) * 0.01
log_returns = trend + noise
prices = 100.0 * np.exp(np.cumsum(log_returns))
# Forward returns
fwd_returns = np.full(n_samples, np.nan)
for i in range(n_samples - FORWARD_HORIZON):
fwd_returns[i] = prices[i + FORWARD_HORIZON] / prices[i] - 1.0
# Feature names
feature_names = [
"momentum_12", "momentum_24", "rsi_14", "vol_ratio",
"price_zscore", "volume_trend", "high_low_range", "close_open_ratio",
"ma_cross_signal", "bb_width", "atr_norm", "obv_slope",
"vwap_deviation", "skewness_20", "kurtosis_20",
]
# Pad or truncate to match n_features
while len(feature_names) < n_features:
feature_names.append(f"noise_feat_{len(feature_names)}")
feature_names = feature_names[:n_features]
# Generate features — first 5 have predictive power
features = {}
n_signal_features = min(5, n_features)
for i in range(n_signal_features):
# Correlated with future returns (with noise)
signal = fwd_returns.copy()
signal[np.isnan(signal)] = 0.0
noise_component = rng.normal(0, 1, n_samples)
features[feature_names[i]] = (
signal_strength * signal / (np.std(signal) + 1e-8)
+ (1 - signal_strength) * noise_component
)
for i in range(n_signal_features, n_features):
# Pure noise features
features[feature_names[i]] = rng.normal(0, 1, n_samples)
df = pd.DataFrame(features)
return df, fwd_returns, prices
# ── Label Creation ──────────────────────────────────────────────────
def create_binary_labels(
fwd_returns: np.ndarray,
threshold: float = RETURN_THRESHOLD,
) -> np.ndarray:
"""Create binary labels from forward returns.
Labels: 1 = up (return > threshold), 0 = down (return < -threshold),
NaN = neutral (within threshold, excluded from training).
Args:
fwd_returns: Array of forward returns.
threshold: Minimum absolute return for a valid label.
Returns:
Array with 1 (up), 0 (down), or NaN (neutral/invalid).
"""
labels = np.full(len(fwd_returns), np.nan)
labels[fwd_returns > threshold] = 1.0
labels[fwd_returns < -threshold] = 0.0
return labels
# ── Walk-Forward Splitting ──────────────────────────────────────────
def walk_forward_splits(
n_samples: int,
train_size: int = TRAIN_SIZE,
test_size: int = TEST_SIZE,
step_size: int = STEP_SIZE,
gap: int = GAP_SIZE,
) -> Iterator[tuple[np.ndarray, np.ndarray]]:
"""Generate walk-forward train/test index splits.
Respects temporal ordering and includes an embargo gap between
train and test to prevent label leakage.
Args:
n_samples: Total number of time-ordered samples.
train_size: Number of training samples per window.
test_size: Number of test samples per window.
step_size: Step between successive windows.
gap: Gap between train end and test start (embargo).
Yields:
Tuples of (train_indices, test_indices).
"""
start = 0
while start + train_size + gap + test_size <= n_samples:
train_end = start + train_size
test_start = train_end + gap
test_end = test_start + test_size
train_idx = np.arange(start, train_end)
test_idx = np.arange(test_start, test_end)
yield train_idx, test_idx
start += step_size
# ── Model Factory ───────────────────────────────────────────────────
def create_model(model_type: str = "auto") -> object:
"""Create a classifier model.
Tries XGBoost first, falls back to sklearn GradientBoostingClassifier.
Args:
model_type: One of 'auto', 'xgboost', 'sklearn'.
Returns:
An sklearn-compatible classifier instance.
"""
if model_type in ("auto", "xgboost"):
try:
from xgboost import XGBClassifier
return XGBClassifier(
n_estimators=200,
max_depth=4,
learning_rate=0.05,
subsample=0.8,
colsample_bytree=0.8,
min_child_weight=5,
gamma=0.1,
eval_metric="logloss",
use_label_encoder=False,
random_state=42,
n_jobs=-1,
verbosity=0,
)
except ImportError:
if model_type == "xgboost":
print("XGBoost not installed. Install with: uv pip install xgboost")
sys.exit(1)
# Fallback to sklearn
from sklearn.ensemble import GradientBoostingClassifier
return GradientBoostingClassifier(
n_estimators=100,
max_depth=4,
learning_rate=0.05,
subsample=0.8,
random_state=42,
)
# ── Walk-Forward Training ──────────────────────────────────────────
def run_walk_forward(
X: pd.DataFrame,
y: np.ndarray,
fwd_returns: np.ndarray,
model_type: str = "auto",
) -> dict:
"""Run walk-forward training and collect out-of-sample predictions.
Args:
X: Feature matrix (n_samples x n_features).
y: Binary labels (1=up, 0=down, NaN=excluded).
fwd_returns: Forward returns for profit factor computation.
model_type: Model type to use.
Returns:
Dictionary with per-fold metrics and aggregated results.
"""
# Identify valid (non-NaN) label indices
valid_mask = ~np.isnan(y)
all_test_indices: list[int] = []
all_predictions: list[float] = []
all_true_labels: list[float] = []
all_returns: list[float] = []
fold_metrics: list[dict] = []
feature_importances: list[np.ndarray] = []
fold_num = 0
for train_idx, test_idx in walk_forward_splits(len(X)):
# Filter to valid labels
train_valid = train_idx[valid_mask[train_idx]]
test_valid = test_idx[valid_mask[test_idx]]
if len(train_valid) < 20 or len(test_valid) < 5:
continue
fold_num += 1
X_train = X.iloc[train_valid]
y_train = y[train_valid]
X_test = X.iloc[test_valid]
y_test = y[test_valid]
test_returns = fwd_returns[test_valid]
# Train model
model = create_model(model_type)
model.fit(X_train, y_train)
# Predict probabilities
probs = model.predict_proba(X_test)[:, 1]
# Compute fold metrics
preds_binary = (probs >= 0.5).astype(int)
acc = accuracy_score(y_test, preds_binary)
prec = precision_score(y_test, preds_binary, zero_division=0)
rec = recall_score(y_test, preds_binary, zero_division=0)
f1 = f1_score(y_test, preds_binary, zero_division=0)
try:
auc = roc_auc_score(y_test, probs)
except ValueError:
auc = 0.5 # Only one class in test set
fold_metrics.append({
"fold": fold_num,
"n_train": len(train_valid),
"n_test": len(test_valid),
"accuracy": acc,
"precision": prec,
"recall": rec,
"f1": f1,
"auc": auc,
})
# Collect feature importance
if hasattr(model, "feature_importances_"):
feature_importances.append(model.feature_importances_)
# Aggregate predictions
all_test_indices.extend(test_valid.tolist())
all_predictions.extend(probs.tolist())
all_true_labels.extend(y_test.tolist())
all_returns.extend(test_returns.tolist())
if not fold_metrics:
print("ERROR: No valid walk-forward folds. Increase data or adjust windows.")
sys.exit(1)
# Aggregate feature importance
avg_importance = None
if feature_importances:
avg_importance = np.mean(feature_importances, axis=0)
return {
"fold_metrics": fold_metrics,
"all_predictions": np.array(all_predictions),
"all_true_labels": np.array(all_true_labels),
"all_returns": np.array(all_returns),
"avg_feature_importance": avg_importance,
"feature_names": list(X.columns),
"n_folds": fold_num,
}
# ── Threshold Optimization ─────────────────────────────────────────
def optimize_threshold(
probabilities: np.ndarray,
returns: np.ndarray,
cost: float = 0.005,
min_signals: int = 10,
) -> tuple[float, float, int]:
"""Find the probability threshold that maximizes profit factor.
Args:
probabilities: Model predicted probabilities for the positive class.
returns: Actual forward returns corresponding to predictions.
cost: Round-trip transaction cost to subtract from each trade.
min_signals: Minimum number of signals required at a threshold.
Returns:
Tuple of (best_threshold, best_profit_factor, n_signals).
"""
thresholds = np.arange(0.45, 0.85, 0.01)
best_threshold = 0.5
best_pf = 0.0
best_n = 0
for t in thresholds:
signals = probabilities >= t
n_signals = int(signals.sum())
if n_signals < min_signals:
continue
signal_returns = returns[signals] - cost
gross_profit = float(signal_returns[signal_returns > 0].sum())
gross_loss = float(abs(signal_returns[signal_returns < 0].sum()))
pf = gross_profit / gross_loss if gross_loss > 0 else 0.0
if pf > best_pf:
best_pf = pf
best_threshold = float(t)
best_n = n_signals
return best_threshold, best_pf, best_n
# ── SHAP Analysis ───────────────────────────────────────────────────
def compute_shap_importance(
model: object,
X_sample: pd.DataFrame,
max_samples: int = 100,
) -> Optional[pd.DataFrame]:
"""Compute SHAP feature importance if shap is available.
Args:
model: Trained tree-based model.
X_sample: Feature matrix to explain.
max_samples: Maximum samples for SHAP computation.
Returns:
DataFrame with feature names and mean absolute SHAP values,
or None if shap is not installed.
"""
try:
import shap
except ImportError:
return None
sample = X_sample.iloc[:max_samples]
try:
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(sample)
# Handle binary classification (may return list of arrays)
if isinstance(shap_values, list):
shap_values = shap_values[1] # positive class
mean_abs_shap = np.mean(np.abs(shap_values), axis=0)
importance_df = pd.DataFrame({
"feature": sample.columns,
"mean_abs_shap": mean_abs_shap,
}).sort_values("mean_abs_shap", ascending=False).reset_index(drop=True)
return importance_df
except Exception:
return None
# ── Reporting ───────────────────────────────────────────────────────
def print_report(results: dict, threshold_info: tuple) -> None:
"""Print a comprehensive classification report.
Args:
results: Output from run_walk_forward.
threshold_info: Output from optimize_threshold.
"""
print("\n" + "=" * 70)
print("SIGNAL CLASSIFICATION — WALK-FORWARD RESULTS")
print("=" * 70)
# Per-fold metrics
print(f"\n{'Fold':>4} {'Train':>6} {'Test':>5} {'Acc':>6} "
f"{'Prec':>6} {'Rec':>6} {'F1':>6} {'AUC':>6}")
print("-" * 55)
for fm in results["fold_metrics"]:
print(f"{fm['fold']:>4} {fm['n_train']:>6} {fm['n_test']:>5} "
f"{fm['accuracy']:>6.3f} {fm['precision']:>6.3f} "
f"{fm['recall']:>6.3f} {fm['f1']:>6.3f} {fm['auc']:>6.3f}")
# Aggregate metrics
all_preds = results["all_predictions"]
all_labels = results["all_true_labels"]
preds_binary = (all_preds >= 0.5).astype(int)
agg_acc = accuracy_score(all_labels, preds_binary)
agg_prec = precision_score(all_labels, preds_binary, zero_division=0)
agg_rec = recall_score(all_labels, preds_binary, zero_division=0)
agg_f1 = f1_score(all_labels, preds_binary, zero_division=0)
try:
agg_auc = roc_auc_score(all_labels, all_preds)
except ValueError:
agg_auc = 0.5
print("-" * 55)
print(f"{'AGG':>4} {'':>6} {len(all_labels):>5} "
f"{agg_acc:>6.3f} {agg_prec:>6.3f} "
f"{agg_rec:>6.3f} {agg_f1:>6.3f} {agg_auc:>6.3f}")
# Threshold optimization
best_t, best_pf, n_signals = threshold_info
print(f"\n{'THRESHOLD OPTIMIZATION':>40}")
print("-" * 40)
print(f" Optimal threshold: {best_t:.2f}")
print(f" Profit factor: {best_pf:.3f}")
print(f" Signals at threshold: {n_signals}")
print(f" Signal rate: {n_signals / len(all_preds) * 100:.1f}%")
# Feature importance
if results["avg_feature_importance"] is not None:
imp = results["avg_feature_importance"]
names = results["feature_names"]
sorted_idx = np.argsort(imp)[::-1]
print(f"\n{'FEATURE IMPORTANCE (top 10)':>40}")
print("-" * 40)
for rank, idx in enumerate(sorted_idx[:10], 1):
bar = "#" * int(imp[idx] / imp[sorted_idx[0]] * 20)
print(f" {rank:>2}. {names[idx]:<20} {imp[idx]:.4f} {bar}")
# Assessment
print(f"\n{'MODEL ASSESSMENT':>40}")
print("-" * 40)
if agg_auc > 0.55:
print(" AUC > 0.55: Model shows predictive power")
else:
print(" AUC <= 0.55: Model may not have predictive power")
if best_pf > 1.3:
print(f" Profit factor {best_pf:.2f} > 1.3: Potentially tradeable")
elif best_pf > 1.0:
print(f" Profit factor {best_pf:.2f}: Marginal after costs")
else:
print(f" Profit factor {best_pf:.2f} < 1.0: Not profitable")
avg_fold_auc = np.mean([fm["auc"] for fm in results["fold_metrics"]])
std_fold_auc = np.std([fm["auc"] for fm in results["fold_metrics"]])
print(f" AUC stability: {avg_fold_auc:.3f} +/- {std_fold_auc:.3f}")
if std_fold_auc > 0.10:
print(" WARNING: High AUC variance across folds — unstable model")
print("\nNote: This analysis is for informational purposes only.")
print("Past model performance does not guarantee future results.")
print("=" * 70)
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Run the signal classification pipeline."""
parser = argparse.ArgumentParser(
description="Train a signal classifier with walk-forward validation."
)
parser.add_argument(
"--demo", action="store_true",
help="Run in demo mode with default synthetic data.",
)
parser.add_argument(
"--samples", type=int, default=DEFAULT_SAMPLES,
help=f"Number of samples to generate (default: {DEFAULT_SAMPLES}).",
)
parser.add_argument(
"--features", type=int, default=DEFAULT_FEATURES,
help=f"Number of features to generate (default: {DEFAULT_FEATURES}).",
)
parser.add_argument(
"--model", type=str, default="auto",
choices=["auto", "xgboost", "sklearn"],
help="Model type to use (default: auto).",
)
parser.add_argument(
"--signal-strength", type=float, default=0.3,
help="Strength of embedded signal in synthetic data (0-1, default: 0.3).",
)
args = parser.parse_args()
print("Signal Classification — Train Classifier")
print(f" Samples: {args.samples}, Features: {args.features}")
print(f" Model: {args.model}, Signal strength: {args.signal_strength}")
print(f" Walk-forward: train={TRAIN_SIZE}, test={TEST_SIZE}, "
f"step={STEP_SIZE}, gap={GAP_SIZE}")
# Step 1: Generate data
print("\n[1/5] Generating synthetic data...")
X, fwd_returns, prices = generate_synthetic_data(
n_samples=args.samples,
n_features=args.features,
signal_strength=args.signal_strength,
)
print(f" Feature matrix: {X.shape[0]} samples x {X.shape[1]} features")
# Step 2: Create labels
print("[2/5] Creating binary labels...")
y = create_binary_labels(fwd_returns, threshold=RETURN_THRESHOLD)
n_up = int(np.nansum(y == 1))
n_down = int(np.nansum(y == 0))
n_neutral = int(np.isnan(y).sum())
print(f" Up: {n_up}, Down: {n_down}, Neutral (dropped): {n_neutral}")
# Step 3: Walk-forward training
print("[3/5] Running walk-forward validation...")
results = run_walk_forward(X, y, fwd_returns, model_type=args.model)
print(f" Completed {results['n_folds']} folds, "
f"{len(results['all_predictions'])} out-of-sample predictions")
# Step 4: Threshold optimization
print("[4/5] Optimizing probability threshold...")
threshold_info = optimize_threshold(
results["all_predictions"],
results["all_returns"],
)
print(f" Best threshold: {threshold_info[0]:.2f}, "
f"Profit factor: {threshold_info[1]:.3f}")
# Step 5: SHAP (optional)
print("[5/5] Computing feature importance...")
shap_df = None
try:
model = create_model(args.model)
valid = ~np.isnan(y)
model.fit(X[valid], y[valid])
shap_df = compute_shap_importance(model, X[valid])
if shap_df is not None:
print(" SHAP importance computed successfully")
else:
print(" SHAP not available (install: uv pip install shap)")
print(" Using built-in feature importance instead")
except Exception as e:
print(f" SHAP computation skipped: {e}")
# Report
print_report(results, threshold_info)
if shap_df is not None:
print(f"\n{'SHAP FEATURE IMPORTANCE':>40}")
print("-" * 40)
for _, row in shap_df.head(10).iterrows():
bar = "#" * int(
row["mean_abs_shap"] / shap_df["mean_abs_shap"].max() * 20
)
print(f" {row['feature']:<20} {row['mean_abs_shap']:.4f} {bar}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Backtest ML signals vs buy-and-hold with walk-forward validation.
Demonstrates end-to-end workflow:
1. Generate synthetic price data with an embedded signal
2. Build features from price data
3. Create forward-return labels
4. Run walk-forward classification
5. Convert ML probabilities to trading signals via threshold
6. Simulate trading based on ML signals
7. Compare: ML strategy vs buy-and-hold vs random signals
Usage:
python scripts/walk_forward_backtest.py
python scripts/walk_forward_backtest.py --demo
python scripts/walk_forward_backtest.py --bars 500 --threshold 0.60
Dependencies:
uv pip install pandas numpy scikit-learn
Environment Variables:
None required — uses synthetic data.
"""
import argparse
import sys
import warnings
from typing import Iterator
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score, roc_auc_score
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
# ── Configuration ───────────────────────────────────────────────────
DEFAULT_BARS = 500
FORWARD_HORIZON = 12
RETURN_THRESHOLD = 0.01
TRAIN_SIZE = 100
TEST_SIZE = 30
STEP_SIZE = 15
GAP_SIZE = 12
TRANSACTION_COST = 0.005 # 50 bps round-trip
# ── Data Generation ─────────────────────────────────────────────────
def generate_price_data(
n_bars: int = DEFAULT_BARS,
signal_strength: float = 0.15,
seed: int = 42,
) -> pd.DataFrame:
"""Generate synthetic OHLCV data with an embedded tradeable pattern.
Creates a price series with a mean-reverting component that can
be detected by ML models. The signal_strength controls how
detectable the pattern is.
Args:
n_bars: Number of bars to generate.
signal_strength: Strength of embedded pattern (0=random, 1=obvious).
seed: Random seed for reproducibility.
Returns:
DataFrame with columns: open, high, low, close, volume.
"""
rng = np.random.default_rng(seed)
# Base price with trend and cycles
t = np.arange(n_bars, dtype=float)
trend = 0.0001 * t # slight uptrend
cycle = signal_strength * 0.02 * np.sin(2 * np.pi * t / 50)
noise = rng.normal(0, 0.015, n_bars)
log_returns = trend + cycle + noise
close = 100.0 * np.exp(np.cumsum(log_returns))
# Generate OHLCV from close
spread = rng.uniform(0.002, 0.01, n_bars)
high = close * (1 + spread)
low = close * (1 - spread)
open_price = close * (1 + rng.normal(0, 0.003, n_bars))
# Volume with mean-reversion correlation
base_volume = rng.lognormal(mean=10, sigma=0.5, size=n_bars)
volume = base_volume * (1 + 2 * np.abs(log_returns) / 0.015)
df = pd.DataFrame({
"open": open_price,
"high": high,
"low": low,
"close": close,
"volume": volume,
})
return df
# ── Feature Engineering ─────────────────────────────────────────────
def build_features(df: pd.DataFrame) -> pd.DataFrame:
"""Build trading features from OHLCV data.
Computes a set of common technical features without using
external TA libraries (pure numpy/pandas).
Args:
df: DataFrame with open, high, low, close, volume columns.
Returns:
DataFrame of features aligned with input index.
"""
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(close)
features: dict[str, np.ndarray] = {}
# Returns at various lookbacks
for lb in [3, 6, 12, 24]:
ret = np.full(n, np.nan)
ret[lb:] = close[lb:] / close[:-lb] - 1
features[f"return_{lb}"] = ret
# RSI approximation (14-bar)
period = 14
delta = np.diff(close, prepend=close[0])
gain = np.where(delta > 0, delta, 0.0)
loss = np.where(delta < 0, -delta, 0.0)
avg_gain = np.full(n, np.nan)
avg_loss = np.full(n, np.nan)
avg_gain[period] = np.mean(gain[1:period + 1])
avg_loss[period] = np.mean(loss[1:period + 1])
for i in range(period + 1, n):
avg_gain[i] = (avg_gain[i - 1] * (period - 1) + gain[i]) / period
avg_loss[i] = (avg_loss[i - 1] * (period - 1) + loss[i]) / period
rs = avg_gain / (avg_loss + 1e-10)
features["rsi_14"] = 100 - 100 / (1 + rs)
# Volatility (rolling std of returns)
for window in [10, 20]:
vol = np.full(n, np.nan)
returns = np.diff(close, prepend=close[0]) / np.maximum(close, 1e-10)
for i in range(window, n):
vol[i] = np.std(returns[i - window:i])
features[f"volatility_{window}"] = vol
# Volume ratio (current / moving average)
for window in [10, 20]:
vol_ma = np.full(n, np.nan)
for i in range(window, n):
vol_ma[i] = np.mean(volume[i - window:i])
features[f"volume_ratio_{window}"] = volume / (vol_ma + 1e-10)
# Price position in range (0 = at low, 1 = at high)
for window in [10, 20]:
pos = np.full(n, np.nan)
for i in range(window, n):
h = np.max(high[i - window:i + 1])
l = np.min(low[i - window:i + 1])
pos[i] = (close[i] - l) / (h - l + 1e-10)
features[f"price_position_{window}"] = pos
# Moving average crossover
ma_fast = np.full(n, np.nan)
ma_slow = np.full(n, np.nan)
for i in range(10, n):
ma_fast[i] = np.mean(close[i - 10:i])
for i in range(30, n):
ma_slow[i] = np.mean(close[i - 30:i])
features["ma_cross"] = (ma_fast - ma_slow) / (ma_slow + 1e-10)
# High-low range normalized
features["hl_range"] = (high - low) / (close + 1e-10)
return pd.DataFrame(features, index=df.index)
# ── Label Creation ──────────────────────────────────────────────────
def create_labels(
prices: np.ndarray,
horizon: int = FORWARD_HORIZON,
threshold: float = RETURN_THRESHOLD,
) -> tuple[np.ndarray, np.ndarray]:
"""Create binary labels and forward returns from price series.
Args:
prices: Array of close prices.
horizon: Forward return horizon in bars.
threshold: Minimum return magnitude for a label.
Returns:
Tuple of (labels, forward_returns). Labels are 1/0/NaN.
"""
n = len(prices)
fwd_returns = np.full(n, np.nan)
for i in range(n - horizon):
fwd_returns[i] = prices[i + horizon] / prices[i] - 1.0
labels = np.full(n, np.nan)
labels[fwd_returns > threshold] = 1.0
labels[fwd_returns < -threshold] = 0.0
return labels, fwd_returns
# ── Walk-Forward Splits ─────────────────────────────────────────────
def walk_forward_splits(
n_samples: int,
train_size: int = TRAIN_SIZE,
test_size: int = TEST_SIZE,
step_size: int = STEP_SIZE,
gap: int = GAP_SIZE,
) -> Iterator[tuple[np.ndarray, np.ndarray]]:
"""Generate walk-forward train/test index splits.
Args:
n_samples: Total number of time-ordered samples.
train_size: Training window length.
test_size: Test window length.
step_size: Step between windows.
gap: Embargo gap between train and test.
Yields:
Tuples of (train_indices, test_indices).
"""
start = 0
while start + train_size + gap + test_size <= n_samples:
train_end = start + train_size
test_start = train_end + gap
test_end = test_start + test_size
yield np.arange(start, train_end), np.arange(test_start, test_end)
start += step_size
# ── ML Signal Generation ───────────────────────────────────────────
def generate_ml_signals(
X: pd.DataFrame,
y: np.ndarray,
threshold: float = 0.55,
) -> np.ndarray:
"""Generate out-of-sample ML signals via walk-forward.
Args:
X: Feature matrix.
y: Binary labels (1/0/NaN).
threshold: Probability threshold for generating a signal.
Returns:
Array of signals: 1 (long), 0 (no position), for each bar.
Only out-of-sample bars get signals; others are 0.
"""
n = len(X)
signals = np.zeros(n)
valid_mask = ~np.isnan(y)
fold_count = 0
oos_count = 0
total_auc = 0.0
for train_idx, test_idx in walk_forward_splits(n):
train_valid = train_idx[valid_mask[train_idx]]
test_valid = test_idx[valid_mask[test_idx]]
if len(train_valid) < 20 or len(test_valid) < 3:
continue
fold_count += 1
X_train = X.iloc[train_valid].values
y_train = y[train_valid]
X_test = X.iloc[test_valid].values
y_test = y[test_valid]
model = GradientBoostingClassifier(
n_estimators=100,
max_depth=4,
learning_rate=0.05,
subsample=0.8,
random_state=42,
)
model.fit(X_train, y_train)
probs = model.predict_proba(X_test)[:, 1]
try:
auc = roc_auc_score(y_test, probs)
total_auc += auc
except ValueError:
pass
# Generate signals for test bars where probability > threshold
for i, idx in enumerate(test_valid):
if probs[i] >= threshold:
signals[idx] = 1.0
oos_count += len(test_valid)
avg_auc = total_auc / fold_count if fold_count > 0 else 0.5
print(f" Walk-forward: {fold_count} folds, {oos_count} OOS samples, "
f"avg AUC: {avg_auc:.3f}")
return signals
# ── Trading Simulation ──────────────────────────────────────────────
def simulate_strategy(
prices: np.ndarray,
signals: np.ndarray,
cost: float = TRANSACTION_COST,
) -> dict:
"""Simulate a long-only strategy based on signals.
When signal=1, enter long at next bar's open and hold for
FORWARD_HORIZON bars. Track returns net of transaction costs.
Args:
prices: Array of close prices.
signals: Array of 0/1 signals.
cost: Round-trip transaction cost.
Returns:
Dictionary with performance metrics.
"""
n = len(prices)
trade_returns: list[float] = []
equity_curve = np.ones(n)
in_position = False
entry_price = 0.0
entry_bar = 0
n_trades = 0
for i in range(1, n):
if in_position:
# Check if holding period expired
if i - entry_bar >= FORWARD_HORIZON:
exit_return = prices[i] / entry_price - 1.0 - cost
trade_returns.append(exit_return)
in_position = False
n_trades += 1
equity_curve[i] = equity_curve[i - 1] * (1 + exit_return)
else:
equity_curve[i] = equity_curve[i - 1]
else:
# Check for entry signal (use previous bar's signal)
if i > 0 and signals[i - 1] == 1:
entry_price = prices[i]
entry_bar = i
in_position = True
equity_curve[i] = equity_curve[i - 1]
trade_returns_arr = np.array(trade_returns) if trade_returns else np.array([0.0])
# Metrics
total_return = float(equity_curve[-1] / equity_curve[0] - 1)
n_winning = int(np.sum(trade_returns_arr > 0))
n_losing = int(np.sum(trade_returns_arr <= 0))
win_rate = n_winning / max(len(trade_returns_arr), 1)
gross_profit = float(trade_returns_arr[trade_returns_arr > 0].sum())
gross_loss = float(abs(trade_returns_arr[trade_returns_arr <= 0].sum()))
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf")
avg_return = float(np.mean(trade_returns_arr))
avg_win = float(np.mean(trade_returns_arr[trade_returns_arr > 0])) if n_winning > 0 else 0.0
avg_loss = float(np.mean(trade_returns_arr[trade_returns_arr <= 0])) if n_losing > 0 else 0.0
# Max drawdown
peak = np.maximum.accumulate(equity_curve)
drawdowns = (equity_curve - peak) / peak
max_drawdown = float(np.min(drawdowns))
# Sharpe ratio (annualized, assume hourly bars)
if len(trade_returns_arr) > 1 and np.std(trade_returns_arr) > 0:
sharpe = float(np.mean(trade_returns_arr) / np.std(trade_returns_arr)
* np.sqrt(252 * 24 / FORWARD_HORIZON))
else:
sharpe = 0.0
return {
"total_return": total_return,
"n_trades": len(trade_returns_arr),
"win_rate": win_rate,
"profit_factor": profit_factor,
"avg_return": avg_return,
"avg_win": avg_win,
"avg_loss": avg_loss,
"max_drawdown": max_drawdown,
"sharpe_ratio": sharpe,
"equity_curve": equity_curve,
}
def simulate_buy_and_hold(prices: np.ndarray) -> dict:
"""Simulate buy-and-hold for comparison.
Args:
prices: Array of close prices.
Returns:
Dictionary with performance metrics.
"""
total_return = float(prices[-1] / prices[0] - 1)
equity_curve = prices / prices[0]
# Daily returns for Sharpe
returns = np.diff(prices) / prices[:-1]
sharpe = 0.0
if len(returns) > 1 and np.std(returns) > 0:
sharpe = float(np.mean(returns) / np.std(returns) * np.sqrt(252 * 24))
peak = np.maximum.accumulate(equity_curve)
drawdowns = (equity_curve - peak) / peak
max_drawdown = float(np.min(drawdowns))
return {
"total_return": total_return,
"n_trades": 1,
"win_rate": 1.0 if total_return > 0 else 0.0,
"profit_factor": float("inf") if total_return > 0 else 0.0,
"avg_return": total_return,
"avg_win": total_return if total_return > 0 else 0.0,
"avg_loss": total_return if total_return <= 0 else 0.0,
"max_drawdown": max_drawdown,
"sharpe_ratio": sharpe,
"equity_curve": equity_curve,
}
def generate_random_signals(
n: int, signal_rate: float = 0.1, seed: int = 99,
) -> np.ndarray:
"""Generate random signals for baseline comparison.
Args:
n: Number of bars.
signal_rate: Fraction of bars with a signal.
seed: Random seed.
Returns:
Array of 0/1 random signals.
"""
rng = np.random.default_rng(seed)
return (rng.random(n) < signal_rate).astype(float)
# ── Reporting ───────────────────────────────────────────────────────
def print_comparison(
ml_result: dict,
bh_result: dict,
random_result: dict,
ml_threshold: float,
) -> None:
"""Print performance comparison table.
Args:
ml_result: ML strategy performance.
bh_result: Buy-and-hold performance.
random_result: Random signal performance.
ml_threshold: ML probability threshold used.
"""
print("\n" + "=" * 75)
print("WALK-FORWARD BACKTEST — STRATEGY COMPARISON")
print("=" * 75)
header = (f"{'Metric':<22} {'ML (t='}{ml_threshold:.2f}{')':<15} "
f"{'Buy & Hold':<15} {'Random':<15}")
print(f"\n{header}")
print("-" * 70)
metrics = [
("Total Return", "total_return", "{:.2%}"),
("Trades", "n_trades", "{:d}"),
("Win Rate", "win_rate", "{:.1%}"),
("Profit Factor", "profit_factor", "{:.2f}"),
("Avg Return/Trade", "avg_return", "{:.3%}"),
("Avg Win", "avg_win", "{:.3%}"),
("Avg Loss", "avg_loss", "{:.3%}"),
("Max Drawdown", "max_drawdown", "{:.2%}"),
("Sharpe Ratio", "sharpe_ratio", "{:.2f}"),
]
for label, key, fmt in metrics:
ml_val = ml_result[key]
bh_val = bh_result[key]
rd_val = random_result[key]
# Format values
if key == "n_trades":
ml_str = fmt.format(int(ml_val))
bh_str = fmt.format(int(bh_val))
rd_str = fmt.format(int(rd_val))
elif key == "profit_factor" and ml_val == float("inf"):
ml_str = "inf"
bh_str = "inf" if bh_val == float("inf") else fmt.format(bh_val)
rd_str = "inf" if rd_val == float("inf") else fmt.format(rd_val)
else:
ml_str = fmt.format(ml_val)
bh_str = fmt.format(bh_val)
rd_str = fmt.format(rd_val)
print(f" {label:<20} {ml_str:<15} {bh_str:<15} {rd_str:<15}")
# Summary
print(f"\n{'ASSESSMENT':>40}")
print("-" * 50)
if ml_result["total_return"] > bh_result["total_return"]:
print(" ML strategy outperformed buy-and-hold on total return.")
else:
print(" Buy-and-hold outperformed ML strategy on total return.")
if ml_result["total_return"] > random_result["total_return"]:
print(" ML strategy outperformed random signals.")
else:
print(" WARNING: ML strategy did not beat random signals.")
if ml_result["max_drawdown"] > bh_result["max_drawdown"]:
print(" ML strategy had smaller drawdown than buy-and-hold.")
else:
print(" Buy-and-hold had smaller drawdown.")
if ml_result["profit_factor"] > 1.3:
print(f" Profit factor {ml_result['profit_factor']:.2f} > 1.3: "
f"Potentially viable signal.")
elif ml_result["profit_factor"] > 1.0:
print(f" Profit factor {ml_result['profit_factor']:.2f}: "
f"Marginal — may not survive additional costs.")
else:
print(f" Profit factor {ml_result['profit_factor']:.2f}: "
f"Not profitable at this threshold.")
print("\nNote: This analysis uses synthetic data for demonstration.")
print("Past model performance does not guarantee future results.")
print("This is for informational and educational purposes only.")
print("=" * 75)
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Run the walk-forward backtest pipeline."""
parser = argparse.ArgumentParser(
description="Backtest ML signals with walk-forward validation."
)
parser.add_argument(
"--demo", action="store_true",
help="Run in demo mode with default settings.",
)
parser.add_argument(
"--bars", type=int, default=DEFAULT_BARS,
help=f"Number of price bars to generate (default: {DEFAULT_BARS}).",
)
parser.add_argument(
"--threshold", type=float, default=0.55,
help="ML probability threshold for signals (default: 0.55).",
)
parser.add_argument(
"--signal-strength", type=float, default=0.15,
help="Embedded signal strength in synthetic data (default: 0.15).",
)
args = parser.parse_args()
print("Walk-Forward Backtest — ML Signals vs Baselines")
print(f" Bars: {args.bars}, Threshold: {args.threshold:.2f}")
print(f" Signal strength: {args.signal_strength}")
print(f" Transaction cost: {TRANSACTION_COST:.1%} round-trip")
# Step 1: Generate price data
print("\n[1/6] Generating synthetic price data...")
ohlcv = generate_price_data(
n_bars=args.bars,
signal_strength=args.signal_strength,
)
prices = ohlcv["close"].values
print(f" {len(prices)} bars, price range: "
f"${prices.min():.2f} - ${prices.max():.2f}")
# Step 2: Build features
print("[2/6] Building features from OHLCV...")
features = build_features(ohlcv)
print(f" {features.shape[1]} features computed")
# Step 3: Create labels
print("[3/6] Creating forward-return labels...")
labels, fwd_returns = create_labels(prices)
n_valid = int(~np.isnan(labels)).sum()
n_up = int(np.nansum(labels == 1))
n_down = int(np.nansum(labels == 0))
print(f" Valid labels: {n_valid} (up: {n_up}, down: {n_down})")
# Drop rows where features or labels are NaN
valid_mask = ~(features.isna().any(axis=1) | np.isnan(labels))
valid_indices = np.where(valid_mask)[0]
print(f" Usable samples (no NaN): {len(valid_indices)}")
if len(valid_indices) < TRAIN_SIZE + GAP_SIZE + TEST_SIZE + 50:
print("ERROR: Not enough valid samples. Increase --bars.")
sys.exit(1)
# Step 4: Generate ML signals
print("[4/6] Running walk-forward ML signal generation...")
ml_signals = generate_ml_signals(features, labels, threshold=args.threshold)
n_signals = int(ml_signals.sum())
print(f" ML signals generated: {n_signals} "
f"({n_signals / len(ml_signals) * 100:.1f}% of bars)")
# Step 5: Simulate strategies
print("[5/6] Simulating trading strategies...")
print(" a) ML strategy...")
ml_result = simulate_strategy(prices, ml_signals)
print(f" {ml_result['n_trades']} trades, "
f"return: {ml_result['total_return']:.2%}")
print(" b) Buy and hold...")
bh_result = simulate_buy_and_hold(prices)
print(f" return: {bh_result['total_return']:.2%}")
print(" c) Random signals...")
random_signals = generate_random_signals(
len(prices),
signal_rate=n_signals / len(prices) if n_signals > 0 else 0.1,
)
random_result = simulate_strategy(prices, random_signals)
print(f" {random_result['n_trades']} trades, "
f"return: {random_result['total_return']:.2%}")
# Step 6: Report
print("[6/6] Generating comparison report...")
print_comparison(ml_result, bh_result, random_result, args.threshold)
if __name__ == "__main__":
main()
Related skills
FAQ
Why use tree-based models for trading signals?
XGBoost and LightGBM capture non-linear feature interactions, are robust to feature scale, handle missing values natively, and consistently outperform alternatives on tabular trading features under 100k samples.
What is walk-forward validation?
It respects time ordering by training on past bars, skipping an embargo gap equal to the forward horizon, then predicting the next window, which prevents the label leakage that random cross-validation creates.