Options analytics and aggregation functions.
Pure functions for analyzing option chains, calculating open interest
distributions, and identifying key market levels. Works with plain maps
containing option data (e.g., :open_interest, :raw with greeks).
All functions that need strike/expiry data parse it from symbols using
ZenQuant.Options.Deribit.parse_option/1.
Example
chain = %{
"BTC-31JAN26-84000-C" => %{open_interest: 100.0, raw: %{"gamma" => 0.00001}},
"BTC-31JAN26-84000-P" => %{open_interest: 50.0, raw: %{"gamma" => 0.00001}}
}
ZenQuant.Options.oi_by_strike(chain)
# => %{84000.0 => 150.0}
ZenQuant.Options.put_call_ratio(chain)
# => {:ok, 0.5}API Functions
| Function | Arity | Description | Param Kinds |
|---|---|---|---|
breakeven_move | 2 | Calculate percentage move to breakeven for an option. | option: value, spot: value |
aggregate_oi | 1 | Aggregate open interest across multiple option chains. | chains: exchange_data |
pin_risk | 3 | Assess pin risk for strikes near spot price. | chain: exchange_data, spot: value |
moneyness_skew | 3 | Build per-expiry moneyness skew observations from an option chain. | chain: exchange_data, spot: value |
atm_iv_term_structure | 3 | Build per-expiry ATM IV observations from an option chain. | chain: exchange_data, spot: value |
atm_iv | 2 | Extract ATM implied volatility from an option chain. | chain: exchange_data, spot: value |
expected_range | 3 | Calculate expected 1-sigma price range for given IV and time horizon. | spot: value, iv: value, hours_to_expiry: value |
theta_per_hour | 1 | Calculate hourly theta decay rate for an option. | option: value |
greeks_sum | 1 | Sum Greeks across all options in a chain. | chain: exchange_data |
hot_zone | 3 | Detect hot zone / pin risk at expiry. | chain: exchange_data, spot: value |
pin_magnets | 3 | Find high-gamma strikes near spot (pin magnets). | chain: exchange_data, spot: value |
zero_gamma | 3 | Find aggregate dealer zero-gamma spot levels under sticky-strike repricing. | chain: exchange_data, spot: value |
gamma_flip | 2 | Find the first per-strike GEX sign change scanning strikes upward. | chain: exchange_data, spot: value |
gex_by_strike | 2 | Calculate gamma exposure (GEX) by strike. | chain: exchange_data, spot: value |
in_play? | 3 | Check if a strike is within threshold of spot price. | strike: value, spot: value, threshold_pct: value |
strike_distance | 2 | Calculate percentage distance from spot to strike. | strike: value, spot: value |
largest_positions | 2 | Get the N largest positions by open interest. | chain: exchange_data, n: value |
session_phase | 1 | Determine trading session phase based on time to expiry. | expiry: value |
put_call_ratio | 1 | Calculate put/call ratio from open interest. | chain: exchange_data |
max_pain | 1 | Calculate max pain strike where most options expire worthless. | chain: exchange_data |
filter_by_dte | 3 | Filter option chain by maximum days to expiry. | chain: exchange_data, max_dte: value |
time_to_expiry | 1 | Calculate time remaining until expiry. | expiry: value |
oi_by_expiry | 1 | Aggregate open interest by expiry date. | chain: exchange_data |
oi_by_strike | 1 | Aggregate open interest by strike price. | chain: exchange_data |
Summary
Types
Aggregate zero-gamma validation or solver failure.
A leg excluded from zero-gamma analysis by explicit caller policy.
Aggregate dealer zero-gamma result under sticky-strike repricing.
Functions
Aggregate open interest across multiple option chains.
Extract ATM implied volatility from an option chain.
Per-expiry ATM implied volatility term structure from a chain.
Calculate percentage move to breakeven for an option.
Calculate expected 1-sigma price range for given IV and time horizon.
Filter option chain by maximum days to expiry.
Returns the first per-strike GEX sign change while scanning strikes upward.
Calculate gamma exposure (GEX) by strike.
Sum Greeks across all options in a chain.
Detect hot zone / pin risk at expiry.
Check if a strike is within threshold of spot price.
Get the N largest positions by open interest.
Calculate max pain strike where most options expire worthless.
Per-expiry moneyness skew from a chain at caller-supplied moneyness levels.
Aggregate open interest by expiry date.
Aggregate open interest by strike price.
Find high-gamma strikes near spot (pin magnets).
Assess pin risk for strikes near spot price.
Calculate put/call ratio from open interest.
Determine trading session phase based on time to expiry.
Calculate percentage distance from spot to strike.
Calculate hourly theta decay rate for an option.
Calculate time remaining until expiry.
Solves for aggregate dealer GEX sign changes as spot varies.
Types
@type expiry() :: Date.t()
@type strike() :: float()
@type zero_gamma_error_reason() :: {:invalid_zero_gamma_input, :chain | :spot | :options} | {:invalid_zero_gamma_option, :search_range | :relative_tolerance | :min_gross_gex | :on_unpriceable} | {:unpriceable_leg, zero_gamma_omission()} | :no_gamma_signal | :no_zero_gamma_found | :no_zero_gamma_in_populated_range | {:zero_gamma_did_not_converge, map()}
Aggregate zero-gamma validation or solver failure.
@type zero_gamma_omission() :: %{ symbol: term(), reason: :missing_iv | :degenerate_input }
A leg excluded from zero-gamma analysis by explicit caller policy.
@type zero_gamma_result() :: %{ level: float(), roots: [float()], current_gex: float(), current_gross_gex: float(), gex_at_level: float(), gross_gex_at_level: float(), searched_range: {float(), float()}, populated_strike_range: {float(), float()}, relative_tolerance: float(), omitted: [zero_gamma_omission()] }
Aggregate dealer zero-gamma result under sticky-strike repricing.
Functions
@spec aggregate_oi([option_chain()]) :: %{ calls: float(), puts: float(), total: float(), ratio: float() | nil }
Aggregate open interest across multiple option chains.
Parameters
chains- List of option chain maps (exchange_data)
Returns
Map with :calls, :puts, :total, :ratio (puts/calls) (map)
Example
%{calls: 1800.0, total: 3000.0, puts: 1200.0, ratio: 0.667}# descripex:contract
%{
params: %{
chains: %{
description: "List of option chain maps",
source: "multiple Options.Deribit.chain() calls",
kind: :exchange_data
}
},
returns: %{
type: :map,
description: "Map with :calls, :puts, :total, :ratio (puts/calls)"
},
returns_example: %{calls: 1800.0, total: 3000.0, puts: 1200.0, ratio: 0.667}
}
@spec atm_iv(option_chain(), number()) :: {:ok, float()} | {:error, :no_iv_data}
Extract ATM implied volatility from an option chain.
Parameters
chain- Option chain with IV data in :raw (exchange_data)spot- Current spot price (value)
Returns
{:ok, iv_percentage} or {:error, :no_iv_data} (tuple)
Example
{:ok, 46.3}Errors
:no_iv_data
# descripex:contract
%{
params: %{
chain: %{
description: "Option chain with IV data in :raw",
source: "Options.Deribit.chain(exchange_mod, enrich: :greeks)",
kind: :exchange_data
},
spot: %{description: "Current spot price", kind: :value}
},
errors: [:no_iv_data],
returns: %{
type: :tuple,
description: "{:ok, iv_percentage} or {:error, :no_iv_data}"
},
returns_example: {:ok, 46.3}
}
@spec atm_iv_term_structure(option_chain() | term(), number() | term(), keyword()) :: {:ok, [map()]} | {:error, atom()}
Per-expiry ATM implied volatility term structure from a chain.
Groups legs by expiry, selects ATM strikes relative to spot, and returns
one observation per expiry. Observations carry the
Options.Skew.term_structure/1 contract (:expiry, :tenor_days,
:measure, :units, :value) plus :open_interest and an explicit
:status — feed the list straight into Skew.term_structure/1 without
reshaping.
ATM selection (:window_pct)
0(default) — the single strike nearestspot(ties break to the lower strike). Call and put IVs at that strike are averaged.> 0— arithmetic mean of per-strike IVs for every strike within±window_pctpercent of spot. An empty window yieldsstatus: :no_ivandvalue: nilfor that expiry (no silent nearest-strike fallback).
Missing IV
Every expiry present in the chain appears in the result. When no readable IV
is available under the selection policy, value is nil and
status: :no_iv — the expiry is never dropped and never given a fabricated
numeric IV.
:tenor_days is max(0, Date.diff(expiry, as_of_date)) from the required
caller-supplied :as_of (Date or DateTime); the function never reads
system time.
Calculate percentage move to breakeven for an option.
Parameters
option- Option map with :symbol, :mark_price, :underlying_price (value)spot- Current spot price (value)
Returns
{:ok, percentage} or {:error, :missing_data} (tuple)
Example
{:ok, 4.2}Errors
:missing_data
# descripex:contract
%{
params: %{
option: %{
description: "Option map with :symbol, :mark_price, :underlying_price",
kind: :value
},
spot: %{description: "Current spot price", kind: :value}
},
errors: [:missing_data],
returns: %{
type: :tuple,
description: "{:ok, percentage} or {:error, :missing_data}"
},
returns_example: {:ok, 4.2}
}
@spec expected_range(number(), number(), number()) :: %{ lower: float(), upper: float(), move_pct: float() }
Calculate expected 1-sigma price range for given IV and time horizon.
Parameters
spot- Current spot price (value)iv- Annualized IV as percentage (e.g., 46.3) (value)hours_to_expiry- Hours until expiry (value)
Returns
Map with :lower, :upper, :move_pct (map)
Example
%{upper: 1.0, lower: 1.0, move_pct: 1.0}# descripex:contract
%{
params: %{
spot: %{description: "Current spot price", kind: :value},
iv: %{description: "Annualized IV as percentage (e.g., 46.3)", kind: :value},
hours_to_expiry: %{description: "Hours until expiry", kind: :value}
},
returns: %{type: :map, description: "Map with :lower, :upper, :move_pct"},
returns_example: %{upper: 1.0, lower: 1.0, move_pct: 1.0}
}
@spec filter_by_dte(option_chain(), pos_integer(), DateTime.t()) :: option_chain()
Filter option chain by maximum days to expiry.
Parameters
chain- Option chain map (exchange_data)max_dte- Maximum days to expiry (value)
Options
now_dt- Current time (for testing) (default:"DateTime.utc_now()")
Returns
Filtered chain with only options within max_dte (map)
Example
%{value: 1.0}# descripex:contract
%{
opts: %{
now_dt: %{
default: "DateTime.utc_now()",
type: :datetime,
description: "Current time (for testing)"
}
},
params: %{
chain: %{
description: "Option chain map",
source: "Options.Deribit.chain(exchange_mod)",
kind: :exchange_data
},
max_dte: %{description: "Maximum days to expiry", kind: :value}
},
returns: %{
type: :map,
description: "Filtered chain with only options within max_dte"
},
returns_example: %{value: 1.0}
}
@spec gamma_flip(option_chain(), number()) :: {:ok, strike()} | {:error, :no_flip_found}
Returns the first per-strike GEX sign change while scanning strikes upward.
This is the put/call open-interest boundary evaluated at the supplied spot.
It is not the aggregate zero-gamma level; use zero_gamma/3 for that.
@spec gex_by_strike(option_chain(), number()) :: %{required(strike()) => float()}
Calculate gamma exposure (GEX) by strike.
Parameters
chain- Option chain with greeks in :raw data (exchange_data)spot- Current spot price (value)
Returns
Map of %{strike => gex_value} (map)
Example
%{value: 1.0}Composes With
gamma_flip
# descripex:contract
%{
params: %{
chain: %{
description: "Option chain with greeks in :raw data",
source: "Options.Deribit.chain(exchange_mod, enrich: :greeks)",
kind: :exchange_data
},
spot: %{description: "Current spot price", kind: :value}
},
returns: %{type: :map, description: "Map of %{strike => gex_value}"},
returns_example: %{value: 1.0},
composes_with: [:gamma_flip]
}
@spec greeks_sum(option_chain()) :: %{ delta: float(), gamma: float(), theta: float(), vega: float() }
Sum Greeks across all options in a chain.
Parameters
chain- Option chain with greeks in :raw data (exchange_data)
Returns
Map with aggregated :delta, :gamma, :theta, :vega (map)
Example
%{delta: 1.0, gamma: 1.0, vega: 1.0, theta: 1.0}# descripex:contract
%{
params: %{
chain: %{
description: "Option chain with greeks in :raw data",
source: "Options.Deribit.chain(exchange_mod, enrich: :greeks)",
kind: :exchange_data
}
},
returns: %{
type: :map,
description: "Map with aggregated :delta, :gamma, :theta, :vega"
},
returns_example: %{delta: 1.0, gamma: 1.0, vega: 1.0, theta: 1.0}
}
@spec hot_zone(option_chain(), number(), keyword()) :: {:hot, strike(), float()} | :clear
Detect hot zone / pin risk at expiry.
Parameters
chain- Option chain map (exchange_data)spot- Current spot price (value)
Options
expiry- Expiry date to check (default:"Date.utc_today()")threshold_pct- Max distance for hot zone (default:1.0)
Returns
{:hot, strike, oi} or :clear (tuple)
Example
{:hot, 84000.0, 1250.0}# descripex:contract
%{
opts: %{
expiry: %{
default: "Date.utc_today()",
type: :date,
description: "Expiry date to check"
},
threshold_pct: %{
default: 1.0,
type: :float,
description: "Max distance for hot zone"
}
},
params: %{
chain: %{
description: "Option chain map",
source: "Options.Deribit.chain(exchange_mod)",
kind: :exchange_data
},
spot: %{description: "Current spot price", kind: :value}
},
returns: %{type: :tuple, description: "{:hot, strike, oi} or :clear"},
returns_example: {:hot, 84000.0, 1250.0}
}
Check if a strike is within threshold of spot price.
Parameters
strike- Strike price (value)spot- Current spot price (value)threshold_pct- Maximum distance percentage (value)
Returns
true if abs(distance) <= threshold (boolean)
Example
true# descripex:contract
%{
params: %{
spot: %{description: "Current spot price", kind: :value},
strike: %{description: "Strike price", kind: :value},
threshold_pct: %{description: "Maximum distance percentage", kind: :value}
},
returns: %{type: :boolean, description: "true if abs(distance) <= threshold"},
returns_example: true
}
@spec largest_positions(option_chain(), pos_integer()) :: [{String.t(), map()}]
Get the N largest positions by open interest.
Parameters
chain- Option chain map (exchange_data)n- Number of positions to return (value)
Returns
List of {symbol, option_map} sorted by OI desc (list)
Example
[{"BTC-31JAN26-84000-C", %{open_interest: 1200.0}}]# descripex:contract
%{
params: %{
n: %{description: "Number of positions to return", kind: :value},
chain: %{
description: "Option chain map",
source: "Options.Deribit.chain(exchange_mod)",
kind: :exchange_data
}
},
returns: %{
type: :list,
description: "List of {symbol, option_map} sorted by OI desc"
},
returns_example: [{"BTC-31JAN26-84000-C", %{open_interest: 1200.0}}]
}
@spec max_pain(option_chain()) :: {:ok, strike()} | {:error, :empty_chain}
Calculate max pain strike where most options expire worthless.
Parameters
chain- Option chain map (exchange_data)
Returns
{:ok, strike} or {:error, :empty_chain} (tuple)
Example
{:ok, 84000.0}Errors
:empty_chain
# descripex:contract
%{
params: %{
chain: %{
description: "Option chain map",
source: "Options.Deribit.chain(exchange_mod)",
kind: :exchange_data
}
},
errors: [:empty_chain],
returns: %{
type: :tuple,
description: "{:ok, strike} or {:error, :empty_chain}"
},
returns_example: {:ok, 84000.0}
}
@spec moneyness_skew(option_chain() | term(), number() | term(), keyword()) :: {:ok, [map()]} | {:error, atom()}
Per-expiry moneyness skew from a chain at caller-supplied moneyness levels.
For each expiry, selects the strike(s) nearest spot * low and
spot * high (defaults 0.9 and 1.1), reads IV with the same policy as
atm_iv/2, and returns value = iv_low - iv_high (percentage points).
Observations match the Options.Skew.term_structure/1 contract and can be
passed through without reshaping. Missing wings are never dropped: value
is nil and :status is :no_iv (unreadable IV) or :no_strike (no
strike within selection policy / :max_distance_pct).
ATM/wing selection uses the same :window_pct rules as
atm_iv_term_structure/3. Optional :max_distance_pct rejects a nearest
strike farther than that percent of spot from the target moneyness level.
@spec oi_by_expiry(option_chain()) :: %{required(expiry()) => float()}
Aggregate open interest by expiry date.
Parameters
chain- Option chain map %{symbol => option_map} (exchange_data)
Returns
Map of %{Date => total_oi} (map)
Example
%{value: 1.0}# descripex:contract
%{
params: %{
chain: %{
description: "Option chain map %{symbol => option_map}",
source: "Options.Deribit.chain(exchange_mod)",
kind: :exchange_data
}
},
returns: %{type: :map, description: "Map of %{Date => total_oi}"},
returns_example: %{value: 1.0}
}
@spec oi_by_strike(option_chain()) :: %{required(strike()) => float()}
Aggregate open interest by strike price.
Parameters
chain- Option chain map %{symbol => option_map} (exchange_data)
Returns
Map of %{strike => total_oi} (map)
Example
%{value: 1.0}Composes With
max_pain
# descripex:contract
%{
params: %{
chain: %{
description: "Option chain map %{symbol => option_map}",
source: "Options.Deribit.chain(exchange_mod)",
kind: :exchange_data
}
},
returns: %{type: :map, description: "Map of %{strike => total_oi}"},
returns_example: %{value: 1.0},
composes_with: [:max_pain]
}
@spec pin_magnets(option_chain(), number(), keyword()) :: [{strike(), float()}]
Find high-gamma strikes near spot (pin magnets).
Parameters
chain- Option chain map (exchange_data)spot- Current spot price (value)
Options
threshold_pct- Max distance from spot (default:5.0)min_oi- Minimum OI to consider (default:0)
Returns
List of {strike, oi} sorted by OI desc (list)
Example
[{84000.0, 1250.0}]# descripex:contract
%{
opts: %{
threshold_pct: %{
default: 5.0,
type: :float,
description: "Max distance from spot"
},
min_oi: %{default: 0, type: :number, description: "Minimum OI to consider"}
},
params: %{
chain: %{
description: "Option chain map",
source: "Options.Deribit.chain(exchange_mod)",
kind: :exchange_data
},
spot: %{description: "Current spot price", kind: :value}
},
returns: %{type: :list, description: "List of {strike, oi} sorted by OI desc"},
returns_example: [{84000.0, 1250.0}]
}
@spec pin_risk(option_chain(), number(), keyword()) :: [map()]
Assess pin risk for strikes near spot price.
Parameters
chain- Option chain map (exchange_data)spot- Current spot price (value)
Options
threshold_pct- Max distance from spot (default:5.0)min_oi- Minimum OI to include (default:0)
Returns
List of %{strike, oi, distance_pct, risk: :high|:medium|:low} (list)
Example
[%{strike: 84000.0, oi: 1250.0, risk: :high, distance_pct: 0.6}]# descripex:contract
%{
opts: %{
threshold_pct: %{
default: 5.0,
type: :float,
description: "Max distance from spot"
},
min_oi: %{default: 0, type: :number, description: "Minimum OI to include"}
},
params: %{
chain: %{
description: "Option chain map",
source: "Options.Deribit.chain(exchange_mod)",
kind: :exchange_data
},
spot: %{description: "Current spot price", kind: :value}
},
returns: %{
type: :list,
description: "List of %{strike, oi, distance_pct, risk: :high|:medium|:low}"
},
returns_example: [
%{strike: 84000.0, oi: 1250.0, risk: :high, distance_pct: 0.6}
]
}
@spec put_call_ratio(option_chain()) :: {:ok, float()} | {:error, :no_calls}
Calculate put/call ratio from open interest.
Parameters
chain- Option chain map (exchange_data)
Returns
{:ok, float()} put/call ratio, or {:error, :no_calls} if no call OI (result_tuple)
Example
{:ok, 0.53}Errors
:no_calls
# descripex:contract
%{
params: %{
chain: %{
description: "Option chain map",
source: "Options.Deribit.chain(exchange_mod)",
kind: :exchange_data
}
},
errors: [:no_calls],
returns: %{
type: :result_tuple,
description: "{:ok, float()} put/call ratio, or {:error, :no_calls} if no call OI"
},
returns_example: {:ok, 0.53}
}
@spec session_phase(Date.t() | DateTime.t()) :: :early | :final_hour | :last_15min | :expired
Determine trading session phase based on time to expiry.
Parameters
expiry- Expiry Date (assumes 08:00 UTC) or DateTime (value)
Returns
:early | :final_hour | :last_15min | :expired (atom)
Example
:final_hour# descripex:contract
%{
params: %{
expiry: %{
description: "Expiry Date (assumes 08:00 UTC) or DateTime",
kind: :value
}
},
returns: %{
type: :atom,
description: ":early | :final_hour | :last_15min | :expired"
},
returns_example: :final_hour
}
Calculate percentage distance from spot to strike.
Parameters
strike- Strike price (value)spot- Current spot price (value)
Returns
Percentage distance (positive = strike above spot) (float)
Example
0.1095# descripex:contract
%{
params: %{
spot: %{description: "Current spot price", kind: :value},
strike: %{description: "Strike price", kind: :value}
},
returns: %{
type: :float,
description: "Percentage distance (positive = strike above spot)"
},
returns_example: 0.1095
}
Calculate hourly theta decay rate for an option.
Parameters
option- Option map with :raw containing theta (value)
Returns
Per-hour theta decay, or nil if theta unavailable (float)
Example
0.1095# descripex:contract
%{
params: %{
option: %{
description: "Option map with :raw containing theta",
kind: :value
}
},
returns: %{
type: :float,
description: "Per-hour theta decay, or nil if theta unavailable"
},
returns_example: 0.1095
}
@spec time_to_expiry(Date.t() | DateTime.t()) :: %{hours: float(), minutes: float()}
Calculate time remaining until expiry.
Parameters
expiry- Expiry Date (assumes 08:00 UTC) or DateTime (value)
Returns
Map with :hours and :minutes remaining (map)
Example
%{hours: 1, minutes: 1}# descripex:contract
%{
params: %{
expiry: %{
description: "Expiry Date (assumes 08:00 UTC) or DateTime",
kind: :value
}
},
returns: %{type: :map, description: "Map with :hours and :minutes remaining"},
returns_example: %{hours: 1, minutes: 1}
}
@spec zero_gamma(option_chain() | term(), number() | term(), keyword() | term()) :: {:ok, zero_gamma_result()} | {:error, zero_gamma_error_reason()}
Solves for aggregate dealer GEX sign changes as spot varies.
Each candidate replaces only :spot in every enriched leg's retained
:pricing_inputs; IV, time to expiry, rates, and carry remain fixed. This is
the sticky-strike assumption. The default search range is 0.5–1.5 times the
supplied spot and can be replaced with an absolute :search_range tuple.
The range is scanned over 512 equal intervals and
every observed sign-changing bracket is bisected. Roots outside the minimum
and maximum strikes carrying positive open interest are discarded. The
primary :level is the remaining root nearest the supplied spot, while
:roots contains every remaining root in ascending order.
Calls contribute positive dealer GEX and puts negative dealer GEX. A
candidate is zero when abs(net GEX) <= relative_tolerance * gross GEX.
Empty or near-empty gamma mass returns :no_gamma_signal instead of treating
every spot as a root.