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:
Pratik Bhadane
2026-03-24 02:41:50 +05:30
parent 2d5000262f
commit 602d675749
47 changed files with 4538 additions and 280 deletions
+3 -1
View File
@@ -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
+4 -1
View File
@@ -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"],
)
+230
View File
@@ -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
View File
@@ -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,
)
+11 -9
View File
@@ -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