We already built the two parts needed for Average True Range.
True Range
→ measure one bar's movement
Wilder Smoothing
→ turn a sequence into a smoother running average
ATR does not need a completely new mathematical idea.
It combines those two Learning Blocks.
True Range
+
Wilder Smoothing
=
Average True Range
ATR
This is an important point in Phase 3.
More advanced indicators often become easier when we stop treating them as mysterious formulas and instead recognize familiar pieces being reused.
1. Start with True Range
True Range measures the size of one bar while also checking the previous Close.
TR
=
max(
High - Low,
|High - Previous Close|,
|Low - Previous Close|
)
That means a gap can enlarge True Range even when the current candle itself is narrow.
If you want to review that calculation first, see What Is True Range? Measure Daily Price Movement with Python.
2. One True Range Value Is Only One Bar
Suppose recent True Range values are:
6
4
5
7
6
Each number answers:
How large was this bar's
relevant movement?
But it does not yet answer:
What has the recent
movement level been?
ATR asks that second question.
3. ATR Smooths True Range Across Time
J. Welles Wilder's ATR uses the smoothing method we just studied.
True Range sequence
↓
Wilder smoothing
↓
Average True Range
If the period is N,
the first ATR in our implementation is the arithmetic mean
of the first N valid True Range values.
After that, the update is recursive.
ATR today
=
(
ATR yesterday × (N - 1)
+
TR today
)
/
N
4. Reuse the Wilder Smoothing Block
We do not need to invent another averaging function.
We already built:
wilder_average()
in the previous lesson.
If you want to review why its smoothing factor is 1/N,
see What Is Wilder’s Smoothing? Build Wilder Average from Scratch with Python.
ATR is therefore:
true_range_values
↓
wilder_average(
values=true_range_values,
period=14,
)
↓
ATR 14
5. Calculate a Tiny ATR by Hand
Use:
True Range:
6, 4, 5, 7, 6
ATR period:
3
The first three valid True Range values create the seed:
(6 + 4 + 5) / 3
=
5
So the first ATR is:
ATR = 5
6. Update the ATR with the Next True Range
The next True Range is:
7
Apply Wilder smoothing:
(
5 × 2
+
7
)
/
3
=
5.666667
The next True Range is:
6
Update again:
(
5.666667 × 2
+
6
)
/
3
≈
5.777778
7. Read the Hand Calculation as a Flow
TR
6, 4, 5
↓
average
↓
ATR = 5
next TR = 7
↓
Wilder update
↓
ATR ≈ 5.67
next TR = 6
↓
Wilder update
↓
ATR ≈ 5.78
A single large True Range can push ATR upward.
But ATR does not jump all the way to the new True Range value.
The smoothing rule carries information from earlier bars forward.
8. ATR Measures Magnitude, Not Direction
high ATR
does not mean
bullish
high ATR
does not mean
bearish
A large upward move can increase ATR.
A large downward move can also increase ATR.
ATR
→ recent movement magnitude
not
→ movement direction
9. What Does a Rising ATR Mean?
If ATR rises, recent True Range values are large enough to pull the smoothed average upward.
larger recent TR values
↓
ATR rises
This tells us that price movement has become wider or gappier in price units.
It does not tell us which direction price will move next.
10. What Does a Falling ATR Mean?
If newer True Range values are generally smaller than the existing ATR, the average is pulled downward.
smaller recent TR values
↓
ATR falls
Again, that is a statement about movement size.
It is not automatically a statement about trend direction.
11. ATR Is in Price Units
Suppose AAPL ATR is:
ATR = 5.8
That means approximately:
5.8 price units
It does not mean:
5.8%
This matters when comparing assets with very different price levels.
A later normalized measure can convert ATR into a relative scale, but this lesson keeps the original price-unit interpretation.
12. Why the First ATR Appears Later Than the First True Range
True Range needs a previous Close.
So the first row of our True Range series is:
None
Then ATR needs N valid True Range values
before the first average can be created.
row 0
→ no previous Close
→ TR = None
next N rows
→ collect valid TR values
after N valid TR values
→ first ATR
This is not missing data caused by a bug.
It follows from the information required by the calculation.
13. Build ATR by Composing Two Frozen Functions
The new ATR function is deliberately short.
def average_true_range(
high_values,
low_values,
close_values,
period,
):
true_range_values = true_range(
high_values=high_values,
low_values=low_values,
close_values=close_values,
)
atr_values = wilder_average(
values=true_range_values,
period=period,
)
return (
true_range_values,
atr_values,
)
Notice what happened.
no new True Range formula
no new smoothing formula
reuse
+
composition
=
ATR
14. This Is a Programming Lesson Too
Earlier we froze:
true_range()
Then we froze:
wilder_average()
Now we can connect them without changing either block.
tested block A
+
tested block B
=
new higher-level function
This is one reason modular code becomes more valuable as indicators become more complicated.
15. Validate Before Downloading Market Data
The Python program checks the hand example first.
Expected True Range:
None, 6, 4, 5, 7, 6
Expected ATR(3):
None,
None,
None,
5,
5.666667,
5.777778
If the program cannot reproduce those values, it stops before using real AAPL data.
hand calculation
↓
self-test
↓
PASS
↓
real market data
16. The Chart Uses Two Panels
Panel 1
AAPL candlesticks
Panel 2
True Range
+
ATR 14
Rising candles use seagreen.
Falling candles use firebrick.
True Range and ATR use neutral colors because neither one describes bullish or bearish direction.
17. What Should You Look for on the Chart?
True Range can jump quickly from one bar to the next.
ATR reacts more gradually.
True Range
→ bar-by-bar movement
ATR
→ smoothed recent movement level
Look for periods where individual True Range spikes pull ATR upward.
Then watch how ATR remains elevated for a while even after True Range falls again.
18. Change One Thing Yourself
Start with:
atr_period = 14
Then try:
atr_period = 7
Ask:
Does ATR react faster
to a sudden True Range spike?
Does it fall faster afterward?
Then try:
atr_period = 28
Ask the opposite questions.
The goal is not to optimize ATR yet.
The goal is to understand what the smoothing period changes.
19. ATR 14 Is a Convention, Not a Law
A 14-period ATR is a common default associated with Wilder's framework.
But the period is still a model choice.
shorter period
→ more responsive
→ less smooth
longer period
→ slower response
→ smoother
A trading rule should not treat 14 as a universal physical constant.
20. ATR Is Not a Trading Strategy
ATR rises
does not automatically mean
buy
ATR falls
does not automatically mean
sell
ATR is a volatility measurement.
Later, a strategy may choose to use ATR for:
risk scaling
stop distance
position sizing
volatility filters
But those are separate strategy hypotheses.
They must be defined and tested later.
21. Why ATR Matters for the Next Indicator Family
ATR is not the end of Wilder's framework.
Directional Movement also needs a way to normalize directional movement by the size of recent price movement.
True Range
↓
Wilder smoothing
↓
ATR / smoothed range
↓
Directional Movement framework
↓
+DI / -DI
↓
ADX
So the work we have done is becoming shared infrastructure.
22. The Complete Python Program
This lesson introduces no new Python package.
We reuse FinanceDataReader for market data
and matplotlib for visualization.
Save the following file as:
phase3_average_true_range_atr.py
from pathlib import Path
from datetime import date, timedelta
import os
import FinanceDataReader as fdr
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
# ============================================================
# Phase 3 — Average True Range (ATR)
# Reuse True Range + Wilder Smoothing
# ============================================================
# ------------------------------------------------------------
# 1. Settings
# ------------------------------------------------------------
symbol = "AAPL"
recent_trading_days = 160
atr_period = 14
bullish_color = "seagreen"
bearish_color = "firebrick"
true_range_color = "dimgray"
atr_color = "black"
# ------------------------------------------------------------
# 2. Working folder
# ------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
os.chdir(SCRIPT_DIR)
print("Working folder:")
print(SCRIPT_DIR)
print()
# ------------------------------------------------------------
# 3. Frozen Learning Block: True Range
# ------------------------------------------------------------
def true_range(
high_values,
low_values,
close_values,
):
if not (
len(high_values)
== len(low_values)
== len(close_values)
):
raise ValueError(
"High, Low, and Close must have "
"the same length."
)
result = [None]
for i in range(
1,
len(close_values),
):
high_now = float(
high_values[i]
)
low_now = float(
low_values[i]
)
previous_close = float(
close_values[i - 1]
)
high_low_range = (
high_now
- low_now
)
high_gap = abs(
high_now
- previous_close
)
low_gap = abs(
low_now
- previous_close
)
current_true_range = max(
high_low_range,
high_gap,
low_gap,
)
result.append(
current_true_range
)
return result
# ------------------------------------------------------------
# 4. Frozen Learning Block: Wilder Average
# ------------------------------------------------------------
def wilder_average(
values,
period,
):
result = [None] * len(values)
if period <= 0:
raise ValueError(
"period must be positive"
)
valid_start = next(
(
i
for i, value
in enumerate(values)
if value is not None
),
None,
)
if valid_start is None:
return result
seed_end = (
valid_start
+ period
- 1
)
if seed_end >= len(values):
return result
seed_values = values[
valid_start : seed_end + 1
]
if any(
value is None
for value in seed_values
):
raise ValueError(
"Expected a continuous block "
"of valid values for the "
"Wilder seed."
)
seed = (
sum(
float(value)
for value
in seed_values
)
/ period
)
result[seed_end] = seed
alpha = 1.0 / period
for i in range(
seed_end + 1,
len(values),
):
current_value = values[i]
if current_value is None:
result[i] = None
continue
previous_average = (
result[i - 1]
)
if previous_average is None:
raise ValueError(
"Unexpected missing "
"Wilder state."
)
result[i] = (
previous_average
+ alpha
* (
float(current_value)
- previous_average
)
)
return result
# ------------------------------------------------------------
# 5. ATR is now only composition
# ------------------------------------------------------------
def average_true_range(
high_values,
low_values,
close_values,
period,
):
true_range_values = true_range(
high_values=high_values,
low_values=low_values,
close_values=close_values,
)
atr_values = wilder_average(
values=true_range_values,
period=period,
)
return (
true_range_values,
atr_values,
)
# ------------------------------------------------------------
# 6. Draw candlesticks
# ------------------------------------------------------------
def draw_candlesticks(
ax,
market_df,
body_width=0.62,
):
for x, (_, row) in enumerate(
market_df.iterrows()
):
open_price = float(
row["Open"]
)
high_price = float(
row["High"]
)
low_price = float(
row["Low"]
)
close_price = float(
row["Close"]
)
if close_price >= open_price:
candle_color = (
bullish_color
)
else:
candle_color = (
bearish_color
)
ax.vlines(
x,
low_price,
high_price,
color=candle_color,
linewidth=1.0,
)
body_bottom = min(
open_price,
close_price,
)
body_height = abs(
close_price
- open_price
)
if body_height == 0:
body_height = max(
high_price
- low_price,
0.01,
) * 0.02
body = Rectangle(
(
x
- body_width / 2.0,
body_bottom,
),
body_width,
body_height,
facecolor=candle_color,
edgecolor=candle_color,
linewidth=0.8,
)
ax.add_patch(body)
# ------------------------------------------------------------
# 7. Hand-calculation self-test
# ------------------------------------------------------------
toy_high = [
102.0,
106.0,
108.0,
106.0,
110.0,
109.0,
]
toy_low = [
99.0,
103.0,
104.0,
102.0,
105.0,
103.0,
]
toy_close = [
100.0,
105.0,
107.0,
103.0,
108.0,
104.0,
]
toy_period = 3
toy_tr, toy_atr = (
average_true_range(
high_values=toy_high,
low_values=toy_low,
close_values=toy_close,
period=toy_period,
)
)
expected_tr = [
None,
6.0,
4.0,
5.0,
7.0,
6.0,
]
expected_atr = [
None,
None,
None,
5.0,
5.666666666666667,
5.777777777777779,
]
for actual, expected in zip(
toy_tr,
expected_tr,
):
if expected is None:
assert actual is None
else:
assert abs(
actual - expected
) < 1e-12
for actual, expected in zip(
toy_atr,
expected_atr,
):
if expected is None:
assert actual is None
else:
assert abs(
actual - expected
) < 1e-12
print("Self-test")
print("=========")
print()
print("True Range:")
print(toy_tr)
print()
print("ATR(3):")
print(toy_atr)
print()
print("Self-test: PASS")
print()
# ------------------------------------------------------------
# 8. Download recent AAPL data
# ------------------------------------------------------------
today = date.today()
start_date = (
today
- timedelta(days=360)
).strftime("%Y-%m-%d")
end_date = today.strftime(
"%Y-%m-%d"
)
df = fdr.DataReader(
symbol,
start_date,
end_date,
)
df = df.tail(
recent_trading_days
).copy()
if len(df) < atr_period + 1:
raise ValueError(
"Not enough market data "
"for the selected ATR period."
)
# ------------------------------------------------------------
# 9. Reuse the two frozen blocks
# ------------------------------------------------------------
high_values = [
float(value)
for value
in df["High"]
]
low_values = [
float(value)
for value
in df["Low"]
]
close_values = [
float(value)
for value
in df["Close"]
]
tr_values, atr_values = (
average_true_range(
high_values=high_values,
low_values=low_values,
close_values=close_values,
period=atr_period,
)
)
df["True Range"] = tr_values
df["ATR"] = atr_values
# ------------------------------------------------------------
# 10. Print the latest values
# ------------------------------------------------------------
valid_df = df.dropna(
subset=["ATR"]
)
latest = valid_df.iloc[-1]
latest_date = valid_df.index[-1]
print("Latest ATR")
print("==========")
print()
print(
"Symbol:",
symbol,
)
print(
"Date:",
latest_date.strftime(
"%Y-%m-%d"
),
)
print(
"Close:",
f'{latest["Close"]:.2f}',
)
print(
"True Range:",
f'{latest["True Range"]:.2f}',
)
print(
f"ATR({atr_period}):",
f'{latest["ATR"]:.2f}',
)
# ------------------------------------------------------------
# 11. Plot candles + True Range / ATR
# ------------------------------------------------------------
plot_df = df.tail(90).copy()
x_values = list(
range(len(plot_df))
)
fig = plt.figure(
figsize=(12, 8),
)
grid = fig.add_gridspec(
2,
1,
height_ratios=[2.1, 1.0],
hspace=0.08,
)
ax_price = fig.add_subplot(
grid[0]
)
ax_atr = fig.add_subplot(
grid[1],
sharex=ax_price,
)
draw_candlesticks(
ax=ax_price,
market_df=plot_df,
)
ax_price.set_title(
f"{symbol} — Price and ATR {atr_period}"
)
ax_price.set_ylabel(
"Price"
)
ax_price.grid(
axis="y",
alpha=0.20,
)
ax_price.tick_params(
axis="x",
labelbottom=False,
)
ax_atr.plot(
x_values,
plot_df["True Range"],
color=true_range_color,
linewidth=1.0,
label="True Range",
)
ax_atr.plot(
x_values,
plot_df["ATR"],
color=atr_color,
linewidth=2.0,
label=f"ATR {atr_period}",
)
ax_atr.set_ylabel(
"Price Units"
)
ax_atr.set_xlabel(
"Date"
)
ax_atr.grid(
axis="y",
alpha=0.20,
)
ax_atr.legend()
tick_step = max(
1,
len(plot_df) // 8,
)
tick_positions = list(
range(
0,
len(plot_df),
tick_step,
)
)
tick_labels = [
plot_df.index[i].strftime(
"%Y-%m-%d"
)
for i in tick_positions
]
ax_atr.set_xticks(
tick_positions
)
ax_atr.set_xticklabels(
tick_labels,
rotation=35,
ha="right",
)
fig.subplots_adjust(
left=0.09,
right=0.98,
top=0.94,
bottom=0.13,
)
# ------------------------------------------------------------
# 12. Save the chart
# ------------------------------------------------------------
output_file = (
SCRIPT_DIR
/ "atr_true_range_aapl.png"
)
fig.savefig(
output_file,
dpi=140,
)
print()
print("Chart saved:")
print(output_file)
plt.show()
plt.close(fig)
23. Run the Program
python phase3_average_true_range_atr.py
The program will first print:
Self-test: PASS
Then it downloads recent AAPL data, calculates True Range and ATR, prints the latest values, and saves:
atr_true_range_aapl.png
Check Your Understanding
- True Range measures the relevant movement magnitude of one bar.
- ATR smooths a sequence of True Range values across time.
- Our first ATR is the arithmetic mean of the first N valid True Range values.
- Later ATR values use Wilder's recursive smoothing rule.
- ATR measures movement magnitude, not bullish or bearish direction.
- ATR is expressed in price units, not percent.
- A rising ATR means recent True Range values are pulling the smoothed movement level upward.
- A falling ATR means newer True Range values are pulling the smoothed movement level downward.
- A shorter ATR period reacts faster, while a longer period is smoother.
- ATR can be built by reusing the previously tested
true_range()andwilder_average()blocks. - ATR is a volatility measure, not an automatic trading rule.
What You Just Learned
High / Low / Previous Close
↓
True Range
↓
Wilder Smoothing
↓
ATR
↓
recent movement magnitude
in price units
If one idea stays in your head after this lesson, let it be this:
ATR is not a separate mystery formula. It is True Range passed through the Wilder smoothing block we already understand.
Where Do We Go Next?
We now know:
how large one bar moved
→ True Range
how to smooth a sequence
→ Wilder Smoothing
how large recent movement has been
→ ATR
The next question is:
Which side of the price range
expanded more?
That leads to Directional Movement,
where we will build +DM, -DM, +DI, and -DI.
Sources
- TA-Lib official documentation — Average True Range (ATR) — ATR definition, Wilder smoothing formula, default period, and inputs.
- TA-Lib official documentation — True Range (TRANGE) — True Range formula and relationship to ATR.
- J. Welles Wilder Jr., New Concepts in Technical Trading Systems, Trend Research, 1978 — Google Books bibliographic record.