Skip to content

Put-Call Parity

quantflow.options.parity.PutCallParity pydantic-model

Bases: BaseModel

A matched put-call parity at a single strike, used for discount curve calibration.

Fields:

strike pydantic-field

strike

Strike price

call pydantic-field

call

Call option bid/ask prices

put pydantic-field

put

Put option bid/ask prices

inverse pydantic-field

inverse = False

Whether the option is inverse

bid property

bid

Lower bound of the call-put price difference

ask property

ask

Upper bound of the call-put price difference

mid property

mid

Midpoint of the call-put price difference

spread property

spread

Bid-ask spread of the call-put price difference

quantflow.options.parity.PutCallParities pydantic-model

Bases: BaseModel

A collection of put-call parities for a given maturity

Fields:

parities pydantic-field

parities

List of put-call parities

spot pydantic-field

spot

Spot price of the underlying asset

ttm pydantic-field

ttm

Time to maturity in years

inverse pydantic-field

inverse = False

Whether the options are inverse

from_parities classmethod

from_parities(parities, spot, ttm)
Source code in quantflow/options/parity.py
@classmethod
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=to_decimal(spot),
        ttm=to_decimal(ttm),
        inverse=inverse,
    )

regressand

regressand()

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.

Source code in quantflow/options/parity.py
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])

regressor

regressor()

Calculate the regressor for put-call parity regression, which is the strike price divided by the spot price.

Source code in quantflow/options/parity.py
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])

fit_discounts

fit_discounts(dq=None, da=None, min_rate_q=0.0, min_rate_a=0.0)

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 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).

Source code in quantflow/options/parity.py
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:
    """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 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)

plot

plot(dq=None, da=None, min_rate_q=0.0, min_rate_a=0.0)

Plot the normalized put-call parity data and the fitted regression line.

Source code in quantflow/options/parity.py
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."""
    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, 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"))
    y_label = "c - p" if self.inverse else "(C - P) / S"
    return fig.update_layout(xaxis_title="K / S", yaxis_title=y_label)