Signal functions on OHLC data
A signal function takes historical (or streaming) OHLC data and returns a boolean/enum decision — entirely separate from order placement or risk sizing. Keeping signals pure functions (input data, output signal, no side effects) makes them independently testable and reusable between backtest and live code.
import pandas as pd
def sma_crossover_signal(df: pd.DataFrame, fast: int = 20, slow: int = 50) -> pd.Series:
fast_ma = df["close"].rolling(fast).mean()
slow_ma = df["close"].rolling(slow).mean()
signal = pd.Series(0, index=df.index)
signal[fast_ma > slow_ma] = 1 # long
signal[fast_ma < slow_ma] = -1 # short
return signal
The opening range breakout signal from chapter 79's hypothesis
def opening_range_breakout_signal(df: pd.DataFrame, range_minutes: int = 15) -> pd.DataFrame:
"""df: intraday candles for one day, indexed by datetime."""
session_start = df.index[0]
range_end = session_start + pd.Timedelta(minutes=range_minutes)
opening_range = df[df.index <= range_end]
range_high = opening_range["high"].max()
range_low = opening_range["low"].min()
df = df.copy()
df["signal"] = 0
post_range = df.index > range_end
df.loc[post_range & (df["close"] > range_high), "signal"] = 1
df.loc[post_range & (df["close"] < range_low), "signal"] = -1
df["range_high"], df["range_low"] = range_high, range_low
return df
Rule: a signal function never looks at data it wouldn't have at that point in time
# WRONG — uses the full series' rolling mean, which at any earlier index
# has already "seen" future values through pandas' default centered/backward window quirks
# if not careful about alignment. Always verify rolling calculations only use past data.
fast_ma = df["close"].rolling(fast, center=False).mean() # correct: backward-looking only
This sounds obvious but is the single most common source of an unrealistically good backtest — a subtle look-ahead bug (chapter 83 covers this in depth) that a signal function can introduce silently if you're not deliberate about every calculation only using data available up to and including the current bar, never beyond it.
Reusability between backtest and live
The same opening_range_breakout_signal function should run unchanged whether fed historical data (chapter 82's backtest) or a live-updating DataFrame built from the price cache (chapter 42) — if your signal logic diverges between backtest and live code paths, you're no longer testing what you'll actually run (chapter 88 addresses this directly).