Skip to content

Put-Call Parity

quantflow.options.parity.PutCallParity pydantic-model

Bases: BaseModel

A put-call parity at a single strike

used for forward and 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])

weights

weights()

Inverse bid-ask spread weights for the put-call parity regression.

Pairs with a tighter parity spread receive a larger weight. A floor of one tenth of the median positive spread avoids infinite weights on zero spread pairs.

Source code in quantflow/options/parity.py
def weights(self) -> FloatArray:
    """Inverse bid-ask spread weights for the put-call parity regression.

    Pairs with a tighter parity spread receive a larger weight. A floor
    of one tenth of the median positive spread avoids infinite weights on
    zero spread pairs.
    """
    scale = Decimal(1) if self.inverse else self.spot
    spreads = np.asarray([float(p.spread / scale) for p in self.parities])
    positive = spreads[spreads > 0]
    floor = 0.1 * float(np.median(positive)) if positive.size else 1e-9
    return 1.0 / (spreads + max(floor, 1e-9))

calibrate_forward

calibrate_forward(
    *, band=1.0, min_pairs=3, max_iterations=5, tol=1e-05
)

Calibrate the forward price from put-call parity.

The forward is the zero crossing of the weighted parity regression. Pairs far from the money contain one deep in the money option whose quote carries little information, so the regression is restricted to pairs within band units of convexity adjusted moneyness.

The moneyness requires the forward and the volatility, which are not known upfront. The algorithm therefore iterates: fit the crossing with all pairs, estimate the at the money volatility from the straddle nearest the crossing, select the pairs inside the band, refit, and repeat until the forward is stable.

Returns the forward price, or None when fewer than two pairs are available or the regression is degenerate.

PARAMETER DESCRIPTION
band

Initial half width of the pair selection band, in units of convexity adjusted moneyness (standard deviations). The band widens automatically when it contains fewer than min_pairs pairs.

TYPE: float DEFAULT: 1.0

min_pairs

Minimum number of pairs; the band widens until reached

TYPE: int DEFAULT: 3

max_iterations

Maximum number of forward refinement iterations

TYPE: int DEFAULT: 5

tol

Relative tolerance on the forward for convergence

TYPE: float DEFAULT: 1e-05

Source code in quantflow/options/parity.py
def calibrate_forward(
    self,
    *,
    band: Annotated[
        float,
        Doc(
            "Initial half width of the pair selection band, in units of "
            "convexity adjusted moneyness (standard deviations). "
            "The band widens automatically when it contains fewer than "
            "min_pairs pairs."
        ),
    ] = 1.0,
    min_pairs: Annotated[
        int, Doc("Minimum number of pairs; the band widens until reached")
    ] = 3,
    max_iterations: Annotated[
        int, Doc("Maximum number of forward refinement iterations")
    ] = 5,
    tol: Annotated[
        float, Doc("Relative tolerance on the forward for convergence")
    ] = 1e-5,
) -> float | None:
    """Calibrate the forward price from put-call parity.

    The forward is the zero crossing of the weighted parity regression.
    Pairs far from the money contain one deep in the money option whose
    quote carries little information, so the regression is restricted to
    pairs within `band` units of convexity adjusted moneyness.

    The moneyness requires the forward and the volatility, which are not
    known upfront. The algorithm therefore iterates: fit the crossing with
    all pairs, estimate the at the money volatility from the straddle
    nearest the crossing, select the pairs inside the band, refit, and
    repeat until the forward is stable.

    Returns the forward price, or None when fewer than two pairs are
    available or the regression is degenerate.
    """
    if len(self.parities) < 2:
        return None
    ys = self.regressand()
    xs = self.regressor()
    weights = self.weights()
    ttm = float(self.ttm)
    x0 = self._crossing(xs, ys, weights)
    if x0 is None:
        return None
    for _ in range(max_iterations):
        sigma_sqrt_ttm = self._straddle_vol(x0) * np.sqrt(ttm)
        moneyness = np.log(xs / x0) / sigma_sqrt_ttm + 0.5 * sigma_sqrt_ttm
        half_width = band
        selected = np.abs(moneyness) < half_width
        while selected.sum() < min(min_pairs, len(xs)):
            half_width *= 2
            selected = np.abs(moneyness) < half_width
        x1 = self._crossing(xs[selected], ys[selected], weights[selected])
        if x1 is None:
            break
        converged = abs(x1 - x0) <= tol * x0
        x0 = x1
        if converged:
            break
    return x0 * float(self.spot)

quote_discount

quote_discount(forward)

Quote discount factor with the forward held fixed.

With the forward known, put-call parity has a single free parameter, the quote discount factor \(D_q\):

\[\begin{equation} y = D_q \left(f - x\right) \end{equation}\]

where \(y\) is the normalized call put difference, \(x\) the strike over spot and \(f\) the forward over spot. The parameter is estimated by weighted least squares over all pairs, with the weights of weights. Returns None when the estimate is not positive.

PARAMETER DESCRIPTION
forward

Forward price divided by the spot price

TYPE: float

Source code in quantflow/options/parity.py
def quote_discount(
    self,
    forward: Annotated[float, Doc("Forward price divided by the spot price")],
) -> float | None:
    """Quote discount factor with the forward held fixed.

    With the forward known, put-call parity has a single free parameter,
    the quote discount factor $D_q$:

    \\begin{equation}
        y = D_q \\left(f - x\\right)
    \\end{equation}

    where $y$ is the normalized call put difference, $x$ the strike over
    spot and $f$ the forward over spot. The parameter is estimated by
    weighted least squares over all pairs, with the weights of
    [weights][..weights]. Returns None when the estimate is not positive.
    """
    ys = self.regressand()
    xs = self.regressor()
    weights = self.weights()
    zs = forward - xs
    denominator = float(np.sum((weights * zs) ** 2))
    if denominator <= 0:
        return None
    dq = float(np.sum(weights**2 * ys * zs) / denominator)
    return dq if dq > 0 else None

plot

plot()

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

The line is built from the calibrated forward (calibrate_forward) and the quote discount factor estimated with the forward held fixed (quote_discount).

Source code in quantflow/options/parity.py
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()
    import plotly.graph_objects as go

    xs = self.regressor()
    ys = self.regressand()
    fig = go.Figure()
    fig.add_trace(
        go.Scatter(x=xs, y=ys, mode="markers", name="market", marker_size=10)
    )
    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)