DEV Community

Ayrat Murtazin
Ayrat Murtazin

Posted on

Filtering Market Noise: A Python Guide to Multi-Window SMA Strategies

Simple Moving Averages are one of the oldest tools in technical analysis, but most practitioners use them in isolation — pick a window, apply a signal, move on. The more rigorous approach treats each SMA window as a low-pass filter with measurable properties: a specific lag, a specific noise-rejection profile, and a specific performance footprint across different market regimes. Understanding those properties quantitatively is what separates a casual trader from a systematic one.

In this article we will build a multi-window SMA backtesting engine from scratch in Python. We will pull ten years of S&P 500 daily data, implement a bias-free signal engine using causal shift logic to eliminate lookahead, compute the theoretical lag for each window, and evaluate five strategies — 10, 20, 50, 100, and 200-day SMAs — side by side on a unified risk-adjusted scorecard covering Sharpe ratio, annualized volatility, and maximum drawdown.


Most algo trading content gives you theory.
This gives you the code.

3 Python strategies. Fully backtested. Colab notebook included.
Plus a free ebook with 5 more strategies the moment you subscribe.

5,000 quant traders already run these:

Subscribe | AlgoEdge Insights

Filtering Market Noise: A Python Guide to Multi-Window SMA Strategies

This article covers:

  • Section 1 — The Low-Pass Filter Analogy:** How SMAs suppress high-frequency noise, what lag means mathematically, and why window size is a fundamental trade-off rather than a free parameter
  • Section 2 — Python Implementation:** Full runnable code covering data ingestion, causal signal construction, lag quantification, multi-window backtesting, and performance visualization
  • Section 3 — Results and Strategy Analysis:** Interpreting the scorecard output — which windows win on Sharpe, which survive drawdowns, and what the numbers reveal about trend-following in practice
  • Section 4 — Use Cases:** Where multi-window SMA frameworks apply in real portfolio and research contexts
  • Section 5 — Limitations and Edge Cases:** Honest constraints — transaction costs, regime sensitivity, overfitting risk, and signal crowding

1. The SMA as a Low-Pass Filter

Signal processing gives us a useful lens for thinking about price series. A raw daily price sequence contains information at many frequencies simultaneously — slow macro trends, medium-term momentum cycles, and high-frequency noise from order flow and market microstructure. A Simple Moving Average acts as a low-pass filter: it attenuates the high-frequency components and lets the low-frequency trend pass through. The longer the window, the more aggressively it suppresses noise — but at a cost.

That cost is lag. For a uniform N-period SMA, the theoretical lag is deterministic:

Lag ≈ (N − 1) / 2 bars

A 10-day SMA lags the true price signal by roughly 4.5 trading days. A 200-day SMA lags by approximately 99.5 trading days — nearly five calendar months. This is not a bug in the indicator; it is the mathematical consequence of the smoothing operation. You cannot have both zero lag and perfect noise rejection. Every window choice is a point on that trade-off curve.

The practical implication is that short-window SMAs generate noisier, faster signals with more false positives. Long-window SMAs generate cleaner signals that arrive late — potentially after a significant portion of a move has already occurred. A multi-window framework does not solve this trade-off; it makes it explicit and measurable, allowing you to choose your operating point with evidence rather than intuition.

One critical implementation detail: any production signal must be causal. The SMA at time t must be computed using only data available at or before time t. In pandas this means applying .shift(1) to the SMA series before generating trade signals, ensuring you are never crossing into the bar's own close price. Violating this rule produces lookahead bias — your backtest will look exceptional, and your live results will be disappointing.

2. Python Implementation

2.1 Setup and Parameters

We use yfinance for data ingestion, pandas and numpy for computation, and matplotlib for visualization. The key configurable parameters are the list of SMA windows and the historical lookback period. Adjust WINDOWS freely — the backtester is fully vectorized and handles any number of windows without code changes.

import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from datetime import datetime

# --- Parameters ---
TICKER      = "^GSPC"          # S&P 500 Index
START_DATE  = "2015-01-01"
END_DATE    = datetime.today().strftime("%Y-%m-%d")
WINDOWS     = [10, 20, 50, 100, 200]   # SMA periods to evaluate
TRADING_DAYS_PER_YEAR = 252

# --- Data ingestion ---
raw = yf.download(TICKER, start=START_DATE, end=END_DATE, auto_adjust=True)
prices = raw["Close"].squeeze().dropna()
prices.name = "Close"

print(f"Loaded {len(prices)} trading days  |  {prices.index[0].date()}{prices.index[-1].date()}")
Enter fullscreen mode Exit fullscreen mode

Implementation chart

2.2 Causal Signal Construction and Lag Analysis

For each window we compute the SMA, shift it forward by one bar (causal constraint), then derive a binary position: long (1) when price is above the shifted SMA, flat (0) otherwise. We also compute the theoretical lag and empirical mean crossover duration for reference.

def build_sma_signals(prices: pd.Series, windows: list) -> pd.DataFrame:
    """
    Returns a DataFrame with columns:
        sma_{n}, signal_{n}, lag_{n}  for each window n.
    All signals are strictly causal via .shift(1).
    """
    df = pd.DataFrame(index=prices.index)
    df["close"] = prices

    for n in windows:
        sma_col    = f"sma_{n}"
        signal_col = f"signal_{n}"

        df[sma_col]    = prices.rolling(window=n).mean()
        # Shift SMA by 1 bar — eliminates lookahead bias
        df[signal_col] = (prices > df[sma_col].shift(1)).astype(int)

    return df

signals_df = build_sma_signals(prices, WINDOWS)

# --- Theoretical lag table ---
lag_table = pd.DataFrame({
    "Window (days)": WINDOWS,
    "Theoretical Lag (days)": [(n - 1) / 2 for n in WINDOWS],
    "Theoretical Lag (weeks)": [round((n - 1) / 2 / 5, 1) for n in WINDOWS],
})
print("\n--- Lag Analysis ---")
print(lag_table.to_string(index=False))
Enter fullscreen mode Exit fullscreen mode

2.3 Multi-Window Backtester and Risk-Adjusted Scorecard

The backtester computes daily strategy returns as the product of the lagged signal and the next-day price return. From the return series we derive annualized Sharpe ratio, annualized volatility, total return, and maximum drawdown. Results are collected into a scorecard DataFrame for easy comparison.

def compute_drawdown(equity_curve: pd.Series) -> float:
    """Returns maximum drawdown as a positive decimal (e.g. 0.34 = 34%)."""
    rolling_max = equity_curve.cummax()
    drawdown    = (equity_curve - rolling_max) / rolling_max
    return float(drawdown.min())  # most negative value

def backtest_sma(prices: pd.Series, signals_df: pd.DataFrame, windows: list) -> pd.DataFrame:
    daily_ret = prices.pct_change()
    records   = []

    for n in windows:
        sig         = signals_df[f"signal_{n}"]
        strat_ret   = sig.shift(1) * daily_ret   # enter next bar after signal
        strat_ret   = strat_ret.dropna()

        ann_return  = strat_ret.mean() * TRADING_DAYS_PER_YEAR
        ann_vol     = strat_ret.std()  * np.sqrt(TRADING_DAYS_PER_YEAR)
        sharpe      = ann_return / ann_vol if ann_vol > 0 else np.nan
        total_ret   = (1 + strat_ret).cumprod().iloc[-1] - 1
        equity      = (1 + strat_ret).cumprod()
        max_dd      = compute_drawdown(equity)

        records.append({
            "Window":        n,
            "Lag (days)":    (n - 1) / 2,
            "Ann. Return":   round(ann_return * 100, 2),
            "Ann. Vol (%)":  round(ann_vol    * 100, 2),
            "Sharpe Ratio":  round(sharpe,    3),
            "Total Return":  round(total_ret  * 100, 2),
            "Max Drawdown":  round(max_dd     * 100, 2),
        })

    return pd.DataFrame(records).set_index("Window")

scorecard = backtest_sma(prices, signals_df, WINDOWS)
print("\n--- Risk-Adjusted Scorecard ---")
print(scorecard.to_string())
Enter fullscreen mode Exit fullscreen mode

2.4 Visualization

The chart below shows two panels: the top panel overlays all five SMAs on the price series so you can visually inspect responsiveness versus smoothness; the bottom panel plots the cumulative equity curve for each strategy, giving an immediate sense of performance dispersion across windows.

plt.style.use("dark_background")
fig = plt.figure(figsize=(14, 9))
gs  = gridspec.GridSpec(2, 1, height_ratios=[2, 1.2], hspace=0.08)

colors = ["#00BFFF", "#FF6347", "#7CFC00", "#FFD700", "#DA70D6"]

# --- Panel 1: Price + SMAs ---
ax1 = fig.add_subplot(gs[0])
ax1.plot(prices.index, prices, color="white", linewidth=0.7, label="S&P 500 Close", alpha=0.85)
for n, c in zip(WINDOWS, colors):
    ax1.plot(prices.index, signals_df[f"sma_{n}"], color=c, linewidth=1.2,
             label=f"SMA-{n}  (lag≈{(n-1)/2:.0f}d)")
ax1.set_title("S&P 500 — Multi-Window SMA Overlay", fontsize=13, pad=10)
ax1.set_ylabel("Price (USD)")
ax1.legend(loc="upper left", fontsize=8, framealpha=0.3)
ax1.set_xticklabels([])

# --- Panel 2: Equity Curves ---
ax2 = fig.add_subplot(gs[1])
daily_ret = prices.pct_change()
for n, c in zip(WINDOWS, colors):
    sig       = signals_df[f"signal_{n}"].shift(1)
    strat_ret = (sig * daily_ret).dropna()
    equity    = (1 + strat_ret).cumprod()
    ax2.plot(equity.index, equity, color=c, linewidth=1.1, label=f"SMA-{n}")

ax2.axhline(1.0, color="grey", linewidth=0.6, linestyle="--")
ax2.set_title("Cumulative Equity Curves by SMA Window", fontsize=11, pad=6)
ax2.set_ylabel("Growth of $1")
ax2.set_xlabel("Date")
ax2.legend(loc="upper left", fontsize=8, framealpha=0.3)

plt.savefig("sma_multi_window.png", dpi=150, bbox_inches="tight")
plt.show()
Enter fullscreen mode Exit fullscreen mode

Figure 1. Top panel: five SMA overlays on S&P 500 daily closes, illustrating the smoothness-versus-lag trade-off across window lengths. Bottom panel: cumulative equity curves for each SMA strategy, revealing performance and drawdown dispersion over a ten-year horizon.


Enjoying this strategy so far? This is only a taste of what's possible.

Go deeper with my newsletter: longer, more detailed articles + full Google Colab implementations for every approach.

Or get everything in one powerful package with AlgoEdge Insights: 30+ Python-Powered Trading Strategies — The Complete 2026 Playbook — it comes with detailed write-ups + dedicated Google Colab code/links for each of the 30+ strategies, so you can code, test, and trade them yourself immediately.

Exclusive for readers: 20% off the book with code MEDIUM20.

Join newsletter for free or Claim Your Discounted Book and take your trading to the next level!

3. Results and Strategy Analysis

Running the scorecard on a ten-year S&P 500 sample typically surfaces a non-linear relationship between window length and risk-adjusted performance. Short windows — 10 and 20 days — tend to produce higher gross return in trending years but accumulate transaction costs and whipsaw losses during range-bound periods, compressing Sharpe ratios to the 0.3–0.5 range. The 50-day window often emerges as a middle-ground performer, capturing medium-term trend legs without the excessive lag of the longer filters.

The 200-day SMA, frequently cited as a canonical trend filter, typically posts the lowest annualized volatility in this set — a direct consequence of its aggressive noise rejection — but its nearly 100-day lag means it exits positions late in corrections and re-enters late in recoveries. Its Sharpe ratio is competitive primarily because it keeps the strategy out of the market during extended bear markets, reducing large drawdowns even as it sacrifices upside participation.

Maximum drawdown is often the most telling metric. Long-window strategies tend to limit drawdowns to the 15–25% range during major market dislocations, while short-window strategies can breach 30%+ during volatile whipsaw environments. The lag table makes this interpretable: a 200-day SMA crossing below price at a market top means the exit signal fires roughly 99 days after the peak — you are riding the drawdown for four calendar months before the signal fires. Knowing this quantitatively allows you to pair long-window filters with supplementary momentum or volatility triggers to reduce that exposure.

4. Use Cases

  • Regime filtering for alpha strategies: Use a 200-day SMA as a market-regime binary. When price is above SMA-200, run your long-only alpha models at full allocation. Below it, reduce gross exposure or switch to defensive sectors. This is a structural overlay, not a standalone entry signal.

  • Benchmark for strategy development: The multi-window scorecard serves as a useful baseline. Any new strategy you develop should be benchmarked against the best SMA window on the same instrument and time period. If your complex model cannot beat SMA-50 on a Sharpe basis, it likely has not found genuine alpha.

  • Parameter sensitivity testing: Running five windows simultaneously is a lightweight form of parameter robustness testing. If a strategy's edge is concentrated in a single window with steep degradation on either side, the edge is fragile. A robust edge should be present across a range of neighboring window lengths.

  • Portfolio-level trend confirmation: In a multi-asset portfolio, require that each position's price be above its 50-day and 200-day SMA before entry. This dual-filter condition reduces the number of open positions during broad market downturns without requiring a macro forecasting model.

5. Limitations and Edge Cases

Transaction costs are excluded. The backtester above assumes frictionless execution. Short-window SMAs can generate 50–100+ round-trips per year on a single instrument. At realistic commission and slippage levels, the 10-day and 20-day strategies may be net negative after costs in low-volatility environments.

Regime sensitivity is significant. SMA strategies are trend-following by construction. They perform well in persistent, directional markets and poorly in mean-reverting or choppy markets. A single ten-year backtest on the S&P 500 spans multiple regimes, but the aggregate Sharpe masks substantial sub-period variation — always inspect rolling performance windows.

Survivorship and index composition bias. Applying this to index ETFs reduces survivorship bias but does not eliminate it. Backtesting on individual equities with a fixed universe introduces the risk that your universe excludes historical constituents that were delisted or acquired.

Overfitting via window selection. With five windows in the scorecard, the temptation is to pick the best performer and call it optimal. This is in-sample optimization. If you select a window based on historical Sharpe, you must validate on a held-out period or via walk-forward analysis before treating the result as reliable.

Signal crowding. The 50-day and 200-day SMAs are among the most widely watched levels in institutional technical analysis. When signals are crowded, execution slippage at crossover points can be meaningfully higher than assumed, particularly in less liquid instruments.

Concluding Thoughts

Multi-window SMA analysis is not about finding the one perfect moving average. It is about understanding the continuous trade-off between lag and noise rejection, making that trade-off explicit with numbers, and selecting an operating point that fits your strategy's actual requirements. The lag formula (N-1)/2 is a small piece of algebra with large practical consequences — it tells you precisely how far behind the market your signal will always be, and that number should inform every design decision downstream.

The causal constraint via .shift(1) deserves emphasis beyond its technical role. Lookahead bias is one of the most common and damaging errors in systematic strategy development. Enforcing causality at the data layer — rather than relying on discipline during analysis — removes an entire class of bugs from your workflow.

From here, the natural extensions are walk-forward optimization to validate window selection out-of-sample, transaction cost modeling to stress-test short-window strategies, and combining SMA signals with volatility filters to reduce whipsaw exposure during low-trend regimes. Each of those topics builds directly on the framework constructed here. If you want the full Colab notebook with interactive charts, rolling Sharpe analysis, and multi-asset extensions, subscribe to the newsletter for the complete research package.


Most algo trading content gives you theory.
This gives you the code.

3 Python strategies. Fully backtested. Colab notebook included.
Plus a free ebook with 5 more strategies the moment you subscribe.

5,000 quant traders already run these:

Subscribe | AlgoEdge Insights

Top comments (0)