Packages
Pure-function trading analytics for Elixir — Black-Scholes pricing and IV, options chain analytics (max pain, GEX, skew, surfaces), funding rates, basis, volatility estimators, risk metrics, position sizing, orderflow, and portfolio analytics. Self-describing for AI agents.
Current section
Files
Jump to
Current section
Files
lib/zen_quant/options/skew.ex
defmodule ZenQuant.Options.Skew do
@moduledoc """
Deterministic term structures from normalized option-skew observations.
`term_structure/1` accepts a list of maps with this contract:
| Field | Contract |
| --- | --- |
| `:expiry` | `Date.t()` |
| `:tenor_days` | Non-negative integer number of calendar days |
| `:measure` | Non-empty atom or string identifying the caller-selected skew measure |
| `:units` | Non-empty atom or string identifying the value units |
| `:value` | Numeric skew observation, or `nil` for an explicitly missing observation |
Every observation in one call must use the same measure and units. The module
does not interpret those identifiers; examples include
`:delta_25_risk_reversal` measured in `:decimal_volatility`.
Results are sorted by expiry and then tenor. Exact duplicate expiry/tenor
coordinates are reduced to the arithmetic mean of their numeric values,
independent of input order. Missing duplicates do not enter that mean, and a
coordinate with no numeric values remains `nil`.
`:change_from_previous` is the current value minus the immediately preceding
sorted value. It is `nil` for the first point or when either adjacent point is
missing. No missing value is filled and no interpolation is performed.
"""
use Descripex, namespace: "/options/skew"
@required_fields [:expiry, :tenor_days, :measure, :units, :value]
@typedoc "Normalized caller-selected skew observation."
@type observation :: %{
expiry: Date.t(),
tenor_days: non_neg_integer(),
measure: atom() | String.t(),
units: atom() | String.t(),
value: number() | nil
}
@typedoc "A sorted and duplicate-reduced term point."
@type term_point :: %{
expiry: Date.t(),
tenor_days: non_neg_integer(),
value: float() | nil,
sample_count: non_neg_integer(),
change_from_previous: float() | nil
}
@typedoc "Skew term structure with its explicit measure, units, and policies."
@type term_structure :: %{
measure: atom() | String.t() | nil,
units: atom() | String.t() | nil,
duplicate_policy: :mean_observed_values,
missing_policy: :preserve_nil,
observations: [term_point()]
}
@typedoc "Validation failure returned by `term_structure/1`."
@type error_reason ::
:invalid_observations
| {:invalid_observation, non_neg_integer()}
| {:missing_field, non_neg_integer(), atom()}
| {:invalid_field, non_neg_integer(), atom()}
| {:mixed_measure, atom() | String.t(), atom() | String.t()}
| {:mixed_units, atom() | String.t(), atom() | String.t()}
api(:term_structure, "Build a sorted skew term structure without interpolation.",
params: [
observations: [
kind: :value,
description:
"Normalized maps with Date :expiry, non-negative :tenor_days, caller-defined :measure/:units, and numeric or nil :value"
]
],
returns: %{
type: :result_tuple,
description: "{:ok, %{measure, units, duplicate_policy, missing_policy, observations}} or {:error, reason}"
},
returns_example:
{:ok,
%{
measure: :delta_25_risk_reversal,
units: :decimal_volatility,
duplicate_policy: :mean_observed_values,
missing_policy: :preserve_nil,
observations: [
%{
expiry: ~D[2026-08-28],
tenor_days: 30,
value: 0.03,
sample_count: 1,
change_from_previous: nil
}
]
}},
errors: [
invalid_observations: "Input must be a list",
invalid_observation: "Each item must be a normalized observation map",
missing_field: "A required observation field is absent",
invalid_field: "An observation field has an unsupported type or domain",
mixed_measure: "All observations must identify the same skew measure",
mixed_units: "All observations must identify the same value units"
]
)
@spec term_structure([observation()] | term()) ::
{:ok, term_structure()} | {:error, error_reason()}
def term_structure(observations) when is_list(observations) do
with {:ok, normalized} <- validate_observations(observations),
:ok <- validate_contract(normalized) do
{measure, units} = contract(normalized)
points =
normalized
|> reduce_duplicates()
|> add_adjacent_changes()
{:ok,
%{
measure: measure,
units: units,
duplicate_policy: :mean_observed_values,
missing_policy: :preserve_nil,
observations: points
}}
end
end
def term_structure(_observations), do: {:error, :invalid_observations}
defp validate_observations(observations) do
observations
|> Enum.with_index()
|> Enum.reduce_while({:ok, []}, fn {observation, index}, {:ok, acc} ->
case validate_observation(observation, index) do
{:ok, normalized} -> {:cont, {:ok, [normalized | acc]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, normalized} -> {:ok, Enum.reverse(normalized)}
error -> error
end
end
defp validate_observation(observation, index) when is_map(observation) do
case Enum.find(@required_fields, &(not Map.has_key?(observation, &1))) do
nil -> validate_observation_fields(observation, index)
missing_field -> {:error, {:missing_field, index, missing_field}}
end
end
defp validate_observation(_observation, index), do: {:error, {:invalid_observation, index}}
defp validate_observation_fields(observation, index) do
cond do
not match?(%Date{}, observation.expiry) ->
{:error, {:invalid_field, index, :expiry}}
not (is_integer(observation.tenor_days) and observation.tenor_days >= 0) ->
{:error, {:invalid_field, index, :tenor_days}}
not valid_identifier?(observation.measure) ->
{:error, {:invalid_field, index, :measure}}
not valid_identifier?(observation.units) ->
{:error, {:invalid_field, index, :units}}
true ->
normalize_value(observation, index)
end
end
defp valid_identifier?(value), do: (is_atom(value) and not is_nil(value)) or (is_binary(value) and byte_size(value) > 0)
defp normalize_value(observation, index) do
case observation.value do
nil ->
{:ok, Map.take(observation, @required_fields)}
value when is_number(value) ->
{:ok, observation |> Map.take(@required_fields) |> Map.put(:value, :erlang.float(value))}
_value ->
{:error, {:invalid_field, index, :value}}
end
end
defp validate_contract([]), do: :ok
defp validate_contract([first | rest]) do
Enum.reduce_while(rest, :ok, fn observation, :ok ->
cond do
observation.measure != first.measure ->
{:halt, {:error, {:mixed_measure, first.measure, observation.measure}}}
observation.units != first.units ->
{:halt, {:error, {:mixed_units, first.units, observation.units}}}
true ->
{:cont, :ok}
end
end)
end
defp contract([]), do: {nil, nil}
defp contract([first | _rest]), do: {first.measure, first.units}
defp reduce_duplicates(observations) do
observations
|> Enum.group_by(&{&1.expiry, &1.tenor_days})
|> Enum.map(fn {{expiry, tenor_days}, duplicates} ->
values =
duplicates
|> Enum.flat_map(fn
%{value: nil} -> []
%{value: value} -> [value]
end)
|> Enum.sort()
value = if values == [], do: nil, else: Enum.sum(values) / length(values)
%{
expiry: expiry,
tenor_days: tenor_days,
value: value,
sample_count: length(values)
}
end)
|> Enum.sort_by(fn point -> {Date.to_gregorian_days(point.expiry), point.tenor_days} end)
end
defp add_adjacent_changes(points) do
{points, _previous} =
Enum.map_reduce(points, nil, fn point, previous ->
change =
case {previous, point.value} do
{%{value: previous_value}, value}
when is_number(previous_value) and is_number(value) ->
value - previous_value
_ ->
nil
end
{Map.put(point, :change_from_previous, change), point}
end)
points
end
end