Broker API HubMenu

Technique explained as code

Volatility stop using average true range

Average true range measures how far price typically moves in a day. This page computes it from scratch and uses it to set a stop that trails behind price at a distance that adapts.

Independent and unofficialEducational onlyInvented data onlyCode run

Risk warning: CFDs and spread bets are complex instruments and come with a high risk of losing money rapidly due to leverage. You can lose money on these products, and each provider publishes its own loss figure under FCA rules. You should consider whether you understand how they work and whether you can afford to take the high risk of losing your money. Automated and API trading can amplify losses through bugs, connectivity failures and runaway orders.

Teaching material only. This page explains how a technique works as code and how to test an idea honestly. It is not investment advice, not a recommendation and not an invitation to trade. Every price on it is invented, so no result here says anything about any real market, and nothing here suggests that a technique makes money.

What it is

The true range of a day is the distance from its high to its low, widened to include any jump from the previous close. Average true range, or ATR, is a smoothed average of that over a number of days. It is a measure of movement, in the same units as the price.

A volatility stop places an exit a fixed number of ATRs away from the price. A trailing version moves the exit up as price rises and never moves it down.

The idea behind it

A stop at a fixed distance is too tight when a market is moving a lot and too loose when it is quiet. Measuring the distance in units of recent movement lets the same rule be used through calm and wild periods.

ATR says nothing about direction. It is a ruler. This page is about the exit, so the entry rule is kept deliberately plain.

The code

Two details make this honest. The stop for today is set from yesterday's information, then today's low decides whether it was hit. And if the day opens below the stop, the script fills at the open, not at the stop, because a stop is an instruction to sell at the next available price and not a promise of a price.

Data the script reads: invented-prices.csv. Invented values, never a real market. Save it in one folder and run the script from that folder.

The file that was run: atr_stop.py

# A stop that follows price at a distance set by average true range (ATR).
# Prices come from invented-prices.csv: a random walk made by a script, never a real market.
# The result therefore says nothing about any real market. Read it for the method.
import csv

opens, highs, lows, closes = [], [], [], []
with open("invented-prices.csv", newline="") as f:
    for row in csv.DictReader(f):
        opens.append(float(row["open"]))
        highs.append(float(row["high"]))
        lows.append(float(row["low"]))
        closes.append(float(row["close"]))


def true_ranges():
    """The true range of each day: the day's own range, widened to include any gap from yesterday's close."""
    ranges = [highs[0] - lows[0]]
    for i in range(1, len(closes)):
        ranges.append(max(highs[i] - lows[i], abs(highs[i] - closes[i - 1]), abs(lows[i] - closes[i - 1])))
    return ranges


def average_true_range(period):
    """ATR for every day, with Wilder's smoothing. Positions with too little history hold None."""
    ranges = true_ranges()
    atr = [None] * len(ranges)
    atr[period - 1] = sum(ranges[:period]) / period
    for i in range(period, len(ranges)):
        atr[i] = (atr[i - 1] * (period - 1) + ranges[i]) / period
    return atr


def average(values):
    return sum(values) / len(values)


# Example values, picked only because they are easy to follow. They are not a suggestion.
PERIOD = 14          # days in the ATR
MULTIPLE = 3.0       # the stop sits this many ATRs under the highest close since entry
TREND_DAYS = 20      # a deliberately plain entry rule, so the stop is the thing being studied
COST = 0.10          # points paid on every completed trade, a stand in for the spread

atr = average_true_range(PERIOD)
print("ATR on the last day:", round(atr[-1], 2), "points")

holding = False
entry = stop = highest = 0.0
results = []
gapped = 0           # times the day opened below the stop, so the fill was worse than the stop
START = TREND_DAYS + 1

for day in range(START, len(closes)):
    yesterday = day - 1
    if holding:
        # The stop was set last night. Today's prices decide whether it is hit.
        if lows[day] <= stop:
            fill = min(stop, opens[day])     # a gap down fills at the open, not at the stop
            if opens[day] < stop:
                gapped += 1
            results.append(fill - entry - COST)
            holding = False
        else:
            # Not hit. Move the stop up for tomorrow if today's close made a new high. It never moves down.
            highest = max(highest, closes[day])
            stop = max(stop, highest - MULTIPLE * atr[day])
    elif closes[yesterday] > average(closes[yesterday - TREND_DAYS + 1:yesterday + 1]):
        # Entry decided from prices up to yesterday, taken at today's close.
        holding, entry, highest = True, closes[day], closes[day]
        stop = entry - MULTIPLE * atr[day]

if holding:
    results.append(closes[-1] - entry - COST)

print("Trades:", len(results))
print("Trades that ended above zero:", len([r for r in results if r > 0]))
print("Stops filled below the stop price because of a gap:", gapped)
if results:
    print("Largest single loss:", round(min(results), 2))
print("Total points after costs:", round(sum(results), 2))
print("Simply holding over the same days:", round(closes[-1] - closes[START] - COST, 2))

What the run printed

ATR on the last day: 1.29 points
Trades: 24
Trades that ended above zero: 6
Stops filled below the stop price because of a gap: 0
Largest single loss: -4.85
Total points after costs: -16.1
Simply holding over the same days: -10.82

Output of the run on , copied as printed. Every input to this run was invented, so the result says nothing about any real market.

How to read it

Twenty four trades, six above zero. A trailing stop on a wandering price is hit often, and each hit is a loss of roughly the stop distance plus the cost.

The largest single loss is a few points, in line with a stop set three ATRs away. That is what a stop is for: it does not make a rule gain, it puts an approximate limit on each loss.

The line about gaps reads zero, and that is a property of this file, not of stops. The script that made the prices starts each day exactly where the last one ended, so nothing ever opens beyond a stop. Real markets do gap, and then the loss is larger than the stop distance.

The total is below zero and below holding. The prices are a random walk. It says nothing about any real market.

How it fails or misleads

The page on how a backtest lies shows the mistakes that make any rule look better in a test than it is.

What to try changing

These are programming exercises. Each one changes the code and asks you to explain what you see. None of them is a search for settings to use.

How this page was made

Each script on this page was run with Python 3.12 on Windows, standard library only, from a folder holding the data files listed. No network and no account. Each exited with code 0, and what is shown is everything it printed.

The data files whose names begin with the word invented are written by make_invented_data.py from fixed seeds, so anyone can make the same files again and get the same output. This site is not published if the code or a data file has changed since the run.