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]) / NIn 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:]) / periodProperties 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_yesterdayThe 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_valuesTo 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.valueSMA vs. EMA
| Property | SMA | EMA |
|---|---|---|
| Weighting | Equal weight to all bars | More weight on recent bars |
| Responsiveness | Slower | Faster |
| Lag | More lag | Less lag |
| Noise sensitivity | Less sensitive | More sensitive |
| Best for | Trend identification | Entry 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 priceprev_sma = calc_sma(closes[:-1], 20) if len(closes) > 20 else sma_value
# Bullish cross: price crosses above SMAif prev_price <= prev_sma and price > sma_value and position is None: # Buy signal pass
# Bearish cross: price crosses below SMAif prev_price >= prev_sma and price < sma_value and position == "LONG": # Sell signal passStrategy 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 PlaygroundBuy 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 periodSLOW_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 = Noneprev_slow = Noneposition = Noneentry_price = 0.0trade_count = 0wins = 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
- Fast: 9-20, Slow: 21-50
- Balanced signal frequency
- Example: EMA 9/21 on 5-minute ES
- Fast: 20-50, Slow: 50-200
- Fewer signals, larger moves
- Example: SMA 50/200 on 1-hour NQ
Tips for choosing periods:
- Shorter periods = more signals, more whipsaws, faster response
- Longer periods = fewer signals, less noise, more lag
- Wider gap between periods (e.g., 9/50 vs 9/21) = fewer crossovers, larger moves required to trigger
- Test on historical data using TestMax before committing to any combination
Common Pitfalls
-
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).
-
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.
-
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:
- RSI Strategies — add a momentum oscillator to your toolkit
- Risk Management in Code — protect your capital with stop losses, daily limits, and position sizing
- Multi-Indicator Strategies — combine MAs with RSI and volume for higher-confidence signals