Compute a simple and an exponential moving average, and see how each one weights the past.
A simple moving average of the last n closes gives every one of those n closes the same weight, and gives every close before them a weight of zero. An exponential moving average gives today the most weight, yesterday a bit less, the day before a bit less again, and never drops a close entirely.
In this lesson I compute both on seven prices I can check by hand, reproduce one exponential value from the formula ema_t = a * price_t + (1 - a) * ema_(t-1), and then put a 50-day version of each side by side on AAA.
Step 1. Seven prices
.rolling(3).mean() is the simple moving average over three rows. .ewm(span=3, adjust=False).mean() is the exponential one.
import pandas as pdprice = pd.Series([10.0, 11.0, 12.0, 20.0, 12.0, 12.0, 12.0]) # seven closes, with one spike at row 3sma = price.rolling(3).mean() # flat mean of the last three closesema = price.ewm(span=3, adjust=False).mean() # same three day speed, weighted toward todayprint(pd.DataFrame({"price": price, "sma3": sma, "ema3": ema}))# -> price sma3 ema3# -> 0 10.0 NaN 10.000000# -> 1 11.0 NaN 10.500000# -> 2 12.0 11.000000 11.250000# -> 3 20.0 14.333333 15.625000# -> 4 12.0 14.666667 13.812500# -> 5 12.0 14.666667 12.906250# -> 6 12.0 12.000000 12.453125
The exponential average carries yesterday’s value forward and mixes in today’s price. The mixing weight is a = 2 / (span + 1), so a span of 3 gives a = 0.5.
Row 0 has no yesterday, so with adjust=False pandas seeds it with the first price.
alpha =2/ (3+1) # mixing weight 2/(span+1) for a span of 3print(alpha) # -> 0.5print(ema.iloc[0]) # -> 10.0print(ema.iloc[1]) # -> 10.5print(alpha *11.0+ (1- alpha) *10.0) # -> 10.5
0.5
10.0
10.5
10.5
Row 3 is the same step run on row 2, which is where the jump to 20 shows up.
Unrolling that recursion gives the weight on each past price: a on today, a * (1 - a) on yesterday, a * (1 - a)^2 on the day before, and so on.
weights = [alpha * (1- alpha) ** k for k inrange(6)] # weight landing on each of the last six daysprint([round(w, 4) for w in weights])# -> [0.5, 0.25, 0.125, 0.0625, 0.0312, 0.0156]print(round(sum(weights), 4)) # -> 0.9844
[0.5, 0.25, 0.125, 0.0625, 0.0312, 0.0156]
0.9844
Six days account for 98.44% of the weight. The rest sits on everything older, halving each step back, never reaching zero.
Step 3. When an old price leaves the window
Rows 4, 5 and 6 all have a price of 12. The simple average holds at 14.666667 while the 20 is still inside its three day window, then drops to 12.0 the moment the 20 falls out.
The exponential average moves on all three rows instead: 13.812500, then 12.906250, then 12.453125, halving the distance to 12 each time.
Step 4. Fifty days on AAA
prices.csv sits next to this lesson and holds simulated data: daily closes for four tickers over six years. I take AAA and index it by date.
prices = pd.read_csv("prices.csv", parse_dates=["Date"]) # Date read as timestamps, not textaapl = prices[prices["Ticker"] =="AAA"].set_index("Date") # one ticker, dates as the indexclose = aapl["Close"] # one column, ready for rolling and ewmprint(len(close)) # -> 1566print(close.head(3)) # first three closes, keyed by date# -> Date# -> 2020-01-01 75.08# -> 2020-01-02 76.94# -> 2020-01-03 77.34# -> Name: Close, dtype: float64
Both averages go into one frame next to the close.
ind = pd.DataFrame({"close": close}) # one frame to hold close and both averagesind["sma50"] = close.rolling(50).mean() # equal weight on the last 50 closesind["ema50"] = close.ewm(span=50, adjust=False).mean() # fades old closes instead of dropping themprint(ind.tail(4).round(2)) # ema50 tracks the close more closely# -> close sma50 ema50# -> Date# -> 2025-12-26 197.62 195.16 192.58# -> 2025-12-29 196.94 195.27 192.75# -> 2025-12-30 196.56 195.34 192.90# -> 2025-12-31 199.30 195.50 193.15
close sma50 ema50
Date
2025-12-26 197.62 195.16 192.58
2025-12-29 196.94 195.27 192.75
2025-12-30 196.56 195.34 192.90
2025-12-31 199.30 195.50 193.15
The same one line formula produces the last exponential value, with a = 2 / 51.
a =2/ (50+1) # mixing weight for a span of 50last = a * ind["close"].iloc[-1] + (1- a) * ind["ema50"].iloc[-2] # one recursion step done by handprint(round(a, 6)) # -> 0.039216print(round(last, 6)) # -> 193.149569print(round(ind["ema50"].iloc[-1], 6)) # -> 193.149569
0.039216
193.149569
193.149569
Each new close moves the 50-day exponential average by 3.9216% of the gap between the close and yesterday’s average.
Step 5. How many rows each one needs
The 50-day simple average needs 50 closes, so the first 49 rows are NaN. The exponential average produces a number on row 0.
print(ind.head(3).round(2)) # sma50 still empty this early# -> close sma50 ema50# -> Date# -> 2020-01-01 75.08 NaN 75.08# -> 2020-01-02 76.94 NaN 75.15# -> 2020-01-03 77.34 NaN 75.24print(ind.iloc[48:51].round(2)) # row 49 is the first sma50 value# -> close sma50 ema50# -> Date# -> 2020-03-09 78.50 NaN 76.42# -> 2020-03-10 80.98 76.20 76.60# -> 2020-03-11 79.65 76.29 76.72
close sma50 ema50
Date
2020-01-01 75.08 NaN 75.08
2020-01-02 76.94 NaN 75.15
2020-01-03 77.34 NaN 75.24
close sma50 ema50
Date
2020-03-09 78.50 NaN 76.42
2020-03-10 80.98 76.20 76.60
2020-03-11 79.65 76.29 76.72
The early exponential values are not an average of 50 days. On 2020-01-01 the value is just the first close. By 2020-03-10, where the simple average starts, the exponential average reads 76.60 against a close of 80.98.
Over the rows where both exist, the exponential average sits closer to the close.
both = ind.dropna() # rows where both averages existprint(round((both["close"] - both["sma50"]).abs().mean(), 2)) # -> 5.72print(round((both["close"] - both["ema50"]).abs().mean(), 2)) # -> 4.91
5.72
4.91
Both of these are indicators. Lesson 29 turns one into a position.
Your turn
Compute a 20-day simple and a 20-day exponential moving average on CCC from prices.csv. How many NaN values does each one start with?
TipShow answer
import pandas as pdprices = pd.read_csv("prices.csv", parse_dates=["Date"]) # Date read as timestamps, not textmsft = prices[prices["Ticker"] =="CCC"].set_index("Date") # one ticker, dates as the indexclose = msft["Close"]out = pd.DataFrame({"close": close})out["sma20"] = close.rolling(20).mean() # no value until 20 closes existout["ema20"] = close.ewm(span=20, adjust=False).mean() # a value from the very first rowprint(out["sma20"].isna().sum()) # -> 19print(out["ema20"].isna().sum()) # -> 0print(out.tail(3).round(2))# -> close sma20 ema20# -> Date# -> 2025-12-29 817.75 794.42 796.46# -> 2025-12-30 826.31 794.59 799.30# -> 2025-12-31 828.51 795.60 802.08