# `ZenQuant.Options`
[🔗](https://github.com/ZenHive/zen_quant/blob/v0.8.1/lib/zen_quant/options.ex#L1)

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` |

# `expiry`

```elixir
@type expiry() :: Date.t()
```

# `option_chain`

```elixir
@type option_chain() :: %{optional(String.t()) =&gt; map()}
```

# `strike`

```elixir
@type strike() :: float()
```

# `zero_gamma_error_reason`

```elixir
@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.

# `zero_gamma_omission`

```elixir
@type zero_gamma_omission() :: %{
  symbol: term(),
  reason: :missing_iv | :degenerate_input
}
```

A leg excluded from zero-gamma analysis by explicit caller policy.

# `zero_gamma_result`

```elixir
@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.

# `aggregate_oi`

```elixir
@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

```elixir
%{calls: 1800.0, total: 3000.0, puts: 1200.0, ratio: 0.667}
```

```elixir
# 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}
}
```

# `atm_iv`

```elixir
@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

```elixir
{:ok, 46.3}
```

## Errors

  * `:no_iv_data`

```elixir
# descripex:contract
%{
  params: %{
    spot: %{description: "Current spot price", kind: :value},
    chain: %{
      description: "Option chain with IV data in :raw",
      source: "Options.Deribit.chain(exchange_mod, enrich: :greeks)",
      kind: :exchange_data
    }
  },
  errors: [:no_iv_data],
  returns: %{
    type: :tuple,
    description: "{:ok, iv_percentage} or {:error, :no_iv_data}"
  },
  returns_example: {:ok, 46.3}
}
```

# `atm_iv_term_structure`

```elixir
@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 nearest `spot` (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_pct` percent of spot. An empty window yields `status: :no_iv`
  and `value: nil` for 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.

# `breakeven_move`

```elixir
@spec breakeven_move(map(), number()) :: {:ok, float()} | {:error, :missing_data}
```

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

```elixir
{:ok, 4.2}
```

## Errors

  * `:missing_data`

```elixir
# 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}
}
```

# `expected_range`

```elixir
@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

```elixir
%{upper: 1.0, lower: 1.0, move_pct: 1.0}
```

```elixir
# 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}
}
```

# `filter_by_dte`

```elixir
@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

```elixir
%{value: 1.0}
```

```elixir
# 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}
}
```

# `gamma_flip`

```elixir
@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.

# `gex_by_strike`

```elixir
@spec gex_by_strike(option_chain(), number()) :: %{required(strike()) =&gt; 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

```elixir
%{value: 1.0}
```

## Composes With

  * `gamma_flip`

```elixir
# descripex:contract
%{
  params: %{
    spot: %{description: "Current spot price", kind: :value},
    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 of %{strike => gex_value}"},
  returns_example: %{value: 1.0},
  composes_with: [:gamma_flip]
}
```

# `greeks_sum`

```elixir
@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

```elixir
%{delta: 1.0, gamma: 1.0, theta: 1.0, vega: 1.0}
```

```elixir
# 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, theta: 1.0, vega: 1.0}
}
```

# `hot_zone`

```elixir
@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

```elixir
{:hot, 84000.0, 1250.0}
```

```elixir
# 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: %{
    spot: %{description: "Current spot price", kind: :value},
    chain: %{
      description: "Option chain map",
      source: "Options.Deribit.chain(exchange_mod)",
      kind: :exchange_data
    }
  },
  returns: %{type: :tuple, description: "{:hot, strike, oi} or :clear"},
  returns_example: {:hot, 84000.0, 1250.0}
}
```

# `in_play?`

```elixir
@spec in_play?(strike(), number(), number()) :: boolean()
```

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

```elixir
true
```

```elixir
# 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
}
```

# `largest_positions`

```elixir
@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

```elixir
[{"BTC-31JAN26-84000-C", %{open_interest: 1200.0}}]
```

```elixir
# 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}}]
}
```

# `max_pain`

```elixir
@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

```elixir
{:ok, 84000.0}
```

## Errors

  * `:empty_chain`

```elixir
# 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}
}
```

# `moneyness_skew`

```elixir
@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.

# `oi_by_expiry`

```elixir
@spec oi_by_expiry(option_chain()) :: %{required(expiry()) =&gt; 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

```elixir
%{value: 1.0}
```

```elixir
# 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}
}
```

# `oi_by_strike`

```elixir
@spec oi_by_strike(option_chain()) :: %{required(strike()) =&gt; 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

```elixir
%{value: 1.0}
```

## Composes With

  * `max_pain`

```elixir
# 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]
}
```

# `pin_magnets`

```elixir
@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

```elixir
[{84000.0, 1250.0}]
```

```elixir
# 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: %{
    spot: %{description: "Current spot price", kind: :value},
    chain: %{
      description: "Option chain map",
      source: "Options.Deribit.chain(exchange_mod)",
      kind: :exchange_data
    }
  },
  returns: %{type: :list, description: "List of {strike, oi} sorted by OI desc"},
  returns_example: [{84000.0, 1250.0}]
}
```

# `pin_risk`

```elixir
@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

```elixir
[%{strike: 84000.0, oi: 1250.0, risk: :high, distance_pct: 0.6}]
```

```elixir
# 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: %{
    spot: %{description: "Current spot price", kind: :value},
    chain: %{
      description: "Option chain map",
      source: "Options.Deribit.chain(exchange_mod)",
      kind: :exchange_data
    }
  },
  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}
  ]
}
```

# `put_call_ratio`

```elixir
@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

```elixir
{:ok, 0.53}
```

## Errors

  * `:no_calls`

```elixir
# 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}
}
```

# `session_phase`

```elixir
@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

```elixir
:final_hour
```

```elixir
# 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
}
```

# `strike_distance`

```elixir
@spec strike_distance(strike(), number()) :: float()
```

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

```elixir
0.1095
```

```elixir
# 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
}
```

# `theta_per_hour`

```elixir
@spec theta_per_hour(map()) :: float() | nil
```

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

```elixir
0.1095
```

```elixir
# 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
}
```

# `time_to_expiry`

```elixir
@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

```elixir
%{minutes: 1, hours: 1}
```

```elixir
# 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: %{minutes: 1, hours: 1}
}
```

# `zero_gamma`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
