sifting/io
Forex & Crypto
9 min readSiftingIO Team

Forex ATR in Python: calculate volatility from OHLCV

Calculate forex ATR in Python from OHLCV bars. Test Wilder smoothing, convert ATR to pips and percentages, and avoid seed and unfinished-candle errors.

Forex ATR in Python: calculate volatility from OHLCV

You can calculate forex ATR in Python from historical OHLCV bars without installing a technical-analysis library. The important choices are which bars you include, how you seed the first value, and which smoothing method you use. To follow along with EUR/USD, create a SiftingIO account and get your API key, then pull hourly bars from the Forex Data API.

This guide builds a small, auditable Average True Range calculation, checks it against a hand-worked example, and converts the result into pips and a percentage. It is for research and application development, not a trading recommendation.

What does ATR measure in forex?#

ATR measures the size of recent price movement, including gaps between successive bars. It does not tell you whether a currency pair is likely to rise or fall. A larger value means a larger smoothed true range, not a stronger buy signal.

For each bar after the first, calculate:

true_range = max(
    high - low,
    abs(high - previous_close),
    abs(low - previous_close)
)

The previous close matters. If EUR/USD closes at 1.1010 and the next bar trades between 1.1030 and 1.1050, its high-to-low range is 0.0020. Its true range is 0.0040 because the move from the previous close to the new high is larger.

We use Wilder's smoothing, with the arithmetic mean of the first n true ranges as the seed:

first_ATR = mean(first n true ranges)
next_ATR = (previous_ATR * (n - 1) + current_true_range) / n

This recurrence is documented in Fidelity's ATR reference. The implementation below deliberately uses the first bar only to establish a previous close. That makes the first 14-period ATR available on bar 15. Other implementations may use the first bar's high minus low as their first true range, so the initialization convention must be matched when comparing results.

Pull hourly EUR/USD OHLCV bars#

Generate a key in your SiftingIO dashboard, copy it when it is displayed, and set it as the SIFTINGIO_API_KEY environment variable in your local shell. Keep the key out of source control and browser-side JavaScript.

The Forex bars endpoint documentation defines the request and response used here. This example asks for a completed historical window at a one-hour interval:

curl --fail-with-body --silent --show-error --compressed --get \
  'https://api.sifting.io/v1/hist/forex/EURUSD/bars' \
  -H "X-API-Key: $SIFTINGIO_API_KEY" \
  -H 'Accept-Encoding: gzip' \
  --data-urlencode 'start=2026-09-14T00:00:00Z' \
  --data-urlencode 'end=2026-09-18T23:59:59Z' \
  --data-urlencode 'interval=1h' \
  --data-urlencode 'order=asc' \
  --data-urlencode 'limit=1000' \
  --output eurusd-bars.json

Use a completed date range within your plan's historical access when you run it. The example dates are fixed for reproducibility, not a rolling request for the latest week. Confirm that cURL succeeds before running the calculation.

The data array contains numeric o, h, l, and c fields, plus v and a t timestamp in Unix milliseconds. The timestamp marks the bar's opening time. Accept-Encoding: gzip requests the required compression; --compressed makes cURL decode the response before saving the JSON.

ATR uses high, low, and previous close. It does not use volume. SiftingIO's forex v field represents aggregated traded volume, but it does not enter this calculation. These historical OHLCV bars come from REST, not from the live WebSocket stream.

Calculate Wilder ATR with plain Python#

Save this as forex_atr.py. It uses only Python's standard library. The function rejects non-finite or invalid OHLC values, duplicate timestamps, and out-of-order input rather than silently producing a number from them.

import json
import math
from datetime import datetime, timezone


def wilder_atr(bars, period=14):
    if type(period) is not int or period < 1:
        raise ValueError("period must be a positive integer")
    if len(bars) < period + 1:
        raise ValueError(f"Need at least {period + 1} bars")

    previous_t = None
    previous_close = None
    seed = []
    atr = None
    output = []

    for bar in bars:
        t = bar["t"]
        o, h, l, c = (bar[k] for k in ("o", "h", "l", "c"))
        if type(t) is not int or t < 0:
            raise ValueError("Invalid bar timestamp")
        if previous_t is not None and t <= previous_t:
            raise ValueError("Bars must have unique, ascending timestamps")
        if not all(type(x) in (int, float) and math.isfinite(x)
                   and x > 0 for x in (o, h, l, c)):
            raise ValueError("OHLC prices must be finite positive numbers")
        if not (l <= o <= h and l <= c <= h):
            raise ValueError("Open/close must be inside the high-low range")

        if previous_close is not None:
            tr = max(h - l, abs(h - previous_close),
                     abs(l - previous_close))
            if atr is None:
                seed.append(tr)
                if len(seed) == period:
                    atr = math.fsum(seed) / period
            else:
                atr = (atr * (period - 1) + tr) / period

            if atr is not None:
                output.append({"t": t, "close": c, "atr": atr,
                               "atr_pct": 100 * atr / c})
        previous_t, previous_close = t, c

    return output


if __name__ == "__main__":
    with open("eurusd-bars.json", encoding="utf-8") as file:
        payload = json.load(file)
    if payload.get("meta", {}).get("next_cursor"):
        raise ValueError("More pages exist: fetch the full window first")

    result = wilder_atr(payload["data"], period=14)
    last = result[-1]
    opened = datetime.fromtimestamp(last["t"] / 1000, timezone.utc)
    print(f"EURUSD | 1h bar opened {opened.isoformat()}")
    print(f"ATR(14): {last['atr']:.6f}")
    print(f"ATR in EURUSD pips: {last['atr'] / 0.0001:.2f}")
    print(f"ATR as % of close: {last['atr_pct']:.4f}%")

Run python3 forex_atr.py from the folder containing the JSON file. The result depends on the returned bars; the script does not contain a claimed live ATR value.

The pagination check is intentional. For a larger window, retrieve every page using meta.next_cursor, keep the pair, interval, and end boundary fixed, then calculate on the complete series. Do not restart the ATR seed for each API page. Our cursor pagination guide covers fetching longer histories.

A worked example you can verify#

The following prices are synthetic test inputs, not a market snapshot. Start with a context bar whose close is 1.1000, then use these four bars. For EUR/USD, one pip in this example is 0.0001.

BarPrevious closeHighLowCloseHigh minus lowTrue range
11.10001.10201.09901.101030 pips30 pips
21.10101.10501.10301.104020 pips40 pips
31.10401.10101.09801.099030 pips60 pips
41.09901.10001.09801.099520 pips20 pips

With a three-period setting, the first ATR is (30 + 40 + 60) / 3 = 43.3333 pips. The next is (43.3333 × 2 + 20) / 3 = 35.5556 pips, using full precision internally.

A three-bar simple moving average would instead give (40 + 60 + 20) / 3 = 40 pips at bar 4. Both calculations use the same data. They are different smoothing methods, not evidence that one data feed is wrong.

Run this second file alongside forex_atr.py to check the implementation without an API key:

from math import isclose
from forex_atr import wilder_atr

# Synthetic hourly bars: (open, high, low, close).
prices = [
    (1.1000, 1.1010, 1.0990, 1.1000),  # previous-close context
    (1.1000, 1.1020, 1.0990, 1.1010),
    (1.1030, 1.1050, 1.1030, 1.1040),
    (1.1010, 1.1010, 1.0980, 1.0990),
    (1.0990, 1.1000, 1.0980, 1.0995),
]
bars = [dict(zip(("o", "h", "l", "c"), row),
             t=1_789_344_000_000 + i * 3_600_000)
        for i, row in enumerate(prices)]
values = wilder_atr(bars, period=3)
assert len(values) == 2
assert isclose(values[0]["atr"] / 0.0001, 130 / 3, abs_tol=1e-8)
assert isclose(values[1]["atr"] / 0.0001, 320 / 9, abs_tol=1e-8)
print("ATR seed and next update passed")

Pips or percentages: which should your app display?#

Raw ATR is expressed in the pair's quote-currency price units. Divide by the pair's pip size for a pip reading, or divide by the same bar's close and multiply by 100 for a price-normalized percentage. The latter convention is also described in TradingView's ATR% reference.

DisplayCalculationUseful for
Raw ATRWilder-smoothed true rangeCalculations in the original price units
ATR in pipsATR / pip sizeA familiar scale for one currency pair
ATR%100 × ATR / same-bar closeComparing relative range across pairs on matched intervals

For a hypothetical EUR/USD close of 1.1000 and ATR of 0.0055, the values are 55 pips and 0.5%. A hypothetical USD/JPY close of 150.00 and ATR of 0.75 also gives 0.5%, but 75 pips with a 0.01 pip size. Comparing the raw values, 0.0055 and 0.75, would obscure that relationship.

Keep the interval, period, and smoothing method the same for cross-pair comparisons. ATR% is a relative range measure, not annualized volatility or a probability forecast. For co-movement between pairs, use a different calculation, such as the returns-based forex correlation matrix.

Why your ATR may differ from a chart#

Before changing the formula, compare the inputs and settings:

  1. Bar boundaries. Hourly, daily, and broker-session candles are different inputs. The example uses hourly UTC bars, not a broker's daily close.
  2. Smoothing and initialization. Match Wilder smoothing versus a simple or exponential moving average, plus the first true-range and seed conventions. A fresh short-window seed can differ from an indicator carried forward through a longer history. Fetch extra warm-up bars and retain only the later outputs when comparing.
  3. Unfinished bars. A current candle's high, low, and close can still change. This tutorial uses a completed historical window. A live dashboard should distinguish provisional ATR from the value calculated after a bar closes.
  4. Missing observations. The function validates values and ordering; it does not certify calendar completeness. An unexplained gap during an open session needs investigation. Do not add flat candles merely to make the row count match. Use the missing-candles guide to examine the gap first.
  5. Price source. SiftingIO supplies aggregated reference data. It need not match a particular broker's bid, ask, or execution feed. A different underlying high or low can produce a different ATR even when the calculation is identical.

For scheduled closures, the next observed bar still compares with the preceding observed close. That is different from assuming that a missing open-session bar never happened. Resolve data-quality questions before interpreting the output.

Put the calculation into your application#

A useful volatility panel should show the pair, interval, ATR period, smoothing method, latest bar time, and whether that bar is closed. Store those settings with the result so another researcher can reproduce it.

SiftingIO's Forex Data API provides the historical OHLCV input under a documented REST schema. You control the ATR implementation, can test it on synthetic fixtures, and can reuse the same calculation across supported pairs. No trading-library installation is needed for this example.

Create your account and generate an API key, start with one completed EUR/USD window, and run the fixture before using your own data. Check Forex pricing and historical access if your research needs a longer lookback. This example measures price movement; it does not generate orders or promise trading results.

Keep reading

Related posts