Skip to content

Commit a37b236

Browse files
authored
Merge pull request #90 from Open-Lemma/time_to_expiry
Updated valuation_date and expiry date to accept timestamps, instead of just calendar dates
2 parents c670644 + 64899f9 commit a37b236

66 files changed

Lines changed: 4063 additions & 1293 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to **oipd** will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
66
and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.0.3]
9+
### Changed
10+
- Finalized the maturity contract around three explicit fields:
11+
- `time_to_expiry_years` for pricing and calibration
12+
- `time_to_expiry_days` for continuous day-based reporting
13+
- `calendar_days_to_expiry` for integer calendar-bucket reporting
14+
15+
### Removed
16+
- Removed the old `days_to_expiry` compatibility path from active APIs.
17+
818
## [2.0.2] - 2026-03-06
919
### Added
1020
- Added stable DataFrame export methods for fitted results:

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ chain, snapshot = sources.fetch_chain(ticker, expiries=single_expiry) # download
101101

102102
# 2. fill in the parameters
103103
market = MarketInputs(
104-
valuation_date=snapshot.date, # date on which the options data was downloaded
104+
valuation_date=snapshot.asof, # datetime on which the options data was downloaded
105105
underlying_price=snapshot.underlying_price, # the price of the underlying stock at the time when the options data was downloaded
106106
risk_free_rate=0.04, # the risk-free rate of return. Use the US Fed or Treasury yields that are closest to the horizon of the expiry date
107107
)
@@ -141,7 +141,7 @@ chain_surface, snapshot_surface = sources.fetch_chain(
141141

142142
# 2. fill in the parameters
143143
surface_market = MarketInputs(
144-
valuation_date=snapshot_surface.date, # date on which the options data was downloaded
144+
valuation_date=snapshot_surface.asof, # datetime on which the options data was downloaded
145145
underlying_price=snapshot_surface.underlying_price, # price of the underlying stock at download time
146146
risk_free_rate=0.04, # risk-free rate for the horizon
147147
)
@@ -154,9 +154,9 @@ surface.plot_fan() # Plot a fan chart of price probability over time
154154
plt.show()
155155

156156
# 5. query at arbitrary maturities directly from ProbSurface
157-
pdf_45d = surface.pdf(100, t=45/365) # density at K=100, 45 days
158-
cdf_45d = surface.cdf(100, t="2025-02-15") # equivalent date-style maturity input
159-
q50_45d = surface.quantile(0.50, t=45/365) # median at 45 days
157+
pdf_45d = surface.pdf(100, t=45/365) # density at K=100, 45.0 ACT/365 days from valuation_date
158+
cdf_intraday = surface.cdf(100, t="2025-02-15 09:30:00") # example timestamp-style maturity input
159+
q50_45d = surface.quantile(0.50, t=45/365) # median at 45 days
160160

161161
# 6. "slice" the surface to get a ProbCurve, and query its statistical properties in the same manner as in example A
162162
surface.expiries # list all the expiry dates that were captured

docs/3_user-guide.md

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,57 @@ from oipd import MarketInputs
9797

9898
market = MarketInputs(
9999
risk_free_rate=0.04,
100-
valuation_date=snapshot.date, # or a fixed date
100+
valuation_date=snapshot.asof, # recommended for intraday precision
101101
underlying_price=snapshot.underlying_price, # set explicitly if snapshot is unavailable
102102
# optional:
103103
# risk_free_rate_mode="annualized", # default
104104
# dividend_yield=0.01,
105105
)
106106
```
107107

108+
`valuation_date` accepts both plain dates and full datetimes. Date-only values
109+
remain fully supported, and full datetimes (for example `snapshot.asof`) should
110+
be preferred when intraday precision matters.
111+
112+
Market-object contract:
113+
114+
- `valuation_date` remains the public field name and stores the canonical
115+
normalized timestamp.
116+
- `valuation_calendar_date` is the explicit date-only convenience view.
117+
- `valuation_timestamp` remains as a temporary compatibility alias to the same
118+
canonical timestamp value.
119+
120+
The option maturity field remains `expiry`. OIPD keeps `valuation_date` as the
121+
public input name for backwards compatibility, but internally it resolves both
122+
`valuation_date` and `expiry` to full timestamps before computing maturity.
123+
That means exact `time_to_expiry_years` is now the source-of-truth input for
124+
pricing, calibration, and probability calculations. Reporting in day units now
125+
uses explicit `time_to_expiry_days`, while `calendar_days_to_expiry` is the
126+
explicit integer calendar bucket.
127+
128+
Maturity contract:
129+
130+
- `oipd.core.maturity` is the canonical home of maturity logic.
131+
- `time_to_expiry_years` is the pricing truth.
132+
- `time_to_expiry_days` is the continuous reporting truth.
133+
- `calendar_days_to_expiry` is the integer calendar bucket.
134+
135+
Migration note:
136+
137+
- replace old `days_to_expiry` inputs with `time_to_expiry_years` for pricing
138+
or `time_to_expiry_days` for reporting
139+
- replace old `days_to_expiry` metadata reads with
140+
`calendar_days_to_expiry` if you intended integer calendar-bucket semantics
141+
142+
If you use explicit dividends in the Black-Scholes path, `dividend_schedule`
143+
supports both date-only and timestamp-style `ex_date` values. Same-day timing
144+
matters: an ex-dividend timestamp after `valuation_date` is included in pricing,
145+
while one before `valuation_date` is excluded. Date-only dividend rows keep the
146+
current midnight semantics for backwards compatibility.
147+
148+
Timezone display redesign is still out of scope for this cycle. Intraday
149+
arithmetic is supported, but timezone-aware display semantics are unchanged.
150+
108151
### 2.3 Fit single-expiry (`VolCurve`) or multi-expiry (`VolSurface`)
109152

110153
Single-expiry example:
@@ -143,6 +186,12 @@ Both surface objects support *slicing*:
143186

144187
After slicing, you can use the same methods you would use on regular curve objects (`implied_vol`, `price`, `greeks`, `iv_results` for volatility curves; `pdf`, `prob_below`, `quantile`, `density_results`, `plot` for probability curves).
145188

189+
When `expiry` or `valuation_date` includes a non-midnight timestamp, OIPD
190+
preserves that intraday precision through surface queries and will show the
191+
time-of-day in labels/plots where relevant. Midnight timestamps continue to
192+
render as date-only labels, so older date-based workflows remain visually
193+
stable.
194+
146195
```python
147196
# volatility surface -> volatility curve snapshot
148197
vol_curve_slice = vol_surface.slice("2026-01-16")
@@ -173,7 +222,7 @@ p_below_240 = prob_curve_slice.prob_below(240)
173222
| **Expiries** | `expiries` (1-tuple) | Single expiry date. | `expiries` (list) | List of fitted expiry dates. |
174223
| **Distributions** | `implied_distribution()` | Get `ProbCurve` (RND). | `implied_distribution()` | Get `ProbSurface` (RND Surface). |
175224
| **Visualization (2D curve)** | `plot()` | Plot fitted smile vs market. | `plot()` | Overlayed IV smiles. |
176-
| **Visualization (3D surface)** | | | `plot_3d()` | Isometric 3D volatility surface. |
225+
| **Visualization (3D surface)** | | | `plot_3d()` | Isometric 3D volatility surface. `expiry_range` accepts date-like bounds and is converted to continuous `time_to_expiry_days` internally. |
177226
| **Term Structure** | | | `plot_term_structure()` | Interpolated ATM-forward IV term structure vs days to expiry. |
178227

179228
### Probability API Methods Comparison

docs/4_examples.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,36 @@ The notebooks below mirror the workflows in the User Guide.
1010
- [`examples/quickstart_yfinance.ipynb`](https://github.com/Open-Lemma/options-implied-probability/blob/main/examples/quickstart_yfinance.ipynb): live-data probability workflow (vendor fetch).
1111
- [`examples/quickstart_VolCurve.ipynb`](https://github.com/Open-Lemma/options-implied-probability/blob/main/examples/quickstart_VolCurve.ipynb): single-expiry volatility workflow.
1212
- [`examples/quickstart_VolSurface.ipynb`](https://github.com/Open-Lemma/options-implied-probability/blob/main/examples/quickstart_VolSurface.ipynb): multi-expiry surface workflow.
13+
14+
`MarketInputs.valuation_date` accepts both dates and datetimes. For intraday
15+
precision when using vendor data, prefer `snapshot.asof`.
16+
17+
Object-model contract used throughout the examples:
18+
19+
- `valuation_date` remains the public field name and stores the canonical
20+
normalized timestamp.
21+
- `valuation_calendar_date` is the explicit date-only convenience view.
22+
- `valuation_timestamp` remains as a temporary compatibility alias to the same
23+
canonical timestamp value.
24+
25+
Across the examples, the canonical maturity field is `expiry`. Exact
26+
`time_to_expiry_years` is used internally for pricing and probability
27+
calculations. Reporting in day units uses explicit `time_to_expiry_days`,
28+
while `calendar_days_to_expiry` is the explicit integer calendar bucket. If
29+
you pass intraday timestamps, plots will surface that time-of-day where it
30+
matters.
31+
32+
Developer note:
33+
34+
- `oipd.core.maturity` is the canonical home of maturity logic.
35+
36+
Migration note:
37+
38+
- old `days_to_expiry` inputs should move to `time_to_expiry_years` for
39+
pricing or `time_to_expiry_days` for reporting
40+
- old `days_to_expiry` metadata reads should move to
41+
`calendar_days_to_expiry` if integer calendar-bucket semantics were intended
42+
43+
If you provide a Black-Scholes `dividend_schedule`, its `ex_date` column can
44+
also be date-only or full timestamp values. Same-day timestamp ordering matters
45+
for pricing. Timezone display redesign remains out of scope for this cycle.

examples/quickstart_VolCurve.ipynb

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

examples/quickstart_VolSurface.ipynb

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

examples/quickstart_yfinance.ipynb

Lines changed: 40 additions & 38 deletions
Large diffs are not rendered by default.

oipd/core/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
)
2929
from oipd.core.data_processing import filter_stale_options, select_price_column
3030

31-
3231
_BASE_EXPORTS = [
3332
"calculate_cdf_from_pdf",
3433
"calculate_quartiles",

oipd/core/data_processing/__init__.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
"""Data preprocessing scaffolding package.
1+
"""Convenience namespace for data-processing helpers.
22
3-
This namespace will eventually host the refactored preprocessing helpers.
4-
While the migration is in flight, it simply re-exports the legacy functions
5-
so early adopters can rely on the new module paths without breaking.
3+
This package primarily re-exports the preprocessing functions implemented in
4+
its submodules so callers can import them from one stable package path.
65
"""
76

87
from . import dividends, iv, moneyness, parity, selection, validation # noqa: F401

oipd/core/data_processing/iv.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from scipy.optimize import brentq
1010

1111
from oipd.core.errors import CalculationError
12-
from oipd.core.utils import convert_days_to_years
1312
from oipd.core.vol_surface_fitting import VolCurve, fit_surface
1413
from oipd.pricing.black76 import black76_call_price as _b76_price
1514
from oipd.pricing.black_scholes import (
@@ -18,11 +17,37 @@
1817
)
1918

2019

20+
def _resolve_time_to_expiry_years(
21+
*,
22+
time_to_expiry_years: Optional[float],
23+
) -> float:
24+
"""Validate one explicit year-fraction maturity value.
25+
26+
Args:
27+
time_to_expiry_years: Canonical year-fraction maturity.
28+
29+
Returns:
30+
float: Finite maturity in year fractions.
31+
32+
Raises:
33+
ValueError: If no maturity is provided or the resolved value is non-finite.
34+
"""
35+
if time_to_expiry_years is None:
36+
raise ValueError("compute_iv requires time_to_expiry_years.")
37+
38+
years_to_expiry = float(time_to_expiry_years)
39+
40+
if not np.isfinite(years_to_expiry):
41+
raise ValueError("time_to_expiry_years must be finite for IV extraction.")
42+
43+
return years_to_expiry
44+
45+
2146
def compute_iv(
2247
options_data: pd.DataFrame,
2348
underlying_price: float,
2449
*,
25-
days_to_expiry: int,
50+
time_to_expiry_years: Optional[float] = None,
2651
risk_free_rate: float,
2752
solver_method: Literal["newton", "brent"],
2853
pricing_engine: Literal["black76", "bs"],
@@ -32,14 +57,26 @@ def compute_iv(
3257
3358
Returns a copy of ``options_data`` with an added ``iv`` column and rows where
3459
IV could not be solved (NaN) removed.
60+
61+
Args:
62+
options_data: Option rows with ``price`` and ``strike``.
63+
underlying_price: Spot or forward input used by the pricing engine.
64+
time_to_expiry_years: Exact year-fraction maturity input.
65+
risk_free_rate: Continuously compounded risk-free rate for pricing.
66+
solver_method: BS solver choice when ``pricing_engine == "bs"``.
67+
pricing_engine: ``"black76"`` or ``"bs"``.
68+
dividend_yield: Continuous dividend yield for BS pricing.
3569
"""
3670

3771
if underlying_price is None:
3872
raise ValueError(
3973
"Effective underlying/forward price is required for IV extraction"
4074
)
4175

42-
years_to_expiry = convert_days_to_years(days_to_expiry)
76+
years_to_expiry = _resolve_time_to_expiry_years(
77+
time_to_expiry_years=time_to_expiry_years,
78+
)
79+
4380
prices_arr = options_data["price"].to_numpy(dtype=float)
4481
strikes_arr = options_data["strike"].to_numpy(dtype=float)
4582

0 commit comments

Comments
 (0)