Current section
Files
Jump to
Current section
Files
lib/ccxt/trading/volatility.ex
defmodule CCXT.Trading.Volatility do
@moduledoc """
Volatility calculations for trading systems.
Pure functions for calculating historical (realized) volatility,
comparing implied vs realized volatility, and computing volatility metrics.
## Example
prices = [100, 102, 99, 103, 101, 105, 102]
CCXT.Trading.Volatility.realized(prices)
# => 0.023 (2.3% daily volatility)
CCXT.Trading.Volatility.realized(prices, annualize: true)
# => 0.365 (36.5% annualized)
"""
@trading_days_per_year 365
@typedoc "Volatility as decimal (e.g., 0.25 = 25%)"
@type volatility :: float()
@typedoc "IV comparison output format"
@type iv_format :: :ratio | :premium | :premium_pct
@typedoc "OHLC candle for volatility estimation"
@type candle :: %{
optional(:open) => number(),
:high => number(),
:low => number(),
optional(:close) => number()
}
@doc """
Calculate realized (historical) volatility from prices.
Uses close-to-close returns to estimate volatility.
## Parameters
* `prices` - List of prices (chronological order, oldest first)
* `opts` - Options:
* `:annualize` - Whether to annualize (default: false)
* `:trading_days` - Trading days per year for annualization (default: 365)
## Returns
Standard deviation of returns, or `nil` if insufficient data.
## Example
prices = [100, 102, 99, 103, 101]
CCXT.Trading.Volatility.realized(prices)
# => 0.023 (daily)
CCXT.Trading.Volatility.realized(prices, annualize: true)
# => 0.44 (annualized)
"""
@spec realized([number()], keyword()) :: volatility() | nil | {:error, :invalid_prices}
def realized(prices, opts \\ []) when is_list(prices) do
cond do
length(prices) < 3 -> nil
Enum.any?(prices, &(&1 <= 0)) -> {:error, :invalid_prices}
true -> calculate_realized(prices, opts)
end
end
@doc false
# Computes standard deviation of log returns, optionally annualized
defp calculate_realized(prices, opts) do
returns = calculate_returns(prices)
std_dev = standard_deviation(returns)
if Keyword.get(opts, :annualize, false) do
trading_days = Keyword.get(opts, :trading_days, @trading_days_per_year)
std_dev * :math.sqrt(trading_days)
else
std_dev
end
end
@doc false
# Converts price series to log returns
defp calculate_returns(prices) do
prices
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [prev, curr] -> :math.log(curr / prev) end)
end
@doc """
Calculate Parkinson volatility estimator.
Uses high-low range, which is more efficient than close-to-close
when intraday data is available.
## Parameters
* `candles` - List of candles with `:high` and `:low` fields
* `opts` - Options:
* `:annualize` - Whether to annualize (default: false)
* `:trading_days` - Trading days per year (default: 365)
## Returns
Parkinson volatility estimate, or `nil` if insufficient data.
## Example
candles = [
%{high: 105, low: 98},
%{high: 107, low: 100},
%{high: 103, low: 96}
]
CCXT.Trading.Volatility.parkinson(candles)
# => 0.034
"""
@spec parkinson([candle()], keyword()) :: volatility() | nil | {:error, :invalid_prices}
def parkinson(candles, opts \\ []) when is_list(candles) do
cond do
length(candles) < 2 -> nil
has_invalid_high_low?(candles) -> {:error, :invalid_prices}
true -> calculate_parkinson(candles, opts)
end
end
@doc false
# Checks if any candle has zero or negative high/low values
defp has_invalid_high_low?(candles) do
Enum.any?(candles, fn candle ->
high = candle[:high] || candle["high"]
low = candle[:low] || candle["low"]
(is_number(high) and high <= 0) or (is_number(low) and low <= 0)
end)
end
@doc false
# Computes Parkinson estimator from high-low ranges
defp calculate_parkinson(candles, opts) do
n = length(candles)
sum_squared =
candles
|> Enum.map(fn candle ->
high = candle[:high] || candle["high"]
low = candle[:low] || candle["low"]
:math.pow(:math.log(high / low), 2)
end)
|> Enum.sum()
# Parkinson constant: 1 / (4 * ln(2))
parkinson_constant = 1 / (4 * :math.log(2))
variance = parkinson_constant * sum_squared / n
std_dev = :math.sqrt(variance)
if Keyword.get(opts, :annualize, false) do
trading_days = Keyword.get(opts, :trading_days, @trading_days_per_year)
std_dev * :math.sqrt(trading_days)
else
std_dev
end
end
@doc """
Calculate IV percentile relative to historical distribution.
Shows where current IV ranks compared to past IV values.
## Parameters
* `current_iv` - Current implied volatility
* `historical_ivs` - List of historical IV values
## Returns
Percentile (0-100) showing % of historical values below current,
or `nil` if insufficient data.
## Example
CCXT.Trading.Volatility.iv_percentile(0.65, [0.40, 0.55, 0.60, 0.70, 0.80])
# => 60.0 (current IV is higher than 60% of historical)
"""
@spec iv_percentile(number(), [number()]) :: float() | nil
def iv_percentile(current_iv, historical_ivs) when is_number(current_iv) and is_list(historical_ivs) do
if Enum.empty?(historical_ivs) do
nil
else
count_below = Enum.count(historical_ivs, &(&1 < current_iv))
count_below / length(historical_ivs) * 100
end
end
@doc """
Calculate IV rank (normalized position in range).
Shows where current IV sits relative to the historical range.
## Parameters
* `current_iv` - Current implied volatility
* `historical_ivs` - List of historical IV values
## Returns
Rank (0-100) showing position in min-max range,
or `nil` if insufficient data.
## Example
# Current 65%, range was 40-80%
CCXT.Trading.Volatility.iv_rank(0.65, [0.40, 0.55, 0.60, 0.70, 0.80])
# => 62.5 (65% is 62.5% of the way from 40% to 80%)
"""
@spec iv_rank(number(), [number()]) :: float() | nil
def iv_rank(current_iv, historical_ivs) when is_number(current_iv) and is_list(historical_ivs) do
if length(historical_ivs) < 2 do
nil
else
min_iv = Enum.min(historical_ivs)
max_iv = Enum.max(historical_ivs)
range = max_iv - min_iv
if range == 0 do
50.0
else
(current_iv - min_iv) / range * 100
end
end
end
@doc """
Compare implied volatility to realized volatility.
## Parameters
* `implied_vol` - Current implied volatility
* `realized_vol` - Realized volatility over same period
* `format` - Output format (default: `:ratio`)
## Returns
Based on format:
* `:ratio` - IV / RV ratio (>1 means IV premium)
* `:premium` - IV - RV (positive means IV premium)
* `:premium_pct` - (IV - RV) / RV * 100
## Example
CCXT.Trading.Volatility.iv_vs_rv(0.65, 0.50)
# => 1.3 (IV is 30% higher than RV)
CCXT.Trading.Volatility.iv_vs_rv(0.65, 0.50, :premium)
# => 0.15
CCXT.Trading.Volatility.iv_vs_rv(0.65, 0.50, :premium_pct)
# => 30.0
"""
@spec iv_vs_rv(volatility(), volatility(), iv_format()) :: float() | nil
def iv_vs_rv(implied_vol, realized_vol, format \\ :ratio) when is_number(implied_vol) and is_number(realized_vol) do
if realized_vol == 0 do
nil
else
calculate_iv_vs_rv(implied_vol, realized_vol, format)
end
end
@doc false
# Computes IV vs RV comparison in specified format (ratio, premium, or premium_pct)
defp calculate_iv_vs_rv(implied_vol, realized_vol, :ratio) do
implied_vol / realized_vol
end
defp calculate_iv_vs_rv(implied_vol, realized_vol, :premium) do
implied_vol - realized_vol
end
defp calculate_iv_vs_rv(implied_vol, realized_vol, :premium_pct) do
(implied_vol - realized_vol) / realized_vol * 100
end
@doc """
Calculate volatility cone data for term structure analysis.
Returns percentile bands at different lookback periods.
## Parameters
* `prices` - List of prices (chronological order)
* `periods` - List of lookback periods to calculate
## Returns
Map of period => volatility value (or `nil` if insufficient data).
## Example
CCXT.Trading.Volatility.cone(prices, [5, 10, 20, 30])
# => %{5 => 0.02, 10 => 0.025, 20 => 0.028, 30 => 0.03}
"""
@spec cone([number()], [pos_integer()]) :: %{pos_integer() => volatility() | nil}
def cone(prices, periods) when is_list(prices) and is_list(periods) do
price_count = length(prices)
periods
|> Enum.filter(&(&1 <= price_count))
|> Map.new(fn period ->
recent_prices = Enum.take(prices, -period)
{period, result_to_value(realized(recent_prices))}
end)
end
@doc """
Check if volatility regime is elevated.
## Parameters
* `current_vol` - Current volatility
* `baseline_vol` - Normal/baseline volatility
* `threshold` - Multiple of baseline considered elevated (default: 1.5)
## Example
CCXT.Trading.Volatility.elevated?(0.45, 0.30)
# => true (0.45 > 0.30 * 1.5)
"""
@spec elevated?(number(), number(), number()) :: boolean()
def elevated?(current_vol, baseline_vol, threshold \\ 1.5)
when is_number(current_vol) and is_number(baseline_vol) and is_number(threshold) do
current_vol > baseline_vol * threshold
end
@doc """
Calculate Garman-Klass volatility estimator.
Uses OHLC data for more efficient estimation than close-to-close.
## Parameters
* `candles` - List of candles with `:open`, `:high`, `:low`, `:close` fields
* `opts` - Options:
* `:annualize` - Whether to annualize (default: false)
## Returns
Garman-Klass volatility, or `nil` if insufficient data.
"""
@spec garman_klass([candle()], keyword()) :: volatility() | nil | {:error, :invalid_prices}
def garman_klass(candles, opts \\ []) when is_list(candles) do
cond do
length(candles) < 2 -> nil
has_invalid_ohlc?(candles) -> {:error, :invalid_prices}
true -> calculate_garman_klass(candles, opts)
end
end
@doc false
# Checks if any candle has zero or negative OHLC values
defp has_invalid_ohlc?(candles) do
Enum.any?(candles, &candle_has_invalid_ohlc?/1)
end
@doc false
# Checks a single candle for invalid OHLC values
defp candle_has_invalid_ohlc?(candle) do
Enum.any?(
[
candle[:open] || candle["open"],
candle[:high] || candle["high"],
candle[:low] || candle["low"],
candle[:close] || candle["close"]
],
&invalid_price?/1
)
end
@doc false
# Returns true if the value is a number <= 0
defp invalid_price?(value) when is_number(value), do: value <= 0
defp invalid_price?(_), do: false
@doc false
# Computes Garman-Klass estimator using OHLC data
defp calculate_garman_klass(candles, opts) do
n = length(candles)
sum =
candles
|> Enum.map(&gk_single_candle/1)
|> Enum.sum()
variance = sum / n
std_dev = :math.sqrt(variance)
if Keyword.get(opts, :annualize, false) do
trading_days = Keyword.get(opts, :trading_days, @trading_days_per_year)
std_dev * :math.sqrt(trading_days)
else
std_dev
end
end
@doc false
# Applies Garman-Klass formula to a single OHLC candle
defp gk_single_candle(candle) do
open = candle[:open] || candle["open"]
high = candle[:high] || candle["high"]
low = candle[:low] || candle["low"]
close = candle[:close] || candle["close"]
log_hl = :math.log(high / low)
log_co = :math.log(close / open)
# Garman-Klass formula
0.5 * :math.pow(log_hl, 2) - (2 * :math.log(2) - 1) * :math.pow(log_co, 2)
end
@doc """
Calculate rolling volatility over a price series.
Returns a list of volatility values calculated over a rolling window.
Useful for analyzing volatility trends and regime changes.
## Parameters
* `prices` - List of prices (chronological order)
* `window` - Rolling window size (number of periods)
* `opts` - Options:
* `:annualize` - Whether to annualize (default: false)
* `:trading_days` - Trading days per year (default: 365)
## Returns
List of volatility values. Length will be `length(prices) - window + 1`.
Returns empty list if insufficient data.
## Example
prices = [100, 102, 99, 103, 101, 105, 102, 108, 104, 110]
CCXT.Trading.Volatility.rolling(prices, 5)
# => [0.023, 0.025, 0.028, 0.026, 0.030, 0.027]
"""
@spec rolling([number()], pos_integer(), keyword()) :: [volatility()]
def rolling(prices, window, opts \\ []) when is_list(prices) and is_integer(window) and window > 2 do
if length(prices) < window do
[]
else
calculate_rolling(prices, window, opts)
end
end
@doc false
# Calculates rolling volatility using sliding window
defp calculate_rolling(prices, window, opts) do
prices
|> Enum.chunk_every(window, 1, :discard)
|> Enum.map(&realized(&1, opts))
|> Enum.map(&result_to_value/1)
|> Enum.reject(&is_nil/1)
end
@doc false
# Converts error tuples to nil for aggregate functions
defp result_to_value({:error, _}), do: nil
defp result_to_value(value), do: value
@doc false
# Calculates sample standard deviation of a series (n-1 for unbiased estimator)
defp standard_deviation(values) when length(values) < 2, do: 0.0
defp standard_deviation(values) do
n = length(values)
mean = Enum.sum(values) / n
variance =
values
|> Enum.map(&:math.pow(&1 - mean, 2))
|> Enum.sum()
|> Kernel./(n - 1)
:math.sqrt(variance)
end
end