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.