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 +35 -0
- sppricer/analytics.py +43 -0
- sppricer/engine.py +51 -0
- sppricer/greeks.py +58 -0
- sppricer/market.py +122 -0
- sppricer/models.py +176 -0
- sppricer/products/__init__.py +6 -0
- sppricer/products/autocallable.py +91 -0
- sppricer/products/base.py +79 -0
- sppricer/products/reverse_convertible.py +61 -0
- sppricer/products/vanilla.py +27 -0
- sppricer-0.2.1.dist-info/METADATA +160 -0
- sppricer-0.2.1.dist-info/RECORD +16 -0
- sppricer-0.2.1.dist-info/WHEEL +5 -0
- sppricer-0.2.1.dist-info/licenses/LICENSE +21 -0
- sppricer-0.2.1.dist-info/top_level.txt +1 -0
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
|
+

|
|
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,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
|