diff --git a/app/api/docs/volatility_surface.md b/app/api/docs/volatility_surface.md index f64f0a4..54aee2a 100644 --- a/app/api/docs/volatility_surface.md +++ b/app/api/docs/volatility_surface.md @@ -19,12 +19,12 @@ Two discount curves are then derived from the calibrated forwards: - **quote_curve**: discount curve for the numeraire (USD for equity, crypto for inverse). - **asset_curve**: discount curve for the underlying asset. -The curve model for each can be selected with the `quote_curve` and -`asset_curve` query parameters. Parametric models (CIR, Nelson-Siegel, -Vasicek) fit a small number of parameters, interpolated curves pass through -the observations without any model, and `no-discount` fixes the curve to -zero rates. The curves provide discounting and rates only; the pricing -forwards come from parity regardless of the curve selection. +The asset curve is always an interpolated curve fitted through the discount +factors implied by the calibrated forwards. The quote curve is fixed at no +discounting for crypto assets, whose inverse options settle without +discounting, and fitted as an interpolated curve for equity assets. The +curves provide discounting and rates only; the pricing forwards come from +parity regardless. The forward curve and per-maturity implied forwards from put-call parity are also included, which are useful for detecting curve arbitrage or funding dislocations. diff --git a/app/api/volatility.py b/app/api/volatility.py index b0c36e2..4f732d9 100644 --- a/app/api/volatility.py +++ b/app/api/volatility.py @@ -11,15 +11,6 @@ from quantflow.options.inputs import VolSurfaceInputs from quantflow.options.ssvi import SSVI from quantflow.options.surface import OptionInfo, VolSurfaceLoader -from quantflow.rates import ( - CIRCurve, - InterpolatedLinearCurve, - InterpolatedMonotonicCubicCurve, - NelsonSiegelCurve, - NoDiscountCurve, - VasicekCurve, - YieldCurve, -) from .deps import RedisCache, RedisDep from .rates import YieldCurveResponse @@ -30,16 +21,6 @@ YAHOO_ASSETS = {"SPY", "AAPL", "NVDA"} ALL_ASSETS = sorted(DERIBIT_ASSETS) + sorted(YAHOO_ASSETS) -CURVES: dict[str, type[YieldCurve]] = { - "cir": CIRCurve, - "nelson-siegel": NelsonSiegelCurve, - "vasicek": VasicekCurve, - "interpolated-linear": InterpolatedLinearCurve, - "interpolated-cubic": InterpolatedMonotonicCubicCurve, - "no-discount": NoDiscountCurve, -} -CURVE_NAMES = sorted(CURVES) - class ForwardPoint(BaseModel): maturity: str = Field(description="Maturity date") @@ -86,31 +67,13 @@ async def volatility_surface( description="Asset symbol", enum=ALL_ASSETS, ), - quote_curve: str = Query( - "cir", - description=( - "Curve model calibrated from put-call parity for the quote " - "currency discount curve" - ), - enum=CURVE_NAMES, - ), - asset_curve: str = Query( - "nelson-siegel", - description=( - "Curve model calibrated from put-call parity for the asset " - "discount curve" - ), - enum=CURVE_NAMES, - ), ) -> VolSurfaceResponse: cache = RedisCache( redis=redis, Model=VolSurfaceResponse, - key=f"vol_surface:{asset}:{quote_curve}:{asset_curve}", - ) - return await cache.from_cache( - partial(_volatility_surface, asset, CURVES[quote_curve], CURVES[asset_curve]) + key=f"vol_surface:{asset}", ) + return await cache.from_cache(partial(_volatility_surface, asset)) def _curve_response(curve: Any, max_ttm: float) -> YieldCurveResponse: @@ -143,14 +106,10 @@ def _forward_curve_response( return ForwardCurveResponse(ttm=ttm_grid, forward=forward) -async def _volatility_surface( - asset: str, - quote_curve: type[YieldCurve], - asset_curve: type[YieldCurve], -) -> VolSurfaceResponse: +async def _volatility_surface(asset: str) -> VolSurfaceResponse: loader = await _load_surface(asset) parity_forwards = loader.calibrate_forwards() - loader.calibrate_curves(quote_curve=quote_curve, asset_curve=asset_curve) + loader.calibrate_curves() surface = loader.surface() surface.bs() surface.disable_outliers() diff --git a/docs/examples/curve_calibration.py b/docs/examples/curve_calibration.py index e3917ba..d72bbb6 100644 --- a/docs/examples/curve_calibration.py +++ b/docs/examples/curve_calibration.py @@ -269,8 +269,9 @@ def parity_moneyness_panel(fig: go.Figure, cross: VolCrossSection, col: int) -> ) # figure 4: forward term structure, market futures against parity implied forwards -term = loader.implied_forward_term_structure(max_pairs=100) -implied = pd.DataFrame(term, columns=["maturity", "ttm", "forward"]) +parity_forwards = loader.calibrate_forwards(max_pairs=100) +implied = pd.DataFrame(parity_forwards, columns=["maturity", "ttm", "forward"]) +implied["forward"] = implied["forward"].astype(float) futures = pd.DataFrame( dict( ttm=[c.ttm(ref_date) for c in surface.maturities], @@ -319,13 +320,7 @@ def parity_moneyness_panel(fig: go.Figure, cross: VolCrossSection, col: int) -> slope, intercept = np.polyfit(k_pairs, cp_pairs, 1) rows.append(dict(ttm=c.ttm(ref_date), forward=-intercept / slope * spot)) naive = pd.DataFrame(rows) -parity_forwards = loader.calibrate_forwards() -calibrated = pd.DataFrame( - dict( - ttm=[entry[1] for entry in parity_forwards], - forward=[float(entry[2]) for entry in parity_forwards], - ) -) +calibrated = implied[["ttm", "forward"]] fig = go.Figure() for name, symbol, frame in ( ("all pairs", "diamond", naive), diff --git a/docs/tutorials/curve_calibration.md b/docs/tutorials/curve_calibration.md index 6e0912d..34bf48f 100644 --- a/docs/tutorials/curve_calibration.md +++ b/docs/tutorials/curve_calibration.md @@ -201,17 +201,39 @@ lot, in opposite directions. **What the library does.** [calibrate_curves][quantflow.options.surface.GenericVolSurfaceLoader.calibrate_curves] -never fits the two discount factors freely. It calibrates the forwards -first, then estimates a single parameter per maturity, the quote discount -factor, with the forward held fixed -([quote_discount][quantflow.options.parity.PutCallParities.quote_discount]). -The asset discount factor follows from the forward formula: +never fits the two discount factors freely. The forwards are calibrated +first and held fixed. With the forward known, put-call parity collapses to +a single free parameter, the quote discount factor: + +\begin{equation} + C - P = D_q \left(F - K\right) +\end{equation} + +[quote_discount][quantflow.options.parity.PutCallParities.quote_discount] +estimates $D_q$ at each maturity by weighted least squares across the +parity pairs, with the same inverse spread weights used for the forward. +Fixing the forward removes the ill conditioning of the split: the slope is +the only parameter left and the crossing no longer moves. + +For options traded on exchanges that settle without discounting, such as +Deribit, the quote discount factor is not an estimate but a market +convention: $D_q = 1$ at every maturity. In that case the quote curve is a +[NoDiscountCurve][quantflow.rates.no_discount.NoDiscountCurve] and is kept +as given: no fitting is required. + +The asset discount factor is never estimated independently. It follows +from the forward formula: \begin{equation} D_a = D_q \frac{F}{S} \end{equation} -The selected curve models are then fitted to those discount factors: a +The asset curve is therefore always fitted to these discount factors, even +when the quote curve is a no discount curve: the entire forward to spot +basis is attributed to the asset leg, where it encodes the implied funding +rate of the asset. + +The selected curve models are fitted to those discount factors: a parametric model pools all maturities while an interpolated curve passes through them. This guarantees that the curves are consistent with the parity forwards, and since the surface prices off the parity forwards directly, the diff --git a/docs/tutorials/volatility_surface.md b/docs/tutorials/volatility_surface.md index 2309062..fa3447e 100644 --- a/docs/tutorials/volatility_surface.md +++ b/docs/tutorials/volatility_surface.md @@ -148,22 +148,28 @@ F = S \frac{D_a}{D_q} [put_call_parities][quantflow.options.surface.VolCrossSectionLoader.put_call_parities] collects the most liquid pairs at each maturity, ranked by the bid-ask spread of the parity price, and -[implied_forward][quantflow.options.parity.PutCallParities.implied_forward] fits the +[calibrate_forward][quantflow.options.parity.PutCallParities.calibrate_forward] fits the regression and returns the implied forward. ### Discount curve calibration The [calibrate_curves][quantflow.options.surface.GenericVolSurfaceLoader.calibrate_curves] -method fits smooth yield curves to the same put-call parity data across all -maturities. It 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. +method builds the discount curves on top of the calibrated forwards. With the +forward of each maturity held fixed, put-call parity identifies the quote +discount factor $D_q$ as its only remaining parameter, and the asset discount +factor follows from the forward formula $D_a = D_q F / S$. + +- **Quote curve**: pass a [YieldCurve][quantflow.rates.yield_curve.YieldCurve] + type for `quote_curve` to fit it to the per maturity quote discount factors. + Leave it as `None` to keep the current quote curve and treat it as known: + this is the setup for exchanges that settle without discounting, such as + Deribit, where the quote curve is a + [NoDiscountCurve][quantflow.rates.no_discount.NoDiscountCurve] with + $D_q = 1$ at every maturity. +- **Asset curve**: always fitted to the discount factors $D_a = D_q F / S$. It + cannot be a no discount curve, since the parity forwards and the quote curve + define it. + +See the [curve calibration tutorial](curve_calibration.md) for how the +forwards and the discount factor split are estimated. diff --git a/frontend/src/volatility-surface.md b/frontend/src/volatility-surface.md index c05043e..7f04e4a 100644 --- a/frontend/src/volatility-surface.md +++ b/frontend/src/volatility-surface.md @@ -6,7 +6,7 @@ title: Volatility Surface Live implied volatility surface from market options data. Crypto assets (BTC, ETH) use the [Deribit volatility surface loader](https://quantflow.quantmind.com/api/data/deribit/#quantflow.data.deribit.Deribit.volatility_surface_loader); equities (SPY, AAPL, NVDA) use the [Yahoo Finance volatility surface loader](https://quantflow.quantmind.com/api/data/yahoo/#quantflow.data.yahoo.Yahoo.volatility_surface_loader). -The forwards are calibrated from put call parity and price the options directly. The quote and asset discount curves are then derived from the calibrated forwards using the selected curve models: parametric (CIR, Nelson-Siegel, Vasicek), interpolated (no model, the curve passes through the observations), or no discounting at all. +The forwards are calibrated from put call parity and price the options directly. The quote and asset discount curves are then derived from the calibrated forwards: the asset curve is an interpolated curve through the implied discount factors, while the quote curve is kept at no discounting for crypto assets, whose inverse options settle without discounting, and fitted as an interpolated curve for equity assets. ```js import {fetchJson} from "./lib/api.js"; @@ -16,31 +16,16 @@ import * as d3 from "npm:d3"; ``` ```js -const curveOptions = new Map([ - ["CIR", "cir"], - ["Nelson-Siegel", "nelson-siegel"], - ["Vasicek", "vasicek"], - ["Interpolated (linear)", "interpolated-linear"], - ["Interpolated (cubic)", "interpolated-cubic"], - ["No discount", "no-discount"], -]); - const assetInput = Inputs.select(["BTC", "ETH", "SPY", "AAPL", "NVDA"], {label: "Asset", value: "BTC"}); const asset = Generators.input(assetInput); - -const quoteCurveInput = Inputs.select(curveOptions, {label: "Quote curve", value: "cir"}); -const quoteCurveModel = Generators.input(quoteCurveInput); - -const assetCurveInput = Inputs.select(curveOptions, {label: "Asset curve", value: "nelson-siegel"}); -const assetCurveModel = Generators.input(assetCurveInput); ``` ```js -display(html`
${assetInput}${quoteCurveInput}${assetCurveInput}
`); +display(html`
${assetInput}
`); ``` ```js -const data = await fetchJson(`/.api/volatility-surface?asset=${asset}"e_curve=${quoteCurveModel}&asset_curve=${assetCurveModel}`); +const data = await fetchJson(`/.api/volatility-surface?asset=${asset}`); ``` ```js diff --git a/quantflow/data/deribit.py b/quantflow/data/deribit.py index 4463394..43c6a59 100644 --- a/quantflow/data/deribit.py +++ b/quantflow/data/deribit.py @@ -14,7 +14,6 @@ from quantflow.options.inputs import DefaultVolSecurity, OptionType from quantflow.options.surface import VolSurfaceLoader -from quantflow.rates.no_discount import NoDiscountCurve from quantflow.utils.dates import utcnow from quantflow.utils.numbers import ( Number, @@ -209,13 +208,11 @@ def loader_from_book( Useful for rebuilding a loader from recorded data without network access.""" - ref = ref_date or utcnow() loader = VolSurfaceLoader( asset=currency, + ref_date=ref_date or utcnow(), exclude_open_interest=to_decimal_or_none(exclude_open_interest), exclude_volume=to_decimal_or_none(exclude_volume), - quote_curve=NoDiscountCurve(ref_date=ref), - asset_curve=NoDiscountCurve(ref_date=ref), ) instrument_map = {i["instrument_name"]: i for i in instruments} min_tick_size = Decimal("inf") diff --git a/quantflow/data/yahoo.py b/quantflow/data/yahoo.py index 36f54a6..8e91bd3 100644 --- a/quantflow/data/yahoo.py +++ b/quantflow/data/yahoo.py @@ -13,7 +13,7 @@ from quantflow.options.inputs import DefaultVolSecurity, OptionType from quantflow.options.surface import VolSurfaceLoader -from quantflow.rates.no_discount import NoDiscountCurve +from quantflow.rates.interpolated import InterpolatedMonotonicCubicCurve from quantflow.utils.dates import as_utc, utcnow from quantflow.utils.numbers import to_decimal @@ -141,15 +141,14 @@ def loader_from_chain( quote = chain.get("quote") or {} if ref_date is None and (market_time := quote.get("regularMarketTime")): ref_date = datetime.fromtimestamp(market_time, tz=timezone.utc) - ref = ref_date or utcnow() loader = VolSurfaceLoader( + ref_date=ref_date or utcnow(), 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=NoDiscountCurve(ref_date=ref), - asset_curve=NoDiscountCurve(ref_date=ref), + quote_curve=InterpolatedMonotonicCubicCurve(), ) bid = quote.get("bid") or quote.get("regularMarketPrice") ask = quote.get("ask") or quote.get("regularMarketPrice") diff --git a/quantflow/options/parity.py b/quantflow/options/parity.py index 565a4db..842f563 100644 --- a/quantflow/options/parity.py +++ b/quantflow/options/parity.py @@ -5,7 +5,6 @@ import numpy as np from pydantic import BaseModel, Field -from scipy.optimize import lsq_linear from typing_extensions import Annotated, Doc from quantflow.utils.numbers import ZERO, Number, to_decimal @@ -13,13 +12,6 @@ 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 [put-call parity](../../glossary.md#put-call-parity) at a single strike @@ -86,64 +78,6 @@ def regressor(self) -> FloatArray: """ 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, - min_rate_q: float = 0.0, - min_rate_a: float = 0.0, - ) -> DiscountPair | None: - r"""Return the fitted discount factors, or None if the result is invalid. - - Both direct and inverse options satisfy the same normalized equation - - \begin{equation} - y = Da - K \frac{Dq}{S} - \end{equation} - - where y = mid/S for direct and y = mid for inverse. - - 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. - 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)) - else: - 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) - - def implied_forward( - self, - dq: float | None = None, - da: float | None = None, - ) -> float | None: - """Implied forward price from put-call parity regression. - - Fits asset and quote discount factors from the put-call parity data and - returns `spot * Da / Dq`. Returns None if the fit is invalid. - """ - discounts = self.fit_discounts(dq=dq, da=da) - if discounts is None: - return None - return float(self.spot) * discounts.asset_discount / discounts.quote_discount - def weights(self) -> FloatArray: """Inverse bid-ask spread weights for the put-call parity regression. @@ -269,14 +203,13 @@ def _straddle_vol(self, x0: float) -> float: sigma = straddle / (0.7979 * np.sqrt(float(self.ttm))) return float(np.clip(sigma, 0.05, 5.0)) - def plot( - self, - dq: float | None = None, - da: float | None = None, - 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.""" + def plot(self) -> Any: + """Plot the normalized put-call parity data and the fitted regression line. + + The line is built from the calibrated forward + ([calibrate_forward][..calibrate_forward]) and the quote discount factor + estimated with the forward held fixed ([quote_discount][..quote_discount]). + """ from quantflow.utils.plot import check_plotly check_plotly() @@ -284,16 +217,18 @@ def plot( xs = self.regressor() ys = self.regressand() - 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) ) - 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")) + if (forward := self.calibrate_forward()) is not None: + f = forward / float(self.spot) + if (dq := self.quote_discount(f)) is not None: + x_range = np.linspace(xs.min(), xs.max(), 100) + fig.add_trace( + go.Scatter( + x=x_range, y=dq * (f - x_range), 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 90336ae..b733d5a 100644 --- a/quantflow/options/surface.py +++ b/quantflow/options/surface.py @@ -14,6 +14,7 @@ from quantflow.rates import ( AnyYieldCurve, + InterpolatedMonotonicCubicCurve, NoDiscountCurve, Rate, YieldCurve, @@ -447,7 +448,7 @@ class Strike(BaseModel, Generic[S]): ) def put_call_parity(self) -> PutCallParity | None: - """Return a [PutCallParity][quantflow.rates.calibrator.PutCallParity] for this + """Return a [PutCallParity][quantflow.options.parity.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 @@ -761,6 +762,11 @@ class ForwardPricer(BaseModel, Generic[S]): default="", description="Name of the underlying asset", ) + ref_date: datetime = Field( + default_factory=utcnow, + description="Reference date for pricing: time to maturity calculations " + "are measured from this date", + ) spot: SpotPrice[S] | None = Field( default=None, description="Spot price of the underlying asset", @@ -770,7 +776,7 @@ class ForwardPricer(BaseModel, Generic[S]): description="Discount curve for the quote", ) asset_curve: AnyYieldCurve = Field( - default_factory=NoDiscountCurve, + default_factory=InterpolatedMonotonicCubicCurve, description="Discount curve for the asset", ) tick_size_forwards: DecimalNumber | None = Field( @@ -788,12 +794,6 @@ class ForwardPricer(BaseModel, Generic[S]): ), ) - @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: @@ -1440,6 +1440,7 @@ def surface(self) -> VolSurface[S]: if section := loader._cross_section(forward): maturities.append(section) return VolSurface( + ref_date=self.ref_date, asset=self.asset, spot=self.spot, maturities=tuple(maturities), @@ -1505,7 +1506,7 @@ def calibrate_curves( self, *, quote_curve: Annotated[ - type[YieldCurve] | YieldCurve | None, + type[YieldCurve] | None, Doc( "YieldCurve type or instance to fit the quote currency discount " "curve $D_q$. " @@ -1513,7 +1514,7 @@ def calibrate_curves( ), ] = None, asset_curve: Annotated[ - type[YieldCurve] | YieldCurve | None, + type[YieldCurve] | None, Doc( "YieldCurve type or instance to fit the asset discount curve " "$D_a$. " @@ -1552,10 +1553,18 @@ def calibrate_curves( The selected curve models are fitted to those discount factors: a parametric model pools all maturities while an interpolated curve - passes through them. A curve without a calibrator, such as + passes through them. A quote curve without a calibrator, such as [NoDiscountCurve][quantflow.rates.no_discount.NoDiscountCurve], or a - None argument is treated as known: the quote curve is used as given - and the asset discount factors are derived from it. + None argument is treated as known and used as given: this is the + setup for exchanges that settle without discounting, such as Deribit. + + The asset curve instead is always fitted, since put-call parity + defines it from the forwards and the quote curve. When its model + cannot be calibrated, for example a NoDiscountCurve, an + [InterpolatedMonotonicCubicCurve][quantflow.rates.interpolated.InterpolatedMonotonicCubicCurve] + is fitted instead: it passes exactly through its nodes, so the curve + implied forward reproduces the parity forward at every calibrated + maturity. The surface prices options off the parity forwards regardless of the curves, which only provide discounting and rates. @@ -1583,11 +1592,9 @@ def calibrate_curves( quote_dfs.append(dq) forwards.append(forward) if not ttms: - raise ValueError("No parity forwards available to calibrate curves") + return ttm_arr = np.asarray(ttms) - quote_input = ( - self._curve_calibrator(quote_curve) if quote_curve else self.quote_curve - ) + quote_input = self._curve_calibrator(quote_curve or self.quote_curve) if isinstance(quote_input, YieldCurveCalibration): fitted_quote = quote_input.calibrate_df(ttm_arr, np.asarray(quote_dfs)) else: @@ -1595,13 +1602,11 @@ def calibrate_curves( asset_dfs = np.asarray( fitted_quote.discount_factor(ttm_arr), dtype=float ) * np.asarray(forwards) - asset_input = ( - self._curve_calibrator(asset_curve) if asset_curve else self.asset_curve - ) + asset_input = self._curve_calibrator(asset_curve or self.asset_curve) if isinstance(asset_input, YieldCurveCalibration): fitted_asset = asset_input.calibrate_df(ttm_arr, asset_dfs) else: - fitted_asset = asset_input + raise ValueError("Asset curve must be a calibratable YieldCurve") self.quote_curve = cast(AnyYieldCurve, fitted_quote) self.asset_curve = cast(AnyYieldCurve, fitted_asset) @@ -1660,80 +1665,10 @@ def _curve_calibrator( curve = ( curve_type(ref_date=self.ref_date) if isinstance(curve_type, type) - else curve_type + else curve_type.model_copy(update=dict(ref_date=self.ref_date)) ) return curve.calibrator() or curve - def collect_put_call_parities( - self, - *, - max_pairs: Annotated[ - int, Doc("Maximum number of put-call pairs to use per maturity") - ] = 10, - ) -> tuple[FloatArray, FloatArray, FloatArray]: - """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[FloatArray] = [] - cp: list[FloatArray] = [] - strikes: list[FloatArray] = [] - ref_date = self.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, - ) - regressand = parities.regressand() - if not regressand.size: - continue - ttms.append(np.full(regressand.shape, ttm, dtype=float)) - cp.append(regressand) - strikes.append(parities.regressor()) - if not cp: - raise ValueError("No put-call parity pairs available") - return ( - np.concatenate(ttms), - np.concatenate(cp), - np.concatenate(strikes), - ) - - def implied_forward_term_structure( - self, - *, - max_pairs: Annotated[ - int, Doc("Maximum number of put-call pairs to use per maturity") - ] = 10, - ) -> list[tuple[datetime, float, float]]: - """Return per-maturity implied forwards from put-call parity. - - For each maturity, fits asset and quote discount factors from the most - liquid put-call pairs and returns the implied forward `spot * Da / Dq`. - - Returns a list of `(maturity, ttm, forward)` tuples, one per maturity - for which a valid fit is available. - """ - if not self.spot or self.spot.mid == ZERO: - raise ValueError("No spot price provided") - spot = self.spot.mid - ref_date = self.ref_date - result = [] - 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 - ) - forward = parities.implied_forward() - if forward is not None: - result.append((maturity, ttm, forward)) - return result - class VolSurfaceLoader(GenericVolSurfaceLoader[DefaultVolSecurity]): """Helper class to build a volatility surface from a list of securities @@ -1787,6 +1722,7 @@ def surface_from_inputs( [VolSurfaceInputs][quantflow.options.inputs.VolSurfaceInputs] instance """ loader = VolSurfaceLoader( + ref_date=min(inputs.quote_curve.ref_date, inputs.asset_curve.ref_date), asset=inputs.asset, quote_curve=inputs.quote_curve, asset_curve=inputs.asset_curve, diff --git a/quantflow/rates/interpolated.py b/quantflow/rates/interpolated.py index a7d9601..5b3b98a 100644 --- a/quantflow/rates/interpolated.py +++ b/quantflow/rates/interpolated.py @@ -17,6 +17,7 @@ from quantflow.utils.types import FloatArray, FloatArrayLike, maybe_float from .calibration import YieldCurveCalibration +from .interest_rate import ROUND_RATE from .yield_curve import YieldCurve _YEAR = 365.0 * 86400.0 @@ -221,7 +222,9 @@ def calibrate( curve.anchor_dates = [ ref + timedelta(seconds=float(t) * _YEAR) for t in unique_ttm ] - curve.anchor_rates = [Decimal(str(round(float(r), 10))) for r in mean_rates] + curve.anchor_rates = [ + Decimal(str(round(float(r), ROUND_RATE))) for r in mean_rates + ] curve._ttm = unique_ttm curve._rates = mean_rates return curve diff --git a/quantflow_tests/test_app.py b/quantflow_tests/test_app.py index 09fe07c..239ecbb 100644 --- a/quantflow_tests/test_app.py +++ b/quantflow_tests/test_app.py @@ -233,27 +233,22 @@ def test_volatility_surface_cached(client: TestClient, mock_surface: AsyncMock) assert mock_surface.await_count == 1 -def test_volatility_surface_curve_selection( - client: TestClient, mock_surface: AsyncMock -) -> None: - response = client.get( - "/.api/volatility-surface?asset=ETH" - ""e_curve=interpolated-cubic&asset_curve=nelson-siegel" - ) +def test_volatility_surface_curves(client: TestClient, mock_surface: AsyncMock) -> None: + # the curves are forced by the backend: interpolated curves fitted from + # the parity forwards (the mock loader is equity style, so the quote + # curve is fitted too) + response = client.get("/.api/volatility-surface?asset=ETH") assert response.status_code == 200 data = response.json() assert ( data["quote_curve"]["curve"]["curve_type"] == "interpolated_monotonic_cubic_curve" ) - assert data["asset_curve"]["curve"]["curve_type"] == "nelson_siegel_curve" - # different curve selections are cached under different keys - response = client.get("/.api/volatility-surface?asset=ETH") - assert response.status_code == 200 - data = response.json() - assert data["quote_curve"]["curve"]["curve_type"] == "cir_curve" - assert data["asset_curve"]["curve"]["curve_type"] == "nelson_siegel_curve" - assert mock_surface.await_count == 2 + assert ( + data["asset_curve"]["curve"]["curve_type"] + == "interpolated_monotonic_cubic_curve" + ) + assert mock_surface.await_count == 1 def test_cointegration_endpoint(app: FastAPI, client: TestClient) -> None: diff --git a/quantflow_tests/test_non_inverse_surface.py b/quantflow_tests/test_non_inverse_surface.py index c354ad0..32db159 100644 --- a/quantflow_tests/test_non_inverse_surface.py +++ b/quantflow_tests/test_non_inverse_surface.py @@ -37,6 +37,7 @@ def _black_mid_usd(strike: float, call_put: int, ttm: float) -> Decimal: def _build_loader(ttm: float) -> VolSurfaceLoader: loader = VolSurfaceLoader( asset="TEST", + ref_date=REF_DATE, quote_curve=NoDiscountCurve(ref_date=REF_DATE), asset_curve=NoDiscountCurve(ref_date=REF_DATE), ) diff --git a/quantflow_tests/test_parity.py b/quantflow_tests/test_parity.py index 2136b2b..4b7be6a 100644 --- a/quantflow_tests/test_parity.py +++ b/quantflow_tests/test_parity.py @@ -40,27 +40,7 @@ def test_regressand_inverse() -> None: assert y[1] == pytest.approx(0.0) -def test_fit_discounts_with_fixed_values() -> None: - parities = PutCallParities.from_parities( - [_parity(90, 12.5), _parity(110, -6.5)], - 100, - 1, - ) - fitted_both = parities.fit_discounts(dq=0.95, da=0.98) - assert fitted_both is not None - assert fitted_both.quote_discount == pytest.approx(0.95) - assert fitted_both.asset_discount == pytest.approx(0.98) - - fitted_da = parities.fit_discounts(dq=0.95) - assert fitted_da is not None - assert fitted_da.asset_discount == pytest.approx(0.98) - - fitted_dq = parities.fit_discounts(da=0.98) - assert fitted_dq is not None - assert fitted_dq.quote_discount == pytest.approx(0.95) - - -def test_fit_discounts_constrained_branch() -> None: +def test_quote_discount_recovers_slope() -> None: da_true = 0.98 dq_true = 0.95 spot = 100 @@ -69,15 +49,5 @@ def test_fit_discounts_constrained_branch() -> None: parities = PutCallParities.from_parities( [_parity(k, m) for k, m in zip(strikes, mids)], spot=spot, ttm=1 ) - fitted = parities.fit_discounts() - assert fitted is not None - assert fitted.asset_discount == pytest.approx(da_true, abs=1e-6) - assert fitted.quote_discount == pytest.approx(dq_true, abs=1e-6) - - -def test_fit_discounts_invalid_or_empty_returns_none() -> None: - empty = PutCallParities.from_parities([], spot=100, ttm=1) - assert empty.fit_discounts() is None - - parities = PutCallParities.from_parities([_parity(100, 2.0)], spot=100, ttm=1) - assert parities.fit_discounts(dq=1.0, min_rate_q=0.1, min_rate_a=0.1) is None + dq = parities.quote_discount(da_true / dq_true) + assert dq == pytest.approx(dq_true, abs=1e-6) diff --git a/quantflow_tests/test_surface_calibration.py b/quantflow_tests/test_surface_calibration.py index 7288a50..0b057d5 100644 --- a/quantflow_tests/test_surface_calibration.py +++ b/quantflow_tests/test_surface_calibration.py @@ -1,8 +1,8 @@ """Tests for GenericVolSurfaceLoader calibration methods. -Covers collect_put_call_parities, calibrate_curves, calibrate_spot, and -implied_forward_term_structure using the SPX fixture (non-inverse, matched -call/put pairs). +Covers calibrate_curves, calibrate_spot, and calibrate_forwards using the +SPX fixture (non-inverse, matched call/put pairs) and the BTC fixture +(inverse options). """ from __future__ import annotations @@ -51,24 +51,6 @@ async def loader(yahoo_cli: Yahoo) -> VolSurfaceLoader: return await yahoo_cli.volatility_surface_loader("^SPX") -async def test_collect_put_call_parities_shapes(loader: VolSurfaceLoader) -> None: - ttm, cp, strikes = loader.collect_put_call_parities() - assert ttm.shape == cp.shape == strikes.shape - assert len(ttm) > 0 - - -async def test_collect_put_call_parities_ttm_positive(loader: VolSurfaceLoader) -> None: - ttm, _cp, _strikes = loader.collect_put_call_parities() - assert (ttm > 0).all() - - -async def test_collect_put_call_parities_strikes_positive( - loader: VolSurfaceLoader, -) -> None: - _ttm, _cp, strikes = loader.collect_put_call_parities() - assert (strikes > 0).all() - - async def test_calibrate_spot_returns_positive_value(loader: VolSurfaceLoader) -> None: implied = loader.calibrate_spot() assert implied is not None @@ -107,25 +89,29 @@ async def test_calibrate_curves_joint(loader: VolSurfaceLoader) -> None: assert isinstance(loader.quote_curve, NelsonSiegelCurve) -async def test_calibrate_curves_both_none_is_noop(loader: VolSurfaceLoader) -> None: - original_asset = loader.asset_curve - original_quote = loader.quote_curve +async def test_calibrate_curves_default_refits_curves(loader: VolSurfaceLoader) -> None: + # with no arguments the current curve models are refitted: the yahoo + # loader starts with interpolated quote and asset curves loader.calibrate_curves() - assert loader.asset_curve is original_asset - assert loader.quote_curve is original_quote + assert isinstance(loader.quote_curve, InterpolatedMonotonicCubicCurve) + assert loader.quote_curve.anchor_dates + assert isinstance(loader.asset_curve, InterpolatedMonotonicCubicCurve) + assert loader.asset_curve.anchor_dates async def test_calibrate_curves_no_discount_asset(loader: VolSurfaceLoader) -> None: - # a curve without a calibrator is treated as fixed - loader.calibrate_curves(quote_curve=NelsonSiegelCurve, asset_curve=NoDiscountCurve) - assert isinstance(loader.quote_curve, NelsonSiegelCurve) - assert isinstance(loader.asset_curve, NoDiscountCurve) + # the asset curve is always fitted, a model without a calibrator raises + with pytest.raises(ValueError): + loader.calibrate_curves( + quote_curve=NelsonSiegelCurve, asset_curve=NoDiscountCurve + ) -async def test_calibrate_curves_both_no_discount(loader: VolSurfaceLoader) -> None: - loader.calibrate_curves(quote_curve=NoDiscountCurve, asset_curve=NoDiscountCurve) +async def test_calibrate_curves_no_discount_quote(loader: VolSurfaceLoader) -> None: + # a quote curve without a calibrator is kept as known + loader.calibrate_curves(quote_curve=NoDiscountCurve) assert isinstance(loader.quote_curve, NoDiscountCurve) - assert isinstance(loader.asset_curve, NoDiscountCurve) + assert isinstance(loader.asset_curve, InterpolatedMonotonicCubicCurve) async def test_calibrate_curves_joint_interpolated(loader: VolSurfaceLoader) -> None: @@ -206,34 +192,3 @@ def test_calibrate_curves_reproduces_parity_forwards( assert section.parity_forward is not None curve_forward = float(btc_loader.forward(maturity)) assert curve_forward == pytest.approx(float(section.parity_forward), rel=1e-6) - - -async def test_implied_forward_term_structure_returns_entries( - loader: VolSurfaceLoader, -) -> None: - ts = loader.implied_forward_term_structure() - assert len(ts) > 0 - - -async def test_implied_forward_term_structure_ttm_positive( - loader: VolSurfaceLoader, -) -> None: - ts = loader.implied_forward_term_structure() - for _mat, ttm, _fwd in ts: - assert ttm > 0 - - -async def test_implied_forward_term_structure_forward_positive( - loader: VolSurfaceLoader, -) -> None: - ts = loader.implied_forward_term_structure() - for _mat, _ttm, fwd in ts: - assert fwd > 0 - - -async def test_implied_forward_term_structure_increases_with_ttm( - loader: VolSurfaceLoader, -) -> None: - ts = loader.implied_forward_term_structure() - ttms = [ttm for _mat, ttm, _fwd in ts] - assert ttms == sorted(ttms)