"""Model-free relations between European option prices: put-call parity
and Merton's no-arbitrage bounds.
Both follow from static replication alone, with no model for the
underlying. A call minus a put is a forward,
:math:`c - p = S e^{-qT} - K e^{-rT}` (Stoll 1969), and a call is worth
at least the forward's value and at most the stock,
:math:`\\max(S e^{-qT} - K e^{-rT}, 0) \\le c \\le S e^{-qT}` (Merton 1973).
:func:`~financialkit.options.systems.implied_vol.implied_volatility`
checks these bounds before it searches for a volatility.
References: H. R. Stoll, *J. Finance* 24 (1969) 801-824; R. C. Merton,
*Bell J. Econ. Manag. Sci.* 4 (1973) 141-183; Hull, *Options, Futures,
and Other Derivatives*, 10th ed., Ch. 11.
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from financialkit.options.utils.common import as_output, option_sign
__all__ = ["put_call_parity", "parity_residual", "no_arbitrage_bounds"]
def _pv_forward(S: ArrayLike, K: ArrayLike, T: ArrayLike, r: ArrayLike, q: ArrayLike) -> np.ndarray:
S, K, T, r, q = (np.asarray(a, dtype=np.float64) for a in (S, K, T, r, q))
return S * np.exp(-q * T) - K * np.exp(-r * T)
[docs]
def put_call_parity(
price: ArrayLike, S: ArrayLike, K: ArrayLike, T: ArrayLike, r: ArrayLike, q: ArrayLike = 0.0, option_type: str = "call"
) -> np.ndarray | float:
"""The other leg of a European call-put pair, by put-call parity.
Parameters
----------
price : array_like
Price of the known option.
S, K, T, r, q : array_like
Spot, strike, maturity, risk-free rate and dividend yield.
option_type : {"call", "put"}
Type of the *known* option; the other type's price is returned.
Returns
-------
float or ndarray
Examples
--------
Hull, 10th ed., Example 15.6: a 4.76 call with S = 42, K = 40,
r = 10% and six months implies a 0.81 put.
>>> round(put_call_parity(4.7594, 42.0, 40.0, 0.5, 0.10), 2)
0.81
"""
return as_output(np.asarray(price, dtype=np.float64) - option_sign(option_type) * _pv_forward(S, K, T, r, q))
[docs]
def parity_residual(call: ArrayLike, put: ArrayLike, S: ArrayLike, K: ArrayLike, T: ArrayLike, r: ArrayLike, q: ArrayLike = 0.0) -> np.ndarray | float:
"""Violation of put-call parity, :math:`c - p - (S e^{-qT} - K e^{-rT})`.
Zero for any arbitrage-free pair; a nonzero residual is the profit of
the conversion (or reversal) that exploits it.
Parameters
----------
call, put : array_like
European call and put prices with the same strike and maturity.
S, K, T, r, q : array_like
Returns
-------
float or ndarray
"""
return as_output(np.asarray(call, dtype=np.float64) - np.asarray(put, dtype=np.float64) - _pv_forward(S, K, T, r, q))
[docs]
def no_arbitrage_bounds(
S: ArrayLike, K: ArrayLike, T: ArrayLike, r: ArrayLike, q: ArrayLike = 0.0, option_type: str = "call"
) -> tuple[np.ndarray | float, np.ndarray | float]:
"""Merton's (1973) model-free bounds on a European option price.
Calls: :math:`\\max(S e^{-qT} - K e^{-rT}, 0) \\le c \\le S e^{-qT}`.
Puts: :math:`\\max(K e^{-rT} - S e^{-qT}, 0) \\le p \\le K e^{-rT}`.
Parameters
----------
S, K, T, r, q : array_like
option_type : {"call", "put"}
Returns
-------
lower, upper : float or ndarray
Examples
--------
>>> lo, hi = no_arbitrage_bounds(100.0, 90.0, 1.0, 0.05)
>>> round(lo, 4), hi
(14.3894, 100.0)
"""
sign = option_sign(option_type)
S, K, T, r, q = (np.asarray(a, dtype=np.float64) for a in (S, K, T, r, q))
lower = np.maximum(sign * _pv_forward(S, K, T, r, q), 0.0)
upper = S * np.exp(-q * T) if sign == 1 else K * np.exp(-r * T)
lower, upper = np.broadcast_arrays(lower, upper)
return as_output(lower), as_output(upper)