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

1"""Payoff functions for vanilla European option contracts.""" 

2 

3import math 

4import numbers 

5 

6import numpy as np 

7import numpy.typing as npt 

8 

9 

10def _check_strike(strike: float) -> None: 

11 """Reject a nonsensical strike with a clear error. 

12 

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. 

18 

19 Args: 

20 strike: The proposed strike price. 

21 

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) 

38 

39 

40def _as_spot(spot: npt.ArrayLike) -> npt.NDArray[np.float64]: 

41 """Convert ``spot`` to a float64 array, rejecting unusable prices. 

42 

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

49 

50 Args: 

51 spot: The proposed spot price(s), scalar or array-like. 

52 

53 Returns: 

54 ``spot`` as a ``float64`` array (0-d for a scalar). 

55 

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 

84 

85 

86def call_payoff(spot: npt.ArrayLike, strike: float) -> np.float64 | npt.NDArray[np.float64]: 

87 """Return the expiry payoff of a European call option. 

88 

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. 

94 

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. 

101 

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

105 

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. 

111 

112 Examples: 

113 A scalar spot gives a scalar payoff: 

114 

115 >>> call_payoff(120.0, strike=100.0) 

116 np.float64(20.0) 

117 

118 Out of the money the payoff floors at zero rather than going negative: 

119 

120 >>> call_payoff(80.0, strike=100.0) 

121 np.float64(0.0) 

122 

123 An array-like spot is evaluated element-wise: 

124 

125 >>> call_payoff([80.0, 100.0, 130.0], strike=100.0) 

126 array([ 0., 0., 30.]) 

127 

128 An unusable strike fails fast: 

129 

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) 

137 

138 

139def put_payoff(spot: npt.ArrayLike, strike: float) -> np.float64 | npt.NDArray[np.float64]: 

140 """Return the expiry payoff of a European put option. 

141 

142 Mirrors :func:`call_payoff`, including its return-type convention: scalar in, 

143 scalar out; array-like in, array out. 

144 

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. 

151 

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

155 

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. 

161 

162 Examples: 

163 A scalar spot gives a scalar payoff: 

164 

165 >>> put_payoff(80.0, strike=100.0) 

166 np.float64(20.0) 

167 

168 Out of the money the payoff floors at zero: 

169 

170 >>> put_payoff(120.0, strike=100.0) 

171 np.float64(0.0) 

172 

173 An array-like spot is evaluated element-wise: 

174 

175 >>> put_payoff([70.0, 100.0, 130.0], strike=100.0) 

176 array([30., 0., 0.]) 

177 

178 A missing spot propagates as NaN rather than rejecting the batch: 

179 

180 >>> put_payoff([70.0, float("nan")], strike=100.0) 

181 array([30., nan]) 

182 

183 A non-finite strike is rejected alongside a negative one: 

184 

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)