From bf0ee25d1ab7ccf0331b37602286c57d27bfbd90 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 17 May 2026 17:21:49 +0100 Subject: [PATCH 1/5] Add fitting of forward and discount factor --- .github/copilot-instructions.md | 2 +- app/volatility_surface.py | 45 ++++- docs/api/data/index.md | 2 +- docs/api/options/black.md | 4 +- docs/api/options/parity.md | 6 + docs/api/rates/index.md | 14 ++ docs/api/rates/nelson_siegel.md | 3 + docs/glossary.md | 42 ++++- docs/theory/forwards.md | 68 ++++++++ docs/theory/option_pricing.md | 19 +- docs/tutorials/heston_calibration.md | 152 ++++++++++++++++ docs/tutorials/index.md | 3 +- docs/tutorials/volatility_surface.md | 187 ++++---------------- mkdocs.yml | 4 + quantflow/data/yahoo.py | 86 ++++++++- quantflow/options/bs.py | 9 +- quantflow/options/parity.py | 141 +++++++++++++++ quantflow/options/surface.py | 252 ++++++++++++++++++++++----- quantflow/rates/interest_rate.py | 9 + quantflow/rates/nelson_siegel.py | 10 +- quantflow/rates/yield_curve.py | 64 +++++++ quantflow/utils/plot.py | 28 ++- quantflow/utils/price.py | 37 ++++ quantflow_tests/test_rates.py | 4 +- 24 files changed, 957 insertions(+), 234 deletions(-) create mode 100644 docs/api/options/parity.md create mode 100644 docs/api/rates/nelson_siegel.md create mode 100644 docs/theory/forwards.md create mode 100644 docs/tutorials/heston_calibration.md create mode 100644 quantflow/options/parity.py create mode 100644 quantflow/utils/price.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2727217..704f9fb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -32,7 +32,7 @@ applyTo: '/**' * Math in documentation and docstrings: always use `\begin{equation}...\end{equation}` for any formula or equation. Use `$...$` only for brief inline references to variables (e.g. $F$, $K$). Do not use `$$...$$`, `` `...` ``, or RST syntax (`.. math::`, `:math:`). * Math notation convention: use $\Phi$ for the characteristic function and $\phi$ for the characteristic exponent, where $\Phi = e^{-\phi}$. * Glossary entries in `docs/glossary.md` must be kept in alphabetical order. -* Do not repeat concept definitions inline in tutorials or docstrings — link to the glossary instead using a relative markdown link (e.g. `[moneyness](../glossary.md#moneyness)`). +* Do not repeat concept definitions inline in tutorials or docstrings, link to the glossary instead using a relative markdown link (e.g. `[moneyness](../glossary.md#moneyness)`). * Prefer mkdocstrings relative cross-references whenever the target is visible from the current scope: write `[label][.member]` (same class) or `[label][..Sibling]` (same module) instead of repeating the fully-qualified path. Use the full path only when the target lives in a different module than the current docstring. * To rebuild doc examples run `uv run ./dev/build-examples` — runs all scripts in `docs/examples/` and writes their output to `docs/examples_output/` diff --git a/app/volatility_surface.py b/app/volatility_surface.py index b0ac27e..5a133b9 100644 --- a/app/volatility_surface.py +++ b/app/volatility_surface.py @@ -1,6 +1,6 @@ import marimo -__generated_with = "0.22.0" +__generated_with = "0.23.5" app = marimo.App(width="medium") @@ -28,6 +28,8 @@ def _(mo): async with Deribit() as cli: loader = await cli.volatility_surface_loader("eth", exclude_open_interest=0) + # calibrate discount curve for the quoting asset (usd) + loader.calibrate_curves(quote_curve=NelsonSiegel) # build the volatility surface surface = loader.surface() # calculate black implied volatilities @@ -54,6 +56,7 @@ def _(mo): async def _(asset, inverse, mo): import pandas as pd from quantflow.data.deribit import Deribit + from quantflow.rates.nelson_siegel import NelsonSiegel async with Deribit() as cli: loader = await cli.volatility_surface_loader( @@ -62,6 +65,7 @@ async def _(asset, inverse, mo): use_perp=not inverse.value ) + loader.calibrate_curves(quote_curve=NelsonSiegel, asset_curve=NelsonSiegel) # build the volatility surface surface = loader.surface() # calculate black implied volatilities @@ -81,7 +85,7 @@ def int_or_none(v): label="Maturity" ) maturity_dropdown - return int_or_none, maturity_dropdown, pd, surface + return int_or_none, loader, maturity_dropdown, pd, surface @app.cell @@ -114,5 +118,42 @@ def _(ts): return +@app.cell +def _(loader): + loader.quote_curve.plot(ttm_max=2) + return + + +@app.cell +def _(loader): + loader.asset_curve.plot(ttm_max=2) + return + + +@app.cell +def _(loader): + cross = loader.maturities[sorted(loader.maturities)[-2]] + p = cross.put_call_parities(loader.spot.mid, max_pairs=100) + da=None + return da, p + + +@app.cell +def _(da, p): + p.fit_discounts(da=da) + return + + +@app.cell +def _(da, p): + p.plot(da=da) + return + + +@app.cell +def _(): + return + + if __name__ == "__main__": app.run() diff --git a/docs/api/data/index.md b/docs/api/data/index.md index db92556..5ea4094 100644 --- a/docs/api/data/index.md +++ b/docs/api/data/index.md @@ -19,7 +19,7 @@ pip install quantflow[data] | [Financial Modeling Prep](fmp.md) | Equity prices, company profiles, and sector data | | [FRED](fred.md) | US macroeconomic time series from the St. Louis Fed | | [Federal Reserve](fed.md) | Federal Reserve H.15 selected interest rate data | -| [Yahoo](yahoo.md) | Equity option chains from Yahoo Finance | +| [Yahoo](yahoo.md) | Equity prices and option chains from Yahoo Finance | ## Usage diff --git a/docs/api/options/black.md b/docs/api/options/black.md index 5107fdb..9da130a 100644 --- a/docs/api/options/black.md +++ b/docs/api/options/black.md @@ -3,10 +3,10 @@ Here we define the [log strike](../../glossary.md#log-strike) `k` as \begin{equation} - k = \log{\frac{K}{F}} + k = \log{\frac{K}{F_\tau}} \end{equation} -where $K$ is the strike price and $F$ is the forward price of the underlying asset. +where $K$ is the strike price and $F_\tau$ is the forward price of the underlying asset at time to maturity $\tau$. ::: quantflow.options.bs.black_price diff --git a/docs/api/options/parity.md b/docs/api/options/parity.md new file mode 100644 index 0000000..35f2fa9 --- /dev/null +++ b/docs/api/options/parity.md @@ -0,0 +1,6 @@ +# Put-Call Parity + + +::: quantflow.options.parity.PutCallParity + +::: quantflow.options.parity.PutCallParities diff --git a/docs/api/rates/index.md b/docs/api/rates/index.md index bb62d8e..82514be 100644 --- a/docs/api/rates/index.md +++ b/docs/api/rates/index.md @@ -1 +1,15 @@ # Interest Rates + +The `quantflow.rates` module provides primitives for interest rate modelling: flat rates, yield curves, and curve fitting. + +The central concept is the [discount factor](../../glossary.md#discount-factor) $D_\tau$, the present value of one unit of currency paid at time $\tau$. Every class in this module exposes a `discount_factor` method that computes $D_\tau$ from the configured rate or curve. + +**[Rate](interest_rate.md)** represents a spot or forward interest rate with a chosen compounding frequency (continuous by default) and day count convention. It supports continuous and periodic compounding and can be bootstrapped directly from a spot/forward pair. + +**[YieldCurve](yield_curve.md)** is the abstract base for term-structure models. It defines the interface via `discount_factor` and `instantaneous_forward_rate`, with the two quantities linked by + +\begin{equation} + f(\tau) = -\frac{\partial \ln D_\tau}{\partial \tau} +\end{equation} + +**[NelsonSiegel](nelson_siegel.md)** is a concrete `YieldCurve` implementation that fits a smooth parametric curve to observed zero-coupon rates using the Nelson-Siegel functional form. diff --git a/docs/api/rates/nelson_siegel.md b/docs/api/rates/nelson_siegel.md new file mode 100644 index 0000000..9b30a71 --- /dev/null +++ b/docs/api/rates/nelson_siegel.md @@ -0,0 +1,3 @@ +# Nelson Siegel Curve + +::: quantflow.rates.nelson_siegel.NelsonSiegel diff --git a/docs/glossary.md b/docs/glossary.md index 04880f3..a848a0c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -43,6 +43,19 @@ of a real-valued random variable $x$ is the function given by where ${\mathbb P}_x$ is the distrubution measure of $x$. +## Discount Factor + +The discount factor $D\left(\tau\right)$ is the present value of one unit of currency paid at [time to maturity](#time-to-maturity-ttm) $\tau$. +It is equal to the price of a zero-coupon bond maturing at $\tau$: a contract that pays exactly 1 at maturity with no intermediate cashflows. + +\begin{equation} + D\left(\tau\right) = e^{-r \tau} +\end{equation} + +where $r$ is the continuously compounded risk-free rate. + +Under a zero interest rate assumption, $D\left(\tau\right) = 1\ \ \ \forall\ \tau$. + ## Feller Condition The Feller condition is a parameter constraint on a square-root diffusion process @@ -71,6 +84,16 @@ condition holds. The `feller_enforce` flag (default `True`) that imposes this as a hard inequality constraint during optimisation. +## Forwards + +The forward price $F$ of an asset at maturity $\tau$ is the price agreed upon today for delivery of the asset at time $\tau$. It is given by the ratio of two discount factors: one for the asset $D_a(\tau)$ and one for the quote $D_q(\tau)$. + +\begin{equation} +F(\tau) = S \cdot \frac{D_a(\tau)}{D_q(\tau)} +\end{equation} + +See [Forwards and Discount Factors](theory/forwards.md) for more details. + ## Forward Space Forward space is the unit-free convention in which option prices are @@ -172,21 +195,26 @@ The [probability density function](https://en.wikipedia.org/wiki/Probability_den ## Put-Call Parity Put-call parity is a no-arbitrage relationship between the prices of European call -and put options with the same strike $K$ and maturity. Denoting forward-space prices -$c = C/F$ and $p = P/F$ (see [Black Pricing](api/options/black.md)), the relationship -reads: +and put options with the same strike $K$ and time to maturity. \begin{equation} - c - p = 1 - \frac{K}{F} = 1 - e^k + C - P = D_q \left(F - K\right) \end{equation} -where $k$ is the [log-strike](#log-strike). -In quoting currency terms, multiplying through by $F$: +where $D_q$ is the [discount factor](#discount-factor) of the quoting asset (generally a currency) +at maturity and $F$ is the forward price +of the underlying asset at maturity. + +Denoting forward-space prices +$c = C/(D_q\ F)$ and $p = P/(D_q\ F)$ (see [Black Pricing](api/options/black.md)), the relationship +reads: \begin{equation} - C - P = F - K + c - p = 1 - \frac{K}{F} = 1 - e^k \end{equation} +where $k$ is the [log-strike](#log-strike). + ## Time To Maturity (TTM) Time to maturity is the time remaining until an option or forward contract expires, diff --git a/docs/theory/forwards.md b/docs/theory/forwards.md new file mode 100644 index 0000000..ff0558a --- /dev/null +++ b/docs/theory/forwards.md @@ -0,0 +1,68 @@ +# Forwards and Discount Factors + +## Discount Factors + +A [discount factor](../glossary.md#discount-factor) $D(\tau)$ is the present value of +one unit of currency delivered at time $\tau$. + +In quantflow, discount factors are provided by a +[YieldCurve][quantflow.rates.yield_curve.YieldCurve]. Different implementations capture +different term structures: a flat zero-rate curve, a fitted +[Nelson-Siegel][quantflow.rates.nelson_siegel.NelsonSiegel] curve, or any custom term +structure. + +## Forward Price + +The forward price of an asset at maturity $\tau$ is defined under the assumption that +two discount factors are available: one for the asset $D_a(\tau)$ and one for the +quote $D_q(\tau)$. + +\begin{equation} +F(\tau) = S \cdot \frac{D_a(\tau)}{D_q(\tau)} +\end{equation} + +where $S$ is the current spot price. + +For an asset that pays no dividends and has no carry costs, the discount factor +for the asset is constant and equal to one. In this case +the forward price is simply given by the discount factor for the quote: + +\begin{equation} +F(\tau) = \frac{S}{D_q(\tau)} +\end{equation} + +When the quote is cash, the forward price is the spot price compounded at the risk-free rate, +which means that forward prices are typically higher than the spot price. + +## Put-Call Parity + +The Put call parity is a fundamental relationship between the prices of European call and put options with the same strike and maturity. +It states that the difference between the call price $C$ and the put price $P$ is equal to the discounted difference between the forward price $F$ and the strike price $K$: + +\begin{equation} +C - P = S \cdot D_a - D_q \cdot K +\end{equation} + +Dividing by the Spot price + +\begin{equation} +\frac{C - P}{S} = D_a - \frac{D_q}{S} \cdot K +\end{equation} + +This is linear in $K$, with slope $-D_q / S$ and intercept $D_a$. + +Fitting a linear regression across multiple strikes at the same maturity therefore +identifies both discount factors simultaneously: $D_q$ from the slope and $D_a$ from +the intercept. + +### Inverse Options + +For inverse options, prices are quoted in units of the underlying asset, which acts as +the quote. The relevant discount factor is therefore $D_a$. Put-call parity becomes: + +\begin{equation} +c - p = D_a \left(1 - \frac{K}{F}\right) = D_a - \frac{D_q}{S} \cdot K +\end{equation} + +This is again linear in $K$, with intercept $D_a$ and slope $-D_q / S$, exactly the same as for regular options. +The same regression therefore identifies both discount factors as before. diff --git a/docs/theory/option_pricing.md b/docs/theory/option_pricing.md index fb6884c..30dfba0 100644 --- a/docs/theory/option_pricing.md +++ b/docs/theory/option_pricing.md @@ -1,24 +1,31 @@ # Option Pricing -We use characteristic function inversion to price European call options on an underlying $S_t = S_0 e^{s_t}$, where $S_0$ is the spot price at time 0. We assume zero interest rates, so the forward equals the spot. The log-return $s_t = x_t - c_t$ is constructed from a driving process $x_t$ and a deterministic [convexity correction](convexity_correction.md) $c_t$ that enforces the martingale condition ${\mathbb E}[e^{s_t}] = 1$. +We use characteristic function inversion to price European call options on an underlying $S_t = F_t e^{s_t}$, where $F_t$ is the forward price at time $t$. +The log-return $s_t = x_t - c_t$ is constructed from a driving process $x_t$ and a deterministic [convexity correction](convexity_correction.md) $c_t$ +that enforces the martingale condition ${\mathbb E}[e^{s_t}] = 1$. ## Call Option -The price $C$ of a call option with strike $K$ is defined as +The price $C$ of a call option with expiry $\tau$ and strike $K$ is defined as \begin{equation} \begin{aligned} -C &= S_0 c_k \\ -k &= \ln\frac{K}{S_0} \\ +C &= D_\tau F_\tau c_k \\ +k &= \ln\frac{K}{F_\tau} \\ c_k &= {\mathbb E}\left[\left(e^{s_t} - e^k\right)^+\right] = \int_{-\infty}^\infty \left(e^s - e^k\right)^+ f_{s_t}(s)\, ds \end{aligned} \label{call-price} \end{equation} -$k$ is the [log-strike](../glossary.md#log-strike) and $f_{s_t}$ is the probability density function of $s_t$. The call price is the discounted expected payoff under the risk-neutral measure, which simplifies to the undiscounted expected payoff when interest rates are zero. +$k$ is the [log-strike](../glossary.md#log-strike) with respect to the forward $F_\tau$ and $f_{s_t}$ is the probability density function of $s_t$. +$D_\tau$ is the [discount factor](../glossary#discount-factor) to time $\tau$, which is 1 under a zero interest rate assumption. -All three methods share this starting point. They all express $c_k$ via the characteristic function $\Phi_{s_t}$, but differ in how the integration contour is chosen, how the payoff is handled, and the discretisation strategy. +## Fourier Inversion Methods + +The key insight is that while the density $f_{s_t}$ may not have a closed form, its [characteristic function](../glossary.md#characteristic-function) $\Phi_{s_t}$ is available analytically for a wide class of stochastic processes. The integral in $\eqref{call-price}$ can therefore be evaluated by inverting $\Phi_{s_t}$ numerically. + +Quantflow implements three Fourier inversion approaches: [Carr and Madan (1999)](../bibliography.md#carr_madan), [Lewis (2001)](../bibliography.md#lewis), and the [COS method](../bibliography.md#cos) (Fang and Oosterlee, 2008). All three share the same starting point and express $c_k$ via $\Phi_{s_t}$, but differ in how the integration contour is chosen, how the payoff is handled, and the discretisation strategy. ## Carr & Madan diff --git a/docs/tutorials/heston_calibration.md b/docs/tutorials/heston_calibration.md new file mode 100644 index 0000000..df3140c --- /dev/null +++ b/docs/tutorials/heston_calibration.md @@ -0,0 +1,152 @@ +# Heston Volatility Model + +## Calibrating the Heston Model + +[HestonCalibration][quantflow.options.calibration.heston.HestonCalibration] fits the +five Heston parameters ($v_0$, $\theta$, $\kappa$, $\sigma$, $\rho$) to the implied +volatility surface using a two-stage optimisation: + +1. **L-BFGS-B** minimises the scalar cost function (sum of squared weighted price + residuals) to reach a good basin of attraction. +2. **Trust-region reflective** (`least_squares` with `method="trf"`) refines the + solution on the residual vector with tight tolerances and enforces parameter bounds. + +Residuals are computed as `weight * (model_call_price - mid_call_price)` where +`mid_call_price` is the average of the bid and ask call prices. + +The weight is $\min(e^{w \cdot m^2}, w_\text{max})$ controlled by +`moneyness_weight` (the coefficient $w$) and `max_cost_weight` (the cap +$w_\text{max}$), with $m = \log(K/F)/\sqrt{T}$ the standardised moneyness. +The quadratic exponent matches the gaussian shape of $1/\nu$ (inverse vega), +so a positive `moneyness_weight` puts wing residuals on the same footing as +ATM ones. The cap prevents a single deep-wing option from dominating the +loss. + +A penalty for violating the Feller condition ($2\kappa\theta \geq \sigma^2$) +is added during stage 1 to keep the variance process well-behaved. + +```python +--8<-- "docs/examples/vol_surface_heston_calibration.py" +``` + +### Output + +--8<-- "docs/examples/output/vol_surface_heston_calibration.out" + +### Calibration Options + +The `moneyness_weight` parameter up-weights far-from-the-money options via +$e^{w \cdot m^2}$ where $m = \log(K/F)/\sqrt{T}$ is the standardised +moneyness. The result is capped at `max_cost_weight` (default 10) so a +single deep-wing option cannot dominate the loss. + +### Plotting the Calibrated Smile + +Use [plot_maturities()][quantflow.options.calibration.base.VolModelCalibration.plot_maturities] +to produce a Plotly figure overlaying market bid/ask implied vols against the model smile +for all maturities at once: + +```python +fig = calibration.plot_maturities(max_moneyness_ttm=1.5, support=101) +fig.write_image("heston_calibrated_smile.png", width=1200) +``` + +The x axis is [moneyness](../glossary.md#moneyness). + +![Heston calibrated smile](../assets/examples/heston_calibrated_smile.png) + +### Model Limitations at Short Maturities + +Inspecting the calibrated smiles across all maturities reveals a systematic pattern: +the Heston model fits long-dated options reasonably well but struggles with short-term +maturities, where the market smile is steeper than the model can reproduce. + +This is a fundamental structural limitation, not a numerical issue. The Heston model +generates an implied volatility smile through two mechanisms: the correlation $\rho$ +between spot and variance (which creates skew) and the volatility-of-variance $\sigma$ +(which inflates the wings). Both effects accumulate diffusively over time. For a maturity +$T$, the smile roughly scales as $\sigma \sqrt{T}$, so as $T \to 0$ the distribution +collapses toward a Gaussian and the smile flattens. + +More precisely, the Heston characteristic function at short maturities satisfies: + +\begin{equation} +\log \phi(u, T) \approx i u \mu T - \tfrac{1}{2} u^2 v_0 T + O(T^2) +\end{equation} + +which is the characteristic function of a Gaussian with variance $v_0 T$. The higher +cumulants that produce skew and excess kurtosis are all $O(T^2)$ or smaller, so they +vanish faster than the Gaussian term as $T \to 0$. + +In practice this means the Heston model essentially reduces to Black-Scholes for +near-expiry options. The market, however, exhibits pronounced short-term skew driven by +jump risk and the market microstructure of short-dated hedging demand. A diffusion-only +model cannot reproduce this behaviour regardless of how its parameters are tuned. + +The natural extension is to add a jump component to the dynamics, which contributes +a term of order $O(T)$ to the cumulants and restores the short-term smile. This is +the motivation for the Heston jump-diffusion model described in the next section. + +## Calibrating the Heston Jump-Diffusion Model + +[HestonJCalibration][quantflow.options.calibration.heston.HestonJCalibration] extends the +Heston calibration with a compound Poisson jump component via the +[HestonJ][quantflow.sp.heston.HestonJ] model. Jumps are drawn from a +[DoubleExponential][quantflow.utils.distributions.DoubleExponential] distribution, +which captures asymmetric jump behaviour common in equity and crypto markets. + +```python +--8<-- "docs/examples/vol_surface_hestonj_calibration.py" +``` + +--8<-- "docs/examples/output/vol_surface_hestonj_calibration.out" + +### Plotting the Calibrated Smile + +```python +fig = calibration.plot_maturities(max_moneyness_ttm=1.5, support=101) +fig.write_image("hestonj_calibrated_smile.png", width=1200) +``` + +![HestonJ calibrated smile](../assets/examples/hestonj_calibrated_smile.png) + +### Remaining Limitations at Short Maturities + +Adding jumps improves the short-term smile significantly compared to plain Heston, but +the fit at the nearest maturities is still imperfect. Several structural reasons combine: + +**Jump parameters are global.** The compound Poisson component has a single intensity +$\lambda$, jump variance, and asymmetry shared across all maturities. Increasing +$\lambda$ to steepen the short-term smile simultaneously distorts the long-term smile, +so the optimizer settles on a compromise. + +**Long maturities dominate the cost function.** They have more liquid strikes and +therefore more data points. The optimizer minimizes total squared residuals across the +whole surface, so short maturities — with fewer strikes — are outvoted and their fit is +systematically sacrificed. + +**The jump distribution is not rich enough.** The short-term smile in crypto is driven +by large, rare, asymmetric events. A [DoubleExponential][quantflow.utils.distributions.DoubleExponential] +with fixed parameters cannot simultaneously match the wing curvature at short and long +maturities. + +The natural next step is a rough volatility model (for example rough Heston with Hurst +parameter $H < \tfrac{1}{2}$). Because the variance process has long memory and does not +behave diffusively at short time scales, rough models produce a steep short-term skew +without requiring jumps, and the skew decays as a power law $T^H$ rather than the +$T^{1/2}$ rate of classical stochastic volatility. + +### Parameter Reference + +The calibrated parameter vector for the jump-diffusion model is: + +| Parameter | Description | +|---|---| +| `vol` | Initial volatility ($\sqrt{v_0}$) | +| `theta` | Long-run volatility ($\sqrt{\theta}$) | +| `kappa` | Mean reversion speed | +| `sigma` | Volatility of variance | +| `rho` | Spot-variance correlation | +| `jump intensity` | Jump arrival rate (jumps per year) | +| `jump variance` | Variance of a single jump | +| `jump asymmetry` | Asymmetry of the jump distribution ([DoubleExponential][quantflow.utils.distributions.DoubleExponential]) | diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md index 0b129bb..ff01f8e 100644 --- a/docs/tutorials/index.md +++ b/docs/tutorials/index.md @@ -5,6 +5,7 @@ Step-by-step guides for common quantflow workflows. | Tutorial | Description | |---|---| | [Option Pricing](option_pricing.md) | Price a European option with the Black-Scholes and Heston-jump-diffusion models | -| [Volatility Surface](volatility_surface.md) | Fetch live option data, build an implied volatility surface, and calibrate Heston and jump-diffusion models | +| [Volatility Surface](volatility_surface.md) | Fetch live option data, build an implied volatility surface, and extract forwards and discount factors from option prices | +| [Heston Volatility Model](heston_calibration.md) | Calibrate the Heston and Heston-jump-diffusion models to an implied volatility surface | | [SPX Volatility Surface](spx_vol_surface.md) | Build a 3D implied volatility surface for the S&P 500 from a Yahoo Finance option chain | | [BNS Volatility Model](bns_calibration.md) | Calibrate the Barndorff-Nielsen and Shephard stochastic-volatility model to an implied volatility surface | diff --git a/docs/tutorials/volatility_surface.md b/docs/tutorials/volatility_surface.md index b9eb2c9..2249623 100644 --- a/docs/tutorials/volatility_surface.md +++ b/docs/tutorials/volatility_surface.md @@ -1,8 +1,8 @@ # Volatility Surface -This tutorial covers the full workflow for building and calibrating an implied volatility -surface: fetching option quotes from Deribit, inspecting the surface inputs, and -calibrating the Heston and Heston-jump-diffusion models. +This tutorial covers the full workflow for building an implied volatility surface: +fetching option quotes from Deribit, extracting implied forwards and discount factors +from option prices, and inspecting the surface inputs. ## Fetching Data from Deribit @@ -98,153 +98,34 @@ inputs = surface.inputs(converged=True) # VolSurface -> VolSurfaceInputs surface2 = surface_from_inputs(inputs) # VolSurfaceInputs -> VolSurface ``` -## Calibrating the Heston Model - -[HestonCalibration][quantflow.options.calibration.heston.HestonCalibration] fits the -five Heston parameters ($v_0$, $\theta$, $\kappa$, $\sigma$, $\rho$) to the implied -volatility surface using a two-stage optimisation: - -1. **L-BFGS-B** minimises the scalar cost function (sum of squared weighted price - residuals) to reach a good basin of attraction. -2. **Trust-region reflective** (`least_squares` with `method="trf"`) refines the - solution on the residual vector with tight tolerances and enforces parameter bounds. - -Residuals are computed as `weight * (model_call_price - mid_call_price)` where -`mid_call_price` is the average of the bid and ask call prices. - -The weight is $\min(e^{w \cdot m^2}, w_\text{max})$ controlled by -`moneyness_weight` (the coefficient $w$) and `max_cost_weight` (the cap -$w_\text{max}$), with $m = \log(K/F)/\sqrt{T}$ the standardised moneyness. -The quadratic exponent matches the gaussian shape of $1/\nu$ (inverse vega), -so a positive `moneyness_weight` puts wing residuals on the same footing as -ATM ones. The cap prevents a single deep-wing option from dominating the -loss. - -A penalty for violating the Feller condition ($2\kappa\theta \geq \sigma^2$) -is added during stage 1 to keep the variance process well-behaved. - -```python ---8<-- "docs/examples/vol_surface_heston_calibration.py" -``` - -### Output - ---8<-- "docs/examples/output/vol_surface_heston_calibration.out" - -### Calibration Options - -The `moneyness_weight` parameter up-weights far-from-the-money options via -$e^{w \cdot m^2}$ where $m = \log(K/F)/\sqrt{T}$ is the standardised -moneyness. The result is capped at `max_cost_weight` (default 10) so a -single deep-wing option cannot dominate the loss. - -### Plotting the Calibrated Smile - -Use [plot_maturities()][quantflow.options.calibration.base.VolModelCalibration.plot_maturities] -to produce a Plotly figure overlaying market bid/ask implied vols against the model smile -for all maturities at once: - -```python -fig = calibration.plot_maturities(max_moneyness_ttm=1.5, support=101) -fig.write_image("heston_calibrated_smile.png", width=1200) -``` - -The x axis is [moneyness](../glossary.md#moneyness). - -![Heston calibrated smile](../assets/examples/heston_calibrated_smile.png) - -### Model Limitations at Short Maturities - -Inspecting the calibrated smiles across all maturities reveals a systematic pattern: -the Heston model fits long-dated options reasonably well but struggles with short-term -maturities, where the market smile is steeper than the model can reproduce. - -This is a fundamental structural limitation, not a numerical issue. The Heston model -generates an implied volatility smile through two mechanisms: the correlation $\rho$ -between spot and variance (which creates skew) and the volatility-of-variance $\sigma$ -(which inflates the wings). Both effects accumulate diffusively over time. For a maturity -$T$, the smile roughly scales as $\sigma \sqrt{T}$, so as $T \to 0$ the distribution -collapses toward a Gaussian and the smile flattens. - -More precisely, the Heston characteristic function at short maturities satisfies: - -\begin{equation} -\log \phi(u, T) \approx i u \mu T - \tfrac{1}{2} u^2 v_0 T + O(T^2) -\end{equation} - -which is the characteristic function of a Gaussian with variance $v_0 T$. The higher -cumulants that produce skew and excess kurtosis are all $O(T^2)$ or smaller, so they -vanish faster than the Gaussian term as $T \to 0$. - -In practice this means the Heston model essentially reduces to Black-Scholes for -near-expiry options. The market, however, exhibits pronounced short-term skew driven by -jump risk and the market microstructure of short-dated hedging demand. A diffusion-only -model cannot reproduce this behaviour regardless of how its parameters are tuned. - -The natural extension is to add a jump component to the dynamics, which contributes -a term of order $O(T)$ to the cumulants and restores the short-term smile. This is -the motivation for the Heston jump-diffusion model described in the next section. - -## Calibrating the Heston Jump-Diffusion Model - -[HestonJCalibration][quantflow.options.calibration.heston.HestonJCalibration] extends the -Heston calibration with a compound Poisson jump component via the -[HestonJ][quantflow.sp.heston.HestonJ] model. Jumps are drawn from a -[DoubleExponential][quantflow.utils.distributions.DoubleExponential] distribution, -which captures asymmetric jump behaviour common in equity and crypto markets. - -```python ---8<-- "docs/examples/vol_surface_hestonj_calibration.py" -``` - ---8<-- "docs/examples/output/vol_surface_hestonj_calibration.out" - -### Plotting the Calibrated Smile - -```python -fig = calibration.plot_maturities(max_moneyness_ttm=1.5, support=101) -fig.write_image("hestonj_calibrated_smile.png", width=1200) -``` - -![HestonJ calibrated smile](../assets/examples/hestonj_calibrated_smile.png) - -### Remaining Limitations at Short Maturities - -Adding jumps improves the short-term smile significantly compared to plain Heston, but -the fit at the nearest maturities is still imperfect. Several structural reasons combine: - -**Jump parameters are global.** The compound Poisson component has a single intensity -$\lambda$, jump variance, and asymmetry shared across all maturities. Increasing -$\lambda$ to steepen the short-term smile simultaneously distorts the long-term smile, -so the optimizer settles on a compromise. - -**Long maturities dominate the cost function.** They have more liquid strikes and -therefore more data points. The optimizer minimizes total squared residuals across the -whole surface, so short maturities — with fewer strikes — are outvoted and their fit is -systematically sacrificed. - -**The jump distribution is not rich enough.** The short-term smile in crypto is driven -by large, rare, asymmetric events. A [DoubleExponential][quantflow.utils.distributions.DoubleExponential] -with fixed parameters cannot simultaneously match the wing curvature at short and long -maturities. - -The natural next step is a rough volatility model (for example rough Heston with Hurst -parameter $H < \tfrac{1}{2}$). Because the variance process has long memory and does not -behave diffusively at short time scales, rough models produce a steep short-term skew -without requiring jumps, and the skew decays as a power law $T^H$ rather than the -$T^{1/2}$ rate of classical stochastic volatility. - -### Parameter Reference - -The calibrated parameter vector for the jump-diffusion model is: - -| Parameter | Description | -|---|---| -| `vol` | Initial volatility ($\sqrt{v_0}$) | -| `theta` | Long-run volatility ($\sqrt{\theta}$) | -| `kappa` | Mean reversion speed | -| `sigma` | Volatility of variance | -| `rho` | Spot-variance correlation | -| `jump intensity` | Jump arrival rate (jumps per year) | -| `jump variance` | Variance of a single jump | -| `jump asymmetry` | Asymmetry of the jump distribution ([DoubleExponential][quantflow.utils.distributions.DoubleExponential]) | +## Extracting Forwards and Discount Factors + +Pricing an option requires two market inputs beyond the option price itself: the forward +price $F$ of the underlying at expiry, and the discount factor $D$ for that maturity. + +In liquid markets these quantities are directly observable. Futures and forward contracts +give $F$ outright, and interest rate swaps or government bond strips give $D$. In many +option markets, however, neither is quoted directly. Crypto options on Deribit are a +clear example: there is no liquid term structure of interest rates and the forward for +each expiry must be inferred from the options themselves. + +Even when forwards are available, the discount factor used to value options may differ +from the rate implied by the forward-spot basis. For equity options the carry includes +dividends and repo costs that are not captured by a simple interest rate curve. For +crypto inverse options the discount factor reflects funding in the underlying asset +rather than in dollars. + +For these reasons, quantflow can extract $D_q$ and $D_a$ directly from the market prices +of options using put-call parity. The +[calibrate_curves][quantflow.options.surface.GenericVolSurfaceLoader.calibrate_curves] +method supports three modes: + +- **Both curves**: pass a [YieldCurve][quantflow.rates.yield_curve.YieldCurve] type for + both `quote_curve` and `asset_curve`. A single OLS regression per maturity identifies + $D_q$ and $D_a$ simultaneously from the slope and intercept. +- **Asset curve only**: pass a type for `asset_curve` and leave `quote_curve` as `None`. + The existing `quote_curve` on the loader is treated as known and $D_a$ is computed + analytically from each put-call pair using the known $D_q$. +- **Quote curve only**: pass a type for `quote_curve` and leave `asset_curve` as `None`. + The same simultaneous OLS is run but only the quote discount factors are used to fit + the curve. diff --git a/mkdocs.yml b/mkdocs.yml index 564efbe..6fa5c46 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ nav: - Black-Scholes: api/options/black.md - Calibration: api/options/calibration.md - Deep IV Factor Model: api/options/divfm.md + - Put-Call Parity: api/options/parity.md - Pricer: api/options/pricer.md - SVI Smile: api/options/svi.md - Volatility Surface: api/options/vol_surface.md @@ -99,6 +100,7 @@ nav: - Rates: - api/rates/index.md - Interest Rate: api/rates/interest_rate.md + - Nelson Siegel Curve: api/rates/nelson_siegel.md - Yield Curve: api/rates/yield_curve.md - Utilities: - api/utils/index.md @@ -111,6 +113,7 @@ nav: - tutorials/index.md - BNS Volatility Model: tutorials/bns_calibration.md - CIR Process: tutorials/cir.md + - Heston Volatility Model: tutorials/heston_calibration.md - Option Pricing: tutorials/option_pricing.md - Pricing Method Comparison: tutorials/pricing_method_comparison.md - SPX Volatility Surface: tutorials/spx_vol_surface.md @@ -119,6 +122,7 @@ nav: - theory/index.md - Characteristic Function: theory/characteristic.md - Convexity Correction: theory/convexity_correction.md + - Forwards and Discount Factors: theory/forwards.md - Inversion: theory/inversion.md - Lévy Process: theory/levy.md - Option Pricing: theory/option_pricing.md diff --git a/quantflow/data/yahoo.py b/quantflow/data/yahoo.py index 302d9a0..bfe9dfa 100644 --- a/quantflow/data/yahoo.py +++ b/quantflow/data/yahoo.py @@ -3,7 +3,8 @@ import gzip import json from dataclasses import dataclass, field -from datetime import timezone +from datetime import date, timezone +from enum import StrEnum from pathlib import Path import pandas as pd @@ -12,6 +13,7 @@ from quantflow.options.inputs import DefaultVolSecurity, OptionType from quantflow.options.surface import VolSurfaceLoader +from quantflow.utils.dates import as_utc from quantflow.utils.numbers import to_decimal @@ -19,13 +21,22 @@ class Yahoo(HttpxClient): """Yahoo Finance API client - Minimal client for fetching option chains used to build volatility surfaces. + Minimal client for fetching historical prices and option chains. - ## Example + ## Examples + + Fetch daily prices for a symbol: ```python from quantflow.data.yahoo import Yahoo + async with Yahoo() as yahoo: + df = await yahoo.prices("AAPL", range="1y") + ``` + + Build a volatility surface from the option chain: + + ```python async with Yahoo() as yahoo: loader = await yahoo.volatility_surface_loader("AAPL") surface = loader.surface() @@ -47,6 +58,21 @@ class Yahoo(HttpxClient): ) _crumb: str | None = None + class freq(StrEnum): + """Yahoo Finance chart intervals""" + + one_min = "1m" + two_min = "2m" + five_min = "5m" + fifteen_min = "15m" + thirty_min = "30m" + one_hour = "1h" + one_day = "1d" + five_day = "5d" + one_week = "1wk" + one_month = "1mo" + three_month = "3mo" + async def option_chain( self, symbol: Annotated[str, Doc("Underlying ticker symbol")], @@ -142,6 +168,60 @@ def loader_from_chain( ) return loader + async def prices( + self, + symbol: Annotated[str, Doc("Ticker symbol")], + *, + interval: Annotated[ + str | freq, Doc("Bar interval — use Yahoo.freq members or a raw string") + ] = freq.one_day, + from_date: Annotated[date | None, Doc("Start date (inclusive)")] = None, + to_date: Annotated[date | None, Doc("End date (inclusive)")] = None, + range: Annotated[ + str | None, + Doc( + "Shorthand period when dates are omitted: '1mo', '3mo', '6mo', " + "'1y', '2y', '5y', 'ytd', 'max', etc." + ), + ] = None, + ) -> pd.DataFrame: + """Historical OHLCV prices for `symbol`. + + Returns a DataFrame with columns `timestamp`, `open`, `high`, `low`, + `close`, `volume`, and `adj_close` (when available). + + Pass `from_date` / `to_date` for a specific window, or `range` for a + shorthand period. When neither is given Yahoo defaults to one month. + """ + params: dict = {"interval": str(interval)} + if from_date: + params["period1"] = int(as_utc(from_date).timestamp()) + if to_date: + params["period2"] = int(as_utc(to_date).timestamp()) + if range and not from_date and not to_date: + params["range"] = range + data = await self.get( + f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}", + params=params, + ) + result = data["chart"]["result"][0] + timestamps = result.get("timestamps") or result.get("timestamp", []) + quote = result["indicators"]["quote"][0] + adj = result["indicators"].get("adjclose", [{}])[0] + df = pd.DataFrame( + { + "timestamp": pd.to_datetime(timestamps, unit="s", utc=True), + "open": quote.get("open"), + "high": quote.get("high"), + "low": quote.get("low"), + "close": quote.get("close"), + "volume": quote.get("volume"), + } + ) + if "adjclose" in adj: + df["adj_close"] = adj["adjclose"] + return df + async def save_fixture( self, symbol: Annotated[str, Doc("Underlying ticker symbol")], diff --git a/quantflow/options/bs.py b/quantflow/options/bs.py index cf2df1c..363a741 100644 --- a/quantflow/options/bs.py +++ b/quantflow/options/bs.py @@ -165,13 +165,13 @@ def black_price( ), ], ) -> FloatArray: - r"""Calculate the Black call/put option prices in forward terms + r"""Calculate the undiscounted Black call/put option prices in forward terms from the following params $$ \begin{align} - c &= \frac{C}{F} = N(d1) - e^k N(d2) \\ - p &= \frac{P}{F} = -N(-d1) + e^k N(-d2) \\ + c &= \frac{C}{D_\tau F_\tau} = N(d1) - e^k N(d2) \\ + p &= \frac{P}{D_\tau F_\tau} = -N(-d1) + e^k N(-d2) \\ d1 &= \frac{-k + \frac{\sigma^2 t}{2}}{\sigma \sqrt{t}} \\ d2 &= d1 - \sigma \sqrt{t} \end{align} @@ -180,7 +180,8 @@ def black_price( The results are option prices divided by the forward price also known as option prices in forward terms. These are non-dimensional prices that can be easily converted to actual option prices by multiplying with the - forward price of the underlying asset. + forward price of the underlying asset at time to maturity $\tau$ + and a suitable discount factor if interest rates are non-zero. """ sig2 = sigma * sigma * ttm sig = np.sqrt(sig2) diff --git a/quantflow/options/parity.py b/quantflow/options/parity.py new file mode 100644 index 0000000..da7b64f --- /dev/null +++ b/quantflow/options/parity.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Self + +import numpy as np +from pydantic import BaseModel, Field + +from quantflow.utils.price import Price +from quantflow.utils.types import FloatArray + + +class DiscountPair(BaseModel, frozen=True): + asset_discount: float = Field( + description="Discount factor for the underlying asset" + ) + quote_discount: float = Field(description="Discount factor for the option quote") + + +class PutCallParity(BaseModel, frozen=True): + """A matched put-call parity at a single strike, + used for discount curve calibration.""" + + strike: Decimal = Field(description="Strike price") + call: Price = Field(description="Call option bid/ask prices") + put: Price = Field(description="Put option bid/ask prices") + inverse: bool = Field(default=False, description="Whether the option is inverse") + + @property + def bid(self) -> Decimal: + """Lower bound of the call-put price difference""" + return self.call.bid - self.put.ask + + @property + def ask(self) -> Decimal: + """Upper bound of the call-put price difference""" + return self.call.ask - self.put.bid + + @property + def mid(self) -> Decimal: + """Midpoint of the call-put price difference""" + return (self.bid + self.ask) / 2 + + @property + def spread(self) -> Decimal: + """Bid-ask spread of the call-put price difference""" + return self.ask - self.bid + + +class PutCallParities(BaseModel, frozen=True): + """A collection of put-call parities for a given maturity""" + + parities: list[PutCallParity] = Field(description="List of put-call parities") + spot: Decimal = Field(description="Spot price of the underlying asset") + inverse: bool = Field(default=False, description="Whether the options are inverse") + + @classmethod + def from_parities(cls, parities: list[PutCallParity], spot: Decimal) -> Self: + inverse = any(p.inverse for p in parities) + return cls(parities=parities, spot=spot, inverse=inverse) + + def regressand(self) -> FloatArray: + """Calculate the regressand for put-call parity regression. + + For direct options, the regressand is (C - P) / S, while for inverse + options it is simply c - p. + """ + scale = self.spot if not self.inverse else Decimal(1) + return np.asarray([float(p.mid / scale) for p in self.parities]) + + def regressor(self) -> FloatArray: + """Calculate the regressor for put-call parity regression, + which is the strike price divided by the spot price. + """ + return np.asarray([float(p.strike / self.spot) for p in self.parities]) + + def fit_discounts( + self, + dq: float | None = None, + da: float | None = None, + constrained: bool = False, + ) -> DiscountPair | None: + """Return the fitted discount factors, or None if the result is invalid. + + Both direct and inverse options satisfy the same normalized equation + y = Da - (Dq/S) * K, where y = mid/S for direct and y = mid for inverse. + + When both known values are None a full OLS is run. + When one is provided the other is solved analytically as the mean over pairs. + When constrained is True, discount factors are bounded to (0, 1]. + """ + if not self.parities: + return None + ys = self.regressand() + xs = self.regressor() + if dq is not None: + if da is not None: + return DiscountPair(asset_discount=da, quote_discount=dq) + da = float(np.mean(ys + dq * xs)) + elif da is not None: + dq = float(np.mean((da - ys) / xs)) + elif constrained: + from scipy.optimize import lsq_linear + + A = np.column_stack([np.ones(len(xs)), xs]) + # alpha = Da in (0, 1], beta = -Dq in [-1, 0) + result = lsq_linear(A, ys, bounds=([0, -1], [1, 0])) + da, dq = float(result.x[0]), -float(result.x[1]) + else: + beta, alpha = np.polyfit(xs, ys, 1) + da, dq = float(alpha), -float(beta) + upper = 1.0 if constrained else float("inf") + if not (0 < dq <= upper and 0 < da <= upper): + return None + return DiscountPair(asset_discount=da, quote_discount=dq) + + def plot( + self, + dq: float | None = None, + da: float | None = None, + constrained: bool = False, + ) -> Any: + """Plot the normalized put-call parity data and the fitted regression line.""" + from quantflow.utils.plot import check_plotly + + check_plotly() + import plotly.graph_objects as go + + xs = self.regressor() + ys = self.regressand() + discounts = self.fit_discounts(dq=dq, da=da, constrained=constrained) + fig = go.Figure() + fig.add_trace( + go.Scatter(x=xs, y=ys, mode="markers", name="market", marker_size=10) + ) + if discounts is not None: + x_range = np.linspace(xs.min(), xs.max(), 100) + y_fit = discounts.asset_discount - discounts.quote_discount * x_range + fig.add_trace(go.Scatter(x=x_range, y=y_fit, mode="lines", name="fit")) + y_label = "c - p" if self.inverse else "(C - P) / S" + return fig.update_layout(xaxis_title="K / S", yaxis_title=y_label) diff --git a/quantflow/options/surface.py b/quantflow/options/surface.py index c6f0293..937d032 100644 --- a/quantflow/options/surface.py +++ b/quantflow/options/surface.py @@ -14,9 +14,11 @@ from typing_extensions import Annotated, Doc from quantflow.rates.interest_rate import Rate +from quantflow.rates.yield_curve import NoDiscount, YieldCurve from quantflow.utils import plot from quantflow.utils.dates import utcnow from quantflow.utils.numbers import ( + ONE, ZERO, DecimalNumber, Number, @@ -27,6 +29,7 @@ to_decimal, to_decimal_or_none, ) +from quantflow.utils.price import Price from quantflow.utils.types import FloatArray from .bs import black_price, implied_black_volatility @@ -42,6 +45,7 @@ VolSurfaceInputs, VolSurfaceSecurity, ) +from .parity import PutCallParities, PutCallParity from .svi import SVI INITIAL_VOL = 0.5 @@ -71,44 +75,26 @@ class OptionSelection(enum.Enum): """Select all options regardless of their moneyness""" -class Price(BaseModel, Generic[S]): +class SecurityPrice(Price, Generic[S]): """Represents the bid/ask price of a security, which can be a spot price, forward price or option price """ security: S = Field(description="The underlying security of the price") - bid: DecimalNumber = Field(description="Bid price") - ask: DecimalNumber = Field(description="Ask price") - - @property - def mid(self) -> Decimal: - """Calculate the mid price by averaging the bid and ask prices""" - return (self.bid + self.ask) / 2 - - @property - def spread(self) -> Decimal: - """Calculate the bid-ask spread""" - return self.ask - self.bid - - @property - def bp_spread(self) -> Decimal: - """Bid-ask spread in basis points, calculated as spread divided by mid - price and multiplied by 10000""" - mid = self.mid - if mid > ZERO: - return round(10000 * self.spread / mid, 2) - else: - return Decimal("inf") - - -class SpotPrice(Price[S]): - """Represents the spot bid/ask price of an underlying asset""" - open_interest: DecimalNumber = Field( default=ZERO, description="Open interest of the spot price" ) volume: DecimalNumber = Field(default=ZERO, description="Total volume traded") + def is_valid(self) -> bool: + """Check if the forward price is valid, which means that the bid and ask + are positive and the bid is less than or equal to the ask""" + return self.bid > ZERO and self.ask > ZERO and super().is_valid() + + +class SpotPrice(SecurityPrice[S]): + """Represents the spot bid/ask price of an underlying asset""" + def inputs(self) -> SpotInput: return SpotInput( bid=self.bid, @@ -118,15 +104,11 @@ def inputs(self) -> SpotInput: ) -class FwdPrice(Price[S]): +class FwdPrice(SecurityPrice[S]): """Represents the forward bid/ask price of an underlying asset at a specific maturity""" maturity: datetime = Field(description="Maturity date of the forward price") - open_interest: DecimalNumber = Field( - default=ZERO, description="Open interest of the forward price" - ) - volume: DecimalNumber = Field(default=ZERO, description="Total volume traded") def inputs(self) -> ForwardInput: return ForwardInput( @@ -137,11 +119,6 @@ def inputs(self) -> ForwardInput: volume=self.volume, ) - def is_valid(self) -> bool: - """Check if the forward price is valid, which means that the bid and ask - are positive and the bid is less than or equal to the ask""" - return self.bid > ZERO and self.ask > ZERO and self.bid <= self.ask - class ImpliedFwdPrice(FwdPrice[S]): """Represents the implied forward price of an underlying asset at a specific @@ -513,6 +490,10 @@ def spread(self) -> Decimal: """Calculate the bid-ask spread""" return self.ask.price - self.bid.price + def price(self) -> Price: + """Convert the option prices to a Price object""" + return Price(bid=self.bid.price, ask=self.ask.price) + def iv_bid_ask_spread(self) -> float: """Calculate the bid-ask spread of the implied volatility""" return self.ask.implied_vol - self.bid.implied_vol @@ -583,11 +564,31 @@ class Strike(BaseModel, Generic[S]): default=None, description="Put option prices for the strike" ) + def put_call_parity(self) -> PutCallParity | None: + """Return a [PutCallParity][quantflow.rates.calibrator.PutCallParity] for this + strike, or None if either the call or the put are not available.""" + if self.call is None or self.put is None: + return None + return PutCallParity( + strike=self.strike, + call=self.call.price(), + put=self.put.price(), + inverse=self.call.meta.inverse, + ) + def implied_forward( self, tick_size: Annotated[ Decimal | None, Doc("Tick size for rounding the implied forward bid/ask") ] = None, + df: Annotated[ + Decimal, + Doc( + "Discount factor at this maturity. " + "Defaults to 1 (no discounting). When set, option prices are " + "deflated by $D$ before applying put-call parity." + ), + ] = ONE, ) -> ImpliedFwdPrice[S] | None: r"""Extract the implied forward price from put-call parity. @@ -599,15 +600,17 @@ def implied_forward( put-call parity reads \begin{equation} - F = \frac{K}{1 - c + p} + F = \frac{K}{1 - (c - p) / D} \end{equation} For non-inverse options (prices quoted in the quote currency) \begin{equation} - F = K + C - P + F = K + \frac{C - P}{D} \end{equation} + where $D$ is the discount factor (default 1, i.e. no discounting). + Returns None when the strike does not have both a call and a put, or when the denominator is non-positive (arbitrage condition violated). """ @@ -616,15 +619,15 @@ def implied_forward( cp_bid = self.call.bid.price - self.put.ask.price cp_ask = self.call.ask.price - self.put.bid.price if self.call.meta.inverse: - d_bid = 1 - cp_bid - d_ask = 1 - cp_ask + d_bid = ONE - cp_bid / df + d_ask = ONE - cp_ask / df if d_bid <= ZERO or d_ask <= ZERO: return None bid = self.strike / d_bid ask = self.strike / d_ask else: - bid = self.strike + cp_bid - ask = self.strike + cp_ask + bid = self.strike + cp_bid / df + ask = self.strike + cp_ask / df if bid <= ZERO or ask <= ZERO: return None if bid > ask: @@ -1388,14 +1391,25 @@ def cross_section( Decimal | None, Doc("Tick size for rounding implied forward bid/ask prices"), ] = None, + yield_curve: Annotated[ + YieldCurve | None, + Doc( + "Yield curve used to compute the per-maturity discount factor " + "applied in put-call parity. When None, no discounting is applied." + ), + ] = None, ) -> VolCrossSection[S] | None: strikes = [] implied_forwards = [] + ref = ref_date or utcnow() + ttm = self.day_counter.dcf(ref, self.maturity) + yield_curve = yield_curve or NoDiscount() + df = yield_curve.discount_factor(ttm) for strike in sorted(self.strikes): sk = self.strikes[strike] if sk.call is None and sk.put is None: continue - if implied_forward := sk.implied_forward(tick_size=tick_size): + if implied_forward := sk.implied_forward(tick_size=tick_size, df=df): implied_forwards.append(implied_forward) strikes.append(sk) forward = self.forward @@ -1421,6 +1435,30 @@ def cross_section( else None ) + def put_call_parities( + self, + spot: Annotated[Decimal, Doc("Spot price of the underlying asset")], + *, + max_pairs: Annotated[ + int, Doc("Maximum number of put-call pairs to consider") + ] = 10, + ) -> PutCallParities: + """Return a list of the most liquid + [PutCallParity][quantflow.options.parity.PutCallParities] + from a cross-section loader. + + Liquidity is determined by the bid-ask spread of the put-call parity price. + """ + parities = sorted( + ( + p + for sk in self.strikes.values() + if (p := sk.put_call_parity()) is not None + ), + key=lambda p: p.spread, + )[:max_pairs] + return PutCallParities.from_parities(parities, spot) + class GenericVolSurfaceLoader(BaseModel, Generic[S], arbitrary_types_allowed=True): """Helper class to build a volatility surface from a list of securities @@ -1461,9 +1499,35 @@ class GenericVolSurfaceLoader(BaseModel, Generic[S], arbitrary_types_allowed=Tru exclude_volume: DecimalNumber | None = Field( default=None, description="Exclude options with volume at or below this value" ) + quote_curve: YieldCurve = Field( + default_factory=NoDiscount, + description=( + "Discount curve for the quote currency $D_q$. " + "Set by calling calibrate_curves()." + ), + ) + asset_curve: YieldCurve = Field( + default_factory=NoDiscount, + description=( + "Discount curve for the asset $D_a$. " "Set by calling calibrate_curves()." + ), + ) + + def ref_date( + self, + ref_date: Annotated[ + datetime | None, Doc("Reference date for the volatility surface") + ] = None, + ) -> datetime: + """Reference date for the volatility surface, taken as the earliest maturity + or the provided ref_date if it's earlier""" + if ref_date is not None: + return ref_date + return utcnow() def get_or_create_maturity( - self, maturity: Annotated[datetime, Doc("Maturity date for the options")] + self, + maturity: Annotated[datetime, Doc("Maturity date for the options")], ) -> VolCrossSectionLoader[S]: """Get or create a [VolCrossSectionLoader][quantflow.options.surface.VolCrossSectionLoader] @@ -1565,6 +1629,7 @@ def surface( ref_date=ref_date, previous_forward=previous_forward, tick_size=self.tick_size_forwards, + yield_curve=self.quote_curve, ): previous_forward = section.forward.mid maturities.append(section) @@ -1578,6 +1643,101 @@ def surface( tick_size_options=self.tick_size_options, ) + def calibrate_curves( + self, + *, + quote_curve: Annotated[ + type[YieldCurve] | None, + Doc( + "YieldCurve type to fit the quote currency discount curve $D_q$ " + "from option prices. When None the current quote_curve is unchanged." + ), + ] = None, + asset_curve: Annotated[ + type[YieldCurve] | None, + Doc( + "YieldCurve type to fit the asset discount curve $D_a$ " + "from option prices. When None the current asset_curve is unchanged." + ), + ] = None, + ref_date: Annotated[ + datetime | None, Doc("Reference date for time to maturity calculations") + ] = None, + max_pairs: Annotated[ + int, Doc("Maximum number of put-call pairs to use per maturity") + ] = 10, + ) -> None: + """Calibrate the quote and/or asset discount curves from option prices. + + Three modes are supported: + + Both curves: pass a type for both `quote_curve` and `asset_curve`. + A single OLS regression per maturity identifies $D_q$ and $D_a$ simultaneously. + + Asset only: pass a type for `asset_curve`, leave `quote_curve` as None. + The existing `quote_curve` is treated as known and $D_a$ is solved analytically. + + Quote only: pass a type for `quote_curve`, leave `asset_curve` as None. + The existing `asset_curve` is treated as known and $D_q$ is solved analytically. + """ + ttms, rates_q, rates_a = self.collect_rates( + fit_quote_curve=quote_curve is not None, + fit_asset_curve=asset_curve is not None, + ref_date=ref_date, + max_pairs=max_pairs, + ) + if quote_curve is not None: + self.quote_curve = quote_curve.calibrate(ttms, rates_q) + if asset_curve is not None: + self.asset_curve = asset_curve.calibrate(ttms, rates_a) + + def collect_rates( + self, + *, + fit_quote_curve: Annotated[ + bool, Doc("Whether to fit the quote discount curve $D_q$") + ] = True, + fit_asset_curve: Annotated[ + bool, Doc("Whether to fit the asset discount curve $D_a$") + ] = True, + ref_date: Annotated[ + datetime | None, Doc("Reference date for time to maturity calculations") + ] = None, + max_pairs: Annotated[ + int, Doc("Maximum number of put-call pairs to use per maturity") + ] = 10, + ) -> tuple[list[float], list[float], list[float]]: + """Collect per-maturity continuously compounded rates from put-call parity.""" + if not self.spot or self.spot.mid == ZERO: + raise ValueError("No spot price provided") + spot = self.spot.mid + ttms: list[float] = [] + rates_q: list[float] = [] + rates_a: list[float] = [] + ref_date = self.ref_date(ref_date=ref_date) + for maturity, section in sorted(self.maturities.items()): + ttm = self.day_counter.dcf(ref_date, maturity) + if ttm <= 0: + continue + parities = section.put_call_parities(spot, max_pairs=max_pairs) + dq = ( + None + if fit_quote_curve + else float(self.quote_curve.discount_factor(ttm)) + ) + da = ( + None + if fit_asset_curve + else float(self.asset_curve.discount_factor(ttm)) + ) + d = parities.fit_discounts(dq=dq, da=da) + if d is None: + continue + ttms.append(ttm) + rates_q.append(-math.log(d.quote_discount) / ttm) + rates_a.append(-math.log(d.asset_discount) / ttm) + return ttms, rates_q, rates_a + class VolSurfaceLoader(GenericVolSurfaceLoader[DefaultVolSecurity]): """Helper class to build a volatility surface from a list of securities diff --git a/quantflow/rates/interest_rate.py b/quantflow/rates/interest_rate.py index c083a28..e588869 100644 --- a/quantflow/rates/interest_rate.py +++ b/quantflow/rates/interest_rate.py @@ -24,6 +24,15 @@ class Rate(BaseModel, arbitrary_types_allowed=True): default=DayCounter.ACTACT, description="Day count convention to use when calculating time to maturity", ) + ttm: float = Field( + default=0.0, + ge=0.0, + description=( + "Time to maturity for the rate, used to calculate discount factors. " + "Expressed in years. when 0 it is a spot rate, when > 0 " + "it is a forward rate." + ), + ) frequency: Period | None = Field( default=None, description=( diff --git a/quantflow/rates/nelson_siegel.py b/quantflow/rates/nelson_siegel.py index fceb18b..a44a0ce 100644 --- a/quantflow/rates/nelson_siegel.py +++ b/quantflow/rates/nelson_siegel.py @@ -6,7 +6,7 @@ from numpy.typing import ArrayLike from pydantic import Field from scipy.optimize import minimize_scalar -from typing_extensions import Annotated, Doc +from typing_extensions import Annotated, Doc, Self from quantflow.utils.numbers import ONE, Number, to_decimal @@ -68,21 +68,21 @@ def discount_factor(self, ttm: Number) -> Decimal: return (-zero_coupon_rate * ttmd).exp() @classmethod - def fit( + def calibrate( cls, ttm: Annotated[ ArrayLike, Doc("times to maturity in years (1-D, length >= 3)"), ], rates: Annotated[ - ArrayLike, Doc("observed zero-coupon rates, same length as ttm") + ArrayLike, Doc("observed continuously compounded rates, same length as ttm") ], lambda_bounds: Annotated[ tuple[float, float], Doc("search bounds for the decay parameter $\\lambda$"), ] = (0.01, 10.0), - ) -> NelsonSiegel: - r"""Fit a Nelson-Siegel curve to observed zero-coupon rates. + ) -> Self: + r"""Fit a Nelson-Siegel curve to observed continuously compounded rates. Uses a profile OLS approach: for each candidate $\lambda$ the betas are solved exactly via least squares, so only a 1-D scalar minimisation over diff --git a/quantflow/rates/yield_curve.py b/quantflow/rates/yield_curve.py index 29d292e..4aa1476 100644 --- a/quantflow/rates/yield_curve.py +++ b/quantflow/rates/yield_curve.py @@ -1,7 +1,15 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from decimal import Decimal +from typing import Any +from numpy.typing import ArrayLike from pydantic import BaseModel +from typing_extensions import Annotated, Doc, Self + +from quantflow.utils import plot +from quantflow.utils.numbers import ONE, ZERO class YieldCurve(BaseModel, ABC, extra="forbid"): @@ -35,3 +43,59 @@ def discount_factor(self, ttm: float) -> Decimal: where $f(\tau)$ is the instantaneous forward rate for a given time to maturity $\tau$. """ + + @classmethod + @abstractmethod + def calibrate(cls, ttm: ArrayLike, rates: ArrayLike) -> Self: + """Fit the yield curve to continuously compounded rates. + + Parameters + ---------- + ttm: + Times to maturity in years. + rates: + Continuously compounded rates, same length as ttm (e.g. 0.05 for 5%). + """ + + def continuously_compounded_rate(self, ttm: float) -> Decimal: + r"""Calculate the continuously compounded rate for a given time to maturity + + The continuously compounded rate is related to the discount factor + by the following formula: + + \begin{equation} + r(\tau) = -\frac{\ln D(\tau)}{\tau} + \end{equation} + + where $D(\tau)$ is the discount factor for a given time to maturity $\tau$. + """ + if ttm <= 0: + return self.instanteous_forward_rate(0) + else: + return -self.discount_factor(float(ttm)).ln() / Decimal(ttm) + + def plot( + self, + ttm_max: Annotated[float, Doc("Maximum time to maturity in years")] = 10.0, + n: Annotated[int, Doc("Number of points to evaluate")] = 200, + **kwargs: Any, + ) -> Any: + """Plot the continuously compounded rate vs time to maturity. + + Requires plotly to be installed. + """ + return plot.plot_yield_curve(self, ttm_max=ttm_max, n=n, **kwargs) + + +class NoDiscount(YieldCurve): + """Flat yield curve with zero rates (discount factor is always 1).""" + + def instanteous_forward_rate(self, ttm: float) -> Decimal: + return ZERO + + def discount_factor(self, ttm: float) -> Decimal: + return ONE + + @classmethod + def calibrate(cls, ttm: ArrayLike, rates: ArrayLike) -> Self: + return cls() diff --git a/quantflow/utils/plot.py b/quantflow/utils/plot.py index 8e8eb88..e99aa2a 100644 --- a/quantflow/utils/plot.py +++ b/quantflow/utils/plot.py @@ -1,12 +1,16 @@ import os -from typing import Any +from typing import TYPE_CHECKING, Any +import numpy as np import pandas as pd from scipy.stats import norm from .marginal import Marginal1D from .types import FloatArray +if TYPE_CHECKING: + from quantflow.rates.yield_curve import YieldCurve + PLOTLY_THEME = os.environ.get("PLOTLY_THEME", "plotly_dark") try: @@ -214,6 +218,28 @@ def plot3d( return fig +def plot_yield_curve( + curve: YieldCurve, + ttm_max: float = 10.0, + n: int = 200, + **kwargs: Any, +) -> Any: + check_plotly() + ttms = np.linspace(0.01, ttm_max, n) + rates = [float(curve.continuously_compounded_rate(t)) for t in ttms] + df = pd.DataFrame({"ttm": ttms, "rate": rates}) + return px.line( + df, + x="ttm", + y="rate", + labels={ + "ttm": "time to maturity (years)", + "rate": "continuously compounded rate", + }, + **kwargs, + ) + + def candlestick_plot(df: pd.DataFrame, slider: bool = True) -> Any: fig = go.Figure( data=go.Candlestick( diff --git a/quantflow/utils/price.py b/quantflow/utils/price.py new file mode 100644 index 0000000..d7d02b4 --- /dev/null +++ b/quantflow/utils/price.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel, Field + +from .numbers import ZERO, Decimal, DecimalNumber + + +class Price(BaseModel): + """Represents the bid/ask price of a security, + which can be a spot price, forward price or option price + """ + + bid: DecimalNumber = Field(description="Bid price") + ask: DecimalNumber = Field(description="Ask price") + + @property + def mid(self) -> Decimal: + """Calculate the mid price by averaging the bid and ask prices""" + return (self.bid + self.ask) / 2 + + @property + def spread(self) -> Decimal: + """Calculate the bid-ask spread""" + return self.ask - self.bid + + @property + def bp_spread(self) -> Decimal: + """Bid-ask spread in basis points, calculated as spread divided by mid + price and multiplied by 10000""" + mid = self.mid + if mid > ZERO: + return round(10000 * self.spread / mid, 2) + else: + return Decimal("inf") + + def is_valid(self) -> bool: + """Check if the price is valid, which means the bid is less than + or equal to the ask""" + return self.bid <= self.ask diff --git a/quantflow_tests/test_rates.py b/quantflow_tests/test_rates.py index 0b947bb..d24fee5 100644 --- a/quantflow_tests/test_rates.py +++ b/quantflow_tests/test_rates.py @@ -186,7 +186,7 @@ def test_nelson_siegel_fit_recovers_parameters() -> None: rates = np.array([float(ns_true.discount_factor(t)) for t in ttm]) # convert discount factors back to zero rates for fitting zero_rates = -np.log(rates) / ttm - ns_fit = NelsonSiegel.fit(ttm, zero_rates) + ns_fit = NelsonSiegel.calibrate(ttm, zero_rates) for t in [1.0, 2.0, 5.0]: assert float(ns_fit.discount_factor(t)) == pytest.approx( float(ns_true.discount_factor(t)), rel=1e-4 @@ -196,7 +196,7 @@ def test_nelson_siegel_fit_recovers_parameters() -> None: def test_nelson_siegel_fit_flat_curve() -> None: ttm = np.array([0.5, 1.0, 2.0, 5.0, 10.0]) rates = np.full_like(ttm, 0.05) - ns = NelsonSiegel.fit(ttm, rates) + ns = NelsonSiegel.calibrate(ttm, rates) for t in ttm: assert float(ns.discount_factor(t)) == pytest.approx( math.exp(-0.05 * t), rel=1e-4 From b10c5b292390d2c6a3fffcddab2611d81e14d9b5 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 17 May 2026 18:39:58 +0100 Subject: [PATCH 2/5] Add constraints --- app/volatility_surface.py | 6 +++++ quantflow/options/parity.py | 45 +++++++++++++++++++++++------------- quantflow/options/surface.py | 10 ++++++-- quantflow/utils/plot.py | 2 +- 4 files changed, 44 insertions(+), 19 deletions(-) diff --git a/app/volatility_surface.py b/app/volatility_surface.py index 5a133b9..efda531 100644 --- a/app/volatility_surface.py +++ b/app/volatility_surface.py @@ -150,6 +150,12 @@ def _(da, p): return +@app.cell +def _(loader): + loader.collect_rates() + return + + @app.cell def _(): return diff --git a/quantflow/options/parity.py b/quantflow/options/parity.py index da7b64f..2baf68e 100644 --- a/quantflow/options/parity.py +++ b/quantflow/options/parity.py @@ -6,6 +6,7 @@ import numpy as np from pydantic import BaseModel, Field +from quantflow.utils.numbers import ZERO, Number, to_decimal from quantflow.utils.price import Price from quantflow.utils.types import FloatArray @@ -51,13 +52,21 @@ class PutCallParities(BaseModel, frozen=True): """A collection of put-call parities for a given maturity""" parities: list[PutCallParity] = Field(description="List of put-call parities") - spot: Decimal = Field(description="Spot price of the underlying asset") + spot: Decimal = Field(ge=ZERO, description="Spot price of the underlying asset") + ttm: Decimal = Field(gt=ZERO, description="Time to maturity in years") inverse: bool = Field(default=False, description="Whether the options are inverse") @classmethod - def from_parities(cls, parities: list[PutCallParity], spot: Decimal) -> Self: + def from_parities( + cls, parities: list[PutCallParity], spot: Number, ttm: Number + ) -> Self: inverse = any(p.inverse for p in parities) - return cls(parities=parities, spot=spot, inverse=inverse) + return cls( + parities=parities, + spot=to_decimal(spot), + ttm=to_decimal(ttm), + inverse=inverse, + ) def regressand(self) -> FloatArray: """Calculate the regressand for put-call parity regression. @@ -78,39 +87,40 @@ def fit_discounts( self, dq: float | None = None, da: float | None = None, - constrained: bool = False, + min_rate_q: float = 0.0, + min_rate_a: float = 0.0, ) -> DiscountPair | None: """Return the fitted discount factors, or None if the result is invalid. Both direct and inverse options satisfy the same normalized equation y = Da - (Dq/S) * K, where y = mid/S for direct and y = mid for inverse. - When both known values are None a full OLS is run. + When both known values are None a full OLS is run via constrained least squares. When one is provided the other is solved analytically as the mean over pairs. - When constrained is True, discount factors are bounded to (0, 1]. + Discount factors are bounded by D <= exp(-min_rate * ttm), so min_rate=0 + enforces D <= 1 (non-negative rates). """ if not self.parities: return None ys = self.regressand() xs = self.regressor() + ttm = float(self.ttm) + max_dq = float(np.exp(-min_rate_q * ttm)) + max_da = float(np.exp(-min_rate_a * ttm)) if dq is not None: if da is not None: return DiscountPair(asset_discount=da, quote_discount=dq) da = float(np.mean(ys + dq * xs)) elif da is not None: dq = float(np.mean((da - ys) / xs)) - elif constrained: + else: from scipy.optimize import lsq_linear A = np.column_stack([np.ones(len(xs)), xs]) - # alpha = Da in (0, 1], beta = -Dq in [-1, 0) - result = lsq_linear(A, ys, bounds=([0, -1], [1, 0])) + # alpha = Da in (0, max_da], beta = -Dq in [-max_dq, 0) + result = lsq_linear(A, ys, bounds=([0, -max_dq], [max_da, 0])) da, dq = float(result.x[0]), -float(result.x[1]) - else: - beta, alpha = np.polyfit(xs, ys, 1) - da, dq = float(alpha), -float(beta) - upper = 1.0 if constrained else float("inf") - if not (0 < dq <= upper and 0 < da <= upper): + if not (0 < dq <= max_dq and 0 < da <= max_da): return None return DiscountPair(asset_discount=da, quote_discount=dq) @@ -118,7 +128,8 @@ def plot( self, dq: float | None = None, da: float | None = None, - constrained: bool = False, + min_rate_q: float = 0.0, + min_rate_a: float = 0.0, ) -> Any: """Plot the normalized put-call parity data and the fitted regression line.""" from quantflow.utils.plot import check_plotly @@ -128,7 +139,9 @@ def plot( xs = self.regressor() ys = self.regressand() - discounts = self.fit_discounts(dq=dq, da=da, constrained=constrained) + discounts = self.fit_discounts( + dq=dq, da=da, min_rate_q=min_rate_q, min_rate_a=min_rate_a + ) fig = go.Figure() fig.add_trace( go.Scatter(x=xs, y=ys, mode="markers", name="market", marker_size=10) diff --git a/quantflow/options/surface.py b/quantflow/options/surface.py index 937d032..70a1db1 100644 --- a/quantflow/options/surface.py +++ b/quantflow/options/surface.py @@ -1439,6 +1439,9 @@ def put_call_parities( self, spot: Annotated[Decimal, Doc("Spot price of the underlying asset")], *, + ref_date: Annotated[ + datetime | None, Doc("Reference date for time to maturity calculation") + ] = None, max_pairs: Annotated[ int, Doc("Maximum number of put-call pairs to consider") ] = 10, @@ -1449,6 +1452,7 @@ def put_call_parities( Liquidity is determined by the bid-ask spread of the put-call parity price. """ + ttm = self.day_counter.dcf(ref_date or utcnow(), self.maturity) parities = sorted( ( p @@ -1457,7 +1461,7 @@ def put_call_parities( ), key=lambda p: p.spread, )[:max_pairs] - return PutCallParities.from_parities(parities, spot) + return PutCallParities.from_parities(parities, spot, ttm) class GenericVolSurfaceLoader(BaseModel, Generic[S], arbitrary_types_allowed=True): @@ -1719,7 +1723,9 @@ def collect_rates( ttm = self.day_counter.dcf(ref_date, maturity) if ttm <= 0: continue - parities = section.put_call_parities(spot, max_pairs=max_pairs) + parities = section.put_call_parities( + spot, ref_date=ref_date, max_pairs=max_pairs + ) dq = ( None if fit_quote_curve diff --git a/quantflow/utils/plot.py b/quantflow/utils/plot.py index e99aa2a..acaacdd 100644 --- a/quantflow/utils/plot.py +++ b/quantflow/utils/plot.py @@ -219,7 +219,7 @@ def plot3d( def plot_yield_curve( - curve: YieldCurve, + curve: "YieldCurve", ttm_max: float = 10.0, n: int = 200, **kwargs: Any, From 855c9d09d7e43e855b01a4944651140547e633f4 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 17 May 2026 20:24:13 +0100 Subject: [PATCH 3/5] Fit with bounds --- app/volatility_surface.py | 8 ++-- quantflow/options/parity.py | 10 ++--- quantflow/options/surface.py | 71 ++++++++++++++++++++++++++++++------ 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/app/volatility_surface.py b/app/volatility_surface.py index efda531..f77ccad 100644 --- a/app/volatility_surface.py +++ b/app/volatility_surface.py @@ -65,7 +65,7 @@ async def _(asset, inverse, mo): use_perp=not inverse.value ) - loader.calibrate_curves(quote_curve=NelsonSiegel, asset_curve=NelsonSiegel) + loader.calibrate_curves(quote_curve=NelsonSiegel) # build the volatility surface surface = loader.surface() # calculate black implied volatilities @@ -132,9 +132,9 @@ def _(loader): @app.cell def _(loader): - cross = loader.maturities[sorted(loader.maturities)[-2]] + cross = loader.maturities[sorted(loader.maturities)[6]] p = cross.put_call_parities(loader.spot.mid, max_pairs=100) - da=None + da=1 return da, p @@ -152,7 +152,7 @@ def _(da, p): @app.cell def _(loader): - loader.collect_rates() + loader.collect_rates(fit_asset_curve=False).model_dump() return diff --git a/quantflow/options/parity.py b/quantflow/options/parity.py index 2baf68e..ebdad7e 100644 --- a/quantflow/options/parity.py +++ b/quantflow/options/parity.py @@ -5,6 +5,7 @@ import numpy as np from pydantic import BaseModel, Field +from scipy.optimize import lsq_linear from quantflow.utils.numbers import ZERO, Number, to_decimal from quantflow.utils.price import Price @@ -114,12 +115,9 @@ def fit_discounts( elif da is not None: dq = float(np.mean((da - ys) / xs)) else: - from scipy.optimize import lsq_linear - - A = np.column_stack([np.ones(len(xs)), xs]) - # alpha = Da in (0, max_da], beta = -Dq in [-max_dq, 0) - result = lsq_linear(A, ys, bounds=([0, -max_dq], [max_da, 0])) - da, dq = float(result.x[0]), -float(result.x[1]) + A = np.column_stack([np.ones(len(xs)), -xs]) + result = lsq_linear(A, ys, bounds=([0, 0], [max_da, max_dq])) + da, dq = float(result.x[0]), float(result.x[1]) if not (0 < dq <= max_dq and 0 < da <= max_da): return None return DiscountPair(asset_discount=da, quote_discount=dq) diff --git a/quantflow/options/surface.py b/quantflow/options/surface.py index 70a1db1..6c3e4c6 100644 --- a/quantflow/options/surface.py +++ b/quantflow/options/surface.py @@ -1464,6 +1464,14 @@ def put_call_parities( return PutCallParities.from_parities(parities, spot, ttm) +class VolRates(BaseModel, frozen=True): + """Per-maturity continuously compounded rates fitted from put-call parity.""" + + ttms: list[float] = Field(description="Times to maturity in years") + quote_rates: list[float] = Field(description="Quote continuously compounded rates") + asset_rates: list[float] = Field(description="Asset continuously compounded rates") + + class GenericVolSurfaceLoader(BaseModel, Generic[S], arbitrary_types_allowed=True): """Helper class to build a volatility surface from a list of securities @@ -1664,6 +1672,20 @@ def calibrate_curves( "from option prices. When None the current asset_curve is unchanged." ), ] = None, + min_rate_q: Annotated[ + float, + Doc( + "Minimum continuously compounded quote rate." + " Default 0 enforces non-negative quote rates." + ), + ] = 0.0, + min_rate_a: Annotated[ + float, + Doc( + "Minimum continuously compounded asset rate." + " Set negative to allow positive asset carry." + ), + ] = 0.0, ref_date: Annotated[ datetime | None, Doc("Reference date for time to maturity calculations") ] = None, @@ -1684,16 +1706,22 @@ def calibrate_curves( Quote only: pass a type for `quote_curve`, leave `asset_curve` as None. The existing `asset_curve` is treated as known and $D_q$ is solved analytically. """ - ttms, rates_q, rates_a = self.collect_rates( + vol_rates = self.collect_rates( fit_quote_curve=quote_curve is not None, fit_asset_curve=asset_curve is not None, + min_rate_q=min_rate_q, + min_rate_a=min_rate_a, ref_date=ref_date, max_pairs=max_pairs, ) if quote_curve is not None: - self.quote_curve = quote_curve.calibrate(ttms, rates_q) + self.quote_curve = quote_curve.calibrate( + vol_rates.ttms, vol_rates.quote_rates + ) if asset_curve is not None: - self.asset_curve = asset_curve.calibrate(ttms, rates_a) + self.asset_curve = asset_curve.calibrate( + vol_rates.ttms, vol_rates.asset_rates + ) def collect_rates( self, @@ -1704,27 +1732,43 @@ def collect_rates( fit_asset_curve: Annotated[ bool, Doc("Whether to fit the asset discount curve $D_a$") ] = True, + min_rate_q: Annotated[ + float, + Doc( + "Minimum continuously compounded quote rate." + " Default 0 enforces non-negative quote rates." + ), + ] = 0.0, + min_rate_a: Annotated[ + float, + Doc( + "Minimum continuously compounded asset rate." + " Set negative to allow positive asset carry." + ), + ] = 0.0, ref_date: Annotated[ datetime | None, Doc("Reference date for time to maturity calculations") ] = None, max_pairs: Annotated[ int, Doc("Maximum number of put-call pairs to use per maturity") ] = 10, - ) -> tuple[list[float], list[float], list[float]]: + ) -> VolRates: """Collect per-maturity continuously compounded rates from put-call parity.""" if not self.spot or self.spot.mid == ZERO: raise ValueError("No spot price provided") spot = self.spot.mid ttms: list[float] = [] - rates_q: list[float] = [] - rates_a: list[float] = [] + quote_rates: list[float] = [] + asset_rates: list[float] = [] ref_date = self.ref_date(ref_date=ref_date) for maturity, section in sorted(self.maturities.items()): ttm = self.day_counter.dcf(ref_date, maturity) if ttm <= 0: continue parities = section.put_call_parities( - spot, ref_date=ref_date, max_pairs=max_pairs + spot, + ref_date=ref_date, + max_pairs=max_pairs, ) dq = ( None @@ -1736,13 +1780,18 @@ def collect_rates( if fit_asset_curve else float(self.asset_curve.discount_factor(ttm)) ) - d = parities.fit_discounts(dq=dq, da=da) + d = parities.fit_discounts( + dq=dq, + da=da, + min_rate_q=min_rate_q, + min_rate_a=min_rate_a, + ) if d is None: continue ttms.append(ttm) - rates_q.append(-math.log(d.quote_discount) / ttm) - rates_a.append(-math.log(d.asset_discount) / ttm) - return ttms, rates_q, rates_a + quote_rates.append(-math.log(d.quote_discount) / ttm) + asset_rates.append(-math.log(d.asset_discount) / ttm) + return VolRates(ttms=ttms, quote_rates=quote_rates, asset_rates=asset_rates) class VolSurfaceLoader(GenericVolSurfaceLoader[DefaultVolSecurity]): From 29619cc0e0002cadc0202dca91e5fee8699165f3 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 17 May 2026 21:07:20 +0100 Subject: [PATCH 4/5] Add examples index --- docs/examples/index.md | 32 ++++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 34 insertions(+) create mode 100644 docs/examples/index.md diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..6e881bc --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,32 @@ +# Interactive Examples + +Interactive notebooks for exploring quantflow tools and models. + +Each example is a [marimo](https://marimo.io) notebook served as a live application. +You can run the code, adjust parameters, and see results update in real time. + +!!! warning "Work in progress" + These notebooks are not always stable and may fail to load or produce unexpected results. + We are actively working on improving their reliability. + If you have experience with marimo and would like to help, contributions are very welcome via [GitHub](https://github.com/quantmind/quantflow). + +## Stochastic Processes + +| Example | Description | +|---|---| +| [Gaussian Sampling](gaussian-sampling) | Sample the Gaussian Ornstein-Uhlenbeck (Vasicek) process for different mean-reversion speeds and path counts | +| [Poisson Sampling](poisson-sampling) | Compare Monte Carlo simulation of the Poisson process against the analytical PDF | +| [Double Exponential Sampling](double-exponential-sampling) | Explore the Asymmetric Laplace distribution with adjustable asymmetry parameter | + +## Time Series Analysis + +| Example | Description | +|---|---| +| [Hurst Exponent](hurst) | Estimate the Hurst exponent to classify a time series as trending, mean-reverting, or random | +| [Supersmoother](supersmoother) | Apply the Supersmoother and EWMA filters to financial time series | + +## Volatility and Options + +| Example | Description | +|---|---| +| [Volatility Surface](volatility-surface) | Build and visualise an implied volatility surface from live Deribit ETH/USD options data | diff --git a/mkdocs.yml b/mkdocs.yml index 6fa5c46..30f9622 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -127,6 +127,7 @@ nav: - Lévy Process: theory/levy.md - Option Pricing: theory/option_pricing.md - Examples: + - examples/index.md - Gaussian Sampling: examples/gaussian-sampling - Poisson Sampling: examples/poisson-sampling - Double Exponential Sampling: examples/double-exponential-sampling @@ -139,6 +140,7 @@ nav: - Bibliography: bibliography.md - Release Notes: release-notes.md markdown_extensions: + - admonition - attr_list - tables - pymdownx.arithmatex: From 784720f31fe80b1cb8f0a90a2a84556dee95dddc Mon Sep 17 00:00:00 2001 From: Luca Date: Mon, 18 May 2026 10:51:57 +0100 Subject: [PATCH 5/5] Yield curve dump --- app/volatility_surface.py | 56 +-- docs/api/options/calibration.md | 2 + docs/api/options/vol_surface.md | 2 - docs/api/rates/yield_curve.md | 2 - quantflow/data/deribit.py | 9 + quantflow/data/yahoo.py | 17 +- quantflow/options/inputs.py | 7 +- quantflow/options/surface.py | 466 +++++--------------- quantflow/rates/__init__.py | 22 + quantflow/rates/nelson_siegel.py | 2 + quantflow/rates/vasicek.py | 40 ++ quantflow/rates/yield_curve.py | 13 +- quantflow/utils/plot.py | 2 +- quantflow_tests/test_data_deribit.py | 12 +- quantflow_tests/test_data_yahoo.py | 1 + quantflow_tests/test_implied_fwd.py | 175 -------- quantflow_tests/test_non_inverse_surface.py | 17 +- 17 files changed, 255 insertions(+), 590 deletions(-) create mode 100644 quantflow/rates/vasicek.py delete mode 100644 quantflow_tests/test_implied_fwd.py diff --git a/app/volatility_surface.py b/app/volatility_surface.py index f77ccad..593ea10 100644 --- a/app/volatility_surface.py +++ b/app/volatility_surface.py @@ -85,21 +85,13 @@ def int_or_none(v): label="Maturity" ) maturity_dropdown - return int_or_none, loader, maturity_dropdown, pd, surface + return int_or_none, maturity_dropdown, surface @app.cell def _(int_or_none, maturity_dropdown, surface): index = int_or_none(maturity_dropdown.value) surface.plot3d(index=index) - return (index,) - - -@app.cell -def _(index, pd, surface): - # display inputs - only options with converged implied volatility - surface_inputs = surface.inputs(converged=True, index=index) - pd.DataFrame([i.model_dump() for i in surface_inputs.inputs]) return @@ -111,48 +103,18 @@ def _(surface): @app.cell -def _(ts): - from quantflow.utils import plot - - plot.plot_lines(ts, x="ttm", y="rate_percent") - return - - -@app.cell -def _(loader): - loader.quote_curve.plot(ttm_max=2) - return - - -@app.cell -def _(loader): - loader.asset_curve.plot(ttm_max=2) +def _(surface, ts): + import math + ttm_max = 0.1*math.ceil(10*surface.maturities[-1].ttm(surface.ref_date)) + fig = surface.quote_curve.plot(ttm_max=ttm_max) + fig.add_scatter(x=ts["ttm"], y=ts["rate"], mode="markers", name="Cross Sections", marker=dict(size=8, color="orange")) + fig return @app.cell -def _(loader): - cross = loader.maturities[sorted(loader.maturities)[6]] - p = cross.put_call_parities(loader.spot.mid, max_pairs=100) - da=1 - return da, p - - -@app.cell -def _(da, p): - p.fit_discounts(da=da) - return - - -@app.cell -def _(da, p): - p.plot(da=da) - return - - -@app.cell -def _(loader): - loader.collect_rates(fit_asset_curve=False).model_dump() +def _(surface): + surface.asset_curve.plot(ttm_max=2) return diff --git a/docs/api/options/calibration.md b/docs/api/options/calibration.md index b2eadd8..022ebd4 100644 --- a/docs/api/options/calibration.md +++ b/docs/api/options/calibration.md @@ -1,5 +1,7 @@ # Vol Model Calibration +::: quantflow.options.calibration.base.ResidualKind + ::: quantflow.options.calibration.base.OptionEntry ::: quantflow.options.calibration.base.VolModelCalibration diff --git a/docs/api/options/vol_surface.md b/docs/api/options/vol_surface.md index 666a753..cc52999 100644 --- a/docs/api/options/vol_surface.md +++ b/docs/api/options/vol_surface.md @@ -19,8 +19,6 @@ ::: quantflow.options.surface.FwdPrice -::: quantflow.options.surface.ImpliedFwdPrice - ::: quantflow.options.surface.Strike ::: quantflow.options.surface.OptionArrays diff --git a/docs/api/rates/yield_curve.md b/docs/api/rates/yield_curve.md index f2f41c8..77e74ce 100644 --- a/docs/api/rates/yield_curve.md +++ b/docs/api/rates/yield_curve.md @@ -2,5 +2,3 @@ ::: quantflow.rates.yield_curve.YieldCurve - -::: quantflow.rates.nelson_siegel.NelsonSiegel diff --git a/quantflow/data/deribit.py b/quantflow/data/deribit.py index 40f98c5..536e641 100644 --- a/quantflow/data/deribit.py +++ b/quantflow/data/deribit.py @@ -14,6 +14,8 @@ from quantflow.options.inputs import DefaultVolSecurity, OptionType from quantflow.options.surface import VolSurfaceLoader +from quantflow.rates.yield_curve import NoDiscount +from quantflow.utils.dates import utcnow from quantflow.utils.numbers import ( Number, round_to_step, @@ -125,6 +127,10 @@ async def volatility_surface_loader( self, currency: Annotated[str, Doc("Currency")], *, + ref_date: Annotated[ + datetime | None, + Doc("Reference date for the yield curves; defaults to now"), + ] = None, inverse: Annotated[ bool, Doc( @@ -145,10 +151,13 @@ async def volatility_surface_loader( ) -> VolSurfaceLoader: """Create a [VolSurfaceLoader][quantflow.options.surface.VolSurfaceLoader] for a given crypto-currency""" + ref = ref_date or utcnow() loader = VolSurfaceLoader( asset=currency, exclude_open_interest=to_decimal_or_none(exclude_open_interest), exclude_volume=to_decimal_or_none(exclude_volume), + quote_curve=NoDiscount(ref_date=ref), + asset_curve=NoDiscount(ref_date=ref), ) if inverse: futures = await self.get_book_summary_by_currency( diff --git a/quantflow/data/yahoo.py b/quantflow/data/yahoo.py index bfe9dfa..adf50e3 100644 --- a/quantflow/data/yahoo.py +++ b/quantflow/data/yahoo.py @@ -3,7 +3,7 @@ import gzip import json from dataclasses import dataclass, field -from datetime import date, timezone +from datetime import date, datetime, timezone from enum import StrEnum from pathlib import Path @@ -13,7 +13,8 @@ from quantflow.options.inputs import DefaultVolSecurity, OptionType from quantflow.options.surface import VolSurfaceLoader -from quantflow.utils.dates import as_utc +from quantflow.rates.yield_curve import NoDiscount +from quantflow.utils.dates import as_utc, utcnow from quantflow.utils.numbers import to_decimal @@ -86,6 +87,10 @@ async def volatility_surface_loader( self, symbol: Annotated[str, Doc("Underlying ticker symbol")], *, + ref_date: Annotated[ + datetime | None, + Doc("Reference date for the yield curves; defaults to now"), + ] = None, exclude_volume: Annotated[ int | None, Doc("Drop contracts with volume at or below this threshold") ] = None, @@ -99,6 +104,7 @@ async def volatility_surface_loader( [loader_from_chain][quantflow.data.yahoo.Yahoo.loader_from_chain].""" return self.loader_from_chain( await self.option_chain(symbol), + ref_date=ref_date, exclude_volume=exclude_volume, exclude_open_interest=exclude_open_interest, ) @@ -108,6 +114,10 @@ def loader_from_chain( cls, chain: Annotated[dict, Doc("Yahoo option chain payload")], *, + ref_date: Annotated[ + datetime | None, + Doc("Reference date for the yield curves; defaults to now"), + ] = None, exclude_volume: Annotated[ int | None, Doc("Drop contracts with volume at or below this threshold") ] = None, @@ -124,12 +134,15 @@ def loader_from_chain( by Yahoo, so they are recovered from put-call parity by the loader. """ symbol = chain.get("underlyingSymbol", "") + ref = ref_date or utcnow() loader = VolSurfaceLoader( asset=symbol, exclude_volume=to_decimal(exclude_volume) if exclude_volume else None, exclude_open_interest=( to_decimal(exclude_open_interest) if exclude_open_interest else None ), + quote_curve=NoDiscount(ref_date=ref), + asset_curve=NoDiscount(ref_date=ref), ) quote = chain.get("quote") or {} bid = quote.get("bid") or quote.get("regularMarketPrice") diff --git a/quantflow/options/inputs.py b/quantflow/options/inputs.py index 9c4bb91..fbaf2da 100644 --- a/quantflow/options/inputs.py +++ b/quantflow/options/inputs.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field +from quantflow.rates import AnyYieldCurve from quantflow.utils.numbers import ZERO, DecimalNumber P = TypeVar("P") @@ -44,11 +45,14 @@ class VolSecurityType(enum.StrEnum): class VolSurfaceSecurity(BaseModel): + """Base class for Volatility Surface Securities""" + def vol_surface_type(self) -> VolSecurityType: raise NotImplementedError("vol_surface_type must be implemented by subclasses") @classmethod def forward(cls) -> Self: + """Create a forward security for the volatility surface""" raise NotImplementedError("forward_input must be implemented by subclasses") @@ -141,7 +145,8 @@ class VolSurfaceInputs(BaseModel): """Class representing the inputs for a volatility surface""" asset: str = Field(description="Underlying asset of the volatility surface") - ref_date: datetime = Field(description="Reference date for the volatility surface") + asset_curve: AnyYieldCurve = Field(description="Asset yield curve") + quote_curve: AnyYieldCurve = Field(description="Quote yield curve") inputs: list[ForwardInput | SpotInput | OptionInput] = Field( description="List of inputs for the volatility surface" ) diff --git a/quantflow/options/surface.py b/quantflow/options/surface.py index 6c3e4c6..c8d52fb 100644 --- a/quantflow/options/surface.py +++ b/quantflow/options/surface.py @@ -5,7 +5,7 @@ import warnings from datetime import datetime, timedelta from decimal import Decimal -from typing import Any, Generic, Iterator, NamedTuple, Self, TypeVar +from typing import Any, Generic, Iterator, NamedTuple, Self, TypeVar, cast import numpy as np import pandas as pd @@ -13,16 +13,13 @@ from pydantic import BaseModel, Field from typing_extensions import Annotated, Doc -from quantflow.rates.interest_rate import Rate -from quantflow.rates.yield_curve import NoDiscount, YieldCurve +from quantflow.rates import AnyYieldCurve, NoDiscount, Rate, YieldCurve from quantflow.utils import plot from quantflow.utils.dates import utcnow from quantflow.utils.numbers import ( - ONE, ZERO, DecimalNumber, Number, - Rounding, normalize_decimal, round_to_step, sigfig, @@ -103,6 +100,14 @@ def inputs(self) -> SpotInput: volume=self.volume, ) + def _implied_forward(self, maturity: datetime, price: Decimal) -> FwdPrice[S]: + return FwdPrice( + security=self.security.forward(), + maturity=maturity, + bid=price, + ask=price, + ) + class FwdPrice(SecurityPrice[S]): """Represents the forward bid/ask price of an underlying asset @@ -120,113 +125,6 @@ def inputs(self) -> ForwardInput: ) -class ImpliedFwdPrice(FwdPrice[S]): - """Represents the implied forward price of an underlying asset at a specific - maturity, extracted from option prices via put-call parity""" - - strike: DecimalNumber = Field( - description="Strike price of the options used to extract the forward price" - ) - - def moneyness(self, ttm: float) -> float: - """Moneyness of the implied forward""" - return math.log(float(self.strike / self.mid)) / math.sqrt(ttm) - - @classmethod - def aggregate( - cls, - implied_forwards: Annotated[ - list[Self], Doc("Implied forward prices from put-call parity") - ], - ttm: Annotated[float, Doc("Time to maturity in years")], - default: Annotated[ - FwdPrice[S] | None, - Doc("Market forward (e.g. from futures) used as fallback or for blending"), - ] = None, - previous_forward: Annotated[ - Decimal | None, - Doc( - "Anchor forward for proximity weighting, " - "typically the previous maturity" - ), - ] = None, - tick_size: Annotated[ - Decimal | None, Doc("Tick size for rounding the implied forward bid/ask") - ] = None, - ) -> FwdPrice[S] | None: - r"""Aggregate implied forward prices extracted from put-call parity into a - single best-estimate forward price. - - **Selection**: valid implied forwards are sorted by bid-ask spread in basis - points and the tightest 5 are retained as candidates. Let $c$ denote the - tightest bp spread among the candidates. - - **Default priority**: if a default forward is provided and its bp spread is - tighter than $c$, it is returned immediately as the most reliable price. - - **Default inclusion**: if the default's bp spread is wider than $c$ but - narrower than the worst candidate, it is appended to the candidate pool - and weighted on equal footing with the implied forwards. - - **Weighting**: each candidate $i$ receives weight - - \begin{equation} - w_i = w^{\text{spread}}_i \cdot w^{\text{proximity}}_i - \end{equation} - - where the spread weight is a Gaussian on the normalised distance from the - best spread $c$ and the proximity weight, applied only when - `previous_forward` is provided. - The result is the weighted average of the candidate mid prices, with the - bid/ask spread computed as the weighted average of candidate spreads. - When `tick_size` is provided the output bid is rounded down and the ask - is rounded up to the nearest tick. - """ - forwards: list[FwdPrice[S]] = [f for f in implied_forwards if f.is_valid()] - if not forwards: - return default - forwards = sorted(forwards, key=lambda f: f.bp_spread)[:5] - best_bp_spread = forwards[0].bp_spread - if ( - default is not None - and default.is_valid() - and default.bp_spread < best_bp_spread - ): - return default - weights = 0.0 - values = 0.0 - spreads = 0.0 - worse_bp_spread = forwards[-1].bp_spread - if ( - default is not None - and default.is_valid() - and default.bp_spread < worse_bp_spread - ): - forwards.append(default) - for forward in forwards: - s = (forward.bp_spread - best_bp_spread) / best_bp_spread - weight = math.exp(-s * s) - if previous_forward is not None: - d = (forward.mid - previous_forward) / previous_forward - weight *= math.exp(-d * d) - weights += weight - values += weight * float(forward.mid) - spreads += weight * float(forward.spread) - mid = to_decimal(values / weights) - spread = to_decimal(spreads / weights) - bid = mid - spread / 2 - ask = mid + spread / 2 - if tick_size is not None: - bid = round_to_step(bid, tick_size, Rounding.DOWN) - ask = round_to_step(ask, tick_size, Rounding.UP) - return FwdPrice( - security=forwards[0].security.forward(), - bid=bid, - ask=ask, - maturity=forwards[0].maturity, - ) - - class OptionMetadata(BaseModel): """Represents the metadata of an option, including its strike, type, maturity, and other relevant information.""" @@ -576,73 +474,6 @@ def put_call_parity(self) -> PutCallParity | None: inverse=self.call.meta.inverse, ) - def implied_forward( - self, - tick_size: Annotated[ - Decimal | None, Doc("Tick size for rounding the implied forward bid/ask") - ] = None, - df: Annotated[ - Decimal, - Doc( - "Discount factor at this maturity. " - "Defaults to 1 (no discounting). When set, option prices are " - "deflated by $D$ before applying put-call parity." - ), - ] = ONE, - ) -> ImpliedFwdPrice[S] | None: - r"""Extract the implied forward price from put-call parity. - - Requires both a call and a put at this strike. Uses bid/ask prices - to construct the bid/ask of the implied forward. When `tick_size` is - provided, bid is rounded down and ask is rounded up to the nearest tick. - - For inverse options (prices quoted in the underlying currency) - put-call parity reads - - \begin{equation} - F = \frac{K}{1 - (c - p) / D} - \end{equation} - - For non-inverse options (prices quoted in the quote currency) - - \begin{equation} - F = K + \frac{C - P}{D} - \end{equation} - - where $D$ is the discount factor (default 1, i.e. no discounting). - - Returns None when the strike does not have both a call and a put, - or when the denominator is non-positive (arbitrage condition violated). - """ - if self.call is None or self.put is None: - return None - cp_bid = self.call.bid.price - self.put.ask.price - cp_ask = self.call.ask.price - self.put.bid.price - if self.call.meta.inverse: - d_bid = ONE - cp_bid / df - d_ask = ONE - cp_ask / df - if d_bid <= ZERO or d_ask <= ZERO: - return None - bid = self.strike / d_bid - ask = self.strike / d_ask - else: - bid = self.strike + cp_bid / df - ask = self.strike + cp_ask / df - if bid <= ZERO or ask <= ZERO: - return None - if bid > ask: - return None - if tick_size is not None: - bid = round_to_step(bid, tick_size, Rounding.DOWN) - ask = round_to_step(ask, tick_size, Rounding.UP) - return ImpliedFwdPrice( - security=self.call.security.forward(), - strike=self.strike, - maturity=self.call.meta.maturity, - bid=bid, - ask=ask, - ) - def options_iter( self, forward: Annotated[Decimal, Doc("Forward price of the underlying asset")], @@ -748,33 +579,23 @@ def ttm(self, ref_date: datetime) -> float: """Time to maturity in years""" return self.day_counter.dcf(ref_date, self.maturity) - def forward_rate(self, ref_date: datetime, spot: SpotPrice[S]) -> Rate: - """Compute the implied continuous rate from spot and forward mid""" - return Rate.from_spot_and_forward( - spot.mid, - self.forward.mid, - ref_date, - self.maturity, - day_counter=self.day_counter, - ) - - def forward_spread_fraction(self) -> Decimal: - """Bid-ask spread of the forward as a fraction of its mid price""" - mid = self.forward.mid - if mid <= ZERO: - return Decimal("Inf") - return (self.forward.ask - self.forward.bid) / mid - - def info_dict(self, ref_date: datetime, spot: SpotPrice[S]) -> dict: + def info_dict( + self, + ref_date: datetime, + spot: Decimal, + implied_forward: Decimal, + ) -> dict: """Return a dictionary with information about the cross section""" + ttm = self.ttm(ref_date) return dict( maturity=self.maturity, - ttm=self.ttm(ref_date), + ttm=ttm, forward=self.forward.mid, + implied_forward=implied_forward, + forward_basis=implied_forward - self.forward.mid, + rate=Rate.from_number(float((implied_forward / spot).ln()) / ttm).rate, bid_ask_spread=self.forward.spread, - basis=self.forward.mid - spot.mid, - rate_percent=self.forward_rate(ref_date, spot).percent, - fwd_spread_pct=round(100 * self.forward_spread_fraction(), 4), + basis=implied_forward - spot, open_interest=self.forward.open_interest, volume=self.forward.volume, ) @@ -784,6 +605,7 @@ def option_prices( ref_date: Annotated[ datetime, Doc("Reference date for time to maturity calculation") ], + forward: Annotated[Decimal, Doc("Forward price of the underlying asset")], *, select: Annotated[ OptionSelection, Doc("Option selection method") @@ -798,7 +620,7 @@ def option_prices( """Iterator over option prices in the cross section""" for s in self.strikes: yield from s.option_prices( - self.forward.mid, + forward, self.ttm(ref_date), select=select, initial_vol=initial_vol, @@ -921,7 +743,66 @@ def disable_outliers( break -class VolSurface(BaseModel, Generic[S]): +class ForwardPricer(BaseModel, Generic[S]): + """Base class for forward/discount factor pricers""" + + asset: str = Field( + default="", + description="Name of the underlying asset", + ) + spot: SpotPrice[S] | None = Field( + default=None, + description="Spot price of the underlying asset", + ) + quote_curve: AnyYieldCurve = Field( + default_factory=NoDiscount, + description="Discount curve for the quote", + ) + asset_curve: AnyYieldCurve = Field( + default_factory=NoDiscount, + description="Discount curve for the asset", + ) + tick_size_forwards: DecimalNumber | None = Field( + default=None, + description="Tick size for rounding forward and spot prices - optional", + ) + tick_size_options: DecimalNumber | None = Field( + default=None, description="Tick size for rounding option prices - optional" + ) + day_counter: DayCounter = Field( + default=default_day_counter, + description=( + "Day counter for time to maturity calculations, " + "by default it uses Act/Act" + ), + ) + + @property + def ref_date(self) -> datetime: + """Reference date for the volatility surface, taken as the earliest maturity + or the provided ref_date if it's earlier""" + return min(self.quote_curve.ref_date, self.asset_curve.ref_date) + + def spot_price(self) -> Decimal: + """Get the spot price if it exists""" + if self.spot is None: + raise ValueError("No spot price provided") + return self.spot.mid + + def forward(self, maturity: datetime) -> Decimal: + """Calculate the implied forward for a given maturity""" + ttm = self.day_counter.dcf(self.ref_date, maturity) + df_quote = self.quote_curve.discount_factor(ttm) + df_asset = self.asset_curve.discount_factor(ttm) + forward_rate = self.spot_price() * df_asset / df_quote + return ( + round_to_step(forward_rate, self.tick_size_forwards) + if self.tick_size_forwards + else forward_rate + ) + + +class VolSurface(ForwardPricer[S]): """Represents a volatility surface, which captures the implied volatility of an option for different strikes and maturities. @@ -947,31 +828,14 @@ class VolSurface(BaseModel, Generic[S]): future volatility. """ - ref_date: datetime = Field(description="Reference date for the volatility surface") - asset: str = Field(description="Underlying asset of the volatility surface") - spot: SpotPrice[S] = Field(description="Spot price of the underlying asset") maturities: tuple[VolCrossSection[S], ...] = Field( + default=(), description=( "Sorted tuple of " "[VolCrossSection][quantflow.options.surface.VolCrossSection], " "each containing the forward price and option prices for that maturity" - ) - ) - day_counter: DayCounter = Field( - default=default_day_counter, - description=( - "Day counter for time to maturity calculations, " - "by default it uses Act/Act" ), ) - tick_size_forwards: DecimalNumber | None = Field( - default=None, - description="Tick size for rounding forward and spot prices - optional", - ) - tick_size_options: DecimalNumber | None = Field( - default=None, - description="Tick size for rounding option prices - optional", - ) def securities( self, @@ -992,7 +856,8 @@ def securities( ] = False, ) -> Iterator[SpotPrice[S] | FwdPrice[S] | OptionPrices[S]]: """Iterator over securities in the volatility surface""" - yield self.spot + if self.spot is not None: + yield self.spot if index is not None: yield from self.maturities[index].securities( select=select, converged=converged @@ -1023,7 +888,8 @@ def inputs( [VolSurfaceInputs][quantflow.options.inputs.VolSurfaceInputs] instance""" return VolSurfaceInputs( asset=self.asset, - ref_date=self.ref_date, + asset_curve=self.asset_curve, + quote_curve=self.quote_curve, inputs=list( s.inputs() for s in self.securities( @@ -1034,8 +900,10 @@ def inputs( def term_structure(self) -> pd.DataFrame: """Return the term structure of the volatility surface as a DataFrame""" + spot = self.spot_price() return pd.DataFrame( - cross.info_dict(self.ref_date, self.spot) for cross in self.maturities + cross.info_dict(self.ref_date, spot, self.forward(cross.maturity)) + for cross in self.maturities ) def trim(self, num_maturities: int) -> Self: @@ -1067,16 +935,19 @@ def option_prices( ) -> Iterator[OptionPrice]: """Iterator over selected option prices in the surface""" if index is not None: - yield from self.maturities[index].option_prices( + cross = self.maturities[index] + yield from cross.option_prices( self.ref_date, + self.forward(cross.maturity), select=select, initial_vol=initial_vol, converged=converged, ) else: - for maturity in self.maturities: - yield from maturity.option_prices( + for cross in self.maturities: + yield from cross.option_prices( self.ref_date, + self.forward(cross.maturity), select=select, initial_vol=initial_vol, converged=converged, @@ -1374,56 +1245,13 @@ def add_option( else: self.strikes[strike].put = option - def cross_section( - self, - ref_date: Annotated[ - datetime | None, Doc("Reference date for the volatility surface") - ] = None, - previous_forward: Annotated[ - Decimal | None, - Doc( - "Previous forward price for the volatility surface " - "Usaed by the implied forward calculation to replace missing " - "or unreliable forwards" - ), - ] = None, - tick_size: Annotated[ - Decimal | None, - Doc("Tick size for rounding implied forward bid/ask prices"), - ] = None, - yield_curve: Annotated[ - YieldCurve | None, - Doc( - "Yield curve used to compute the per-maturity discount factor " - "applied in put-call parity. When None, no discounting is applied." - ), - ] = None, - ) -> VolCrossSection[S] | None: + def _cross_section(self, forward: FwdPrice[S]) -> VolCrossSection[S] | None: strikes = [] - implied_forwards = [] - ref = ref_date or utcnow() - ttm = self.day_counter.dcf(ref, self.maturity) - yield_curve = yield_curve or NoDiscount() - df = yield_curve.discount_factor(ttm) for strike in sorted(self.strikes): sk = self.strikes[strike] if sk.call is None and sk.put is None: continue - if implied_forward := sk.implied_forward(tick_size=tick_size, df=df): - implied_forwards.append(implied_forward) strikes.append(sk) - forward = self.forward - if implied_forwards: - ttm = self.day_counter.dcf(ref_date or utcnow(), self.maturity) - forward = ImpliedFwdPrice.aggregate( - implied_forwards, - ttm, - default=self.forward, - previous_forward=previous_forward, - tick_size=tick_size, - ) - if forward is None or not forward.is_valid(): - return None return ( VolCrossSection( maturity=self.maturity, @@ -1472,7 +1300,7 @@ class VolRates(BaseModel, frozen=True): asset_rates: list[float] = Field(description="Asset continuously compounded rates") -class GenericVolSurfaceLoader(BaseModel, Generic[S], arbitrary_types_allowed=True): +class GenericVolSurfaceLoader(ForwardPricer[S], arbitrary_types_allowed=True): """Helper class to build a volatility surface from a list of securities Use this class to add spot, forward and option securities with their prices @@ -1480,30 +1308,12 @@ class GenericVolSurfaceLoader(BaseModel, Generic[S], arbitrary_types_allowed=Tru from the provided data. """ - asset: str = Field(default="", description="Name of the underlying asset") - spot: SpotPrice[S] | None = Field( - default=None, description="Spot price of the underlying asset" - ) maturities: dict[datetime, VolCrossSectionLoader[S]] = Field( default_factory=dict, description=( "Dictionary of maturities and their corresponding cross section loaders" ), ) - day_counter: DayCounter = Field( - default=default_day_counter, - description=( - "Day counter for time to maturity calculations " - "by default it uses Act/Act" - ), - ) - tick_size_forwards: DecimalNumber | None = Field( - default=None, - description="Tick size for rounding forward and spot prices - optional", - ) - tick_size_options: DecimalNumber | None = Field( - default=None, description="Tick size for rounding option prices - optional" - ) exclude_open_interest: DecimalNumber | None = Field( default=None, description="Exclude options with open interest at or below this value", @@ -1511,31 +1321,6 @@ class GenericVolSurfaceLoader(BaseModel, Generic[S], arbitrary_types_allowed=Tru exclude_volume: DecimalNumber | None = Field( default=None, description="Exclude options with volume at or below this value" ) - quote_curve: YieldCurve = Field( - default_factory=NoDiscount, - description=( - "Discount curve for the quote currency $D_q$. " - "Set by calling calibrate_curves()." - ), - ) - asset_curve: YieldCurve = Field( - default_factory=NoDiscount, - description=( - "Discount curve for the asset $D_a$. " "Set by calling calibrate_curves()." - ), - ) - - def ref_date( - self, - ref_date: Annotated[ - datetime | None, Doc("Reference date for the volatility surface") - ] = None, - ) -> datetime: - """Reference date for the volatility surface, taken as the earliest maturity - or the provided ref_date if it's earlier""" - if ref_date is not None: - return ref_date - return utcnow() def get_or_create_maturity( self, @@ -1624,33 +1409,27 @@ def add_option( inverse=inverse, ) - def surface( - self, - ref_date: Annotated[ - datetime | None, Doc("Reference date for the volatility surface") - ] = None, - ) -> VolSurface[S]: + def surface(self) -> VolSurface[S]: """Build a volatility surface from the provided data""" - if not self.spot or self.spot.mid == ZERO: - raise ValueError("No spot price provided") maturities = [] - ref_date = ref_date or utcnow() - previous_forward = self.spot.mid + spot = self.spot + if spot is None: + raise ValueError("No spot price provided") for maturity in sorted(self.maturities): - if section := self.maturities[maturity].cross_section( - ref_date=ref_date, - previous_forward=previous_forward, - tick_size=self.tick_size_forwards, - yield_curve=self.quote_curve, - ): - previous_forward = section.forward.mid + loader = self.maturities[maturity] + forward = loader.forward + if forward is None: + implied_forward_price = self.forward(maturity) + forward = spot._implied_forward(maturity, implied_forward_price) + if section := loader._cross_section(forward): maturities.append(section) return VolSurface( asset=self.asset, - ref_date=ref_date, spot=self.spot, maturities=tuple(maturities), day_counter=self.day_counter, + quote_curve=self.quote_curve.model_copy(), + asset_curve=self.asset_curve.model_copy(), tick_size_forwards=self.tick_size_forwards, tick_size_options=self.tick_size_options, ) @@ -1686,9 +1465,6 @@ def calibrate_curves( " Set negative to allow positive asset carry." ), ] = 0.0, - ref_date: Annotated[ - datetime | None, Doc("Reference date for time to maturity calculations") - ] = None, max_pairs: Annotated[ int, Doc("Maximum number of put-call pairs to use per maturity") ] = 10, @@ -1711,16 +1487,17 @@ def calibrate_curves( fit_asset_curve=asset_curve is not None, min_rate_q=min_rate_q, min_rate_a=min_rate_a, - ref_date=ref_date, max_pairs=max_pairs, ) if quote_curve is not None: - self.quote_curve = quote_curve.calibrate( - vol_rates.ttms, vol_rates.quote_rates + self.quote_curve = cast( + AnyYieldCurve, + quote_curve.calibrate(vol_rates.ttms, vol_rates.quote_rates), ) if asset_curve is not None: - self.asset_curve = asset_curve.calibrate( - vol_rates.ttms, vol_rates.asset_rates + self.asset_curve = cast( + AnyYieldCurve, + asset_curve.calibrate(vol_rates.ttms, vol_rates.asset_rates), ) def collect_rates( @@ -1746,9 +1523,6 @@ def collect_rates( " Set negative to allow positive asset carry." ), ] = 0.0, - ref_date: Annotated[ - datetime | None, Doc("Reference date for time to maturity calculations") - ] = None, max_pairs: Annotated[ int, Doc("Maximum number of put-call pairs to use per maturity") ] = 10, @@ -1760,7 +1534,7 @@ def collect_rates( ttms: list[float] = [] quote_rates: list[float] = [] asset_rates: list[float] = [] - ref_date = self.ref_date(ref_date=ref_date) + ref_date = self.ref_date for maturity, section in sorted(self.maturities.items()): ttm = self.day_counter.dcf(ref_date, maturity) if ttm <= 0: @@ -1848,4 +1622,4 @@ def surface_from_inputs( loader = VolSurfaceLoader() for input in inputs.inputs: loader.add(input) - return loader.surface(ref_date=inputs.ref_date) + return loader.surface() diff --git a/quantflow/rates/__init__.py b/quantflow/rates/__init__.py index e69de29..6458328 100644 --- a/quantflow/rates/__init__.py +++ b/quantflow/rates/__init__.py @@ -0,0 +1,22 @@ +from typing import Annotated, Union + +from pydantic import Field + +from .interest_rate import Rate +from .nelson_siegel import NelsonSiegel +from .vasicek import VasicekCurve +from .yield_curve import NoDiscount, YieldCurve + +__all__ = [ + "YieldCurve", + "NoDiscount", + "NelsonSiegel", + "VasicekCurve", + "AnyYieldCurve", + "Rate", +] + +AnyYieldCurve = Annotated[ + Union[NoDiscount, NelsonSiegel, VasicekCurve], + Field(discriminator="curve_type"), +] diff --git a/quantflow/rates/nelson_siegel.py b/quantflow/rates/nelson_siegel.py index a44a0ce..9a76fcc 100644 --- a/quantflow/rates/nelson_siegel.py +++ b/quantflow/rates/nelson_siegel.py @@ -1,6 +1,7 @@ from __future__ import annotations from decimal import Decimal +from typing import Literal import numpy as np from numpy.typing import ArrayLike @@ -34,6 +35,7 @@ class NelsonSiegel(YieldCurve): beta2: Decimal = Field(..., description="Slope parameter") beta3: Decimal = Field(..., description="Curvature parameter") lambda_: Decimal = Field(..., description="Decay factor") + curve_type: Literal["nelson_siegel"] = "nelson_siegel" def instanteous_forward_rate(self, ttm: Number) -> Decimal: ttmd = to_decimal(ttm) diff --git a/quantflow/rates/vasicek.py b/quantflow/rates/vasicek.py new file mode 100644 index 0000000..07c62b2 --- /dev/null +++ b/quantflow/rates/vasicek.py @@ -0,0 +1,40 @@ +from decimal import Decimal +from typing import Literal + +from pydantic import Field + +from quantflow.sp.ou import Vasicek +from quantflow.sp.wiener import WienerProcess +from quantflow.utils.numbers import ONE, ZERO, DecimalNumber, Number, to_decimal + +from .yield_curve import YieldCurve + + +class VasicekCurve(YieldCurve): + """Class representing a Vasicek yield curve""" + + rate: DecimalNumber = Field(description=r"Initial value $x_0$") + kappa: DecimalNumber = Field(gt=ZERO, description=r"Mean reversion speed $\kappa$") + theta: DecimalNumber = Field(description=r"Mean level $\theta$") + sigma: DecimalNumber = Field(ge=ZERO, description=r"Volatility $\sigma$") + curve_type: Literal["vasicek"] = "vasicek" + + def process(self) -> Vasicek: + return Vasicek( + rate=float(self.rate), + kappa=float(self.kappa), + theta=float(self.theta), + bdlp=WienerProcess(sigma=float(self.sigma)), + ) + + def discount_factor(self, ttm: Number) -> Decimal: + r"""Calculate the discount factor for a given time to maturity.""" + ttmd = to_decimal(ttm) + if ttmd <= ZERO: + return ONE + b = (ONE - (-self.kappa * ttmd).exp()) / self.kappa + s2 = self.sigma * self.sigma + a = (self.theta - s2 / (2 * self.kappa * self.kappa)) * ( + b - ttmd + ) + s2 * b * b / (4 * self.kappa) + return (a - self.rate * b).exp() diff --git a/quantflow/rates/yield_curve.py b/quantflow/rates/yield_curve.py index 4aa1476..760b0fb 100644 --- a/quantflow/rates/yield_curve.py +++ b/quantflow/rates/yield_curve.py @@ -1,20 +1,27 @@ from __future__ import annotations from abc import ABC, abstractmethod +from datetime import datetime from decimal import Decimal -from typing import Any +from typing import Any, Literal from numpy.typing import ArrayLike -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing_extensions import Annotated, Doc, Self from quantflow.utils import plot +from quantflow.utils.dates import utcnow from quantflow.utils.numbers import ONE, ZERO class YieldCurve(BaseModel, ABC, extra="forbid"): """Abstract base class for yield curves""" + ref_date: datetime = Field( + default_factory=utcnow, + description="Reference date for the yield curve", + ) + @abstractmethod def instanteous_forward_rate(self, ttm: float) -> Decimal: r"""Calculate the instantaneous forward rate for a given time to maturity @@ -90,6 +97,8 @@ def plot( class NoDiscount(YieldCurve): """Flat yield curve with zero rates (discount factor is always 1).""" + curve_type: Literal["no_discount"] = "no_discount" + def instanteous_forward_rate(self, ttm: float) -> Decimal: return ZERO diff --git a/quantflow/utils/plot.py b/quantflow/utils/plot.py index acaacdd..17357db 100644 --- a/quantflow/utils/plot.py +++ b/quantflow/utils/plot.py @@ -225,7 +225,7 @@ def plot_yield_curve( **kwargs: Any, ) -> Any: check_plotly() - ttms = np.linspace(0.01, ttm_max, n) + ttms = np.linspace(0.0, ttm_max, n) rates = [float(curve.continuously_compounded_rate(t)) for t in ttms] df = pd.DataFrame({"ttm": ttms, "rate": rates}) return px.line( diff --git a/quantflow_tests/test_data_deribit.py b/quantflow_tests/test_data_deribit.py index 01c8a99..0509e0d 100644 --- a/quantflow_tests/test_data_deribit.py +++ b/quantflow_tests/test_data_deribit.py @@ -54,8 +54,8 @@ async def test_loader_loads_known_options( deribit_cli: Deribit, ref_date: datetime ) -> None: """Options present in both book summary and instruments are loaded.""" - loader = await deribit_cli.volatility_surface_loader("btc") - surface = loader.surface(ref_date=ref_date) + loader = await deribit_cli.volatility_surface_loader("btc", ref_date=ref_date) + surface = loader.surface() # fixture has 2 strikes (70000 C+P, 75000 C) all on one maturity total_strikes = sum(len(m.strikes) for m in surface.maturities) assert total_strikes == 2 @@ -68,8 +68,8 @@ async def test_loader_skips_option_missing_from_instruments( ghost = "BTC-10APR26-67500-P" assert any(o["instrument_name"] == ghost for o in options) - loader = await deribit_cli.volatility_surface_loader("btc") - surface = loader.surface(ref_date=ref_date) + loader = await deribit_cli.volatility_surface_loader("btc", ref_date=ref_date) + surface = loader.surface() all_strikes = { strike.strike for mat in surface.maturities for strike in mat.strikes } @@ -82,6 +82,6 @@ async def test_loader_skips_future_missing_from_instruments( """Futures absent from the instruments list are silently skipped.""" assert any(f["instrument_name"] == "BTC-GHOST-26" for f in futures) - loader = await deribit_cli.volatility_surface_loader("btc") - surface = loader.surface(ref_date=ref_date) + loader = await deribit_cli.volatility_surface_loader("btc", ref_date=ref_date) + surface = loader.surface() assert surface is not None diff --git a/quantflow_tests/test_data_yahoo.py b/quantflow_tests/test_data_yahoo.py index d3ee18a..c958fe0 100644 --- a/quantflow_tests/test_data_yahoo.py +++ b/quantflow_tests/test_data_yahoo.py @@ -32,6 +32,7 @@ async def test_loader_builds_surface(yahoo_cli: Yahoo, spx_chain: dict) -> None: surface = loader.surface() assert surface.asset == "^SPX" assert len(surface.maturities) == len(spx_chain["options"]) + assert surface.spot is not None assert surface.spot.mid > 0 diff --git a/quantflow_tests/test_implied_fwd.py b/quantflow_tests/test_implied_fwd.py deleted file mode 100644 index fbb71cd..0000000 --- a/quantflow_tests/test_implied_fwd.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Tests for ImpliedFwdPrice.aggregate""" - -from __future__ import annotations - -from datetime import datetime, timezone -from decimal import Decimal - -import pytest -from hypothesis import given -from hypothesis import strategies as st - -from quantflow.options.inputs import DefaultVolSecurity -from quantflow.options.surface import FwdPrice, ImpliedFwdPrice - -MATURITY = datetime(2026, 12, 31, tzinfo=timezone.utc) - - -def make_implied( - mid: float, spread_bp: float, strike: float | None = None -) -> ImpliedFwdPrice: - mid_d = Decimal(str(round(mid, 6))) - half_spread = Decimal(str(round(mid * spread_bp / 20000, 8))) - return ImpliedFwdPrice( - security=DefaultVolSecurity.forward(), - bid=mid_d - half_spread, - ask=mid_d + half_spread, - strike=Decimal(str(round(strike if strike is not None else mid, 6))), - maturity=MATURITY, - ) - - -def make_fwd(mid: float, spread_bp: float) -> FwdPrice: - mid_d = Decimal(str(round(mid, 6))) - half_spread = Decimal(str(round(mid * spread_bp / 20000, 8))) - return FwdPrice( - security=DefaultVolSecurity.forward(), - bid=mid_d - half_spread, - ask=mid_d + half_spread, - maturity=MATURITY, - ) - - -def test_aggregate_empty_no_default_returns_none() -> None: - assert ImpliedFwdPrice.aggregate([], ttm=1.0) is None - - -def test_aggregate_empty_with_default_returns_default() -> None: - default = make_fwd(100, 20) - assert ImpliedFwdPrice.aggregate([], ttm=1.0, default=default) is default - - -def make_implied_market( - theoretical_forward: float, - mid_fraction: float, - spread_multiplier: float, - strike_fraction: float, -) -> ImpliedFwdPrice: - """Implied forward whose spread scales with distance from theoretical_forward.""" - mid = theoretical_forward * mid_fraction - spread_bp = max( - 1.0, - abs(mid - theoretical_forward) - / theoretical_forward - * 10000 - * spread_multiplier, - ) - return make_implied(mid, spread_bp, strike=theoretical_forward * strike_fraction) - - -def test_aggregate_all_invalid_returns_default() -> None: - invalid = ImpliedFwdPrice( - security=DefaultVolSecurity.forward(), - bid=Decimal("101"), - ask=Decimal("99"), # bid > ask - strike=Decimal("100"), - maturity=MATURITY, - ) - default = make_fwd(100, 20) - assert ImpliedFwdPrice.aggregate([invalid], ttm=1.0, default=default) is default - - -@given( - theoretical_forward=st.floats( - min_value=100.0, max_value=100_000.0, allow_nan=False, allow_infinity=False - ), - anchor=st.floats( - min_value=0.9, max_value=0.99, allow_nan=False, allow_infinity=False - ), - mid_fractions=st.lists( - st.floats( - min_value=0.95, max_value=1.05, allow_nan=False, allow_infinity=False - ), - min_size=2, - max_size=8, - ), - spread_multipliers=st.lists( - st.floats(min_value=0.5, max_value=3.0, allow_nan=False, allow_infinity=False), - min_size=2, - max_size=8, - ), - strike_fractions=st.lists( - st.floats(min_value=0.8, max_value=1.2, allow_nan=False, allow_infinity=False), - min_size=2, - max_size=8, - ), - ttm=st.floats(min_value=0.1, max_value=2.0, allow_nan=False, allow_infinity=False), -) -def test_aggregate_with_previous_forward( - theoretical_forward: float, - anchor: float, - mid_fractions: list[float], - spread_multipliers: list[float], - strike_fractions: list[float], - ttm: float, -) -> None: - previous_forward = Decimal(str(round(theoretical_forward * anchor, 4))) - n = min(len(mid_fractions), len(spread_multipliers), len(strike_fractions)) - forwards = [ - make_implied_market( - theoretical_forward, - mid_fractions[i], - spread_multipliers[i], - strike_fractions[i], - ) - for i in range(n) - ] - result = ImpliedFwdPrice.aggregate( - forwards, ttm=ttm, previous_forward=previous_forward - ) - assert result is not None - assert result.is_valid() - mids = [float(f.mid) for f in forwards if f.is_valid()] - assert min(mids) - 1e-4 <= float(result.mid) <= max(mids) + 1e-4 - - -def test_aggregate_implied_tighter_than_default_uses_implied() -> None: - # implied forwards (5 bp) are tighter than the default (20 bp) - # the default is not included in the candidate pool, result comes from implied - forwards = [make_implied(100, 5), make_implied(100, 5)] - default = make_fwd(100, 20) - result = ImpliedFwdPrice.aggregate(forwards, ttm=1.0, default=default) - assert result is not None - assert result is not default - assert float(result.mid) == pytest.approx(100.0, rel=1e-3) - - -def test_aggregate_previous_forward_pulls_result_toward_anchor() -> None: - # two forwards: one at 90 (the anchor), one at 110, same tight spread - # without previous_forward: result ≈ 100 (equal weights) - # with previous_forward=90: forward at 90 gets proximity_weight=1, - # forward at 110 is penalised → result pulled below 100 - fwd_at_anchor = make_implied(90, 5) - fwd_above = make_implied(110, 5) - previous_forward = Decimal("90") - - result_without = ImpliedFwdPrice.aggregate([fwd_at_anchor, fwd_above], ttm=1.0) - result_with = ImpliedFwdPrice.aggregate( - [fwd_at_anchor, fwd_above], ttm=1.0, previous_forward=previous_forward - ) - assert result_without is not None - assert result_with is not None - assert float(result_with.mid) < float(result_without.mid) - - -def test_aggregate_outlier_with_wide_spread_does_not_move_result() -> None: - # three tight forwards near 100, one outlier far away with enormous spread - tight = [make_implied(100, 5) for _ in range(3)] - outlier = make_implied(200, 500) - result_without_outlier = ImpliedFwdPrice.aggregate(tight, ttm=1.0) - result_with_outlier = ImpliedFwdPrice.aggregate(tight + [outlier], ttm=1.0) - assert result_without_outlier is not None - assert result_with_outlier is not None - assert ( - abs(float(result_with_outlier.mid) - float(result_without_outlier.mid)) < 0.01 - ) diff --git a/quantflow_tests/test_non_inverse_surface.py b/quantflow_tests/test_non_inverse_surface.py index 309ba98..6689b09 100644 --- a/quantflow_tests/test_non_inverse_surface.py +++ b/quantflow_tests/test_non_inverse_surface.py @@ -17,6 +17,7 @@ from quantflow.options.bs import black_price from quantflow.options.inputs import DefaultVolSecurity, OptionType from quantflow.options.surface import VolSurfaceLoader +from quantflow.rates.yield_curve import NoDiscount REF_DATE = datetime(2026, 1, 1, tzinfo=timezone.utc) MATURITY = datetime(2026, 7, 2, tzinfo=timezone.utc) # roughly 0.5y @@ -34,7 +35,11 @@ def _black_mid_usd(strike: float, call_put: int, ttm: float) -> Decimal: def _build_loader(ttm: float) -> VolSurfaceLoader: - loader = VolSurfaceLoader(asset="TEST") + loader = VolSurfaceLoader( + asset="TEST", + quote_curve=NoDiscount(ref_date=REF_DATE), + asset_curve=NoDiscount(ref_date=REF_DATE), + ) loader.add_spot( DefaultVolSecurity.spot(), bid=Decimal(str(FORWARD)), @@ -61,7 +66,7 @@ def _build_loader(ttm: float) -> VolSurfaceLoader: def test_loader_recovers_forward_via_parity() -> None: """With matched call/put prices the implied forward equals the true forward.""" loader = _build_loader(ttm=0.5) - surface = loader.surface(ref_date=REF_DATE) + surface = loader.surface() cross = surface.maturities[0] assert float(cross.forward.mid) == pytest.approx(FORWARD, rel=1e-6) @@ -69,12 +74,12 @@ def test_loader_recovers_forward_via_parity() -> None: def test_bs_recovers_input_volatility() -> None: """`bs()` inverts the synthetic non-inverse prices back to the input sigma.""" loader = _build_loader(ttm=0.5) - surface = loader.surface(ref_date=REF_DATE) + surface = loader.surface() ttm = surface.maturities[0].ttm(surface.ref_date) # rebuild prices at the actual ttm so the inversion is not biased by the # slight day-count drift from our nominal 0.5y target. loader = _build_loader(ttm=ttm) - surface = loader.surface(ref_date=REF_DATE) + surface = loader.surface() surface.bs() options = list(surface.option_prices(converged=True)) assert options, "expected converged options on the synthetic surface" @@ -85,10 +90,10 @@ def test_bs_recovers_input_volatility() -> None: def test_non_inverse_price_in_forward_space_matches_black() -> None: """`price_in_forward_space` is the Black forward-space price.""" loader = _build_loader(ttm=0.5) - surface = loader.surface(ref_date=REF_DATE) + surface = loader.surface() ttm = surface.maturities[0].ttm(surface.ref_date) loader = _build_loader(ttm=ttm) - surface = loader.surface(ref_date=REF_DATE) + surface = loader.surface() for option in surface.option_prices(): log_strike = float(option.log_strike) call_put = 1 if option.option_type.is_call() else -1