Coverage for src/dummypy/payoffs.py: 100%
35 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:46 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:46 +0000
1"""Payoff functions for vanilla European option contracts."""
3import math
4import numbers
6import numpy as np
7import numpy.typing as npt
10def _check_strike(strike: float) -> None:
11 """Reject a nonsensical strike with a clear error.
13 Mirrors the validation style of :func:`dummypy.grid._check_n`: fail fast
14 with an actionable message rather than silently producing a meaningless
15 payoff. Infinities are rejected alongside NaN: an unchecked infinite
16 strike does not fail, it silently yields an infinite put payoff, which
17 is exactly the meaningless result this validator exists to prevent.
19 Args:
20 strike: The proposed strike price.
22 Raises:
23 TypeError: If ``strike`` is not a real number (bool is rejected too).
24 ValueError: If ``strike`` is not finite (NaN or infinite), or is
25 negative.
26 """
27 # bool is a subclass of int; reject it as _check_n does, rather than
28 # silently pricing a strike of 1.0.
29 if not isinstance(strike, numbers.Real) or isinstance(strike, bool):
30 msg = f"strike must be a real number, got {type(strike).__name__}"
31 raise TypeError(msg)
32 if not math.isfinite(strike):
33 msg = f"strike must be a finite real number, got {strike}"
34 raise ValueError(msg)
35 if strike < 0:
36 msg = f"strike must be non-negative, got {strike}"
37 raise ValueError(msg)
40def _as_spot(spot: npt.ArrayLike) -> npt.NDArray[np.float64]:
41 """Convert ``spot`` to a float64 array, rejecting unusable prices.
43 The counterpart of :func:`_check_strike` for the other argument, with one
44 deliberate difference: NaN is *not* rejected. ``spot`` is routinely an
45 array, and a missing price in one entry should yield a NaN payoff in that
46 entry rather than refuse the whole batch -- which is also what numpy does
47 with it anyway. ``+inf`` is allowed for the same reason, and gives the
48 limiting payoffs (``inf`` for a call, ``0`` for a put).
50 Args:
51 spot: The proposed spot price(s), scalar or array-like.
53 Returns:
54 ``spot`` as a ``float64`` array (0-d for a scalar).
56 Raises:
57 TypeError: If ``spot`` does not convert to an integer or float numpy
58 array -- a bool, a string (even a numeric one, as for ``strike``),
59 a complex number, ``None``, a ragged nested sequence, or a Python
60 number numpy stores as an object, such as a ``Fraction`` or a
61 ``Decimal``.
62 ValueError: If any entry of ``spot`` is negative, ``-inf`` included.
63 """
64 try:
65 raw = np.asarray(spot)
66 except ValueError: # a ragged nested sequence
67 raw = None
68 # Integer and float dtype kinds only. Narrower than _check_strike, which
69 # admits any numbers.Real: a Fraction or Decimal spot becomes an object
70 # array, and coercing one element by element is not worth supporting.
71 if raw is None or raw.dtype.kind not in "iuf":
72 msg = f"spot must be real-valued, got {type(spot).__name__}"
73 raise TypeError(msg)
74 values = raw.astype(np.float64, copy=False)
75 # NaN compares False here, so it passes through by design (see above).
76 if np.any(values < 0):
77 # Name the first offender rather than echoing the input, which may be
78 # an array of any size.
79 index = np.argwhere(values < 0)[0]
80 where = "" if values.ndim == 0 else f" at index {', '.join(map(str, index))}"
81 msg = f"spot must be non-negative, got {values[tuple(index)]}{where}"
82 raise ValueError(msg)
83 return values
86def call_payoff(spot: npt.ArrayLike, strike: float) -> np.float64 | npt.NDArray[np.float64]:
87 """Return the expiry payoff of a European call option.
89 The return type follows :func:`numpy.maximum`, which this delegates to: a
90 scalar ``spot`` yields a :class:`numpy.float64`, an array-like yields an
91 array. The two are deliberately *not* normalised to an array — doing so
92 would make ``call_payoff(120.0, 100.0)`` return ``array([20.])`` and
93 surprise every caller who passed a single number.
95 Args:
96 spot: Underlying spot price(s) at expiry. Scalars and array-likes
97 are both accepted. Must be non-negative; a NaN entry is allowed
98 and yields a NaN payoff in that position.
99 strike: Strike price of the option. Must be a finite, non-negative
100 real number.
102 Returns:
103 Element-wise payoff ``max(spot - strike, 0)``: a :class:`numpy.float64`
104 for scalar ``spot``, or a ``float64`` array for array-like ``spot``.
106 Raises:
107 TypeError: If ``strike`` is not a real number (e.g. a bool or a str),
108 or ``spot`` is not real-valued.
109 ValueError: If ``strike`` is not finite (NaN or infinite), or is
110 negative, or if any entry of ``spot`` is negative.
112 Examples:
113 A scalar spot gives a scalar payoff:
115 >>> call_payoff(120.0, strike=100.0)
116 np.float64(20.0)
118 Out of the money the payoff floors at zero rather than going negative:
120 >>> call_payoff(80.0, strike=100.0)
121 np.float64(0.0)
123 An array-like spot is evaluated element-wise:
125 >>> call_payoff([80.0, 100.0, 130.0], strike=100.0)
126 array([ 0., 0., 30.])
128 An unusable strike fails fast:
130 >>> call_payoff(120.0, strike=-1.0)
131 Traceback (most recent call last):
132 ...
133 ValueError: strike must be non-negative, got -1.0
134 """
135 _check_strike(strike)
136 return np.maximum(_as_spot(spot) - strike, 0.0)
139def put_payoff(spot: npt.ArrayLike, strike: float) -> np.float64 | npt.NDArray[np.float64]:
140 """Return the expiry payoff of a European put option.
142 Mirrors :func:`call_payoff`, including its return-type convention: scalar in,
143 scalar out; array-like in, array out.
145 Args:
146 spot: Underlying spot price(s) at expiry. Scalars and array-likes
147 are both accepted. Must be non-negative; a NaN entry is allowed
148 and yields a NaN payoff in that position.
149 strike: Strike price of the option. Must be a finite, non-negative
150 real number.
152 Returns:
153 Element-wise payoff ``max(strike - spot, 0)``: a :class:`numpy.float64`
154 for scalar ``spot``, or a ``float64`` array for array-like ``spot``.
156 Raises:
157 TypeError: If ``strike`` is not a real number (e.g. a bool or a str),
158 or ``spot`` is not real-valued.
159 ValueError: If ``strike`` is not finite (NaN or infinite), or is
160 negative, or if any entry of ``spot`` is negative.
162 Examples:
163 A scalar spot gives a scalar payoff:
165 >>> put_payoff(80.0, strike=100.0)
166 np.float64(20.0)
168 Out of the money the payoff floors at zero:
170 >>> put_payoff(120.0, strike=100.0)
171 np.float64(0.0)
173 An array-like spot is evaluated element-wise:
175 >>> put_payoff([70.0, 100.0, 130.0], strike=100.0)
176 array([30., 0., 0.])
178 A missing spot propagates as NaN rather than rejecting the batch:
180 >>> put_payoff([70.0, float("nan")], strike=100.0)
181 array([30., nan])
183 A non-finite strike is rejected alongside a negative one:
185 >>> put_payoff(80.0, strike=float("nan"))
186 Traceback (most recent call last):
187 ...
188 ValueError: strike must be a finite real number, got nan
189 """
190 _check_strike(strike)
191 return np.maximum(strike - _as_spot(spot), 0.0)