sppricer 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sppricer/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """sppricer: Monte Carlo pricing and risk for structured products."""
2
+
3
+ from sppricer.analytics import bs_delta, bs_price, bs_vega
4
+ from sppricer.engine import PricingResult, price_mc
5
+ from sppricer.greeks import Greeks, bump_greeks
6
+ from sppricer.market import Curve, Market, Underlying
7
+ from sppricer.models import GBMModel, LocalVolModel, LocalVolSource, PathModel, simulate_gbm
8
+ from sppricer.products import (
9
+ BarrierReverseConvertible,
10
+ EuropeanOption,
11
+ PhoenixAutocallable,
12
+ Product,
13
+ )
14
+
15
+ __all__ = [
16
+ "BarrierReverseConvertible",
17
+ "Curve",
18
+ "EuropeanOption",
19
+ "GBMModel",
20
+ "Greeks",
21
+ "LocalVolModel",
22
+ "LocalVolSource",
23
+ "Market",
24
+ "PathModel",
25
+ "PhoenixAutocallable",
26
+ "PricingResult",
27
+ "Product",
28
+ "Underlying",
29
+ "bs_delta",
30
+ "bs_price",
31
+ "bs_vega",
32
+ "bump_greeks",
33
+ "price_mc",
34
+ "simulate_gbm",
35
+ ]
sppricer/analytics.py ADDED
@@ -0,0 +1,43 @@
1
+ """Closed-form Black-Scholes formulas, used as benchmarks for the MC engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+
8
+ def _norm_cdf(x: float) -> float:
9
+ return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
10
+
11
+
12
+ def _norm_pdf(x: float) -> float:
13
+ return math.exp(-0.5 * x * x) / math.sqrt(2.0 * math.pi)
14
+
15
+
16
+ def _d1_d2(spot, strike, maturity, rate, vol, div_yield):
17
+ sqrt_t = math.sqrt(maturity)
18
+ d1 = (math.log(spot / strike) + (rate - div_yield + 0.5 * vol**2) * maturity) / (vol * sqrt_t)
19
+ return d1, d1 - vol * sqrt_t
20
+
21
+
22
+ def bs_price(spot: float, strike: float, maturity: float, rate: float, vol: float,
23
+ div_yield: float = 0.0, is_call: bool = True) -> float:
24
+ d1, d2 = _d1_d2(spot, strike, maturity, rate, vol, div_yield)
25
+ fwd_s = spot * math.exp(-div_yield * maturity)
26
+ pv_k = strike * math.exp(-rate * maturity)
27
+ if is_call:
28
+ return fwd_s * _norm_cdf(d1) - pv_k * _norm_cdf(d2)
29
+ return pv_k * _norm_cdf(-d2) - fwd_s * _norm_cdf(-d1)
30
+
31
+
32
+ def bs_delta(spot: float, strike: float, maturity: float, rate: float, vol: float,
33
+ div_yield: float = 0.0, is_call: bool = True) -> float:
34
+ d1, _ = _d1_d2(spot, strike, maturity, rate, vol, div_yield)
35
+ disc_q = math.exp(-div_yield * maturity)
36
+ return disc_q * _norm_cdf(d1) if is_call else disc_q * (_norm_cdf(d1) - 1.0)
37
+
38
+
39
+ def bs_vega(spot: float, strike: float, maturity: float, rate: float, vol: float,
40
+ div_yield: float = 0.0) -> float:
41
+ """Vega per 1.00 change in vol (divide by 100 for per-vol-point)."""
42
+ d1, _ = _d1_d2(spot, strike, maturity, rate, vol, div_yield)
43
+ return spot * math.exp(-div_yield * maturity) * _norm_pdf(d1) * math.sqrt(maturity)
sppricer/engine.py ADDED
@@ -0,0 +1,51 @@
1
+ """Monte Carlo pricing engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from sppricer.market import Market
10
+ from sppricer.models import GBMModel, PathModel, simulate_gbm
11
+ from sppricer.products.base import Product
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class PricingResult:
16
+ price: float
17
+ std_error: float
18
+ n_paths: int
19
+
20
+ @property
21
+ def ci95(self) -> tuple[float, float]:
22
+ return self.price - 1.96 * self.std_error, self.price + 1.96 * self.std_error
23
+
24
+ def __str__(self) -> str:
25
+ lo, hi = self.ci95
26
+ return f"{self.price:.4f} ± {1.96 * self.std_error:.4f} (95% CI [{lo:.4f}, {hi:.4f}], {self.n_paths:,} paths)"
27
+
28
+
29
+ def price_mc(product: Product, market: Market, n_paths: int = 100_000,
30
+ seed: int | None = None, antithetic: bool = True, model: PathModel | None = None) -> PricingResult:
31
+ """Price a product by Monte Carlo, under GBM unless another PathModel is given.
32
+
33
+ With antithetic sampling, each (Z, -Z) pair is averaged first so the
34
+ standard error reflects the true (reduced) variance of the estimator.
35
+
36
+ Barrier monitoring other than "european" uses a Brownian-bridge correction
37
+ that assumes flat GBM vol, so it raises under any other model.
38
+ """
39
+ if model is None:
40
+ paths = simulate_gbm(market, product.simulation_times, n_paths, seed, antithetic)
41
+ else:
42
+ monitoring = getattr(product, "barrier_monitoring", "european")
43
+ if monitoring != "european" and not isinstance(model, GBMModel):
44
+ raise ValueError(f"barrier_monitoring={monitoring!r} assumes flat-vol GBM between dates; "
45
+ f"use 'european' with {type(model).__name__}")
46
+ paths = model.simulate(market, product.simulation_times, n_paths, seed, antithetic)
47
+ pv = product.discounted_payoff(paths, market)
48
+ if antithetic:
49
+ half = n_paths // 2
50
+ pv = 0.5 * (pv[:half] + pv[half:])
51
+ return PricingResult(float(pv.mean()), float(pv.std(ddof=1) / np.sqrt(len(pv))), n_paths)
sppricer/greeks.py ADDED
@@ -0,0 +1,58 @@
1
+ """Finite-difference Greeks with common random numbers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from sppricer.engine import price_mc
10
+ from sppricer.market import Market
11
+ from sppricer.models import PathModel
12
+ from sppricer.products.base import Product
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Greeks:
17
+ price: float
18
+ delta: np.ndarray # per underlying, per 1 unit of spot
19
+ gamma: np.ndarray # per underlying, per 1 unit of spot squared
20
+ vega: np.ndarray # per underlying, per 1 vol point (0.01)
21
+ rho: float # per 1 basis point
22
+
23
+
24
+ def bump_greeks(product: Product, market: Market, n_paths: int = 100_000, seed: int = 0,
25
+ spot_bump: float = 0.01, vol_bump: float = 0.01,
26
+ rate_bump: float = 0.0001, model: PathModel | None = None) -> Greeks:
27
+ """Central-difference Greeks, reusing the same seed for every revaluation.
28
+
29
+ Common random numbers cancel most Monte Carlo noise between bumped prices.
30
+ Gamma of digital/barrier features is still noisy with bump-and-reprice;
31
+ see the roadmap (pathwise / likelihood-ratio estimators, payoff smoothing).
32
+
33
+ `model` is passed to price_mc. Vega bumps the underlyings' flat vols, so under
34
+ a model that ignores them (LocalVolModel) it is zero: bump the surface instead.
35
+ """
36
+ def pv(m: Market) -> float:
37
+ return price_mc(product, m, n_paths=n_paths, seed=seed, model=model).price
38
+
39
+ base = pv(market)
40
+ n = market.n_assets
41
+ delta, gamma, vega = np.zeros(n), np.zeros(n), np.zeros(n)
42
+
43
+ for i, u in enumerate(market.underlyings):
44
+ h = u.spot * spot_bump
45
+ up, dn = pv(market.with_spot(i, u.spot + h)), pv(market.with_spot(i, u.spot - h))
46
+ delta[i] = (up - dn) / (2 * h)
47
+ gamma[i] = (up - 2 * base + dn) / h**2
48
+
49
+ vol_dn = max(u.vol - vol_bump, 0.0)
50
+ v_up = pv(market.with_vol(i, u.vol + vol_bump))
51
+ v_dn = pv(market.with_vol(i, vol_dn))
52
+ vega[i] = (v_up - v_dn) / (u.vol + vol_bump - vol_dn) * 0.01
53
+
54
+ r_up = pv(market.with_rate(market.rate + rate_bump))
55
+ r_dn = pv(market.with_rate(market.rate - rate_bump))
56
+ rho = (r_up - r_dn) / 2
57
+
58
+ return Greeks(base, delta, gamma, vega, rho)
sppricer/market.py ADDED
@@ -0,0 +1,122 @@
1
+ """Market data containers: underlyings, rates (flat or curves), and correlation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from typing import Protocol
7
+
8
+ import numpy as np
9
+
10
+
11
+ class Curve(Protocol):
12
+ """A forward and discount term structure, e.g. one implied from an option chain.
13
+
14
+ forward(T) is the forward price of the asset for delivery at T (so forward(0)
15
+ is its spot); discount(T) is the zero-coupon discount factor to T.
16
+ """
17
+
18
+ def forward(self, T): ...
19
+
20
+ def discount(self, T): ...
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Underlying:
25
+ """A single underlying with flat volatility and continuous dividend yield."""
26
+
27
+ name: str
28
+ spot: float
29
+ vol: float
30
+ div_yield: float = 0.0
31
+
32
+ def __post_init__(self) -> None:
33
+ if self.spot <= 0:
34
+ raise ValueError(f"{self.name}: spot must be positive")
35
+ if self.vol < 0:
36
+ raise ValueError(f"{self.name}: vol must be non-negative")
37
+
38
+
39
+ @dataclass(frozen=True, eq=False)
40
+ class Market:
41
+ """Market snapshot: underlyings, flat continuously compounded rate, correlation.
42
+
43
+ Term structure (optional): `curves` gives one Curve per underlying, and the
44
+ asset then grows like its forward, S0 * F(t) / F(0), instead of at r - q; S0
45
+ stays the underlying's spot, so spot bumps work as before. `discount_curve`
46
+ replaces exp(-rate * t) in `discount`. With either set, `rate` and
47
+ `div_yield` no longer enter prices, so `bump_greeks` reports rho = 0.
48
+ """
49
+
50
+ underlyings: tuple[Underlying, ...]
51
+ rate: float
52
+ correlation: np.ndarray | None = None
53
+ curves: tuple[Curve, ...] | None = None
54
+ discount_curve: Curve | None = None
55
+
56
+ def __post_init__(self) -> None:
57
+ n = len(self.underlyings)
58
+ if n == 0:
59
+ raise ValueError("at least one underlying is required")
60
+ corr = np.eye(n) if self.correlation is None else np.asarray(self.correlation, dtype=float)
61
+ if corr.shape != (n, n):
62
+ raise ValueError(f"correlation must be {n}x{n}, got {corr.shape}")
63
+ if not np.allclose(corr, corr.T) or not np.allclose(np.diag(corr), 1.0):
64
+ raise ValueError("correlation must be symmetric with unit diagonal")
65
+ try:
66
+ np.linalg.cholesky(corr)
67
+ except np.linalg.LinAlgError as exc:
68
+ raise ValueError("correlation matrix is not positive definite") from exc
69
+ if self.curves is not None:
70
+ if len(self.curves) != n:
71
+ raise ValueError(f"need one curve per underlying ({n}), got {len(self.curves)}")
72
+ object.__setattr__(self, "curves", tuple(self.curves))
73
+ object.__setattr__(self, "underlyings", tuple(self.underlyings))
74
+ object.__setattr__(self, "correlation", corr)
75
+
76
+ @classmethod
77
+ def single(cls, spot: float, vol: float, rate: float, div_yield: float = 0.0,
78
+ name: str = "S") -> Market:
79
+ return cls((Underlying(name, spot, vol, div_yield),), rate)
80
+
81
+ @property
82
+ def n_assets(self) -> int:
83
+ return len(self.underlyings)
84
+
85
+ @property
86
+ def spots(self) -> np.ndarray:
87
+ return np.array([u.spot for u in self.underlyings])
88
+
89
+ @property
90
+ def vols(self) -> np.ndarray:
91
+ return np.array([u.vol for u in self.underlyings])
92
+
93
+ @property
94
+ def div_yields(self) -> np.ndarray:
95
+ return np.array([u.div_yield for u in self.underlyings])
96
+
97
+ def discount(self, t: float | np.ndarray) -> float | np.ndarray:
98
+ if self.discount_curve is not None:
99
+ return self.discount_curve.discount(np.asarray(t, dtype=float))
100
+ return np.exp(-self.rate * np.asarray(t))
101
+
102
+ def log_growth(self, t: np.ndarray) -> np.ndarray:
103
+ """ln E[S(t)] / S(0) per asset, shape (len(t), n_assets): ln F(t)/F(0) or (r - q) t."""
104
+ t = np.asarray(t, dtype=float)
105
+ if self.curves is None:
106
+ return (self.rate - self.div_yields)[None, :] * t[:, None]
107
+ return np.stack([np.log(c.forward(t) / c.forward(0.0)) for c in self.curves], axis=1)
108
+
109
+ # Bump helpers (used for finite-difference Greeks)
110
+ def _with_underlying(self, i: int, **changes: float) -> Market:
111
+ unds = list(self.underlyings)
112
+ unds[i] = replace(unds[i], **changes)
113
+ return replace(self, underlyings=tuple(unds))
114
+
115
+ def with_spot(self, i: int, spot: float) -> Market:
116
+ return self._with_underlying(i, spot=spot)
117
+
118
+ def with_vol(self, i: int, vol: float) -> Market:
119
+ return self._with_underlying(i, vol=vol)
120
+
121
+ def with_rate(self, rate: float) -> Market:
122
+ return replace(self, rate=rate)
sppricer/models.py ADDED
@@ -0,0 +1,176 @@
1
+ """Path simulation models: GBM (exact, on product dates) and Dupire local vol (log-Euler)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Protocol
7
+
8
+ import numpy as np
9
+
10
+ from sppricer.market import Curve, Market
11
+
12
+
13
+ def _check(times, n_paths: int, antithetic: bool) -> np.ndarray:
14
+ times = np.asarray(times, dtype=float)
15
+ if times.ndim != 1 or len(times) == 0:
16
+ raise ValueError("times must be a non-empty 1-D array")
17
+ if times[0] <= 0 or np.any(np.diff(times) <= 0):
18
+ raise ValueError("times must be positive and strictly increasing")
19
+ if antithetic and n_paths % 2:
20
+ raise ValueError("n_paths must be even when antithetic=True")
21
+ return times
22
+
23
+
24
+ def simulate_gbm(market: Market, times: np.ndarray, n_paths: int,
25
+ seed: int | None = None, antithetic: bool = True) -> np.ndarray:
26
+ """Simulate correlated geometric Brownian motion at the given times.
27
+
28
+ The log-Euler step is exact for GBM, so paths are only generated on the
29
+ dates the payoff needs (e.g. discrete observation dates). With market.curves
30
+ set, the drift over each step is ln F(t2) - ln F(t1) - vol^2 dt / 2.
31
+
32
+ Returns an array of shape (n_paths, n_times, n_assets). With antithetic=True
33
+ the first half of the paths uses Z and the second half uses -Z.
34
+ """
35
+ times = _check(times, n_paths, antithetic)
36
+
37
+ rng = np.random.default_rng(seed)
38
+ n_draw = n_paths // 2 if antithetic else n_paths
39
+ z = rng.standard_normal((n_draw, len(times), market.n_assets))
40
+ if antithetic:
41
+ z = np.concatenate([z, -z], axis=0)
42
+
43
+ chol = np.linalg.cholesky(market.correlation)
44
+ z = z @ chol.T
45
+
46
+ dt = np.diff(np.concatenate([[0.0], times]))[None, :, None]
47
+ vol = market.vols[None, None, :]
48
+ if market.curves is None:
49
+ drift = (market.rate - market.div_yields[None, None, :] - 0.5 * vol**2) * dt
50
+ else:
51
+ drift = np.diff(market.log_growth(np.concatenate([[0.0], times])), axis=0)[None] - 0.5 * vol**2 * dt
52
+ shocks = vol * np.sqrt(dt) * z
53
+ log_paths = np.log(market.spots)[None, None, :] + np.cumsum(drift + shocks, axis=1)
54
+ return np.exp(log_paths)
55
+
56
+
57
+ class PathModel(Protocol):
58
+ """Anything that simulates paths at the product's dates for price_mc.
59
+
60
+ Must return shape (n_paths, len(times), n_assets) in the antithetic layout
61
+ (first half Z, second half -Z), which the engine's standard error relies on.
62
+ """
63
+
64
+ def simulate(self, market: Market, times: np.ndarray, n_paths: int,
65
+ seed: int | None = None, antithetic: bool = True) -> np.ndarray: ...
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class GBMModel:
70
+ """Flat-vol GBM, the default model: exactly simulate_gbm."""
71
+
72
+ def simulate(self, market: Market, times: np.ndarray, n_paths: int,
73
+ seed: int | None = None, antithetic: bool = True) -> np.ndarray:
74
+ return simulate_gbm(market, times, n_paths, seed, antithetic)
75
+
76
+
77
+ class LocalVolSource(Protocol):
78
+ """A Dupire local-vol surface for one asset and the forward curve it was built on.
79
+
80
+ local_vol(K, T) takes absolute strikes (an array) and a time in years and
81
+ returns the local vol at each; it must be finite on the strikes paths reach
82
+ (clamp or extrapolate in the surface, not here).
83
+ """
84
+
85
+ curve: Curve
86
+
87
+ def local_vol(self, K, T): ...
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class LocalVolModel:
92
+ """Dupire (1994) local volatility, one surface per asset, simulated by log-Euler.
93
+
94
+ The grid is every multiple of dt up to the last product date, plus the product
95
+ dates; a date already on the dt grid adds no step, so it does not shift the
96
+ random stream (common random numbers survive a schedule change). Over a step
97
+ [t0, t1] each asset moves by
98
+
99
+ d ln S = ln F(t1) - ln F(t0) - sigma^2 dt / 2 + sigma sqrt(dt) Z,
100
+ sigma = local_vol(S(t0), (t0 + t1) / 2),
101
+
102
+ with F the surface's forward curve, so the asset grows like its forward (the
103
+ surface's curve, not market.rate; match market.discount_curve to it). Z is
104
+ correlated with the market's Cholesky factor, in the antithetic layout.
105
+ Euler bias is O(dt): halve dt to check it.
106
+ """
107
+
108
+ surfaces: tuple[LocalVolSource, ...]
109
+ dt: float = 1 / 252
110
+ n_strikes: int = 401 # strikes per step at which local vol is evaluated, then interpolated in ln K
111
+
112
+ def __post_init__(self) -> None:
113
+ object.__setattr__(self, "surfaces", tuple(self.surfaces))
114
+ if not self.dt > 0:
115
+ raise ValueError("dt must be positive")
116
+
117
+ def grid(self, times: np.ndarray) -> np.ndarray:
118
+ """Simulation grid, starting at 0: multiples of dt below the last date, plus the dates."""
119
+ times = np.asarray(times, dtype=float)
120
+ base = self.dt * np.arange(1, int(np.ceil(times[-1] / self.dt - 1e-9)))
121
+ base = base[np.abs(base[:, None] - times[None, :]).min(axis=1) > 1e-9]
122
+ return np.concatenate([[0.0], np.sort(np.concatenate([base, times]))])
123
+
124
+ def simulate(self, market: Market, times: np.ndarray, n_paths: int,
125
+ seed: int | None = None, antithetic: bool = True) -> np.ndarray:
126
+ """Paths sampled at `times`, shape (n_paths, n_times, n_assets), like simulate_gbm."""
127
+ return self._run(market, times, n_paths, seed, antithetic, keep_grid=False)[1]
128
+
129
+ def simulate_grid(self, market: Market, times: np.ndarray, n_paths: int,
130
+ seed: int | None = None, antithetic: bool = True) -> tuple[np.ndarray, np.ndarray]:
131
+ """(grid, paths on the whole grid), e.g. for barrier monitoring on every step.
132
+
133
+ Same random numbers as simulate. Memory is n_paths * len(grid) * n_assets floats.
134
+ """
135
+ return self._run(market, times, n_paths, seed, antithetic, keep_grid=True)
136
+
137
+ def _run(self, market, times, n_paths, seed, antithetic, keep_grid):
138
+ times = _check(times, n_paths, antithetic)
139
+ if len(self.surfaces) != market.n_assets:
140
+ raise ValueError(f"{len(self.surfaces)} surfaces for {market.n_assets} assets")
141
+ grid = self.grid(times)
142
+ sample = np.searchsorted(grid, times)
143
+ ln_f = np.stack([np.log(s.curve.forward(grid)) for s in self.surfaces], axis=1)
144
+ chol = np.linalg.cholesky(market.correlation)
145
+ rng = np.random.default_rng(seed)
146
+ n_draw = n_paths // 2 if antithetic else n_paths
147
+
148
+ log_s = np.tile(np.log(market.spots), (n_paths, 1))
149
+ out = np.empty((n_paths, len(grid) if keep_grid else len(times), market.n_assets))
150
+ if keep_grid:
151
+ out[:, 0] = market.spots
152
+ j = 0
153
+ for i in range(1, len(grid)):
154
+ t0, t1 = grid[i - 1], grid[i]
155
+ z = rng.standard_normal((n_draw, market.n_assets))
156
+ z = (np.concatenate([z, -z]) if antithetic else z) @ chol.T
157
+ sigma = np.column_stack([self._sigma(s, log_s[:, a], 0.5 * (t0 + t1))
158
+ for a, s in enumerate(self.surfaces)])
159
+ log_s += ln_f[i] - ln_f[i - 1] - 0.5 * sigma**2 * (t1 - t0) + sigma * np.sqrt(t1 - t0) * z
160
+ if keep_grid:
161
+ out[:, i] = np.exp(log_s)
162
+ elif i == sample[j]:
163
+ out[:, j] = np.exp(log_s)
164
+ j += 1
165
+ return grid, out
166
+
167
+ def _sigma(self, surface: LocalVolSource, log_s: np.ndarray, t: float) -> np.ndarray:
168
+ # ponytail: local vol evaluated at n_strikes points spanning today's paths and interpolated
169
+ # linearly in ln K, not per path; raise n_strikes if a surface has sharp features.
170
+ lo, hi = float(log_s.min()), float(log_s.max())
171
+ k = np.linspace(lo, hi, self.n_strikes) if hi > lo else np.array([lo])
172
+ lv = np.asarray(surface.local_vol(np.exp(k), t), dtype=float) * np.ones_like(k)
173
+ if not np.all(np.isfinite(lv)):
174
+ raise ValueError(f"local vol is not finite at t={t:.4f} for strikes the paths reach; "
175
+ "clamp or extrapolate the surface (e.g. floor/cap)")
176
+ return np.interp(log_s, k, lv)
@@ -0,0 +1,6 @@
1
+ from sppricer.products.autocallable import PhoenixAutocallable
2
+ from sppricer.products.base import Product
3
+ from sppricer.products.reverse_convertible import BarrierReverseConvertible
4
+ from sppricer.products.vanilla import EuropeanOption
5
+
6
+ __all__ = ["BarrierReverseConvertible", "EuropeanOption", "PhoenixAutocallable", "Product"]
@@ -0,0 +1,91 @@
1
+ """Phoenix autocallable on a worst-of basket."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from sppricer.market import Market
10
+ from sppricer.products.base import (
11
+ Product,
12
+ knock_in_probability,
13
+ validate_monitoring,
14
+ validate_schedule,
15
+ worst_performance,
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PhoenixAutocallable(Product):
21
+ """Worst-of Phoenix autocallable with optional memory coupon.
22
+
23
+ On each observation date t_i (while the note is alive), with W = worst-of
24
+ performance S_t / S_0:
25
+ * Coupon: if W >= coupon_barrier, pay notional * coupon_rate
26
+ (plus any missed coupons if memory=True).
27
+ * Autocall (from first_autocall_index, before maturity): if
28
+ W >= autocall_barrier, repay notional and terminate.
29
+ * Maturity: with barrier_monitoring="european", repay notional if
30
+ W >= protection_barrier, otherwise notional * W. With "continuous" or
31
+ "daily", the protection barrier is a knock-in watched over the whole
32
+ life: if any asset touched it and W < 1, repay notional * W.
33
+
34
+ Barriers are expressed as fractions of the initial level (0.7 = 70%).
35
+ coupon_rate is per observation period (e.g. 0.02 for 2% per quarter).
36
+ """
37
+
38
+ initial_levels: tuple[float, ...]
39
+ observation_times: tuple[float, ...]
40
+ coupon_rate: float
41
+ coupon_barrier: float = 0.70
42
+ autocall_barrier: float = 1.00
43
+ protection_barrier: float = 0.60
44
+ memory: bool = True
45
+ first_autocall_index: int = 0
46
+ notional: float = 100.0
47
+ barrier_monitoring: str = "european"
48
+
49
+ def __post_init__(self) -> None:
50
+ validate_schedule(self.observation_times)
51
+ validate_monitoring(self.barrier_monitoring)
52
+ for name in ("coupon_barrier", "autocall_barrier", "protection_barrier"):
53
+ if getattr(self, name) < 0:
54
+ raise ValueError(f"{name} must be non-negative")
55
+ if not 0 <= self.first_autocall_index < len(self.observation_times):
56
+ raise ValueError("first_autocall_index out of range")
57
+
58
+ @property
59
+ def simulation_times(self) -> np.ndarray:
60
+ return np.asarray(self.observation_times, dtype=float)
61
+
62
+ def discounted_payoff(self, paths: np.ndarray, market: Market) -> np.ndarray:
63
+ worst = worst_performance(paths, self.initial_levels)
64
+ n_paths, n_obs = worst.shape
65
+ alive = np.ones(n_paths, dtype=bool)
66
+ missed = np.zeros(n_paths, dtype=int)
67
+ pv = np.zeros(n_paths)
68
+
69
+ for i, t in enumerate(self.simulation_times):
70
+ df = market.discount(t)
71
+ w = worst[:, i]
72
+
73
+ pays_coupon = alive & (w >= self.coupon_barrier)
74
+ n_coupons = missed + 1 if self.memory else 1
75
+ pv += np.where(pays_coupon, self.notional * self.coupon_rate * n_coupons, 0.0) * df
76
+ missed = np.where(alive & ~pays_coupon, missed + 1, 0)
77
+
78
+ if i == n_obs - 1:
79
+ if self.barrier_monitoring == "european":
80
+ redemption = np.where(w >= self.protection_barrier, self.notional, self.notional * w)
81
+ else:
82
+ p_ki = knock_in_probability(paths, self.simulation_times, self.initial_levels,
83
+ self.protection_barrier, market, self.barrier_monitoring)
84
+ redemption = self.notional * (1.0 - p_ki * np.maximum(1.0 - w, 0.0))
85
+ pv += np.where(alive, redemption, 0.0) * df
86
+ elif i >= self.first_autocall_index:
87
+ called = alive & (w >= self.autocall_barrier)
88
+ pv += np.where(called, self.notional, 0.0) * df
89
+ alive &= ~called
90
+
91
+ return pv
@@ -0,0 +1,79 @@
1
+ """Product interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ import numpy as np
8
+
9
+ from sppricer.market import Market
10
+
11
+
12
+ class Product(ABC):
13
+ """A product defines which dates to simulate and how to value each path.
14
+
15
+ `discounted_payoff` receives paths of shape (n_paths, n_times, n_assets),
16
+ sampled at `simulation_times`, and returns the present value per path.
17
+ """
18
+
19
+ @property
20
+ @abstractmethod
21
+ def simulation_times(self) -> np.ndarray: ...
22
+
23
+ @abstractmethod
24
+ def discounted_payoff(self, paths: np.ndarray, market: Market) -> np.ndarray: ...
25
+
26
+
27
+ def validate_schedule(times: tuple[float, ...]) -> np.ndarray:
28
+ arr = np.asarray(times, dtype=float)
29
+ if arr.ndim != 1 or len(arr) == 0:
30
+ raise ValueError("schedule must be a non-empty sequence of times")
31
+ if arr[0] <= 0 or np.any(np.diff(arr) <= 0):
32
+ raise ValueError("schedule must be positive and strictly increasing")
33
+ return arr
34
+
35
+
36
+ BARRIER_MONITORING = ("european", "continuous", "daily")
37
+
38
+ # Broadie-Glasserman-Kou: a barrier monitored every dt behaves like a continuous
39
+ # barrier shifted away from spot by exp(-beta * vol * sqrt(dt)), beta = -zeta(1/2)/sqrt(2*pi).
40
+ _BGK_BETA = 0.5826
41
+
42
+
43
+ def validate_monitoring(monitoring: str) -> None:
44
+ if monitoring not in BARRIER_MONITORING:
45
+ raise ValueError(f"barrier_monitoring must be one of {BARRIER_MONITORING}, got {monitoring!r}")
46
+
47
+
48
+ def knock_in_probability(paths: np.ndarray, times: np.ndarray, initial_levels: tuple[float, ...],
49
+ barrier: float, market: Market, monitoring: str) -> np.ndarray:
50
+ """Probability, per path, that any asset touched the down barrier over [0, times[-1]].
51
+
52
+ Uses the Brownian-bridge crossing probability between simulated dates,
53
+ exp(-2 * a * b / (vol^2 * dt)) with a, b the log-distances above the barrier,
54
+ so paths only need the product's observation dates. "daily" applies the
55
+ Broadie-Glasserman-Kou shift to match daily closing-price monitoring.
56
+ """
57
+ vols = market.vols
58
+ levels = np.asarray(initial_levels, dtype=float) * barrier
59
+ if monitoring == "daily":
60
+ levels = levels * np.exp(-_BGK_BETA * vols * np.sqrt(1 / 252))
61
+ with np.errstate(divide="ignore"):
62
+ log_b = np.log(levels)
63
+ start = np.broadcast_to(np.log(market.spots), (paths.shape[0], 1, paths.shape[2]))
64
+ dist = np.concatenate([start, np.log(paths)], axis=1) - log_b
65
+ a, b = np.clip(dist[:, :-1], 0, None), np.clip(dist[:, 1:], 0, None)
66
+ var = vols**2 * np.diff(np.concatenate([[0.0], times]))[:, None]
67
+ p_cross = np.exp(-2 * a * b / np.maximum(var, 1e-300))
68
+ # ponytail: assets' bridges treated as independent given endpoints; slightly overstates
69
+ # knock-in for positively correlated baskets on coarse grids. Refine the grid if it matters.
70
+ return 1.0 - np.prod(1.0 - p_cross, axis=(1, 2))
71
+
72
+
73
+ def worst_performance(paths: np.ndarray, initial_levels: tuple[float, ...]) -> np.ndarray:
74
+ """Worst-of performance S_t / S_0 across assets, shape (n_paths, n_times)."""
75
+ if paths.shape[2] != len(initial_levels):
76
+ raise ValueError(
77
+ f"product has {len(initial_levels)} initial levels but market has {paths.shape[2]} assets"
78
+ )
79
+ return (paths / np.asarray(initial_levels, dtype=float)).min(axis=2)
@@ -0,0 +1,61 @@
1
+ """Barrier reverse convertible on a worst-of basket."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from sppricer.market import Market
10
+ from sppricer.products.base import (
11
+ Product,
12
+ knock_in_probability,
13
+ validate_monitoring,
14
+ validate_schedule,
15
+ worst_performance,
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class BarrierReverseConvertible(Product):
21
+ """Guaranteed coupons plus a short down-and-in put at maturity.
22
+
23
+ Coupons of notional * coupon_rate are paid on every coupon date. At
24
+ maturity the investor receives notional, unless the put has knocked in
25
+ and worst-of performance W is below 1, in which case they get notional * W.
26
+
27
+ barrier_monitoring:
28
+ * "european": knocked in if W < knock_in_barrier at maturity only.
29
+ * "continuous" / "daily": knocked in if any asset touched the barrier at
30
+ any time (daily closes for "daily"), via a Brownian-bridge correction
31
+ on the coupon-date grid.
32
+ """
33
+
34
+ initial_levels: tuple[float, ...]
35
+ coupon_times: tuple[float, ...]
36
+ coupon_rate: float
37
+ knock_in_barrier: float = 0.60
38
+ notional: float = 100.0
39
+ barrier_monitoring: str = "european"
40
+
41
+ def __post_init__(self) -> None:
42
+ validate_schedule(self.coupon_times)
43
+ validate_monitoring(self.barrier_monitoring)
44
+
45
+ @property
46
+ def simulation_times(self) -> np.ndarray:
47
+ if self.barrier_monitoring == "european":
48
+ return np.array([self.coupon_times[-1]])
49
+ return np.asarray(self.coupon_times, dtype=float)
50
+
51
+ def discounted_payoff(self, paths: np.ndarray, market: Market) -> np.ndarray:
52
+ times = np.asarray(self.coupon_times, dtype=float)
53
+ coupons_pv = self.notional * self.coupon_rate * np.sum(market.discount(times))
54
+ w = worst_performance(paths, self.initial_levels)[:, -1]
55
+ if self.barrier_monitoring == "european":
56
+ redemption = np.where(w >= self.knock_in_barrier, self.notional, self.notional * w)
57
+ else:
58
+ p_ki = knock_in_probability(paths, self.simulation_times, self.initial_levels,
59
+ self.knock_in_barrier, market, self.barrier_monitoring)
60
+ redemption = self.notional * (1.0 - p_ki * np.maximum(1.0 - w, 0.0))
61
+ return coupons_pv + redemption * market.discount(times[-1])
@@ -0,0 +1,27 @@
1
+ """European vanilla options (benchmark product for engine validation)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from sppricer.market import Market
10
+ from sppricer.products.base import Product
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class EuropeanOption(Product):
15
+ strike: float
16
+ maturity: float
17
+ is_call: bool = True
18
+ asset_index: int = 0
19
+
20
+ @property
21
+ def simulation_times(self) -> np.ndarray:
22
+ return np.array([self.maturity])
23
+
24
+ def discounted_payoff(self, paths: np.ndarray, market: Market) -> np.ndarray:
25
+ s_t = paths[:, -1, self.asset_index]
26
+ intrinsic = s_t - self.strike if self.is_call else self.strike - s_t
27
+ return np.maximum(intrinsic, 0.0) * market.discount(self.maturity)
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: sppricer
3
+ Version: 0.2.1
4
+ Summary: Monte Carlo pricing and risk for structured products (autocallables, reverse convertibles)
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/hossainyousuf751/sp-pricer
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy>=1.24
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7; extra == "dev"
13
+ Requires-Dist: pytest-cov>=4; extra == "dev"
14
+ Requires-Dist: ruff>=0.5; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # sppricer
18
+
19
+ ![tests](https://github.com/hossainyousuf751/sp-pricer/actions/workflows/ci.yml/badge.svg)
20
+
21
+ Monte Carlo pricing and risk for retail structured products: worst-of Phoenix
22
+ autocallables and barrier reverse convertibles. The goal is a transparent,
23
+ tested engine that answers a practical question: **what is a structured note
24
+ actually worth compared with its issue price?**
25
+
26
+ ## Quickstart
27
+
28
+ ```bash
29
+ pip install sppricer # from PyPI
30
+ pip install -e ".[dev]" # or, from a clone, for development
31
+ pytest
32
+ python examples/price_worst_of_phoenix.py
33
+ ```
34
+
35
+ ```python
36
+ import numpy as np
37
+ from sppricer import Market, Underlying, PhoenixAutocallable, price_mc, bump_greeks
38
+
39
+ market = Market(
40
+ (Underlying("STOCK_A", 100.0, 0.25, 0.02), Underlying("STOCK_B", 50.0, 0.30, 0.015)),
41
+ rate=0.04,
42
+ correlation=np.array([[1.0, 0.6], [0.6, 1.0]]),
43
+ )
44
+ note = PhoenixAutocallable(
45
+ initial_levels=(100.0, 50.0),
46
+ observation_times=tuple(0.25 * k for k in range(1, 13)),
47
+ coupon_rate=0.02, coupon_barrier=0.70, autocall_barrier=1.00,
48
+ protection_barrier=0.60, memory=True, first_autocall_index=3,
49
+ )
50
+ print(price_mc(note, market, n_paths=400_000, seed=42))
51
+ ```
52
+
53
+ Example output (3-year, quarterly, 8% p.a. memory coupon, 60% European protection):
54
+
55
+ | Metric | Value |
56
+ |---|---|
57
+ | Fair value per 100 notional | 93.88 ± 0.07 (95% CI) |
58
+ | Implied issuer margin | ~6.1 |
59
+ | Fair value, barrier on daily closes | 90.58 ± 0.06 |
60
+ | Vega per vol point (A / B) | −0.44 / −0.54 |
61
+
62
+ The investor is short volatility and short gamma, which is the defining risk of
63
+ these products: the coupon is compensation for the down-and-in put the investor sells.
64
+
65
+ ## Barrier monitoring
66
+
67
+ Both structured products take `barrier_monitoring`:
68
+
69
+ - `"european"` (default): barrier checked at maturity only.
70
+ - `"continuous"`: knock-in if any asset ever touches the barrier.
71
+ - `"daily"`: knock-in on daily closes, which is what most term sheets specify.
72
+
73
+ Paths are still simulated only on observation dates. The chance of crossing
74
+ between them comes from the Brownian-bridge formula, and `"daily"` adds the
75
+ Broadie-Glasserman-Kou barrier shift. The tests check this against a brute-force
76
+ daily simulation using the same random paths. Daily monitoring takes about 3.3
77
+ points off the example note, so a maturity-only barrier noticeably overstates its value.
78
+
79
+ ## Local volatility and term structure (v0.2, M6)
80
+
81
+ Every product here is short a down-and-in put, so its value depends on the put skew, which flat
82
+ vol ignores. `price_mc(product, market, model=...)` takes any `PathModel`: `GBMModel` (the default,
83
+ identical numbers) or `LocalVolModel`, a Dupire (1994) local-vol simulation.
84
+
85
+ ```python
86
+ from sppricer import LocalVolModel, price_mc
87
+
88
+ model = LocalVolModel([surface_a, surface_b], dt=1 / 252) # any objects with local_vol(K, T) and .curve
89
+ price_mc(note, market, n_paths=200_000, seed=42, model=model)
90
+ ```
91
+
92
+ - **No dependency on a surface library.** A surface is anything with `local_vol(K, T)` (absolute
93
+ strike, years) and a `curve` with `forward(T)` / `discount(T)` (the `LocalVolSource` protocol).
94
+ [quant-stack](https://github.com/hossainyousuf751/quant-stack) wires in volsurface's SSVI surfaces.
95
+ - **Scheme.** Log-Euler on a grid of every dt plus every observation date. Each step drifts by
96
+ ln F(t+dt) − ln F(t) − ½σ²dt, so the asset tracks its forward curve, with σ = local vol at the
97
+ current spot and the step's mid-time. Correlation uses the market's Cholesky factor, and
98
+ antithetic sampling is kept, so standard errors and common random numbers work as before.
99
+ The bias is O(dt): the tests check that it shrinks when dt halves.
100
+ - **Term structure.** `Market(..., curves=(...), discount_curve=...)` replaces the flat rate and
101
+ dividend yield by forward and discount curves. GBM then drifts along each asset's forward, and
102
+ products discount with `market.discount`.
103
+ - **Barriers.** Daily or continuous monitoring uses a Brownian-bridge correction that assumes
104
+ flat GBM vol between dates, so `price_mc` raises under local vol. With local vol, use
105
+ `"european"` monitoring, or `LocalVolModel.simulate_grid` to monitor on every step.
106
+
107
+ ## Design
108
+
109
+ | Module | Responsibility |
110
+ |---|---|
111
+ | `market.py` | Immutable market snapshot: spots, flat vols, dividend yields, rate (or forward/discount curves), correlation (validated PSD) |
112
+ | `models.py` | Correlated GBM via Cholesky, exact log-step on observation dates only, antithetic variates; Dupire local vol (log-Euler) behind the same `PathModel` interface |
113
+ | `products/` | Each product declares its simulation dates and a vectorized `discounted_payoff(paths, market)` |
114
+ | `engine.py` | MC pricing with correct standard errors under antithetic sampling |
115
+ | `greeks.py` | Central-difference Greeks with common random numbers |
116
+ | `analytics.py` | Black-Scholes closed forms used as benchmarks |
117
+
118
+ Adding a product means implementing two methods; the engine and Greeks work unchanged.
119
+
120
+ ## Validation
121
+
122
+ The test suite checks the engine against cases with known answers:
123
+
124
+ - European call/put prices converge to Black-Scholes within 4 standard errors.
125
+ - Put-call parity holds; antithetic sampling reduces standard error.
126
+ - Bump-and-reprice delta and vega match Black-Scholes analytics.
127
+ - Limit cases: unreachable barriers give a zero-coupon bond; zero barriers give an immediate
128
+ call; full downside with no coupons gives the discounted forward; a zero-barrier BRC gives a coupon bond.
129
+ - Monotonicity: memory coupons are worth more; worst-of is cheaper than single-asset;
130
+ european > daily > continuous barrier monitoring.
131
+ - The daily-monitoring Brownian-bridge estimator matches brute-force daily simulation
132
+ (single asset and basket) within 4 standard errors.
133
+
134
+ ## Roadmap
135
+
136
+ Done: M1 core engine, M2 Greeks, M4 continuous barriers, M6 local vol and curves, T1 tooling.
137
+ Next: issuer funding spread, variance reduction. Full plan with acceptance criteria in [ROADMAP.md](https://github.com/hossainyousuf751/sp-pricer/blob/main/ROADMAP.md); how to
138
+ contribute in [CONTRIBUTING.md](https://github.com/hossainyousuf751/sp-pricer/blob/main/CONTRIBUTING.md).
139
+
140
+ ## Limitations
141
+
142
+ Under the default flat volatility, the skew is ignored, which understates the value of the
143
+ short down-and-in put. Fair values are therefore biased upward relative to a skew-aware model.
144
+ `LocalVolModel` removes that bias given a surface. Under local vol, `bump_greeks` vega is zero,
145
+ so bump the surface instead. Barrier monitoring must be European.
146
+ Dividends are continuous yields, and there is no issuer credit spread (funding
147
+ discounting), which matters materially for real notes. For baskets, the
148
+ Brownian-bridge correction treats each asset's path between observation dates as
149
+ independent, which slightly overstates knock-in on coarse grids (about 0.03 on the test basket).
150
+
151
+ ## Part of quant-stack
152
+
153
+ sp-pricer is one of five independent libraries linked in
154
+ [quant-stack](https://github.com/hossainyousuf751/quant-stack). There, volsurface surfaces drive
155
+ `LocalVolModel` (study S1, skew and the autocallable), and risk-lab revalues sp-pricer notes for
156
+ VaR. sp-pricer itself depends only on numpy.
157
+
158
+ ## Disclaimer
159
+
160
+ Educational project. All examples use synthetic or publicly available term sheets.
@@ -0,0 +1,16 @@
1
+ sppricer/__init__.py,sha256=NjxY4kNNpPDouCWQZaFIMUZRYWgYz2Sk41MT2LQZYX0,870
2
+ sppricer/analytics.py,sha256=Tcyj5cL95N-qZAg9zaiWEaVcU5g8Q9How7Uc6XH9Mt8,1694
3
+ sppricer/engine.py,sha256=A1LygGPChJj0X0DS9xY9sYUlaJBT9z5h61Kz0Dn2Wfs,2035
4
+ sppricer/greeks.py,sha256=wZDFeY8pw46HcD2GnQcnwxcRr7VNuGXS8zRJyBqcQPk,2222
5
+ sppricer/market.py,sha256=qlasMQWdrKMvxJwH-tarBDLLX8g6NAkkPy0HHQF_uXc,4589
6
+ sppricer/models.py,sha256=-pEi4uQEW1q21YYIdIRhiFHLFnDCAUvkywto6Dq6icc,7978
7
+ sppricer/products/__init__.py,sha256=jUXHD3epbnJPidrKKOghdFgVMGGZLxkccRdEj9MV_Bk,328
8
+ sppricer/products/autocallable.py,sha256=ynGbVg0AMStvKrutL0bc6yx5jj_7SeH-GBkuZDVJxC4,3690
9
+ sppricer/products/base.py,sha256=JHKVgeSNEqYqi7fYr_LvZPty7Kah5-n84VjljQMvPVc,3307
10
+ sppricer/products/reverse_convertible.py,sha256=qtdiLtLLkIQDKQAiHuv3G_Cn8e1HUYrEsrF8rePu6r4,2342
11
+ sppricer/products/vanilla.py,sha256=GuHfjQTHg_d2Oe7_ABfPmqo09cRhlDjokfxnlyfhNLs,771
12
+ sppricer-0.2.1.dist-info/licenses/LICENSE,sha256=OAV_RTERHYKJrtHIxvr_VWUiM6k-1PKksx8ezi9wMmY,1079
13
+ sppricer-0.2.1.dist-info/METADATA,sha256=WRMxdroOtKnrayp6KCm_gXAaLYBxONSC2ae1R9yQyAw,7776
14
+ sppricer-0.2.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
15
+ sppricer-0.2.1.dist-info/top_level.txt,sha256=QclH6L3OcpB_bbB68y6q6XuEIsFMQcDYLIFcyZPtJWY,9
16
+ sppricer-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yousuf Hossain Shafeen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sppricer