sppricer 0.2.1__tar.gz
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-0.2.1/LICENSE +21 -0
- sppricer-0.2.1/PKG-INFO +160 -0
- sppricer-0.2.1/README.md +144 -0
- sppricer-0.2.1/pyproject.toml +34 -0
- sppricer-0.2.1/setup.cfg +4 -0
- sppricer-0.2.1/src/sppricer/__init__.py +35 -0
- sppricer-0.2.1/src/sppricer/analytics.py +43 -0
- sppricer-0.2.1/src/sppricer/engine.py +51 -0
- sppricer-0.2.1/src/sppricer/greeks.py +58 -0
- sppricer-0.2.1/src/sppricer/market.py +122 -0
- sppricer-0.2.1/src/sppricer/models.py +176 -0
- sppricer-0.2.1/src/sppricer/products/__init__.py +6 -0
- sppricer-0.2.1/src/sppricer/products/autocallable.py +91 -0
- sppricer-0.2.1/src/sppricer/products/base.py +79 -0
- sppricer-0.2.1/src/sppricer/products/reverse_convertible.py +61 -0
- sppricer-0.2.1/src/sppricer/products/vanilla.py +27 -0
- sppricer-0.2.1/src/sppricer.egg-info/PKG-INFO +160 -0
- sppricer-0.2.1/src/sppricer.egg-info/SOURCES.txt +24 -0
- sppricer-0.2.1/src/sppricer.egg-info/dependency_links.txt +1 -0
- sppricer-0.2.1/src/sppricer.egg-info/requires.txt +6 -0
- sppricer-0.2.1/src/sppricer.egg-info/top_level.txt +1 -0
- sppricer-0.2.1/tests/test_barriers.py +59 -0
- sppricer-0.2.1/tests/test_models.py +153 -0
- sppricer-0.2.1/tests/test_regression.py +34 -0
- sppricer-0.2.1/tests/test_structured.py +75 -0
- sppricer-0.2.1/tests/test_vanilla.py +38 -0
sppricer-0.2.1/LICENSE
ADDED
|
@@ -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.
|
sppricer-0.2.1/PKG-INFO
ADDED
|
@@ -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.
|
sppricer-0.2.1/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# sppricer
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Monte Carlo pricing and risk for retail structured products: worst-of Phoenix
|
|
6
|
+
autocallables and barrier reverse convertibles. The goal is a transparent,
|
|
7
|
+
tested engine that answers a practical question: **what is a structured note
|
|
8
|
+
actually worth compared with its issue price?**
|
|
9
|
+
|
|
10
|
+
## Quickstart
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install sppricer # from PyPI
|
|
14
|
+
pip install -e ".[dev]" # or, from a clone, for development
|
|
15
|
+
pytest
|
|
16
|
+
python examples/price_worst_of_phoenix.py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import numpy as np
|
|
21
|
+
from sppricer import Market, Underlying, PhoenixAutocallable, price_mc, bump_greeks
|
|
22
|
+
|
|
23
|
+
market = Market(
|
|
24
|
+
(Underlying("STOCK_A", 100.0, 0.25, 0.02), Underlying("STOCK_B", 50.0, 0.30, 0.015)),
|
|
25
|
+
rate=0.04,
|
|
26
|
+
correlation=np.array([[1.0, 0.6], [0.6, 1.0]]),
|
|
27
|
+
)
|
|
28
|
+
note = PhoenixAutocallable(
|
|
29
|
+
initial_levels=(100.0, 50.0),
|
|
30
|
+
observation_times=tuple(0.25 * k for k in range(1, 13)),
|
|
31
|
+
coupon_rate=0.02, coupon_barrier=0.70, autocall_barrier=1.00,
|
|
32
|
+
protection_barrier=0.60, memory=True, first_autocall_index=3,
|
|
33
|
+
)
|
|
34
|
+
print(price_mc(note, market, n_paths=400_000, seed=42))
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Example output (3-year, quarterly, 8% p.a. memory coupon, 60% European protection):
|
|
38
|
+
|
|
39
|
+
| Metric | Value |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Fair value per 100 notional | 93.88 ± 0.07 (95% CI) |
|
|
42
|
+
| Implied issuer margin | ~6.1 |
|
|
43
|
+
| Fair value, barrier on daily closes | 90.58 ± 0.06 |
|
|
44
|
+
| Vega per vol point (A / B) | −0.44 / −0.54 |
|
|
45
|
+
|
|
46
|
+
The investor is short volatility and short gamma, which is the defining risk of
|
|
47
|
+
these products: the coupon is compensation for the down-and-in put the investor sells.
|
|
48
|
+
|
|
49
|
+
## Barrier monitoring
|
|
50
|
+
|
|
51
|
+
Both structured products take `barrier_monitoring`:
|
|
52
|
+
|
|
53
|
+
- `"european"` (default): barrier checked at maturity only.
|
|
54
|
+
- `"continuous"`: knock-in if any asset ever touches the barrier.
|
|
55
|
+
- `"daily"`: knock-in on daily closes, which is what most term sheets specify.
|
|
56
|
+
|
|
57
|
+
Paths are still simulated only on observation dates. The chance of crossing
|
|
58
|
+
between them comes from the Brownian-bridge formula, and `"daily"` adds the
|
|
59
|
+
Broadie-Glasserman-Kou barrier shift. The tests check this against a brute-force
|
|
60
|
+
daily simulation using the same random paths. Daily monitoring takes about 3.3
|
|
61
|
+
points off the example note, so a maturity-only barrier noticeably overstates its value.
|
|
62
|
+
|
|
63
|
+
## Local volatility and term structure (v0.2, M6)
|
|
64
|
+
|
|
65
|
+
Every product here is short a down-and-in put, so its value depends on the put skew, which flat
|
|
66
|
+
vol ignores. `price_mc(product, market, model=...)` takes any `PathModel`: `GBMModel` (the default,
|
|
67
|
+
identical numbers) or `LocalVolModel`, a Dupire (1994) local-vol simulation.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from sppricer import LocalVolModel, price_mc
|
|
71
|
+
|
|
72
|
+
model = LocalVolModel([surface_a, surface_b], dt=1 / 252) # any objects with local_vol(K, T) and .curve
|
|
73
|
+
price_mc(note, market, n_paths=200_000, seed=42, model=model)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **No dependency on a surface library.** A surface is anything with `local_vol(K, T)` (absolute
|
|
77
|
+
strike, years) and a `curve` with `forward(T)` / `discount(T)` (the `LocalVolSource` protocol).
|
|
78
|
+
[quant-stack](https://github.com/hossainyousuf751/quant-stack) wires in volsurface's SSVI surfaces.
|
|
79
|
+
- **Scheme.** Log-Euler on a grid of every dt plus every observation date. Each step drifts by
|
|
80
|
+
ln F(t+dt) − ln F(t) − ½σ²dt, so the asset tracks its forward curve, with σ = local vol at the
|
|
81
|
+
current spot and the step's mid-time. Correlation uses the market's Cholesky factor, and
|
|
82
|
+
antithetic sampling is kept, so standard errors and common random numbers work as before.
|
|
83
|
+
The bias is O(dt): the tests check that it shrinks when dt halves.
|
|
84
|
+
- **Term structure.** `Market(..., curves=(...), discount_curve=...)` replaces the flat rate and
|
|
85
|
+
dividend yield by forward and discount curves. GBM then drifts along each asset's forward, and
|
|
86
|
+
products discount with `market.discount`.
|
|
87
|
+
- **Barriers.** Daily or continuous monitoring uses a Brownian-bridge correction that assumes
|
|
88
|
+
flat GBM vol between dates, so `price_mc` raises under local vol. With local vol, use
|
|
89
|
+
`"european"` monitoring, or `LocalVolModel.simulate_grid` to monitor on every step.
|
|
90
|
+
|
|
91
|
+
## Design
|
|
92
|
+
|
|
93
|
+
| Module | Responsibility |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `market.py` | Immutable market snapshot: spots, flat vols, dividend yields, rate (or forward/discount curves), correlation (validated PSD) |
|
|
96
|
+
| `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 |
|
|
97
|
+
| `products/` | Each product declares its simulation dates and a vectorized `discounted_payoff(paths, market)` |
|
|
98
|
+
| `engine.py` | MC pricing with correct standard errors under antithetic sampling |
|
|
99
|
+
| `greeks.py` | Central-difference Greeks with common random numbers |
|
|
100
|
+
| `analytics.py` | Black-Scholes closed forms used as benchmarks |
|
|
101
|
+
|
|
102
|
+
Adding a product means implementing two methods; the engine and Greeks work unchanged.
|
|
103
|
+
|
|
104
|
+
## Validation
|
|
105
|
+
|
|
106
|
+
The test suite checks the engine against cases with known answers:
|
|
107
|
+
|
|
108
|
+
- European call/put prices converge to Black-Scholes within 4 standard errors.
|
|
109
|
+
- Put-call parity holds; antithetic sampling reduces standard error.
|
|
110
|
+
- Bump-and-reprice delta and vega match Black-Scholes analytics.
|
|
111
|
+
- Limit cases: unreachable barriers give a zero-coupon bond; zero barriers give an immediate
|
|
112
|
+
call; full downside with no coupons gives the discounted forward; a zero-barrier BRC gives a coupon bond.
|
|
113
|
+
- Monotonicity: memory coupons are worth more; worst-of is cheaper than single-asset;
|
|
114
|
+
european > daily > continuous barrier monitoring.
|
|
115
|
+
- The daily-monitoring Brownian-bridge estimator matches brute-force daily simulation
|
|
116
|
+
(single asset and basket) within 4 standard errors.
|
|
117
|
+
|
|
118
|
+
## Roadmap
|
|
119
|
+
|
|
120
|
+
Done: M1 core engine, M2 Greeks, M4 continuous barriers, M6 local vol and curves, T1 tooling.
|
|
121
|
+
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
|
|
122
|
+
contribute in [CONTRIBUTING.md](https://github.com/hossainyousuf751/sp-pricer/blob/main/CONTRIBUTING.md).
|
|
123
|
+
|
|
124
|
+
## Limitations
|
|
125
|
+
|
|
126
|
+
Under the default flat volatility, the skew is ignored, which understates the value of the
|
|
127
|
+
short down-and-in put. Fair values are therefore biased upward relative to a skew-aware model.
|
|
128
|
+
`LocalVolModel` removes that bias given a surface. Under local vol, `bump_greeks` vega is zero,
|
|
129
|
+
so bump the surface instead. Barrier monitoring must be European.
|
|
130
|
+
Dividends are continuous yields, and there is no issuer credit spread (funding
|
|
131
|
+
discounting), which matters materially for real notes. For baskets, the
|
|
132
|
+
Brownian-bridge correction treats each asset's path between observation dates as
|
|
133
|
+
independent, which slightly overstates knock-in on coarse grids (about 0.03 on the test basket).
|
|
134
|
+
|
|
135
|
+
## Part of quant-stack
|
|
136
|
+
|
|
137
|
+
sp-pricer is one of five independent libraries linked in
|
|
138
|
+
[quant-stack](https://github.com/hossainyousuf751/quant-stack). There, volsurface surfaces drive
|
|
139
|
+
`LocalVolModel` (study S1, skew and the autocallable), and risk-lab revalues sp-pricer notes for
|
|
140
|
+
VaR. sp-pricer itself depends only on numpy.
|
|
141
|
+
|
|
142
|
+
## Disclaimer
|
|
143
|
+
|
|
144
|
+
Educational project. All examples use synthetic or publicly available term sheets.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sppricer"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "Monte Carlo pricing and risk for structured products (autocallables, reverse convertibles)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["numpy>=1.24"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/hossainyousuf751/sp-pricer"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=7", "pytest-cov>=4", "ruff>=0.5"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
markers = ["slow: README-scale runs (seconds to minutes); run with -m slow"]
|
|
27
|
+
addopts = "-m 'not slow'"
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
line-length = 120
|
|
31
|
+
target-version = "py310"
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
sppricer-0.2.1/setup.cfg
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
|
+
]
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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)
|