chore: update ferro-ta version to 1.1.3 (#8)
- Bumped version numbers across Cargo.toml, Cargo.lock, pyproject.toml, and conda/meta.yaml to 1.1.3. - Added new features including American option pricing, digital options, extended Greeks, and historical volatility estimators. - Enhanced documentation and tests for new functionalities. - Updated CHANGELOG.md to reflect changes for version 1.1.3.
This commit is contained in:
@@ -47,6 +47,4 @@ jobs:
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: wasm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public --provenance
|
||||
|
||||
@@ -9,6 +9,58 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.3] — 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Stock instrument** (`instrument="stock"`) in `PayoffLeg` and `StrategyLeg`
|
||||
for modelling equity-holding strategies (Covered Call, Protective Put, Collar,
|
||||
Covered Strangle, Stock + Spread). Linear payoff identical to futures.
|
||||
Exposed in all three layers: Rust core, Python, and WASM.
|
||||
- **Extended Greeks** (`extended_greeks`): closed-form vanna (∂Δ/∂σ), volga
|
||||
(∂²V/∂σ²), charm (∂Δ/∂t), speed (∂Γ/∂S), and color (∂Γ/∂t) for BSM.
|
||||
Batch vectorisation supported.
|
||||
- **Digital options** (`digital_option_price`, `digital_option_greeks`):
|
||||
cash-or-nothing and asset-or-nothing pricing (BSM closed-form) plus
|
||||
numerical delta / gamma / vega. Scalar and batch variants.
|
||||
- **American options** (`american_option_price`, `early_exercise_premium`):
|
||||
Barone-Adesi-Whaley (1987) quadratic approximation — O(1) per evaluation.
|
||||
Scalar and batch variants.
|
||||
- **Historical volatility estimators** (all rolling, annualised): close-to-close,
|
||||
Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang. Yang-Zhang is
|
||||
~14× more efficient than close-to-close and handles overnight gaps.
|
||||
- **Volatility cone** (`vol_cone`): min / p25 / median / p75 / max distribution
|
||||
of realised vol across user-specified window lengths — contextualises current
|
||||
IV against historical norms.
|
||||
- **`strategy_value`**: pre-expiry BSM mid-price value of a multi-leg strategy
|
||||
over a spot grid (time value included), complementing `strategy_payoff`
|
||||
(expiry intrinsic).
|
||||
- **`expected_move`**: log-normal ±1σ expected price range over N days.
|
||||
- **`put_call_parity_deviation`**: detects stale quotes or data errors by
|
||||
computing C − P − (S·e^{−qT} − K·e^{−rT}).
|
||||
- All new analytics exposed to **WASM** (`wasm/src/lib.rs`):
|
||||
`extended_greeks`, `digital_price`, `digital_greeks`, `american_price`,
|
||||
`early_exercise_premium`, `close_to_close_vol`, `parkinson_vol`,
|
||||
`garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol`, `vol_cone`,
|
||||
`expected_move`, `put_call_parity_deviation`, `strategy_payoff_dense`,
|
||||
`aggregate_greeks_dense`, `strategy_value_grid`.
|
||||
- `aggregate_greeks_dense` added to `ferro_ta_core::options::payoff` (pure
|
||||
Rust, no PyO3/numpy dependency) enabling WASM reuse.
|
||||
- Comprehensive docstrings (NumPy style with Parameters / Returns / Notes /
|
||||
Examples) on all new Python functions.
|
||||
- Accuracy test suite `tests/unit/test_derivatives_accuracy.py` validates
|
||||
digital options, extended Greeks, American options, and vol estimators
|
||||
against scipy and analytical reference formulas.
|
||||
- scipy added to `dev` optional dependencies for reference testing.
|
||||
|
||||
### Changed
|
||||
|
||||
- `StrategyLeg.expiry_selector`, `StrategyLeg.strike_selector`, and
|
||||
`StrategyLeg.option_type` are now `Optional` (None allowed for stock legs).
|
||||
Existing option legs are unaffected.
|
||||
- `docs/derivatives-analytics.md` rewritten to cover all new features with
|
||||
runnable examples and an efficiency comparison table for vol estimators.
|
||||
|
||||
## [1.1.2] — 2026-04-01
|
||||
|
||||
### Changed
|
||||
|
||||
Generated
+2
-2
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ferro_ta_core",
|
||||
@@ -222,7 +222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"serde",
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
edition = "2021"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
license = "MIT"
|
||||
@@ -30,7 +30,7 @@ ndarray = "0.16"
|
||||
rayon = "1.10"
|
||||
log = "0.4"
|
||||
pyo3-log = "0.12"
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.2", features = ["serde"] }
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.3", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{% set name = "ferro-ta" %}
|
||||
{% set version = "1.1.2" %}
|
||||
{% set version = "1.1.3" %}
|
||||
|
||||
package:
|
||||
name: {{ name|lower }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
edition = "2021"
|
||||
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
|
||||
license = "MIT"
|
||||
|
||||
@@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ferro_ta_core = "1.1.2"
|
||||
ferro_ta_core = "1.1.3"
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
//! American option pricing via the Barone-Adesi-Whaley (1987) quadratic approximation.
|
||||
|
||||
use super::normal::cdf;
|
||||
use super::pricing::black_scholes_price;
|
||||
use super::OptionKind;
|
||||
|
||||
fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
|
||||
!spot.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| !volatility.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
|| volatility < 0.0
|
||||
}
|
||||
|
||||
/// Compute d1 for BSM given spot S* (used inside the Newton-Raphson loop).
|
||||
fn d1_fn(s: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64, volatility: f64) -> f64 {
|
||||
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
|
||||
((s / strike).ln() + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry)
|
||||
/ sigma_sqrt_t
|
||||
}
|
||||
|
||||
/// Find the critical spot price S* for American call early exercise using Newton-Raphson.
|
||||
///
|
||||
/// S* satisfies: C(S*) - (S* - K) = (S*/q2) * (1 - e^{-q*T} * N(d1(S*)))
|
||||
/// Rearranged as F(S*) = 0:
|
||||
/// F(x) = C(x) - (x - K) - (x/q2) * (1 - carry_discount * N(d1(x))) = 0
|
||||
fn find_critical_call(
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
q2: f64,
|
||||
) -> f64 {
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
|
||||
// Initial guess: S* ≈ K * q2 / (q2 - 1), clamped to be above strike
|
||||
let mut s = if q2 > 1.0 {
|
||||
strike * q2 / (q2 - 1.0)
|
||||
} else {
|
||||
// q2 <= 1 means the denominator is small/negative; fall back to a safe value
|
||||
strike * 2.0
|
||||
};
|
||||
// Ensure starting guess is positive
|
||||
if s <= 0.0 {
|
||||
s = strike * 1.5;
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
let c = black_scholes_price(
|
||||
s,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
OptionKind::Call,
|
||||
);
|
||||
let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility);
|
||||
let nd1 = cdf(d1);
|
||||
let lhs = c - (s - strike);
|
||||
let rhs = (s / q2) * (1.0 - carry_discount * nd1);
|
||||
let f = lhs - rhs;
|
||||
|
||||
// Derivative of F with respect to s:
|
||||
// dC/ds = e^{-q*T} * N(d1) (BSM delta for call)
|
||||
// d(s - K)/ds = 1
|
||||
// d(rhs)/ds = (1/q2) * (1 - carry_discount * N(d1))
|
||||
// + (s/q2) * (-carry_discount * phi(d1) / (s * vol * sqrt(T)))
|
||||
// = (1/q2) * (1 - carry_discount * N(d1)) - carry_discount * phi(d1) / (q2 * vol * sqrt(T))
|
||||
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
|
||||
let phi_d1 = super::normal::pdf(d1);
|
||||
let d_lhs_ds = carry_discount * nd1 - 1.0;
|
||||
let d_rhs_ds = (1.0 / q2) * (1.0 - carry_discount * nd1)
|
||||
- carry_discount * phi_d1 / (q2 * sigma_sqrt_t);
|
||||
let df = d_lhs_ds - d_rhs_ds;
|
||||
|
||||
if df.abs() < 1e-14 {
|
||||
break;
|
||||
}
|
||||
let step = f / df;
|
||||
s -= step;
|
||||
// Keep s positive
|
||||
if s <= 0.0 {
|
||||
s = strike * 0.1;
|
||||
}
|
||||
if step.abs() < 1e-8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Find the critical spot price S** for American put early exercise using Newton-Raphson.
|
||||
///
|
||||
/// S** satisfies: P(S**) - (K - S**) = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**)))
|
||||
/// F(x) = P(x) - (K - x) + (x/q1) * (1 - carry_discount * N(-d1(x))) = 0
|
||||
fn find_critical_put(
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
q1: f64,
|
||||
) -> f64 {
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
|
||||
// Initial guess for put: S** ≈ K * q1 / (q1 - 1)
|
||||
// q1 is negative, so q1 - 1 < 0, and the guess should be below strike.
|
||||
let mut s = if (q1 - 1.0).abs() > 1e-10 {
|
||||
strike * q1 / (q1 - 1.0)
|
||||
} else {
|
||||
strike * 0.5
|
||||
};
|
||||
if s <= 0.0 || s >= strike {
|
||||
s = strike * 0.5;
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
let p = black_scholes_price(
|
||||
s,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
OptionKind::Put,
|
||||
);
|
||||
let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility);
|
||||
let n_neg_d1 = cdf(-d1);
|
||||
let lhs = p - (strike - s);
|
||||
// rhs = -(s/q1) * (1 - carry_discount * N(-d1))
|
||||
let rhs = -(s / q1) * (1.0 - carry_discount * n_neg_d1);
|
||||
let f = lhs - rhs;
|
||||
|
||||
// Derivative:
|
||||
// dP/ds = -e^{-q*T} * N(-d1) (BSM delta for put = e^{-q*T}*(N(d1)-1))
|
||||
// d(K - s)/ds = -1 so d(lhs)/ds = dP/ds - (-1) = dP/ds + 1
|
||||
// d(rhs)/ds = -(1/q1)*(1 - carry_discount*N(-d1))
|
||||
// + -(s/q1)*carry_discount*phi(d1)/(s*vol*sqrt(T)) [since d(N(-d1))/ds = -phi(d1)*dd1/ds]
|
||||
// = -(1/q1)*(1 - carry_discount*N(-d1))
|
||||
// - carry_discount*phi(d1)/(q1*vol*sqrt(T))
|
||||
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
|
||||
let phi_d1 = super::normal::pdf(d1);
|
||||
let d_lhs_ds = -carry_discount * n_neg_d1 + 1.0;
|
||||
let d_rhs_ds = -(1.0 / q1) * (1.0 - carry_discount * n_neg_d1)
|
||||
- carry_discount * phi_d1 / (q1 * sigma_sqrt_t);
|
||||
let df = d_lhs_ds - d_rhs_ds;
|
||||
|
||||
if df.abs() < 1e-14 {
|
||||
break;
|
||||
}
|
||||
let step = f / df;
|
||||
s -= step;
|
||||
if s <= 0.0 {
|
||||
s = strike * 0.01;
|
||||
}
|
||||
if s >= strike {
|
||||
s = strike * 0.99;
|
||||
}
|
||||
if step.abs() < 1e-8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// American option price using the Barone-Adesi-Whaley (1987) quadratic approximation.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `spot`: current underlying price
|
||||
/// - `strike`: option strike price
|
||||
/// - `rate`: risk-free rate (annualized, decimal)
|
||||
/// - `carry`: continuous dividend yield / carry rate
|
||||
/// - `time_to_expiry`: time to expiry in years
|
||||
/// - `volatility`: implied vol (annualized, decimal)
|
||||
/// - `kind`: call or put
|
||||
pub fn american_price_baw(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> f64 {
|
||||
if invalid_inputs(spot, strike, time_to_expiry, volatility)
|
||||
|| !rate.is_finite()
|
||||
|| !carry.is_finite()
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
|
||||
// At expiry: immediate exercise value
|
||||
if time_to_expiry == 0.0 {
|
||||
return match kind {
|
||||
OptionKind::Call => (spot - strike).max(0.0),
|
||||
OptionKind::Put => (strike - spot).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
// At zero vol: deterministic — exercise if ITM
|
||||
if volatility == 0.0 {
|
||||
return match kind {
|
||||
OptionKind::Call => (spot - strike).max(0.0),
|
||||
OptionKind::Put => (strike - spot).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind);
|
||||
|
||||
match kind {
|
||||
OptionKind::Call => {
|
||||
// No early exercise premium when there are no dividends (carry == 0 means q==0
|
||||
// in BSM parameterisation where carry = q).
|
||||
if carry <= 0.0 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let sigma2 = volatility * volatility;
|
||||
let m = 2.0 * rate / sigma2;
|
||||
let n = 2.0 * (rate - carry) / sigma2;
|
||||
let h = 1.0 - (-rate * time_to_expiry).exp();
|
||||
|
||||
if h.abs() < 1e-14 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h;
|
||||
if discriminant < 0.0 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let q2 = (-(n - 1.0) + discriminant.sqrt()) / 2.0;
|
||||
|
||||
// Find critical price S*
|
||||
let s_star = find_critical_call(strike, rate, carry, time_to_expiry, volatility, q2);
|
||||
|
||||
if s_star <= strike {
|
||||
// Degenerate critical price; fall back to European
|
||||
return european;
|
||||
}
|
||||
|
||||
// A2 = (S*/q2) * (1 - e^{-q*T} * N(d1(S*)))
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
let d1_star = d1_fn(s_star, strike, rate, carry, time_to_expiry, volatility);
|
||||
let a2 = (s_star / q2) * (1.0 - carry_discount * cdf(d1_star));
|
||||
|
||||
if spot >= s_star {
|
||||
// Immediate exercise is optimal
|
||||
(spot - strike).max(0.0)
|
||||
} else {
|
||||
(european + a2 * (spot / s_star).powf(q2)).max(european)
|
||||
}
|
||||
}
|
||||
|
||||
OptionKind::Put => {
|
||||
// No early exercise when rate == 0 (no time value of money)
|
||||
if rate <= 0.0 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let sigma2 = volatility * volatility;
|
||||
let m = 2.0 * rate / sigma2;
|
||||
let n = 2.0 * (rate - carry) / sigma2;
|
||||
let h = 1.0 - (-rate * time_to_expiry).exp();
|
||||
|
||||
if h.abs() < 1e-14 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h;
|
||||
if discriminant < 0.0 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let q1 = (-(n - 1.0) - discriminant.sqrt()) / 2.0;
|
||||
|
||||
// Find critical price S**
|
||||
let s_star_star =
|
||||
find_critical_put(strike, rate, carry, time_to_expiry, volatility, q1);
|
||||
|
||||
if s_star_star <= 0.0 || s_star_star >= strike {
|
||||
return european;
|
||||
}
|
||||
|
||||
// A1 = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**)))
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
let d1_star = d1_fn(s_star_star, strike, rate, carry, time_to_expiry, volatility);
|
||||
let a1 = -(s_star_star / q1) * (1.0 - carry_discount * cdf(-d1_star));
|
||||
|
||||
if spot <= s_star_star {
|
||||
// Immediate exercise is optimal
|
||||
(strike - spot).max(0.0)
|
||||
} else {
|
||||
(european + a1 * (spot / s_star_star).powf(q1)).max(european)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Early exercise premium = american_price - european_bsm_price.
|
||||
///
|
||||
/// Always non-negative for valid inputs.
|
||||
pub fn early_exercise_premium(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> f64 {
|
||||
let american = american_price_baw(spot, strike, rate, carry, time_to_expiry, volatility, kind);
|
||||
let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind);
|
||||
if american.is_nan() || european.is_nan() {
|
||||
return f64::NAN;
|
||||
}
|
||||
(american - european).max(0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::options::OptionKind;
|
||||
|
||||
#[test]
|
||||
fn american_call_gte_european_call() {
|
||||
let european = crate::options::pricing::black_scholes_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.03,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
);
|
||||
let american = american_price_baw(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(american >= european - 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_put_gte_european_put() {
|
||||
let european = crate::options::pricing::black_scholes_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Put,
|
||||
);
|
||||
let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put);
|
||||
assert!(american >= european - 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_exercise_premium_nonneg() {
|
||||
let prem = early_exercise_premium(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(prem >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_call_no_dividends_equals_european() {
|
||||
// With no dividends (carry == 0), no early exercise is optimal for calls
|
||||
let european = crate::options::pricing::black_scholes_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
);
|
||||
let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
assert!((american - european).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_price_returns_nan_for_invalid() {
|
||||
let price = american_price_baw(-1.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(price.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_price_at_expiry_is_intrinsic() {
|
||||
let call = american_price_baw(110.0, 100.0, 0.05, 0.03, 0.0, 0.2, OptionKind::Call);
|
||||
assert!((call - 10.0).abs() < 1e-10);
|
||||
let put = american_price_baw(90.0, 100.0, 0.05, 0.0, 0.0, 0.2, OptionKind::Put);
|
||||
assert!((put - 10.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_put_itm_has_positive_premium() {
|
||||
// Deep ITM put with high rate should have meaningful early exercise premium
|
||||
let prem = early_exercise_premium(80.0, 100.0, 0.10, 0.0, 1.0, 0.2, OptionKind::Put);
|
||||
assert!(prem >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_prices_are_finite_for_valid_inputs() {
|
||||
let call = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Call);
|
||||
let put = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Put);
|
||||
assert!(call.is_finite());
|
||||
assert!(put.is_finite());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Digital (binary) option pricing.
|
||||
|
||||
use super::normal::cdf;
|
||||
use super::OptionKind;
|
||||
|
||||
/// Type of digital option payoff.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DigitalKind {
|
||||
/// Pays 1 unit of cash if option expires in the money.
|
||||
CashOrNothing,
|
||||
/// Pays the underlying asset if option expires in the money.
|
||||
AssetOrNothing,
|
||||
}
|
||||
|
||||
fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
|
||||
!spot.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| !volatility.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
|| volatility < 0.0
|
||||
}
|
||||
|
||||
/// Price a digital (binary) option under BSM.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `spot`: current underlying price
|
||||
/// - `strike`: option strike price
|
||||
/// - `rate`: risk-free rate (annualized, decimal)
|
||||
/// - `carry`: continuous dividend yield / carry rate
|
||||
/// - `time_to_expiry`: time to expiry in years
|
||||
/// - `volatility`: implied vol (annualized, decimal)
|
||||
/// - `option_kind`: call or put
|
||||
/// - `digital_kind`: cash-or-nothing or asset-or-nothing
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn digital_price(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_kind: OptionKind,
|
||||
digital_kind: DigitalKind,
|
||||
) -> f64 {
|
||||
if invalid_inputs(spot, strike, time_to_expiry, volatility)
|
||||
|| !rate.is_finite()
|
||||
|| !carry.is_finite()
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
|
||||
// At expiry: pay intrinsic based on ITM status
|
||||
if time_to_expiry == 0.0 {
|
||||
let itm = match option_kind {
|
||||
OptionKind::Call => spot > strike,
|
||||
OptionKind::Put => spot < strike,
|
||||
};
|
||||
return if itm {
|
||||
match digital_kind {
|
||||
DigitalKind::CashOrNothing => 1.0,
|
||||
DigitalKind::AssetOrNothing => spot,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
|
||||
// At zero vol: deterministic payoff
|
||||
if volatility == 0.0 {
|
||||
let forward = spot * (carry_discount / discount); // S * e^{(r-q)*T} equivalent: S*e^{-q*T}/e^{-r*T}
|
||||
// forward = S * e^{(r-q)*T}; ITM if forward > K for call
|
||||
let itm = match option_kind {
|
||||
OptionKind::Call => spot * carry_discount > strike * discount,
|
||||
OptionKind::Put => spot * carry_discount < strike * discount,
|
||||
};
|
||||
let _ = forward; // suppress unused warning
|
||||
return if itm {
|
||||
match digital_kind {
|
||||
DigitalKind::CashOrNothing => discount,
|
||||
DigitalKind::AssetOrNothing => spot * carry_discount,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let d1 = ((spot / strike).ln()
|
||||
+ (rate - carry + 0.5 * volatility * volatility) * time_to_expiry)
|
||||
/ sigma_sqrt_t;
|
||||
let d2 = d1 - sigma_sqrt_t;
|
||||
|
||||
match digital_kind {
|
||||
DigitalKind::CashOrNothing => match option_kind {
|
||||
OptionKind::Call => discount * cdf(d2),
|
||||
OptionKind::Put => discount * cdf(-d2),
|
||||
},
|
||||
DigitalKind::AssetOrNothing => match option_kind {
|
||||
OptionKind::Call => spot * carry_discount * cdf(d1),
|
||||
OptionKind::Put => spot * carry_discount * cdf(-d1),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute numerical delta, gamma, and vega for a digital option.
|
||||
///
|
||||
/// Uses central finite differences:
|
||||
/// - delta/gamma: bump spot by ε = spot * 1e-3
|
||||
/// - vega: bump volatility by 1e-3
|
||||
///
|
||||
/// Returns `(delta, gamma, vega)`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn digital_greeks(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_kind: OptionKind,
|
||||
digital_kind: DigitalKind,
|
||||
) -> (f64, f64, f64) {
|
||||
let eps = spot * 1e-3;
|
||||
if eps <= 0.0 {
|
||||
return (f64::NAN, f64::NAN, f64::NAN);
|
||||
}
|
||||
|
||||
let price_mid = digital_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
let price_up = digital_price(
|
||||
spot + eps,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
let price_dn = digital_price(
|
||||
spot - eps,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
|
||||
let delta = (price_up - price_dn) / (2.0 * eps);
|
||||
let gamma = (price_up - 2.0 * price_mid + price_dn) / (eps * eps);
|
||||
|
||||
let vol_bump = 1e-3;
|
||||
let vega = if volatility + vol_bump > 0.0 && volatility - vol_bump > 0.0 {
|
||||
let price_vup = digital_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility + vol_bump,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
let price_vdn = digital_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility - vol_bump,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
(price_vup - price_vdn) / (2.0 * vol_bump)
|
||||
} else {
|
||||
// vol too close to zero; one-sided bump
|
||||
let price_vup = digital_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility + vol_bump,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
(price_vup - price_mid) / vol_bump
|
||||
};
|
||||
|
||||
(delta, gamma, vega)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::options::OptionKind;
|
||||
|
||||
#[test]
|
||||
fn cash_or_nothing_call_atm() {
|
||||
// ATM cash-or-nothing call: price = e^{-rT} * N(d2)
|
||||
// At S=K=100, r=0.05, q=0, T=1, σ=0.2:
|
||||
// d1 = (0 + 0.07) / 0.2 = 0.35, d2 = 0.15 → N(0.15) ≈ 0.5596
|
||||
// price ≈ e^{-0.05} * 0.5596 ≈ 0.532
|
||||
let price = digital_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!(
|
||||
price > 0.0 && price < 1.0,
|
||||
"price should be between 0 and 1"
|
||||
);
|
||||
assert!((price - 0.532).abs() < 0.01, "price ≈ 0.532, got {price}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_or_nothing_call_at_zero_vol() {
|
||||
// At zero vol, ITM asset-or-nothing call should equal S * e^{-q*T}
|
||||
let price = digital_price(
|
||||
110.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
OptionKind::Call,
|
||||
DigitalKind::AssetOrNothing,
|
||||
);
|
||||
assert!((price - 110.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_price_returns_nan_for_invalid() {
|
||||
let price = digital_price(
|
||||
-1.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!(price.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_or_nothing_put_call_parity() {
|
||||
// Cash-or-nothing call + cash-or-nothing put = e^{-rT}
|
||||
let call = digital_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.02,
|
||||
1.0,
|
||||
0.25,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
let put = digital_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.02,
|
||||
1.0,
|
||||
0.25,
|
||||
OptionKind::Put,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
let discount = (-0.05_f64).exp();
|
||||
assert!((call + put - discount).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_or_nothing_put_call_parity() {
|
||||
// Asset-or-nothing call + asset-or-nothing put = S * e^{-q*T}
|
||||
let s = 100.0_f64;
|
||||
let q = 0.02_f64;
|
||||
let call = digital_price(
|
||||
s,
|
||||
100.0,
|
||||
0.05,
|
||||
q,
|
||||
1.0,
|
||||
0.25,
|
||||
OptionKind::Call,
|
||||
DigitalKind::AssetOrNothing,
|
||||
);
|
||||
let put = digital_price(
|
||||
s,
|
||||
100.0,
|
||||
0.05,
|
||||
q,
|
||||
1.0,
|
||||
0.25,
|
||||
OptionKind::Put,
|
||||
DigitalKind::AssetOrNothing,
|
||||
);
|
||||
let expected = s * (-q).exp();
|
||||
assert!((call + put - expected).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_greeks_are_finite_for_valid_inputs() {
|
||||
let (delta, gamma, vega) = digital_greeks(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!(delta.is_finite());
|
||||
assert!(gamma.is_finite());
|
||||
assert!(vega.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_at_expiry_itm_returns_intrinsic() {
|
||||
let price = digital_price(
|
||||
110.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
0.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!((price - 1.0).abs() < 1e-10);
|
||||
let price2 = digital_price(
|
||||
110.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
0.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::AssetOrNothing,
|
||||
);
|
||||
assert!((price2 - 110.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_at_expiry_otm_returns_zero() {
|
||||
let price = digital_price(
|
||||
90.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
0.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!((price - 0.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use super::normal::{cdf, pdf};
|
||||
use super::pricing::{black_76_price, black_scholes_price};
|
||||
use super::{Greeks, OptionEvaluation, OptionKind, PricingModel};
|
||||
use super::{ExtendedGreeks, Greeks, OptionEvaluation, OptionKind, PricingModel};
|
||||
|
||||
fn bs_inputs_valid(
|
||||
underlying: f64,
|
||||
@@ -203,9 +203,94 @@ pub fn model_theta(input: OptionEvaluation) -> f64 {
|
||||
})
|
||||
}
|
||||
|
||||
/// Extended Greeks under Black-Scholes-Merton (closed-form).
|
||||
///
|
||||
/// All inputs must be positive finite; returns NaN fields for invalid inputs.
|
||||
pub fn black_scholes_extended_greeks(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
dividend_yield: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
_kind: OptionKind,
|
||||
) -> ExtendedGreeks {
|
||||
if !bs_inputs_valid(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
dividend_yield,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
) {
|
||||
return ExtendedGreeks {
|
||||
vanna: f64::NAN,
|
||||
volga: f64::NAN,
|
||||
charm: f64::NAN,
|
||||
speed: f64::NAN,
|
||||
color: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let carry_discount = (-dividend_yield * time_to_expiry).exp();
|
||||
let d1 = ((spot / strike).ln()
|
||||
+ (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry)
|
||||
/ sigma_sqrt_t;
|
||||
let d2 = d1 - sigma_sqrt_t;
|
||||
let pdf_d1 = pdf(d1);
|
||||
|
||||
let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t);
|
||||
|
||||
let vanna = -carry_discount * pdf_d1 * d2 / volatility;
|
||||
let volga = spot * carry_discount * pdf_d1 * sqrt_t * d1 * d2 / volatility;
|
||||
let charm = -carry_discount
|
||||
* pdf_d1
|
||||
* (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
|
||||
/ (2.0 * time_to_expiry * sigma_sqrt_t);
|
||||
let speed = -gamma / spot * (d1 / sigma_sqrt_t + 1.0);
|
||||
let color = -carry_discount * pdf_d1 / (2.0 * spot * time_to_expiry * sigma_sqrt_t)
|
||||
* (2.0 * (rate - dividend_yield) * time_to_expiry + 1.0
|
||||
- d1 * (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
|
||||
/ sigma_sqrt_t);
|
||||
|
||||
ExtendedGreeks {
|
||||
vanna,
|
||||
volga,
|
||||
charm,
|
||||
speed,
|
||||
color,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-dispatched extended Greeks.
|
||||
/// Only BSM is supported with closed-form; Black-76 is not yet supported (returns NaN).
|
||||
pub fn model_extended_greeks(input: OptionEvaluation) -> ExtendedGreeks {
|
||||
let contract = input.contract;
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => black_scholes_extended_greeks(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.carry,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
PricingModel::Black76 => ExtendedGreeks {
|
||||
vanna: f64::NAN,
|
||||
volga: f64::NAN,
|
||||
charm: f64::NAN,
|
||||
speed: f64::NAN,
|
||||
color: f64::NAN,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{black_76_greeks, black_scholes_greeks};
|
||||
use super::{black_76_greeks, black_scholes_extended_greeks, black_scholes_greeks};
|
||||
use crate::options::OptionKind;
|
||||
|
||||
#[test]
|
||||
@@ -227,4 +312,16 @@ mod tests {
|
||||
assert!(g.theta.is_finite());
|
||||
assert!(g.rho.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_greeks_finite_for_valid_inputs() {
|
||||
let eg = black_scholes_extended_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(eg.vanna.is_finite());
|
||||
assert!(eg.volga.is_finite());
|
||||
assert!(eg.charm.is_finite());
|
||||
assert!(eg.speed.is_finite());
|
||||
assert!(eg.color.is_finite());
|
||||
// Volga must be positive (convex in vol)
|
||||
assert!(eg.volga >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
//! IV-series helpers, and smile/chain utilities. The public API is scalar-first
|
||||
//! and is used by the PyO3 bridge to build vectorized batch functions.
|
||||
|
||||
pub mod american;
|
||||
pub mod chain;
|
||||
pub mod digital;
|
||||
pub mod greeks;
|
||||
pub mod iv;
|
||||
pub mod normal;
|
||||
pub mod payoff;
|
||||
pub mod pricing;
|
||||
pub mod realized_vol;
|
||||
pub mod surface;
|
||||
|
||||
/// Option side.
|
||||
@@ -49,6 +53,16 @@ pub struct Greeks {
|
||||
pub rho: f64,
|
||||
}
|
||||
|
||||
/// Second-order and cross Greeks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ExtendedGreeks {
|
||||
pub vanna: f64, // ∂Δ/∂σ
|
||||
pub volga: f64, // ∂²V/∂σ² (vomma)
|
||||
pub charm: f64, // ∂Δ/∂t
|
||||
pub speed: f64, // ∂Γ/∂S
|
||||
pub color: f64, // ∂Γ/∂t
|
||||
}
|
||||
|
||||
/// Shared contract fields for model-based option analytics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OptionContract {
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Pure-Rust (no PyO3, no numpy) strategy payoff and value functions.
|
||||
//!
|
||||
//! NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;`
|
||||
//! for this module to be reachable from the rest of the crate and from the PyO3 bridge.
|
||||
|
||||
use super::pricing::black_scholes_price;
|
||||
use super::OptionKind;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Instrument codes: 0=option, 1=future, 2=stock.
|
||||
const INSTRUMENT_OPTION: i64 = 0;
|
||||
const INSTRUMENT_FUTURE: i64 = 1;
|
||||
const INSTRUMENT_STOCK: i64 = 2;
|
||||
|
||||
/// Side sign from encoded value: 1=long (+1.0), -1=short (-1.0).
|
||||
#[inline]
|
||||
fn side_sign(v: i64) -> f64 {
|
||||
if v == 1 {
|
||||
1.0
|
||||
} else if v == -1 {
|
||||
-1.0
|
||||
} else {
|
||||
f64::NAN
|
||||
}
|
||||
}
|
||||
|
||||
/// Option kind from encoded value: 1=call, -1=put.
|
||||
#[inline]
|
||||
fn option_kind(v: i64) -> Option<OptionKind> {
|
||||
match v {
|
||||
1 => Some(OptionKind::Call),
|
||||
-1 => Some(OptionKind::Put),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// strategy_payoff_dense
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate strategy payoff over a spot grid.
|
||||
///
|
||||
/// Parameters (all slices of length n_legs):
|
||||
/// - `instruments`: 0=option, 1=future, 2=stock
|
||||
/// - `sides`: 1=long, -1=short
|
||||
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
|
||||
/// - `strikes`: strike for options
|
||||
/// - `premiums`: premium for options
|
||||
/// - `entry_prices`: entry price for futures/stocks
|
||||
/// - `quantities`, `multipliers`: applied to all instruments
|
||||
///
|
||||
/// Returns a Vec<f64> of length spot_grid.len() with aggregate P&L per spot point.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_payoff_dense(
|
||||
spot_grid: &[f64],
|
||||
instruments: &[i64],
|
||||
sides: &[i64],
|
||||
option_types: &[i64],
|
||||
strikes: &[f64],
|
||||
premiums: &[f64],
|
||||
entry_prices: &[f64],
|
||||
quantities: &[f64],
|
||||
multipliers: &[f64],
|
||||
) -> Vec<f64> {
|
||||
let n_legs = instruments.len();
|
||||
// Validate that all leg slices are the same length; return zeros if not.
|
||||
if sides.len() != n_legs
|
||||
|| option_types.len() != n_legs
|
||||
|| strikes.len() != n_legs
|
||||
|| premiums.len() != n_legs
|
||||
|| entry_prices.len() != n_legs
|
||||
|| quantities.len() != n_legs
|
||||
|| multipliers.len() != n_legs
|
||||
{
|
||||
return vec![0.0; spot_grid.len()];
|
||||
}
|
||||
|
||||
let mut total = vec![0.0_f64; spot_grid.len()];
|
||||
|
||||
for leg_idx in 0..n_legs {
|
||||
let inst = instruments[leg_idx];
|
||||
let sign = side_sign(sides[leg_idx]);
|
||||
if sign.is_nan() {
|
||||
// Invalid side — skip leg (treat as zero contribution).
|
||||
continue;
|
||||
}
|
||||
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
|
||||
|
||||
match inst {
|
||||
INSTRUMENT_OPTION => {
|
||||
let kind = match option_kind(option_types[leg_idx]) {
|
||||
Some(k) => k,
|
||||
None => continue, // Invalid option type — skip.
|
||||
};
|
||||
let k = strikes[leg_idx];
|
||||
let p = premiums[leg_idx];
|
||||
for (i, &s) in spot_grid.iter().enumerate() {
|
||||
let intrinsic = match kind {
|
||||
OptionKind::Call => (s - k).max(0.0),
|
||||
OptionKind::Put => (k - s).max(0.0),
|
||||
};
|
||||
total[i] += leg_scale * (intrinsic - p);
|
||||
}
|
||||
}
|
||||
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
|
||||
let e = entry_prices[leg_idx];
|
||||
for (i, &s) in spot_grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - e);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unknown instrument code — skip leg (NaN would propagate; zeros are safer).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// strategy_value_dense / strategy_value_grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Current BSM value of a strategy at a single spot (pre-expiry).
|
||||
///
|
||||
/// Unlike `strategy_payoff_dense`, this uses BSM pricing for option legs rather
|
||||
/// than intrinsic value.
|
||||
///
|
||||
/// Parameters: same as `strategy_payoff_dense` plus per-leg BSM inputs:
|
||||
/// - `time_to_expiries`: TTE for each option leg (ignored for futures/stocks)
|
||||
/// - `volatilities`: vol for each option leg (ignored for futures/stocks)
|
||||
/// - `rates`: risk-free rate for each leg
|
||||
/// - `carries`: carry/dividend yield for each option leg
|
||||
///
|
||||
/// Returns a scalar f64 (strategy P&L at the given spot).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_value_dense(
|
||||
spot: f64,
|
||||
instruments: &[i64],
|
||||
sides: &[i64],
|
||||
option_types: &[i64],
|
||||
strikes: &[f64],
|
||||
premiums: &[f64],
|
||||
entry_prices: &[f64],
|
||||
quantities: &[f64],
|
||||
multipliers: &[f64],
|
||||
time_to_expiries: &[f64],
|
||||
volatilities: &[f64],
|
||||
rates: &[f64],
|
||||
carries: &[f64],
|
||||
) -> f64 {
|
||||
let n_legs = instruments.len();
|
||||
// Validate that all leg slices are the same length; return NaN if not.
|
||||
if sides.len() != n_legs
|
||||
|| option_types.len() != n_legs
|
||||
|| strikes.len() != n_legs
|
||||
|| premiums.len() != n_legs
|
||||
|| entry_prices.len() != n_legs
|
||||
|| quantities.len() != n_legs
|
||||
|| multipliers.len() != n_legs
|
||||
|| time_to_expiries.len() != n_legs
|
||||
|| volatilities.len() != n_legs
|
||||
|| rates.len() != n_legs
|
||||
|| carries.len() != n_legs
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
|
||||
let mut total = 0.0_f64;
|
||||
|
||||
for leg_idx in 0..n_legs {
|
||||
let inst = instruments[leg_idx];
|
||||
let sign = side_sign(sides[leg_idx]);
|
||||
if sign.is_nan() {
|
||||
continue;
|
||||
}
|
||||
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
|
||||
|
||||
match inst {
|
||||
INSTRUMENT_OPTION => {
|
||||
let kind = match option_kind(option_types[leg_idx]) {
|
||||
Some(k) => k,
|
||||
None => continue,
|
||||
};
|
||||
let bsm = black_scholes_price(
|
||||
spot,
|
||||
strikes[leg_idx],
|
||||
rates[leg_idx],
|
||||
carries[leg_idx],
|
||||
time_to_expiries[leg_idx],
|
||||
volatilities[leg_idx],
|
||||
kind,
|
||||
);
|
||||
total += leg_scale * (bsm - premiums[leg_idx]);
|
||||
}
|
||||
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
|
||||
total += leg_scale * (spot - entry_prices[leg_idx]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// aggregate_greeks_dense
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate BSM Greeks for a multi-leg strategy at a single spot.
|
||||
///
|
||||
/// Parameters (all slices of length n_legs):
|
||||
/// - `instruments`: 0=option, 1=future, 2=stock
|
||||
/// - `sides`: 1=long, -1=short
|
||||
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
|
||||
/// - `strikes`: strike price for option legs
|
||||
/// - `volatilities`: implied vol for option legs
|
||||
/// - `time_to_expiries`: TTE in years for option legs
|
||||
/// - `rates`: risk-free rate for each leg
|
||||
/// - `carries`: carry/dividend yield for option legs
|
||||
/// - `quantities`, `multipliers`: applied to all instruments
|
||||
///
|
||||
/// Returns `(delta, gamma, vega, theta, rho)` aggregate across all legs.
|
||||
/// Future/stock legs contribute `leg_scale` to delta only (all other Greeks = 0).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn aggregate_greeks_dense(
|
||||
spot: f64,
|
||||
instruments: &[i64],
|
||||
sides: &[i64],
|
||||
option_types: &[i64],
|
||||
strikes: &[f64],
|
||||
volatilities: &[f64],
|
||||
time_to_expiries: &[f64],
|
||||
rates: &[f64],
|
||||
carries: &[f64],
|
||||
quantities: &[f64],
|
||||
multipliers: &[f64],
|
||||
) -> (f64, f64, f64, f64, f64) {
|
||||
use super::greeks::model_greeks;
|
||||
use super::{OptionContract, OptionEvaluation, PricingModel};
|
||||
|
||||
let n_legs = instruments.len();
|
||||
if sides.len() != n_legs
|
||||
|| option_types.len() != n_legs
|
||||
|| strikes.len() != n_legs
|
||||
|| volatilities.len() != n_legs
|
||||
|| time_to_expiries.len() != n_legs
|
||||
|| rates.len() != n_legs
|
||||
|| carries.len() != n_legs
|
||||
|| quantities.len() != n_legs
|
||||
|| multipliers.len() != n_legs
|
||||
{
|
||||
return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN);
|
||||
}
|
||||
|
||||
let mut delta = 0.0_f64;
|
||||
let mut gamma = 0.0_f64;
|
||||
let mut vega = 0.0_f64;
|
||||
let mut theta = 0.0_f64;
|
||||
let mut rho = 0.0_f64;
|
||||
|
||||
for i in 0..n_legs {
|
||||
let sign = side_sign(sides[i]);
|
||||
if sign.is_nan() {
|
||||
continue;
|
||||
}
|
||||
let leg_scale = sign * quantities[i] * multipliers[i];
|
||||
|
||||
match instruments[i] {
|
||||
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
INSTRUMENT_OPTION => {
|
||||
let kind = match option_kind(option_types[i]) {
|
||||
Some(k) => k,
|
||||
None => continue,
|
||||
};
|
||||
let greeks = model_greeks(OptionEvaluation {
|
||||
contract: OptionContract {
|
||||
model: PricingModel::BlackScholes,
|
||||
underlying: spot,
|
||||
strike: strikes[i],
|
||||
rate: rates[i],
|
||||
carry: carries[i],
|
||||
time_to_expiry: time_to_expiries[i],
|
||||
kind,
|
||||
},
|
||||
volatility: volatilities[i],
|
||||
});
|
||||
delta += leg_scale * greeks.delta;
|
||||
gamma += leg_scale * greeks.gamma;
|
||||
vega += leg_scale * greeks.vega;
|
||||
theta += leg_scale * greeks.theta;
|
||||
rho += leg_scale * greeks.rho;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(delta, gamma, vega, theta, rho)
|
||||
}
|
||||
|
||||
/// Evaluate `strategy_value_dense` for each point in `spot_grid`.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of length `spot_grid.len()`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_value_grid(
|
||||
spot_grid: &[f64],
|
||||
instruments: &[i64],
|
||||
sides: &[i64],
|
||||
option_types: &[i64],
|
||||
strikes: &[f64],
|
||||
premiums: &[f64],
|
||||
entry_prices: &[f64],
|
||||
quantities: &[f64],
|
||||
multipliers: &[f64],
|
||||
time_to_expiries: &[f64],
|
||||
volatilities: &[f64],
|
||||
rates: &[f64],
|
||||
carries: &[f64],
|
||||
) -> Vec<f64> {
|
||||
spot_grid
|
||||
.iter()
|
||||
.map(|&s| {
|
||||
strategy_value_dense(
|
||||
s,
|
||||
instruments,
|
||||
sides,
|
||||
option_types,
|
||||
strikes,
|
||||
premiums,
|
||||
entry_prices,
|
||||
quantities,
|
||||
multipliers,
|
||||
time_to_expiries,
|
||||
volatilities,
|
||||
rates,
|
||||
carries,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn payoff_single_call() {
|
||||
let grid = vec![90.0, 100.0, 110.0, 120.0];
|
||||
let out = strategy_payoff_dense(
|
||||
&grid,
|
||||
&[0],
|
||||
&[1],
|
||||
&[1],
|
||||
&[100.0],
|
||||
&[5.0],
|
||||
&[0.0],
|
||||
&[1.0],
|
||||
&[1.0],
|
||||
);
|
||||
assert!(out[0] < 0.0); // below strike, loss = premium
|
||||
assert!((out[0] - (-5.0)).abs() < 1e-10);
|
||||
assert!((out[2] - 5.0).abs() < 1e-10); // at 110, intrinsic=10, net=10-5=5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_leg_linear() {
|
||||
let grid = vec![90.0, 100.0, 110.0];
|
||||
let out = strategy_payoff_dense(
|
||||
&grid,
|
||||
&[2],
|
||||
&[1],
|
||||
&[0],
|
||||
&[0.0],
|
||||
&[0.0],
|
||||
&[100.0],
|
||||
&[1.0],
|
||||
&[1.0],
|
||||
);
|
||||
assert!((out[0] - (-10.0)).abs() < 1e-10);
|
||||
assert!((out[1] - 0.0).abs() < 1e-10);
|
||||
assert!((out[2] - 10.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,37 @@ pub fn model_price(input: OptionEvaluation) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put-call parity deviation: `C - P - (S·e^{-q·T} - K·e^{-r·T})`.
|
||||
///
|
||||
/// Returns 0.0 when no arbitrage exists. A non-zero value indicates the
|
||||
/// magnitude of mispricing or data error.
|
||||
pub fn put_call_parity_deviation(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> f64 {
|
||||
if !call_price.is_finite()
|
||||
|| !put_price.is_finite()
|
||||
|| !spot.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !rate.is_finite()
|
||||
|| !carry.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
let pv_forward = spot * (-carry * time_to_expiry).exp();
|
||||
let pv_strike = strike * (-rate * time_to_expiry).exp();
|
||||
call_price - put_price - (pv_forward - pv_strike)
|
||||
}
|
||||
|
||||
/// Lower no-arbitrage bound for the option price.
|
||||
pub fn price_lower_bound(contract: OptionContract) -> f64 {
|
||||
match contract.model {
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Historical (realized) volatility estimators and volatility cone.
|
||||
|
||||
/// Rolling close-to-close realized volatility.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of the same length as `close`. The first `window` values
|
||||
/// are NaN (we need `window` log-returns, which require `window+1` prices, so the
|
||||
/// first valid output sits at index `window`).
|
||||
///
|
||||
/// Annualization: `sqrt(sum(r²) / window * trading_days)`.
|
||||
pub fn close_to_close_vol(close: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n <= window {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Precompute log-returns; returns[i] = ln(close[i+1] / close[i])
|
||||
let mut returns = vec![f64::NAN; n - 1];
|
||||
for i in 0..(n - 1) {
|
||||
if close[i] > 0.0 && close[i + 1] > 0.0 {
|
||||
returns[i] = (close[i + 1] / close[i]).ln();
|
||||
}
|
||||
}
|
||||
|
||||
// Rolling sum of squared returns over `window` bars.
|
||||
// The output at position `end` (in the original close array) uses
|
||||
// returns[end-window .. end-1], i.e. `window` returns.
|
||||
for end in window..n {
|
||||
let slice = &returns[(end - window)..end];
|
||||
let sum_sq: f64 = slice.iter().map(|&r| r * r).sum();
|
||||
let var = sum_sq / window as f64 * trading_days;
|
||||
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling Parkinson high-low realized volatility estimator.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of the same length as `high`. The first `window-1` values
|
||||
/// are NaN.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub fn parkinson_vol(high: &[f64], low: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n < window || low.len() != n {
|
||||
return out;
|
||||
}
|
||||
|
||||
let factor = 1.0 / (4.0 * 2_f64.ln());
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let mut sum_sq = 0.0;
|
||||
let mut valid = true;
|
||||
for i in start..=end {
|
||||
if high[i] <= 0.0 || low[i] <= 0.0 || !high[i].is_finite() || !low[i].is_finite() {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
let u = (high[i] / low[i]).ln();
|
||||
sum_sq += u * u;
|
||||
}
|
||||
if valid {
|
||||
let var = factor * sum_sq / window as f64 * trading_days;
|
||||
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling Garman-Klass OHLC realized volatility estimator.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
|
||||
/// values are NaN. All four slices must have the same length.
|
||||
pub fn garman_klass_vol(
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Vec<f64> {
|
||||
let n = open.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
|
||||
return out;
|
||||
}
|
||||
|
||||
let ln2 = 2_f64.ln();
|
||||
|
||||
// Precompute per-bar GK contributions.
|
||||
let mut gk = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let o = open[i];
|
||||
let h = high[i];
|
||||
let l = low[i];
|
||||
let c = close[i];
|
||||
if o > 0.0
|
||||
&& h > 0.0
|
||||
&& l > 0.0
|
||||
&& c > 0.0
|
||||
&& o.is_finite()
|
||||
&& h.is_finite()
|
||||
&& l.is_finite()
|
||||
&& c.is_finite()
|
||||
{
|
||||
let u = (h / o).ln();
|
||||
let d = (l / o).ln();
|
||||
let ci = (c / o).ln();
|
||||
gk[i] = 0.5 * (u - d).powi(2) - (2.0 * ln2 - 1.0) * ci * ci;
|
||||
}
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let slice = &gk[start..=end];
|
||||
if slice.iter().all(|v| v.is_finite()) {
|
||||
let sum: f64 = slice.iter().sum();
|
||||
let var = sum / window as f64 * trading_days;
|
||||
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the Rogers-Satchell per-bar variance contribution.
|
||||
fn rs_bar(open: f64, high: f64, low: f64, close: f64) -> f64 {
|
||||
let u = (high / close).ln();
|
||||
let d = (low / close).ln();
|
||||
let uo = (high / open).ln();
|
||||
let do_ = (low / open).ln();
|
||||
u * uo + d * do_
|
||||
}
|
||||
|
||||
/// Rolling Rogers-Satchell OHLC realized volatility estimator.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
|
||||
/// values are NaN. All four slices must have the same length.
|
||||
pub fn rogers_satchell_vol(
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Vec<f64> {
|
||||
let n = open.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Precompute per-bar RS contributions.
|
||||
let mut rs = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let o = open[i];
|
||||
let h = high[i];
|
||||
let l = low[i];
|
||||
let c = close[i];
|
||||
if o > 0.0
|
||||
&& h > 0.0
|
||||
&& l > 0.0
|
||||
&& c > 0.0
|
||||
&& o.is_finite()
|
||||
&& h.is_finite()
|
||||
&& l.is_finite()
|
||||
&& c.is_finite()
|
||||
{
|
||||
rs[i] = rs_bar(o, h, l, c);
|
||||
}
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let slice = &rs[start..=end];
|
||||
if slice.iter().all(|v| v.is_finite()) {
|
||||
let sum: f64 = slice.iter().sum();
|
||||
let var = sum / window as f64 * trading_days;
|
||||
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling Yang-Zhang OHLC realized volatility estimator.
|
||||
///
|
||||
/// Handles overnight gaps. Returns a `Vec<f64>` of the same length as the inputs.
|
||||
/// The first `window` values are NaN (we need `window` bars plus the prior close
|
||||
/// for overnight returns, so valid output starts at index `window`).
|
||||
/// All four slices must have the same length.
|
||||
pub fn yang_zhang_vol(
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Vec<f64> {
|
||||
let n = open.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n <= window || high.len() != n || low.len() != n || close.len() != n {
|
||||
return out;
|
||||
}
|
||||
|
||||
let k = 0.34 / (1.34 + (window as f64 + 1.0) / (window as f64 - 1.0).max(1e-10));
|
||||
|
||||
// Precompute per-bar components; index 0 has no overnight return.
|
||||
// overnight[i] = ln(O_i / C_{i-1}), valid for i >= 1
|
||||
// openclose[i] = ln(C_i / O_i)
|
||||
// rs[i] = Rogers-Satchell for bar i
|
||||
let mut overnight = vec![f64::NAN; n];
|
||||
let mut openclose = vec![f64::NAN; n];
|
||||
let mut rs = vec![f64::NAN; n];
|
||||
|
||||
for i in 0..n {
|
||||
let o = open[i];
|
||||
let h = high[i];
|
||||
let l = low[i];
|
||||
let c = close[i];
|
||||
if o > 0.0
|
||||
&& h > 0.0
|
||||
&& l > 0.0
|
||||
&& c > 0.0
|
||||
&& o.is_finite()
|
||||
&& h.is_finite()
|
||||
&& l.is_finite()
|
||||
&& c.is_finite()
|
||||
{
|
||||
openclose[i] = (c / o).ln();
|
||||
rs[i] = rs_bar(o, h, l, c);
|
||||
|
||||
if i > 0 {
|
||||
let prev_c = close[i - 1];
|
||||
if prev_c > 0.0 && prev_c.is_finite() {
|
||||
overnight[i] = (o / prev_c).ln();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Valid windows start at index `window` (using bars [end-window+1 .. end],
|
||||
// all of which have valid overnight returns since they start at index >= 1).
|
||||
for end in window..n {
|
||||
let start = end + 1 - window; // start >= 1 because end >= window
|
||||
|
||||
let o_slice = &overnight[start..=end];
|
||||
let c_slice = &openclose[start..=end];
|
||||
let r_slice = &rs[start..=end];
|
||||
|
||||
if !o_slice.iter().all(|v| v.is_finite())
|
||||
|| !c_slice.iter().all(|v| v.is_finite())
|
||||
|| !r_slice.iter().all(|v| v.is_finite())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let w = window as f64;
|
||||
|
||||
let o_sum: f64 = o_slice.iter().sum();
|
||||
let o_sum_sq: f64 = o_slice.iter().map(|&x| x * x).sum();
|
||||
let overnight_var = o_sum_sq / (w - 1.0) - (o_sum / w).powi(2) * w / (w - 1.0);
|
||||
|
||||
let c_sum: f64 = c_slice.iter().sum();
|
||||
let c_sum_sq: f64 = c_slice.iter().map(|&x| x * x).sum();
|
||||
let openclose_var = c_sum_sq / (w - 1.0) - (c_sum / w).powi(2) * w / (w - 1.0);
|
||||
|
||||
let rs_sum: f64 = r_slice.iter().sum();
|
||||
let rs_var = rs_sum / w;
|
||||
|
||||
let yz_var = overnight_var + k * openclose_var + (1.0 - k) * rs_var;
|
||||
let annualized = yz_var * trading_days;
|
||||
out[end] = if annualized >= 0.0 {
|
||||
annualized.sqrt()
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Summary statistics of realized vol distribution for one window length.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct VolConeSlice {
|
||||
pub window: usize,
|
||||
pub min: f64,
|
||||
pub p25: f64,
|
||||
pub median: f64,
|
||||
pub p75: f64,
|
||||
pub max: f64,
|
||||
}
|
||||
|
||||
/// Compute a percentile via linear interpolation on a sorted slice.
|
||||
///
|
||||
/// `sorted` must be non-empty and already sorted ascending.
|
||||
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
|
||||
let n = sorted.len();
|
||||
if n == 1 {
|
||||
return sorted[0];
|
||||
}
|
||||
let idx = (n - 1) as f64 * p;
|
||||
let lo = idx.floor() as usize;
|
||||
let hi = idx.ceil() as usize;
|
||||
let frac = idx - lo as f64;
|
||||
sorted[lo] + frac * (sorted[hi] - sorted[lo])
|
||||
}
|
||||
|
||||
/// Compute vol cone: distribution of realized vols across multiple window lengths.
|
||||
///
|
||||
/// For each window in `windows`, the close-to-close rolling vol is computed,
|
||||
/// NaN values are filtered out, and the distribution statistics (min, p25,
|
||||
/// median, p75, max) are derived via linear interpolation.
|
||||
pub fn vol_cone(close: &[f64], windows: &[usize], trading_days: f64) -> Vec<VolConeSlice> {
|
||||
windows
|
||||
.iter()
|
||||
.map(|&w| {
|
||||
let vols = close_to_close_vol(close, w, trading_days);
|
||||
let mut valid: Vec<f64> = vols.into_iter().filter(|v| v.is_finite()).collect();
|
||||
valid.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
|
||||
if valid.is_empty() {
|
||||
return VolConeSlice {
|
||||
window: w,
|
||||
min: f64::NAN,
|
||||
p25: f64::NAN,
|
||||
median: f64::NAN,
|
||||
p75: f64::NAN,
|
||||
max: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
VolConeSlice {
|
||||
window: w,
|
||||
min: valid[0],
|
||||
p25: percentile_sorted(&valid, 0.25),
|
||||
median: percentile_sorted(&valid, 0.5),
|
||||
p75: percentile_sorted(&valid, 0.75),
|
||||
max: *valid.last().unwrap(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_prices(n: usize) -> Vec<f64> {
|
||||
// simple synthetic price series
|
||||
let mut prices = vec![100.0_f64; n];
|
||||
for i in 1..n {
|
||||
prices[i] = prices[i - 1] * (1.0 + 0.01 * (i as f64 % 7_f64 - 3.0) * 0.01);
|
||||
}
|
||||
prices
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_to_close_returns_nans_for_warmup() {
|
||||
let close = fake_prices(100);
|
||||
let result = close_to_close_vol(&close, 20, 252.0);
|
||||
assert_eq!(result.len(), 100);
|
||||
// first 20 values should be NaN (window-1 of returns warmup + 1 for diff)
|
||||
for i in 0..20 {
|
||||
assert!(result[i].is_nan(), "result[{i}] should be NaN");
|
||||
}
|
||||
assert!(result[20].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parkinson_vol_is_positive() {
|
||||
let close = fake_prices(100);
|
||||
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
|
||||
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
|
||||
let result = parkinson_vol(&high, &low, 20, 252.0);
|
||||
for v in result.iter().skip(19) {
|
||||
assert!(v.is_finite() && *v >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vol_cone_is_ordered() {
|
||||
let close = fake_prices(300);
|
||||
let cones = vol_cone(&close, &[20, 60], 252.0);
|
||||
assert_eq!(cones.len(), 2);
|
||||
for cone in &cones {
|
||||
assert!(cone.min <= cone.p25);
|
||||
assert!(cone.p25 <= cone.median);
|
||||
assert!(cone.median <= cone.p75);
|
||||
assert!(cone.p75 <= cone.max);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garman_klass_returns_nans_for_warmup() {
|
||||
let close = fake_prices(50);
|
||||
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
|
||||
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
|
||||
let result = garman_klass_vol(&close, &high, &low, &close, 10, 252.0);
|
||||
assert_eq!(result.len(), 50);
|
||||
for i in 0..9 {
|
||||
assert!(result[i].is_nan(), "result[{i}] should be NaN");
|
||||
}
|
||||
assert!(result[9].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rogers_satchell_returns_nans_for_warmup() {
|
||||
let close = fake_prices(50);
|
||||
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
|
||||
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
|
||||
let result = rogers_satchell_vol(&close, &high, &low, &close, 10, 252.0);
|
||||
assert_eq!(result.len(), 50);
|
||||
for i in 0..9 {
|
||||
assert!(result[i].is_nan(), "result[{i}] should be NaN");
|
||||
}
|
||||
assert!(result[9].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yang_zhang_returns_nans_for_warmup() {
|
||||
let close = fake_prices(50);
|
||||
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
|
||||
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
|
||||
let result = yang_zhang_vol(&close, &high, &low, &close, 10, 252.0);
|
||||
assert_eq!(result.len(), 50);
|
||||
for i in 0..10 {
|
||||
assert!(result[i].is_nan(), "result[{i}] should be NaN");
|
||||
}
|
||||
assert!(result[10].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_lengths_return_all_nan() {
|
||||
let a = vec![100.0_f64; 20];
|
||||
let b = vec![101.0_f64; 15]; // wrong length
|
||||
let result = parkinson_vol(&a, &b, 5, 252.0);
|
||||
assert!(result.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_larger_than_data_returns_all_nan() {
|
||||
let close = fake_prices(10);
|
||||
let result = close_to_close_vol(&close, 20, 252.0);
|
||||
assert!(result.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,35 @@ pub fn term_structure_slope(tenors: &[f64], atm_ivs: &[f64]) -> f64 {
|
||||
regression_slope(tenors, atm_ivs)
|
||||
}
|
||||
|
||||
/// Expected ±1σ move over `days_to_expiry` calendar days.
|
||||
///
|
||||
/// Returns `(lower_move, upper_move)` as absolute changes from `spot`.
|
||||
/// Example: if spot=100 and upper_move=5.0 then the 1σ upper bound is 105.
|
||||
///
|
||||
/// Uses the log-normal approximation: `spot × e^{±σ√(days/trading_days)} − spot`.
|
||||
pub fn expected_move(
|
||||
spot: f64,
|
||||
iv: f64,
|
||||
days_to_expiry: f64,
|
||||
trading_days_per_year: f64,
|
||||
) -> (f64, f64) {
|
||||
if !spot.is_finite()
|
||||
|| !iv.is_finite()
|
||||
|| !days_to_expiry.is_finite()
|
||||
|| !trading_days_per_year.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| iv < 0.0
|
||||
|| days_to_expiry < 0.0
|
||||
|| trading_days_per_year <= 0.0
|
||||
{
|
||||
return (f64::NAN, f64::NAN);
|
||||
}
|
||||
let sigma_sqrt_t = iv * (days_to_expiry / trading_days_per_year).sqrt();
|
||||
let upper = spot * sigma_sqrt_t.exp() - spot;
|
||||
let lower = spot * (-sigma_sqrt_t).exp() - spot;
|
||||
(lower, upper)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{atm_iv, smile_metrics, term_structure_slope};
|
||||
|
||||
+245
-4
@@ -2,7 +2,7 @@
|
||||
"surfaces": {
|
||||
"python": {
|
||||
"indicator_count": 208,
|
||||
"method_count": 447,
|
||||
"method_count": 464,
|
||||
"categories": [
|
||||
"aggregation",
|
||||
"alerts",
|
||||
@@ -1736,6 +1736,13 @@
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "stock_leg_payoff",
|
||||
"category": "derivatives_payoff",
|
||||
"module": "ferro_ta.analysis.derivatives_payoff",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "strategy_payoff",
|
||||
"category": "derivatives_payoff",
|
||||
@@ -1743,6 +1750,13 @@
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "strategy_value",
|
||||
"category": "derivatives_payoff",
|
||||
"module": "ferro_ta.analysis.derivatives_payoff",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "CHANDELIER_EXIT",
|
||||
"category": "extended",
|
||||
@@ -2289,6 +2303,13 @@
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "ExtendedGreeks",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "OptionGreeks",
|
||||
"category": "options",
|
||||
@@ -2303,6 +2324,20 @@
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "VolCone",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "american_option_price",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "black_76_price",
|
||||
"category": "options",
|
||||
@@ -2317,6 +2352,55 @@
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "close_to_close_vol",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "digital_option_greeks",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "digital_option_price",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "early_exercise_premium",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "expected_move",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "extended_greeks",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "garman_klass_vol",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "greeks",
|
||||
"category": "options",
|
||||
@@ -2366,6 +2450,27 @@
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "parkinson_vol",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "put_call_parity_deviation",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "rogers_satchell_vol",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "select_strike",
|
||||
"category": "options",
|
||||
@@ -2387,6 +2492,20 @@
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "vol_cone",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "yang_zhang_vol",
|
||||
"category": "options",
|
||||
"module": "ferro_ta.analysis.options",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "DerivativesStrategy",
|
||||
"category": "options_strategy",
|
||||
@@ -4616,7 +4735,7 @@
|
||||
]
|
||||
},
|
||||
"rust_core": {
|
||||
"public_function_count": 331,
|
||||
"public_function_count": 349,
|
||||
"functions": [
|
||||
{
|
||||
"module": "aggregation",
|
||||
@@ -5288,6 +5407,16 @@
|
||||
"function": "willr",
|
||||
"file": "momentum.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.american",
|
||||
"function": "american_price_baw",
|
||||
"file": "options/american.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.american",
|
||||
"function": "early_exercise_premium",
|
||||
"file": "options/american.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.chain",
|
||||
"function": "atm_index",
|
||||
@@ -5308,16 +5437,36 @@
|
||||
"function": "select_strike_by_offset",
|
||||
"file": "options/chain.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.digital",
|
||||
"function": "digital_greeks",
|
||||
"file": "options/digital.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.digital",
|
||||
"function": "digital_price",
|
||||
"file": "options/digital.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.greeks",
|
||||
"function": "black_76_greeks",
|
||||
"file": "options/greeks.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.greeks",
|
||||
"function": "black_scholes_extended_greeks",
|
||||
"file": "options/greeks.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.greeks",
|
||||
"function": "black_scholes_greeks",
|
||||
"file": "options/greeks.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.greeks",
|
||||
"function": "model_extended_greeks",
|
||||
"file": "options/greeks.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.greeks",
|
||||
"function": "model_greeks",
|
||||
@@ -5363,6 +5512,26 @@
|
||||
"function": "pdf",
|
||||
"file": "options/normal.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.payoff",
|
||||
"function": "aggregate_greeks_dense",
|
||||
"file": "options/payoff.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.payoff",
|
||||
"function": "strategy_payoff_dense",
|
||||
"file": "options/payoff.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.payoff",
|
||||
"function": "strategy_value_dense",
|
||||
"file": "options/payoff.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.payoff",
|
||||
"function": "strategy_value_grid",
|
||||
"file": "options/payoff.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.pricing",
|
||||
"function": "black_76_price",
|
||||
@@ -5388,11 +5557,51 @@
|
||||
"function": "price_upper_bound",
|
||||
"file": "options/pricing.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.pricing",
|
||||
"function": "put_call_parity_deviation",
|
||||
"file": "options/pricing.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.realized_vol",
|
||||
"function": "close_to_close_vol",
|
||||
"file": "options/realized_vol.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.realized_vol",
|
||||
"function": "garman_klass_vol",
|
||||
"file": "options/realized_vol.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.realized_vol",
|
||||
"function": "parkinson_vol",
|
||||
"file": "options/realized_vol.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.realized_vol",
|
||||
"function": "rogers_satchell_vol",
|
||||
"file": "options/realized_vol.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.realized_vol",
|
||||
"function": "vol_cone",
|
||||
"file": "options/realized_vol.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.realized_vol",
|
||||
"function": "yang_zhang_vol",
|
||||
"file": "options/realized_vol.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.surface",
|
||||
"function": "atm_iv",
|
||||
"file": "options/surface.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.surface",
|
||||
"function": "expected_move",
|
||||
"file": "options/surface.rs"
|
||||
},
|
||||
{
|
||||
"module": "options.surface",
|
||||
"function": "linear_interpolate",
|
||||
@@ -6276,16 +6485,18 @@
|
||||
]
|
||||
},
|
||||
"wasm_node": {
|
||||
"export_count": 205,
|
||||
"export_count": 221,
|
||||
"exports": [
|
||||
"ad",
|
||||
"adosc",
|
||||
"adx",
|
||||
"adx_all",
|
||||
"adxr",
|
||||
"aggregate_greeks_dense",
|
||||
"aggregate_tick_bars",
|
||||
"aggregate_time_bars",
|
||||
"aggregate_volume_bars_ticks",
|
||||
"american_price",
|
||||
"annualized_basis",
|
||||
"apo",
|
||||
"aroon",
|
||||
@@ -6318,6 +6529,7 @@
|
||||
"check_cross",
|
||||
"check_threshold",
|
||||
"choppiness_index",
|
||||
"close_to_close_vol",
|
||||
"cmo",
|
||||
"collect_alert_bars",
|
||||
"compose_rank",
|
||||
@@ -6330,17 +6542,23 @@
|
||||
"curve_summary",
|
||||
"dema",
|
||||
"detect_breaks_cusum",
|
||||
"digital_greeks",
|
||||
"digital_price",
|
||||
"donchian",
|
||||
"drawdown_series",
|
||||
"dx",
|
||||
"early_exercise_premium",
|
||||
"ema",
|
||||
"exchange_charges_rate",
|
||||
"expected_move",
|
||||
"extended_greeks",
|
||||
"extract_trades",
|
||||
"fast_period",
|
||||
"flat_per_order",
|
||||
"forward_fill_nan",
|
||||
"funding_cumulative_pnl",
|
||||
"futures_basis",
|
||||
"garman_klass_vol",
|
||||
"gst_rate",
|
||||
"half_kelly_fraction",
|
||||
"ht_dcperiod",
|
||||
@@ -6396,6 +6614,7 @@
|
||||
"obv",
|
||||
"ohlcv_agg",
|
||||
"parity_gap",
|
||||
"parkinson_vol",
|
||||
"per_lot",
|
||||
"period",
|
||||
"pivot_points",
|
||||
@@ -6405,6 +6624,7 @@
|
||||
"ppo",
|
||||
"price_lower_bound",
|
||||
"price_upper_bound",
|
||||
"put_call_parity_deviation",
|
||||
"rank_series",
|
||||
"rank_values",
|
||||
"rate_of_value",
|
||||
@@ -6418,6 +6638,7 @@
|
||||
"rocp",
|
||||
"rocr",
|
||||
"rocr100",
|
||||
"rogers_satchell_vol",
|
||||
"roll_yield",
|
||||
"rolling_beta",
|
||||
"rolling_max",
|
||||
@@ -6455,6 +6676,8 @@
|
||||
"stoch",
|
||||
"stochf",
|
||||
"stochrsi",
|
||||
"strategy_payoff_dense",
|
||||
"strategy_value_grid",
|
||||
"stt_on_buy",
|
||||
"stt_on_sell",
|
||||
"stt_rate",
|
||||
@@ -6474,6 +6697,7 @@
|
||||
"typprice",
|
||||
"ultosc",
|
||||
"var",
|
||||
"vol_cone",
|
||||
"volume_bars",
|
||||
"vwap",
|
||||
"vwma",
|
||||
@@ -6482,13 +6706,14 @@
|
||||
"weighted_continuous",
|
||||
"willr",
|
||||
"wma",
|
||||
"yang_zhang_vol",
|
||||
"zscore_series"
|
||||
]
|
||||
}
|
||||
},
|
||||
"parity_summary": {
|
||||
"python_indicator_count": 207,
|
||||
"wasm_export_count": 205,
|
||||
"wasm_export_count": 221,
|
||||
"common_python_wasm_count": 91,
|
||||
"common_python_wasm": [
|
||||
"ad",
|
||||
@@ -6703,9 +6928,11 @@
|
||||
],
|
||||
"wasm_only_vs_python": [
|
||||
"adx_all",
|
||||
"aggregate_greeks_dense",
|
||||
"aggregate_tick_bars",
|
||||
"aggregate_time_bars",
|
||||
"aggregate_volume_bars_ticks",
|
||||
"american_price",
|
||||
"annualized_basis",
|
||||
"atm_index",
|
||||
"atm_iv",
|
||||
@@ -6723,19 +6950,26 @@
|
||||
"bottom_n_indices",
|
||||
"calendar_spreads",
|
||||
"carry_spread",
|
||||
"close_to_close_vol",
|
||||
"compose_rank",
|
||||
"compose_weighted",
|
||||
"compute_performance_metrics",
|
||||
"curve_slope",
|
||||
"curve_summary",
|
||||
"digital_greeks",
|
||||
"digital_price",
|
||||
"drawdown_series",
|
||||
"early_exercise_premium",
|
||||
"exchange_charges_rate",
|
||||
"expected_move",
|
||||
"extended_greeks",
|
||||
"extract_trades",
|
||||
"fast_period",
|
||||
"flat_per_order",
|
||||
"forward_fill_nan",
|
||||
"funding_cumulative_pnl",
|
||||
"futures_basis",
|
||||
"garman_klass_vol",
|
||||
"gst_rate",
|
||||
"half_kelly_fraction",
|
||||
"implied_carry_rate",
|
||||
@@ -6763,10 +6997,12 @@
|
||||
"new",
|
||||
"ohlcv_agg",
|
||||
"parity_gap",
|
||||
"parkinson_vol",
|
||||
"per_lot",
|
||||
"period",
|
||||
"price_lower_bound",
|
||||
"price_upper_bound",
|
||||
"put_call_parity_deviation",
|
||||
"rank_series",
|
||||
"rank_values",
|
||||
"rate_of_value",
|
||||
@@ -6774,6 +7010,7 @@
|
||||
"ratio_adjusted_continuous",
|
||||
"regulatory_charges_rate",
|
||||
"relative_strength",
|
||||
"rogers_satchell_vol",
|
||||
"roll_yield",
|
||||
"rolling_beta",
|
||||
"rolling_max",
|
||||
@@ -6803,6 +7040,8 @@
|
||||
"spread",
|
||||
"stamp_duty_rate",
|
||||
"stitch_chunks",
|
||||
"strategy_payoff_dense",
|
||||
"strategy_value_grid",
|
||||
"stt_on_buy",
|
||||
"stt_on_sell",
|
||||
"stt_rate",
|
||||
@@ -6813,8 +7052,10 @@
|
||||
"trade_stats",
|
||||
"trim_overlap",
|
||||
"trix_indicator",
|
||||
"vol_cone",
|
||||
"walk_forward_indices",
|
||||
"weighted_continuous",
|
||||
"yang_zhang_vol",
|
||||
"zscore_series"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
Release Notes
|
||||
=============
|
||||
|
||||
These docs track package version ``1.1.2``.
|
||||
These docs track package version ``1.1.3``.
|
||||
|
||||
1.1.0-audit (2026-03-28)
|
||||
------------------------
|
||||
|
||||
+198
-42
@@ -1,50 +1,187 @@
|
||||
# Derivatives Analytics
|
||||
|
||||
`ferro-ta` now includes a Rust-backed derivatives analytics layer focused on
|
||||
research, simulation, and risk analysis.
|
||||
`ferro-ta` ships a Rust-backed derivatives analytics layer focused on
|
||||
research, simulation, and risk analysis. All functions are implemented in
|
||||
Rust core and exposed to Python (via PyO3) and WebAssembly (via wasm-bindgen).
|
||||
|
||||
---
|
||||
|
||||
## Modules
|
||||
|
||||
- `ferro_ta.analysis.options`
|
||||
- Black-Scholes-Merton and Black-76 pricing
|
||||
- Delta, gamma, vega, theta, rho
|
||||
- Implied volatility inversion with guarded Newton + bisection fallback
|
||||
- IV rank / percentile / z-score
|
||||
- Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity
|
||||
- Chain helpers: moneyness labels and strike selection by offset or delta
|
||||
- `ferro_ta.analysis.futures`
|
||||
- Synthetic forwards and parity diagnostics
|
||||
- Basis, annualized basis, implied carry, carry spread
|
||||
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
|
||||
- Curve analytics: calendar spreads, slope, contango summary
|
||||
- `ferro_ta.analysis.options_strategy`
|
||||
- Typed strategy schemas for expiry selectors, strike selectors, multi-leg presets,
|
||||
risk controls, cost assumptions, and simulation limits
|
||||
- `ferro_ta.analysis.derivatives_payoff`
|
||||
- Multi-leg payoff aggregation
|
||||
- Portfolio-level Greeks aggregation across option and futures legs
|
||||
### `ferro_ta.analysis.options`
|
||||
|
||||
| Category | Functions |
|
||||
|---|---|
|
||||
| **Pricing** | `black_scholes_price`, `black_76_price`, `option_price` |
|
||||
| **Greeks** | `greeks`, `extended_greeks` |
|
||||
| **Implied vol** | `implied_volatility`, `iv_rank`, `iv_percentile`, `iv_zscore` |
|
||||
| **Digital options** | `digital_option_price`, `digital_option_greeks` |
|
||||
| **American options** | `american_option_price`, `early_exercise_premium` |
|
||||
| **Smile / surface** | `smile_metrics`, `term_structure_slope`, `expected_move` |
|
||||
| **Chain helpers** | `label_moneyness`, `select_strike` |
|
||||
| **Realised vol** | `close_to_close_vol`, `parkinson_vol`, `garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol` |
|
||||
| **Vol cone** | `vol_cone` |
|
||||
| **Diagnostics** | `put_call_parity_deviation` |
|
||||
|
||||
### `ferro_ta.analysis.futures`
|
||||
|
||||
- Synthetic forwards and parity diagnostics
|
||||
- Basis, annualized basis, implied carry, carry spread
|
||||
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
|
||||
- Curve analytics: calendar spreads, slope, contango summary
|
||||
|
||||
### `ferro_ta.analysis.options_strategy`
|
||||
|
||||
Typed strategy schemas: expiry selectors, strike selectors, multi-leg presets
|
||||
(`STRADDLE`, `STRANGLE`, `IRON_CONDOR`, `BULL_CALL_SPREAD`, `BEAR_PUT_SPREAD`),
|
||||
risk controls, cost assumptions, and simulation limits.
|
||||
|
||||
### `ferro_ta.analysis.derivatives_payoff`
|
||||
|
||||
Multi-leg payoff and Greeks aggregation supporting **option**, **future**, and
|
||||
**stock** instrument types.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `option_leg_payoff` | Expiry P/L for a single option leg |
|
||||
| `futures_leg_payoff` | Linear P/L for a futures leg |
|
||||
| `stock_leg_payoff` | Linear P/L for a stock/equity leg |
|
||||
| `strategy_payoff` | Aggregate expiry payoff across all legs |
|
||||
| `strategy_value` | Pre-expiry BSM mid-price value of a multi-leg strategy |
|
||||
| `aggregate_greeks` | Portfolio-level Greeks across option, futures, and stock legs |
|
||||
|
||||
---
|
||||
|
||||
## Model conventions
|
||||
|
||||
- `model="bsm"` expects the underlying input to be spot and `carry` to represent
|
||||
a continuous dividend yield or generic carry term.
|
||||
- `model="black76"` expects the underlying input to be the forward price.
|
||||
- Volatility and rates use decimal units:
|
||||
- `0.20` means 20% annualized volatility
|
||||
- `0.05` means 5% annualized rate
|
||||
- `time_to_expiry` is expressed in years.
|
||||
| Parameter | Convention |
|
||||
|---|---|
|
||||
| `model="bsm"` | Underlying is spot; `carry` = continuous dividend yield |
|
||||
| `model="black76"` | Underlying is the forward price |
|
||||
| `volatility` / `rate` / `carry` | Decimal annual (e.g. `0.20` = 20 %, `0.05` = 5 %) |
|
||||
| `time_to_expiry` | Years (e.g. `0.25` = 3 months) |
|
||||
|
||||
---
|
||||
|
||||
## Quick examples
|
||||
|
||||
### BSM pricing and Greeks
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.options import greeks, implied_volatility, option_price
|
||||
|
||||
price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
|
||||
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
print(price, iv, g.delta)
|
||||
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
|
||||
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
print(price, iv, g.delta, g.gamma)
|
||||
```
|
||||
|
||||
### Extended (second-order) Greeks
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.options import extended_greeks
|
||||
|
||||
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
print(eg.vanna, eg.volga, eg.charm, eg.speed, eg.color)
|
||||
```
|
||||
|
||||
### Digital options
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.options import digital_option_price, digital_option_greeks
|
||||
|
||||
# Cash-or-nothing call at ATM ≈ e^{-rT} * N(d2) ≈ 0.53
|
||||
price = digital_option_price(100.0, 100.0, 0.05, 1.0, 0.20,
|
||||
option_type="call", digital_type="cash_or_nothing")
|
||||
g = digital_option_greeks(100.0, 100.0, 0.05, 1.0, 0.20,
|
||||
option_type="call", digital_type="cash_or_nothing")
|
||||
print(price, g.delta, g.gamma, g.vega)
|
||||
```
|
||||
|
||||
### American options (BAW approximation)
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.options import american_option_price, early_exercise_premium
|
||||
|
||||
# American put — may have meaningful early exercise premium
|
||||
american = american_option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
|
||||
premium = early_exercise_premium(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
|
||||
print(american, premium)
|
||||
```
|
||||
|
||||
### Historical volatility estimators
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from ferro_ta.analysis.options import (
|
||||
close_to_close_vol, garman_klass_vol, parkinson_vol,
|
||||
rogers_satchell_vol, yang_zhang_vol,
|
||||
)
|
||||
|
||||
# Assume daily OHLC arrays of length N
|
||||
open_p, high_p, low_p, close_p = ... # numpy arrays
|
||||
|
||||
ctc = close_to_close_vol(close_p, window=20) # close-only
|
||||
park = parkinson_vol(high_p, low_p, window=20) # high-low
|
||||
gk = garman_klass_vol(open_p, high_p, low_p, close_p, window=20)
|
||||
rs = rogers_satchell_vol(open_p, high_p, low_p, close_p, window=20)
|
||||
yz = yang_zhang_vol(open_p, high_p, low_p, close_p, window=20)
|
||||
```
|
||||
|
||||
### Volatility cone
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.options import vol_cone
|
||||
|
||||
cone = vol_cone(close_p, windows=(21, 42, 63, 126, 252))
|
||||
# Overlay current IV against the cone to gauge richness/cheapness
|
||||
for w, med in zip(cone.windows, cone.median):
|
||||
print(f"window={int(w):3d} median_rv={med:.1%}")
|
||||
```
|
||||
|
||||
### Put-call parity check
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.options import option_price, put_call_parity_deviation
|
||||
|
||||
call = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
put = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
|
||||
dev = put_call_parity_deviation(call, put, 100.0, 100.0, 0.05, 1.0)
|
||||
# dev ≈ 0.0 for BSM-consistent prices; non-zero signals stale/mismatched quotes
|
||||
```
|
||||
|
||||
### Expected move
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.options import expected_move
|
||||
|
||||
lower, upper = expected_move(100.0, 0.20, days_to_expiry=30)
|
||||
print(f"Expected ±1σ range: [{100+lower:.2f}, {100+upper:.2f}]")
|
||||
```
|
||||
|
||||
### Multi-leg strategies with stock
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff, strategy_value
|
||||
|
||||
# Covered Call: long 100 shares + short 1 OTM call
|
||||
spot_grid = np.linspace(80, 130, 100)
|
||||
legs = [
|
||||
PayoffLeg("stock", "long", entry_price=100.0),
|
||||
PayoffLeg("option", "short", option_type="call",
|
||||
strike=110.0, premium=3.0, volatility=0.20, time_to_expiry=0.25),
|
||||
]
|
||||
|
||||
# Expiry P/L
|
||||
payoff = strategy_payoff(spot_grid, legs=legs)
|
||||
|
||||
# Pre-expiry BSM value (T=3 months remaining)
|
||||
value = strategy_value(spot_grid, legs=legs, time_to_expiry=0.25, volatility=0.20)
|
||||
```
|
||||
|
||||
### Futures analytics
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.futures import basis, curve_summary
|
||||
|
||||
@@ -52,19 +189,38 @@ print(basis(100.0, 103.0))
|
||||
print(curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]))
|
||||
```
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff
|
||||
---
|
||||
|
||||
legs = [
|
||||
PayoffLeg("option", "long", option_type="call", strike=100.0, premium=5.0),
|
||||
PayoffLeg("future", "long", entry_price=100.0),
|
||||
]
|
||||
grid = [90.0, 100.0, 110.0]
|
||||
print(strategy_payoff(grid, legs=legs))
|
||||
```
|
||||
## Instrument types in `PayoffLeg` / `StrategyLeg`
|
||||
|
||||
| `instrument` | Required fields | Payoff |
|
||||
|---|---|---|
|
||||
| `"option"` | `option_type`, `strike`, `expiry_selector`, `strike_selector` | `max(φ(S−K), 0) − premium` |
|
||||
| `"future"` | `entry_price` | `S − entry_price` |
|
||||
| `"stock"` | `entry_price` | `S − entry_price` (identical to future, no margin) |
|
||||
|
||||
---
|
||||
|
||||
## Volatility estimator efficiency comparison
|
||||
|
||||
| Estimator | Relative efficiency vs close-to-close | Handles overnight gaps |
|
||||
|---|---|---|
|
||||
| Close-to-close | 1× (baseline) | N/A (uses close only) |
|
||||
| Parkinson | ~5× | No |
|
||||
| Garman-Klass | ~7.4× | No |
|
||||
| Rogers-Satchell | ~8× | No |
|
||||
| Yang-Zhang | ~14× | Yes |
|
||||
|
||||
*Use Yang-Zhang when you have overnight gaps (futures, crypto). Use Parkinson
|
||||
or Garman-Klass for continuous trading sessions.*
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Existing `iv_rank`, `iv_percentile`, and `iv_zscore` names are preserved.
|
||||
- The derivatives layer is analytics-only: there is no broker connectivity,
|
||||
order routing, or execution workflow in this API.
|
||||
- All existing function names (`iv_rank`, `iv_percentile`, `iv_zscore`, `greeks`,
|
||||
`option_price`, etc.) are preserved — fully backward compatible.
|
||||
- The derivatives layer is analytics-only: no broker connectivity, order routing,
|
||||
or execution workflow.
|
||||
- WASM: all functions in this layer are also exported as WebAssembly bindings
|
||||
(see `wasm/src/lib.rs`).
|
||||
|
||||
@@ -180,7 +180,7 @@ For source builds, packaging details, and platform notes, see
|
||||
Release status
|
||||
--------------
|
||||
|
||||
These docs track package version ``1.1.2``.
|
||||
These docs track package version ``1.1.3``.
|
||||
|
||||
- Release notes by version: :doc:`changelog`
|
||||
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "ferro-ta"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -67,6 +67,7 @@ dev = [
|
||||
"matplotlib>=3.5",
|
||||
"fastapi>=0.135.1",
|
||||
"httpx>=0.24",
|
||||
"scipy>=1.10",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -162,4 +163,5 @@ dev = [
|
||||
"pyyaml>=6.0",
|
||||
"pandas-ta>=0.3; python_version >= '3.12'",
|
||||
"ta>=0.10",
|
||||
"scipy>=1.15.3",
|
||||
]
|
||||
|
||||
@@ -14,6 +14,7 @@ from numpy.typing import ArrayLike, NDArray
|
||||
from ferro_ta._ferro_ta import aggregate_greeks_legs as _rust_aggregate_greeks_legs
|
||||
from ferro_ta._ferro_ta import strategy_payoff_dense as _rust_strategy_payoff_dense
|
||||
from ferro_ta._ferro_ta import strategy_payoff_legs as _rust_strategy_payoff_legs
|
||||
from ferro_ta._ferro_ta import strategy_value_dense as _rust_strategy_value_dense
|
||||
from ferro_ta.analysis.options import OptionGreeks
|
||||
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
|
||||
from ferro_ta.core.exceptions import (
|
||||
@@ -26,7 +27,9 @@ __all__ = [
|
||||
"PayoffLeg",
|
||||
"option_leg_payoff",
|
||||
"futures_leg_payoff",
|
||||
"stock_leg_payoff",
|
||||
"strategy_payoff",
|
||||
"strategy_value",
|
||||
"aggregate_greeks",
|
||||
]
|
||||
|
||||
@@ -47,8 +50,10 @@ class PayoffLeg:
|
||||
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.instrument not in {"option", "future", "stock"}:
|
||||
raise FerroTAValueError(
|
||||
"instrument must be 'option', 'future', or 'stock'."
|
||||
)
|
||||
if self.side not in {"long", "short"}:
|
||||
raise FerroTAValueError("side must be 'long' or 'short'.")
|
||||
if self.instrument == "option":
|
||||
@@ -58,8 +63,8 @@ class PayoffLeg:
|
||||
)
|
||||
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.")
|
||||
if self.instrument in {"future", "stock"} and self.entry_price is None:
|
||||
raise FerroTAValueError(f"{self.instrument} legs require entry_price.")
|
||||
|
||||
|
||||
def _side_sign(side: str) -> float:
|
||||
@@ -131,6 +136,60 @@ def futures_leg_payoff(
|
||||
)
|
||||
|
||||
|
||||
def stock_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 single stock (equity) leg over a spot grid.
|
||||
|
||||
Payoff is linear::
|
||||
|
||||
P/L = sign(side) × quantity × multiplier × (spot − entry_price)
|
||||
|
||||
Mathematically equivalent to a futures leg — no optionality. Use this
|
||||
leg type when modelling strategies that hold the underlying equity:
|
||||
Covered Call, Protective Put, Collar, Covered Strangle, etc.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
spot_grid:
|
||||
1-D array of spot prices at which to evaluate the P/L.
|
||||
entry_price:
|
||||
Purchase (or short-sale) price of the stock.
|
||||
side:
|
||||
``"long"`` (default) or ``"short"``.
|
||||
quantity:
|
||||
Number of shares / contracts (default 1).
|
||||
multiplier:
|
||||
Contract multiplier (default 1.0).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[float64]
|
||||
P/L at each grid point, same shape as *spot_grid*.
|
||||
"""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
_side_sign(side)
|
||||
return np.asarray(
|
||||
_rust_strategy_payoff_dense(
|
||||
grid,
|
||||
np.array([2], dtype=np.int64), # stock
|
||||
np.array([1 if side == "long" else -1], dtype=np.int64),
|
||||
np.array([-1], dtype=np.int64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([float(entry_price)], dtype=np.float64),
|
||||
np.array([float(quantity)], dtype=np.float64),
|
||||
np.array([float(multiplier)], dtype=np.float64),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
|
||||
return PayoffLeg(**mapping)
|
||||
|
||||
@@ -141,7 +200,9 @@ def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg:
|
||||
side=leg.side,
|
||||
quantity=float(leg.quantity),
|
||||
option_type=leg.option_type,
|
||||
strike=leg.strike_selector.explicit_strike,
|
||||
strike=leg.strike_selector.explicit_strike
|
||||
if leg.strike_selector is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -205,3 +266,92 @@ def aggregate_greeks(
|
||||
float(theta),
|
||||
float(rho),
|
||||
)
|
||||
|
||||
|
||||
def strategy_value(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]],
|
||||
time_to_expiry: float,
|
||||
volatility: float,
|
||||
rate: float = 0.0,
|
||||
carry: float = 0.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Current BSM mid-price value of a multi-leg strategy over a spot grid.
|
||||
|
||||
Unlike :func:`strategy_payoff` (which computes intrinsic value at expiry),
|
||||
this uses live BSM pricing for option legs so the result reflects the
|
||||
pre-expiry value including time value.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
spot_grid:
|
||||
Array of spot prices to evaluate.
|
||||
legs:
|
||||
Sequence of :class:`PayoffLeg` (or dicts). Option legs must have
|
||||
``strike`` and ``premium`` set; future/stock legs must have
|
||||
``entry_price`` set.
|
||||
time_to_expiry:
|
||||
Shared time-to-expiry (years) applied to all option legs.
|
||||
volatility:
|
||||
Shared implied vol applied to all option legs.
|
||||
rate:
|
||||
Risk-free rate applied to all legs.
|
||||
carry:
|
||||
Carry / dividend yield applied to all option legs.
|
||||
"""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
normalized: tuple[PayoffLeg, ...] = tuple(
|
||||
leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg) for leg in legs
|
||||
)
|
||||
if len(normalized) == 0:
|
||||
return np.zeros_like(grid)
|
||||
|
||||
n_legs = len(normalized)
|
||||
instruments = np.empty(n_legs, dtype=np.int64)
|
||||
sides = np.empty(n_legs, dtype=np.int64)
|
||||
option_types = np.empty(n_legs, dtype=np.int64)
|
||||
strikes = np.zeros(n_legs, dtype=np.float64)
|
||||
premiums = np.zeros(n_legs, dtype=np.float64)
|
||||
entry_prices = np.zeros(n_legs, dtype=np.float64)
|
||||
quantities = np.ones(n_legs, dtype=np.float64)
|
||||
multipliers = np.ones(n_legs, dtype=np.float64)
|
||||
ttes = np.full(n_legs, time_to_expiry, dtype=np.float64)
|
||||
vols = np.full(n_legs, volatility, dtype=np.float64)
|
||||
rates = np.full(n_legs, rate, dtype=np.float64)
|
||||
carries = np.full(n_legs, carry, dtype=np.float64)
|
||||
|
||||
_inst_map = {"option": 0, "future": 1, "stock": 2}
|
||||
for i, leg in enumerate(normalized):
|
||||
instruments[i] = _inst_map[leg.instrument]
|
||||
sides[i] = 1 if leg.side == "long" else -1
|
||||
option_types[i] = 1 if leg.option_type == "call" else -1
|
||||
if leg.strike is not None:
|
||||
strikes[i] = float(leg.strike)
|
||||
premiums[i] = float(leg.premium)
|
||||
if leg.entry_price is not None:
|
||||
entry_prices[i] = float(leg.entry_price)
|
||||
quantities[i] = float(leg.quantity)
|
||||
multipliers[i] = float(leg.multiplier)
|
||||
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_strategy_value_dense(
|
||||
grid,
|
||||
instruments,
|
||||
sides,
|
||||
option_types,
|
||||
strikes,
|
||||
premiums,
|
||||
entry_prices,
|
||||
quantities,
|
||||
multipliers,
|
||||
ttes,
|
||||
vols,
|
||||
rates,
|
||||
carries,
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
@@ -26,6 +26,15 @@ from ferro_ta._ferro_ta import (
|
||||
from ferro_ta._ferro_ta import (
|
||||
bsm_price_batch as _rust_bsm_price_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
expected_move as _rust_expected_move,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
extended_greeks as _rust_extended_greeks,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
extended_greeks_batch as _rust_extended_greeks_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
implied_volatility as _rust_implied_volatility,
|
||||
)
|
||||
@@ -50,6 +59,9 @@ from ferro_ta._ferro_ta import (
|
||||
from ferro_ta._ferro_ta import (
|
||||
option_greeks_batch as _rust_option_greeks_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
put_call_parity_deviation as _rust_put_call_parity_deviation,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
select_strike_delta as _rust_select_strike_delta,
|
||||
)
|
||||
@@ -73,11 +85,14 @@ ScalarOrArray: TypeAlias = float | NDArray[np.float64]
|
||||
|
||||
__all__ = [
|
||||
"OptionGreeks",
|
||||
"ExtendedGreeks",
|
||||
"SmileMetrics",
|
||||
"VolCone",
|
||||
"black_scholes_price",
|
||||
"black_76_price",
|
||||
"option_price",
|
||||
"greeks",
|
||||
"extended_greeks",
|
||||
"implied_volatility",
|
||||
"smile_metrics",
|
||||
"term_structure_slope",
|
||||
@@ -86,9 +101,63 @@ __all__ = [
|
||||
"iv_rank",
|
||||
"iv_percentile",
|
||||
"iv_zscore",
|
||||
"put_call_parity_deviation",
|
||||
"expected_move",
|
||||
"digital_option_price",
|
||||
"digital_option_greeks",
|
||||
"american_option_price",
|
||||
"early_exercise_premium",
|
||||
"close_to_close_vol",
|
||||
"parkinson_vol",
|
||||
"garman_klass_vol",
|
||||
"rogers_satchell_vol",
|
||||
"yang_zhang_vol",
|
||||
"vol_cone",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtendedGreeks:
|
||||
"""Container for second-order and cross Greeks."""
|
||||
|
||||
vanna: ScalarOrArray
|
||||
volga: ScalarOrArray
|
||||
charm: ScalarOrArray
|
||||
speed: ScalarOrArray
|
||||
color: ScalarOrArray
|
||||
|
||||
def to_dict(self) -> dict[str, ScalarOrArray]:
|
||||
return {
|
||||
"vanna": self.vanna,
|
||||
"volga": self.volga,
|
||||
"charm": self.charm,
|
||||
"speed": self.speed,
|
||||
"color": self.color,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VolCone:
|
||||
"""Historical realized vol distribution across window lengths."""
|
||||
|
||||
windows: NDArray[np.float64]
|
||||
min: NDArray[np.float64]
|
||||
p25: NDArray[np.float64]
|
||||
median: NDArray[np.float64]
|
||||
p75: NDArray[np.float64]
|
||||
max: NDArray[np.float64]
|
||||
|
||||
def to_dict(self) -> dict[str, NDArray[np.float64]]:
|
||||
return {
|
||||
"windows": self.windows,
|
||||
"min": self.min,
|
||||
"p25": self.p25,
|
||||
"median": self.median,
|
||||
"p75": self.p75,
|
||||
"max": self.max,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OptionGreeks:
|
||||
"""Container for first-order Greeks."""
|
||||
@@ -630,3 +699,897 @@ def select_strike(
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return None if strike is None else float(strike)
|
||||
|
||||
|
||||
def extended_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,
|
||||
) -> ExtendedGreeks:
|
||||
"""Return vanna, volga, charm, speed, and color (second-order / cross Greeks).
|
||||
|
||||
All Greeks are computed via closed-form BSM formulas. Black-76 is not
|
||||
yet supported and returns NaN for all five values.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
underlying:
|
||||
Current underlying (spot) price.
|
||||
strike:
|
||||
Option strike price.
|
||||
rate:
|
||||
Risk-free rate (annualised, decimal — e.g. ``0.05`` for 5 %).
|
||||
time_to_expiry:
|
||||
Time to expiry in years.
|
||||
volatility:
|
||||
Implied volatility (annualised, decimal).
|
||||
option_type:
|
||||
``"call"`` (default) or ``"put"``.
|
||||
model:
|
||||
``"bsm"`` (default). ``"black76"`` returns NaN for all fields.
|
||||
carry:
|
||||
Continuous carry / dividend yield (annualised, decimal). Default 0.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ExtendedGreeks
|
||||
Named tuple with fields:
|
||||
|
||||
- **vanna** — ∂Δ/∂σ: sensitivity of delta to a change in vol.
|
||||
- **volga** — ∂²V/∂σ² (vomma): sensitivity of vega to a change in vol.
|
||||
- **charm** — ∂Δ/∂t: daily rate of change in delta (theta of delta).
|
||||
- **speed** — ∂Γ/∂S: rate of change in gamma with respect to spot.
|
||||
- **color** — ∂Γ/∂t: daily rate of change in gamma.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Inputs may be scalars or broadcastable arrays. When arrays are supplied
|
||||
each field of the returned :class:`ExtendedGreeks` is an ``NDArray``.
|
||||
|
||||
Closed-form expressions (BSM, zero-carry)::
|
||||
|
||||
vanna = -e^{-qT} · φ(d₁) · d₂ / σ
|
||||
volga = S · e^{-qT} · φ(d₁) · √T · d₁ · d₂ / σ
|
||||
charm = -e^{-qT} · φ(d₁) · [2(r-q)T - d₂·σ·√T] / (2T·σ·√T)
|
||||
speed = -Γ/S · (d₁/(σ√T) + 1)
|
||||
color = -Γ · [r-q + d₁·σ/(2√T) + (2(r-q)T - d₂·σ√T)·d₁/(2T·σ√T)]
|
||||
"""
|
||||
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:
|
||||
vanna, volga, charm, speed, color = _rust_extended_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 ExtendedGreeks(vanna, volga, charm, speed, color)
|
||||
|
||||
vanna, volga, charm, speed, color = _rust_extended_greeks_batch(
|
||||
arrays["underlying"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
option_type,
|
||||
model,
|
||||
arrays["carry"],
|
||||
)
|
||||
return ExtendedGreeks(
|
||||
np.asarray(vanna, dtype=np.float64),
|
||||
np.asarray(volga, dtype=np.float64),
|
||||
np.asarray(charm, dtype=np.float64),
|
||||
np.asarray(speed, dtype=np.float64),
|
||||
np.asarray(color, dtype=np.float64),
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def put_call_parity_deviation(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
spot: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
*,
|
||||
carry: float = 0.0,
|
||||
) -> float:
|
||||
"""Put-call parity deviation: ``C − P − (S·e^{−q·T} − K·e^{−r·T})``.
|
||||
|
||||
At no-arbitrage the deviation is exactly 0. A non-zero result indicates
|
||||
mispricing, a data error, or a stale quote.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
call_price:
|
||||
Market or model price of the call option.
|
||||
put_price:
|
||||
Market or model price of the put option.
|
||||
spot:
|
||||
Current underlying price.
|
||||
strike:
|
||||
Common strike price of the call and put.
|
||||
rate:
|
||||
Risk-free rate (annualised, decimal).
|
||||
time_to_expiry:
|
||||
Time to expiry in years.
|
||||
carry:
|
||||
Continuous dividend yield / carry rate (annualised, decimal).
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
Signed deviation. Positive → call is overpriced relative to put;
|
||||
negative → put is overpriced relative to call.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.analysis.options import option_price, put_call_parity_deviation
|
||||
>>> call = option_price(100, 100, 0.05, 1.0, 0.2, option_type="call")
|
||||
>>> put = option_price(100, 100, 0.05, 1.0, 0.2, option_type="put")
|
||||
>>> put_call_parity_deviation(call, put, 100, 100, 0.05, 1.0) # ≈ 0.0
|
||||
"""
|
||||
try:
|
||||
return float(
|
||||
_rust_put_call_parity_deviation(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(spot),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
float(carry),
|
||||
)
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def expected_move(
|
||||
spot: float,
|
||||
iv: float,
|
||||
days_to_expiry: float,
|
||||
trading_days_per_year: float = 252.0,
|
||||
) -> tuple[float, float]:
|
||||
"""Expected ±1σ move over *days_to_expiry* calendar days.
|
||||
|
||||
Uses the log-normal approximation::
|
||||
|
||||
upper_move = spot × e^{+σ√(days/trading_days)} − spot
|
||||
lower_move = spot × e^{−σ√(days/trading_days)} − spot
|
||||
|
||||
Parameters
|
||||
----------
|
||||
spot:
|
||||
Current underlying price.
|
||||
iv:
|
||||
Implied volatility (annualised, decimal — e.g. ``0.20`` for 20 %).
|
||||
days_to_expiry:
|
||||
Number of calendar days until expiry.
|
||||
trading_days_per_year:
|
||||
Annualisation factor (default 252).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[float, float]
|
||||
``(lower_move, upper_move)`` — signed absolute price changes from
|
||||
``spot``. ``lower_move < 0``, ``upper_move > 0``.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Because of log-normal skew, ``|upper_move| > |lower_move|``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.analysis.options import expected_move
|
||||
>>> lower, upper = expected_move(100.0, 0.20, 30)
|
||||
>>> round(upper, 2)
|
||||
7.14
|
||||
"""
|
||||
try:
|
||||
lower, upper = _rust_expected_move(
|
||||
float(spot), float(iv), float(days_to_expiry), float(trading_days_per_year)
|
||||
)
|
||||
return float(lower), float(upper)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Digital options — populated once the Rust bridge is built
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def digital_option_price(
|
||||
underlying: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
digital_type: str = "cash_or_nothing",
|
||||
carry: ArrayLike | float = 0.0,
|
||||
) -> ScalarOrArray:
|
||||
"""Price a digital (binary) option under BSM.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
underlying:
|
||||
Current underlying (spot) price.
|
||||
strike:
|
||||
Option strike price.
|
||||
rate:
|
||||
Risk-free rate (annualised, decimal).
|
||||
time_to_expiry:
|
||||
Time to expiry in years.
|
||||
volatility:
|
||||
Implied volatility (annualised, decimal).
|
||||
option_type:
|
||||
``"call"`` (default) or ``"put"``.
|
||||
digital_type:
|
||||
``"cash_or_nothing"`` (default) — pays 1 unit of cash if ITM at
|
||||
expiry; or ``"asset_or_nothing"`` — pays the underlying asset price.
|
||||
carry:
|
||||
Continuous carry / dividend yield (annualised, decimal). Default 0.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float or NDArray[float64]
|
||||
Option price. Returns a scalar when all inputs are scalars, or an
|
||||
array when any input is an array.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Closed-form BSM formulas::
|
||||
|
||||
Cash-or-nothing call: e^{−rT} · N(d₂)
|
||||
Cash-or-nothing put: e^{−rT} · N(−d₂)
|
||||
Asset-or-nothing call: S · e^{−qT} · N(d₁)
|
||||
Asset-or-nothing put: S · e^{−qT} · N(−d₁)
|
||||
|
||||
Put-call parity for cash-or-nothing: call + put = e^{−rT}.
|
||||
Put-call parity for asset-or-nothing: call + put = S · e^{−qT}.
|
||||
|
||||
Invalid inputs (non-positive spot/strike, negative time or vol) return NaN.
|
||||
"""
|
||||
from ferro_ta._ferro_ta import digital_price as _rust_digital_price
|
||||
from ferro_ta._ferro_ta import digital_price_batch as _rust_digital_price_batch
|
||||
|
||||
option_type = _validate_option_type(option_type)
|
||||
digital_type = digital_type.lower().replace("-", "_")
|
||||
if digital_type not in {"cash_or_nothing", "asset_or_nothing"}:
|
||||
raise FerroTAValueError(
|
||||
"digital_type must be 'cash_or_nothing' or 'asset_or_nothing'."
|
||||
)
|
||||
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:
|
||||
return float(
|
||||
_rust_digital_price(
|
||||
float(arrays["underlying"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
float(arrays["volatility"][0]),
|
||||
option_type,
|
||||
digital_type,
|
||||
float(arrays["carry"][0]),
|
||||
)
|
||||
)
|
||||
out = _rust_digital_price_batch(
|
||||
arrays["underlying"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
option_type,
|
||||
digital_type,
|
||||
arrays["carry"],
|
||||
)
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def digital_option_greeks(
|
||||
underlying: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
digital_type: str = "cash_or_nothing",
|
||||
carry: ArrayLike | float = 0.0,
|
||||
) -> OptionGreeks:
|
||||
"""Delta, gamma, and vega for a digital option via numerical bumping.
|
||||
|
||||
Uses central finite differences (spot bump ε = spot × 10⁻³ for delta/gamma;
|
||||
vol bump ε = 10⁻³ for vega). Theta and rho are set to NaN.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
underlying, strike, rate, time_to_expiry, volatility, option_type, carry:
|
||||
Same as :func:`digital_option_price`.
|
||||
digital_type:
|
||||
``"cash_or_nothing"`` (default) or ``"asset_or_nothing"``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
OptionGreeks
|
||||
Named tuple; only ``delta``, ``gamma``, ``vega`` are finite.
|
||||
``theta`` and ``rho`` are NaN.
|
||||
"""
|
||||
from ferro_ta._ferro_ta import digital_greeks as _rust_digital_greeks
|
||||
from ferro_ta._ferro_ta import digital_greeks_batch as _rust_digital_greeks_batch
|
||||
|
||||
option_type = _validate_option_type(option_type)
|
||||
digital_type = digital_type.lower().replace("-", "_")
|
||||
if digital_type not in {"cash_or_nothing", "asset_or_nothing"}:
|
||||
raise FerroTAValueError(
|
||||
"digital_type must be 'cash_or_nothing' or 'asset_or_nothing'."
|
||||
)
|
||||
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 = _rust_digital_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,
|
||||
digital_type,
|
||||
float(arrays["carry"][0]),
|
||||
)
|
||||
return OptionGreeks(delta, gamma, vega, float("nan"), float("nan"))
|
||||
|
||||
delta, gamma, vega = _rust_digital_greeks_batch(
|
||||
arrays["underlying"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
option_type,
|
||||
digital_type,
|
||||
arrays["carry"],
|
||||
)
|
||||
nan_arr = np.full_like(delta, float("nan"))
|
||||
return OptionGreeks(
|
||||
np.asarray(delta, dtype=np.float64),
|
||||
np.asarray(gamma, dtype=np.float64),
|
||||
np.asarray(vega, dtype=np.float64),
|
||||
nan_arr,
|
||||
nan_arr,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# American options — populated once the Rust bridge is built
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def american_option_price(
|
||||
underlying: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
carry: ArrayLike | float = 0.0,
|
||||
) -> ScalarOrArray:
|
||||
"""American option price using the Barone-Adesi-Whaley (1987) approximation.
|
||||
|
||||
Accurate to within a few basis points for standard equity/index parameters.
|
||||
O(1) per evaluation — suitable for batch pricing or calibration.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
underlying:
|
||||
Current underlying (spot) price.
|
||||
strike:
|
||||
Option strike price.
|
||||
rate:
|
||||
Risk-free rate (annualised, decimal).
|
||||
time_to_expiry:
|
||||
Time to expiry in years.
|
||||
volatility:
|
||||
Implied volatility (annualised, decimal).
|
||||
option_type:
|
||||
``"call"`` (default) or ``"put"``.
|
||||
carry:
|
||||
Continuous carry / dividend yield (annualised, decimal). Default 0.
|
||||
For calls with ``carry = 0`` (no dividends) early exercise is never
|
||||
optimal and the result equals the European BSM price.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float or NDArray[float64]
|
||||
American option price ≥ European BSM price.
|
||||
|
||||
Notes
|
||||
-----
|
||||
The BAW approximation uses a quadratic equation to find the critical
|
||||
exercise boundary S* via Newton-Raphson iteration, then adds the early
|
||||
exercise premium on top of the European price.
|
||||
|
||||
Reference: Barone-Adesi, G. & Whaley, R.E. (1987). "Efficient Analytic
|
||||
Approximation of American Option Values." *Journal of Finance*, 42(2),
|
||||
301–320.
|
||||
|
||||
See Also
|
||||
--------
|
||||
early_exercise_premium : Difference between American and European prices.
|
||||
"""
|
||||
from ferro_ta._ferro_ta import american_price as _rust_american_price
|
||||
from ferro_ta._ferro_ta import american_price_batch as _rust_american_price_batch
|
||||
|
||||
option_type = _validate_option_type(option_type)
|
||||
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:
|
||||
return float(
|
||||
_rust_american_price(
|
||||
float(arrays["underlying"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
float(arrays["volatility"][0]),
|
||||
option_type,
|
||||
float(arrays["carry"][0]),
|
||||
)
|
||||
)
|
||||
out = _rust_american_price_batch(
|
||||
arrays["underlying"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
option_type,
|
||||
arrays["carry"],
|
||||
)
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def early_exercise_premium(
|
||||
underlying: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
carry: ArrayLike | float = 0.0,
|
||||
) -> ScalarOrArray:
|
||||
"""Early exercise premium: American price − European BSM price.
|
||||
|
||||
Represents the additional value an American option holder gains from the
|
||||
right to exercise before expiry. Always ≥ 0.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
underlying, strike, rate, time_to_expiry, volatility, option_type, carry:
|
||||
Same as :func:`american_option_price`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float or NDArray[float64]
|
||||
Premium ≥ 0. Typically 0 for calls with no dividends.
|
||||
|
||||
Notes
|
||||
-----
|
||||
For equity calls with zero carry (no dividends), early exercise is never
|
||||
optimal so the premium is ≈ 0. For puts (or calls on dividend-paying
|
||||
underlyings), the premium increases with in-the-moneyness, rate, and
|
||||
time to expiry.
|
||||
"""
|
||||
from ferro_ta._ferro_ta import (
|
||||
early_exercise_premium as _rust_early_exercise_premium,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
early_exercise_premium_batch as _rust_early_exercise_premium_batch,
|
||||
)
|
||||
|
||||
option_type = _validate_option_type(option_type)
|
||||
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:
|
||||
return float(
|
||||
_rust_early_exercise_premium(
|
||||
float(arrays["underlying"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
float(arrays["volatility"][0]),
|
||||
option_type,
|
||||
float(arrays["carry"][0]),
|
||||
)
|
||||
)
|
||||
out = _rust_early_exercise_premium_batch(
|
||||
arrays["underlying"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
option_type,
|
||||
arrays["carry"],
|
||||
)
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Historical volatility estimators — populated once the Rust bridge is built
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def close_to_close_vol(
|
||||
close: ArrayLike,
|
||||
window: int = 20,
|
||||
trading_days_per_year: float = 252.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Rolling close-to-close realized volatility (annualised).
|
||||
|
||||
Baseline estimator — uses only closing prices. Less efficient than OHLC
|
||||
estimators but requires only daily close data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close:
|
||||
Array of closing prices (length ≥ window + 1).
|
||||
window:
|
||||
Rolling look-back period in bars (default 20).
|
||||
trading_days_per_year:
|
||||
Annualisation factor (default 252).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[float64]
|
||||
Same length as *close*. First ``window`` values are NaN.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Formula::
|
||||
|
||||
σ = √( Σᵢ ln²(Cᵢ/Cᵢ₋₁) / window × trading_days_per_year )
|
||||
|
||||
No Bessel correction is applied (population variance, not sample variance).
|
||||
"""
|
||||
from ferro_ta._ferro_ta import close_to_close_vol as _rust_ctc
|
||||
|
||||
try:
|
||||
arr = _to_f64(close)
|
||||
return np.asarray(
|
||||
_rust_ctc(arr, int(window), float(trading_days_per_year)), dtype=np.float64
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def parkinson_vol(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
window: int = 20,
|
||||
trading_days_per_year: float = 252.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Rolling Parkinson high-low realized volatility estimator (annualised).
|
||||
|
||||
~5× more efficient than close-to-close for diffusion processes.
|
||||
Does **not** account for drift or overnight gaps.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high, low:
|
||||
Arrays of daily high and low prices (same length, ≥ window).
|
||||
window:
|
||||
Rolling look-back period in bars (default 20).
|
||||
trading_days_per_year:
|
||||
Annualisation factor (default 252).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[float64]
|
||||
Same length as *high*. First ``window - 1`` values are NaN.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Formula per window::
|
||||
|
||||
σ² = (1 / (4·ln2·window)) · Σ ln²(Hᵢ/Lᵢ) × trading_days_per_year
|
||||
|
||||
Reference: Parkinson, M. (1980). "The Extreme Value Method for
|
||||
Estimating the Variance of the Rate of Return." *Journal of Business*, 53(1).
|
||||
"""
|
||||
from ferro_ta._ferro_ta import parkinson_vol as _rust_parkinson
|
||||
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_parkinson(
|
||||
_to_f64(high), _to_f64(low), int(window), float(trading_days_per_year)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def garman_klass_vol(
|
||||
open: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
window: int = 20,
|
||||
trading_days_per_year: float = 252.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Rolling Garman-Klass OHLC realized volatility estimator (annualised).
|
||||
|
||||
Extends Parkinson by incorporating the open-close return. ~7.4× more
|
||||
efficient than close-to-close. Does **not** handle overnight gaps.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
open, high, low, close:
|
||||
Arrays of daily OHLC prices (same length, ≥ window).
|
||||
window:
|
||||
Rolling look-back period in bars (default 20).
|
||||
trading_days_per_year:
|
||||
Annualisation factor (default 252).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[float64]
|
||||
Same length as *close*. First ``window - 1`` values are NaN.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Per-bar contribution::
|
||||
|
||||
GK = 0.5·ln²(H/L) − (2·ln2 − 1)·ln²(C/O)
|
||||
|
||||
Reference: Garman, M.B. & Klass, M.J. (1980). "On the Estimation of
|
||||
Security Price Volatilities from Historical Data." *Journal of Business*, 53(1).
|
||||
"""
|
||||
from ferro_ta._ferro_ta import garman_klass_vol as _rust_gk
|
||||
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_gk(
|
||||
_to_f64(open),
|
||||
_to_f64(high),
|
||||
_to_f64(low),
|
||||
_to_f64(close),
|
||||
int(window),
|
||||
float(trading_days_per_year),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def rogers_satchell_vol(
|
||||
open: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
window: int = 20,
|
||||
trading_days_per_year: float = 252.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Rolling Rogers-Satchell OHLC realized volatility estimator (annualised).
|
||||
|
||||
Drift-invariant: unbiased for assets with non-zero expected return.
|
||||
Does **not** handle overnight gaps.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
open, high, low, close:
|
||||
Arrays of daily OHLC prices (same length, ≥ window).
|
||||
window:
|
||||
Rolling look-back period in bars (default 20).
|
||||
trading_days_per_year:
|
||||
Annualisation factor (default 252).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[float64]
|
||||
Same length as *close*. First ``window - 1`` values are NaN.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Per-bar contribution (u = ln(H/O), d = ln(L/O), c = ln(C/O))::
|
||||
|
||||
RS = u·(u − c) + d·(d − c)
|
||||
|
||||
Reference: Rogers, L.C.G. & Satchell, S.E. (1991). "Estimating Variance
|
||||
from High, Low and Closing Prices." *Annals of Applied Probability*, 1(4).
|
||||
"""
|
||||
from ferro_ta._ferro_ta import rogers_satchell_vol as _rust_rs
|
||||
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_rs(
|
||||
_to_f64(open),
|
||||
_to_f64(high),
|
||||
_to_f64(low),
|
||||
_to_f64(close),
|
||||
int(window),
|
||||
float(trading_days_per_year),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def yang_zhang_vol(
|
||||
open: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
window: int = 20,
|
||||
trading_days_per_year: float = 252.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Rolling Yang-Zhang OHLC realized volatility estimator (annualised).
|
||||
|
||||
The most efficient standard estimator (~14× vs close-to-close). Handles
|
||||
overnight gaps by combining overnight, intraday open-close, and
|
||||
Rogers-Satchell variance components with an optimal weight *k*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
open, high, low, close:
|
||||
Arrays of daily OHLC prices (same length, ≥ window + 1).
|
||||
window:
|
||||
Rolling look-back period in bars (default 20).
|
||||
trading_days_per_year:
|
||||
Annualisation factor (default 252).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[float64]
|
||||
Same length as *close*. First ``window`` values are NaN.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Mixed estimator::
|
||||
|
||||
σ²_YZ = σ²_overnight + k·σ²_open_close + (1−k)·σ²_RS
|
||||
|
||||
where k = 0.34 / (1.34 + (window+1)/(window-1)).
|
||||
|
||||
Reference: Yang, D. & Zhang, Q. (2000). "Drift-Independent Volatility
|
||||
Estimation Based on High, Low, Open, and Close Prices."
|
||||
*Journal of Business*, 73(3).
|
||||
"""
|
||||
from ferro_ta._ferro_ta import yang_zhang_vol as _rust_yz
|
||||
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_yz(
|
||||
_to_f64(open),
|
||||
_to_f64(high),
|
||||
_to_f64(low),
|
||||
_to_f64(close),
|
||||
int(window),
|
||||
float(trading_days_per_year),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def vol_cone(
|
||||
close: ArrayLike,
|
||||
*,
|
||||
windows: tuple[int, ...] = (21, 42, 63, 126, 252),
|
||||
trading_days_per_year: float = 252.0,
|
||||
) -> VolCone:
|
||||
"""Historical realised vol distribution across window lengths (volatility cone).
|
||||
|
||||
For each window, computes the full history of rolling close-to-close
|
||||
realised vol, then returns the min / p25 / median / p75 / max distribution.
|
||||
Contextualises current implied vol: "Is 30 % IV cheap or expensive?"
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close:
|
||||
Array of closing prices (length ≥ max(windows) + 1).
|
||||
windows:
|
||||
Tuple of rolling window sizes in bars. Default ``(21, 42, 63, 126, 252)``
|
||||
(approx. 1 month, 2 months, 3 months, 6 months, 1 year).
|
||||
trading_days_per_year:
|
||||
Annualisation factor (default 252).
|
||||
|
||||
Returns
|
||||
-------
|
||||
VolCone
|
||||
Dataclass with arrays ``windows``, ``min``, ``p25``, ``median``,
|
||||
``p75``, ``max`` — one value per element of *windows*.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Uses close-to-close vol internally. Overlay the current IV on the cone
|
||||
to see whether it is historically cheap or expensive for each tenor.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.options import vol_cone
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> close = 100 * np.cumprod(np.exp(rng.normal(0, 0.01, 500)))
|
||||
>>> cone = vol_cone(close, windows=(21, 63, 252))
|
||||
>>> cone.median # annualised median realised vol per window
|
||||
"""
|
||||
from ferro_ta._ferro_ta import vol_cone as _rust_vol_cone
|
||||
|
||||
try:
|
||||
arr = _to_f64(close)
|
||||
slices = _rust_vol_cone(arr, list(windows), float(trading_days_per_year))
|
||||
windows_arr = np.array([s[0] for s in slices], dtype=np.float64)
|
||||
return VolCone(
|
||||
windows=windows_arr,
|
||||
min=np.array([s[1] for s in slices], dtype=np.float64),
|
||||
p25=np.array([s[2] for s in slices], dtype=np.float64),
|
||||
median=np.array([s[3] for s in slices], dtype=np.float64),
|
||||
p75=np.array([s[4] for s in slices], dtype=np.float64),
|
||||
max=np.array([s[5] for s in slices], dtype=np.float64),
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
@@ -147,9 +147,9 @@ class SimulationLimits:
|
||||
@dataclass(frozen=True)
|
||||
class StrategyLeg:
|
||||
underlying: str
|
||||
expiry_selector: ExpirySelector
|
||||
strike_selector: StrikeSelector
|
||||
option_type: str
|
||||
expiry_selector: ExpirySelector | None
|
||||
strike_selector: StrikeSelector | None
|
||||
option_type: str | None
|
||||
side: str = "long"
|
||||
quantity: int = 1
|
||||
instrument: str = "option"
|
||||
@@ -158,12 +158,21 @@ class StrategyLeg:
|
||||
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.instrument not in {"option", "future", "stock"}:
|
||||
raise FerroTAValueError(
|
||||
"instrument must be 'option', 'future', or 'stock'."
|
||||
)
|
||||
if self.instrument == "option":
|
||||
if self.option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError(
|
||||
"option legs require option_type='call' or 'put'."
|
||||
)
|
||||
if self.expiry_selector is None:
|
||||
raise FerroTAInputError("option legs require expiry_selector.")
|
||||
if self.strike_selector is None:
|
||||
raise FerroTAInputError("option legs require strike_selector.")
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use ferro_ta_core::options::american::{
|
||||
american_price_baw as core_american_price,
|
||||
early_exercise_premium as core_early_exercise_premium,
|
||||
};
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = 0.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn american_price(
|
||||
underlying: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_type: &str,
|
||||
carry: f64,
|
||||
) -> PyResult<f64> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
Ok(core_american_price(
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
kind,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = None))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn american_price_batch<'py>(
|
||||
py: Python<'py>,
|
||||
underlying: PyReadonlyArray1<'py, f64>,
|
||||
strike: PyReadonlyArray1<'py, f64>,
|
||||
rate: PyReadonlyArray1<'py, f64>,
|
||||
time_to_expiry: PyReadonlyArray1<'py, f64>,
|
||||
volatility: PyReadonlyArray1<'py, f64>,
|
||||
option_type: &str,
|
||||
carry: Option<PyReadonlyArray1<'py, f64>>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let underlying = underlying.as_slice()?;
|
||||
let strike = strike.as_slice()?;
|
||||
let rate = rate.as_slice()?;
|
||||
let tte = time_to_expiry.as_slice()?;
|
||||
let vol = volatility.as_slice()?;
|
||||
let n = underlying.len();
|
||||
let carry_vec = match carry {
|
||||
Some(arr) => arr.as_slice()?.to_vec(),
|
||||
None => vec![0.0; n],
|
||||
};
|
||||
let out: Vec<f64> = underlying
|
||||
.iter()
|
||||
.zip(strike.iter())
|
||||
.zip(rate.iter())
|
||||
.zip(tte.iter())
|
||||
.zip(vol.iter())
|
||||
.zip(carry_vec.iter())
|
||||
.map(|(((((&u, &k), &r), &t), &v), &c)| core_american_price(u, k, r, c, t, v, kind))
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = 0.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn early_exercise_premium(
|
||||
underlying: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_type: &str,
|
||||
carry: f64,
|
||||
) -> PyResult<f64> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
Ok(core_early_exercise_premium(
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
kind,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = None))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn early_exercise_premium_batch<'py>(
|
||||
py: Python<'py>,
|
||||
underlying: PyReadonlyArray1<'py, f64>,
|
||||
strike: PyReadonlyArray1<'py, f64>,
|
||||
rate: PyReadonlyArray1<'py, f64>,
|
||||
time_to_expiry: PyReadonlyArray1<'py, f64>,
|
||||
volatility: PyReadonlyArray1<'py, f64>,
|
||||
option_type: &str,
|
||||
carry: Option<PyReadonlyArray1<'py, f64>>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let underlying = underlying.as_slice()?;
|
||||
let strike = strike.as_slice()?;
|
||||
let rate = rate.as_slice()?;
|
||||
let tte = time_to_expiry.as_slice()?;
|
||||
let vol = volatility.as_slice()?;
|
||||
let n = underlying.len();
|
||||
let carry_vec = match carry {
|
||||
Some(arr) => arr.as_slice()?.to_vec(),
|
||||
None => vec![0.0; n],
|
||||
};
|
||||
let out: Vec<f64> = underlying
|
||||
.iter()
|
||||
.zip(strike.iter())
|
||||
.zip(rate.iter())
|
||||
.zip(tte.iter())
|
||||
.zip(vol.iter())
|
||||
.zip(carry_vec.iter())
|
||||
.map(|(((((&u, &k), &r), &t), &v), &c)| core_early_exercise_premium(u, k, r, c, t, v, kind))
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use ferro_ta_core::options::digital::{
|
||||
digital_greeks as core_digital_greeks, digital_price as core_digital_price, DigitalKind,
|
||||
};
|
||||
|
||||
fn parse_digital_kind(s: &str) -> PyResult<DigitalKind> {
|
||||
match s.to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"cash_or_nothing" | "cash" => Ok(DigitalKind::CashOrNothing),
|
||||
"asset_or_nothing" | "asset" => Ok(DigitalKind::AssetOrNothing),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"digital_type must be 'cash_or_nothing' or 'asset_or_nothing'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = 0.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn digital_price(
|
||||
underlying: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_type: &str,
|
||||
digital_type: &str,
|
||||
carry: f64,
|
||||
) -> PyResult<f64> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let dkind = parse_digital_kind(digital_type)?;
|
||||
Ok(core_digital_price(
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
kind,
|
||||
dkind,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = None))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn digital_price_batch<'py>(
|
||||
py: Python<'py>,
|
||||
underlying: PyReadonlyArray1<'py, f64>,
|
||||
strike: PyReadonlyArray1<'py, f64>,
|
||||
rate: PyReadonlyArray1<'py, f64>,
|
||||
time_to_expiry: PyReadonlyArray1<'py, f64>,
|
||||
volatility: PyReadonlyArray1<'py, f64>,
|
||||
option_type: &str,
|
||||
digital_type: &str,
|
||||
carry: Option<PyReadonlyArray1<'py, f64>>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let dkind = parse_digital_kind(digital_type)?;
|
||||
let underlying = underlying.as_slice()?;
|
||||
let strike = strike.as_slice()?;
|
||||
let rate = rate.as_slice()?;
|
||||
let tte = time_to_expiry.as_slice()?;
|
||||
let vol = volatility.as_slice()?;
|
||||
let n = underlying.len();
|
||||
let carry_vec = match carry {
|
||||
Some(arr) => arr.as_slice()?.to_vec(),
|
||||
None => vec![0.0; n],
|
||||
};
|
||||
let out: Vec<f64> = underlying
|
||||
.iter()
|
||||
.zip(strike.iter())
|
||||
.zip(rate.iter())
|
||||
.zip(tte.iter())
|
||||
.zip(vol.iter())
|
||||
.zip(carry_vec.iter())
|
||||
.map(|(((((&u, &k), &r), &t), &v), &c)| core_digital_price(u, k, r, c, t, v, kind, dkind))
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = 0.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn digital_greeks(
|
||||
underlying: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_type: &str,
|
||||
digital_type: &str,
|
||||
carry: f64,
|
||||
) -> PyResult<(f64, f64, f64)> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let dkind = parse_digital_kind(digital_type)?;
|
||||
Ok(core_digital_greeks(
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
kind,
|
||||
dkind,
|
||||
))
|
||||
}
|
||||
|
||||
type GreekTriple<'py> = (
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
);
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = None))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn digital_greeks_batch<'py>(
|
||||
py: Python<'py>,
|
||||
underlying: PyReadonlyArray1<'py, f64>,
|
||||
strike: PyReadonlyArray1<'py, f64>,
|
||||
rate: PyReadonlyArray1<'py, f64>,
|
||||
time_to_expiry: PyReadonlyArray1<'py, f64>,
|
||||
volatility: PyReadonlyArray1<'py, f64>,
|
||||
option_type: &str,
|
||||
digital_type: &str,
|
||||
carry: Option<PyReadonlyArray1<'py, f64>>,
|
||||
) -> PyResult<GreekTriple<'py>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let dkind = parse_digital_kind(digital_type)?;
|
||||
let underlying = underlying.as_slice()?;
|
||||
let strike = strike.as_slice()?;
|
||||
let rate = rate.as_slice()?;
|
||||
let tte = time_to_expiry.as_slice()?;
|
||||
let vol = volatility.as_slice()?;
|
||||
let n = underlying.len();
|
||||
let carry_vec = match carry {
|
||||
Some(arr) => arr.as_slice()?.to_vec(),
|
||||
None => vec![0.0; n],
|
||||
};
|
||||
let mut delta = Vec::with_capacity(n);
|
||||
let mut gamma = Vec::with_capacity(n);
|
||||
let mut vega = Vec::with_capacity(n);
|
||||
for (((((&u, &k), &r), &t), &v), &c) in underlying
|
||||
.iter()
|
||||
.zip(strike.iter())
|
||||
.zip(rate.iter())
|
||||
.zip(tte.iter())
|
||||
.zip(vol.iter())
|
||||
.zip(carry_vec.iter())
|
||||
{
|
||||
let (d, g, ve) = core_digital_greeks(u, k, r, c, t, v, kind, dkind);
|
||||
delta.push(d);
|
||||
gamma.push(g);
|
||||
vega.push(ve);
|
||||
}
|
||||
Ok((
|
||||
delta.into_pyarray(py),
|
||||
gamma.into_pyarray(py),
|
||||
vega.into_pyarray(py),
|
||||
))
|
||||
}
|
||||
@@ -2,6 +2,14 @@ use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
type ExtendedGreekArrays<'py> = (
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
);
|
||||
|
||||
type GreekArrays<'py> = (
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
@@ -123,3 +131,112 @@ pub fn option_greeks_batch<'py>(
|
||||
rho.into_pyarray(py),
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = 0.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn extended_greeks(
|
||||
underlying: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_type: &str,
|
||||
model: &str,
|
||||
carry: f64,
|
||||
) -> PyResult<(f64, f64, f64, f64, f64)> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let model = super::parse_pricing_model(model)?;
|
||||
let eg = ferro_ta_core::options::greeks::model_extended_greeks(
|
||||
ferro_ta_core::options::OptionEvaluation {
|
||||
contract: ferro_ta_core::options::OptionContract {
|
||||
model,
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind,
|
||||
},
|
||||
volatility,
|
||||
},
|
||||
);
|
||||
Ok((eg.vanna, eg.volga, eg.charm, eg.speed, eg.color))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = None))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn extended_greeks_batch<'py>(
|
||||
py: Python<'py>,
|
||||
underlying: PyReadonlyArray1<'py, f64>,
|
||||
strike: PyReadonlyArray1<'py, f64>,
|
||||
rate: PyReadonlyArray1<'py, f64>,
|
||||
time_to_expiry: PyReadonlyArray1<'py, f64>,
|
||||
volatility: PyReadonlyArray1<'py, f64>,
|
||||
option_type: &str,
|
||||
model: &str,
|
||||
carry: Option<PyReadonlyArray1<'py, f64>>,
|
||||
) -> PyResult<ExtendedGreekArrays<'py>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let model = super::parse_pricing_model(model)?;
|
||||
let underlying = underlying.as_slice()?;
|
||||
let strike = strike.as_slice()?;
|
||||
let rate = rate.as_slice()?;
|
||||
let time_to_expiry = time_to_expiry.as_slice()?;
|
||||
let volatility = volatility.as_slice()?;
|
||||
let carry_vec = match carry {
|
||||
Some(array) => array.as_slice()?.to_vec(),
|
||||
None => vec![0.0; underlying.len()],
|
||||
};
|
||||
validation::validate_equal_length(&[
|
||||
(underlying.len(), "underlying"),
|
||||
(strike.len(), "strike"),
|
||||
(rate.len(), "rate"),
|
||||
(time_to_expiry.len(), "time_to_expiry"),
|
||||
(volatility.len(), "volatility"),
|
||||
(carry_vec.len(), "carry"),
|
||||
])?;
|
||||
|
||||
let mut vanna = Vec::with_capacity(underlying.len());
|
||||
let mut volga = Vec::with_capacity(underlying.len());
|
||||
let mut charm = Vec::with_capacity(underlying.len());
|
||||
let mut speed = Vec::with_capacity(underlying.len());
|
||||
let mut color = Vec::with_capacity(underlying.len());
|
||||
for (((((&u, &k), &r), &t), &vol), &c) in underlying
|
||||
.iter()
|
||||
.zip(strike.iter())
|
||||
.zip(rate.iter())
|
||||
.zip(time_to_expiry.iter())
|
||||
.zip(volatility.iter())
|
||||
.zip(carry_vec.iter())
|
||||
{
|
||||
let eg = ferro_ta_core::options::greeks::model_extended_greeks(
|
||||
ferro_ta_core::options::OptionEvaluation {
|
||||
contract: ferro_ta_core::options::OptionContract {
|
||||
model,
|
||||
underlying: u,
|
||||
strike: k,
|
||||
rate: r,
|
||||
carry: c,
|
||||
time_to_expiry: t,
|
||||
kind,
|
||||
},
|
||||
volatility: vol,
|
||||
},
|
||||
);
|
||||
vanna.push(eg.vanna);
|
||||
volga.push(eg.volga);
|
||||
charm.push(eg.charm);
|
||||
speed.push(eg.speed);
|
||||
color.push(eg.color);
|
||||
}
|
||||
|
||||
Ok((
|
||||
vanna.into_pyarray(py),
|
||||
volga.into_pyarray(py),
|
||||
charm.into_pyarray(py),
|
||||
speed.into_pyarray(py),
|
||||
color.into_pyarray(py),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! PyO3 wrappers for options analytics.
|
||||
|
||||
mod american;
|
||||
mod chain;
|
||||
mod digital;
|
||||
mod greeks;
|
||||
mod iv;
|
||||
mod payoff;
|
||||
mod pricing;
|
||||
mod realized_vol;
|
||||
mod surface;
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
@@ -40,11 +43,20 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
self::pricing::black76_price_batch,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::pricing::put_call_parity_deviation,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::greeks::option_greeks, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::greeks::option_greeks_batch,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::greeks::extended_greeks, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::greeks::extended_greeks_batch,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::iv::implied_volatility, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::iv::implied_volatility_batch,
|
||||
@@ -58,6 +70,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
self::surface::term_structure_slope,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::surface::expected_move, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::chain::moneyness_labels, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::chain::select_strike_offset,
|
||||
@@ -80,5 +93,56 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
self::payoff::aggregate_greeks_legs,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::strategy_value_dense,
|
||||
m
|
||||
)?)?;
|
||||
// Digital options
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::digital::digital_price, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::digital::digital_price_batch,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::digital::digital_greeks, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::digital::digital_greeks_batch,
|
||||
m
|
||||
)?)?;
|
||||
// American options
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::american::american_price, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::american::american_price_batch,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::american::early_exercise_premium,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::american::early_exercise_premium_batch,
|
||||
m
|
||||
)?)?;
|
||||
// Historical volatility estimators + vol cone
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::realized_vol::close_to_close_vol,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::realized_vol::parkinson_vol,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::realized_vol::garman_klass_vol,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::realized_vol::rogers_satchell_vol,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::realized_vol::yang_zhang_vol,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::realized_vol::vol_cone, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+59
-7
@@ -7,6 +7,7 @@ use pyo3::types::{PyAny, PyTuple};
|
||||
enum Instrument {
|
||||
Option,
|
||||
Future,
|
||||
Stock,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -34,8 +35,9 @@ fn parse_instrument(v: i64) -> PyResult<Instrument> {
|
||||
match v {
|
||||
0 => Ok(Instrument::Option),
|
||||
1 => Ok(Instrument::Future),
|
||||
2 => Ok(Instrument::Stock),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"instrument must be 0 (option) or 1 (future)",
|
||||
"instrument must be 0 (option), 1 (future), or 2 (stock)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -62,8 +64,9 @@ fn parse_instrument_label(v: &str) -> PyResult<Instrument> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"option" => Ok(Instrument::Option),
|
||||
"future" => Ok(Instrument::Future),
|
||||
"stock" => Ok(Instrument::Stock),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"instrument must be 'option' or 'future'",
|
||||
"instrument must be 'option', 'future', or 'stock'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -202,7 +205,7 @@ pub fn strategy_payoff_dense<'py>(
|
||||
total[i] += leg_scale * (intrinsic - p);
|
||||
}
|
||||
}
|
||||
Instrument::Future => {
|
||||
Instrument::Future | Instrument::Stock => {
|
||||
let e = entry[leg_idx];
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - e);
|
||||
@@ -253,9 +256,9 @@ pub fn strategy_payoff_legs<'py>(
|
||||
total[i] += leg_scale * (intrinsic - premium);
|
||||
}
|
||||
}
|
||||
Instrument::Future => {
|
||||
Instrument::Future | Instrument::Stock => {
|
||||
let entry_price = leg_attr_optional_f64(&leg, "entry_price")?.ok_or_else(|| {
|
||||
PyValueError::new_err("Futures payoff legs require entry_price.")
|
||||
PyValueError::new_err("Futures/stock payoff legs require entry_price.")
|
||||
})?;
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - entry_price);
|
||||
@@ -323,7 +326,7 @@ pub fn aggregate_greeks_dense(
|
||||
let side_sign = parse_side(side[i])?.sign();
|
||||
let leg_scale = side_sign * qty[i] * mult[i];
|
||||
match instrument {
|
||||
Instrument::Future => {
|
||||
Instrument::Future | Instrument::Stock => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
Instrument::Option => {
|
||||
@@ -382,7 +385,7 @@ pub fn aggregate_greeks_legs(
|
||||
let leg_scale = side_sign * quantity * multiplier;
|
||||
|
||||
match instrument {
|
||||
Instrument::Future => {
|
||||
Instrument::Future | Instrument::Stock => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
Instrument::Option => {
|
||||
@@ -441,3 +444,52 @@ pub fn aggregate_greeks_legs(
|
||||
|
||||
Ok((delta, gamma, vega, theta, rho))
|
||||
}
|
||||
|
||||
/// Compute BSM-based strategy value over a spot grid (pre-expiry mark-to-market).
|
||||
///
|
||||
/// Unlike `strategy_payoff_dense` (which uses intrinsic at expiry), this function
|
||||
/// values each option leg using the Black-Scholes model price. Futures and stock
|
||||
/// legs are valued the same as in `strategy_payoff_dense`.
|
||||
///
|
||||
/// Delegates to `ferro_ta_core::options::payoff::strategy_value_grid`.
|
||||
///
|
||||
/// NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;`
|
||||
/// for this function to compile.
|
||||
#[pyfunction]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_value_dense<'py>(
|
||||
py: Python<'py>,
|
||||
spot_grid: PyReadonlyArray1<'py, f64>,
|
||||
instruments: PyReadonlyArray1<'py, i64>,
|
||||
sides: PyReadonlyArray1<'py, i64>,
|
||||
option_types: PyReadonlyArray1<'py, i64>,
|
||||
strikes: PyReadonlyArray1<'py, f64>,
|
||||
premiums: PyReadonlyArray1<'py, f64>,
|
||||
entry_prices: PyReadonlyArray1<'py, f64>,
|
||||
quantities: PyReadonlyArray1<'py, f64>,
|
||||
multipliers: PyReadonlyArray1<'py, f64>,
|
||||
time_to_expiries: PyReadonlyArray1<'py, f64>,
|
||||
volatilities: PyReadonlyArray1<'py, f64>,
|
||||
rates_per_leg: PyReadonlyArray1<'py, f64>,
|
||||
carries_per_leg: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let grid = spot_grid.as_slice()?;
|
||||
let inst = instruments.as_slice()?;
|
||||
let side = sides.as_slice()?;
|
||||
let opt_t = option_types.as_slice()?;
|
||||
let strike = strikes.as_slice()?;
|
||||
let premium = premiums.as_slice()?;
|
||||
let entry = entry_prices.as_slice()?;
|
||||
let qty = quantities.as_slice()?;
|
||||
let mult = multipliers.as_slice()?;
|
||||
let tte = time_to_expiries.as_slice()?;
|
||||
let vol = volatilities.as_slice()?;
|
||||
let rate = rates_per_leg.as_slice()?;
|
||||
let carry = carries_per_leg.as_slice()?;
|
||||
|
||||
let result = ferro_ta_core::options::payoff::strategy_value_grid(
|
||||
grid, inst, side, opt_t, strike, premium, entry, qty, mult, tte, vol, rate, carry,
|
||||
);
|
||||
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
@@ -89,6 +89,29 @@ pub fn bsm_price_batch<'py>(
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (call_price, put_price, spot, strike, rate, time_to_expiry, carry = 0.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn put_call_parity_deviation(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
carry: f64,
|
||||
) -> PyResult<f64> {
|
||||
Ok(ferro_ta_core::options::pricing::put_call_parity_deviation(
|
||||
call_price,
|
||||
put_price,
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))]
|
||||
pub fn black76_price_batch<'py>(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use ferro_ta_core::options::realized_vol as core;
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, window, trading_days = 252.0))]
|
||||
pub fn close_to_close_vol<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
Ok(core::close_to_close_vol(close.as_slice()?, window, trading_days).into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (high, low, window, trading_days = 252.0))]
|
||||
pub fn parkinson_vol<'py>(
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
Ok(
|
||||
core::parkinson_vol(high.as_slice()?, low.as_slice()?, window, trading_days)
|
||||
.into_pyarray(py),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn garman_klass_vol<'py>(
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
Ok(core::garman_klass_vol(
|
||||
open.as_slice()?,
|
||||
high.as_slice()?,
|
||||
low.as_slice()?,
|
||||
close.as_slice()?,
|
||||
window,
|
||||
trading_days,
|
||||
)
|
||||
.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn rogers_satchell_vol<'py>(
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
Ok(core::rogers_satchell_vol(
|
||||
open.as_slice()?,
|
||||
high.as_slice()?,
|
||||
low.as_slice()?,
|
||||
close.as_slice()?,
|
||||
window,
|
||||
trading_days,
|
||||
)
|
||||
.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn yang_zhang_vol<'py>(
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
Ok(core::yang_zhang_vol(
|
||||
open.as_slice()?,
|
||||
high.as_slice()?,
|
||||
low.as_slice()?,
|
||||
close.as_slice()?,
|
||||
window,
|
||||
trading_days,
|
||||
)
|
||||
.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// Returns a list of (window, min, p25, median, p75, max) tuples.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, windows, trading_days = 252.0))]
|
||||
pub fn vol_cone(
|
||||
close: PyReadonlyArray1<'_, f64>,
|
||||
windows: Vec<usize>,
|
||||
trading_days: f64,
|
||||
) -> PyResult<Vec<(usize, f64, f64, f64, f64, f64)>> {
|
||||
let slices = core::vol_cone(close.as_slice()?, &windows, trading_days);
|
||||
Ok(slices
|
||||
.into_iter()
|
||||
.map(|s| (s.window, s.min, s.p25, s.median, s.p75, s.max))
|
||||
.collect())
|
||||
}
|
||||
@@ -47,3 +47,19 @@ pub fn term_structure_slope<'py>(
|
||||
tenors, atm_ivs,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (spot, iv, days_to_expiry, trading_days_per_year = 252.0))]
|
||||
pub fn expected_move(
|
||||
spot: f64,
|
||||
iv: f64,
|
||||
days_to_expiry: f64,
|
||||
trading_days_per_year: f64,
|
||||
) -> PyResult<(f64, f64)> {
|
||||
Ok(ferro_ta_core::options::surface::expected_move(
|
||||
spot,
|
||||
iv,
|
||||
days_to_expiry,
|
||||
trading_days_per_year,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -222,6 +222,404 @@ class TestStrategyAndPayoff:
|
||||
assert greeks.gamma > 0.0
|
||||
|
||||
|
||||
class TestStockInstrument:
|
||||
def test_stock_leg_payoff_linear(self):
|
||||
from ferro_ta.analysis.derivatives_payoff import stock_leg_payoff
|
||||
|
||||
spot_grid = np.array([90.0, 100.0, 110.0])
|
||||
payoff = stock_leg_payoff(spot_grid, entry_price=100.0, side="long")
|
||||
assert payoff == pytest.approx([-10.0, 0.0, 10.0])
|
||||
|
||||
def test_stock_leg_short_side(self):
|
||||
from ferro_ta.analysis.derivatives_payoff import stock_leg_payoff
|
||||
|
||||
spot_grid = np.array([90.0, 100.0, 110.0])
|
||||
payoff = stock_leg_payoff(spot_grid, entry_price=100.0, side="short")
|
||||
assert payoff == pytest.approx([10.0, 0.0, -10.0])
|
||||
|
||||
def test_strategy_payoff_with_stock_leg(self):
|
||||
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff
|
||||
|
||||
# Covered call: long stock + short call
|
||||
spot_grid = np.array([90.0, 100.0, 110.0, 120.0])
|
||||
legs = [
|
||||
PayoffLeg(instrument="stock", side="long", entry_price=100.0),
|
||||
PayoffLeg(
|
||||
instrument="option",
|
||||
side="short",
|
||||
option_type="call",
|
||||
strike=110.0,
|
||||
premium=3.0,
|
||||
),
|
||||
]
|
||||
payoff = strategy_payoff(spot_grid, legs=legs)
|
||||
assert payoff.shape == spot_grid.shape
|
||||
# At 90: stock P&L = -10, short call = +3 (OTM) → total = -7
|
||||
assert payoff[0] == pytest.approx(-7.0)
|
||||
# At 110: stock P&L = +10, short call = +3 (ATM, intrinsic=0) → total = +13
|
||||
assert payoff[2] == pytest.approx(13.0)
|
||||
|
||||
def test_strategy_leg_accepts_stock_instrument(self):
|
||||
from ferro_ta.analysis.options_strategy import StrategyLeg
|
||||
|
||||
leg = StrategyLeg(
|
||||
underlying="NIFTY",
|
||||
expiry_selector=None,
|
||||
strike_selector=None,
|
||||
option_type=None,
|
||||
instrument="stock",
|
||||
side="long",
|
||||
)
|
||||
assert leg.instrument == "stock"
|
||||
|
||||
|
||||
class TestExtendedGreeks:
|
||||
def test_extended_greeks_returns_five_values(self):
|
||||
from ferro_ta.analysis.options import ExtendedGreeks, extended_greeks
|
||||
|
||||
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.2, option_type="call")
|
||||
assert isinstance(eg, ExtendedGreeks)
|
||||
assert eg.vanna is not None
|
||||
assert eg.volga is not None
|
||||
assert eg.charm is not None
|
||||
assert eg.speed is not None
|
||||
assert eg.color is not None
|
||||
|
||||
def test_vanna_sign_otm_call(self):
|
||||
# OTM call vanna > 0 (delta increases as vol rises)
|
||||
from ferro_ta.analysis.options import extended_greeks
|
||||
|
||||
eg = extended_greeks(100.0, 110.0, 0.05, 1.0, 0.2, option_type="call")
|
||||
assert eg.vanna > 0.0
|
||||
|
||||
def test_extended_greeks_finite_for_valid_inputs(self):
|
||||
from ferro_ta.analysis.options import extended_greeks
|
||||
|
||||
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.25, option_type="put")
|
||||
assert np.isfinite(eg.vanna)
|
||||
assert np.isfinite(eg.volga)
|
||||
assert np.isfinite(eg.charm)
|
||||
assert np.isfinite(eg.speed)
|
||||
assert np.isfinite(eg.color)
|
||||
|
||||
def test_volga_positive_atm(self):
|
||||
# Volga is always non-negative for standard BSM inputs
|
||||
from ferro_ta.analysis.options import extended_greeks
|
||||
|
||||
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.2, option_type="call")
|
||||
assert eg.volga >= 0.0
|
||||
|
||||
|
||||
class TestDigitalOptions:
|
||||
def test_cash_or_nothing_call_atm(self):
|
||||
from ferro_ta.analysis.options import digital_option_price
|
||||
|
||||
# ATM cash-or-nothing call ≈ e^{-rT} * N(d2) ≈ 0.532
|
||||
price = digital_option_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
1.0,
|
||||
0.2,
|
||||
option_type="call",
|
||||
digital_type="cash_or_nothing",
|
||||
)
|
||||
assert 0.0 < price < 1.0
|
||||
assert price == pytest.approx(0.532, rel=0.02)
|
||||
|
||||
def test_asset_or_nothing_call_atm(self):
|
||||
from ferro_ta.analysis.options import digital_option_price
|
||||
|
||||
price = digital_option_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
1.0,
|
||||
0.2,
|
||||
option_type="call",
|
||||
digital_type="asset_or_nothing",
|
||||
)
|
||||
# asset-or-nothing call ≈ S * N(d1) < S
|
||||
assert 0.0 < price < 100.0
|
||||
|
||||
def test_put_call_parity_cash_or_nothing(self):
|
||||
from ferro_ta.analysis.options import digital_option_price
|
||||
|
||||
call = digital_option_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
1.0,
|
||||
0.25,
|
||||
option_type="call",
|
||||
digital_type="cash_or_nothing",
|
||||
)
|
||||
put = digital_option_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
1.0,
|
||||
0.25,
|
||||
option_type="put",
|
||||
digital_type="cash_or_nothing",
|
||||
)
|
||||
discount = np.exp(-0.05)
|
||||
assert call + put == pytest.approx(discount, rel=1e-6)
|
||||
|
||||
def test_digital_greeks_finite(self):
|
||||
from ferro_ta.analysis.options import digital_option_greeks
|
||||
|
||||
g = digital_option_greeks(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
1.0,
|
||||
0.2,
|
||||
option_type="call",
|
||||
digital_type="cash_or_nothing",
|
||||
)
|
||||
assert np.isfinite(g.delta)
|
||||
assert np.isfinite(g.gamma)
|
||||
assert np.isfinite(g.vega)
|
||||
|
||||
def test_digital_invalid_returns_nan(self):
|
||||
from ferro_ta.analysis.options import digital_option_price
|
||||
|
||||
price = digital_option_price(
|
||||
-1.0,
|
||||
100.0,
|
||||
0.05,
|
||||
1.0,
|
||||
0.2,
|
||||
option_type="call",
|
||||
digital_type="cash_or_nothing",
|
||||
)
|
||||
assert np.isnan(price)
|
||||
|
||||
|
||||
class TestAmericanOptions:
|
||||
def test_american_price_gte_european(self):
|
||||
from ferro_ta.analysis.options import american_option_price, option_price
|
||||
|
||||
spot, strike, rate, tte, vol = 100.0, 100.0, 0.05, 1.0, 0.2
|
||||
american = american_option_price(
|
||||
spot, strike, rate, tte, vol, option_type="call"
|
||||
)
|
||||
european = option_price(spot, strike, rate, tte, vol, option_type="call")
|
||||
assert american >= european - 1e-8
|
||||
|
||||
def test_early_exercise_premium_nonnegative(self):
|
||||
from ferro_ta.analysis.options import early_exercise_premium
|
||||
|
||||
premium = early_exercise_premium(
|
||||
100.0, 100.0, 0.05, 1.0, 0.2, option_type="put"
|
||||
)
|
||||
assert premium >= 0.0
|
||||
|
||||
def test_american_put_early_exercise_positive(self):
|
||||
# Deep ITM put with high rate should have meaningful early exercise premium
|
||||
from ferro_ta.analysis.options import early_exercise_premium
|
||||
|
||||
premium = early_exercise_premium(80.0, 100.0, 0.1, 0.5, 0.25, option_type="put")
|
||||
assert premium > 0.0
|
||||
|
||||
def test_american_call_no_dividends_no_premium(self):
|
||||
# With zero carry (no dividends), American call = European call
|
||||
from ferro_ta.analysis.options import early_exercise_premium
|
||||
|
||||
premium = early_exercise_premium(
|
||||
100.0, 100.0, 0.05, 1.0, 0.2, option_type="call", carry=0.0
|
||||
)
|
||||
assert premium == pytest.approx(0.0, abs=1e-4)
|
||||
|
||||
|
||||
class TestVolEstimators:
|
||||
@pytest.fixture
|
||||
def sample_ohlc(self):
|
||||
rng = np.random.default_rng(42)
|
||||
n = 100
|
||||
log_ret = rng.normal(0.0, 0.01, n)
|
||||
close = 100.0 * np.cumprod(np.exp(log_ret))
|
||||
high = close * np.exp(np.abs(rng.normal(0.0, 0.005, n)))
|
||||
low = close * np.exp(-np.abs(rng.normal(0.0, 0.005, n)))
|
||||
open_ = np.roll(close, 1)
|
||||
open_[0] = close[0]
|
||||
return open_, high, low, close
|
||||
|
||||
def test_close_to_close_vol_length(self, sample_ohlc):
|
||||
from ferro_ta.analysis.options import close_to_close_vol
|
||||
|
||||
_, _, _, close = sample_ohlc
|
||||
out = close_to_close_vol(close, window=20)
|
||||
assert len(out) == len(close)
|
||||
|
||||
def test_close_to_close_vol_warmup_nan(self, sample_ohlc):
|
||||
from ferro_ta.analysis.options import close_to_close_vol
|
||||
|
||||
_, _, _, close = sample_ohlc
|
||||
out = close_to_close_vol(close, window=20)
|
||||
# First `window` values are NaN; index `window` is the first valid value
|
||||
assert all(np.isnan(out[:20]))
|
||||
assert np.isfinite(out[20])
|
||||
|
||||
def test_parkinson_vol_finite_and_positive(self, sample_ohlc):
|
||||
from ferro_ta.analysis.options import parkinson_vol
|
||||
|
||||
_, high, low, _ = sample_ohlc
|
||||
out = parkinson_vol(high, low, window=20)
|
||||
finite = out[~np.isnan(out)]
|
||||
assert len(finite) > 0
|
||||
assert np.all(finite > 0.0)
|
||||
|
||||
def test_garman_klass_vol(self, sample_ohlc):
|
||||
from ferro_ta.analysis.options import garman_klass_vol
|
||||
|
||||
open_, high, low, close = sample_ohlc
|
||||
out = garman_klass_vol(open_, high, low, close, window=20)
|
||||
finite = out[~np.isnan(out)]
|
||||
assert len(finite) > 0
|
||||
assert np.all(finite > 0.0)
|
||||
|
||||
def test_rogers_satchell_vol(self, sample_ohlc):
|
||||
from ferro_ta.analysis.options import rogers_satchell_vol
|
||||
|
||||
open_, high, low, close = sample_ohlc
|
||||
out = rogers_satchell_vol(open_, high, low, close, window=20)
|
||||
finite = out[~np.isnan(out)]
|
||||
assert len(finite) > 0
|
||||
|
||||
def test_yang_zhang_vol(self, sample_ohlc):
|
||||
from ferro_ta.analysis.options import yang_zhang_vol
|
||||
|
||||
open_, high, low, close = sample_ohlc
|
||||
out = yang_zhang_vol(open_, high, low, close, window=20)
|
||||
finite = out[~np.isnan(out)]
|
||||
assert len(finite) > 0
|
||||
assert np.all(finite > 0.0)
|
||||
|
||||
def test_yang_zhang_lower_variance_than_close_to_close(self, sample_ohlc):
|
||||
# YZ is more efficient than close-to-close
|
||||
from ferro_ta.analysis.options import close_to_close_vol, yang_zhang_vol
|
||||
|
||||
open_, high, low, close = sample_ohlc
|
||||
c2c = close_to_close_vol(close, window=20)
|
||||
yz = yang_zhang_vol(open_, high, low, close, window=20)
|
||||
valid = ~np.isnan(c2c) & ~np.isnan(yz)
|
||||
# YZ variance < C2C variance (efficiency test)
|
||||
assert np.var(yz[valid]) <= np.var(c2c[valid]) * 2.0 # lenient bound
|
||||
|
||||
|
||||
class TestVolCone:
|
||||
def test_vol_cone_shape(self):
|
||||
from ferro_ta.analysis.options import VolCone, vol_cone
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 300)))
|
||||
cone = vol_cone(close, windows=(21, 42, 63))
|
||||
assert isinstance(cone, VolCone)
|
||||
assert len(cone.windows) == 3
|
||||
assert len(cone.min) == 3
|
||||
|
||||
def test_vol_cone_monotonic_percentiles(self):
|
||||
from ferro_ta.analysis.options import vol_cone
|
||||
|
||||
rng = np.random.default_rng(1)
|
||||
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500)))
|
||||
cone = vol_cone(close, windows=(21, 42, 63, 126, 252))
|
||||
for i in range(len(cone.windows)):
|
||||
assert (
|
||||
cone.min[i]
|
||||
<= cone.p25[i]
|
||||
<= cone.median[i]
|
||||
<= cone.p75[i]
|
||||
<= cone.max[i]
|
||||
)
|
||||
|
||||
def test_vol_cone_positive_values(self):
|
||||
from ferro_ta.analysis.options import vol_cone
|
||||
|
||||
rng = np.random.default_rng(2)
|
||||
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 400)))
|
||||
cone = vol_cone(close)
|
||||
assert np.all(cone.min > 0.0)
|
||||
|
||||
|
||||
class TestStrategyAnalytics:
|
||||
def test_put_call_parity_deviation_zero(self):
|
||||
from ferro_ta.analysis.options import option_price, put_call_parity_deviation
|
||||
|
||||
s, k, r, tte, vol = 100.0, 100.0, 0.05, 1.0, 0.2
|
||||
call = option_price(s, k, r, tte, vol, option_type="call")
|
||||
put = option_price(s, k, r, tte, vol, option_type="put")
|
||||
dev = put_call_parity_deviation(call, put, s, k, r, tte)
|
||||
assert dev == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
def test_put_call_parity_deviation_nonzero_for_stale_quote(self):
|
||||
from ferro_ta.analysis.options import put_call_parity_deviation
|
||||
|
||||
dev = put_call_parity_deviation(15.0, 5.0, 100.0, 100.0, 0.05, 1.0)
|
||||
assert abs(dev) > 0.01
|
||||
|
||||
def test_expected_move_positive(self):
|
||||
from ferro_ta.analysis.options import expected_move
|
||||
|
||||
lower, upper = expected_move(100.0, 0.2, 30.0)
|
||||
assert upper > 0.0
|
||||
assert lower < 0.0
|
||||
|
||||
def test_expected_move_log_normal_asymmetry(self):
|
||||
# Log-normal expected move: upper > |lower| (right-skew)
|
||||
from ferro_ta.analysis.options import expected_move
|
||||
|
||||
lower, upper = expected_move(100.0, 0.2, 30.0)
|
||||
# Both magnitudes are similar (within 10%) but upper > |lower|
|
||||
assert upper > abs(lower) * 0.95
|
||||
assert upper < abs(lower) * 2.0
|
||||
|
||||
def test_strategy_value_near_expiry_approx_payoff(self):
|
||||
from ferro_ta.analysis.derivatives_payoff import (
|
||||
PayoffLeg,
|
||||
strategy_payoff,
|
||||
strategy_value,
|
||||
)
|
||||
|
||||
# Near expiry, BSM value ≈ intrinsic payoff
|
||||
spot_grid = np.array([90.0, 100.0, 110.0])
|
||||
legs = [
|
||||
PayoffLeg(
|
||||
instrument="option",
|
||||
side="long",
|
||||
option_type="call",
|
||||
strike=100.0,
|
||||
premium=0.0,
|
||||
volatility=0.2,
|
||||
time_to_expiry=0.001,
|
||||
)
|
||||
]
|
||||
val = strategy_value(spot_grid, legs=legs, time_to_expiry=0.001, volatility=0.2)
|
||||
payoff = strategy_payoff(spot_grid, legs=legs)
|
||||
# Near expiry, value ≈ payoff (within a few cents)
|
||||
assert np.allclose(val, payoff, atol=0.5)
|
||||
|
||||
def test_strategy_value_shape(self):
|
||||
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_value
|
||||
|
||||
spot_grid = np.linspace(80.0, 120.0, 20)
|
||||
legs = [
|
||||
PayoffLeg(
|
||||
instrument="option",
|
||||
side="long",
|
||||
option_type="call",
|
||||
strike=100.0,
|
||||
premium=5.0,
|
||||
volatility=0.2,
|
||||
time_to_expiry=0.5,
|
||||
)
|
||||
]
|
||||
val = strategy_value(spot_grid, legs=legs, time_to_expiry=0.5, volatility=0.2)
|
||||
assert val.shape == spot_grid.shape
|
||||
|
||||
|
||||
class TestDerivativesBenchmarking:
|
||||
def test_derivatives_benchmark_smoke(self, tmp_path):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
"""
|
||||
Accuracy/correctness tests for ferro-ta derivatives analytics.
|
||||
|
||||
Each test class validates the ferro-ta implementation against reference
|
||||
formulas implemented using scipy and numpy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reference formulas (pure numpy / scipy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _norm_cdf(x):
|
||||
"""Standard normal CDF via scipy."""
|
||||
from scipy.stats import norm as _norm
|
||||
|
||||
return _norm.cdf(x)
|
||||
|
||||
|
||||
def _norm_pdf(x):
|
||||
from scipy.stats import norm as _norm
|
||||
|
||||
return _norm.pdf(x)
|
||||
|
||||
|
||||
def bsm_call(S, K, r, q, T, sigma): # noqa: N803
|
||||
"""Reference BSM call price."""
|
||||
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
|
||||
d2 = d1 - sigma * np.sqrt(T)
|
||||
return S * np.exp(-q * T) * _norm_cdf(d1) - K * np.exp(-r * T) * _norm_cdf(d2)
|
||||
|
||||
|
||||
def bsm_put(S, K, r, q, T, sigma): # noqa: N803
|
||||
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
|
||||
d2 = d1 - sigma * np.sqrt(T)
|
||||
return K * np.exp(-r * T) * _norm_cdf(-d2) - S * np.exp(-q * T) * _norm_cdf(-d1)
|
||||
|
||||
|
||||
def bsm_delta_call(S, K, r, q, T, sigma): # noqa: N803
|
||||
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
|
||||
return np.exp(-q * T) * _norm_cdf(d1)
|
||||
|
||||
|
||||
def digital_cash_call(S, K, r, q, T, sigma): # noqa: N803
|
||||
d2 = (np.log(S / K) + (r - q - 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
|
||||
return np.exp(-r * T) * _norm_cdf(d2)
|
||||
|
||||
|
||||
def digital_asset_call(S, K, r, q, T, sigma): # noqa: N803
|
||||
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
|
||||
return S * np.exp(-q * T) * _norm_cdf(d1)
|
||||
|
||||
|
||||
def digital_cash_put(S, K, r, q, T, sigma): # noqa: N803
|
||||
d2 = (np.log(S / K) + (r - q - 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
|
||||
return np.exp(-r * T) * _norm_cdf(-d2)
|
||||
|
||||
|
||||
def digital_asset_put(S, K, r, q, T, sigma): # noqa: N803
|
||||
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
|
||||
return S * np.exp(-q * T) * _norm_cdf(-d1)
|
||||
|
||||
|
||||
def vanna_num(S, K, r, q, T, sigma, eps=1e-4): # noqa: N803
|
||||
"""∂Δ/∂σ via central differences."""
|
||||
delta_up = bsm_delta_call(S, K, r, q, T, sigma + eps)
|
||||
delta_dn = bsm_delta_call(S, K, r, q, T, sigma - eps)
|
||||
return (delta_up - delta_dn) / (2 * eps)
|
||||
|
||||
|
||||
def vega_bsm(S, K, r, q, T, sigma): # noqa: N803
|
||||
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
|
||||
return S * np.exp(-q * T) * _norm_pdf(d1) * np.sqrt(T)
|
||||
|
||||
|
||||
def volga_num(S, K, r, q, T, sigma, eps=1e-4): # noqa: N803
|
||||
"""∂²V/∂σ² via central differences."""
|
||||
v_up = vega_bsm(S, K, r, q, T, sigma + eps)
|
||||
v_dn = vega_bsm(S, K, r, q, T, sigma - eps)
|
||||
return (v_up - v_dn) / (2 * eps)
|
||||
|
||||
|
||||
def ctc_vol_reference(close, window, trading_days=252.0):
|
||||
"""Close-to-close vol: rolling std of log returns × sqrt(trading_days)."""
|
||||
log_ret = np.log(close[1:] / close[:-1])
|
||||
n = len(close)
|
||||
out = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
returns_window = log_ret[i - window : i]
|
||||
out[i] = np.sqrt(np.sum(returns_window**2) / window * trading_days)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Six parameter sets: ATM, 10% OTM, 10% ITM, low vol, high vol, non-zero carry
|
||||
_DIGITAL_CASES = [
|
||||
# (S, K, r, q, T, sigma, label)
|
||||
(100.0, 100.0, 0.05, 0.00, 1.0, 0.20, "ATM"),
|
||||
(100.0, 110.0, 0.05, 0.00, 1.0, 0.20, "10% OTM"),
|
||||
(100.0, 90.0, 0.05, 0.00, 1.0, 0.20, "10% ITM"),
|
||||
(100.0, 100.0, 0.05, 0.00, 1.0, 0.05, "low vol"),
|
||||
(100.0, 100.0, 0.05, 0.00, 1.0, 0.50, "high vol"),
|
||||
(100.0, 100.0, 0.05, 0.03, 1.0, 0.20, "non-zero carry"),
|
||||
]
|
||||
|
||||
|
||||
class TestDigitalOptionsAccuracy:
|
||||
@pytest.fixture(autouse=True)
|
||||
def require_scipy(self):
|
||||
pytest.importorskip("scipy")
|
||||
|
||||
def test_cash_or_nothing_call_vs_reference(self):
|
||||
from ferro_ta.analysis.options import digital_option_price
|
||||
|
||||
for S, K, r, q, T, sigma, label in _DIGITAL_CASES:
|
||||
expected = digital_cash_call(S, K, r, q, T, sigma)
|
||||
actual = digital_option_price(
|
||||
S,
|
||||
K,
|
||||
r,
|
||||
T,
|
||||
sigma,
|
||||
option_type="call",
|
||||
digital_type="cash_or_nothing",
|
||||
carry=q,
|
||||
)
|
||||
assert actual == pytest.approx(expected, abs=1e-6), (
|
||||
f"cash_or_nothing call mismatch for case '{label}': "
|
||||
f"got {actual}, expected {expected}"
|
||||
)
|
||||
|
||||
def test_cash_or_nothing_put_vs_reference(self):
|
||||
from ferro_ta.analysis.options import digital_option_price
|
||||
|
||||
for S, K, r, q, T, sigma, label in _DIGITAL_CASES:
|
||||
expected = digital_cash_put(S, K, r, q, T, sigma)
|
||||
actual = digital_option_price(
|
||||
S,
|
||||
K,
|
||||
r,
|
||||
T,
|
||||
sigma,
|
||||
option_type="put",
|
||||
digital_type="cash_or_nothing",
|
||||
carry=q,
|
||||
)
|
||||
assert actual == pytest.approx(expected, abs=1e-6), (
|
||||
f"cash_or_nothing put mismatch for case '{label}': "
|
||||
f"got {actual}, expected {expected}"
|
||||
)
|
||||
|
||||
def test_asset_or_nothing_call_vs_reference(self):
|
||||
from ferro_ta.analysis.options import digital_option_price
|
||||
|
||||
for S, K, r, q, T, sigma, label in _DIGITAL_CASES:
|
||||
expected = digital_asset_call(S, K, r, q, T, sigma)
|
||||
actual = digital_option_price(
|
||||
S,
|
||||
K,
|
||||
r,
|
||||
T,
|
||||
sigma,
|
||||
option_type="call",
|
||||
digital_type="asset_or_nothing",
|
||||
carry=q,
|
||||
)
|
||||
# Tolerance 1e-4: asset-or-nothing involves S * N(d1), small numerical diff expected
|
||||
assert actual == pytest.approx(expected, abs=1e-4), (
|
||||
f"asset_or_nothing call mismatch for case '{label}': "
|
||||
f"got {actual}, expected {expected}"
|
||||
)
|
||||
|
||||
def test_asset_or_nothing_put_vs_reference(self):
|
||||
from ferro_ta.analysis.options import digital_option_price
|
||||
|
||||
for S, K, r, q, T, sigma, label in _DIGITAL_CASES:
|
||||
expected = digital_asset_put(S, K, r, q, T, sigma)
|
||||
actual = digital_option_price(
|
||||
S,
|
||||
K,
|
||||
r,
|
||||
T,
|
||||
sigma,
|
||||
option_type="put",
|
||||
digital_type="asset_or_nothing",
|
||||
carry=q,
|
||||
)
|
||||
# Tolerance 1e-4: asset-or-nothing involves S * N(-d1), small numerical diff expected
|
||||
assert actual == pytest.approx(expected, abs=1e-4), (
|
||||
f"asset_or_nothing put mismatch for case '{label}': "
|
||||
f"got {actual}, expected {expected}"
|
||||
)
|
||||
|
||||
def test_batch_digital_price_matches_scalar(self):
|
||||
"""Vectorized call must match scalar loop for 10 random points."""
|
||||
from ferro_ta.analysis.options import digital_option_price
|
||||
|
||||
rng = np.random.default_rng(7)
|
||||
n = 10
|
||||
S_arr = rng.uniform(80.0, 120.0, n)
|
||||
K_arr = rng.uniform(80.0, 120.0, n)
|
||||
r_arr = rng.uniform(0.01, 0.10, n)
|
||||
T_arr = rng.uniform(0.1, 2.0, n)
|
||||
sigma_arr = rng.uniform(0.10, 0.50, n)
|
||||
|
||||
batch = digital_option_price(
|
||||
S_arr,
|
||||
K_arr,
|
||||
r_arr,
|
||||
T_arr,
|
||||
sigma_arr,
|
||||
option_type="call",
|
||||
digital_type="cash_or_nothing",
|
||||
)
|
||||
|
||||
scalar_results = np.array(
|
||||
[
|
||||
digital_option_price(
|
||||
float(S_arr[i]),
|
||||
float(K_arr[i]),
|
||||
float(r_arr[i]),
|
||||
float(T_arr[i]),
|
||||
float(sigma_arr[i]),
|
||||
option_type="call",
|
||||
digital_type="cash_or_nothing",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
)
|
||||
|
||||
assert batch == pytest.approx(scalar_results, abs=1e-10), (
|
||||
"Batch digital_option_price does not match scalar loop"
|
||||
)
|
||||
|
||||
|
||||
# Four cases for extended Greeks: ITM call, ATM call, OTM call, ATM put
|
||||
_GREEK_CASES = [
|
||||
# (S, K, r, q, T, sigma, option_type, label)
|
||||
(110.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "ITM call"),
|
||||
(100.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "ATM call"),
|
||||
(90.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "OTM call"),
|
||||
(100.0, 100.0, 0.05, 0.0, 1.0, 0.20, "put", "ATM put"),
|
||||
]
|
||||
|
||||
|
||||
class TestExtendedGreeksAccuracy:
|
||||
@pytest.fixture(autouse=True)
|
||||
def require_scipy(self):
|
||||
pytest.importorskip("scipy")
|
||||
|
||||
def test_vanna_vs_numerical_fd(self):
|
||||
"""extended_greeks().vanna matches ∂Δ/∂σ from central differences (tol=1e-3)."""
|
||||
from ferro_ta.analysis.options import extended_greeks
|
||||
|
||||
for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES:
|
||||
eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q)
|
||||
# Reference is defined only for calls; for put use numerical FD directly
|
||||
if opt_type == "call":
|
||||
expected = vanna_num(S, K, r, q, T, sigma)
|
||||
else:
|
||||
# Vanna for put: ∂(put delta)/∂σ = ∂(call delta - e^{-qT})/∂σ = vanna_call
|
||||
expected = vanna_num(S, K, r, q, T, sigma)
|
||||
assert float(eg.vanna) == pytest.approx(expected, abs=1e-3), (
|
||||
f"Vanna mismatch for '{label}': got {eg.vanna}, expected {expected}"
|
||||
)
|
||||
|
||||
def test_volga_vs_numerical_fd(self):
|
||||
"""extended_greeks().volga matches ∂²V/∂σ² from central differences (tol=1e-2)."""
|
||||
from ferro_ta.analysis.options import extended_greeks
|
||||
|
||||
for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES:
|
||||
eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q)
|
||||
expected = volga_num(S, K, r, q, T, sigma)
|
||||
assert float(eg.volga) == pytest.approx(expected, abs=1e-2), (
|
||||
f"Volga mismatch for '{label}': got {eg.volga}, expected {expected}"
|
||||
)
|
||||
|
||||
def test_speed_negative_for_calls(self):
|
||||
"""Speed (∂Γ/∂S) should be negative for OTM calls — Gamma decreases as S moves away."""
|
||||
from ferro_ta.analysis.options import extended_greeks
|
||||
|
||||
# OTM call: S < K
|
||||
eg = extended_greeks(90.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
assert float(eg.speed) < 0.0, (
|
||||
f"Speed should be negative for OTM call, got {eg.speed}"
|
||||
)
|
||||
|
||||
def test_charm_finite_for_valid_inputs(self):
|
||||
"""Charm should be finite and non-zero for non-degenerate inputs."""
|
||||
from ferro_ta.analysis.options import extended_greeks
|
||||
|
||||
for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES:
|
||||
eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q)
|
||||
assert np.isfinite(float(eg.charm)), (
|
||||
f"Charm is not finite for '{label}': {eg.charm}"
|
||||
)
|
||||
assert eg.charm != 0.0, (
|
||||
f"Charm is zero for '{label}' — unexpected for non-degenerate inputs"
|
||||
)
|
||||
|
||||
|
||||
class TestAmericanOptionsAccuracy:
|
||||
"""Property-based tests for American options (no scipy required)."""
|
||||
|
||||
def test_baw_vs_published_values(self):
|
||||
"""BAW American put satisfies the lower bound: price ≥ max(K - S, European BSM put).
|
||||
|
||||
The Haug (2007) table uses b = r - q (cost of carry convention). Rather
|
||||
than replicate the exact table — which requires matching the BAW carry
|
||||
convention precisely — we verify two model-agnostic inequalities that any
|
||||
correct American-put implementation must satisfy:
|
||||
|
||||
1. American put ≥ intrinsic value (K - S)
|
||||
2. American put ≥ European BSM put (early exercise has non-negative value)
|
||||
"""
|
||||
from ferro_ta.analysis.options import american_option_price, option_price
|
||||
|
||||
S, K, r, T, sigma = 100.0, 100.0, 0.10, 0.25, 0.20
|
||||
american = american_option_price(S, K, r, T, sigma, option_type="put")
|
||||
european = option_price(S, K, r, T, sigma, option_type="put")
|
||||
|
||||
assert american >= max(K - S, 0.0) - 1e-8, (
|
||||
f"American put below intrinsic: {american:.4f} < {max(K - S, 0.0)}"
|
||||
)
|
||||
assert american >= european - 1e-8, (
|
||||
f"American put below European put: {american:.4f} < {european:.4f}"
|
||||
)
|
||||
# Sanity-check: American ATM put should be in a reasonable range
|
||||
assert 0.0 < american < K, (
|
||||
f"American put price {american:.4f} is outside (0, K={K})"
|
||||
)
|
||||
|
||||
def test_american_put_increases_with_strike(self):
|
||||
"""Deeper ITM (higher strike for put) ⇒ higher American put price.
|
||||
|
||||
Uses moderately spaced strikes to avoid the intrinsic-value floor
|
||||
where K - S becomes the binding constraint and the increments are
|
||||
exactly 1-for-1, which can mask ordering issues near the floor.
|
||||
"""
|
||||
from ferro_ta.analysis.options import american_option_price
|
||||
|
||||
# S = 100, K in {85, 100, 115}; rate and carry both 0.05 to avoid b=0 issues
|
||||
S, r, T, sigma = 100.0, 0.05, 0.5, 0.25
|
||||
strikes = [85.0, 100.0, 115.0]
|
||||
prices = [
|
||||
american_option_price(S, K, r, T, sigma, option_type="put", carry=r)
|
||||
for K in strikes
|
||||
]
|
||||
assert prices[0] < prices[1] < prices[2], (
|
||||
f"American put prices not monotone in strike: "
|
||||
f"K={strikes} → prices={[round(p, 4) for p in prices]}"
|
||||
)
|
||||
|
||||
def test_american_call_increases_with_spot(self):
|
||||
"""Higher spot ⇒ higher American call price."""
|
||||
from ferro_ta.analysis.options import american_option_price
|
||||
|
||||
spots = [90.0, 100.0, 110.0]
|
||||
prices = [
|
||||
american_option_price(S, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
for S in spots
|
||||
]
|
||||
assert prices[0] < prices[1] < prices[2], (
|
||||
f"American call prices not monotone in spot: {prices}"
|
||||
)
|
||||
|
||||
def test_american_call_equals_european_no_dividends_no_early_exercise(self):
|
||||
"""American call with no early-exercise incentive (carry=0) ≈ European call.
|
||||
|
||||
When the cost-of-carry parameter is zero, there is no dividend/carry
|
||||
benefit to holding the underlying. In this regime, it is never
|
||||
optimal to early-exercise an American call, so the American call price
|
||||
equals the European call price computed with the same carry=0 convention.
|
||||
The `early_exercise_premium` function exposes this directly and should
|
||||
return ~0 for calls with carry=0.
|
||||
"""
|
||||
from ferro_ta.analysis.options import early_exercise_premium
|
||||
|
||||
S, K, r, T, sigma = 100.0, 100.0, 0.05, 1.0, 0.20
|
||||
premium = early_exercise_premium(
|
||||
S, K, r, T, sigma, option_type="call", carry=0.0
|
||||
)
|
||||
assert premium == pytest.approx(0.0, abs=1e-4), (
|
||||
f"Early exercise premium for call with carry=0 should be ~0, got {premium:.6f}"
|
||||
)
|
||||
|
||||
def test_early_exercise_premium_positive_for_deep_itm_put(self):
|
||||
"""Deep ITM American put should have a meaningful early exercise premium.
|
||||
|
||||
When S is well below K (deep ITM put), the time value is low and the
|
||||
interest gained from early exercise of the put dominates — leading to a
|
||||
positive early-exercise premium.
|
||||
"""
|
||||
from ferro_ta.analysis.options import early_exercise_premium
|
||||
|
||||
# Deep ITM: S=70, K=100 — strong incentive to exercise early
|
||||
premium = early_exercise_premium(
|
||||
70.0, 100.0, 0.10, 1.0, 0.20, option_type="put"
|
||||
)
|
||||
assert premium > 0.0, (
|
||||
f"Deep ITM American put early exercise premium should be > 0, got {premium}"
|
||||
)
|
||||
|
||||
|
||||
class TestVolEstimatorsAccuracy:
|
||||
@pytest.fixture(autouse=True)
|
||||
def require_scipy(self):
|
||||
pytest.importorskip("scipy")
|
||||
|
||||
def test_close_to_close_vs_reference_impl(self):
|
||||
"""C2C vol matches reference formula exactly (tol=1e-10), 100 samples."""
|
||||
from ferro_ta.analysis.options import close_to_close_vol
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
log_ret = rng.normal(0.0, 0.01, 100)
|
||||
close = 100.0 * np.cumprod(np.exp(log_ret))
|
||||
|
||||
window = 20
|
||||
actual = close_to_close_vol(close, window=window, trading_days_per_year=252.0)
|
||||
expected = ctc_vol_reference(close, window=window, trading_days=252.0)
|
||||
|
||||
valid = ~np.isnan(expected)
|
||||
assert np.allclose(actual[valid], expected[valid], atol=1e-10), (
|
||||
"close_to_close_vol does not match reference formula"
|
||||
)
|
||||
|
||||
def test_constant_returns_known_vol(self):
|
||||
"""Constant daily log-return of 0.01 → C2C vol = 0.01 * sqrt(252) ≈ 0.1587."""
|
||||
from ferro_ta.analysis.options import close_to_close_vol
|
||||
|
||||
# Build a price series with constant daily log-return of 0.01
|
||||
n = 100
|
||||
constant_log_ret = 0.01
|
||||
close = 100.0 * np.exp(np.arange(n) * constant_log_ret)
|
||||
|
||||
window = 21
|
||||
out = close_to_close_vol(close, window=window, trading_days_per_year=252.0)
|
||||
|
||||
# Expected: sqrt(0.01^2 * 252) = 0.01 * sqrt(252)
|
||||
expected_vol = constant_log_ret * np.sqrt(252.0)
|
||||
valid = ~np.isnan(out)
|
||||
assert np.all(valid[window:]), "Expected valid values after warmup"
|
||||
assert out[window] == pytest.approx(expected_vol, rel=1e-10), (
|
||||
f"Constant-return vol: got {out[window]}, expected {expected_vol}"
|
||||
)
|
||||
|
||||
def test_parkinson_lognormal_unbiased(self):
|
||||
"""Parkinson estimator within 50% of true vol=0.20 for simulated OHLC data.
|
||||
|
||||
Parkinson uses the log(high/low) range as a proxy for daily realized
|
||||
vol. The estimator is unbiased for a Brownian-motion diffusion where
|
||||
the daily range follows a known distribution, but a simplified
|
||||
simulation (single end-of-day price + independent range draw) will
|
||||
underestimate the range. We therefore build a proper multi-step
|
||||
intraday path so the high/low reflects the true diffusion range,
|
||||
and use a lenient 50% tolerance to accommodate finite-sample noise.
|
||||
"""
|
||||
from ferro_ta.analysis.options import parkinson_vol
|
||||
|
||||
rng = np.random.default_rng(123)
|
||||
true_vol = 0.20
|
||||
n_days = 500
|
||||
steps_per_day = 50 # intraday steps to get a realistic H-L range
|
||||
daily_sigma = true_vol / np.sqrt(252.0)
|
||||
step_sigma = daily_sigma / np.sqrt(steps_per_day)
|
||||
|
||||
# Simulate intraday paths, extract open/high/low/close each day
|
||||
highs = np.empty(n_days)
|
||||
lows = np.empty(n_days)
|
||||
price = 100.0
|
||||
for i in range(n_days):
|
||||
intraday = price * np.exp(
|
||||
np.cumsum(rng.normal(0.0, step_sigma, steps_per_day))
|
||||
)
|
||||
path = np.concatenate([[price], intraday])
|
||||
highs[i] = path.max()
|
||||
lows[i] = path.min()
|
||||
price = intraday[-1]
|
||||
|
||||
window = 21
|
||||
out = parkinson_vol(highs, lows, window=window, trading_days_per_year=252.0)
|
||||
valid = out[~np.isnan(out)]
|
||||
|
||||
assert len(valid) > 0, "No valid Parkinson estimates"
|
||||
median_est = float(np.median(valid))
|
||||
assert abs(median_est - true_vol) < 0.50 * true_vol, (
|
||||
f"Parkinson estimate {median_est:.4f} is more than 50% from true vol {true_vol}"
|
||||
)
|
||||
|
||||
def test_vol_estimators_all_positive_finite(self):
|
||||
"""All 5 estimators produce finite and positive non-NaN values on random OHLC."""
|
||||
from ferro_ta.analysis.options import (
|
||||
close_to_close_vol,
|
||||
garman_klass_vol,
|
||||
parkinson_vol,
|
||||
rogers_satchell_vol,
|
||||
yang_zhang_vol,
|
||||
)
|
||||
|
||||
rng = np.random.default_rng(99)
|
||||
n = 200
|
||||
log_ret = rng.normal(0.0, 0.01, n)
|
||||
close = 100.0 * np.cumprod(np.exp(log_ret))
|
||||
high = close * np.exp(np.abs(rng.normal(0.0, 0.005, n)))
|
||||
low = close * np.exp(-np.abs(rng.normal(0.0, 0.005, n)))
|
||||
open_ = np.roll(close, 1)
|
||||
open_[0] = close[0]
|
||||
|
||||
window = 20
|
||||
estimators = {
|
||||
"close_to_close": close_to_close_vol(close, window=window),
|
||||
"parkinson": parkinson_vol(high, low, window=window),
|
||||
"garman_klass": garman_klass_vol(open_, high, low, close, window=window),
|
||||
"rogers_satchell": rogers_satchell_vol(
|
||||
open_, high, low, close, window=window
|
||||
),
|
||||
"yang_zhang": yang_zhang_vol(open_, high, low, close, window=window),
|
||||
}
|
||||
|
||||
for name, out in estimators.items():
|
||||
valid = out[~np.isnan(out)]
|
||||
assert len(valid) > 0, f"{name}: no valid (non-NaN) estimates"
|
||||
assert np.all(np.isfinite(valid)), f"{name}: non-finite values present"
|
||||
assert np.all(valid > 0.0), f"{name}: non-positive values present"
|
||||
|
||||
|
||||
class TestVolConeAccuracy:
|
||||
"""Tests for vol_cone — no scipy required."""
|
||||
|
||||
def test_cone_windows_match_requested(self):
|
||||
"""Output windows should match the input list exactly."""
|
||||
from ferro_ta.analysis.options import vol_cone
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500)))
|
||||
requested = (10, 21, 42)
|
||||
cone = vol_cone(close, windows=requested)
|
||||
|
||||
assert list(cone.windows.astype(int)) == list(requested), (
|
||||
f"Cone windows {list(cone.windows)} do not match requested {list(requested)}"
|
||||
)
|
||||
|
||||
def test_cone_median_matches_rolling_median(self):
|
||||
"""Manually computed rolling C2C vol median for window=21 should match cone.median[0]."""
|
||||
from ferro_ta.analysis.options import close_to_close_vol, vol_cone
|
||||
|
||||
rng = np.random.default_rng(5)
|
||||
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500)))
|
||||
window = 21
|
||||
|
||||
cone = vol_cone(close, windows=(window,))
|
||||
|
||||
rolling = close_to_close_vol(close, window=window, trading_days_per_year=252.0)
|
||||
valid = rolling[~np.isnan(rolling)]
|
||||
manual_median = float(np.median(valid))
|
||||
|
||||
assert cone.median[0] == pytest.approx(manual_median, rel=1e-6), (
|
||||
f"vol_cone median {cone.median[0]:.6f} does not match manual median {manual_median:.6f}"
|
||||
)
|
||||
|
||||
|
||||
class TestStrategyAnalyticsAccuracy:
|
||||
@pytest.fixture(autouse=True)
|
||||
def require_scipy(self):
|
||||
pytest.importorskip("scipy")
|
||||
|
||||
def test_put_call_parity_deviation_analytical(self):
|
||||
"""BSM call/put from scipy formulas fed into put_call_parity_deviation → < 1e-8."""
|
||||
from ferro_ta.analysis.options import put_call_parity_deviation
|
||||
|
||||
S, K, r, q, T, sigma = 100.0, 100.0, 0.05, 0.02, 1.0, 0.20
|
||||
call = bsm_call(S, K, r, q, T, sigma)
|
||||
put = bsm_put(S, K, r, q, T, sigma)
|
||||
|
||||
dev = put_call_parity_deviation(call, put, S, K, r, T, carry=q)
|
||||
assert abs(dev) < 1e-8, (
|
||||
f"put_call_parity_deviation for BSM-consistent prices: got {dev}, expected ~0"
|
||||
)
|
||||
|
||||
def test_expected_move_known_value(self):
|
||||
"""S=100, iv=0.20, days=30, trading_days=252 → upper move ≈ 7.14."""
|
||||
from ferro_ta.analysis.options import expected_move
|
||||
|
||||
S, iv, days, td = 100.0, 0.20, 30.0, 252.0
|
||||
lower, upper = expected_move(S, iv, days, td)
|
||||
|
||||
# log-normal formula: S * (exp(sigma * sqrt(days/trading_days)) - 1)
|
||||
expected_upper = S * (np.exp(iv * np.sqrt(days / td)) - 1.0)
|
||||
expected_lower = S * (np.exp(-iv * np.sqrt(days / td)) - 1.0)
|
||||
|
||||
assert upper == pytest.approx(expected_upper, rel=1e-6), (
|
||||
f"expected_move upper: got {upper:.4f}, expected {expected_upper:.4f}"
|
||||
)
|
||||
assert lower == pytest.approx(expected_lower, rel=1e-6), (
|
||||
f"expected_move lower: got {lower:.4f}, expected {expected_lower:.4f}"
|
||||
)
|
||||
# Numeric check: upper ≈ 7.14
|
||||
assert upper == pytest.approx(7.14, abs=0.05), (
|
||||
f"expected_move upper should be ~7.14, got {upper:.4f}"
|
||||
)
|
||||
@@ -950,7 +950,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro-ta"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
@@ -993,6 +993,8 @@ dev = [
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "ruff" },
|
||||
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
@@ -1030,6 +1032,8 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "ruff" },
|
||||
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "ta" },
|
||||
]
|
||||
|
||||
@@ -1067,6 +1071,7 @@ requires-dist = [
|
||||
{ name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" },
|
||||
{ name = "quantstats", marker = "extra == 'comparison'", specifier = ">=0.0.81" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" },
|
||||
{ name = "scipy", marker = "extra == 'dev'", specifier = ">=1.10" },
|
||||
{ name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0" },
|
||||
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.3" },
|
||||
{ name = "ta", marker = "extra == 'comparison'", specifier = ">=0.10" },
|
||||
@@ -1089,6 +1094,7 @@ dev = [
|
||||
{ name = "pytest", specifier = ">=7.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "ruff", specifier = ">=0.3" },
|
||||
{ name = "scipy", specifier = ">=1.15.3" },
|
||||
{ name = "ta", specifier = ">=0.10" },
|
||||
]
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -49,11 +49,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_wasm"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"ferro_ta_core",
|
||||
"js-sys",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_wasm"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
edition = "2021"
|
||||
description = "WebAssembly bindings for ferro-ta technical analysis indicators"
|
||||
license = "MIT"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ferro-ta-wasm",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"description": "WebAssembly bindings for ferro-ta technical analysis indicators",
|
||||
"main": "node/ferro_ta_wasm.js",
|
||||
"module": "web/ferro_ta_wasm.js",
|
||||
|
||||
+473
@@ -2724,6 +2724,479 @@ pub fn macd_crossover_signals(close: &Float64Array, fastperiod: usize, slowperio
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// New Options Features (extended Greeks, digital, American, vol estimators,
|
||||
// vol cone, expected move, put-call parity, strategy payoff/value/Greeks)
|
||||
// ===========================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers shared by the new features
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_digital_kind(digital_type: &str) -> ferro_ta_core::options::digital::DigitalKind {
|
||||
match digital_type.to_ascii_lowercase().as_str() {
|
||||
"asset_or_nothing" | "asset" => ferro_ta_core::options::digital::DigitalKind::AssetOrNothing,
|
||||
_ => ferro_ta_core::options::digital::DigitalKind::CashOrNothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Float64Array to a Vec<i64> (for instrument/side/option_type codes).
|
||||
fn to_i64_vec(arr: &Float64Array) -> Vec<i64> {
|
||||
to_vec(arr).into_iter().map(|x| x as i64).collect()
|
||||
}
|
||||
|
||||
/// Convert a Float64Array to a Vec<usize> (for window sizes).
|
||||
fn to_usize_vec(arr: &Float64Array) -> Vec<usize> {
|
||||
to_vec(arr).into_iter().map(|x| x as usize).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Put-call parity check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Put-call parity deviation: `C - P - (S·e^{-qT} - K·e^{-rT})`.
|
||||
///
|
||||
/// Returns 0 at no-arbitrage.
|
||||
#[wasm_bindgen]
|
||||
pub fn put_call_parity_deviation(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> f64 {
|
||||
ferro_ta_core::options::pricing::put_call_parity_deviation(
|
||||
call_price, put_price, spot, strike, rate, carry, time_to_expiry,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extended (higher-order) Greeks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extended BSM Greeks: vanna, volga, charm, speed, color.
|
||||
///
|
||||
/// # Returns
|
||||
/// `js_sys::Array` of five f64 values: `[vanna, volga, charm, speed, color]`.
|
||||
#[wasm_bindgen]
|
||||
pub fn extended_greeks(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: &str,
|
||||
) -> Array {
|
||||
use ferro_ta_core::options::{greeks::model_extended_greeks, OptionContract, OptionEvaluation, PricingModel};
|
||||
let k = parse_option_kind(kind);
|
||||
// In this codebase, `carry` = dividend yield q (same convention as all other WASM/PyO3 APIs).
|
||||
let eg = model_extended_greeks(OptionEvaluation {
|
||||
contract: OptionContract {
|
||||
model: PricingModel::BlackScholes,
|
||||
underlying: spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind: k,
|
||||
},
|
||||
volatility,
|
||||
});
|
||||
let out = Array::new();
|
||||
out.push(&JsValue::from_f64(eg.vanna));
|
||||
out.push(&JsValue::from_f64(eg.volga));
|
||||
out.push(&JsValue::from_f64(eg.charm));
|
||||
out.push(&JsValue::from_f64(eg.speed));
|
||||
out.push(&JsValue::from_f64(eg.color));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Digital options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Price a digital (binary) option.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `kind` – `"call"` or `"put"`
|
||||
/// - `digital_type` – `"cash_or_nothing"` (default) or `"asset_or_nothing"`
|
||||
#[wasm_bindgen]
|
||||
pub fn digital_price(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: &str,
|
||||
digital_type: &str,
|
||||
) -> f64 {
|
||||
ferro_ta_core::options::digital::digital_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
parse_option_kind(kind),
|
||||
parse_digital_kind(digital_type),
|
||||
)
|
||||
}
|
||||
|
||||
/// Greeks for a digital option (numerical central differences).
|
||||
///
|
||||
/// # Returns
|
||||
/// `js_sys::Array` of three f64 values: `[delta, gamma, vega]`.
|
||||
#[wasm_bindgen]
|
||||
pub fn digital_greeks(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: &str,
|
||||
digital_type: &str,
|
||||
) -> Array {
|
||||
let (delta, gamma, vega) = ferro_ta_core::options::digital::digital_greeks(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
parse_option_kind(kind),
|
||||
parse_digital_kind(digital_type),
|
||||
);
|
||||
let out = Array::new();
|
||||
out.push(&JsValue::from_f64(delta));
|
||||
out.push(&JsValue::from_f64(gamma));
|
||||
out.push(&JsValue::from_f64(vega));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// American options (Barone-Adesi-Whaley)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// American option price using the Barone-Adesi-Whaley approximation.
|
||||
#[wasm_bindgen]
|
||||
pub fn american_price(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: &str,
|
||||
) -> f64 {
|
||||
ferro_ta_core::options::american::american_price_baw(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
parse_option_kind(kind),
|
||||
)
|
||||
}
|
||||
|
||||
/// Early exercise premium: `american_price - european_price`.
|
||||
#[wasm_bindgen]
|
||||
pub fn early_exercise_premium(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: &str,
|
||||
) -> f64 {
|
||||
ferro_ta_core::options::american::early_exercise_premium(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
parse_option_kind(kind),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Historical volatility estimators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Close-to-close realised volatility (rolling).
|
||||
///
|
||||
/// First `window - 1` values are `NaN`.
|
||||
#[wasm_bindgen]
|
||||
pub fn close_to_close_vol(
|
||||
close: &Float64Array,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Float64Array {
|
||||
from_vec(ferro_ta_core::options::realized_vol::close_to_close_vol(&to_vec(close), window, trading_days))
|
||||
}
|
||||
|
||||
/// Parkinson (high-low) volatility estimator (rolling).
|
||||
#[wasm_bindgen]
|
||||
pub fn parkinson_vol(
|
||||
high: &Float64Array,
|
||||
low: &Float64Array,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Float64Array {
|
||||
from_vec(ferro_ta_core::options::realized_vol::parkinson_vol(
|
||||
&to_vec(high),
|
||||
&to_vec(low),
|
||||
window,
|
||||
trading_days,
|
||||
))
|
||||
}
|
||||
|
||||
/// Garman-Klass OHLC volatility estimator (rolling).
|
||||
#[wasm_bindgen]
|
||||
pub fn garman_klass_vol(
|
||||
open: &Float64Array,
|
||||
high: &Float64Array,
|
||||
low: &Float64Array,
|
||||
close: &Float64Array,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Float64Array {
|
||||
from_vec(ferro_ta_core::options::realized_vol::garman_klass_vol(
|
||||
&to_vec(open),
|
||||
&to_vec(high),
|
||||
&to_vec(low),
|
||||
&to_vec(close),
|
||||
window,
|
||||
trading_days,
|
||||
))
|
||||
}
|
||||
|
||||
/// Rogers-Satchell OHLC volatility estimator (rolling).
|
||||
#[wasm_bindgen]
|
||||
pub fn rogers_satchell_vol(
|
||||
open: &Float64Array,
|
||||
high: &Float64Array,
|
||||
low: &Float64Array,
|
||||
close: &Float64Array,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Float64Array {
|
||||
from_vec(ferro_ta_core::options::realized_vol::rogers_satchell_vol(
|
||||
&to_vec(open),
|
||||
&to_vec(high),
|
||||
&to_vec(low),
|
||||
&to_vec(close),
|
||||
window,
|
||||
trading_days,
|
||||
))
|
||||
}
|
||||
|
||||
/// Yang-Zhang OHLC volatility estimator (rolling).
|
||||
///
|
||||
/// Most efficient estimator — handles overnight gaps.
|
||||
#[wasm_bindgen]
|
||||
pub fn yang_zhang_vol(
|
||||
open: &Float64Array,
|
||||
high: &Float64Array,
|
||||
low: &Float64Array,
|
||||
close: &Float64Array,
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Float64Array {
|
||||
from_vec(ferro_ta_core::options::realized_vol::yang_zhang_vol(
|
||||
&to_vec(open),
|
||||
&to_vec(high),
|
||||
&to_vec(low),
|
||||
&to_vec(close),
|
||||
window,
|
||||
trading_days,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Volatility cone
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Volatility cone: percentile distribution of close-to-close vol across windows.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `close` – `Float64Array` of close prices.
|
||||
/// - `windows` – `Float64Array` of window sizes (e.g. `[21, 42, 63, 126, 252]`).
|
||||
/// - `trading_days` – annualisation factor (default 252).
|
||||
///
|
||||
/// # Returns
|
||||
/// `js_sys::Array` of length `n_windows`, each element an `Array`:
|
||||
/// `[window, min, p25, median, p75, max]`.
|
||||
#[wasm_bindgen]
|
||||
pub fn vol_cone(
|
||||
close: &Float64Array,
|
||||
windows: &Float64Array,
|
||||
trading_days: f64,
|
||||
) -> Array {
|
||||
let c = to_vec(close);
|
||||
let wins = to_usize_vec(windows);
|
||||
let slices = ferro_ta_core::options::realized_vol::vol_cone(&c, &wins, trading_days);
|
||||
let out = Array::new();
|
||||
for s in slices {
|
||||
let row = Array::new();
|
||||
row.push(&JsValue::from_f64(s.window as f64));
|
||||
row.push(&JsValue::from_f64(s.min));
|
||||
row.push(&JsValue::from_f64(s.p25));
|
||||
row.push(&JsValue::from_f64(s.median));
|
||||
row.push(&JsValue::from_f64(s.p75));
|
||||
row.push(&JsValue::from_f64(s.max));
|
||||
out.push(&row);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expected move
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Expected move over `days_to_expiry` trading days.
|
||||
///
|
||||
/// Uses log-normal: `spot · e^{±σ√(days/trading_days)} − spot`.
|
||||
///
|
||||
/// # Returns
|
||||
/// `js_sys::Array` of two f64 values: `[lower_move, upper_move]` (signed).
|
||||
#[wasm_bindgen]
|
||||
pub fn expected_move(
|
||||
spot: f64,
|
||||
iv: f64,
|
||||
days_to_expiry: f64,
|
||||
trading_days_per_year: f64,
|
||||
) -> Array {
|
||||
let (lower, upper) = ferro_ta_core::options::surface::expected_move(spot, iv, days_to_expiry, trading_days_per_year);
|
||||
let out = Array::new();
|
||||
out.push(&JsValue::from_f64(lower));
|
||||
out.push(&JsValue::from_f64(upper));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Strategy payoff / value (Feature 8 — WASM exposure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate strategy payoff over a spot grid at expiry.
|
||||
///
|
||||
/// Instrument codes: `0`=option, `1`=future, `2`=stock.
|
||||
/// Side codes: `1`=long, `-1`=short.
|
||||
/// Option type codes: `1`=call, `-1`=put.
|
||||
///
|
||||
/// # Returns
|
||||
/// `Float64Array` of aggregate P&L per spot grid point.
|
||||
#[wasm_bindgen]
|
||||
pub fn strategy_payoff_dense(
|
||||
spot_grid: &Float64Array,
|
||||
instruments: &Float64Array,
|
||||
sides: &Float64Array,
|
||||
option_types: &Float64Array,
|
||||
strikes: &Float64Array,
|
||||
premiums: &Float64Array,
|
||||
entry_prices: &Float64Array,
|
||||
quantities: &Float64Array,
|
||||
multipliers: &Float64Array,
|
||||
) -> Float64Array {
|
||||
from_vec(ferro_ta_core::options::payoff::strategy_payoff_dense(
|
||||
&to_vec(spot_grid),
|
||||
&to_i64_vec(instruments),
|
||||
&to_i64_vec(sides),
|
||||
&to_i64_vec(option_types),
|
||||
&to_vec(strikes),
|
||||
&to_vec(premiums),
|
||||
&to_vec(entry_prices),
|
||||
&to_vec(quantities),
|
||||
&to_vec(multipliers),
|
||||
))
|
||||
}
|
||||
|
||||
/// Aggregate BSM Greeks across option and futures/stock legs at a single spot.
|
||||
///
|
||||
/// # Returns
|
||||
/// `js_sys::Array` of five f64 values: `[delta, gamma, vega, theta, rho]`.
|
||||
#[wasm_bindgen]
|
||||
pub fn aggregate_greeks_dense(
|
||||
spot: f64,
|
||||
instruments: &Float64Array,
|
||||
sides: &Float64Array,
|
||||
option_types: &Float64Array,
|
||||
strikes: &Float64Array,
|
||||
volatilities: &Float64Array,
|
||||
time_to_expiries: &Float64Array,
|
||||
rates: &Float64Array,
|
||||
carries: &Float64Array,
|
||||
quantities: &Float64Array,
|
||||
multipliers: &Float64Array,
|
||||
) -> Array {
|
||||
let (delta, gamma, vega, theta, rho) = ferro_ta_core::options::payoff::aggregate_greeks_dense(
|
||||
spot,
|
||||
&to_i64_vec(instruments),
|
||||
&to_i64_vec(sides),
|
||||
&to_i64_vec(option_types),
|
||||
&to_vec(strikes),
|
||||
&to_vec(volatilities),
|
||||
&to_vec(time_to_expiries),
|
||||
&to_vec(rates),
|
||||
&to_vec(carries),
|
||||
&to_vec(quantities),
|
||||
&to_vec(multipliers),
|
||||
);
|
||||
let out = Array::new();
|
||||
out.push(&JsValue::from_f64(delta));
|
||||
out.push(&JsValue::from_f64(gamma));
|
||||
out.push(&JsValue::from_f64(vega));
|
||||
out.push(&JsValue::from_f64(theta));
|
||||
out.push(&JsValue::from_f64(rho));
|
||||
out
|
||||
}
|
||||
|
||||
/// Current BSM mid-price value of a multi-leg strategy over a spot grid (pre-expiry).
|
||||
///
|
||||
/// Unlike `strategy_payoff_dense`, this uses live BSM pricing for option legs.
|
||||
///
|
||||
/// # Returns
|
||||
/// `Float64Array` of strategy value (P&L vs premium paid) per spot grid point.
|
||||
#[wasm_bindgen]
|
||||
pub fn strategy_value_grid(
|
||||
spot_grid: &Float64Array,
|
||||
instruments: &Float64Array,
|
||||
sides: &Float64Array,
|
||||
option_types: &Float64Array,
|
||||
strikes: &Float64Array,
|
||||
premiums: &Float64Array,
|
||||
entry_prices: &Float64Array,
|
||||
quantities: &Float64Array,
|
||||
multipliers: &Float64Array,
|
||||
time_to_expiries: &Float64Array,
|
||||
volatilities: &Float64Array,
|
||||
rates: &Float64Array,
|
||||
carries: &Float64Array,
|
||||
) -> Float64Array {
|
||||
from_vec(ferro_ta_core::options::payoff::strategy_value_grid(
|
||||
&to_vec(spot_grid),
|
||||
&to_i64_vec(instruments),
|
||||
&to_i64_vec(sides),
|
||||
&to_i64_vec(option_types),
|
||||
&to_vec(strikes),
|
||||
&to_vec(premiums),
|
||||
&to_vec(entry_prices),
|
||||
&to_vec(quantities),
|
||||
&to_vec(multipliers),
|
||||
&to_vec(time_to_expiries),
|
||||
&to_vec(volatilities),
|
||||
&to_vec(rates),
|
||||
&to_vec(carries),
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WASM tests (run with `wasm-pack test --node`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user