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 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.
@@ -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,144 @@
1
+ # sppricer
2
+
3
+ ![tests](https://github.com/hossainyousuf751/sp-pricer/actions/workflows/ci.yml/badge.svg)
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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)