Backtesting a multi-indicator confluence strategy

Closes Part 12 by wiring chapters 112-116 into one complete, testable strategy — the indicator equivalent of chapter 99's capstone.

def confluence_strategy_signal(df: pd.DataFrame) -> pd.Series:
    indicators_df = compute_all_indicators(df)   # ch 112

    ema_trend = (indicators_df["ema_9"] > indicators_df["ema_21"]).apply(lambda x: 1 if x else -1)
    macd_cross = detect_crossover(indicators_df["macd"], indicators_df["macd_signal"])   # ch 113
    adx_gate = indicators_df["adx"].apply(lambda x: 1 if x > 20 else 0)                      # ch 106
    rsi_divergence = detect_divergence(indicators_df["close"], indicators_df["rsi_14"])       # ch 116

    core_signal = combine_min_count([ema_trend, macd_cross], min_agree=1)   # ch 115 — either agreeing is enough
    gated_signal = apply_gate(core_signal, adx_gate)                          # only trade when trending

    # Divergence acts as an override: a strong reversal divergence can flip or block a trend-following entry
    final_signal = gated_signal.copy()
    final_signal[rsi_divergence != 0] = rsi_divergence[rsi_divergence != 0]

    return final_signal

Backtest it with realistic costs (chapter 86), not a bare price series

import vectorbt as vbt

signal = confluence_strategy_signal(df)
entries = signal == 1
exits = signal == -1

portfolio = vbt.Portfolio.from_signals(
    df["close"], entries, exits,
    init_cash=100000, fees=0.0003, slippage=0.001, freq="1D",
)
print(portfolio.stats())

Run the overfitting checklist (chapter 85) specifically against this strategy

Count the free parameters actually in play here: EMA periods (9, 21), MACD periods (12, 26, 9), ADX threshold (20), RSI divergence lookback order (5) — that's 6+ tunable values before you've touched a single combination-mode choice. Before trusting a good-looking backtest:

def count_free_parameters(strategy_config: dict) -> int:
    return len(strategy_config)

def parameter_sensitivity_check(df, base_params: dict, param_name: str, test_range: list):
    """Vary ONE parameter at a time, holding others fixed — check chapter 85's
    'narrow knife-edge optimum' red flag."""
    results = []
    for value in test_range:
        params = {**base_params, param_name: value}
        signal = confluence_strategy_signal_with_params(df, **params)
        result = backtest_signal(signal, df["close"])
        results.append({param_name: value, **result})
    return pd.DataFrame(results)

If performance collapses sharply just outside your chosen EMA period or ADX threshold, that's chapter 85's red flag #4 — narrow, fragile optima that indicate curve-fitting rather than a real, robust edge.

Run walk-forward validation (chapter 84) before considering this a candidate strategy

wf_results = run_walk_forward(
    df, strategy_fn=lambda test_df, **params: backtest_signal(confluence_strategy_signal_with_params(test_df, **params), test_df["close"]),
    optimize_fn=lambda train_df: grid_search_weights(train_df, rules, train_df["close"], weight_options=[0.5, 1.0, 1.5]),
)

The honest expectation

Most confluence combinations you try will not survive walk-forward validation with realistic costs any better than a single well-chosen indicator would — the value of building this framework isn't that "more indicators = better," it's that you now have a systematic way to *test* whether a specific combination actually adds value, rather than assuming it does because it feels more thorough. This directly closes the loop back to chapter 85's checklist and chapter 79's discipline of stating an expected result before testing.

Next: 118 — The Black-Scholes model