Combining conditions: AND / OR / threshold logic

Chapter 114's weighted score is one way to combine indicators. Often you want simpler, more interpretable logic: strict AND (all conditions must agree), OR (any one is enough), or a minimum-count threshold (at least N of M agree). This chapter builds all three as reusable primitives.

import pandas as pd

def combine_and(*conditions: pd.Series) -> pd.Series:
    """All conditions must be the SAME non-zero sign for a signal; otherwise 0."""
    stacked = pd.concat(conditions, axis=1)
    all_positive = (stacked > 0).all(axis=1)
    all_negative = (stacked < 0).all(axis=1)
    signal = pd.Series(0, index=conditions[0].index)
    signal[all_positive] = 1
    signal[all_negative] = -1
    return signal

def combine_or(*conditions: pd.Series) -> pd.Series:
    """Any non-zero condition passes through; if conditions disagree in direction, net to 0."""
    stacked = pd.concat(conditions, axis=1)
    net = stacked.sum(axis=1)
    signal = pd.Series(0, index=conditions[0].index)
    signal[net > 0] = 1
    signal[net < 0] = -1
    return signal

def combine_min_count(conditions: list[pd.Series], min_agree: int) -> pd.Series:
    """At least min_agree conditions must agree on direction."""
    stacked = pd.concat(conditions, axis=1)
    positive_count = (stacked > 0).sum(axis=1)
    negative_count = (stacked < 0).sum(axis=1)
    signal = pd.Series(0, index=conditions[0].index)
    signal[positive_count >= min_agree] = 1
    signal[negative_count >= min_agree] = -1
    return signal

Worked example — three different combination strategies on the same indicators

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
rsi_bias = indicators_df["rsi_14"].apply(lambda x: 1 if x > 50 else -1)
adx_gate = indicators_df["adx"].apply(lambda x: 1 if x > 20 else 0)

strict_signal = combine_and(ema_trend, macd_cross, rsi_bias)
# fires only when EMA trend, MACD crossover, AND RSI bias ALL point the same way — rare, high-conviction

loose_signal = combine_or(ema_trend, macd_cross, rsi_bias)
# fires when the net direction across all three leans one way — frequent, lower-conviction

majority_signal = combine_min_count([ema_trend, macd_cross, rsi_bias], min_agree=2)
# fires when at least 2 of 3 agree — a middle ground

AND is stricter and rarer — trades fewer times, typically higher per-trade conviction

OR is looser and more frequent — more trades, typically noisier, lower per-trade conviction

Neither is "correct" in the abstract — the right choice depends on your strategy's target trade frequency and how correlated the underlying indicators already are (chapter 132 covers measuring that correlation directly — if two of your three conditions are 90% correlated, an AND of all three barely differs from an AND of just one, giving a false sense of confirmation).

Applying a gate condition (like ADX) — different from voting conditions

def apply_gate(signal: pd.Series, gate: pd.Series) -> pd.Series:
    """gate: 1 = allow signal through, 0 = suppress regardless of signal's own direction."""
    return signal.where(gate == 1, 0)

final_signal = apply_gate(strict_signal, adx_gate)

A gate (chapter 106's "is there a trend at all") is conceptually different from a voting condition — it doesn't contribute a direction, it only permits or blocks whatever direction the other conditions already produced. Mixing gates into a voting/AND-OR scheme without this distinction (e.g. accidentally treating ADX's 0/1 gate as if it voted -1/1) silently corrupts the combination logic.

Testing every combination mode before committing to one

def compare_combination_modes(conditions: list[pd.Series], price_series: pd.Series) -> pd.DataFrame:
    modes = {
        "and": combine_and(*conditions),
        "or": combine_or(*conditions),
        "majority": combine_min_count(conditions, min_agree=len(conditions)//2 + 1),
    }
    results = []
    for name, signal in modes.items():
        result = backtest_signal(signal, price_series)   # ch 81-82
        results.append({"mode": name, **result})
    return pd.DataFrame(results)

Run this comparison rather than assuming AND is "safer" or OR is "better" — the right combination mode is itself an empirical question, subject to the same walk-forward validation (chapter 84) as any other strategy choice.

Next: 116 — Divergence detection (price vs indicator)