Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions app/api/docs/volatility_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 4 additions & 45 deletions app/api/volatility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 4 additions & 9 deletions docs/examples/curve_calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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),
Expand Down
34 changes: 28 additions & 6 deletions docs/tutorials/curve_calibration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 19 additions & 13 deletions docs/tutorials/volatility_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 3 additions & 18 deletions frontend/src/volatility-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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`<div style="display: flex; gap: 1rem; align-items: end; flex-wrap: wrap">${assetInput}${quoteCurveInput}${assetCurveInput}</div>`);
display(html`<div style="display: flex; gap: 1rem; align-items: end; flex-wrap: wrap">${assetInput}</div>`);
```

```js
const data = await fetchJson(`/.api/volatility-surface?asset=${asset}&quote_curve=${quoteCurveModel}&asset_curve=${assetCurveModel}`);
const data = await fetchJson(`/.api/volatility-surface?asset=${asset}`);
```

```js
Expand Down
5 changes: 1 addition & 4 deletions quantflow/data/deribit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
7 changes: 3 additions & 4 deletions quantflow/data/yahoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading