A moving-average crossover as rules
Lesson 10 · about 11 min
The moving-average crossover is the "hello world" of systematic trading: be long when a fast average is above a slow one, flat (or short) otherwise. It is not chosen because it is good. It is chosen because it is simple enough that every step from rule to number is visible, and every mistake you can make in a backtest can be made here and caught. Once the pipeline works for this strategy, swapping in a better one is a one-function change.
Write the rules in words first
Before code, write the rule as a sentence that a stranger could execute by hand:
At the close of each daily bar, compute the 20-bar and 50-bar simple moving averages of the close. If the 20 is above the 50, hold a long position of fixed size for the next bar. Otherwise hold nothing.
Three things in that sentence are decisions, not facts: the windows (20, 50), the instrument set (one symbol), and what "otherwise" means (flat here; short would be another strategy). Writing the sentence forces the decisions into the open. Most "the backtest doesn't match the chart" problems come from a decision that was never written down.
The signal function
A signal is what the rule says at the end of each bar, before any question of when you can act on it.
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
from src.indicators import sma
def ma_crossover_signal(close: pd.Series, fast: int = 20, slow: int = 50,
allow_short: bool = False) -> pd.Series:
"""+1 when fast SMA > slow SMA, else 0 (or -1 if allow_short). NaN during warm-up."""
if fast >= slow:
raise ValueError("fast window must be shorter than slow window")
f = sma(close, fast)
s = sma(close, slow)
long_ = (f > s).astype(float)
if allow_short:
signal = np.where(f > s, 1.0, -1.0)
signal = pd.Series(signal, index=close.index)
else:
signal = long_
signal[s.isna()] = np.nan
return signal.rename("signal")
bars = synthetic_ohlcv(750, seed=42)
signal = ma_crossover_signal(bars["close"])
print(signal.value_counts(dropna=False))
A few deliberate choices. The signal is a float so that it multiplies cleanly with returns later. The warm-up is NaN, not zero, because "no signal yet" is different from "signal says flat". And the function takes a Series, not the whole DataFrame, so it can be reused on any price column.
Crossings versus states
Beginners often code the crossover as an event: "buy when the fast crosses above the slow, sell when it crosses below". The state version above ("be long while fast is above slow") is equivalent for a strategy that is always either in or out, and it is far easier to reason about, because you never have to track whether you are currently in a trade. The state at each bar is the position you want.
You can recover the events from the state when you need them, for counting trades:
entries = (signal.diff() > 0) # 0 -> 1
exits = (signal.diff() < 0) # 1 -> 0
print(f"entries: {int(entries.sum())}, exits: {int(exits.sum())}")
diff() compares each bar's state with the previous one. A change from 0 to 1 is an entry; 1 to 0 is an exit. The first non-NaN bar after warm-up has a NaN diff and is not counted, which is correct: nothing "crossed" there.
Look at it before you backtest it
import matplotlib.pyplot as plt
view = bars.tail(250).copy()
view["sma20"] = sma(bars["close"], 20).tail(250)
view["sma50"] = sma(bars["close"], 50).tail(250)
view["signal"] = signal.tail(250)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6), sharex=True,
gridspec_kw={"height_ratios": [3, 1]})
view[["close", "sma20", "sma50"]].plot(ax=ax1, title="Crossover state")
view["signal"].plot(ax=ax2, drawstyle="steps-post", title="Signal (1 = long)")
plt.tight_layout()
plt.show()
Every time you write a new signal, plot it against price for a few hundred bars and check by eye that it turns on and off where the rule says it should. This takes a minute and catches inverted comparisons, off-by-one windows and the wrong column more reliably than any statistic.
Key idea: A strategy is a function from price history to a desired state at each bar. Write it as a state, not as a sequence of events, and keep it separate from any logic about when the state can be acted on or how big the position is.
Parameters belong in the signature
Notice that fast, slow and allow_short are arguments with defaults, not constants inside the function. Module 8 will call this function hundreds of times with different windows; if the numbers were hard-coded you would be editing source in a loop. The rule for the whole course: anything you might want to vary is a parameter, and the defaults are the values you would trade if you had to pick today.
A second strategy in the same shape
To prove the shape generalises, here is an RSI mean-reversion signal with the identical interface:
from src.indicators import rsi
def rsi_reversion_signal(close: pd.Series, n: int = 14, lower: float = 30.0,
upper: float = 70.0) -> pd.Series:
"""Long when RSI < lower; flat when RSI > upper; otherwise keep previous state."""
r = rsi(close, n)
raw = pd.Series(np.nan, index=close.index)
raw[r < lower] = 1.0
raw[r > upper] = 0.0
signal = raw.ffill().fillna(0.0)
signal[r.isna()] = np.nan
return signal.rename("signal")
print(rsi_reversion_signal(bars["close"]).value_counts(dropna=False))
The ffill() is the state machine: between an entry trigger and an exit trigger, the previous state persists. Both signal functions return the same kind of Series, so the backtester in the next two lessons will run either without modification.
Try it: Write
ma_crossover_signalusing EMAs instead of SMAs (importema). Count entries and exits for both versions on the same synthetic bars. EMAs react faster; you should see more trades. Then plot the two signals on the same axis and find a bar where they disagree.
Recap
- Write the rule as a sentence first; every decision in it becomes a parameter.
- A signal is the desired state at each bar: +1, 0 or −1, NaN during warm-up.
- Encode states, not events; recover entries and exits with
diff()when needed. - Plot every new signal against price before computing a single statistic.
- Different strategies share one interface (
close -> signal), so one backtester serves all of them.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.