What Is ATR? Build Average True Range with Python

AAPL candlestick chart with True Range and ATR 14 calculated in Python

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() and wilder_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