diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7ec3f14..2727217 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -33,6 +33,7 @@ applyTo: '/**' * 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)`). +* 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/` ## Pydantic models diff --git a/docs/api/data/index.md b/docs/api/data/index.md index 6037237..db92556 100644 --- a/docs/api/data/index.md +++ b/docs/api/data/index.md @@ -19,6 +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 | ## Usage diff --git a/docs/api/data/yahoo.md b/docs/api/data/yahoo.md new file mode 100644 index 0000000..4b8a9fc --- /dev/null +++ b/docs/api/data/yahoo.md @@ -0,0 +1,21 @@ +# Yahoo + +Fetch equity option chains from [Yahoo Finance](https://finance.yahoo.com/). + +The client is intentionally minimal: it fetches a full option chain via the +public `v7/finance/options` endpoint and exposes a helper to build a +[VolSurfaceLoader][quantflow.options.surface.VolSurfaceLoader] from it. + +You can import the module via + +```python +from quantflow.data.yahoo import Yahoo +``` + +## Authentication + +Yahoo Finance requires a session cookie and a `crumb` token for the options +endpoint. The client fetches both on the first request and caches the crumb +for the lifetime of the instance. + +::: quantflow.data.yahoo.Yahoo diff --git a/docs/examples/spx_vol_surface.py b/docs/examples/spx_vol_surface.py new file mode 100644 index 0000000..170708d --- /dev/null +++ b/docs/examples/spx_vol_surface.py @@ -0,0 +1,60 @@ +import gzip +import json +from pathlib import Path + +from docs.examples._utils import assets_path, print_model +from quantflow.data.yahoo import Yahoo +from quantflow.options.calibration import BNS2Calibration +from quantflow.options.calibration.base import ResidualKind +from quantflow.options.pricer import OptionPricer, OptionPricingMethod +from quantflow.sp.bns import BNS, BNS2 + +FIXTURE = ( + Path(__file__).resolve().parents[2] + / "quantflow_tests" + / "fixtures" + / "yahoo_spx.json.gz" +) + +chain = json.loads(gzip.decompress(FIXTURE.read_bytes())) +loader = Yahoo.loader_from_chain(chain, exclude_volume=1) +surface = loader.surface() +surface.bs() +surface.disable_outliers() + +fig = surface.plot3d() +fig.update_traces(marker=dict(size=3)) +fig.update_layout( + title="SPX implied volatility surface", + scene=dict( + xaxis_title="moneyness", + yaxis_title="time to maturity (log)", + zaxis_title="implied volatility", + yaxis=dict(type="log"), + camera=dict(eye=dict(x=0.6, y=-2.2, z=0.8)), + ), +) +fig.write_image(assets_path("spx_vol_surface.png"), width=1200, height=800) + +# Calibrate a two-factor BNS model to the SPX surface. A fast factor absorbs +# the steep short-dated equity skew; a slow factor anchors the long end. +pricer = OptionPricer( + model=BNS2( + bns1=BNS.create(vol=0.2, kappa=20.0, decay=10.0, rho=-0.6), + bns2=BNS.create(vol=0.2, kappa=0.3, decay=10.0, rho=-0.3), + weight=0.5, + ), + method=OptionPricingMethod.COS, +) +calibration: BNS2Calibration[BNS2] = BNS2Calibration( + pricer=pricer, + vol_surface=surface, + residual_kind=ResidualKind.IV, +) +result = calibration.fit() +print(result.message) +print_model(calibration.model) + +smile = calibration.plot_maturities(max_moneyness=0.5, support=101) +smile.update_layout(title="SPX BNS2 Calibrated Smiles") +smile.write_image(assets_path("spx_vol_surface_bns2.png"), width=1200) diff --git a/docs/glossary.md b/docs/glossary.md index e0dc255..04880f3 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -71,6 +71,39 @@ condition holds. The `feller_enforce` flag (default `True`) that imposes this as a hard inequality constraint during optimisation. +## Forward Space + +Forward space is the unit-free convention in which option prices are +normalised by the forward price. + +For a call $C$ and put $P$ with strike $K$, maturity $T$, and forward $F$, +the forward-space prices are + +\begin{equation} + c = \frac{C}{F}, \qquad p = \frac{P}{F} +\end{equation} + +Forward-space prices are dimensionless and depend only on the +[log-strike](#log-strike) $k = \log(K/F)$, the implied volatility, +and the time to maturity. They are the natural output of Fourier-based +pricers and of [Black pricing](api/options/black.md). + +The conversion to quote-currency prices is a single multiplication by $F$: + +\begin{equation} + C = c\, F, \qquad P = p\, F +\end{equation} + +Quantflow uses forward space everywhere downstream of the input layer. +The `inverse` flag on [OptionPrice][quantflow.options.surface.OptionPrice] +only controls how the *input* `price` field is stored: for inverse +options (option premium paid in the underlying) it already is in forward +space; for non-inverse options (premium paid in the quote currency) it +is the absolute quote-currency price and must be divided by $F$ to enter +forward space. The +[price_in_forward_space][quantflow.options.surface.OptionPrice.price_in_forward_space] +property handles both cases uniformly. + ## Hurst Exponent The Hurst exponent is a measure of the long-term memory of time series. The Hurst exponent is a measure of the relative tendency of a time series either to regress strongly to the mean or to cluster in a direction. diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md index 4241fbf..0b129bb 100644 --- a/docs/tutorials/index.md +++ b/docs/tutorials/index.md @@ -6,4 +6,5 @@ Step-by-step guides for common quantflow workflows. |---|---| | [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 | +| [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/spx_vol_surface.md b/docs/tutorials/spx_vol_surface.md new file mode 100644 index 0000000..3619fb2 --- /dev/null +++ b/docs/tutorials/spx_vol_surface.md @@ -0,0 +1,75 @@ +# SPX Volatility Surface + +Build an implied volatility surface for the S&P 500 index from a Yahoo Finance +option chain, then calibrate a two-factor BNS model to it. + +The [Yahoo][quantflow.data.yahoo.Yahoo] client fetches the full chain for a +ticker. To keep this tutorial offline and reproducible we load a snapshot from +a gzipped JSON fixture, but the code is identical to what you would run +against the live endpoint. + +## Loading the chain + +[Yahoo.loader_from_chain][quantflow.data.yahoo.Yahoo.loader_from_chain] turns +the raw chain dictionary into a +[VolSurfaceLoader][quantflow.options.surface.VolSurfaceLoader]. SPX options +are non-inverse (quoted in USD) and Yahoo does not provide forwards, so each +maturity's forward is recovered from put-call parity inside the loader. + +Once the loader has the data, [surface()][quantflow.options.surface.GenericVolSurfaceLoader.surface] +builds the [VolSurface][quantflow.options.surface.VolSurface], +[bs()][quantflow.options.surface.VolSurface.bs] inverts each bid and ask +through Black-Scholes, and +[disable_outliers()][quantflow.options.surface.VolSurface.disable_outliers] +drops strikes with unrealistic implied vols. + +## 3D surface + +[plot3d()][quantflow.options.surface.VolSurface.plot3d] renders the +converged implied vols against moneyness and time to maturity. + +[![SPX implied volatility surface](../assets/examples/spx_vol_surface.png)](../assets/examples/spx_vol_surface.png){target="_blank"} + +## BNS2 calibration + +A single-factor diffusive Heston struggles on SPX because the short-dated +skew is too steep to absorb with a single mean-reversion timescale. +[BNS2][quantflow.sp.bns.BNS2] adds a second Gamma-OU variance factor and +injects jumps directly into the variance process, with the leverage parameter +mirroring those jumps into the log-price. + +[BNS2Calibration][quantflow.options.calibration.bns.BNS2Calibration] fits +nine parameters with both factors sharing the same Gamma stationary marginal, +following the BNS superposition-of-OU construction. See the +[BNS tutorial](bns_calibration.md) for the full parameterisation and the +rationale behind tying $(\theta, \beta)$. + +The initial parameters seed a fast factor ($\kappa = 20$) and a slow factor +($\kappa = 0.3$). Both leverages start negative to reflect the persistent +equity-style downside skew across the term structure. Residuals are scored in +implied-vol space ([ResidualKind.IV][quantflow.options.calibration.base.ResidualKind]) +to weight the wings comparably to the ATM region. + +### Calibrated parameters + +--8<-- "docs/examples/output/spx_vol_surface.out" + +[![SPX BNS2 calibrated smile](../assets/examples/spx_vol_surface_bns2.png)](../assets/examples/spx_vol_surface_bns2.png){target="_blank"} + +The weight collapses almost entirely onto the fast factor, so $v_0$ for bns1 +sits close to the ATM variance read off the 3D surface and bns2 contributes +only marginally to the variance level. The slow factor instead carries the +stronger negative leverage ($\rho \approx -0.84$ against the fast factor's +$\rho \approx -0.43$): its low $\kappa$ keeps jumps persistent in the +log-price, which is what shapes the long-dated downside skew. Both factors +share the same BDLP intensity and jump decay by construction. + +The remaining short-maturity gap is structural to BNS, as discussed in the +[BNS tutorial](bns_calibration.md): jumps live in the variance process, so +the log-price wings are bounded by the variance jumps scaled by $|\rho_i|$. + +## Code + +```python +--8<-- "docs/examples/spx_vol_surface.py" +``` diff --git a/docs/tutorials/volatility_surface.md b/docs/tutorials/volatility_surface.md index 31ef802..b9eb2c9 100644 --- a/docs/tutorials/volatility_surface.md +++ b/docs/tutorials/volatility_surface.md @@ -248,4 +248,3 @@ The calibrated parameter vector for the jump-diffusion model is: | `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/mkdocs.yml b/mkdocs.yml index 16ed196..564efbe 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Federal Reserve: api/data/fed.md - Financial Modeling Prep: api/data/fmp.md - FRED: api/data/fred.md + - Yahoo: api/data/yahoo.md - Options: - api/options/index.md - Black-Scholes: api/options/black.md @@ -112,6 +113,7 @@ nav: - CIR Process: tutorials/cir.md - Option Pricing: tutorials/option_pricing.md - Pricing Method Comparison: tutorials/pricing_method_comparison.md + - SPX Volatility Surface: tutorials/spx_vol_surface.md - Volatility Surface: tutorials/volatility_surface.md - Theory: - theory/index.md diff --git a/quantflow/data/yahoo.py b/quantflow/data/yahoo.py new file mode 100644 index 0000000..302d9a0 --- /dev/null +++ b/quantflow/data/yahoo.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import gzip +import json +from dataclasses import dataclass, field +from datetime import timezone +from pathlib import Path + +import pandas as pd +from fluid.utils.http_client import HttpResponse, HttpxClient, ResponseType +from typing_extensions import Annotated, Doc + +from quantflow.options.inputs import DefaultVolSecurity, OptionType +from quantflow.options.surface import VolSurfaceLoader +from quantflow.utils.numbers import to_decimal + + +@dataclass +class Yahoo(HttpxClient): + """Yahoo Finance API client + + Minimal client for fetching option chains used to build volatility surfaces. + + ## Example + + ```python + from quantflow.data.yahoo import Yahoo + + async with Yahoo() as yahoo: + loader = await yahoo.volatility_surface_loader("AAPL") + surface = loader.surface() + ``` + """ + + url: str = "https://query2.finance.yahoo.com/v7/finance" + content_type: str = ( + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif," + "image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" + ) + default_headers: dict[str, str] = field( + default_factory=lambda: { + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" + ) + } + ) + _crumb: str | None = None + + async def option_chain( + self, + symbol: Annotated[str, Doc("Underlying ticker symbol")], + ) -> dict: # pragma: no cover + """Return the full option chain for `symbol`""" + params = dict(getAllData="true", crumb=await self._get_crumb()) + data = await self.get(f"{self.url}/options/{symbol}", params=params) + return data["optionChain"]["result"][0] + + async def volatility_surface_loader( + self, + symbol: Annotated[str, Doc("Underlying ticker symbol")], + *, + exclude_volume: Annotated[ + int | None, Doc("Drop contracts with volume at or below this threshold") + ] = None, + exclude_open_interest: Annotated[ + int | None, + Doc("Drop contracts with open interest at or below this threshold"), + ] = None, + ) -> VolSurfaceLoader: + """Build a [VolSurfaceLoader][quantflow.options.surface.VolSurfaceLoader] + by fetching the option chain for `symbol` and passing it to + [loader_from_chain][quantflow.data.yahoo.Yahoo.loader_from_chain].""" + return self.loader_from_chain( + await self.option_chain(symbol), + exclude_volume=exclude_volume, + exclude_open_interest=exclude_open_interest, + ) + + @classmethod + def loader_from_chain( + cls, + chain: Annotated[dict, Doc("Yahoo option chain payload")], + *, + exclude_volume: Annotated[ + int | None, Doc("Drop contracts with volume at or below this threshold") + ] = None, + exclude_open_interest: Annotated[ + int | None, + Doc("Drop contracts with open interest at or below this threshold"), + ] = None, + ) -> VolSurfaceLoader: + """Build a [VolSurfaceLoader][quantflow.options.surface.VolSurfaceLoader] + from a Yahoo option chain dictionary. + + US equity options are non-inverse: prices are in the quote currency and + the spot is taken from the underlying quote. Forwards are not provided + by Yahoo, so they are recovered from put-call parity by the loader. + """ + symbol = chain.get("underlyingSymbol", "") + 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 = chain.get("quote") or {} + bid = quote.get("bid") or quote.get("regularMarketPrice") + ask = quote.get("ask") or quote.get("regularMarketPrice") + if bid and ask: + loader.add_spot( + DefaultVolSecurity.spot(), + bid=to_decimal(bid), + ask=to_decimal(ask), + ) + for expiry in chain.get("options", []): + maturity = ( + pd.to_datetime(expiry["expirationDate"], unit="s", utc=True) + .to_pydatetime() + .replace(hour=20, tzinfo=timezone.utc) + ) + for option_type, contracts in ( + (OptionType.call, expiry.get("calls", [])), + (OptionType.put, expiry.get("puts", [])), + ): + for c in contracts: + bid_ = c.get("bid") + ask_ = c.get("ask") + if not bid_ or not ask_: + continue + loader.add_option( + DefaultVolSecurity.option(), + strike=to_decimal(c["strike"]), + maturity=maturity, + option_type=option_type, + bid=to_decimal(bid_), + ask=to_decimal(ask_), + open_interest=to_decimal(c.get("openInterest") or 0), + volume=to_decimal(c.get("volume") or 0), + inverse=False, + ) + return loader + + async def save_fixture( + self, + symbol: Annotated[str, Doc("Underlying ticker symbol")], + path: Annotated[str | Path, Doc("File path where to save the fixture")], + ) -> Path: + """Fetch the option chain for `symbol` and save it as a JSON fixture. + + Only the fields read by + [volatility_surface_loader] + [quantflow.data.yahoo.Yahoo.volatility_surface_loader] + are kept, so the fixture stays small enough to commit. + + If `path` ends with `.gz`, the output is gzipped. + """ + chain = await self.option_chain(symbol) + contract_keys = ("strike", "bid", "ask", "openInterest", "volume") + quote_keys = ("bid", "ask", "regularMarketPrice") + quote = chain.get("quote") or {} + stripped = { + "underlyingSymbol": chain.get("underlyingSymbol", symbol), + "quote": {k: quote[k] for k in quote_keys if k in quote}, + "options": [ + { + "expirationDate": expiry["expirationDate"], + "calls": [ + {k: c[k] for k in contract_keys if k in c} + for c in expiry.get("calls", []) + ], + "puts": [ + {k: c[k] for k in contract_keys if k in c} + for c in expiry.get("puts", []) + ], + } + for expiry in chain.get("options", []) + ], + } + out = Path(path) + payload = json.dumps(stripped, indent=2).encode() + if out.suffix == ".gz": + out.write_bytes(gzip.compress(payload)) + else: + out.write_bytes(payload) + return out + + async def _get_crumb(self) -> str: # pragma: no cover + if self._crumb is not None: + return self._crumb + text = await self.get("https://query2.finance.yahoo.com/v1/test/getcrumb") + self._crumb = text.strip() + return self._crumb + + @classmethod + async def response_data( + cls, response: HttpResponse + ) -> ResponseType: # pragma: no cover + if ( + "text/plain" in response.headers["content-type"] + or "text/html" in response.headers["content-type"] + ): + return await response.text() + return await response.json() diff --git a/quantflow/options/pricer.py b/quantflow/options/pricer.py index 3fa2188..d36e818 100644 --- a/quantflow/options/pricer.py +++ b/quantflow/options/pricer.py @@ -24,16 +24,23 @@ TTM_FACTOR = 10000 -def get_intrinsic_value(log_strike: FloatArray) -> FloatArray: - return 1.0 - np.exp(np.clip(log_strike, None, 0)) - - class ModelOptionPrice(BaseModel, frozen=True): - """Represents the model price and sensitivities of an option for a given strike, + r"""Model price and sensitivities of an option for a given strike, forward and time to maturity. - It provides black price and sensitivies too for comparison and analysis - of the model price. + The [price][.price] field is always in + [forward space](../../glossary.md#forward-space), regardless of whether + the underlying market quotes options in the quote currency (e.g. SPX, + USD) or in the underlying (inverse options, e.g. BTC). + + \begin{equation} + c = \frac{C}{F} + \end{equation} + + Use [price_in_quote][.price_in_quote] to recover the quote-currency + premium $C = c\,F$. + + Also exposes Black price and sensitivities for comparison. """ strike: DecimalNumber = Field(description="Strike price of the option") @@ -42,10 +49,34 @@ class ModelOptionPrice(BaseModel, frozen=True): log_strike: float = Field(description="Log strike over forward, i.e. log(K/F)") moneyness: float = Field(description="Moneyness") ttm: float = Field(default=0, description="Time to maturity in years") - price: float = Field(description=("Price in forward space")) + price: float = Field( + description=( + "Option price in" + " [forward space](../../glossary.md#forward-space)." + " Multiply by [forward][.forward]" + " (or read [price_in_quote][.price_in_quote])" + " to obtain the quote-currency premium." + ) + ) delta: float = Field(description="Model delta of the option") gamma: float = Field(description="Model gamma of the option") + @property + def price_in_quote(self) -> float: + """Premium in the quote currency: forward-space price times forward. + + For inverse markets (BTC) the conventional premium is in the + underlying and equals [price][.price] directly; callers should pick + the convention that matches their downstream consumer. + """ + return self.price * float(self.forward) + + @property + def parity(self) -> float: + """Put call parity value for the option, i.e. the difference between call + and put price for the same strike and maturity""" + return 1.0 - float(np.exp(self.log_strike)) + @computed_field # type: ignore [prop-decorator] @property def black(self) -> BlackSensitivities: @@ -72,9 +103,9 @@ def intrinsic_value(self) -> float: is positive, i.e. when the strike is above the forward price. """ if self.option_type == OptionType.call: - return max(0.0, 1.0 - np.exp(self.log_strike)) + return max(0.0, self.parity) else: - return max(0.0, np.exp(self.log_strike) - 1.0) + return max(0.0, -self.parity) def as_option_type( self, @@ -83,18 +114,22 @@ def as_option_type( Doc("Type of the option, call or put"), ], ) -> Self: - """Convert the option price to the given option type""" + """Convert the option price to the given option type via put-call parity.""" if self.option_type == option_type: return self + if self.option_type == OptionType.call: + new_price = self.price - self.parity + new_delta = self.delta - 1.0 else: - return self.model_copy( - update=dict( - option_type=option_type, - price=self.price - self.intrinsic_value, - delta=self.delta - 1.0, - gamma=self.gamma, - ) + new_price = self.price + self.parity + new_delta = self.delta + 1.0 + return self.model_copy( + update=dict( + option_type=option_type, + price=new_price, + delta=new_delta, ) + ) class MaturityPricer(BaseModel, arbitrary_types_allowed=True): diff --git a/quantflow/options/surface.py b/quantflow/options/surface.py index eca93eb..c6f0293 100644 --- a/quantflow/options/surface.py +++ b/quantflow/options/surface.py @@ -657,12 +657,9 @@ def options_iter( """ match select: case OptionSelection.best: - if self.call: - if self.call.is_in_the_money(forward) and self.put: - yield self.put - else: - yield self.call - elif self.put: + if self.call and not self.call.is_in_the_money(forward): + yield self.call + elif self.put and not self.put.is_in_the_money(forward): yield self.put case OptionSelection.call: if self.call: diff --git a/quantflow_tests/fixtures/yahoo_spx.json.gz b/quantflow_tests/fixtures/yahoo_spx.json.gz new file mode 100644 index 0000000..2789fe6 Binary files /dev/null and b/quantflow_tests/fixtures/yahoo_spx.json.gz differ diff --git a/quantflow_tests/test_data_yahoo.py b/quantflow_tests/test_data_yahoo.py new file mode 100644 index 0000000..1c66772 --- /dev/null +++ b/quantflow_tests/test_data_yahoo.py @@ -0,0 +1,82 @@ +"""Tests for the Yahoo volatility surface loader.""" + +from __future__ import annotations + +import gzip +import json +from pathlib import Path +from typing import AsyncIterator +from unittest.mock import AsyncMock, patch + +import pytest + +from quantflow.data.yahoo import Yahoo + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def spx_chain() -> dict: + return json.loads(gzip.decompress((FIXTURES / "yahoo_spx.json.gz").read_bytes())) + + +@pytest.fixture +async def yahoo_cli(spx_chain: dict) -> AsyncIterator[Yahoo]: + with patch.object(Yahoo, "option_chain", AsyncMock(return_value=spx_chain)): + async with Yahoo() as cli: + yield cli + + +async def test_loader_builds_surface(yahoo_cli: Yahoo, spx_chain: dict) -> None: + """Surface has one cross section per expiration in the fixture.""" + loader = await yahoo_cli.volatility_surface_loader("^SPX") + surface = loader.surface() + assert surface.asset == "^SPX" + assert len(surface.maturities) == len(spx_chain["options"]) + assert surface.spot.mid > 0 + + +async def test_loader_options_are_non_inverse(yahoo_cli: Yahoo) -> None: + """Yahoo equity options are quoted in USD, so the loader marks them + non-inverse.""" + loader = await yahoo_cli.volatility_surface_loader("^SPX") + surface = loader.surface() + options = list(surface.option_prices()) + assert options + assert all(not o.meta.inverse for o in options) + + +async def test_loader_skips_zero_bid_ask(yahoo_cli: Yahoo, spx_chain: dict) -> None: + """Contracts with zero or missing bid/ask are dropped.""" + raw_contracts = sum( + len(e.get("calls", [])) + len(e.get("puts", [])) for e in spx_chain["options"] + ) + loader = await yahoo_cli.volatility_surface_loader("^SPX") + surface = loader.surface() + loaded = sum( + (1 if s.call else 0) + (1 if s.put else 0) + for m in surface.maturities + for s in m.strikes + ) + assert loaded < raw_contracts + + +async def test_save_fixture_roundtrip( + yahoo_cli: Yahoo, spx_chain: dict, tmp_path: Path +) -> None: + """`save_fixture` keeps only the fields needed by the loader and reloads.""" + plain = await yahoo_cli.save_fixture("^SPX", tmp_path / "spx.json") + payload = json.loads(plain.read_bytes()) + assert payload["underlyingSymbol"] == spx_chain["underlyingSymbol"] + assert set(payload["quote"]).issubset({"bid", "ask", "regularMarketPrice"}) + assert len(payload["options"]) == len(spx_chain["options"]) + contract = payload["options"][0]["calls"][0] + assert set(contract).issubset({"strike", "bid", "ask", "openInterest", "volume"}) + + gz = await yahoo_cli.save_fixture("^SPX", tmp_path / "spx.json.gz") + assert json.loads(gzip.decompress(gz.read_bytes())) == payload + + loader = Yahoo.loader_from_chain(payload) + surface = loader.surface() + assert surface.asset == "^SPX" + assert len(surface.maturities) == len(spx_chain["options"]) diff --git a/quantflow_tests/test_non_inverse_surface.py b/quantflow_tests/test_non_inverse_surface.py new file mode 100644 index 0000000..309ba98 --- /dev/null +++ b/quantflow_tests/test_non_inverse_surface.py @@ -0,0 +1,96 @@ +"""End-to-end test for the non-inverse (quote-currency) pricing path. + +Builds a synthetic option chain from known Black-Scholes prices, feeds it +through [VolSurfaceLoader][quantflow.options.surface.VolSurfaceLoader] with +`inverse=False`, then checks that the loader recovers the forward via +put-call parity and that `bs()` inverts back to the input volatility. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +import numpy as np +import pytest + +from quantflow.options.bs import black_price +from quantflow.options.inputs import DefaultVolSecurity, OptionType +from quantflow.options.surface import VolSurfaceLoader + +REF_DATE = datetime(2026, 1, 1, tzinfo=timezone.utc) +MATURITY = datetime(2026, 7, 2, tzinfo=timezone.utc) # roughly 0.5y +FORWARD = 100.0 +SIGMA = 0.25 +STRIKES = (80.0, 90.0, 100.0, 110.0, 120.0) +HALF_SPREAD = Decimal("0.005") # tiny tick around the mid, in quote currency + + +def _black_mid_usd(strike: float, call_put: int, ttm: float) -> Decimal: + """Black price in quote currency: forward-space price times the forward.""" + log_strike = float(np.log(strike / FORWARD)) + pfs = float(black_price(np.asarray(log_strike), SIGMA, ttm, call_put).sum()) + return Decimal(str(pfs * FORWARD)) + + +def _build_loader(ttm: float) -> VolSurfaceLoader: + loader = VolSurfaceLoader(asset="TEST") + loader.add_spot( + DefaultVolSecurity.spot(), + bid=Decimal(str(FORWARD)), + ask=Decimal(str(FORWARD)), + ) + for strike in STRIKES: + for option_type, call_put in ( + (OptionType.call, 1), + (OptionType.put, -1), + ): + mid = _black_mid_usd(strike, call_put, ttm) + loader.add_option( + DefaultVolSecurity.option(), + strike=Decimal(str(strike)), + maturity=MATURITY, + option_type=option_type, + bid=mid - HALF_SPREAD, + ask=mid + HALF_SPREAD, + inverse=False, + ) + return loader + + +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) + cross = surface.maturities[0] + assert float(cross.forward.mid) == pytest.approx(FORWARD, rel=1e-6) + + +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) + 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.bs() + options = list(surface.option_prices(converged=True)) + assert options, "expected converged options on the synthetic surface" + for option in options: + assert option.implied_vol == pytest.approx(SIGMA, abs=5e-4) + + +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) + ttm = surface.maturities[0].ttm(surface.ref_date) + loader = _build_loader(ttm=ttm) + surface = loader.surface(ref_date=REF_DATE) + for option in surface.option_prices(): + log_strike = float(option.log_strike) + call_put = 1 if option.option_type.is_call() else -1 + expected = float(black_price(np.asarray(log_strike), SIGMA, ttm, call_put)) + assert float(option.price_in_forward_space) == pytest.approx(expected, abs=2e-4) diff --git a/quantflow_tests/test_options_pricer.py b/quantflow_tests/test_options_pricer.py index 08e2de6..1da2719 100644 --- a/quantflow_tests/test_options_pricer.py +++ b/quantflow_tests/test_options_pricer.py @@ -3,7 +3,7 @@ import pytest from quantflow.options.pricer import OptionPricer, OptionType -from quantflow.sp.heston import HestonJ +from quantflow.sp.heston import Heston, HestonJ from quantflow.sp.wiener import WienerProcess from quantflow.utils.distributions import DoubleExponential from quantflow_tests.utils import has_plotly @@ -51,3 +51,68 @@ def test_wiener_matches_black(strike: int, forward: int) -> None: assert float(black.iv) == pytest.approx(sigma, rel=1e-3) assert price.delta == pytest.approx(float(black.delta), rel=1e-3) assert price.gamma == pytest.approx(float(black.gamma), rel=5e-3) + + +@pytest.mark.parametrize("strike,forward", [(80, 100), (100, 100), (120, 100)]) +def test_put_call_parity_across_strikes(strike: int, forward: int) -> None: + """`c - p = 1 - K/F` in forward space, on both sides of the forward. + + Regression for `as_option_type` previously using the clipped intrinsic, + which collapsed the put price onto the call whenever the call was OTM. + """ + pricer = OptionPricer(model=Heston.create(vol=0.2, kappa=2.0, sigma=0.5, rho=-0.5)) + call = pricer.price( + option_type=OptionType.call, strike=strike, forward=forward, ttm=0.5 + ) + put = pricer.price( + option_type=OptionType.put, strike=strike, forward=forward, ttm=0.5 + ) + assert call.price - put.price == pytest.approx(1.0 - strike / forward, abs=1e-9) + assert call.delta - put.delta == pytest.approx(1.0, abs=1e-9) + assert call.gamma == pytest.approx(put.gamma, abs=1e-9) + + +@pytest.mark.parametrize( + "option_type,strike,forward,expected", + [ + # calls: payoff max(F - K, 0) / F = max(0, 1 - K/F) + (OptionType.call, 80, 100, 0.2), # ITM + (OptionType.call, 100, 100, 0.0), # ATM + (OptionType.call, 120, 100, 0.0), # OTM + # puts: payoff max(K - F, 0) / F = max(0, K/F - 1) + (OptionType.put, 80, 100, 0.0), # OTM + (OptionType.put, 100, 100, 0.0), # ATM + (OptionType.put, 120, 100, 0.2), # ITM + ], +) +def test_intrinsic_value( + option_type: OptionType, strike: int, forward: int, expected: float +) -> None: + """`intrinsic_value` is the forward-space payoff if exercised immediately.""" + pricer = OptionPricer(model=Heston.create(vol=0.2, kappa=2.0, sigma=0.5, rho=-0.5)) + price = pricer.price( + option_type=option_type, strike=strike, forward=forward, ttm=0.5 + ) + assert price.intrinsic_value == pytest.approx(expected, abs=1e-12) + + +def test_price_in_quote_scales_with_forward() -> None: + """`price_in_quote` is the forward-space price multiplied by the forward.""" + pricer = OptionPricer(model=Heston.create(vol=0.2, kappa=2.0, sigma=0.5, rho=-0.5)) + price = pricer.price( + option_type=OptionType.call, strike=5500, forward=5000, ttm=0.5 + ) + assert price.price_in_quote == pytest.approx(price.price * 5000.0, abs=1e-9) + + +@pytest.mark.parametrize("strike,forward", [(80, 100), (100, 100), (120, 100)]) +def test_as_option_type_roundtrip(strike: int, forward: int) -> None: + """`call.as_option_type(put).as_option_type(call)` recovers the original.""" + pricer = OptionPricer(model=Heston.create(vol=0.2, kappa=2.0, sigma=0.5, rho=-0.5)) + call = pricer.price( + option_type=OptionType.call, strike=strike, forward=forward, ttm=0.5 + ) + roundtrip = call.as_option_type(OptionType.put).as_option_type(OptionType.call) + assert roundtrip.price == pytest.approx(call.price, abs=1e-12) + assert roundtrip.delta == pytest.approx(call.delta, abs=1e-12) + assert roundtrip.gamma == pytest.approx(call.gamma, abs=1e-12)