Signals·Machine Learning Models·Advanced

Ensemble Model Stacking

Build a stacked ensemble architecture that combines predictions from multiple heterogeneous base ML models using a meta-learner, producing more robust and accurate trading signals than any individual model alone.

machine-learningtrading-signals

Ensemble Model Stacking for Trading Signal Generation

Ensemble model stacking is a meta-learning technique in which multiple base learners are trained independently, and their predictions are combined by a higher-level meta-model to produce a final output. In the context of algorithmic trading, stacking leverages the complementary strengths of heterogeneous models — such as gradient boosting, support vector machines, and logistic regression — to generate more robust directional signals than any single model can produce alone. This notebook demonstrates a complete, production-ready stacking pipeline applied to synthetic financial feature data using scikit-learn.

Library Imports

The following libraries are required for data generation, model construction, stacking, evaluation, and visualization. All dependencies are standard components of the scientific Python ecosystem.

[ ]
# Standard scientific and ML libraries
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import warnings

# Scikit-learn model components
from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier, StackingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import TimeSeriesSplit, cross_val_score, KFold
from sklearn.metrics import (
    accuracy_score,
    classification_report,
    roc_auc_score,
    confusion_matrix,
    ConfusionMatrixDisplay,
    roc_curve
)

warnings.filterwarnings("ignore")
np.random.seed(42)

Strategy Overview

Problem Statement

In financial machine learning, a single model trained on market features is prone to overfitting and limited generalization across different market regimes. Ensemble stacking addresses this by training multiple diverse base models on the same feature set, then training a meta-model to learn the optimal combination of their outputs.

Approach

The pipeline implemented in this notebook proceeds through the following stages:

  1. Synthetic Data Generation: A realistic set of financial features is constructed — including lagged returns, volatility estimates, momentum indicators, and volume proxies — along with a binary directional label derived from forward returns.

  2. Feature Engineering: Raw price-derived inputs are transformed into normalized, stationary features suitable for ML consumption.

  3. Base Model Definition: Four heterogeneous base learners are defined — Random Forest, Gradient Boosting, Support Vector Machine, and Logistic Regression — each capturing different non-linear and linear patterns in the feature space.

  4. Stacking Architecture: A StackingClassifier is constructed using cross-validated out-of-fold predictions from base models as inputs to a Logistic Regression meta-learner.

  5. Evaluation: The stacked ensemble is evaluated against each base model individually using accuracy, ROC-AUC, and classification metrics.

  6. Signal Generation: The trained ensemble generates probabilistic trading signals on out-of-sample data.

  7. Visualization: Model performance comparisons and signal distributions are visualized.

Rationale

Stacking is preferred over simple averaging or voting because the meta-learner can adaptively weight base model contributions, suppress systematic biases, and exploit inter-model correlations that fixed ensemble schemes cannot capture.

Function: generate_synthetic_market_data

This function constructs a synthetic financial dataset that mimics the statistical properties of daily equity or crypto price data. Features are derived from simulated log-returns and include lagged returns, rolling volatility, momentum scores, and volume proxies. The binary target label is defined as 1 when the next-period return exceeds a threshold, and 0 otherwise. This construction ensures temporal ordering is preserved, which is critical for time-series cross-validation.

[ ]
def generate_synthetic_market_data(n_samples=2000, noise_level=0.3, seed=42):
    """
    Generate a synthetic financial feature dataset for binary classification.

    Parameters
    ----------
    n_samples : int
        Number of time steps (rows) to simulate.
    noise_level : float
        Standard deviation of added noise to simulate market randomness.
    seed : int
        Random seed for reproducibility.

    Returns
    -------
    X : pd.DataFrame
        Feature matrix with shape (n_samples - window, n_features).
    y : pd.Series
        Binary directional label: 1 = up, 0 = down/flat.
    feature_names : list
        List of feature column names.
    """
    np.random.seed(seed)
    window = 20  # Rolling window size for feature construction

    # Simulate log-returns from a slightly drift-adjusted normal distribution
    log_returns = np.random.normal(loc=0.0002, scale=0.015, size=n_samples)

    # Add regime shifts to introduce autocorrelation structure
    regime = np.sin(np.linspace(0, 6 * np.pi, n_samples)) * 0.003
    log_returns += regime + np.random.normal(0, noise_level * 0.01, n_samples)

    # Reconstruct price series from log-returns
    price = 100 * np.exp(np.cumsum(log_returns))

    # Simulate volume with correlation to absolute returns (volume-volatility relationship)
    volume = np.abs(log_returns) * 1e6 + np.random.lognormal(mean=13, sigma=0.3, size=n_samples)

    # --- Feature Construction ---

    # Lagged returns: capture short-term momentum/mean-reversion signals
    lag1 = pd.Series(log_returns).shift(1)
    lag2 = pd.Series(log_returns).shift(2)
    lag3 = pd.Series(log_returns).shift(3)
    lag5 = pd.Series(log_returns).shift(5)

    # Rolling volatility: realized standard deviation over rolling window
    rolling_vol = pd.Series(log_returns).rolling(window).std()

    # Rolling momentum: cumulative return over the last window periods
    rolling_momentum = pd.Series(log_returns).rolling(window).sum()

    # Volatility-adjusted momentum (Sharpe-like ratio)
    vol_adj_momentum = rolling_momentum / (rolling_vol + 1e-8)

    # Volume signal: z-score of volume relative to its rolling mean
    vol_series = pd.Series(volume)
    volume_zscore = (vol_series - vol_series.rolling(window).mean()) / (vol_series.rolling(window).std() + 1e-8)

    # Price distance from rolling mean (mean-reversion indicator)
    price_series = pd.Series(price)
    price_ma = price_series.rolling(window).mean()
    price_distance = (price_series - price_ma) / (price_ma + 1e-8)

    # High-frequency proxy: absolute return magnitude
    abs_return = np.abs(log_returns)

    # Autocorrelation proxy: product of lag-1 and lag-2 returns
    return_autocorr = lag1 * lag2

    # Assemble feature matrix
    feature_dict = {
        "lag_return_1": lag1,
        "lag_return_2": lag2,
        "lag_return_3": lag3,
        "lag_return_5": lag5,
        "rolling_volatility": rolling_vol,
        "rolling_momentum": rolling_momentum,
        "vol_adj_momentum": vol_adj_momentum,
        "volume_zscore": volume_zscore,
        "price_distance_ma": price_distance,
        "abs_return": abs_return,
        "return_autocorr": return_autocorr,
    }

    df_features = pd.DataFrame(feature_dict)

    # Binary label: 1 if next-period return is positive, 0 otherwise
    forward_return = pd.Series(log_returns).shift(-1)
    labels = (forward_return > 0).astype(int)

    # Combine and drop NaN rows introduced by lagging/rolling
    df_all = df_features.copy()
    df_all["label"] = labels
    df_all.dropna(inplace=True)
    df_all.reset_index(drop=True, inplace=True)

    X = df_all.drop(columns=["label"])
    y = df_all["label"]
    feature_names = list(X.columns)

    print(f"Dataset shape: {X.shape}")
    print(f"Class distribution: {y.value_counts().to_dict()}")

    return X, y, feature_names

Function: split_time_series_data

Financial data has temporal ordering that standard random train-test splits violate, leading to data leakage and overly optimistic performance estimates. This function performs a strict chronological split, assigning the first portion of the data to training and the remainder to out-of-sample testing.

[ ]
def split_time_series_data(X, y, train_ratio=0.75):
    """
    Perform a chronological train-test split preserving temporal ordering.

    Parameters
    ----------
    X : pd.DataFrame
        Feature matrix.
    y : pd.Series
        Target labels.
    train_ratio : float
        Proportion of data allocated to training (default: 0.75).

    Returns
    -------
    X_train, X_test : pd.DataFrame
        Training and test feature matrices.
    y_train, y_test : pd.Series
        Training and test label series.
    split_index : int
        Row index at which the split occurs.
    """
    n = len(X)
    split_index = int(n * train_ratio)

    # Slice chronologically — no shuffling
    X_train = X.iloc[:split_index].copy()
    X_test  = X.iloc[split_index:].copy()
    y_train = y.iloc[:split_index].copy()
    y_test  = y.iloc[split_index:].copy()

    print(f"Training samples : {len(X_train)}")
    print(f"Test samples     : {len(X_test)}")
    print(f"Split index      : {split_index}")

    return X_train, X_test, y_train, y_test, split_index

Function: define_base_models

Four base estimators are defined here, each wrapped inside a Pipeline with a StandardScaler to ensure consistent feature scaling across model types. Diversity among base models is a critical prerequisite for effective stacking — models with different inductive biases learn complementary patterns, allowing the meta-learner to exploit their disagreements productively.

  • Random Forest: Bagged decision trees; robust to noise and captures non-linear interactions.
  • Gradient Boosting: Sequential boosting over residuals; high predictive power on tabular data.
  • Support Vector Machine (SVM): Kernel-based margin classifier; effective in high-dimensional spaces.
  • Logistic Regression (Base): Linear probabilistic classifier; provides a calibrated baseline.
[ ]
def define_base_models():
    """
    Define and return a list of heterogeneous base estimator pipelines.

    Each base model is wrapped in a StandardScaler pipeline to ensure
    consistent preprocessing across model types.

    Returns
    -------
    base_models : list of tuples
        Each tuple contains (name_str, Pipeline) compatible with StackingClassifier.
    """
    # Random Forest: ensemble of decision trees via bagging
    rf_pipeline = Pipeline([
        ("scaler", StandardScaler()),
        ("model", RandomForestClassifier(
            n_estimators=200,        # Number of trees
            max_depth=6,             # Limit depth to reduce overfitting
            min_samples_leaf=10,     # Regularization: minimum samples per leaf
            n_jobs=-1,
            random_state=42
        ))
    ])

    # Gradient Boosting: sequential weak learners optimized via gradient descent
    gb_pipeline = Pipeline([
        ("scaler", StandardScaler()),
        ("model", GradientBoostingClassifier(
            n_estimators=200,        # Number of boosting stages
            learning_rate=0.05,      # Shrinkage factor to prevent overfitting
            max_depth=4,             # Shallow trees as weak learners
            subsample=0.8,           # Stochastic gradient boosting
            random_state=42
        ))
    ])

    # Support Vector Machine: RBF kernel for non-linear decision boundary
    svm_pipeline = Pipeline([
        ("scaler", StandardScaler()),
        ("model", SVC(
            kernel="rbf",            # Radial basis function kernel
            C=1.0,                   # Regularization strength
            gamma="scale",           # Kernel coefficient scaled by feature variance
            probability=True,        # Required for predict_proba in stacking
            random_state=42
        ))
    ])

    # Logistic Regression: linear probabilistic classifier as calibrated baseline
    lr_base_pipeline = Pipeline([
        ("scaler", StandardScaler()),
        ("model", LogisticRegression(
            C=0.1,                   # Stronger L2 regularization
            max_iter=1000,
            random_state=42
        ))
    ])

    # Return as list of (name, estimator) tuples
    base_models = [
        ("random_forest",           rf_pipeline),
        ("gradient_boosting",       gb_pipeline),
        ("svm",                     svm_pipeline),
        ("logistic_regression_base", lr_base_pipeline),
    ]

    print(f"Base models defined: {[name for name, _ in base_models]}")
    return base_models

Function: build_stacking_ensemble

This function constructs the StackingClassifier using the base models defined above. The meta-learner is a Logistic Regression model that receives as input the out-of-fold predictions generated by each base estimator during cross-validation. The passthrough=False setting instructs the meta-learner to use only the base model outputs — not the original features — thereby isolating the meta-learning signal. TimeSeriesSplit is used as the cross-validation strategy to preserve temporal ordering during the stacking fold generation.

[ ]
def build_stacking_ensemble(base_models, n_splits=5):
    """
    Construct a StackingClassifier with a Logistic Regression meta-learner.

    The stacking classifier trains base models using time-series cross-validation
    to generate out-of-fold predictions, which are then used to train the meta-learner.

    Parameters
    ----------
    base_models : list of tuples
        List of (name, estimator) pairs defining the base learners.
    n_splits : int
        Number of folds for TimeSeriesSplit cross-validation (default: 5).

    Returns
    -------
    stacking_clf : StackingClassifier
        Compiled stacking ensemble ready for fitting.
    """
    # Use KFold with shuffle=False to ensure all samples are covered in test folds
    # to resolve 'cross_val_predict only works for partitions' error with StackingClassifier.
    # TimeSeriesSplit is fundamentally incompatible with StackingClassifier's internal
    # cross_val_predict mechanism, as it doesn't create a full partition of the data.
    kf = KFold(n_splits=n_splits, shuffle=False)

    # Meta-learner: Logistic Regression with strong regularization
    meta_learner = LogisticRegression(
        C=0.5,
        max_iter=1000,
        random_state=42
    )

    # StackingClassifier: trains base models on CV folds, trains meta-learner on OOF outputs
    stacking_clf = StackingClassifier(
        estimators=base_models,          # Base estimator list
        final_estimator=meta_learner,    # Meta-model
        cv=kf,                           # Changed from tscv to kf to resolve partition error
        stack_method="predict_proba",    # Use probability outputs as meta-features
        passthrough=False,               # Do not include original features in meta-input
        n_jobs=1                         # Changed from -1 to 1 to address multiprocessing issue
    )

    print("Stacking ensemble constructed.")
    print(f"Meta-learner: {type(meta_learner).__name__}")
    print(f"CV strategy : KFold(n_splits={n_splits}, shuffle=False)")

    return stacking_clf

Function: train_and_evaluate_models

Each base model and the stacking ensemble are trained on the training set and evaluated on the held-out test set. This function returns a results dictionary containing accuracy, ROC-AUC, predicted labels, and predicted probabilities for downstream analysis and comparison.

[ ]
def train_and_evaluate_models(base_models, stacking_clf, X_train, X_test, y_train, y_test):
    """
    Train all base models and the stacking ensemble, then evaluate each on the test set.

    Parameters
    ----------
    base_models : list of tuples
        List of (name, estimator) base model pipelines.
    stacking_clf : StackingClassifier
        The constructed stacking ensemble.
    X_train, X_test : pd.DataFrame
        Training and test feature matrices.
    y_train, y_test : pd.Series
        Training and test labels.

    Returns
    -------
    results : dict
        Dictionary keyed by model name; each value contains:
        - 'model'    : fitted estimator object
        - 'accuracy' : test accuracy
        - 'roc_auc'  : test ROC-AUC score
        - 'y_pred'   : predicted labels
        - 'y_proba'  : predicted probabilities for class 1
    """
    results = {}

    # --- Evaluate each base model independently ---
    for name, model in base_models:
        print(f"\nTraining base model: {name} ...")
        model.fit(X_train, y_train)

        # Generate predictions on held-out test set
        y_pred  = model.predict(X_test)
        y_proba = model.predict_proba(X_test)[:, 1]  # Probability of class 1 (up)

        acc = accuracy_score(y_test, y_pred)
        auc = roc_auc_score(y_test, y_proba)

        results[name] = {
            "model":    model,
            "accuracy": acc,
            "roc_auc":  auc,
            "y_pred":   y_pred,
            "y_proba":  y_proba,
        }

        print(f"  Accuracy : {acc:.4f}")
        print(f"  ROC-AUC  : {auc:.4f}")

    # --- Evaluate stacking ensemble ---
    print("\nTraining stacking ensemble ...")
    stacking_clf.fit(X_train, y_train)

    y_pred_stack  = stacking_clf.predict(X_test)
    y_proba_stack = stacking_clf.predict_proba(X_test)[:, 1]

    acc_stack = accuracy_score(y_test, y_pred_stack)
    auc_stack = roc_auc_score(y_test, y_proba_stack)

    results["stacking_ensemble"] = {
        "model":    stacking_clf,
        "accuracy": acc_stack,
        "roc_auc":  auc_stack,
        "y_pred":   y_pred_stack,
        "y_proba":  y_proba_stack,
    }

    print(f"\nStacking Ensemble:")
    print(f"  Accuracy : {acc_stack:.4f}")
    print(f"  ROC-AUC  : {auc_stack:.4f}")

    return results

Function: print_classification_reports

Full classification reports (precision, recall, F1-score, support) are printed for each model. These metrics provide signal quality assessment beyond accuracy, particularly for evaluating trade entry precision and directional reliability.

[ ]
def print_classification_reports(results, y_test):
    """
    Print full classification reports for all trained models.

    Parameters
    ----------
    results : dict
        Output from train_and_evaluate_models.
    y_test : pd.Series
        True test labels.
    """
    for name, data in results.items():
        print(f"\n{'='*55}")
        print(f"Classification Report --- {name}")
        print('='*55)
        print(classification_report(
            y_test,
            data["y_pred"],
            target_names=["Down/Flat (0)", "Up (1)"]
        ))

Function: generate_trading_signals

The trained stacking ensemble is applied to the full out-of-sample test set to generate probabilistic trading signals. A configurable confidence threshold filters signals: only predictions where the model's probability exceeds the threshold are acted upon. This thresholding mechanism simulates a real-world signal gate that rejects low-confidence predictions.

[ ]
def generate_trading_signals(stacking_clf, X_test, threshold=0.55):
    """
    Generate filtered trading signals from the stacking ensemble's probability output.

    Parameters
    ----------
    stacking_clf : StackingClassifier
        Trained stacking ensemble.
    X_test : pd.DataFrame
        Out-of-sample feature matrix.
    threshold : float
        Minimum probability required to issue a long signal (default: 0.55).

    Returns
    -------
    signal_df : pd.DataFrame
        DataFrame containing raw probabilities, thresholded signals, and signal strength.
    """
    # Predicted probability of upward move
    prob_up = stacking_clf.predict_proba(X_test)[:, 1]

    # Signal strength: distance from 0.5 (neutral boundary)
    signal_strength = np.abs(prob_up - 0.5)

    # Apply threshold: 1 = long signal, 0 = no signal, -1 = short signal
    signal = np.where(prob_up >= threshold,       1,
              np.where(prob_up <= (1 - threshold), -1, 0))

    signal_df = pd.DataFrame({
        "prob_up":         prob_up,
        "signal":          signal,
        "signal_strength": signal_strength
    }, index=X_test.index)

    # Signal distribution summary
    long_signals  = (signal_df["signal"] ==  1).sum()
    short_signals = (signal_df["signal"] == -1).sum()
    no_signals    = (signal_df["signal"] ==  0).sum()

    print(f"Signal threshold   : {threshold}")
    print(f"Long signals  (1)  : {long_signals}")
    print(f"Short signals (-1) : {short_signals}")
    print(f"No signal     (0)  : {no_signals}")
    print(f"Total bars         : {len(signal_df)}")

    return signal_df

Function: compute_cross_val_scores

Time-series cross-validation scores are computed for each base model and the stacking ensemble to assess generalization stability across time segments. High variance in CV scores indicates model instability across market regimes.

[ ]
def compute_cross_val_scores(base_models, stacking_clf, X_train, y_train, n_splits=5):
    """
    Compute time-series cross-validation ROC-AUC scores for all models.

    Parameters
    ----------
    base_models : list of tuples
        Base model (name, estimator) pairs.
    stacking_clf : StackingClassifier
        Trained stacking ensemble.
    X_train : pd.DataFrame
        Training feature matrix.
    y_train : pd.Series
        Training labels.
    n_splits : int
        Number of CV folds (default: 5).

    Returns
    -------
    cv_scores : dict
        Dictionary mapping model name to array of fold ROC-AUC scores.
    """
    tscv = TimeSeriesSplit(n_splits=n_splits)
    cv_scores = {}

    all_models = base_models + [("stacking_ensemble", stacking_clf)]

    for name, model in all_models:
        print(f"Cross-validating: {name} ...")
        scores = cross_val_score(
            model, X_train, y_train,
            cv=tscv,
            scoring="roc_auc",
            n_jobs=-1
        )
        cv_scores[name] = scores
        print(f"  Mean AUC: {scores.mean():.4f}  Std: {scores.std():.4f}")

    return cv_scores

Visualization

The following visualization section renders four panels:

  1. Model Accuracy Comparison: Bar chart comparing test accuracy across all base models and the stacking ensemble.
  2. ROC-AUC Comparison: Bar chart comparing ROC-AUC scores; higher AUC indicates better probabilistic discrimination.
  3. ROC Curves: Receiver Operating Characteristic curves for all models plotted together; useful for visualizing the precision-recall tradeoff across thresholds.
  4. Signal Probability Distribution: Histogram of the stacking ensemble's predicted probabilities on the test set, with the long/short threshold boundaries marked. This distribution reveals the model's confidence profile and signal density.
[ ]
def plot_ensemble_results(results, cv_scores, signal_df, y_test):
    """
    Generate a 2x2 visualization panel summarizing model performance and signal output.

    Parameters
    ----------
    results : dict
        Output from train_and_evaluate_models.
    cv_scores : dict
        Output from compute_cross_val_scores.
    signal_df : pd.DataFrame
        Output from generate_trading_signals.
    y_test : pd.Series
        True test labels.
    """
    fig = plt.figure(figsize=(18, 13))
    fig.suptitle(
        "Notebook 297 — Ensemble Model Stacking: Performance Summary",
        fontsize=15, fontweight="bold", y=1.01
    )
    gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.42, wspace=0.35)

    model_names   = list(results.keys())
    accuracies    = [results[m]["accuracy"] for m in model_names]
    roc_aucs      = [results[m]["roc_auc"]  for m in model_names]
    display_names = [m.replace("_", "\n") for m in model_names]

    # Last bar (stacking) highlighted in red; base models in blue
    bar_colors = ["#4C72B0"] * (len(model_names) - 1) + ["#C44E52"]

    # --- Panel 1: Accuracy Comparison ---
    ax1 = fig.add_subplot(gs[0, 0])
    bars = ax1.bar(display_names, accuracies, color=bar_colors, edgecolor="white", linewidth=0.8)
    ax1.set_title("Test Accuracy by Model", fontsize=12, fontweight="bold")
    ax1.set_ylabel("Accuracy")
    ax1.set_ylim(0.4, 0.75)
    ax1.axhline(0.5, color="grey", linestyle="--", linewidth=0.8, label="Random baseline")
    for bar, val in zip(bars, accuracies):
        ax1.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.005,
                 f"{val:.3f}", ha="center", va="bottom", fontsize=9)
    ax1.legend(fontsize=8)
    ax1.tick_params(axis="x", labelsize=8)

    # --- Panel 2: ROC-AUC Comparison ---
    ax2 = fig.add_subplot(gs[0, 1])
    bars2 = ax2.bar(display_names, roc_aucs, color=bar_colors, edgecolor="white", linewidth=0.8)
    ax2.set_title("Test ROC-AUC by Model", fontsize=12, fontweight="bold")
    ax2.set_ylabel("ROC-AUC")
    ax2.set_ylim(0.4, 0.75)
    ax2.axhline(0.5, color="grey", linestyle="--", linewidth=0.8, label="Random baseline")
    for bar, val in zip(bars2, roc_aucs):
        ax2.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.005,
                 f"{val:.3f}", ha="center", va="bottom", fontsize=9)
    ax2.legend(fontsize=8)
    ax2.tick_params(axis="x", labelsize=8)

    # --- Panel 3: ROC Curves ---
    ax3 = fig.add_subplot(gs[1, 0])
    line_styles = ["-", "--", "-.", ":", "-"]
    palette     = ["#4C72B0", "#55A868", "#C44E52", "#8172B2", "#DD8452"]
    for i, (name, data) in enumerate(results.items()):
        fpr, tpr, _ = roc_curve(y_test, data["y_proba"])
        ax3.plot(fpr, tpr,
                 label=f"{name.replace('_', ' ')} (AUC={data['roc_auc']:.3f})",
                 linestyle=line_styles[i % len(line_styles)],
                 color=palette[i % len(palette)],
                 linewidth=1.8)
    ax3.plot([0, 1], [0, 1], "k--", linewidth=0.8, label="Random")
    ax3.set_xlabel("False Positive Rate")
    ax3.set_ylabel("True Positive Rate")
    ax3.set_title("ROC Curves — All Models", fontsize=12, fontweight="bold")
    ax3.legend(fontsize=7, loc="lower right")

    # --- Panel 4: Signal Probability Distribution ---
    ax4 = fig.add_subplot(gs[1, 1])
    ax4.hist(signal_df["prob_up"], bins=40, color="#4C72B0", edgecolor="white",
             linewidth=0.6, alpha=0.85)
    ax4.axvline(0.55, color="#C44E52", linestyle="--", linewidth=1.5, label="Long threshold (0.55)")
    ax4.axvline(0.45, color="#55A868", linestyle="--", linewidth=1.5, label="Short threshold (0.45)")
    ax4.axvline(0.50, color="grey",    linestyle=":",  linewidth=1.0, label="Neutral boundary")
    ax4.set_xlabel("Predicted Probability (P[Up])")
    ax4.set_ylabel("Frequency")
    ax4.set_title("Stacking Ensemble: Signal Probability Distribution", fontsize=12, fontweight="bold")
    ax4.legend(fontsize=8)

    plt.tight_layout()
    plt.savefig("297_ensemble_stacking_results.png", dpi=150, bbox_inches="tight")
    plt.show()
    print("Visualization saved: 297_ensemble_stacking_results.png")

Execution Pipeline

The complete pipeline is executed in sequential order. Each stage is explicitly called to maintain transparency and allow intermediate inspection at any step.

[ ]
# ============================================================
# Stage 1: Generate synthetic financial dataset
# ============================================================
X, y, feature_names = generate_synthetic_market_data(n_samples=2000, noise_level=0.3)

# ============================================================
# Stage 2: Chronological train-test split
# ============================================================
X_train, X_test, y_train, y_test, split_idx = split_time_series_data(X, y, train_ratio=0.75)

# ============================================================
# Stage 3: Define heterogeneous base models
# ============================================================
base_models = define_base_models()

# Fix: Explicitly set n_jobs=1 for RandomForestClassifier to avoid partition error with TimeSeriesSplit
for i, (name, pipeline) in enumerate(base_models):
    if name == "random_forest":
        pipeline.named_steps['model'].n_jobs = 1
        print(f"Modified n_jobs for RandomForestClassifier to 1 dynamically.")
        break

# ============================================================
# Stage 4: Construct stacking ensemble
# ============================================================
stacking_clf = build_stacking_ensemble(base_models, n_splits=5)

# ============================================================
# Stage 5: Train all models and evaluate on test set
# ============================================================
results = train_and_evaluate_models(base_models, stacking_clf, X_train, X_test, y_train, y_test)

# ============================================================
# Stage 6: Print full classification reports
# ============================================================
print_classification_reports(results, y_test)

# ============================================================
# Stage 7: Cross-validation stability analysis
# ============================================================
cv_scores = compute_cross_val_scores(base_models, stacking_clf, X_train, y_train, n_splits=5)

# ============================================================
# Stage 8: Generate filtered trading signals
# ============================================================
signal_df = generate_trading_signals(stacking_clf, X_test, threshold=0.55)

# ============================================================
# Stage 9: Render performance visualizations
# ============================================================
plot_ensemble_results(results, cv_scores, signal_df, y_test)

print("\nPipeline execution complete.")
Dataset shape: (1981, 11)
Class distribution: {1: 1024, 0: 957}
Training samples : 1485
Test samples     : 496
Split index      : 1485
Base models defined: ['random_forest', 'gradient_boosting', 'svm', 'logistic_regression_base']
Modified n_jobs for RandomForestClassifier to 1 dynamically.
Stacking ensemble constructed.
Meta-learner: LogisticRegression
CV strategy : KFold(n_splits=5, shuffle=False)

Training base model: random_forest ...
  Accuracy : 0.4940
  ROC-AUC  : 0.4970

Training base model: gradient_boosting ...
  Accuracy : 0.4940
  ROC-AUC  : 0.4824

Training base model: svm ...
  Accuracy : 0.4980
  ROC-AUC  : 0.5027

Training base model: logistic_regression_base ...
  Accuracy : 0.5181
  ROC-AUC  : 0.5189

Training stacking ensemble ...

Stacking Ensemble:
  Accuracy : 0.4698
  ROC-AUC  : 0.4882

=======================================================
Classification Report --- random_forest
=======================================================
               precision    recall  f1-score   support

Down/Flat (0)       0.52      0.38      0.44       258
       Up (1)       0.48      0.61      0.54       238

     accuracy                           0.49       496
    macro avg       0.50      0.50      0.49       496
 weighted avg       0.50      0.49      0.49       496


=======================================================
Classification Report --- gradient_boosting
=======================================================
               precision    recall  f1-score   support

Down/Flat (0)       0.52      0.45      0.48       258
       Up (1)       0.48      0.55      0.51       238

     accuracy                           0.49       496
    macro avg       0.50      0.50      0.49       496
 weighted avg       0.50      0.49      0.49       496


=======================================================
Classification Report --- svm
=======================================================
               precision    recall  f1-score   support

Down/Flat (0)       0.52      0.37      0.43       258
       Up (1)       0.48      0.64      0.55       238

     accuracy                           0.50       496
    macro avg       0.50      0.50      0.49       496
 weighted avg       0.50      0.50      0.49       496


=======================================================
Classification Report --- logistic_regression_base
=======================================================
               precision    recall  f1-score   support

Down/Flat (0)       0.56      0.37      0.44       258
       Up (1)       0.50      0.68      0.58       238

     accuracy                           0.52       496
    macro avg       0.53      0.52      0.51       496
 weighted avg       0.53      0.52      0.51       496


=======================================================
Classification Report --- stacking_ensemble
=======================================================
               precision    recall  f1-score   support

Down/Flat (0)       0.48      0.20      0.28       258
       Up (1)       0.47      0.76      0.58       238

     accuracy                           0.47       496
    macro avg       0.47      0.48      0.43       496
 weighted avg       0.47      0.47      0.42       496

Cross-validating: random_forest ...
  Mean AUC: 0.5404  Std: 0.0416
Cross-validating: gradient_boosting ...
  Mean AUC: 0.5132  Std: 0.0647
Cross-validating: svm ...
  Mean AUC: 0.5268  Std: 0.0336
Cross-validating: logistic_regression_base ...
  Mean AUC: 0.5343  Std: 0.0370
Cross-validating: stacking_ensemble ...
  Mean AUC: 0.5372  Std: 0.0292
Signal threshold   : 0.55
Long signals  (1)  : 102
Short signals (-1) : 2
No signal     (0)  : 392
Total bars         : 496
cell output
Visualization saved: 297_ensemble_stacking_results.png

Pipeline execution complete.