Skip to content

Volatility

Price with Bollinger band envelope and Average True Range on BTCUSDT 5m

Volatility indicators answer: how much is price moving — and is that movement expanding or contracting? They drive position sizing (risk more when quiet, less when wild), adaptive stops, and breakout detection (a volatility squeeze often precedes a large move).

Range & true-range measures

  • average_true_range — the canonical volatility unit. True range accounts for gaps, so ATR is the natural scale for stops and for normalising signals across instruments. Start here.
  • true_range — the per-bar quantity ATR averages, exposed on its own: max(high - low, |high - prev_close|, |low - prev_close|). Use it when you want the raw bar-level number rather than a smoothed one.
  • normalized_average_true_range — ATR as a percent of price (NATR). Raw ATR is in price units, so a $2 ATR means something very different at $20 than at $2,000; NATR is the version you want when ranking or thresholding volatility across symbols, or comparing one symbol across a period where its price level moved a lot.
  • ulcer_index — a downside-only volatility: depth and duration of drawdowns, closer to felt risk than symmetric measures.

Bands & channels (mean ± a volatility envelope)

  • Bollinger Bandsmavg ± k·σ. Use bollinger_wband to detect squeezes (narrow band → pending breakout) and bollinger_pband as a 0–1 mean-reversion signal (where price sits in the band).
  • Keltner Channel — like Bollinger but banded by ATR instead of standard deviation, so it reacts to gaps rather than closing dispersion. Bollinger-inside-Keltner is the classic squeeze setup.
  • Donchian Channel — the rolling high/low envelope; the basis of breakout systems (the "Turtle" channel).

Each band family exposes the high / low / mid bands plus a width (_wband, for squeeze detection) and a position (_pband, 0–1 within the band), and the _indicator variants flag band touches.

OHLC volatility estimators live in Quant

Range-based estimators that squeeze more information out of each bar — Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang, EWMA/RiskMetrics — are in Quant, since they're aimed at volatility forecasting / risk rather than charting.


polars_ta.volatility

VolatilityIndicators

average_true_range staticmethod

average_true_range(high: str | Expr, low: str | Expr, close: str | Expr, window: int = 14, fillna: bool = False) -> Expr

Average True Range (ATR)

Source code in polars_ta/volatility.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@staticmethod
def average_true_range(
    high: str | pl.Expr,
    low: str | pl.Expr,
    close: str | pl.Expr,
    window: int = 14,
    fillna: bool = False,
) -> pl.Expr:
    """Average True Range (ATR)"""
    high = as_expr(high)
    low = as_expr(low)
    close = as_expr(close)

    min_periods = 1 if fillna else window

    close_shift = close.shift(1)
    true_range = BaseIndicator.true_range(high, low, close_shift)

    # Wilder's smoothing is equivalent to an EMA with alpha = 1 / window
    atr = true_range.ewm_mean(
        alpha=1.0 / window, adjust=False, min_samples=min_periods
    )
    return BaseIndicator.check_fillna(atr, fillna, value=0)

ulcer_index staticmethod

ulcer_index(close: str | Expr, window: int = 14, fillna: bool = False) -> Expr

Ulcer Index (UI) - fully vectorized!

Source code in polars_ta/volatility.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
@staticmethod
def ulcer_index(
    close: str | pl.Expr, window: int = 14, fillna: bool = False
) -> pl.Expr:
    """Ulcer Index (UI) - fully vectorized!"""
    close = as_expr(close)

    ui_max = close.rolling_max(window_size=window, min_samples=1)
    r_i = 100 * (close - ui_max) / ui_max

    # Instead of a slow python function applied across a rolling window,
    # we can mathematically vectorize the root-mean-square
    r_i_squared = r_i.pow(2)
    ulcer_idx = (
        r_i_squared.rolling_sum(window_size=window, min_samples=1) / window
    ).sqrt()

    return BaseIndicator.check_fillna(ulcer_idx, fillna, value=0)

true_range staticmethod

true_range(high: str | Expr, low: str | Expr, close: str | Expr, fillna: bool = False) -> Expr

True Range — the per-bar range ATR averages.

max(high - low, |high - prev_close|, |low - prev_close|). Unlike a plain high-low range it accounts for gaps, since a bar that opens far from the previous close really did travel that distance.

Source code in polars_ta/volatility.py
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
@staticmethod
def true_range(
    high: str | pl.Expr,
    low: str | pl.Expr,
    close: str | pl.Expr,
    fillna: bool = False,
) -> pl.Expr:
    """True Range — the per-bar range ATR averages.

    `max(high - low, |high - prev_close|, |low - prev_close|)`. Unlike a
    plain high-low range it accounts for gaps, since a bar that opens far
    from the previous close really did travel that distance.
    """
    high = as_expr(high)
    low = as_expr(low)
    close = as_expr(close)
    tr = BaseIndicator.true_range(high, low, close.shift(1))
    return BaseIndicator.check_fillna(tr, fillna, value=0)

normalized_average_true_range staticmethod

normalized_average_true_range(high: str | Expr, low: str | Expr, close: str | Expr, window: int = 14, fillna: bool = False) -> Expr

Normalized Average True Range (NATR) — ATR as a percent of price.

Raw ATR is in price units, so it is not comparable across instruments or across periods where the price level moved a lot: a $2 ATR means something very different at $20 than at $2,000. Dividing by close fixes that, which matters most on the multi-asset frames this library targets — NATR is what you want when ranking or thresholding volatility across symbols.

Source code in polars_ta/volatility.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
@staticmethod
def normalized_average_true_range(
    high: str | pl.Expr,
    low: str | pl.Expr,
    close: str | pl.Expr,
    window: int = 14,
    fillna: bool = False,
) -> pl.Expr:
    """Normalized Average True Range (NATR) — ATR as a percent of price.

    Raw ATR is in price units, so it is not comparable across instruments
    or across periods where the price level moved a lot: a \\$2 ATR means
    something very different at \\$20 than at \\$2,000. Dividing by close
    fixes that, which matters most on the multi-asset frames this library
    targets — NATR is what you want when ranking or thresholding
    volatility *across* symbols.
    """
    high = as_expr(high)
    low = as_expr(low)
    close = as_expr(close)

    atr = VolatilityIndicators.average_true_range(high, low, close, window, fillna)
    # A non-positive close makes the percentage meaningless; null it rather
    # than emitting a nonsense ratio.
    safe_close = pl.when(close > 0).then(close).otherwise(None)
    return BaseIndicator.check_fillna(100.0 * atr / safe_close, fillna, value=0)