Current section
Files
Jump to
Current section
Files
lib/ccxt/trading/options.ex
defmodule CCXT.Trading.Options do
@moduledoc """
Options analytics and aggregation functions.
Pure functions for analyzing option chains, calculating open interest
distributions, and identifying key market levels. Works with
`CCXT.Types.Option` and `CCXT.Types.OptionChain` types.
All functions that need strike/expiry data parse it from symbols using
`CCXT.Trading.Options.Deribit.parse_option/1`.
## Example
chain = %{
"BTC-31JAN26-84000-C" => %CCXT.Types.Option{open_interest: 100.0, ...},
"BTC-31JAN26-84000-P" => %CCXT.Types.Option{open_interest: 50.0, ...}
}
CCXT.Trading.Options.oi_by_strike(chain)
# => %{84000.0 => 150.0}
CCXT.Trading.Options.put_call_ratio(chain)
# => 0.5
"""
alias CCXT.Trading.Options.Deribit
alias CCXT.Types.Option
@type option_chain :: %{optional(String.t()) => Option.t()}
@type strike :: float()
@type expiry :: Date.t()
# Session phases based on Deribit 08:00 UTC expiry
@final_hour_minutes 60
@last_15min_minutes 15
@doc """
Aggregate open interest by strike price.
Sums OI from both calls and puts at each strike.
## Example
CCXT.Trading.Options.oi_by_strike(chain)
# => %{84000.0 => 150.0, 90000.0 => 200.0}
"""
@spec oi_by_strike(option_chain()) :: %{strike() => float()}
def oi_by_strike(chain) when is_map(chain) do
Enum.reduce(chain, %{}, fn {symbol, option}, acc ->
accumulate_oi_by_strike(acc, symbol, option)
end)
end
@doc false
# Accumulates OI by strike, skipping invalid symbols or nil OI
defp accumulate_oi_by_strike(acc, symbol, option) do
with {:ok, %{strike: strike}} <- Deribit.parse_option(symbol),
oi when is_number(oi) <- option.open_interest do
Map.update(acc, strike, oi, &(&1 + oi))
else
_ -> acc
end
end
@doc """
Aggregate open interest by expiry date.
Sums OI from all options at each expiry.
## Example
CCXT.Trading.Options.oi_by_expiry(chain)
# => %{~D[2026-01-31] => 350.0, ~D[2026-02-28] => 200.0}
"""
@spec oi_by_expiry(option_chain()) :: %{expiry() => float()}
def oi_by_expiry(chain) when is_map(chain) do
Enum.reduce(chain, %{}, fn {symbol, option}, acc ->
accumulate_oi_by_expiry(acc, symbol, option)
end)
end
@doc false
# Accumulates OI by expiry, skipping invalid symbols or nil OI
defp accumulate_oi_by_expiry(acc, symbol, option) do
with {:ok, %{expiry: expiry}} <- Deribit.parse_option(symbol),
oi when is_number(oi) <- option.open_interest do
Map.update(acc, expiry, oi, &(&1 + oi))
else
_ -> acc
end
end
@doc """
Calculate time remaining until expiry.
Returns a map with hours and minutes remaining.
## Parameters
* `expiry` - Expiry date or DateTime (assumes 08:00 UTC for Date)
## Example
CCXT.Trading.Options.time_to_expiry(~D[2026-01-31])
# => %{hours: 48.5, minutes: 2910.0}
"""
@spec time_to_expiry(Date.t() | DateTime.t()) :: %{hours: float(), minutes: float()}
def time_to_expiry(%Date{} = expiry) do
# Deribit options expire at 08:00 UTC
{:ok, expiry_dt} = DateTime.new(expiry, ~T[08:00:00], "Etc/UTC")
time_to_expiry(expiry_dt)
end
def time_to_expiry(%DateTime{} = expiry_dt) do
now = DateTime.utc_now()
diff_seconds = DateTime.diff(expiry_dt, now, :second)
diff_minutes = diff_seconds / 60.0
diff_hours = diff_minutes / 60.0
%{
hours: max(0.0, diff_hours),
minutes: max(0.0, diff_minutes)
}
end
@doc """
Filter option chain by maximum days to expiry (DTE).
Returns only options expiring within the specified number of days.
Excludes options that have already expired.
## Parameters
* `chain` - Option chain map
* `max_dte` - Maximum days to expiry
* `now_dt` - Optional current time (for testing)
## Example
CCXT.Trading.Options.filter_by_dte(chain, 7)
# => filtered chain with only options expiring within 7 days
"""
@spec filter_by_dte(option_chain(), pos_integer()) :: option_chain()
@spec filter_by_dte(option_chain(), pos_integer(), DateTime.t()) :: option_chain()
def filter_by_dte(chain, max_dte, now_dt \\ DateTime.utc_now())
def filter_by_dte(chain, max_dte, %DateTime{} = now_dt) when is_map(chain) and is_integer(max_dte) and max_dte > 0 do
today = DateTime.to_date(now_dt)
max_expiry = Date.add(today, max_dte)
{:ok, max_expiry_dt} = DateTime.new(max_expiry, ~T[08:00:00], "Etc/UTC")
chain
|> Enum.filter(fn {symbol, _option} ->
case Deribit.parse_option(symbol) do
{:ok, %{expiry: expiry}} ->
{:ok, expiry_dt} = DateTime.new(expiry, ~T[08:00:00], "Etc/UTC")
DateTime.compare(expiry_dt, now_dt) in [:gt, :eq] and
DateTime.compare(expiry_dt, max_expiry_dt) in [:lt, :eq]
{:error, _} ->
false
end
end)
|> Map.new()
end
@doc """
Calculate max pain strike - where most options expire worthless.
Max pain is the strike price at which option buyers would lose
the most money (and sellers gain the most). The algorithm tests
each strike as a potential settlement price and finds the one
that minimizes total intrinsic value (maximum pain for buyers).
## Parameters
* `chain` - Option chain map
## Example
CCXT.Trading.Options.max_pain(chain)
# => {:ok, 84000.0}
"""
@spec max_pain(option_chain()) :: {:ok, strike()} | {:error, :empty_chain}
def max_pain(chain) when map_size(chain) == 0, do: {:error, :empty_chain}
def max_pain(chain) when is_map(chain) do
by_strike = group_by_strike(chain)
strikes = by_strike |> Map.keys() |> Enum.sort()
if Enum.empty?(strikes) do
{:error, :empty_chain}
else
min_pain_strike = find_min_pain_strike(strikes, by_strike)
{:ok, min_pain_strike}
end
end
@doc false
defp find_min_pain_strike(strikes, by_strike) do
{min_pain_strike, _min_pain} =
strikes
|> Enum.map(fn settlement_price ->
pain = calculate_total_pain(by_strike, settlement_price)
{settlement_price, pain}
end)
|> Enum.min_by(fn {_strike, pain} -> pain end)
min_pain_strike
end
@doc """
Calculate put/call ratio from open interest.
## Example
CCXT.Trading.Options.put_call_ratio(chain)
# => 0.8 (more calls than puts)
"""
@spec put_call_ratio(option_chain()) :: float() | nil
def put_call_ratio(chain) when is_map(chain) do
{puts, calls} =
Enum.reduce(chain, {0.0, 0.0}, fn {symbol, option}, acc ->
accumulate_put_call_oi(acc, symbol, option)
end)
if calls > 0 do
puts / calls
end
end
@doc false
# Accumulates OI into put/call totals, skipping invalid symbols or nil OI
defp accumulate_put_call_oi({put_oi, call_oi}, symbol, option) do
with {:ok, %{type: type}} <- Deribit.parse_option(symbol),
oi when is_number(oi) <- option.open_interest do
case type do
:put -> {put_oi + oi, call_oi}
:call -> {put_oi, call_oi + oi}
end
else
_ -> {put_oi, call_oi}
end
end
@doc """
Determine trading session phase based on time to expiry.
Phases for expiry day:
* `:early` - More than 1 hour to expiry
* `:final_hour` - 15-60 minutes to expiry
* `:last_15min` - Less than 15 minutes to expiry
* `:expired` - Past expiry time
For non-expiry days, returns `:early`.
## Example
CCXT.Trading.Options.session_phase(~D[2026-01-31])
# => :early
"""
@spec session_phase(Date.t() | DateTime.t()) :: :early | :final_hour | :last_15min | :expired
def session_phase(%Date{} = expiry) do
{:ok, expiry_dt} = DateTime.new(expiry, ~T[08:00:00], "Etc/UTC")
session_phase(expiry_dt)
end
def session_phase(%DateTime{} = expiry_dt) do
now = DateTime.utc_now()
diff_minutes = DateTime.diff(expiry_dt, now, :second) / 60.0
cond do
diff_minutes < 0 -> :expired
diff_minutes < @last_15min_minutes -> :last_15min
diff_minutes < @final_hour_minutes -> :final_hour
true -> :early
end
end
@doc """
Get the N largest positions by open interest.
## Example
CCXT.Trading.Options.largest_positions(chain, 5)
# => [{"BTC-31JAN26-84000-C", %Option{...}}, ...]
"""
@spec largest_positions(option_chain(), pos_integer()) :: [{String.t(), Option.t()}]
def largest_positions(chain, n) when is_map(chain) and is_integer(n) and n > 0 do
chain
|> Enum.sort_by(fn {_symbol, option} -> option.open_interest end, :desc)
|> Enum.take(n)
end
@doc """
Calculate distance from spot to strike as percentage.
Positive = OTM for calls, ITM for puts
Negative = ITM for calls, OTM for puts
## Example
CCXT.Trading.Options.strike_distance(90000.0, 85000.0)
# => 5.88 (strike is 5.88% above spot)
"""
@spec strike_distance(strike(), number()) :: float()
def strike_distance(strike, spot) when is_number(strike) and is_number(spot) and spot > 0 do
(strike - spot) / spot * 100.0
end
@doc """
Check if a strike is "in play" (within threshold of spot).
## Example
CCXT.Trading.Options.in_play?(84000.0, 85000.0, 5.0)
# => true (within 5% of spot)
"""
@spec in_play?(strike(), number(), number()) :: boolean()
def in_play?(strike, spot, threshold_pct) when is_number(strike) and is_number(spot) and is_number(threshold_pct) do
abs(strike_distance(strike, spot)) <= threshold_pct
end
@doc """
Calculate gamma exposure (GEX) by strike.
GEX indicates dealer hedging pressure. Positive GEX means dealers
are long gamma (will buy dips, sell rips). Negative GEX means
dealers are short gamma (will amplify moves).
## Parameters
* `chain` - Option chain with greeks (needs gamma field in raw data)
* `spot` - Current spot price
## Example
CCXT.Trading.Options.gex_by_strike(chain, 85000.0)
# => %{84000.0 => 1500000.0, 90000.0 => -500000.0}
"""
@spec gex_by_strike(option_chain(), number()) :: %{strike() => float()}
def gex_by_strike(chain, spot) when is_map(chain) and is_number(spot) do
# GEX = Gamma * OI * Spot^2 * Contract_Multiplier * 0.01
# For calls: dealers are short, so positive gamma = positive GEX
# For puts: dealers are long, so positive gamma = negative GEX
Enum.reduce(chain, %{}, fn {symbol, option}, acc ->
accumulate_gex(acc, symbol, option, spot)
end)
end
@doc false
defp accumulate_gex(acc, symbol, option, spot) do
contract_multiplier = 1.0
with {:ok, parsed} <- Deribit.parse_option(symbol),
gamma when is_number(gamma) <- get_gamma(option) do
# Assumes dealer is short calls, long puts (standard market making)
sign = type_sign(parsed.type)
gex = sign * gamma * option.open_interest * spot * spot * contract_multiplier * 0.01
Map.update(acc, parsed.strike, gex, &(&1 + gex))
else
_ -> acc
end
end
@doc false
defp type_sign(:call), do: 1.0
defp type_sign(:put), do: -1.0
@doc """
Find gamma flip level - where GEX crosses zero.
Above the gamma flip, dealers are long gamma (stabilizing).
Below the gamma flip, dealers are short gamma (destabilizing).
## Example
CCXT.Trading.Options.gamma_flip(chain, 85000.0)
# => {:ok, 84500.0}
"""
@spec gamma_flip(option_chain(), number()) :: {:ok, strike()} | {:error, :no_flip_found}
def gamma_flip(chain, spot) when is_map(chain) and is_number(spot) do
gex = gex_by_strike(chain, spot)
if map_size(gex) < 2 do
{:error, :no_flip_found}
else
strikes = gex |> Map.keys() |> Enum.sort()
# Find where GEX changes sign
flip =
strikes
|> Enum.chunk_every(2, 1, :discard)
|> Enum.find(fn [s1, s2] ->
g1 = Map.get(gex, s1, 0)
g2 = Map.get(gex, s2, 0)
g1 * g2 < 0
end)
case flip do
[s1, s2] ->
# Linear interpolation to find zero crossing
g1 = Map.get(gex, s1)
g2 = Map.get(gex, s2)
flip_strike = s1 + (s2 - s1) * abs(g1) / (abs(g1) + abs(g2))
{:ok, flip_strike}
nil ->
{:error, :no_flip_found}
end
end
end
@doc """
Find high-gamma strikes near spot (pin magnets).
These are strikes where dealers have significant gamma exposure,
creating "magnetic" effects that can pin price.
## Parameters
* `chain` - Option chain
* `spot` - Current spot price
* `opts` - Options:
* `:threshold_pct` - Max distance from spot (default: 5.0)
* `:min_oi` - Minimum OI to consider (default: 0)
## Example
CCXT.Trading.Options.pin_magnets(chain, 85000.0)
# => [{84000.0, 150.0}, {86000.0, 120.0}]
"""
@spec pin_magnets(option_chain(), number(), keyword()) :: [{strike(), float()}]
def pin_magnets(chain, spot, opts \\ []) when is_map(chain) and is_number(spot) do
threshold_pct = Keyword.get(opts, :threshold_pct, 5.0)
min_oi = Keyword.get(opts, :min_oi, 0)
oi = oi_by_strike(chain)
oi
|> Enum.filter(fn {strike, strike_oi} ->
in_play?(strike, spot, threshold_pct) and strike_oi >= min_oi
end)
|> Enum.sort_by(fn {_strike, strike_oi} -> strike_oi end, :desc)
end
@doc """
Detect hot zone / pin risk at expiry.
When price is near a high-OI strike on expiry day, there's
significant pin risk as dealers hedge.
## Parameters
* `chain` - Option chain
* `spot` - Current spot price
* `opts` - Options:
* `:expiry` - Expiry date to check (default: today)
* `:threshold_pct` - Max distance for "hot" (default: 1.0)
## Example
CCXT.Trading.Options.hot_zone(chain, 85000.0)
# => {:hot, 84000.0, 150.0} or :clear
"""
@spec hot_zone(option_chain(), number(), keyword()) ::
{:hot, strike(), float()} | :clear
def hot_zone(chain, spot, opts \\ []) when is_map(chain) and is_number(spot) do
expiry = Keyword.get(opts, :expiry, Date.utc_today())
threshold_pct = Keyword.get(opts, :threshold_pct, 1.0)
# Filter to expiry date
expiry_chain = filter_by_expiry(chain, expiry)
if map_size(expiry_chain) == 0 do
:clear
else
oi = oi_by_strike(expiry_chain)
# Find highest OI strike in play
hot_strike =
oi
|> Enum.filter(fn {strike, _} -> in_play?(strike, spot, threshold_pct) end)
|> Enum.max_by(fn {_, strike_oi} -> strike_oi end, fn -> nil end)
case hot_strike do
{strike, strike_oi} when strike_oi > 0 -> {:hot, strike, strike_oi}
_ -> :clear
end
end
end
@doc """
Sum Greeks across all positions (alias for portfolio aggregation).
Returns aggregate delta, gamma, theta, vega from the chain.
## Example
CCXT.Trading.Options.greeks_sum(chain)
# => %{delta: 0.5, gamma: 0.01, theta: -100.0, vega: 500.0}
"""
@spec greeks_sum(option_chain()) :: %{delta: float(), gamma: float(), theta: float(), vega: float()}
def greeks_sum(chain) when is_map(chain) do
chain
|> Map.values()
|> Enum.reduce(%{delta: 0.0, gamma: 0.0, theta: 0.0, vega: 0.0}, fn option, acc ->
%{
delta: acc.delta + get_greek(option, :delta),
gamma: acc.gamma + get_greek(option, :gamma),
theta: acc.theta + get_greek(option, :theta),
vega: acc.vega + get_greek(option, :vega)
}
end)
end
# Helper: Group options by strike with their parsed data
@doc false
defp group_by_strike(chain) do
Enum.reduce(chain, %{}, fn {symbol, option}, acc ->
case Deribit.parse_option(symbol) do
{:ok, parsed} ->
entry = %{option: option, type: parsed.type, strike: parsed.strike}
Map.update(acc, parsed.strike, [entry], &[entry | &1])
{:error, _} ->
acc
end
end)
end
# Helper: Calculate total pain for a given settlement price
@doc false
defp calculate_total_pain(by_strike, settlement_price) do
Enum.reduce(by_strike, 0.0, fn {strike, options}, total ->
total + calculate_strike_pain(options, strike, settlement_price)
end)
end
@doc false
defp calculate_strike_pain(options, strike, settlement_price) do
Enum.reduce(options, 0.0, fn %{option: opt, type: type}, acc ->
intrinsic = option_intrinsic(type, strike, settlement_price)
acc + intrinsic * opt.open_interest
end)
end
@doc false
defp option_intrinsic(:call, strike, settlement), do: max(0, settlement - strike)
defp option_intrinsic(:put, strike, settlement), do: max(0, strike - settlement)
# Helper: Filter chain to specific expiry
@doc false
defp filter_by_expiry(chain, expiry) do
chain
|> Enum.filter(fn {symbol, _option} ->
case Deribit.parse_option(symbol) do
{:ok, %{expiry: ^expiry}} -> true
_ -> false
end
end)
|> Map.new()
end
# Helper: Extract gamma from option (may be in raw data)
@doc false
defp get_gamma(%Option{} = option) do
case option.raw do
%{"gamma" => gamma} when is_number(gamma) -> gamma
%{gamma: gamma} when is_number(gamma) -> gamma
_ -> nil
end
end
# Helper: Extract a greek value from option
@doc false
defp get_greek(%Option{} = option, greek) do
raw = option.raw
cond do
is_map(raw) and is_number(raw[greek]) -> raw[greek]
is_map(raw) and is_number(raw[Atom.to_string(greek)]) -> raw[Atom.to_string(greek)]
true -> 0.0
end
end
end