Skip to content

Commit 8aaa16a

Browse files
authored
Merge pull request #68 from tyrneh/refactor_orchestration
2 parents 3c65a98 + 76a9abe commit 8aaa16a

File tree

15 files changed

+1018
-793
lines changed

15 files changed

+1018
-793
lines changed

examples/example.ipynb

Lines changed: 16 additions & 14 deletions
Large diffs are not rendered by default.

oipd/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
"""Generate probability distributions for future stock prices using options data."""
33

44
from oipd.core import (
5-
calculate_pdf,
6-
calculate_cdf,
75
calculate_quartiles,
86
OIPDError,
97
InvalidInputError,
@@ -27,8 +25,6 @@
2725

2826
__all__ = [
2927
# Core functions
30-
"calculate_pdf",
31-
"calculate_cdf",
3228
"calculate_quartiles",
3329
# Exceptions
3430
"OIPDError",

oipd/core/__init__.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,47 @@
1-
from oipd.core.pdf import (
2-
calculate_pdf,
3-
calculate_cdf,
1+
from oipd.core.density import (
2+
calculate_cdf_from_pdf,
43
calculate_quartiles,
5-
# Export custom exceptions
6-
OIPDError,
7-
InvalidInputError,
8-
CalculationError,
4+
finite_diff_second_derivative,
5+
pdf_from_price_curve,
6+
price_curve_from_iv,
97
)
8+
from oipd.core.errors import CalculationError, InvalidInputError, OIPDError
9+
from oipd.core.iv import (
10+
black76_iv_brent_method,
11+
bs_iv_brent_method,
12+
bs_iv_newton_method,
13+
compute_iv,
14+
smooth_iv,
15+
)
16+
from oipd.core.iv_smoothing import available_smoothers
1017
from oipd.core.parity import (
11-
# Internal parity functions - not part of public API
12-
preprocess_with_parity,
13-
infer_forward_from_atm,
1418
apply_put_call_parity,
1519
detect_parity_opportunity,
20+
infer_forward_from_atm,
21+
preprocess_with_parity,
1622
)
23+
from oipd.core.prep import filter_stale_options, select_price_column
24+
1725

1826
__all__ = [
19-
"calculate_pdf",
20-
"calculate_cdf",
27+
"calculate_cdf_from_pdf",
2128
"calculate_quartiles",
29+
"finite_diff_second_derivative",
30+
"pdf_from_price_curve",
31+
"price_curve_from_iv",
2232
"OIPDError",
2333
"InvalidInputError",
2434
"CalculationError",
25-
# Parity functions are internal - not exported in public __all__
26-
# but available for internal use by estimator.py
35+
"bs_iv_brent_method",
36+
"bs_iv_newton_method",
37+
"black76_iv_brent_method",
38+
"compute_iv",
39+
"smooth_iv",
40+
"available_smoothers",
41+
"preprocess_with_parity",
42+
"infer_forward_from_atm",
43+
"apply_put_call_parity",
44+
"detect_parity_opportunity",
45+
"filter_stale_options",
46+
"select_price_column",
2747
]

oipd/core/density.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
from __future__ import annotations
2+
3+
"""Helpers for transforming option prices into risk-neutral densities."""
4+
5+
from typing import Iterable, Literal, Tuple
6+
7+
import numpy as np
8+
from scipy.integrate import simpson
9+
from scipy.interpolate import interp1d
10+
from scipy.optimize import brentq
11+
12+
from oipd.core.errors import InvalidInputError
13+
from oipd.core.iv_smoothing import VolCurve
14+
from oipd.pricing import get_pricer
15+
16+
17+
def finite_diff_second_derivative(y: np.ndarray, x: np.ndarray) -> np.ndarray:
18+
"""Stable five-point stencil second derivative with non-uniform fallback."""
19+
20+
if len(x) != len(y):
21+
raise ValueError(f"Arrays must have same length. Got x: {len(x)}, y: {len(y)}")
22+
if len(x) < 5:
23+
raise ValueError(f"Need at least 5 points for 5-point stencil. Got {len(x)}")
24+
25+
h = np.diff(x)
26+
if not np.allclose(h, h[0], rtol=1e-6):
27+
import warnings
28+
29+
warnings.warn(
30+
"Non-uniform grid detected. Using np.gradient fallback which may be less stable. "
31+
"Consider interpolating to a uniform grid first.",
32+
UserWarning,
33+
)
34+
return np.gradient(np.gradient(y, x), x)
35+
36+
step = h[0]
37+
d2y = np.zeros_like(y)
38+
for i in range(2, len(y) - 2):
39+
d2y[i] = (-y[i - 2] + 16 * y[i - 1] - 30 * y[i] + 16 * y[i + 1] - y[i + 2]) / (
40+
12 * step**2
41+
)
42+
43+
d2y[0] = (2 * y[0] - 5 * y[1] + 4 * y[2] - y[3]) / step**2
44+
d2y[1] = (y[0] - 2 * y[1] + y[2]) / step**2
45+
d2y[-2] = (y[-3] - 2 * y[-2] + y[-1]) / step**2
46+
d2y[-1] = (2 * y[-1] - 5 * y[-2] + 4 * y[-3] - y[-4]) / step**2
47+
return d2y
48+
49+
50+
def price_curve_from_iv(
51+
vol_curve: VolCurve,
52+
underlying_price: float,
53+
*,
54+
strike_grid: np.ndarray | None = None,
55+
days_to_expiry: int,
56+
risk_free_rate: float,
57+
pricing_engine: Literal["black76", "bs"],
58+
dividend_yield: float | None = None,
59+
) -> Tuple[np.ndarray, np.ndarray]:
60+
"""Generate call prices on a strike grid from a smoothed IV curve."""
61+
62+
if strike_grid is None:
63+
if hasattr(vol_curve, "grid"):
64+
strike_grid = getattr(vol_curve, "grid")[0]
65+
else:
66+
raise InvalidInputError(
67+
"strike_grid must be provided when smoother grid is unavailable"
68+
)
69+
70+
strikes = np.asarray(strike_grid, dtype=float)
71+
if strikes.ndim != 1:
72+
raise InvalidInputError("strike_grid must be one-dimensional")
73+
74+
sigma = vol_curve(strikes)
75+
years = days_to_expiry / 365.0
76+
pricer = get_pricer(pricing_engine)
77+
q = dividend_yield or 0.0
78+
call_prices = pricer(underlying_price, strikes, sigma, years, risk_free_rate, q)
79+
return strikes, np.asarray(call_prices, dtype=float)
80+
81+
82+
def pdf_from_price_curve(
83+
strikes: np.ndarray,
84+
call_prices: np.ndarray,
85+
*,
86+
risk_free_rate: float,
87+
days_to_expiry: int,
88+
min_strike: float | None = None,
89+
max_strike: float | None = None,
90+
) -> Tuple[np.ndarray, np.ndarray]:
91+
"""Apply Breeden-Litzenberger to obtain a PDF from call prices."""
92+
93+
strikes_arr = np.asarray(strikes, dtype=float)
94+
prices_arr = np.asarray(call_prices, dtype=float)
95+
if strikes_arr.shape != prices_arr.shape:
96+
raise InvalidInputError("Strikes and prices must have the same shape")
97+
98+
second_derivative = finite_diff_second_derivative(prices_arr, strikes_arr)
99+
years = days_to_expiry / 365.0
100+
pdf = np.exp(risk_free_rate * years) * second_derivative
101+
pdf = np.maximum(pdf, 0.0)
102+
103+
if min_strike is not None or max_strike is not None:
104+
left = 0
105+
right = len(strikes_arr) - 1
106+
if min_strike is not None:
107+
while left < len(strikes_arr) and strikes_arr[left] < min_strike:
108+
left += 1
109+
if max_strike is not None:
110+
while right >= 0 and strikes_arr[right] > max_strike:
111+
right -= 1
112+
strikes_arr = strikes_arr[left : right + 1]
113+
pdf = pdf[left : right + 1]
114+
115+
return strikes_arr, pdf
116+
117+
118+
def calculate_cdf_from_pdf(
119+
x_array: np.ndarray, pdf_array: np.ndarray
120+
) -> Tuple[np.ndarray, np.ndarray]:
121+
"""Integrate the PDF numerically to recover the CDF."""
122+
123+
if len(x_array) == 0:
124+
raise InvalidInputError("Input arrays cannot be empty")
125+
if len(x_array) != len(pdf_array):
126+
raise InvalidInputError("Price and PDF arrays must have same length")
127+
128+
cdf = []
129+
total_area = simpson(y=pdf_array, x=x_array)
130+
remaining_area = 1 - total_area
131+
for idx in range(len(x_array)):
132+
if idx == 0:
133+
integral = remaining_area / 2
134+
else:
135+
integral = (
136+
simpson(y=pdf_array[idx - 1 : idx + 1], x=x_array[idx - 1 : idx + 1])
137+
+ cdf[-1]
138+
)
139+
cdf.append(integral)
140+
return x_array, np.array(cdf)
141+
142+
143+
def calculate_quartiles(
144+
cdf_point_arrays: Tuple[np.ndarray, np.ndarray],
145+
) -> dict[float, float]:
146+
"""Compute quartiles from a CDF curve."""
147+
148+
x_array, cdf_values = cdf_point_arrays
149+
cdf_interpolated = interp1d(x_array, cdf_values)
150+
x_start, x_end = x_array[0], x_array[-1]
151+
return {
152+
0.25: brentq(lambda x: cdf_interpolated(x) - 0.25, x_start, x_end),
153+
0.5: brentq(lambda x: cdf_interpolated(x) - 0.5, x_start, x_end),
154+
0.75: brentq(lambda x: cdf_interpolated(x) - 0.75, x_start, x_end),
155+
}
156+
157+
158+
__all__ = [
159+
"finite_diff_second_derivative",
160+
"price_curve_from_iv",
161+
"pdf_from_price_curve",
162+
"calculate_cdf_from_pdf",
163+
"calculate_quartiles",
164+
]

oipd/core/errors.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
"""Centralized error types for the OIPD core package."""
4+
5+
6+
class OIPDError(Exception):
7+
"""Base exception for OIPD package."""
8+
9+
pass
10+
11+
12+
class InvalidInputError(OIPDError):
13+
"""Exception raised for invalid input parameters."""
14+
15+
pass
16+
17+
18+
class CalculationError(OIPDError):
19+
"""Exception raised when calculations fail."""
20+
21+
pass
22+
23+
24+
__all__ = ["OIPDError", "InvalidInputError", "CalculationError"]

0 commit comments

Comments
 (0)