Skip to content
Back to App

Moving Average Strategies

Moving averages are the most widely used technical indicator in trading. They smooth out price noise to reveal the underlying trend. In this tutorial, you will implement SMA and EMA from scratch, then build a crossover strategy that trades based on moving average signals.

What Is a Moving Average?

A moving average calculates the average price over a rolling window of N bars. As each new bar arrives, the oldest bar drops off and the new one enters the calculation. The result is a smooth line that follows price with a lag.

Why it matters: If price is above its moving average, the trend is generally up. If price is below, the trend is generally down. When a fast moving average crosses above a slow one, momentum is shifting bullish — and vice versa.

Simple Moving Average (SMA)

The SMA is the arithmetic mean of the last N closing prices:

SMA(N) = (Close[0] + Close[1] + ... + Close[N-1]) / N

In Python, this is straightforward:

def calc_sma(closes, period):
"""Calculate SMA for the most recent bar. Returns None if not enough data."""
if len(closes) < period:
return None
return sum(closes[-period:]) / period

Properties of SMA:

  • Equal weight to all bars in the window
  • Lags behind price — the longer the period, the more lag
  • Very smooth, filters out noise effectively
  • Responds slowly to sudden price changes

Exponential Moving Average (EMA)

The EMA gives more weight to recent prices, making it respond faster to new information:

Multiplier = 2 / (period + 1)
EMA_today = (Close - EMA_yesterday) * Multiplier + EMA_yesterday

The first EMA value is seeded with the SMA of the first N bars.

def calc_ema_series(closes, period):
"""Calculate EMA for the entire closes array. Returns list of EMA values."""
if len(closes) < period:
return []
multiplier = 2 / (period + 1)
# Seed with SMA of first 'period' bars
ema_values = []
sma_seed = sum(closes[:period]) / period
ema_values.append(sma_seed)
# Calculate EMA for each subsequent bar
for i in range(period, len(closes)):
ema = (closes[i] - ema_values[-1]) * multiplier + ema_values[-1]
ema_values.append(ema)
return ema_values

To get just the current EMA value incrementally (more efficient in a loop):

class EMA:
def __init__(self, period):
self.period = period
self.multiplier = 2 / (period + 1)
self.value = None
self._buffer = []
def update(self, price):
if self.value is None:
self._buffer.append(price)
if len(self._buffer) == self.period:
self.value = sum(self._buffer) / self.period
self._buffer = None # Free memory
else:
self.value = (price - self.value) * self.multiplier + self.value
return self.value

SMA vs. EMA

PropertySMAEMA
WeightingEqual weight to all barsMore weight on recent bars
ResponsivenessSlowerFaster
LagMore lagLess lag
Noise sensitivityLess sensitiveMore sensitive
Best forTrend identificationEntry timing

Rule of thumb: Use SMA for determining the overall trend direction. Use EMA for timing entries and exits, since it reacts faster to price changes.


Strategy 1: Price vs. Single Moving Average

The simplest MA strategy: buy when price crosses above the MA, sell when it crosses below.

# Inside your main loop:
sma_value = calc_sma(closes, 20)
if sma_value is None:
continue
price = closes[-1]
prev_price = closes[-2] if len(closes) > 1 else price
prev_sma = calc_sma(closes[:-1], 20) if len(closes) > 20 else sma_value
# Bullish cross: price crosses above SMA
if prev_price <= prev_sma and price > sma_value and position is None:
# Buy signal
pass
# Bearish cross: price crosses below SMA
if prev_price >= prev_sma and price < sma_value and position == "LONG":
# Sell signal
pass

Strategy 2: Moving Average Crossover (Golden Cross / Death Cross)

The classic: use two moving averages of different periods. When the fast MA crosses above the slow MA, buy. When it crosses below, sell.

  • Golden Cross: Fast MA crosses above slow MA (bullish)
  • Death Cross: Fast MA crosses below slow MA (bearish)

Common period pairs:

  • 9/21 — aggressive, more signals, more whipsaws
  • 20/50 — balanced, good for intraday futures
  • 50/200 — conservative, fewer signals, longer holds

Complete Strategy: EMA 9/21 Crossover

Here is the full implementation. This is a real strategy you can run in the Playground:

#!/usr/bin/env python3
"""
EMA Crossover Strategy — TestMax Algo Playground
Buy on golden cross (EMA 9 > EMA 21), sell on death cross.
"""
import os, time
# --- Setup ---
ACCOUNT_ID = int(os.environ.get("ACCOUNT_ID", "0"))
CONTRACT_ID = os.environ.get("CONTRACT_ID", "")
TOTAL_BARS = int(os.environ.get("TOTAL_BARS", "5000"))
STEP_DELAY = float(os.environ.get("STEP_DELAY", "0.02"))
SPEED_FILE = os.environ.get("SPEED_FILE", "")
# --- Strategy Parameters ---
FAST_PERIOD = 9 # Fast EMA period
SLOW_PERIOD = 21 # Slow EMA period
print(f"[INFO] Strategy: EMA Crossover ({FAST_PERIOD}/{SLOW_PERIOD})")
print(f"[INFO] Account: {ACCOUNT_ID}, Contract: {CONTRACT_ID}")
print("-" * 50)
# --- EMA Class ---
class EMA:
def __init__(self, period):
self.period = period
self.multiplier = 2 / (period + 1)
self.value = None
self._buffer = []
def update(self, price):
if self.value is None:
self._buffer.append(price)
if len(self._buffer) == self.period:
self.value = sum(self._buffer) / self.period
self._buffer = None
else:
self.value = (price - self.value) * self.multiplier + self.value
return self.value
# --- State ---
fast_ema = EMA(FAST_PERIOD)
slow_ema = EMA(SLOW_PERIOD)
prev_fast = None
prev_slow = None
position = None
entry_price = 0.0
trade_count = 0
wins = 0
# --- Main Loop ---
try:
for i in range(TOTAL_BARS):
bar = get_next_bar()
if bar is None:
print("[INFO] No more bars")
break
read_speed()
if STEP_DELAY > 0:
time.sleep(STEP_DELAY)
price = bar["c"]
# Update EMAs
fast_val = fast_ema.update(price)
slow_val = slow_ema.update(price)
# Need both EMAs to be initialized before trading
if fast_ema.value is None or slow_ema.value is None:
prev_fast = fast_val
prev_slow = slow_val
continue
# Log progress every 500 bars
if i % 500 == 0:
print(f"[INFO] Bar {i} | Price: {price:.2f} | Fast EMA: {fast_val:.2f} | Slow EMA: {slow_val:.2f}")
# Detect crossover (need previous values)
if prev_fast is not None and prev_slow is not None:
# Golden Cross: fast EMA crosses above slow EMA
golden_cross = prev_fast <= prev_slow and fast_val > slow_val
# Death Cross: fast EMA crosses below slow EMA
death_cross = prev_fast >= prev_slow and fast_val < slow_val
# --- Entry ---
if golden_cross and position is None:
result = place_order(ACCOUNT_ID, CONTRACT_ID, 0, 1) # Buy
if result.get("orderId"):
position = "LONG"
entry_price = price
print(f"[BUY] Bar {i} @ {price:.2f} | Golden Cross")
elif death_cross and position is None:
result = place_order(ACCOUNT_ID, CONTRACT_ID, 1, 1) # Sell short
if result.get("orderId"):
position = "SHORT"
entry_price = price
print(f"[SELL] Bar {i} @ {price:.2f} | Death Cross")
# --- Exit ---
elif death_cross and position == "LONG":
result = place_order(ACCOUNT_ID, CONTRACT_ID, 1, 1) # Close long
if result.get("orderId"):
pnl = price - entry_price
trade_count += 1
if pnl > 0:
wins += 1
print(f"[EXIT] Bar {i} @ {price:.2f} | P&L: {pnl:+.2f} pts")
position = None
elif golden_cross and position == "SHORT":
result = place_order(ACCOUNT_ID, CONTRACT_ID, 0, 1) # Close short
if result.get("orderId"):
pnl = entry_price - price
trade_count += 1
if pnl > 0:
wins += 1
print(f"[EXIT] Bar {i} @ {price:.2f} | P&L: {pnl:+.2f} pts")
position = None
prev_fast = fast_val
prev_slow = slow_val
finally:
try:
close_all_positions(ACCOUNT_ID, CONTRACT_ID)
except:
pass
account = get_account(ACCOUNT_ID)
balance = account["balance"] if account else 50000
pnl = balance - 50000
print("-" * 50)
print(f"[DONE] Final Balance: ${balance:,.2f}")
print(f"[DONE] Net P&L: ${pnl:+,.2f}")
print(f"[DONE] Total Trades: {trade_count}")
if trade_count > 0:
print(f"[DONE] Win Rate: {wins / trade_count * 100:.1f}%")

How to Tune MA Periods

Different period combinations work better on different instruments and timeframes:

  • Fast: 5-9, Slow: 13-21
  • More signals, tighter stops
  • Example: EMA 5/13 on 1-minute NQ

Tips for choosing periods:

  1. Shorter periods = more signals, more whipsaws, faster response
  2. Longer periods = fewer signals, less noise, more lag
  3. Wider gap between periods (e.g., 9/50 vs 9/21) = fewer crossovers, larger moves required to trigger
  4. Test on historical data using TestMax before committing to any combination

Common Pitfalls

  1. Whipsaw in sideways markets — MA crossovers generate many false signals when price is range-bound. Consider adding a trend filter (e.g., only take long trades when the 200-period MA is sloping up).

  2. Lag at reversals — by definition, MAs lag price. The crossover happens after the move has already started. Accept this — you are trading the middle of the move, not the top or bottom.

  3. Over-optimizing periods — finding the “perfect” period combination on past data does not guarantee future performance. See Parameter Optimization for how to avoid overfitting.

What’s Next

You have now built a real indicator-based strategy. Next steps: