609 lines
24 KiB
Python
609 lines
24 KiB
Python
"""
|
||||
|
|
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}"
|
|||
|
|
)
|