feat: add full derivatives analytics layer (options + futures)
Implements all phases of the derivatives expansion plan: Rust core (crates/ferro_ta_core/src/options/, src/futures/): - BSM and Black-76 pricing (scalar + vectorized batch) - Greeks: delta, gamma, vega, theta, rho - Implied volatility solver (Newton + bisection fallback) - Smile/skew metrics: ATM IV, 25-delta RR/BF, skew slope, convexity - Chain helpers: moneyness labels, strike selection by offset or delta - Synthetic forwards, basis, annualized basis, implied carry, carry spread - Continuous contract stitching: weighted, back-adjusted, ratio-adjusted - Curve analytics: calendar spreads, slope, contango/backwardation summary PyO3 bindings (src/options/, src/futures/): - All Rust functions registered and exposed via _ferro_ta extension Python API (python/ferro_ta/analysis/): - options.py: pricing, greeks, IV, smile, chain, legacy iv_rank/percentile/zscore - futures.py: basis, carry, curve, roll, synthetic, continuous contracts - options_strategy.py: typed strategy schemas (expiry/strike selectors, leg presets, risk controls, simulation limits) - derivatives_payoff.py: multi-leg payoff aggregation and Greeks aggregation Bug fix: wrap _to_f64 calls in iv_rank/iv_percentile/iv_zscore to raise FerroTAInputError (not plain ValueError) for 2D array input. Docs: derivatives.rst, derivatives-analytics.md, options-volatility.md, quickstart.rst, index.rst, api/analysis.rst all updated. Tests: 2053 pass, 12 skipped. All CI checks pass locally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ Sub-packages
|
||||
* :mod:`ferro_ta.indicators` — All indicator functions (overlap, momentum, volume, volatility, statistic, cycle, pattern, price_transform, math_ops, extended)
|
||||
* :mod:`ferro_ta.core` — Core utilities (exceptions, config, logging, registry, raw)
|
||||
* :mod:`ferro_ta.data` — Data utilities (streaming, batch, chunked, resampling, aggregation, adapters)
|
||||
* :mod:`ferro_ta.analysis` — Analysis tools (portfolio, backtest, regime, cross_asset, attribution, signals, features, crypto, options)
|
||||
* :mod:`ferro_ta.analysis` — Analysis tools (portfolio, backtest, regime, cross_asset, attribution, signals, features, crypto, options, futures, derivatives payoff)
|
||||
* :mod:`ferro_ta.tools` — Developer tools (tools, viz, dashboard, alerts, dsl, pipeline, workflow, api_info, gpu)
|
||||
|
||||
Sub-modules (also accessible via sub-packages above)
|
||||
@@ -35,6 +35,8 @@ Sub-modules (also accessible via sub-packages above)
|
||||
* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics
|
||||
* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative strength
|
||||
* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness
|
||||
* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, smile, and chain analytics
|
||||
* :mod:`ferro_ta.analysis.futures` — Futures basis, carry, roll, and curve analytics
|
||||
* :mod:`ferro_ta.tools.viz` — Charting and visualisation API
|
||||
* :mod:`ferro_ta.data.adapters` — Market data adapters
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ Sub-modules
|
||||
* :mod:`ferro_ta.analysis.signals` — Signal composition and screening
|
||||
* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness helpers
|
||||
* :mod:`ferro_ta.analysis.crypto` — Crypto-specific indicators and helpers
|
||||
* :mod:`ferro_ta.analysis.options` — Options pricing and Greeks
|
||||
* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, and smile analytics
|
||||
* :mod:`ferro_ta.analysis.futures` — Futures basis, curve, roll, and synthetic analytics
|
||||
* :mod:`ferro_ta.analysis.options_strategy` — Typed derivatives strategy schemas
|
||||
* :mod:`ferro_ta.analysis.derivatives_payoff` — Multi-leg payoff and Greeks aggregation
|
||||
|
||||
Example usage::
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
ferro_ta.analysis.derivatives_payoff — Multi-leg payoff and Greeks aggregation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta.analysis.options import OptionGreeks
|
||||
from ferro_ta.analysis.options import greeks as option_greeks
|
||||
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
|
||||
__all__ = [
|
||||
"PayoffLeg",
|
||||
"option_leg_payoff",
|
||||
"futures_leg_payoff",
|
||||
"strategy_payoff",
|
||||
"aggregate_greeks",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayoffLeg:
|
||||
instrument: str
|
||||
side: str
|
||||
quantity: float = 1.0
|
||||
option_type: str | None = None
|
||||
strike: float | None = None
|
||||
premium: float = 0.0
|
||||
entry_price: float | None = None
|
||||
volatility: float | None = None
|
||||
time_to_expiry: float | None = None
|
||||
rate: float = 0.0
|
||||
carry: float = 0.0
|
||||
multiplier: float = 1.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.instrument not in {"option", "future"}:
|
||||
raise FerroTAValueError("instrument must be 'option' or 'future'.")
|
||||
if self.side not in {"long", "short"}:
|
||||
raise FerroTAValueError("side must be 'long' or 'short'.")
|
||||
if self.instrument == "option":
|
||||
if self.option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError(
|
||||
"option legs require option_type='call' or 'put'."
|
||||
)
|
||||
if self.strike is None:
|
||||
raise FerroTAValueError("option legs require strike.")
|
||||
if self.instrument == "future" and self.entry_price is None:
|
||||
raise FerroTAValueError("future legs require entry_price.")
|
||||
|
||||
|
||||
def _side_sign(side: str) -> float:
|
||||
return 1.0 if side == "long" else -1.0
|
||||
|
||||
|
||||
def _coerce_spot_grid(spot_grid: ArrayLike) -> NDArray[np.float64]:
|
||||
grid = np.asarray(spot_grid, dtype=np.float64)
|
||||
if grid.ndim != 1:
|
||||
raise FerroTAInputError("spot_grid must be a 1-D array.")
|
||||
return np.ascontiguousarray(grid)
|
||||
|
||||
|
||||
def option_leg_payoff(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
strike: float,
|
||||
premium: float = 0.0,
|
||||
option_type: str = "call",
|
||||
side: str = "long",
|
||||
quantity: float = 1.0,
|
||||
multiplier: float = 1.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Expiry payoff for a single option leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
sign = _side_sign(side) * float(quantity) * float(multiplier)
|
||||
if option_type == "call":
|
||||
intrinsic = np.maximum(grid - float(strike), 0.0)
|
||||
elif option_type == "put":
|
||||
intrinsic = np.maximum(float(strike) - grid, 0.0)
|
||||
else:
|
||||
raise FerroTAValueError("option_type must be 'call' or 'put'.")
|
||||
return sign * (intrinsic - float(premium))
|
||||
|
||||
|
||||
def futures_leg_payoff(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
entry_price: float,
|
||||
side: str = "long",
|
||||
quantity: float = 1.0,
|
||||
multiplier: float = 1.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""P/L profile for a futures leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
sign = _side_sign(side) * float(quantity) * float(multiplier)
|
||||
return sign * (grid - float(entry_price))
|
||||
|
||||
|
||||
def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
|
||||
return PayoffLeg(**mapping)
|
||||
|
||||
|
||||
def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg:
|
||||
return PayoffLeg(
|
||||
instrument=leg.instrument,
|
||||
side=leg.side,
|
||||
quantity=float(leg.quantity),
|
||||
option_type=leg.option_type,
|
||||
strike=leg.strike_selector.explicit_strike,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_legs(
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
|
||||
*,
|
||||
strategy: DerivativesStrategy | None = None,
|
||||
) -> tuple[PayoffLeg, ...]:
|
||||
if strategy is not None:
|
||||
return tuple(_strategy_leg_to_payoff_leg(leg) for leg in strategy.legs)
|
||||
if legs is None:
|
||||
raise FerroTAInputError("Provide either legs or strategy.")
|
||||
normalized: list[PayoffLeg] = []
|
||||
for leg in legs:
|
||||
normalized.append(leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg))
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
def strategy_payoff(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
|
||||
strategy: DerivativesStrategy | None = None,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Aggregate expiry payoff across option and futures legs."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
total = np.zeros_like(grid)
|
||||
for leg in normalized:
|
||||
if leg.instrument == "option":
|
||||
if leg.strike is None:
|
||||
raise FerroTAValueError("Option payoff legs require strike.")
|
||||
total += option_leg_payoff(
|
||||
grid,
|
||||
strike=float(leg.strike),
|
||||
premium=float(leg.premium),
|
||||
option_type=str(leg.option_type),
|
||||
side=str(leg.side),
|
||||
quantity=float(leg.quantity),
|
||||
multiplier=float(leg.multiplier),
|
||||
)
|
||||
else:
|
||||
if leg.entry_price is None:
|
||||
raise FerroTAValueError("Futures payoff legs require entry_price.")
|
||||
total += futures_leg_payoff(
|
||||
grid,
|
||||
entry_price=float(leg.entry_price),
|
||||
side=str(leg.side),
|
||||
quantity=float(leg.quantity),
|
||||
multiplier=float(leg.multiplier),
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
def aggregate_greeks(
|
||||
spot: float,
|
||||
*,
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
|
||||
strategy: DerivativesStrategy | None = None,
|
||||
) -> OptionGreeks:
|
||||
"""Aggregate Greeks across option and futures legs."""
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
totals = {
|
||||
"delta": 0.0,
|
||||
"gamma": 0.0,
|
||||
"vega": 0.0,
|
||||
"theta": 0.0,
|
||||
"rho": 0.0,
|
||||
}
|
||||
for leg in normalized:
|
||||
leg_sign = _side_sign(leg.side) * float(leg.quantity) * float(leg.multiplier)
|
||||
if leg.instrument == "future":
|
||||
totals["delta"] += leg_sign
|
||||
continue
|
||||
if leg.strike is None or leg.volatility is None or leg.time_to_expiry is None:
|
||||
raise FerroTAValueError(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation."
|
||||
)
|
||||
leg_greeks = option_greeks(
|
||||
float(spot),
|
||||
float(leg.strike),
|
||||
float(leg.rate),
|
||||
float(leg.time_to_expiry),
|
||||
float(leg.volatility),
|
||||
option_type=str(leg.option_type),
|
||||
model="bsm",
|
||||
carry=float(leg.carry),
|
||||
)
|
||||
totals["delta"] += leg_sign * float(leg_greeks.delta)
|
||||
totals["gamma"] += leg_sign * float(leg_greeks.gamma)
|
||||
totals["vega"] += leg_sign * float(leg_greeks.vega)
|
||||
totals["theta"] += leg_sign * float(leg_greeks.theta)
|
||||
totals["rho"] += leg_sign * float(leg_greeks.rho)
|
||||
|
||||
return OptionGreeks(
|
||||
totals["delta"],
|
||||
totals["gamma"],
|
||||
totals["vega"],
|
||||
totals["theta"],
|
||||
totals["rho"],
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
ferro_ta.analysis.futures — Futures and forward-curve analytics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import annualized_basis as _rust_annualized_basis
|
||||
from ferro_ta._ferro_ta import (
|
||||
back_adjusted_continuous_contract as _rust_back_adjusted,
|
||||
)
|
||||
from ferro_ta._ferro_ta import calendar_spreads as _rust_calendar_spreads
|
||||
from ferro_ta._ferro_ta import carry_spread as _rust_carry_spread
|
||||
from ferro_ta._ferro_ta import curve_slope as _rust_curve_slope
|
||||
from ferro_ta._ferro_ta import curve_summary as _rust_curve_summary
|
||||
from ferro_ta._ferro_ta import futures_basis as _rust_basis
|
||||
from ferro_ta._ferro_ta import implied_carry_rate as _rust_implied_carry_rate
|
||||
from ferro_ta._ferro_ta import parity_gap as _rust_parity_gap
|
||||
from ferro_ta._ferro_ta import (
|
||||
ratio_adjusted_continuous_contract as _rust_ratio_adjusted,
|
||||
)
|
||||
from ferro_ta._ferro_ta import roll_yield as _rust_roll_yield
|
||||
from ferro_ta._ferro_ta import synthetic_forward as _rust_synthetic_forward
|
||||
from ferro_ta._ferro_ta import synthetic_spot as _rust_synthetic_spot
|
||||
from ferro_ta._ferro_ta import weighted_continuous_contract as _rust_weighted
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
__all__ = [
|
||||
"CurveSummary",
|
||||
"synthetic_forward",
|
||||
"synthetic_spot",
|
||||
"parity_gap",
|
||||
"basis",
|
||||
"annualized_basis",
|
||||
"implied_carry_rate",
|
||||
"carry_spread",
|
||||
"weighted_continuous_contract",
|
||||
"back_adjusted_continuous_contract",
|
||||
"ratio_adjusted_continuous_contract",
|
||||
"roll_yield",
|
||||
"calendar_spreads",
|
||||
"curve_slope",
|
||||
"curve_summary",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurveSummary:
|
||||
front_basis: float
|
||||
average_basis: float
|
||||
slope: float
|
||||
is_contango: bool
|
||||
|
||||
def to_dict(self) -> dict[str, float | bool]:
|
||||
return {
|
||||
"front_basis": self.front_basis,
|
||||
"average_basis": self.average_basis,
|
||||
"slope": self.slope,
|
||||
"is_contango": self.is_contango,
|
||||
}
|
||||
|
||||
|
||||
def synthetic_forward(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_synthetic_forward(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def synthetic_spot(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
*,
|
||||
carry: float = 0.0,
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_synthetic_spot(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
float(carry),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def parity_gap(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
spot: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
*,
|
||||
carry: float = 0.0,
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_parity_gap(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(spot),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
float(carry),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def basis(spot: float, future: float) -> float:
|
||||
return float(_rust_basis(float(spot), float(future)))
|
||||
|
||||
|
||||
def annualized_basis(spot: float, future: float, time_to_expiry: float) -> float:
|
||||
return float(
|
||||
_rust_annualized_basis(float(spot), float(future), float(time_to_expiry))
|
||||
)
|
||||
|
||||
|
||||
def implied_carry_rate(spot: float, future: float, time_to_expiry: float) -> float:
|
||||
return float(
|
||||
_rust_implied_carry_rate(float(spot), float(future), float(time_to_expiry))
|
||||
)
|
||||
|
||||
|
||||
def carry_spread(
|
||||
spot: float, future: float, rate: float, time_to_expiry: float
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_carry_spread(
|
||||
float(spot), float(future), float(rate), float(time_to_expiry)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def weighted_continuous_contract(
|
||||
front: ArrayLike,
|
||||
next_contract: ArrayLike,
|
||||
next_weights: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_weighted(
|
||||
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def back_adjusted_continuous_contract(
|
||||
front: ArrayLike,
|
||||
next_contract: ArrayLike,
|
||||
next_weights: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_back_adjusted(
|
||||
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def ratio_adjusted_continuous_contract(
|
||||
front: ArrayLike,
|
||||
next_contract: ArrayLike,
|
||||
next_weights: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_ratio_adjusted(
|
||||
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def roll_yield(front_price: float, next_price: float, time_to_expiry: float) -> float:
|
||||
return float(
|
||||
_rust_roll_yield(float(front_price), float(next_price), float(time_to_expiry))
|
||||
)
|
||||
|
||||
|
||||
def calendar_spreads(futures_prices: ArrayLike) -> NDArray[np.float64]:
|
||||
return np.asarray(_rust_calendar_spreads(_to_f64(futures_prices)), dtype=np.float64)
|
||||
|
||||
|
||||
def curve_slope(tenors: ArrayLike, futures_prices: ArrayLike) -> float:
|
||||
try:
|
||||
return float(_rust_curve_slope(_to_f64(tenors), _to_f64(futures_prices)))
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def curve_summary(
|
||||
spot: float, tenors: ArrayLike, futures_prices: ArrayLike
|
||||
) -> CurveSummary:
|
||||
try:
|
||||
front_basis, average_basis, slope, is_contango = _rust_curve_summary(
|
||||
float(spot), _to_f64(tenors), _to_f64(futures_prices)
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return CurveSummary(front_basis, average_basis, slope, is_contango)
|
||||
+599
-173
@@ -1,206 +1,632 @@
|
||||
"""
|
||||
ferro_ta.options — Options and Implied Volatility Helpers
|
||||
=========================================================
|
||||
ferro_ta.analysis.options — Rust-backed derivatives analytics for options.
|
||||
|
||||
Optional module that provides helpers for options/IV analysis when supplied
|
||||
with an implied-volatility series (IV series as input). All heavy compute
|
||||
delegates to Rust via ``ferro_ta`` core; this module is a thin orchestration
|
||||
layer.
|
||||
|
||||
.. note::
|
||||
Options support is **optional** and does not require any additional
|
||||
third-party libraries beyond ``numpy``. For advanced option-pricing
|
||||
functionality (e.g. Black-Scholes, Greeks) install the optional
|
||||
``ferro_ta[options]`` extra which may pull in additional dependencies.
|
||||
|
||||
See ``docs/options-volatility.md`` for the full design doc.
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.options import iv_rank, iv_percentile
|
||||
>>>
|
||||
>>> # Synthetic IV series (e.g. VIX or single-name IV)
|
||||
>>> rng = np.random.default_rng(42)
|
||||
>>> iv = rng.uniform(10, 40, 252)
|
||||
>>>
|
||||
>>> rank = iv_rank(iv, window=252)
|
||||
>>> pct = iv_percentile(iv, window=252)
|
||||
|
||||
API
|
||||
---
|
||||
iv_rank(iv_series, window)
|
||||
Rolling IV rank: where is today's IV relative to min/max over *window* bars?
|
||||
Returns values in [0, 1] (NaN during warm-up).
|
||||
|
||||
iv_percentile(iv_series, window)
|
||||
Rolling IV percentile: fraction of observations over *window* bars that are
|
||||
≤ today's IV. Returns values in [0, 1] (NaN during warm-up).
|
||||
|
||||
iv_zscore(iv_series, window)
|
||||
Rolling IV z-score: (IV - rolling_mean) / rolling_std over *window* bars.
|
||||
Returns z-score values (NaN during warm-up).
|
||||
This module preserves the legacy IV-series helpers and expands them with
|
||||
pricing, Greeks, implied-volatility inversion, smile analytics, and strike
|
||||
selection helpers suitable for research and simulation workflows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
import numpy as np
|
||||
from numpy.lib.stride_tricks import sliding_window_view
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
from ferro_ta._ferro_ta import (
|
||||
black76_price as _rust_black76_price,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
black76_price_batch as _rust_black76_price_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
bsm_price as _rust_bsm_price,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
bsm_price_batch as _rust_bsm_price_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
implied_volatility as _rust_implied_volatility,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
implied_volatility_batch as _rust_implied_volatility_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
iv_percentile as _rust_iv_percentile,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
iv_rank as _rust_iv_rank,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
iv_zscore as _rust_iv_zscore,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
moneyness_labels as _rust_moneyness_labels,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
option_greeks as _rust_option_greeks,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
option_greeks_batch as _rust_option_greeks_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
select_strike_delta as _rust_select_strike_delta,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
select_strike_offset as _rust_select_strike_offset,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
smile_metrics as _rust_smile_metrics,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
term_structure_slope as _rust_term_structure_slope,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import (
|
||||
FerroTAInputError,
|
||||
FerroTAValueError,
|
||||
_normalize_rust_error,
|
||||
)
|
||||
|
||||
ScalarOrArray: TypeAlias = float | NDArray[np.float64]
|
||||
|
||||
__all__ = [
|
||||
"OptionGreeks",
|
||||
"SmileMetrics",
|
||||
"black_scholes_price",
|
||||
"black_76_price",
|
||||
"option_price",
|
||||
"greeks",
|
||||
"implied_volatility",
|
||||
"smile_metrics",
|
||||
"term_structure_slope",
|
||||
"label_moneyness",
|
||||
"select_strike",
|
||||
"iv_rank",
|
||||
"iv_percentile",
|
||||
"iv_zscore",
|
||||
]
|
||||
|
||||
|
||||
def _validate_iv(iv_series: NDArray[np.float64], window: int) -> NDArray[np.float64]:
|
||||
"""Validate and convert iv_series; check window."""
|
||||
arr = np.asarray(iv_series, dtype=np.float64)
|
||||
if arr.ndim != 1:
|
||||
raise FerroTAInputError("iv_series must be a 1-D array.")
|
||||
@dataclass(frozen=True)
|
||||
class OptionGreeks:
|
||||
"""Container for first-order Greeks."""
|
||||
|
||||
delta: ScalarOrArray
|
||||
gamma: ScalarOrArray
|
||||
vega: ScalarOrArray
|
||||
theta: ScalarOrArray
|
||||
rho: ScalarOrArray
|
||||
|
||||
def to_dict(self) -> dict[str, ScalarOrArray]:
|
||||
return {
|
||||
"delta": self.delta,
|
||||
"gamma": self.gamma,
|
||||
"vega": self.vega,
|
||||
"theta": self.theta,
|
||||
"rho": self.rho,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmileMetrics:
|
||||
"""Summary metrics for a single smile slice."""
|
||||
|
||||
atm_iv: float
|
||||
risk_reversal_25d: float
|
||||
butterfly_25d: float
|
||||
skew_slope: float
|
||||
convexity: float
|
||||
|
||||
def to_dict(self) -> dict[str, float]:
|
||||
return {
|
||||
"atm_iv": self.atm_iv,
|
||||
"risk_reversal_25d": self.risk_reversal_25d,
|
||||
"butterfly_25d": self.butterfly_25d,
|
||||
"skew_slope": self.skew_slope,
|
||||
"convexity": self.convexity,
|
||||
}
|
||||
|
||||
|
||||
def _validate_option_type(option_type: str) -> str:
|
||||
value = option_type.lower()
|
||||
if value not in {"call", "put"}:
|
||||
raise FerroTAValueError("option_type must be 'call' or 'put'.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_model(model: str) -> str:
|
||||
value = model.lower()
|
||||
aliases = {
|
||||
"bsm": "bsm",
|
||||
"black_scholes": "bsm",
|
||||
"black-scholes": "bsm",
|
||||
"blackscholes": "bsm",
|
||||
"black76": "black76",
|
||||
"black_76": "black76",
|
||||
"black-76": "black76",
|
||||
}
|
||||
if value not in aliases:
|
||||
raise FerroTAValueError(
|
||||
"model must be one of 'bsm', 'black_scholes', or 'black76'."
|
||||
)
|
||||
return aliases[value]
|
||||
|
||||
|
||||
def _coerce_1d(data: ArrayLike | float, *, name: str) -> tuple[np.ndarray, bool]:
|
||||
arr = np.asarray(data, dtype=np.float64)
|
||||
if arr.ndim > 1:
|
||||
raise FerroTAInputError(f"{name} must be a scalar or 1-D array.")
|
||||
return np.ascontiguousarray(arr.reshape(-1)), arr.ndim == 0
|
||||
|
||||
|
||||
def _broadcast_inputs(
|
||||
**kwargs: ArrayLike | float,
|
||||
) -> tuple[dict[str, np.ndarray], bool]:
|
||||
arrays: dict[str, np.ndarray] = {}
|
||||
scalar_flags: list[bool] = []
|
||||
for name, value in kwargs.items():
|
||||
arr, is_scalar = _coerce_1d(value, name=name)
|
||||
arrays[name] = arr
|
||||
scalar_flags.append(is_scalar)
|
||||
try:
|
||||
broadcast = np.broadcast_arrays(*arrays.values())
|
||||
except ValueError as err:
|
||||
raise FerroTAInputError(
|
||||
f"Inputs could not be broadcast together: {', '.join(arrays.keys())}"
|
||||
) from err
|
||||
out = {
|
||||
name: np.ascontiguousarray(arr, dtype=np.float64).reshape(-1)
|
||||
for name, arr in zip(arrays.keys(), broadcast)
|
||||
}
|
||||
return out, all(scalar_flags)
|
||||
|
||||
|
||||
def _result_or_scalar(result: np.ndarray, scalar_mode: bool) -> ScalarOrArray:
|
||||
return float(result[0]) if scalar_mode else result
|
||||
|
||||
|
||||
def iv_rank(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV rank in Rust while preserving the legacy API."""
|
||||
try:
|
||||
arr = _to_f64(iv_series)
|
||||
except ValueError as err:
|
||||
raise FerroTAInputError(str(err)) from err
|
||||
if len(arr) == 0:
|
||||
raise FerroTAInputError("iv_series must not be empty.")
|
||||
if window < 1:
|
||||
raise FerroTAValueError(f"window must be >= 1, got {window}.")
|
||||
return arr
|
||||
try:
|
||||
return np.asarray(_rust_iv_rank(arr, int(window)), dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def iv_rank(
|
||||
iv_series: ArrayLike,
|
||||
window: int = 252,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV rank.
|
||||
|
||||
IV rank measures where today's IV sits relative to the min/max of IV over
|
||||
the look-back *window*. A value of 1.0 means current IV is at its
|
||||
highest, 0.0 means it is at its lowest.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
iv_series : array-like
|
||||
1-D series of implied volatility values (e.g. VIX daily closes or
|
||||
single-name option IV). Any positive numeric values are accepted.
|
||||
window : int
|
||||
Look-back period in bars (default 252 ≈ 1 trading year).
|
||||
|
||||
Returns
|
||||
-------
|
||||
ndarray of float64
|
||||
Rolling IV rank in [0, 1]. NaN for bars where the window is not yet
|
||||
full (i.e. the first ``window - 1`` bars).
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.options import iv_rank
|
||||
>>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0])
|
||||
>>> iv_rank(iv, window=3)
|
||||
array([ nan, nan, 1. , 0. , 0.46666667])
|
||||
"""
|
||||
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan, dtype=np.float64)
|
||||
if window > n:
|
||||
return out
|
||||
|
||||
windows = sliding_window_view(arr, window_shape=window)
|
||||
lower = np.nanmin(windows, axis=1)
|
||||
upper = np.nanmax(windows, axis=1)
|
||||
current = arr[window - 1 :]
|
||||
spread = upper - lower
|
||||
out[window - 1 :] = np.where(spread == 0.0, 0.0, (current - lower) / spread)
|
||||
|
||||
return out
|
||||
def iv_percentile(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV percentile in Rust while preserving the legacy API."""
|
||||
try:
|
||||
arr = _to_f64(iv_series)
|
||||
except ValueError as err:
|
||||
raise FerroTAInputError(str(err)) from err
|
||||
if len(arr) == 0:
|
||||
raise FerroTAInputError("iv_series must not be empty.")
|
||||
if window < 1:
|
||||
raise FerroTAValueError(f"window must be >= 1, got {window}.")
|
||||
try:
|
||||
return np.asarray(_rust_iv_percentile(arr, int(window)), dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def iv_percentile(
|
||||
iv_series: ArrayLike,
|
||||
window: int = 252,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV percentile.
|
||||
|
||||
IV percentile measures the fraction of days over the look-back *window*
|
||||
for which IV was *at or below* today's level. Unlike IV rank (which only
|
||||
considers min/max), IV percentile uses the full distribution of values.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
iv_series : array-like
|
||||
1-D series of implied volatility values.
|
||||
window : int
|
||||
Look-back period in bars (default 252).
|
||||
|
||||
Returns
|
||||
-------
|
||||
ndarray of float64
|
||||
Rolling IV percentile in [0, 1]. NaN for bars before the window fills.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.options import iv_percentile
|
||||
>>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0])
|
||||
>>> iv_percentile(iv, window=3)
|
||||
array([ nan, nan, 1. , 0. , 0.33333333])
|
||||
"""
|
||||
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan, dtype=np.float64)
|
||||
if window > n:
|
||||
return out
|
||||
|
||||
windows = sliding_window_view(arr, window_shape=window)
|
||||
current = arr[window - 1 :, None]
|
||||
out[window - 1 :] = np.sum(windows <= current, axis=1, dtype=np.int64) / window
|
||||
|
||||
return out
|
||||
def iv_zscore(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV z-score in Rust while preserving the legacy API."""
|
||||
try:
|
||||
arr = _to_f64(iv_series)
|
||||
except ValueError as err:
|
||||
raise FerroTAInputError(str(err)) from err
|
||||
if len(arr) == 0:
|
||||
raise FerroTAInputError("iv_series must not be empty.")
|
||||
if window < 1:
|
||||
raise FerroTAValueError(f"window must be >= 1, got {window}.")
|
||||
try:
|
||||
return np.asarray(_rust_iv_zscore(arr, int(window)), dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def iv_zscore(
|
||||
iv_series: ArrayLike,
|
||||
window: int = 252,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV z-score.
|
||||
def black_scholes_price(
|
||||
spot: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
dividend_yield: ArrayLike | float = 0.0,
|
||||
) -> ScalarOrArray:
|
||||
"""Price options under Black-Scholes-Merton."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
arrays, scalar_mode = _broadcast_inputs(
|
||||
spot=spot,
|
||||
strike=strike,
|
||||
rate=rate,
|
||||
time_to_expiry=time_to_expiry,
|
||||
volatility=volatility,
|
||||
dividend_yield=dividend_yield,
|
||||
)
|
||||
try:
|
||||
if scalar_mode:
|
||||
return float(
|
||||
_rust_bsm_price(
|
||||
float(arrays["spot"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
float(arrays["volatility"][0]),
|
||||
option_type,
|
||||
float(arrays["dividend_yield"][0]),
|
||||
)
|
||||
)
|
||||
out = _rust_bsm_price_batch(
|
||||
arrays["spot"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
arrays["dividend_yield"],
|
||||
option_type,
|
||||
)
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
Measures how many standard deviations today's IV is above (positive) or
|
||||
below (negative) the rolling mean over *window* bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
iv_series : array-like
|
||||
1-D series of implied volatility values.
|
||||
window : int
|
||||
Look-back period in bars (default 252).
|
||||
def black_76_price(
|
||||
forward: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
) -> ScalarOrArray:
|
||||
"""Price options under Black-76."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
arrays, scalar_mode = _broadcast_inputs(
|
||||
forward=forward,
|
||||
strike=strike,
|
||||
rate=rate,
|
||||
time_to_expiry=time_to_expiry,
|
||||
volatility=volatility,
|
||||
)
|
||||
try:
|
||||
if scalar_mode:
|
||||
return float(
|
||||
_rust_black76_price(
|
||||
float(arrays["forward"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
float(arrays["volatility"][0]),
|
||||
option_type,
|
||||
)
|
||||
)
|
||||
out = _rust_black76_price_batch(
|
||||
arrays["forward"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
option_type,
|
||||
)
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
Returns
|
||||
-------
|
||||
ndarray of float64
|
||||
Rolling z-score. NaN during warm-up (first ``window - 1`` bars) and
|
||||
when the rolling standard deviation is zero.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.options import iv_zscore
|
||||
>>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0])
|
||||
>>> z = iv_zscore(iv, window=3)
|
||||
>>> z[2] # (30 - 25) / std([20, 25, 30])
|
||||
np.float64(1.2247...)
|
||||
"""
|
||||
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan, dtype=np.float64)
|
||||
if window > n:
|
||||
return out
|
||||
def option_price(
|
||||
underlying: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
model: str = "bsm",
|
||||
carry: ArrayLike | float = 0.0,
|
||||
) -> ScalarOrArray:
|
||||
"""Model-dispatched option price helper."""
|
||||
model = _validate_model(model)
|
||||
if model == "black76":
|
||||
return black_76_price(
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_type=option_type,
|
||||
)
|
||||
return black_scholes_price(
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_type=option_type,
|
||||
dividend_yield=carry,
|
||||
)
|
||||
|
||||
windows = sliding_window_view(arr, window_shape=window)
|
||||
mean = np.nanmean(windows, axis=1)
|
||||
std = np.nanstd(windows, axis=1, ddof=0)
|
||||
current = arr[window - 1 :]
|
||||
out[window - 1 :] = np.where(std == 0.0, np.nan, (current - mean) / std)
|
||||
|
||||
return out
|
||||
def greeks(
|
||||
underlying: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
model: str = "bsm",
|
||||
carry: ArrayLike | float = 0.0,
|
||||
) -> OptionGreeks:
|
||||
"""Return delta, gamma, vega, theta, and rho."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
model = _validate_model(model)
|
||||
arrays, scalar_mode = _broadcast_inputs(
|
||||
underlying=underlying,
|
||||
strike=strike,
|
||||
rate=rate,
|
||||
time_to_expiry=time_to_expiry,
|
||||
volatility=volatility,
|
||||
carry=carry,
|
||||
)
|
||||
try:
|
||||
if scalar_mode:
|
||||
delta, gamma, vega, theta, rho = _rust_option_greeks(
|
||||
float(arrays["underlying"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
float(arrays["volatility"][0]),
|
||||
option_type,
|
||||
model,
|
||||
float(arrays["carry"][0]),
|
||||
)
|
||||
return OptionGreeks(delta, gamma, vega, theta, rho)
|
||||
|
||||
delta, gamma, vega, theta, rho = _rust_option_greeks_batch(
|
||||
arrays["underlying"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
option_type,
|
||||
model,
|
||||
arrays["carry"],
|
||||
)
|
||||
return OptionGreeks(
|
||||
np.asarray(delta, dtype=np.float64),
|
||||
np.asarray(gamma, dtype=np.float64),
|
||||
np.asarray(vega, dtype=np.float64),
|
||||
np.asarray(theta, dtype=np.float64),
|
||||
np.asarray(rho, dtype=np.float64),
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def implied_volatility(
|
||||
price: ArrayLike | float,
|
||||
underlying: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
model: str = "bsm",
|
||||
carry: ArrayLike | float = 0.0,
|
||||
initial_guess: ArrayLike | float = 0.2,
|
||||
tolerance: float = 1e-8,
|
||||
max_iterations: int = 100,
|
||||
) -> ScalarOrArray:
|
||||
"""Invert option prices to implied volatility."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
model = _validate_model(model)
|
||||
arrays, scalar_mode = _broadcast_inputs(
|
||||
price=price,
|
||||
underlying=underlying,
|
||||
strike=strike,
|
||||
rate=rate,
|
||||
time_to_expiry=time_to_expiry,
|
||||
carry=carry,
|
||||
initial_guess=initial_guess,
|
||||
)
|
||||
try:
|
||||
if scalar_mode:
|
||||
return float(
|
||||
_rust_implied_volatility(
|
||||
float(arrays["price"][0]),
|
||||
float(arrays["underlying"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
option_type,
|
||||
model,
|
||||
float(arrays["carry"][0]),
|
||||
float(arrays["initial_guess"][0]),
|
||||
float(tolerance),
|
||||
int(max_iterations),
|
||||
)
|
||||
)
|
||||
out = _rust_implied_volatility_batch(
|
||||
arrays["price"],
|
||||
arrays["underlying"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
option_type,
|
||||
model,
|
||||
arrays["carry"],
|
||||
arrays["initial_guess"],
|
||||
float(tolerance),
|
||||
int(max_iterations),
|
||||
)
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def smile_metrics(
|
||||
strikes: ArrayLike,
|
||||
vols: ArrayLike,
|
||||
reference_price: float,
|
||||
time_to_expiry: float,
|
||||
*,
|
||||
model: str = "bsm",
|
||||
rate: float = 0.0,
|
||||
carry: float = 0.0,
|
||||
) -> SmileMetrics:
|
||||
"""Compute ATM IV, 25-delta RR/BF, skew slope, and convexity."""
|
||||
model = _validate_model(model)
|
||||
strikes_arr = _to_f64(strikes)
|
||||
vols_arr = _to_f64(vols)
|
||||
order = np.argsort(strikes_arr)
|
||||
strikes_arr = strikes_arr[order]
|
||||
vols_arr = vols_arr[order]
|
||||
try:
|
||||
atm_iv, rr25, bf25, slope, convexity = _rust_smile_metrics(
|
||||
strikes_arr,
|
||||
vols_arr,
|
||||
float(reference_price),
|
||||
float(time_to_expiry),
|
||||
model,
|
||||
float(rate),
|
||||
float(carry),
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return SmileMetrics(atm_iv, rr25, bf25, slope, convexity)
|
||||
|
||||
|
||||
def term_structure_slope(tenors: ArrayLike, atm_ivs: ArrayLike) -> float:
|
||||
"""Slope of ATM IV against tenor."""
|
||||
try:
|
||||
return float(_rust_term_structure_slope(_to_f64(tenors), _to_f64(atm_ivs)))
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def label_moneyness(
|
||||
strikes: ArrayLike,
|
||||
reference_price: float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
) -> NDArray[np.object_]:
|
||||
"""Label strikes as ``ITM``, ``ATM``, or ``OTM``."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
try:
|
||||
codes = np.asarray(
|
||||
_rust_moneyness_labels(
|
||||
_to_f64(strikes), float(reference_price), option_type
|
||||
),
|
||||
dtype=np.int8,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
mapping = np.array(["OTM", "ATM", "ITM"], dtype=object)
|
||||
return mapping[codes + 1]
|
||||
|
||||
|
||||
def _parse_selector_steps(selector: str) -> int:
|
||||
suffix = selector[3:]
|
||||
if suffix == "":
|
||||
return 1
|
||||
try:
|
||||
return int(suffix)
|
||||
except ValueError as err:
|
||||
raise FerroTAValueError(
|
||||
f"Could not parse strike selector '{selector}'. Expected forms like ATM, ITM1, OTM2."
|
||||
) from err
|
||||
|
||||
|
||||
def select_strike(
|
||||
strikes: ArrayLike,
|
||||
reference_price: float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
selector: str = "ATM",
|
||||
delta_target: float | None = None,
|
||||
volatilities: ArrayLike | None = None,
|
||||
time_to_expiry: float | None = None,
|
||||
model: str = "bsm",
|
||||
rate: float = 0.0,
|
||||
carry: float = 0.0,
|
||||
) -> float | None:
|
||||
"""Select a strike by ATM/ITM/OTM offset or delta target."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
model = _validate_model(model)
|
||||
strikes_arr = _to_f64(strikes)
|
||||
|
||||
if len(strikes_arr) == 0:
|
||||
raise FerroTAInputError("strikes must not be empty.")
|
||||
|
||||
selector_norm = selector.strip().upper()
|
||||
if delta_target is None and selector_norm.startswith("DELTA"):
|
||||
try:
|
||||
delta_target = float(selector_norm.replace("DELTA", ""))
|
||||
except ValueError as err:
|
||||
raise FerroTAValueError(
|
||||
f"Could not parse delta selector '{selector}'. Example: selector='DELTA0.25'."
|
||||
) from err
|
||||
|
||||
if delta_target is not None:
|
||||
if volatilities is None or time_to_expiry is None:
|
||||
raise FerroTAValueError(
|
||||
"Delta-based strike selection requires volatilities and time_to_expiry."
|
||||
)
|
||||
vols_arr = _to_f64(volatilities)
|
||||
if len(vols_arr) != len(strikes_arr):
|
||||
raise FerroTAInputError(
|
||||
"strikes and volatilities must have the same length."
|
||||
)
|
||||
order = np.argsort(strikes_arr)
|
||||
strikes_arr = strikes_arr[order]
|
||||
vols_arr = vols_arr[order]
|
||||
try:
|
||||
strike = _rust_select_strike_delta(
|
||||
strikes_arr,
|
||||
vols_arr,
|
||||
float(reference_price),
|
||||
float(time_to_expiry),
|
||||
float(delta_target),
|
||||
option_type,
|
||||
model,
|
||||
float(rate),
|
||||
float(carry),
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return None if strike is None else float(strike)
|
||||
|
||||
order = np.argsort(strikes_arr)
|
||||
sorted_strikes = strikes_arr[order]
|
||||
if selector_norm == "ATM":
|
||||
offset = 0
|
||||
elif selector_norm.startswith("ITM"):
|
||||
steps = _parse_selector_steps(selector_norm)
|
||||
offset = -steps if option_type == "call" else steps
|
||||
elif selector_norm.startswith("OTM"):
|
||||
steps = _parse_selector_steps(selector_norm)
|
||||
offset = steps if option_type == "call" else -steps
|
||||
else:
|
||||
raise FerroTAValueError(
|
||||
f"Unsupported selector '{selector}'. Use ATM, ITM<n>, OTM<n>, or DELTA<x>."
|
||||
)
|
||||
|
||||
try:
|
||||
strike = _rust_select_strike_offset(
|
||||
sorted_strikes, float(reference_price), int(offset)
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return None if strike is None else float(strike)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
ferro_ta.analysis.options_strategy — Typed strategy parameter schemas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import date
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
|
||||
__all__ = [
|
||||
"ExpirySelectorKind",
|
||||
"StrikeSelectorKind",
|
||||
"LegPreset",
|
||||
"RiskMode",
|
||||
"ExpirySelector",
|
||||
"StrikeSelector",
|
||||
"RiskControl",
|
||||
"SimulationLimits",
|
||||
"StrategyLeg",
|
||||
"DerivativesStrategy",
|
||||
"build_strategy_preset",
|
||||
]
|
||||
|
||||
|
||||
class ExpirySelectorKind(str, Enum):
|
||||
CURRENT_WEEK = "current_week"
|
||||
NEXT_WEEK = "next_week"
|
||||
CURRENT_MONTH = "current_month"
|
||||
NEXT_MONTH = "next_month"
|
||||
EXPLICIT_DATE = "explicit_date"
|
||||
|
||||
|
||||
class StrikeSelectorKind(str, Enum):
|
||||
ATM = "atm"
|
||||
ITM = "itm"
|
||||
OTM = "otm"
|
||||
DELTA = "delta"
|
||||
EXPLICIT = "explicit"
|
||||
|
||||
|
||||
class LegPreset(str, Enum):
|
||||
STRADDLE = "straddle"
|
||||
STRANGLE = "strangle"
|
||||
IRON_CONDOR = "iron_condor"
|
||||
BULL_CALL_SPREAD = "bull_call_spread"
|
||||
BEAR_PUT_SPREAD = "bear_put_spread"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class RiskMode(str, Enum):
|
||||
PER_LEG = "per_leg"
|
||||
COMBINED_PNL = "combined_pnl"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpirySelector:
|
||||
kind: ExpirySelectorKind | str
|
||||
explicit_date: date | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
kind = ExpirySelectorKind(self.kind)
|
||||
object.__setattr__(self, "kind", kind)
|
||||
if kind is ExpirySelectorKind.EXPLICIT_DATE and self.explicit_date is None:
|
||||
raise FerroTAValueError(
|
||||
"ExpirySelector(kind='explicit_date') requires explicit_date."
|
||||
)
|
||||
if (
|
||||
kind is not ExpirySelectorKind.EXPLICIT_DATE
|
||||
and self.explicit_date is not None
|
||||
):
|
||||
raise FerroTAValueError(
|
||||
"explicit_date is only valid when kind='explicit_date'."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrikeSelector:
|
||||
kind: StrikeSelectorKind | str
|
||||
steps: int = 0
|
||||
delta: float | None = None
|
||||
explicit_strike: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
kind = StrikeSelectorKind(self.kind)
|
||||
object.__setattr__(self, "kind", kind)
|
||||
if self.steps < 0:
|
||||
raise FerroTAValueError("steps must be >= 0.")
|
||||
if kind is StrikeSelectorKind.DELTA and self.delta is None:
|
||||
raise FerroTAValueError(
|
||||
"StrikeSelector(kind='delta') requires a delta target."
|
||||
)
|
||||
if self.delta is not None and not (0.0 < float(self.delta) < 1.0):
|
||||
raise FerroTAValueError("delta must be in the open interval (0, 1).")
|
||||
if kind is StrikeSelectorKind.EXPLICIT and self.explicit_strike is None:
|
||||
raise FerroTAValueError(
|
||||
"StrikeSelector(kind='explicit') requires explicit_strike."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiskControl:
|
||||
stop_loss_type: str | None = None
|
||||
stop_loss_value: float | None = None
|
||||
target_type: str | None = None
|
||||
target_value: float | None = None
|
||||
trailstop_type: str | None = None
|
||||
trailstop_value: float | None = None
|
||||
breakeven_trigger: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"stop_loss_value",
|
||||
"target_value",
|
||||
"trailstop_value",
|
||||
"breakeven_trigger",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and float(value) < 0.0:
|
||||
raise FerroTAValueError(f"{name} must be >= 0.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationLimits:
|
||||
max_premium_outlay: float | None = None
|
||||
max_loss_per_trade: float | None = None
|
||||
daily_max_drawdown: float | None = None
|
||||
cooldown_bars: int = 0
|
||||
reentry_allowed: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"max_premium_outlay",
|
||||
"max_loss_per_trade",
|
||||
"daily_max_drawdown",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and float(value) < 0.0:
|
||||
raise FerroTAValueError(f"{name} must be >= 0.")
|
||||
if self.cooldown_bars < 0:
|
||||
raise FerroTAValueError("cooldown_bars must be >= 0.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrategyLeg:
|
||||
underlying: str
|
||||
expiry_selector: ExpirySelector
|
||||
strike_selector: StrikeSelector
|
||||
option_type: str
|
||||
side: str = "long"
|
||||
quantity: int = 1
|
||||
instrument: str = "option"
|
||||
premium_limit: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.underlying.strip() == "":
|
||||
raise FerroTAInputError("underlying must not be empty.")
|
||||
if self.option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError("option_type must be 'call' or 'put'.")
|
||||
if self.side not in {"long", "short"}:
|
||||
raise FerroTAValueError("side must be 'long' or 'short'.")
|
||||
if self.instrument not in {"option", "future"}:
|
||||
raise FerroTAValueError("instrument must be 'option' or 'future'.")
|
||||
if self.quantity == 0:
|
||||
raise FerroTAValueError("quantity must be non-zero.")
|
||||
if self.premium_limit is not None and self.premium_limit < 0.0:
|
||||
raise FerroTAValueError("premium_limit must be >= 0.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DerivativesStrategy:
|
||||
name: str
|
||||
preset: LegPreset | str = LegPreset.CUSTOM
|
||||
legs: tuple[StrategyLeg, ...] = field(default_factory=tuple)
|
||||
risk_controls: RiskControl = field(default_factory=RiskControl)
|
||||
risk_mode: RiskMode | str = RiskMode.COMBINED_PNL
|
||||
commission: float = 0.0
|
||||
slippage: float = 0.0
|
||||
spread_assumption: float = 0.0
|
||||
limits: SimulationLimits = field(default_factory=SimulationLimits)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
preset = LegPreset(self.preset)
|
||||
risk_mode = RiskMode(self.risk_mode)
|
||||
object.__setattr__(self, "preset", preset)
|
||||
object.__setattr__(self, "risk_mode", risk_mode)
|
||||
if self.name.strip() == "":
|
||||
raise FerroTAInputError("name must not be empty.")
|
||||
if len(self.legs) == 0:
|
||||
raise FerroTAInputError("legs must contain at least one strategy leg.")
|
||||
for cost_name in ("commission", "slippage", "spread_assumption"):
|
||||
if float(getattr(self, cost_name)) < 0.0:
|
||||
raise FerroTAValueError(f"{cost_name} must be >= 0.")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def build_strategy_preset(
|
||||
preset: LegPreset | str,
|
||||
*,
|
||||
name: str,
|
||||
underlying: str,
|
||||
expiry_selector: ExpirySelector,
|
||||
base_strike_selector: StrikeSelector | None = None,
|
||||
risk_controls: RiskControl | None = None,
|
||||
risk_mode: RiskMode | str = RiskMode.COMBINED_PNL,
|
||||
commission: float = 0.0,
|
||||
slippage: float = 0.0,
|
||||
spread_assumption: float = 0.0,
|
||||
limits: SimulationLimits | None = None,
|
||||
) -> DerivativesStrategy:
|
||||
"""Build a common research preset using typed leg definitions."""
|
||||
preset = LegPreset(preset)
|
||||
risk_controls = risk_controls or RiskControl()
|
||||
limits = limits or SimulationLimits()
|
||||
atm = base_strike_selector or StrikeSelector(StrikeSelectorKind.ATM)
|
||||
|
||||
if preset is LegPreset.CUSTOM:
|
||||
raise FerroTAValueError(
|
||||
"build_strategy_preset does not construct CUSTOM presets."
|
||||
)
|
||||
|
||||
legs: tuple[StrategyLeg, ...]
|
||||
|
||||
if preset is LegPreset.STRADDLE:
|
||||
legs = (
|
||||
StrategyLeg(underlying, expiry_selector, atm, "call", "long"),
|
||||
StrategyLeg(underlying, expiry_selector, atm, "put", "long"),
|
||||
)
|
||||
elif preset is LegPreset.STRANGLE:
|
||||
legs = (
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"call",
|
||||
"long",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"put",
|
||||
"long",
|
||||
),
|
||||
)
|
||||
elif preset is LegPreset.BULL_CALL_SPREAD:
|
||||
legs = (
|
||||
StrategyLeg(underlying, expiry_selector, atm, "call", "long"),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"call",
|
||||
"short",
|
||||
),
|
||||
)
|
||||
elif preset is LegPreset.BEAR_PUT_SPREAD:
|
||||
legs = (
|
||||
StrategyLeg(underlying, expiry_selector, atm, "put", "long"),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"put",
|
||||
"short",
|
||||
),
|
||||
)
|
||||
elif preset is LegPreset.IRON_CONDOR:
|
||||
legs = (
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"put",
|
||||
"short",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=2),
|
||||
"put",
|
||||
"long",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"call",
|
||||
"short",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=2),
|
||||
"call",
|
||||
"long",
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise FerroTAValueError(f"Unsupported preset '{preset.value}'.")
|
||||
|
||||
return DerivativesStrategy(
|
||||
name=name,
|
||||
preset=preset,
|
||||
legs=legs,
|
||||
risk_controls=risk_controls,
|
||||
risk_mode=risk_mode,
|
||||
commission=commission,
|
||||
slippage=slippage,
|
||||
spread_assumption=spread_assumption,
|
||||
limits=limits,
|
||||
)
|
||||
@@ -29,7 +29,7 @@ Usage
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
@@ -120,7 +120,9 @@ def _extract_timeperiod(
|
||||
|
||||
|
||||
def compute_many(
|
||||
indicators: list[str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object]],
|
||||
indicators: Sequence[
|
||||
str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object]
|
||||
],
|
||||
*,
|
||||
close: ArrayLike,
|
||||
high: ArrayLike | None = None,
|
||||
@@ -138,7 +140,9 @@ def compute_many(
|
||||
close_arr = np.ascontiguousarray(close, dtype=np.float64)
|
||||
high_arr = None if high is None else np.ascontiguousarray(high, dtype=np.float64)
|
||||
low_arr = None if low is None else np.ascontiguousarray(low, dtype=np.float64)
|
||||
volume_arr = None if volume is None else np.ascontiguousarray(volume, dtype=np.float64)
|
||||
volume_arr = (
|
||||
None if volume is None else np.ascontiguousarray(volume, dtype=np.float64)
|
||||
)
|
||||
|
||||
normalized = [_normalize_indicator_spec(spec) for spec in indicators]
|
||||
results: list[object | None] = [None] * len(normalized)
|
||||
@@ -161,11 +165,7 @@ def compute_many(
|
||||
continue
|
||||
|
||||
hlc_period = _extract_timeperiod(name, kwargs, _HLC_FASTPATH_DEFAULTS)
|
||||
if (
|
||||
hlc_period is not None
|
||||
and high_arr is not None
|
||||
and low_arr is not None
|
||||
):
|
||||
if hlc_period is not None and high_arr is not None and low_arr is not None:
|
||||
hlc_indices.append(idx)
|
||||
hlc_names.append(name)
|
||||
hlc_periods.append(hlc_period)
|
||||
@@ -196,7 +196,9 @@ def compute_many(
|
||||
|
||||
if high_arr is not None and low_arr is not None:
|
||||
try:
|
||||
results[idx] = _registry_run(name, high_arr, low_arr, close_arr, **kwargs)
|
||||
results[idx] = _registry_run(
|
||||
name, high_arr, low_arr, close_arr, **kwargs
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user