From 606b8f139882804ddcb87f661f307417c264556c Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Tue, 12 May 2026 21:12:05 +0100 Subject: [PATCH 01/10] Add MC vs FD American regression tests - Also update README --- README.md | 1 - tests/test_barrier.py | 122 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bda9f84..aa069d8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ knock-in and knock-out structures, and rebates. - **Stochastic processes** — Geometric Brownian Motion, Jump Diffusion (Merton), Square-Root Diffusion (CIR) - **Discount curves** — log-linear interpolation on arbitrary term structures; deterministic time-varying forward rate and dividend curves - **Discrete dividends** — supported across all pricing methods -- **Barrier options** — continuous and discrete monitoring, knock-in/knock-out, rebates (at-hit and at-expiry) - **Control variates** — European analytical control variates for American pricing variance reduction - **Custom payoffs** — user-defined payoff functions via `PayoffSpec` diff --git a/tests/test_barrier.py b/tests/test_barrier.py index 69ef5d9..1648a9a 100644 --- a/tests/test_barrier.py +++ b/tests/test_barrier.py @@ -3692,3 +3692,125 @@ def test_american_ko_without_barrier_aware_basis(self): assert np.isclose(pv_naive, pv_aware, rtol=0.05), ( f"barrier_aware_basis=False ({pv_naive:.4f}) vs True ({pv_aware:.4f})" ) + + +class TestBarrierMCAmericanRegressionVsPDE: + """Pinned regression checks: American MC barrier PVs vs PDE_FD on the + **well-behaved** (non-reverse) cases where MC LSM is expected to + track PDE within sampling noise. Spreads coverage across: + + - continuous monitoring (DOC, UIC) + - discrete monitoring via ``num_observations`` (UOP, M=50) + - discrete monitoring via explicit ``monitoring_dates`` (DIP, weekly) + + Reverse-barrier American cases (DOP, UOC) have documented LSM + downward bias (warning emitted at ``__init__``) and are covered + separately in notebooks/scripts at higher path counts. All four + cases here use a fixed seed and a loose ~3% tolerance so they catch + real engine regressions without flagging benign seed-noise drift. + """ + + # 52 weekly observation dates spanning the 1-year contract, used by + # the DIP regression below to exercise the explicit-monitoring-dates + # code path (separate from the ``num_observations`` code path). + _WEEKLY_OBS_DATES = tuple(PRICING_DATE + dt.timedelta(days=7 * (i + 1)) for i in range(52)) + + @pytest.mark.parametrize( + "option_type,direction,action,barrier,monitoring,num_observations,monitoring_dates,label", + [ + pytest.param( + OptionType.CALL, + BarrierDirection.DOWN, + BarrierAction.OUT, + 85.0, + BarrierMonitoring.CONTINUOUS, + None, + None, + "DOC continuous", + id="DOC_American_continuous", + ), + pytest.param( + OptionType.CALL, + BarrierDirection.UP, + BarrierAction.IN, + 115.0, + BarrierMonitoring.CONTINUOUS, + None, + None, + "UIC continuous", + id="UIC_American_continuous", + ), + pytest.param( + OptionType.PUT, + BarrierDirection.UP, + BarrierAction.OUT, + 115.0, + BarrierMonitoring.DISCRETE, + 50, + None, + "UOP discrete M=50", + id="UOP_American_discrete_M50", + ), + pytest.param( + OptionType.PUT, + BarrierDirection.DOWN, + BarrierAction.IN, + 85.0, + BarrierMonitoring.DISCRETE, + None, + _WEEKLY_OBS_DATES, + "DIP discrete weekly", + id="DIP_American_discrete_weekly", + ), + ], + ) + def test_american_mc_tracks_pde_on_regular_cases( + self, + option_type, + direction, + action, + barrier, + monitoring, + num_observations, + monitoring_dates, + label, + ): + spec = _barrier_spec( + option_type=option_type, + exercise_type=ExerciseType.AMERICAN, + direction=direction, + action=action, + barrier=barrier, + monitoring=monitoring, + num_observations=num_observations, + monitoring_dates=monitoring_dates, + ) + ud = _underlying() + pv_pde = float(OptionValuation(ud, spec, PricingMethod.PDE_FD).present_value()) + + gbm = _mc_gbm() + pv_mc = float( + OptionValuation( + gbm, + spec, + PricingMethod.MONTE_CARLO, + params=MonteCarloParams(random_seed=42), + ).present_value() + ) + + logger.info( + "%s American: PDE=%.6f MC=%.6f diff=%+.6f (%.2f%%)", + label, + pv_pde, + pv_mc, + pv_mc - pv_pde, + (pv_mc - pv_pde) / pv_pde * 100, + ) + + # MC LSM is unbiased on these regular cases; gap should be ~1% + # sampling noise at 50k paths / 200 grid steps / seed=42. 3% rtol + # absorbs seed wobble without masking a real bias regression. + assert np.isclose(pv_mc, pv_pde, rtol=0.03, atol=1.0e-4), ( + f"{label} American MC PV {pv_mc:.6f} drifted from PDE_FD {pv_pde:.6f} " + f"by more than 3% — possible LSM regression." + ) From 1786f40ade2b1066dc7a08f84d87e41418ec7e4e Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Tue, 12 May 2026 21:55:06 +0100 Subject: [PATCH 02/10] Update and trim docstrings --- src/derivatives_pricing/valuation/binomial.py | 46 +++----- src/derivatives_pricing/valuation/core.py | 101 ++++++++---------- src/derivatives_pricing/valuation/pde.py | 90 +++++++--------- 3 files changed, 96 insertions(+), 141 deletions(-) diff --git a/src/derivatives_pricing/valuation/binomial.py b/src/derivatives_pricing/valuation/binomial.py index a8b9921..bbc342f 100644 --- a/src/derivatives_pricing/valuation/binomial.py +++ b/src/derivatives_pricing/valuation/binomial.py @@ -958,37 +958,26 @@ def _resolve_effective_num_steps(self) -> int: if spot <= 0.0 or barrier <= 0.0 or sigma <= 0.0 or ttm <= 0.0: return base_steps - # If the option is already triggered at inception, `present_value()` - # will short-circuit through `_inception_short_circuit_value` without - # running the barrier tree. Neither Boyle-Lau inflation nor the - # cap-bind warning is meaningful in that case: - # - KO: the option is dead at t=0 and PV is a closed-form rebate - # discount; the barrier-aware solver never runs. - # - KI: the option becomes vanilla immediately and is priced via - # `_solve_backward` (not the barrier-aware solver), so barrier - # alignment is irrelevant. + # Inception-triggered specs short-circuit upstream in + # `_inception_short_circuit_value`; the barrier solver never runs, + # alignment is moot. if self.valuation_ctx._barrier_triggered_at_inception(): return base_steps log_distance = np.log(max(spot, barrier) / min(spot, barrier)) divisor = log_distance * log_distance - # Only bail if log_distance is exactly zero (barrier coincides with - # spot — a triggered-at-inception case handled upstream by - # `_inception_short_circuit_value`). We must NOT use `np.isclose` here - # with its default atol=1e-8 because that would silently disable the - # Boyle-Lau step adjustment for all near-spot barriers (any barrier - # closer to spot than ~1e-4 in log-space would fall inside the - # tolerance), exactly the regime where BL alignment matters most. + # Strict `<= 0` (not `np.isclose`): default atol=1e-8 would + # silently disable alignment for any barrier within ~1e-4 of spot + # in log-space — the regime where alignment matters most. if divisor <= 0.0: return base_steps - # Continuous monitoring: place a CRR layer exactly ON H (Boyle-Lau 1994). - # Discrete monitoring: place H midway between two CRR layers — the - # binomial probability mass spans half a layer on each side of each - # layer's nominal position, so on-H placement biases the effective - # kill threshold by -Δ/2. Half-step placement (analog of Cheuk-Vorst 1996 / - # Boyle-Tian 1998 / in the FD setting) gives an unbiased - # discrete kill probability. + # Continuous: place a CRR layer on H (Boyle-Lau 1994, factor = i). + # Discrete: place H midway between two CRR layers (factor = i - 0.5). + # Under discrete monitoring, placing H exactly on a CRR layer introduces + # a first-order grid-placement bias in the effective kill threshold; + # half-step placement is the binomial analog of Cheuk-Vorst 1996 / + # Boyle-Tian 1998. shift = 0.0 if self.spec.monitoring is BarrierMonitoring.CONTINUOUS else 0.5 max_steps = max(1000, base_steps * 5) optimum_steps = base_steps @@ -997,14 +986,9 @@ def _resolve_effective_num_steps(self) -> int: candidate = int((factor * factor * sigma * sigma * ttm) / divisor) if candidate >= base_steps: if candidate > max_steps: - # Boyle-Lau alignment requires a tree with `candidate` time - # steps to place a layer of CRR nodes exactly on the barrier - # (typically because the barrier sits very close to spot). - # The `max_steps` cap prevents runaway memory, so the final - # tree will run at `max_steps` without barrier alignment and - # will converge extremely slowly (classic Boyle-Lau bias, - # O(1/√n) with a large constant). Warn the user so they - # know to switch engines. + # Alignment unattainable within memory budget — warn + # and fall back to unaligned at `max_steps` (the warning + # message itself spells out the implications). log_distance_pct = 100.0 * log_distance / max(np.log(spot), 1.0e-12) warnings.warn( ( diff --git a/src/derivatives_pricing/valuation/core.py b/src/derivatives_pricing/valuation/core.py index 7109a5a..e00f128 100644 --- a/src/derivatives_pricing/valuation/core.py +++ b/src/derivatives_pricing/valuation/core.py @@ -1047,8 +1047,7 @@ def _resolve_params( # binomial-tree analog of Cheuk-Vorst 1996 / Boyle-Tian 1998 # for the FD/trinomial setting). Discrete defaults to 3000 # base steps because CRR's intrinsic O(1/N) finite-step error - # remains visible on small-price reverse-barrier cases (UOC - # tighter, DOP tighter, UIP wider). + # remains visible on small-price reverse-barrier cases. num_steps = 1000 if spec.monitoring is BarrierMonitoring.CONTINUOUS else 3000 return BinomialParams(num_steps=num_steps) return BinomialParams() @@ -1449,25 +1448,20 @@ def _reject_barrier_binomial_numerical( ) -> None: """Block NUMERICAL bump-and-revalue greeks on binomial barrier specs. - For continuous barriers, bumping spot, volatility or time for a - barrier option re-invokes ``_resolve_effective_num_steps`` on each - bumped valuation, and the Boyle-Lau barrier-alignment formula - ``candidate = i² σ² T / log(H/S)²`` depends on every one of those - inputs. The bumped trees therefore end up with *different* step - counts from the center tree, so a central difference is comparing - two unrelated tree topologies rather than approximating ``∂V/∂x``. - Rho is exempt because the risk-free rate does not enter the - Boyle-Lau formula, so rate bumps reuse the same tree - topology and the finite difference is well-defined. - - For discrete barriers, bumping does not amend _effective_num_steps - but empirically, the greeks are noisy. We thus conservatively block - NUMERICAL greeks on barrier-binomial specs (except rho which is - empirically stable). - - For delta, gamma, theta, users should use native TREE Greeks - For rho, bump and revalue is permitted. - For vega, users are advised to switch pricing method to PDE_FD. + Both continuous (Boyle-Lau 1994 on-node) and discrete (half-step + Cheuk-Vorst / Boyle-Tian analog) alignment pick the tree step + count from ``candidate ≈ factor² σ² T / log(H/S)²``, which depends + on spot, volatility, and time-to-maturity. Bumping any of those + re-resolves alignment, so a central difference compares trees of + *different topology* rather than approximating ``∂V/∂x``. + + Rho is exempt — the risk-free rate doesn't enter the alignment + formula, so rate bumps reuse the same tree topology and the + finite difference is well-defined. + + Guidance: use ``GreekCalculationMethod.TREE`` for Δ/Γ/Θ; switch + to ``PricingMethod.PDE_FD`` for vega (and for any NUMERICAL + bump-and-revalue, where the grid topology is bump-invariant). """ if allow: return @@ -1516,28 +1510,23 @@ def _reject_barrier_numerical( engine_constraint: PricingMethod | None = None, monitoring_constraint: BarrierMonitoring | None = None, ) -> None: - """Block NUMERICAL bump-and-revalue greeks on barrier specs that - are empirically or structurally unreliable. + """Block NUMERICAL bump-and-revalue on barrier greek/engine + combinations known to be unreliable. - Two distinct greek failure modes are currently covered (see - ``_BARRIER_NUMERICAL_REASON``): + Currently: - - Gamma on Monte Carlo barriers (any monitoring): second-derivative - MC noise scales as ~stderr/ε² and at practical path counts the - noise floor exceeds the |Γ| signal across most of parameter - space. Caller passes + - **Γ on MC barriers** (any monitoring): second-derivative MC + noise scales ~ stderr/ε²; at practical path counts the noise + floor exceeds the |Γ| signal. Pass ``engine_constraint=PricingMethod.MONTE_CARLO``. - - Theta on any discretely-monitored barrier: bumping the pricing - date forces re-resolution of the monitoring schedule, so the - bumped contract is not the same contract — the resulting theta - mixes time decay with a contract-respecification artifact. - Affects every engine equally; caller passes - ``monitoring_constraint=BarrierMonitoring.DISCRETE``. (Binomial - barrier NUMERICAL greeks are already blocked at a finer grain - by ``_reject_barrier_binomial_numerical``.) - - Other greeks (delta, vega, rho) and continuous-monitoring theta - are not blocked. + - **Θ on discretely-monitored barriers** (any engine): bumping + the pricing date forces re-resolution of the monitoring + schedule, so the bumped contract is a different contract. + Pass ``monitoring_constraint=BarrierMonitoring.DISCRETE``. + (Binomial barrier NUMERICAL greeks are blocked at a finer + grain in ``_reject_barrier_binomial_numerical``.) + + Per-greek reasons are kept in ``_BARRIER_NUMERICAL_REASON``. """ if method is not GreekCalculationMethod.NUMERICAL: return @@ -1630,25 +1619,19 @@ def _validate_bump( ) -> None: """Validate a numerical-greek bump argument against the resolved method. - Two checks are bundled here so every greek entry point can run a - single line: - - 1. The bump must be strictly positive when supplied. Negative - bumps are almost certainly user mistakes — central differences - are sign-symmetric so a negative spot/vol/rate bump silently - gives the same magnitude with confusing semantics, while a - negative ``time_bump_days`` flips the forward-difference theta. - 2. The bump must be compatible with the *resolved* greek method. - Passing ``epsilon=...`` together with a method that doesn't use - a finite-difference bump (ANALYTICAL / GRID / TREE / PATHWISE - where applicable / LR) is a contradiction — the bump would be - silently ignored, letting users believe they are controlling - it when they aren't. We therefore require callers to resolve - the method first (via ``_resolve_greek_method``) and pass the - resolved method here; that way the same rule applies whether - the user supplied an explicit method or relied on auto-select. - ``extra_allowed_methods`` lets e.g. gamma also accept PATHWISE - (which uses a finite-difference epsilon under the hood). + Two checks, bundled so every greek entry point is one line: + + 1. **Sign**: bump must be strictly positive — negative bumps are + silent footguns (sign-symmetric central diff for spot/vol/rate + gives the same magnitude; negative ``time_bump_days`` flips + the forward-difference theta). + 2. **Method compatibility**: passing a bump with a non-bumping + method (ANALYTICAL / GRID / TREE / LR / PATHWISE where it + doesn't use ε) is a contradiction — the bump would be + silently ignored. Callers must pre-resolve via + ``_resolve_greek_method`` so this check is invariant to + explicit-method vs auto-select. ``extra_allowed_methods`` + lets e.g. gamma also accept PATHWISE (which uses ε internally). """ if bump_value is None: return diff --git a/src/derivatives_pricing/valuation/pde.py b/src/derivatives_pricing/valuation/pde.py index a1b6e06..d46580c 100644 --- a/src/derivatives_pricing/valuation/pde.py +++ b/src/derivatives_pricing/valuation/pde.py @@ -386,30 +386,25 @@ def _build_log_grid( anchor_spot: float | None = None, anchor_half_step: bool = False, ) -> tuple[np.ndarray, np.ndarray, float]: - """Build log-spot grid. - - For the explicit-family schemes (``EXPLICIT``, ``EXPLICIT_HULL``) the - grid construction preserves Hull's heuristic scale - ``dz_hull = vol * sqrt(3 * dt)`` when the target log-domain fits within - ``spot_steps * dz_hull``. For ``EXPLICIT_HULL`` this is the special - spacing that recovers the trinomial-equivalent explicit discretization - with up/mid/down probabilities ``1/6, 2/3, 1/6``. - - For unconditionally stable schemes (``IMPLICIT``, ``CRANK_NICOLSON``) - ``spot_steps`` controls the spatial density directly: - ``dz = (zmax_target - zmin_target) / spot_steps``. - - When ``anchor_spot`` is provided, the grid is sized so that the anchor - lies exactly on an interior node by default. If ``anchor_half_step`` is - true, the anchor instead lies halfway between two adjacent nodes. In both - cases the resulting domain is a (possibly slight) superset of - ``[zmin_target, zmax_target]``. For CN/IMPLICIT this is achieved by - recomputing ``dz`` from the binding half (left or right of the anchor), - i.e. the side that requires the larger uniform ``dz`` to keep the anchor - on-node or at a cell midpoint while still covering the target domain. The - other side can then have up to roughly one cell of slack. For explicit - schemes ``dz`` is fixed by Hull's stability heuristic, so the grid is - shifted in place while keeping strict cover of the target domain. + """Build a log-spot grid. + + ``dz`` selection by scheme: + + - **Explicit family** (``EXPLICIT``, ``EXPLICIT_HULL``): targets Hull's + stability scale ``dz_hull = vol * sqrt(3 * dt)`` — the trinomial- + equivalent spacing with up/mid/down probabilities ``1/6, 2/3, 1/6``. + Falls back to ``(zmax_target - zmin_target) / spot_steps`` if Hull's + grid is too narrow to cover the target span. + - **Unconditionally stable** (``IMPLICIT``, ``CRANK_NICOLSON``): + ``dz = (zmax_target - zmin_target) / spot_steps`` directly. + + When ``anchor_spot`` is provided, the grid is sized so the anchor sits + exactly on an interior node (or halfway between two nodes when + ``anchor_half_step=True``). The resulting domain is a (possibly slight) + superset of ``[zmin_target, zmax_target]``. CN/IMPLICIT grows ``dz`` on + the binding half (the side of the anchor that needs the larger ``dz`` + to cover its target half); explicit schemes keep ``dz`` fixed by + stability and shift the grid in place instead. """ if anchor_half_step and anchor_spot is None: raise ValidationError("anchor_half_step requires anchor_spot to be provided") @@ -460,13 +455,12 @@ def _build_log_grid( anchor_offset = 0.5 if anchor_half_step else 0.0 if method in (PDEMethod.EXPLICIT, PDEMethod.EXPLICIT_HULL): - # Explicit schemes use Hull's dz_hull heuristic, which leaves - # ``grid_width = spot_steps * dz`` strictly larger than the - # target span (when not capped). dz is fixed by stability, so - # we shift the grid in place while keeping strict cover of - # ``[zmin_target, zmax_target]``. If the target span is already - # capped exactly by ``spot_steps * dz``, exact anchoring is only - # possible when the anchor happens to lie on that fixed grid. + # Explicit: ``dz`` is fixed by Hull's stability heuristic, so + # shift the grid in place while preserving cover of + # ``[zmin_target, zmax_target]``. If the target span is binding + # (already tight to ``spot_steps * dz``), exact anchoring is only + # feasible when the anchor lies on the fixed-dz grid — or, when + # ``anchor_half_step=True``, halfway between two fixed-dz nodes. j_min = max( 0, int(math.ceil((z_anchor - zmin_target) / dz - anchor_offset - 1.0e-12)), @@ -480,21 +474,18 @@ def _build_log_grid( preferred_index = int(round((z_anchor - zmin) / dz - anchor_offset)) j_anchor = min(max(preferred_index, j_min), j_max) else: - # CN/IMPLICIT: dz is free, so instead of shifting a fixed-dz - # grid (which forces an unsatisfiable strict-cover constraint - # when dz exactly tiles the target span), we *grow* dz on the - # binding half. Pick the integer node closest to where the - # anchor naturally falls, then compute the dz required to cover - # the left and right halves separately; whichever side requires - # the larger dz is the binding side, and the other side absorbs - # the slack. The result is a uniform grid that: + # CN/IMPLICIT: ``dz`` is free, so *grow* it on the binding half + # rather than shift a fixed-dz grid (which has an unsatisfiable + # strict-cover constraint when ``dz`` exactly tiles the target). + # Pick the integer node closest to where the anchor falls, then + # take the larger of the left/right ``dz`` needed to cover each + # half. The resulting grid: # - places the anchor exactly on an interior node, # - is strictly tight to the target on the binding side, # - has up to one cell of slack outside the target on the - # other side (i.e. a slight superset of the target — never - # under-covers), - # - costs at most ~1/(spot_steps - 1) extra dz vs the bare- - # minimum tile of the target span. + # other side (slight superset — never under-covers), + # - costs at most ~1/(spot_steps - 1) extra ``dz`` vs the + # bare-minimum tile of the target span. span = zmax_target - zmin_target j_opt = int(round(spot_steps * (z_anchor - zmin_target) / span - anchor_offset)) j_anchor = max(0 if anchor_half_step else 1, min(spot_steps - 1, j_opt)) @@ -1849,14 +1840,11 @@ def _fd_barrier_ko_core( else: payoff = np.maximum(S - strike, 0.0) - # American exercise intrinsic is the holder's exercise value at any - # in-life moment, which is the vanilla payoff — independent of any - # KO-zone modification applied to the maturity payoff below. Under - # discrete monitoring the holder can exercise between observations - # even when sitting in the KO zone, so the PSOR floor must use the - # unmodified vanilla intrinsic. (For continuous KO the grid is - # truncated at the barrier and the KO zone is absent from the grid, - # so the same vanilla array is also correct on the alive side.) + # American intrinsic = vanilla payoff at any in-life moment, regardless of + # KO-zone modifications applied at maturity below. Discrete monitoring + # needs this: between obs dates the holder can exercise even sitting in + # the KO zone. (For continuous KO the grid is truncated at the barrier, + # so the alive-side array is the only one that exists — same logic holds.) intrinsic = payoff.copy() if early_exercise else None # For continuous KO: payoff is zero on the barrier side (enforced From 617e6dc5f332045b3dcb689285da6f5d9964f367 Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Tue, 12 May 2026 22:10:34 +0100 Subject: [PATCH 03/10] Update module docstrings --- src/derivatives_pricing/valuation/__init__.py | 9 ++++++--- src/derivatives_pricing/valuation/binomial.py | 11 +++++++++-- src/derivatives_pricing/valuation/core.py | 2 +- src/derivatives_pricing/valuation/monte_carlo.py | 16 +++++++++++++++- src/derivatives_pricing/valuation/pde.py | 4 ++++ 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/derivatives_pricing/valuation/__init__.py b/src/derivatives_pricing/valuation/__init__.py index 35582dc..52a43bb 100644 --- a/src/derivatives_pricing/valuation/__init__.py +++ b/src/derivatives_pricing/valuation/__init__.py @@ -1,8 +1,9 @@ """Option valuation and pricing engines. -This module provides a unified interface for pricing vanilla and custom options -using various methods: Monte Carlo simulation, Binomial trees, Black-Scholes-Merton -analytical formulas, and PDE finite difference methods. +This module provides a unified interface for pricing vanilla, custom-payoff, +Asian, and barrier options using various methods: Monte Carlo simulation, +Binomial trees, Black-Scholes-Merton analytical formulas, and PDE finite +difference methods. Public API ---------- @@ -10,6 +11,8 @@ OptionValuation: Main dispatcher for option pricing VanillaSpec: Contract specification for vanilla options PayoffSpec: Contract specification for custom payoffs + AsianSpec: Contract specification for Asian options + BarrierSpec: Contract specification for barrier options UnderlyingData: Minimal underlying data container Parameter classes: diff --git a/src/derivatives_pricing/valuation/binomial.py b/src/derivatives_pricing/valuation/binomial.py index bbc342f..b0e6d23 100644 --- a/src/derivatives_pricing/valuation/binomial.py +++ b/src/derivatives_pricing/valuation/binomial.py @@ -1,7 +1,14 @@ """Binomial-tree valuation engines (Cox-Ross-Rubinstein). -Implements European and American vanilla option pricing, plus Asian-option -extensions used by the core dispatcher. +Implements European and American pricing for: + +- vanilla call/put +- Asian options (Hull-style tree averages or MC sampling on the tree) +- barrier options (continuous and discrete monitoring, KO and KI, with + Boyle-Lau on-node alignment for continuous and a half-step CRR-layer + analog for discrete) + +Plugged into the registry dispatcher in ``core.py``. """ from __future__ import annotations diff --git a/src/derivatives_pricing/valuation/core.py b/src/derivatives_pricing/valuation/core.py index e00f128..19c0c33 100644 --- a/src/derivatives_pricing/valuation/core.py +++ b/src/derivatives_pricing/valuation/core.py @@ -2,7 +2,7 @@ This module is the central orchestration layer for pricing: -- Spec dataclasses (`VanillaSpec`, `PayoffSpec`, `AsianSpec`) +- Spec dataclasses (`VanillaSpec`, `PayoffSpec`, `AsianSpec`, `BarrierSpec`) - Underlying data container (`UnderlyingData`) - Registry-based dispatcher (`OptionValuation`) that maps `(PricingMethod, ExerciseType)` to a private implementation engine diff --git a/src/derivatives_pricing/valuation/monte_carlo.py b/src/derivatives_pricing/valuation/monte_carlo.py index a7a3e2a..4d8201f 100644 --- a/src/derivatives_pricing/valuation/monte_carlo.py +++ b/src/derivatives_pricing/valuation/monte_carlo.py @@ -1,4 +1,18 @@ -"""Monte Carlo Simulation option valuation implementations.""" +"""Monte Carlo option valuation implementations. + +Covers European and American pricing for: + +- vanilla call/put (path-terminal payoffs) +- custom payoffs (``PayoffSpec``) +- Asian options (path-averaging) +- barrier options (continuous monitoring with Brownian-bridge continuity + correction; discrete monitoring with explicit observation-date checks; + KO and KI, with AT_HIT and AT_EXPIRY rebates) + +American exercise uses Longstaff-Schwartz regression with optional +barrier-aware basis enrichment. Plugged into the registry dispatcher +in ``core.py``. +""" from __future__ import annotations from typing import TYPE_CHECKING diff --git a/src/derivatives_pricing/valuation/pde.py b/src/derivatives_pricing/valuation/pde.py index d46580c..faa09f7 100644 --- a/src/derivatives_pricing/valuation/pde.py +++ b/src/derivatives_pricing/valuation/pde.py @@ -7,6 +7,10 @@ ------------- PDE via finite differences for European and American options: - vanilla call/put and custom payoffs (PayoffSpec) +- barrier options (BarrierSpec): continuous KO via truncated-grid + Dirichlet BC, continuous KI via in-out parity (European) and + coupled PDE solver (American), discrete monitoring + via full-grid resets at observation dates - time stepping: implicit, explicit, or Crank–Nicolson - optional Rannacher smoothing for Crank–Nicolson - spatial grids: spot or log-spot From ee88791d508a7bb885ca119239bd7c9362290afc Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Tue, 12 May 2026 22:13:17 +0100 Subject: [PATCH 04/10] Simplify DiscountCurve.flat(): drop steps, set default end_time=100 (years) - Steps was a no-op (log-linear interpolation on a flat log-DF is exact between any two endpoints) - end_time; now defaults to 100y so DiscountCurve.flat(0.05) just works without users needing to anticipate the longest query horizon. - Sweep all 5 internal callers + drops the now-vacuous steps=0 and steps-array-size tests. --- src/derivatives_pricing/rates.py | 17 +++++------------ tests/test_discount_curve.py | 15 ++++++--------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/derivatives_pricing/rates.py b/src/derivatives_pricing/rates.py index 7e3117d..a2c0674 100644 --- a/src/derivatives_pricing/rates.py +++ b/src/derivatives_pricing/rates.py @@ -114,12 +114,7 @@ def from_zero_rates( return cls(times=times, dfs=dfs) @classmethod - def flat( - cls, - rate: float, - end_time: float, - steps: int = 1, - ) -> DiscountCurve: + def flat(cls, rate: float, end_time: float = 100.0) -> DiscountCurve: """Build a flat continuously-compounded discount curve. Parameters @@ -127,9 +122,9 @@ def flat( rate Flat continuously-compounded annual rate. end_time - Final maturity in years. - steps - Number of intervals used to discretize ``[0, end_time]``. + Final maturity in years. Defaults to 100, large enough to + cover any realistic option maturity. Override only if you + have a specific reason to truncate the curve domain. Returns ------- @@ -138,9 +133,7 @@ def flat( """ if end_time <= 0.0: raise ValidationError("end_time must be positive") - if steps < 1: - raise ValidationError("steps must be >= 1") - times = np.linspace(0.0, float(end_time), int(steps) + 1) + times = np.array([0.0, float(end_time)]) dfs = np.exp(-float(rate) * times) return cls(times=times, dfs=dfs) diff --git a/tests/test_discount_curve.py b/tests/test_discount_curve.py index f20d4f1..65953f7 100644 --- a/tests/test_discount_curve.py +++ b/tests/test_discount_curve.py @@ -21,10 +21,11 @@ def test_flat_curve_basic(self): assert np.isclose(float(curve.df(0.0)), 1.0) assert np.isclose(float(curve.df(1.0)), np.exp(-0.05)) - def test_flat_curve_multiple_steps(self): - curve = DiscountCurve.flat(rate=0.05, end_time=2.0, steps=10) - assert curve.times.size == 11 + def test_flat_curve_default_end_time(self): + # No end_time → default to 100y so users don't need to specify. + curve = DiscountCurve.flat(rate=0.05) assert np.isclose(float(curve.df(1.0)), np.exp(-0.05)) + assert np.isclose(float(curve.df(50.0)), np.exp(-0.05 * 50.0)) def test_flat_curve_zero_rate(self): curve = DiscountCurve.flat(rate=0.0, end_time=1.0) @@ -38,10 +39,6 @@ def test_flat_curve_zero_end_time_raises(self): with pytest.raises(ValidationError, match="end_time must be positive"): DiscountCurve.flat(rate=0.05, end_time=0.0) - def test_flat_curve_zero_steps_raises(self): - with pytest.raises(ValidationError, match="steps must be >= 1"): - DiscountCurve.flat(rate=0.05, end_time=1.0, steps=0) - def test_non_increasing_times_raises(self): with pytest.raises(ValidationError, match="strictly increasing"): DiscountCurve(times=np.array([0.0, 0.5, 0.5]), dfs=np.array([1.0, 0.98, 0.96])) @@ -168,7 +165,7 @@ class TestDiscountCurveForwardRate: """Test forward_rate() and step_forward_rates().""" def test_flat_curve_forward_rate_equals_flat_rate(self): - curve = DiscountCurve.flat(rate=0.05, end_time=2.0, steps=4) + curve = DiscountCurve.flat(rate=0.05, end_time=2.0) fwd = curve.forward_rate(0.25, 0.75) assert np.isclose(fwd, 0.05, rtol=1e-10) @@ -192,7 +189,7 @@ def test_forward_rate_consistency_with_dfs(self): assert np.isclose(reconstructed, df_t1, rtol=1e-10) def test_step_forward_rates_flat(self): - curve = DiscountCurve.flat(rate=0.05, end_time=2.0, steps=4) + curve = DiscountCurve.flat(rate=0.05, end_time=2.0) grid = np.array([0.0, 0.5, 1.0, 1.5, 2.0]) fwds = curve.step_forward_rates(grid) assert fwds.shape == (4,) From c9f7bbfc9f6db67dfc0a40583d8588525e59ff3a Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Wed, 13 May 2026 13:10:16 +0100 Subject: [PATCH 05/10] Update/trim comments --- .../valuation/barrier_analytical.py | 16 +++++------- src/derivatives_pricing/valuation/core.py | 25 +++++++------------ src/derivatives_pricing/valuation/pde.py | 4 +-- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/src/derivatives_pricing/valuation/barrier_analytical.py b/src/derivatives_pricing/valuation/barrier_analytical.py index fbb076c..aec7e2e 100644 --- a/src/derivatives_pricing/valuation/barrier_analytical.py +++ b/src/derivatives_pricing/valuation/barrier_analytical.py @@ -543,16 +543,12 @@ def present_value(self) -> float: H = _broadie_glasserman_adjustment(H, sigma, T, spec.num_observations, spec.direction) # ── No-rebate barrier value ── - # The Reiner-Rubinstein formulas contain (H/S)**(2*lambda) with - # lambda = (r - q + sigma^2/2)/sigma^2, and d1/x1/y/y1 each divide - # by sigma*sqrt(T). As sigma -> 0 we either divide by zero (when - # computing lambda or any of the d/x/y terms) or blow up at - # (H/S)**(2*lambda). Most operations promote to numpy.float64 (via - # np.log/np.sqrt) so the failure mode is silent inf/nan + - # RuntimeWarning -- np.errstate converts those into - # FloatingPointError so we can fall back to the closed-form - # deterministic-forward price. OverflowError / ZeroDivisionError - # are caught in case any operation stays in Python-float arithmetic. + # Reiner-Rubinstein contains ``(H/S)**(2*lambda)`` and divides by + # ``sigma*sqrt(T)``, so ``sigma -> 0`` produces inf/nan via numpy + # promotion or OverflowError/ZeroDivisionError in pure-Python + # paths. Catch both (``np.errstate`` turns the silent + # floating-point case into ``FloatingPointError``) and fall back + # to the deterministic-forward closed form below. with np.errstate(over="raise", invalid="raise", divide="raise"): try: value = _barrier_price_no_rebate( diff --git a/src/derivatives_pricing/valuation/core.py b/src/derivatives_pricing/valuation/core.py index 19c0c33..0617687 100644 --- a/src/derivatives_pricing/valuation/core.py +++ b/src/derivatives_pricing/valuation/core.py @@ -531,15 +531,10 @@ def delta( return float(self._impl.delta()) if isinstance(self._spec, BarrierSpec) and self._barrier_triggered_at_inception(): - # Inception-triggered short-circuit (NUMERICAL only): - # bump-and-revalue would cross the trigger boundary and price - # the un-triggered contract on the bumped spot, which is - # meaningless for an already-triggered barrier. Engines handle - # TREE/GRID triggered greeks natively, so this branch is only - # reached for NUMERICAL. - # • KO triggered → cashflow constant in spot → δ = 0. - # • KI triggered → contract IS the vanilla equivalent; bump - # there (no state transition). + # Inception-triggered NUMERICAL short-circuit: bumping spot + # would cross the trigger boundary and price the un-triggered + # contract. KO → constant cashflow → δ = 0; KI → delegate + # to the vanilla equivalent. if self._spec.action is BarrierAction.OUT: return 0.0 return self._vanilla_equivalent_valuation().delta( @@ -1041,13 +1036,11 @@ def _resolve_params( return MonteCarloParams() if pricing_method is PricingMethod.BINOMIAL: if isinstance(spec, BarrierSpec): - # Continuous barriers get Boyle-Lau on-node step inflation - # (place a CRR layer ON H); discrete barriers get half-step - # inflation (place H midway between two CRR layers — the - # binomial-tree analog of Cheuk-Vorst 1996 / Boyle-Tian 1998 - # for the FD/trinomial setting). Discrete defaults to 3000 - # base steps because CRR's intrinsic O(1/N) finite-step error - # remains visible on small-price reverse-barrier cases. + # Continuous: on-node alignment (Boyle-Lau 1994). Discrete: + # half-step alignment (binomial analog of Cheuk-Vorst 1996 / + # Boyle-Tian 1998). Discrete defaults to 3000 base steps to + # keep CRR's O(1/N) finite-step error tight on small-price + # reverse-barrier cases. num_steps = 1000 if spec.monitoring is BarrierMonitoring.CONTINUOUS else 3000 return BinomialParams(num_steps=num_steps) return BinomialParams() diff --git a/src/derivatives_pricing/valuation/pde.py b/src/derivatives_pricing/valuation/pde.py index faa09f7..d367ea2 100644 --- a/src/derivatives_pricing/valuation/pde.py +++ b/src/derivatives_pricing/valuation/pde.py @@ -9,7 +9,7 @@ - vanilla call/put and custom payoffs (PayoffSpec) - barrier options (BarrierSpec): continuous KO via truncated-grid Dirichlet BC, continuous KI via in-out parity (European) and - coupled PDE solver (American), discrete monitoring + two-surface coupled PDE solver (American), discrete monitoring via full-grid resets at observation dates - time stepping: implicit, explicit, or Crank–Nicolson - optional Rannacher smoothing for Crank–Nicolson @@ -1629,7 +1629,7 @@ class _FDAmericanValuation(_FDValuationBase): # ═══════════════════════════════════════════════════════════════════════════ -# Barrier option PDE +# Barrier option FD # ═══════════════════════════════════════════════════════════════════════════ From ba1afaf7e549ccbeaaf819b89e5ca7b102fef681 Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Wed, 13 May 2026 16:48:09 +0100 Subject: [PATCH 06/10] Update 06a notebook --- tutorials/06a_barrier_options.ipynb | 134 +++++++++++++--------------- 1 file changed, 63 insertions(+), 71 deletions(-) diff --git a/tutorials/06a_barrier_options.ipynb b/tutorials/06a_barrier_options.ipynb index 93167fa..81d6389 100644 --- a/tutorials/06a_barrier_options.ipynb +++ b/tutorials/06a_barrier_options.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "d30456c4", + "id": "bdd47659", "metadata": {}, "source": [ "# Barrier Options\n", @@ -42,7 +42,7 @@ }, { "cell_type": "markdown", - "id": "c8721ac8", + "id": "11bfc21b", "metadata": {}, "source": [ "## 1) Notebook Setup\n", @@ -65,7 +65,7 @@ { "cell_type": "code", "execution_count": 1, - "id": "9a281303", + "id": "89d12157", "metadata": {}, "outputs": [], "source": [ @@ -80,7 +80,7 @@ { "cell_type": "code", "execution_count": 2, - "id": "d363b6de", + "id": "02b85467", "metadata": {}, "outputs": [ { @@ -125,7 +125,7 @@ }, { "cell_type": "markdown", - "id": "f5795f70", + "id": "4a1f10d6", "metadata": {}, "source": [ "## 2) Anatomy of a Barrier Option\n", @@ -202,7 +202,7 @@ }, { "cell_type": "markdown", - "id": "4b119be7", + "id": "47d657a3", "metadata": {}, "source": [ "## 3) Continuous European Pricing — Four Engines\n", @@ -216,7 +216,7 @@ { "cell_type": "code", "execution_count": 3, - "id": "70de9924", + "id": "6db6ccda", "metadata": {}, "outputs": [], "source": [ @@ -244,7 +244,7 @@ }, { "cell_type": "markdown", - "id": "6cb59998", + "id": "3085cd25", "metadata": {}, "source": [ "### 3.1 BSM analytical (Reiner-Rubinstein)\n", @@ -291,7 +291,7 @@ { "cell_type": "code", "execution_count": 4, - "id": "506a9297", + "id": "de8398e8", "metadata": {}, "outputs": [ { @@ -325,7 +325,7 @@ }, { "cell_type": "markdown", - "id": "47ba7bf2", + "id": "0a1473d7", "metadata": {}, "source": [ "The in-out parity holds to machine precision, confirming the analytical\n", @@ -366,7 +366,7 @@ }, { "cell_type": "markdown", - "id": "3eba970f", + "id": "a597cd7b", "metadata": {}, "source": [ "### 3.3 Binomial — Boyle-Lau step adjustment\n", @@ -419,7 +419,7 @@ { "cell_type": "code", "execution_count": 5, - "id": "1f6d28fd", + "id": "deff4e8e", "metadata": {}, "outputs": [ { @@ -450,7 +450,7 @@ }, { "cell_type": "markdown", - "id": "b15f5a8a", + "id": "24c46160", "metadata": {}, "source": [ "### 3.4 PDE_FD — truncated grid for KO, in-out parity for KI\n", @@ -506,7 +506,7 @@ { "cell_type": "code", "execution_count": 6, - "id": "32230ece", + "id": "34080788", "metadata": {}, "outputs": [ { @@ -537,7 +537,7 @@ }, { "cell_type": "markdown", - "id": "6ced2b05", + "id": "5c89bc60", "metadata": {}, "source": [ "### 3.5 Monte Carlo — Brownian-bridge continuity correction\n", @@ -566,8 +566,8 @@ "\n", "$$p_{\\text{hit}}^{\\text{step}} = \\exp\\!\\left(-\\frac{2\\,\\ln(H/S_i)\\,\\ln(H/S_{i+1})}{\\sigma^2 \\Delta t}\\right)$$\n", "\n", - "(and exactly 1 if either endpoint already crosses). This is the\n", - "Beaglehole-Dybvig-Zhou bridge formula. In `monte_carlo.py` we \n", + "(and exactly 1 if either endpoint already crosses). This is the standard Brownian-bridge\n", + "crossing formula for log-GBM. In `monte_carlo.py` we \n", "multiply through step-by-step to maintain a per-path **survival weight**:\n", "\n", "$$\\text{surv}^{(p)} = \\prod_{i=0}^{N-1} \\bigl(1 - p_{\\text{hit},i}^{\\text{step},(p)}\\bigr)$$\n", @@ -575,8 +575,11 @@ "where $(p)$ indexes the simulated path (one survival weight per path) and\n", "$N$ is the number of time-grid steps. The KO weight is `surv`; the KI\n", "weight is `1 − surv`. Each path's discounted vanilla payoff is multiplied\n", - "by its weight before averaging. The bias collapses to $O(\\Delta t)$ once\n", - "the bridge is applied — no need for ultra-fine grids.\n", + "by its weight before averaging. For European continuous barriers under GBM,\n", + "Brownian-bridge survival weighting removes the monitoring bias in the barrier\n", + "event itself, so ultra-fine monitoring grids are unnecessary. The remaining\n", + "error is mainly MC sampling (and, for AT_HIT rebates, the midpoint\n", + "approximation to the unknown intra-step hit time).\n", "\n", "Rebates are accumulated path-by-path along the same survival recursion\n", "(AT_HIT discount uses the step midpoint as a fast-and-clean proxy for\n", @@ -586,7 +589,7 @@ { "cell_type": "code", "execution_count": 7, - "id": "20fa3920", + "id": "fc10eb68", "metadata": {}, "outputs": [ { @@ -632,7 +635,7 @@ }, { "cell_type": "markdown", - "id": "a1516da7", + "id": "ad4b5d3c", "metadata": {}, "source": [ "### 3.6 European comparison\n", @@ -644,7 +647,7 @@ { "cell_type": "code", "execution_count": 8, - "id": "f6776991", + "id": "b56504ed", "metadata": {}, "outputs": [ { @@ -675,7 +678,7 @@ }, { "cell_type": "markdown", - "id": "0c6e1a63", + "id": "20f03c90", "metadata": {}, "source": [ "## 4) American Exercise (Continuous Monitoring)\n", @@ -692,7 +695,7 @@ { "cell_type": "code", "execution_count": 9, - "id": "26d55e5f", + "id": "6efa43c7", "metadata": {}, "outputs": [], "source": [ @@ -712,7 +715,7 @@ }, { "cell_type": "markdown", - "id": "229f07e5", + "id": "c920abdb", "metadata": {}, "source": [ "### 4.1 Binomial American\n", @@ -732,7 +735,7 @@ { "cell_type": "code", "execution_count": 10, - "id": "34517590", + "id": "5c051ce0", "metadata": {}, "outputs": [ { @@ -762,7 +765,7 @@ }, { "cell_type": "markdown", - "id": "80fc67e0", + "id": "73169cb8", "metadata": {}, "source": [ "### 4.2 PDE_FD American — two-surface coupled PDE for KI\n", @@ -771,7 +774,7 @@ "(same domain as the European KO) but at every time step apply the\n", "free-boundary condition $V \\ge \\text{intrinsic}$. The package uses\n", "**PSOR** (projected successive over-relaxation, `PDEEarlyExercise.GAUSS_SEIDEL`)\n", - "by default; a faster but less accurate **`INTRINSIC`** projection (single\n", + "by default; a faster but less accurate `INTRINSIC` projection (single\n", "`max(V, intrinsic)` sweep per time step, no inner iteration) is also\n", "available — see notebook 06b §2 for the speed/accuracy trade-off.\n", "\n", @@ -802,7 +805,7 @@ { "cell_type": "code", "execution_count": 11, - "id": "eeb2d307", + "id": "c8827893", "metadata": {}, "outputs": [ { @@ -828,7 +831,7 @@ }, { "cell_type": "markdown", - "id": "8a9e5650", + "id": "832c34e8", "metadata": {}, "source": [ "### 4.3 Monte Carlo American — Longstaff-Schwartz\n", @@ -877,7 +880,7 @@ { "cell_type": "code", "execution_count": 12, - "id": "f92098d3", + "id": "6e662cb6", "metadata": {}, "outputs": [ { @@ -903,7 +906,7 @@ }, { "cell_type": "markdown", - "id": "3f98bf1f", + "id": "2cdbafa8", "metadata": {}, "source": [ "### 4.4 American comparison" @@ -912,7 +915,7 @@ { "cell_type": "code", "execution_count": 13, - "id": "8e3c9de8", + "id": "13184846", "metadata": {}, "outputs": [ { @@ -945,7 +948,7 @@ }, { "cell_type": "markdown", - "id": "80cf9a1b", + "id": "5fb95f12", "metadata": {}, "source": [ "## 5) Greeks\n", @@ -960,9 +963,9 @@ "\n", "| Engine | Auto-selected greek method | Notes |\n", "|---|---|---|\n", - "| BSM | Mostly NUMERICAL | For barrier options, delta/gamma/vega/rho use bump-and-reprice (NUMERICAL) by default; theta has a special auto-path that uses the BS PDE identity with the NUMERICAL spatial derivatives, rather than a time bump (former was empirically more accurate) |\n", - "| Binomial | TREE ($\\delta/\\gamma/\\theta$) NUMERICAL ($\\rho$)| Reads delta/gamma off the lattice nodes (Hull §21); theta from the time slice |\n", - "| PDE_FD | GRID ($\\delta/\\gamma/\\theta$) NUMERICAL (vega/$\\rho$) | Reads delta/gamma directly from the spatial grid; theta via the BS PDE identity $\\theta = rV - (r-q)S\\delta - \\tfrac{1}{2}\\sigma^2 S^2 \\Gamma$ using those grid-derived $\\delta/\\Gamma$ |\n", + "| BSM | Mostly NUMERICAL | delta/gamma/vega/rho use bump-and-reprice (NUMERICAL) by default; theta has a special auto-path that uses the BS PDE identity with the NUMERICAL spatial derivatives, rather than a time bump (former was empirically more accurate) |\n", + "| Binomial | TREE ($\\delta/\\gamma/\\theta$)
NUMERICAL ($\\rho$)| Reads delta/gamma off the lattice nodes (Hull §21); theta from the time slice |\n", + "| PDE_FD | GRID ($\\delta/\\gamma/\\theta$)
NUMERICAL (vega/$\\rho$) | Reads delta/gamma directly from the spatial grid; theta via the BS PDE identity $\\theta = rV - (r-q)S\\delta - \\tfrac{1}{2}\\sigma^2 S^2 \\Gamma$ using those grid-derived $\\delta/\\Gamma$ |\n", "| Monte Carlo | NUMERICAL | Bump and reprice using **common random numbers** (CRN) — same seed across bumps to control variance |\n", "\n", "**Caveats specific to barriers**:\n", @@ -975,8 +978,6 @@ " for continuous monitoring, bumping spot/vol/time\n", " re-runs Boyle-Lau and picks a different effective `num_steps` for each\n", " bump, so the central difference compares two different tree topologies.\n", - " Empirically, discrete barrier binomial bump and revalue greeks were also\n", - " noisy, so we block those (except $\\rho$).\n", "- MC barrier gamma is blocked: the central-difference signal-to-noise\n", " ratio is too low for practical path counts.\n", "- Discrete-monitoring barrier theta via NUMERICAL is blocked across\n", @@ -995,7 +996,7 @@ { "cell_type": "code", "execution_count": 14, - "id": "7b1284d1", + "id": "f67b5422", "metadata": {}, "outputs": [ { @@ -1058,7 +1059,7 @@ }, { "cell_type": "markdown", - "id": "08fbf7a0", + "id": "5e739a26", "metadata": {}, "source": [ "## 6) Discrete Monitoring — European\n", @@ -1084,7 +1085,7 @@ { "cell_type": "code", "execution_count": 15, - "id": "d2e7a6fb", + "id": "7f5e2551", "metadata": {}, "outputs": [], "source": [ @@ -1106,7 +1107,7 @@ }, { "cell_type": "markdown", - "id": "1b6a609a", + "id": "cdb19b8d", "metadata": {}, "source": [ "### 6.1 BSM analytical — Broadie-Glasserman-Kou continuity correction\n", @@ -1124,12 +1125,13 @@ "to hit) and $-$ for DOWN barriers (push the barrier down). $\\beta = -\\zeta(1/2)/\\sqrt{2\\pi}$\n", "comes from the expected overshoot of a random walk past a barrier.\n", "\n", - "**Accuracy.** The shift is the leading $O(1/\\sqrt{M})$ correction. It\n", - "is exact in the $M \\to \\infty$ limit and the residual is $O(1/M)$. In\n", - "practice the accuracy is contract-dependent. Stress configurations — barriers\n", + "**Accuracy.** The barrier shift itself is of size $O(1/\\sqrt{M})$ and\n", + "captures the leading discrete-vs-continuous monitoring effect. After\n", + "applying that shift, the remaining error is higher order than\n", + "$O(1/\\sqrt{M})$. In practice the accuracy is contract-dependent. Stress configurations — barriers\n", "close to spot, or contracts where the barrier sits in the part of the\n", "payoff profile that dominates the price (e.g. a deep-ITM DO put with\n", - " near-ATM strike) — can show a **few percent** error.\n", + " near-ATM strike) — can show a few percent error.\n", " \n", "So treat BSM-BGK as a strong first cut for typical configurations. When\n", "precision matters at low $M$ on stress contracts, prefer PDE_FD.\n", @@ -1143,7 +1145,7 @@ { "cell_type": "code", "execution_count": 16, - "id": "a8fcbc38", + "id": "21074998", "metadata": {}, "outputs": [ { @@ -1169,7 +1171,7 @@ }, { "cell_type": "markdown", - "id": "4008deaa", + "id": "66bc11a0", "metadata": {}, "source": [ "### 6.2 Binomial — half-step CRR-layer alignment\n", @@ -1182,16 +1184,6 @@ "into the KI lattice (KI). All other time steps run plain backward\n", "induction without touching the barrier state.\n", "\n", - "**Why spatial grid alignment matters.** At a given time step, the CRR\n", - "spot layers sit at fixed positions $S_0 e^{(2j-n)\\sigma\\sqrt{\\Delta t}}$\n", - "and the barrier $H$ generally falls *between* two adjacent layers. The\n", - "binomial probability mass at an observation date spans roughly half a\n", - "layer on each side of each layer's nominal position, so the effective\n", - "discrete kill threshold sits at the *midpoint* between the alive-side\n", - "and dead-side layers — not at $H$. With $H$ randomly placed between\n", - "layers, this midpoint can shift by up to $\\pm \\Delta/2$ in log-spot\n", - "units, biasing the kill probability and producing multi-percent\n", - "pricing errors on small-price reverse / regular-KI cases.\n", "\n", "**Half-step alignment.**\n", "`_BinomialBarrierValuation._resolve_effective_num_steps` inflates the\n", @@ -1222,7 +1214,7 @@ { "cell_type": "code", "execution_count": 17, - "id": "2dea6a7a", + "id": "f2270095", "metadata": {}, "outputs": [ { @@ -1258,7 +1250,7 @@ }, { "cell_type": "markdown", - "id": "dfb98d00", + "id": "2bceb27c", "metadata": {}, "source": [ "### 6.3 PDE_FD — half-step grid alignment\n", @@ -1298,7 +1290,7 @@ { "cell_type": "code", "execution_count": 18, - "id": "6c3054c8", + "id": "bb3a52b1", "metadata": {}, "outputs": [ { @@ -1334,7 +1326,7 @@ }, { "cell_type": "markdown", - "id": "0b39503e", + "id": "67fa37b1", "metadata": {}, "source": [ "### 6.4 Monte Carlo — monitoring-date injection\n", @@ -1362,7 +1354,7 @@ { "cell_type": "code", "execution_count": 19, - "id": "ce408786", + "id": "2def0485", "metadata": {}, "outputs": [ { @@ -1394,7 +1386,7 @@ }, { "cell_type": "markdown", - "id": "cec308cd", + "id": "cf4b2a9e", "metadata": {}, "source": [ "### 6.5 Discrete European cross-engine table" @@ -1403,7 +1395,7 @@ { "cell_type": "code", "execution_count": 20, - "id": "7b14e4e4", + "id": "68ec88d6", "metadata": {}, "outputs": [ { @@ -1448,7 +1440,7 @@ }, { "cell_type": "markdown", - "id": "e178854b", + "id": "6ca3ce0b", "metadata": {}, "source": [ "## 7) Discrete American\n", @@ -1474,7 +1466,7 @@ { "cell_type": "code", "execution_count": 21, - "id": "8772dd5a", + "id": "68a897b6", "metadata": {}, "outputs": [ { @@ -1544,7 +1536,7 @@ }, { "cell_type": "markdown", - "id": "5e95ecb6", + "id": "43366c76", "metadata": {}, "source": [ "## 8) Summary\n", @@ -1552,7 +1544,7 @@ "| Engine | Continuous EU | Continuous AM | Discrete EU | Discrete AM |\n", "|---|---|---|---|---|\n", "| **BSM analytical** | Reiner-Rubinstein closed form | ✗ (no closed form) | BGK continuity correction | ✗ |\n", - "| **Binomial** | Boyle-Lau step adjustment | Boyle-Lau + free-boundary | Tree-layer snapping | Snap + free-boundary |\n", + "| **Binomial** | Boyle-Lau on-node alignment | Boyle-Lau on-node + free-boundary | Boyle-Lau half-step alignment (Cheuk-Vorst / Boyle-Tian binomial analog) | Boyle-Lau half-step + free-boundary |\n", "| **PDE_FD** | Truncated grid (KO) + parity (KI) | Truncated KO + 2-surface KI | Full grid + Boyle-Tian half-step | Full grid + half-step + free-boundary + 2-surface KI |\n", "| **Monte Carlo** | Brownian-bridge correction | LSM with barrier-aware basis | Monitoring-date injection | LSM + monitoring-date injection |\n", "\n", @@ -1618,7 +1610,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": ".venv (3.12.3)", "language": "python", "name": "python3" }, From a7e28d4eb144326a8c20ba708d5eeee062690fad Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Wed, 13 May 2026 18:23:35 +0100 Subject: [PATCH 07/10] Update 06b notebook --- tutorials/06b_barrier_options.ipynb | 295 +++++++++++++--------------- 1 file changed, 132 insertions(+), 163 deletions(-) diff --git a/tutorials/06b_barrier_options.ipynb b/tutorials/06b_barrier_options.ipynb index ad68903..a089d2c 100644 --- a/tutorials/06b_barrier_options.ipynb +++ b/tutorials/06b_barrier_options.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "94a5e89d", + "id": "884b36e5", "metadata": {}, "source": [ "# Barrier Options — Further Topics\n", @@ -16,9 +16,10 @@ "1. **Engine speed** — the production engine (PDE_FD) is highly accurate\n", " by default (sub-bp on continuous European), with a speed mode available\n", " when throughput matters. Pricing continuous European barriers with default\n", - " params takes ~1-4 seconds per valuation; when extra throughput is needed, switch to\n", - " an explicit FD scheme (e.g. EXPLICIT_HULL) — drops runtime to\n", - " ~0.2-0.6 seconds at the cost of a few bps of accuracy. For Americans\n", + " params generally takes ~1-4 seconds per valuation; when extra throughput\n", + " is needed, switch to an explicit FD scheme (e.g. EXPLICIT_HULL) — \n", + " usually drops runtime to ~0.2-0.7 seconds at the cost of a few bps of\n", + " accuracy. For Americans\n", " an explicit scheme also requires american_solver=INTRINSIC, since\n", " GAUSS_SEIDEL (PSOR) isn't compatible with explicit time stepping.\n", " Discrete-monitoring barriers already default to this fast configuration\n", @@ -47,7 +48,7 @@ }, { "cell_type": "markdown", - "id": "fe2f2efb", + "id": "014745bc", "metadata": {}, "source": [ "## 1) Notebook Setup" @@ -55,8 +56,8 @@ }, { "cell_type": "code", - "execution_count": 1, - "id": "324ac856", + "execution_count": null, + "id": "f5048bd3", "metadata": { "lines_to_next_cell": 2 }, @@ -79,8 +80,8 @@ }, { "cell_type": "code", - "execution_count": 2, - "id": "362d299f", + "execution_count": null, + "id": "03fa1590", "metadata": {}, "outputs": [], "source": [ @@ -132,7 +133,7 @@ }, { "cell_type": "markdown", - "id": "8296d06d", + "id": "7cdef134", "metadata": {}, "source": [ "## 2) Engine Speed: PDE_FD as the Production Engine\n", @@ -143,7 +144,7 @@ "- **Monte Carlo LSM** with 150k paths × 252 time steps (a few seconds\n", " per price).\n", "- **Binomial with `num_steps=10_000`** for the converged-Binomial demos\n", - " in §3 (~10–25s per price).\n", + " in §3.\n", "- The default **Crank-Nicolson + projected SOR** PDE_FD for\n", " continuous-monitoring Americans (a few seconds per contract).\n", "\n", @@ -154,8 +155,8 @@ }, { "cell_type": "code", - "execution_count": 3, - "id": "9318a60b", + "execution_count": null, + "id": "27a7bef2", "metadata": {}, "outputs": [], "source": [ @@ -177,8 +178,8 @@ }, { "cell_type": "code", - "execution_count": 4, - "id": "0ee8b755", + "execution_count": null, + "id": "c258945d", "metadata": {}, "outputs": [ { @@ -188,12 +189,12 @@ "PDE_FD speed: CN default (PSOR) vs EXPLICIT_HULL + INTRINSIC\n", "case BSM PV CN PV CN time EH PV EH time PV diff PV diff (%) speedup\n", "------------------------------------------------------------------------------------------------------\n", - "DOP european 0.4167 0.4166 1184ms 0.4187 182ms +0.0021 0.50% 6.5x\n", - "DOP american — 8.0129 3217ms 8.0346 209ms +0.0217 0.27% 15.4x\n", - "UOC european 0.2623 0.2623 1267ms 0.2635 168ms +0.0011 0.44% 7.6x\n", - "UOC american — 8.6920 2134ms 8.7317 186ms +0.0396 0.46% 11.5x\n", - "DIP american — 8.1447 2415ms 8.1477 307ms +0.0031 0.04% 7.9x\n", - "DIC european 1.2003 1.2003 4100ms 1.1949 741ms -0.0054 -0.45% 5.5x\n" + "DOP european 0.4167 0.4166 2452ms 0.4187 288ms +0.0021 0.50% 8.5x\n", + "DOP american — 8.0129 2463ms 8.0346 331ms +0.0217 0.27% 7.4x\n", + "UOC european 0.2623 0.2623 2013ms 0.2635 410ms +0.0011 0.44% 4.9x\n", + "UOC american — 8.6920 2221ms 8.7317 111ms +0.0396 0.46% 20.0x\n", + "DIP american — 8.1447 1311ms 8.1477 125ms +0.0031 0.04% 10.5x\n", + "DIC european 1.2003 1.2003 1604ms 1.1949 186ms -0.0054 -0.45% 8.6x\n" ] } ], @@ -311,7 +312,7 @@ }, { "cell_type": "markdown", - "id": "1cd9af54", + "id": "38b6580b", "metadata": {}, "source": [ "**Discrete monitoring** already defaults to `EXPLICIT_HULL` +\n", @@ -342,7 +343,7 @@ }, { "cell_type": "markdown", - "id": "76747bc5", + "id": "bd241f50", "metadata": {}, "source": [ "## 3) Truncated-Payoff KO Americans (DOP, UOC)\n", @@ -373,15 +374,15 @@ " represented in any finite path sample.\n", "\n", " The package emits a warning at `__init__` for `MONTE_CARLO + AMERICAN +\n", - " (DOP or UOC)` to flag this — see `monte_carlo.py:1635`. The other\n", + " (DOP or UOC)` to flag this — see `monte_carlo.py:1649`. The other\n", " barrier American specs (DI put, UI call, DO call, UI put, ...) don't\n", " trigger the warning; they converge cleanly." ] }, { "cell_type": "code", - "execution_count": 5, - "id": "f2c952f6", + "execution_count": null, + "id": "1cfd1750", "metadata": {}, "outputs": [ { @@ -459,7 +460,7 @@ }, { "cell_type": "markdown", - "id": "bc30e6c6", + "id": "dd6a4ccf", "metadata": {}, "source": [ "**Reading the table.** The PDE_FD American is the reference.\n", @@ -482,8 +483,8 @@ }, { "cell_type": "code", - "execution_count": 6, - "id": "46af1ff2", + "execution_count": null, + "id": "5584365b", "metadata": {}, "outputs": [ { @@ -513,8 +514,8 @@ }, { "cell_type": "code", - "execution_count": 7, - "id": "741d4bf6", + "execution_count": null, + "id": "6d9eeb5c", "metadata": {}, "outputs": [ { @@ -584,7 +585,7 @@ }, { "cell_type": "markdown", - "id": "2f03e47b", + "id": "fd3fe464", "metadata": {}, "source": [ "The UOC shows even more pronounced divergence. Binomial is further\n", @@ -599,8 +600,8 @@ }, { "cell_type": "code", - "execution_count": 8, - "id": "d62eb7d4", + "execution_count": null, + "id": "1c8c589a", "metadata": {}, "outputs": [ { @@ -631,7 +632,7 @@ }, { "cell_type": "markdown", - "id": "8b81edc2", + "id": "711edd38", "metadata": {}, "source": [ "\n", @@ -654,8 +655,8 @@ }, { "cell_type": "code", - "execution_count": 9, - "id": "37dc9574", + "execution_count": null, + "id": "037f24ce", "metadata": {}, "outputs": [ { @@ -723,7 +724,7 @@ }, { "cell_type": "markdown", - "id": "edbc1a07", + "id": "147c64d6", "metadata": {}, "source": [ "DOC American agrees across all four engines to within MC sampling\n", @@ -735,7 +736,7 @@ }, { "cell_type": "markdown", - "id": "18fedec6", + "id": "d904c610", "metadata": {}, "source": [ "And a **UIC** (up-and-in call) example. Again MC's bias collapses to MC sampling noise." @@ -743,8 +744,8 @@ }, { "cell_type": "code", - "execution_count": 10, - "id": "312cd710", + "execution_count": null, + "id": "2c2da471", "metadata": {}, "outputs": [ { @@ -814,7 +815,7 @@ }, { "cell_type": "markdown", - "id": "011c4543", + "id": "fc4e9973", "metadata": {}, "source": [ "## 4) Barrier Extremely Close to Spot (Boyle-Tian Table 6)\n", @@ -832,7 +833,7 @@ " binds — at which point the tree runs without barrier alignment and\n", " only achieves $O(1/\\sqrt{N})$ convergence.\n", "- **Binomial near-barrier greeks** also have a separate guard,\n", - " `_stencil_straddles_barrier` (`binomial.py:1057`). Hull's tree\n", + " `_stencil_straddles_barrier` (`binomial.py:1053`). Hull's tree\n", " delta uses a step-1 central difference (the up- and down-nodes\n", " from the root); the gamma uses the step-2 stencil. When spot is\n", " close enough to the barrier that one of those nodes lies past $H$,\n", @@ -855,8 +856,8 @@ }, { "cell_type": "code", - "execution_count": 11, - "id": "bafb2b02", + "execution_count": null, + "id": "b31a8065", "metadata": { "lines_to_next_cell": 2 }, @@ -913,7 +914,7 @@ }, { "cell_type": "markdown", - "id": "0894540b", + "id": "d4c418a2", "metadata": {}, "source": [ "### 4.1 PV vs paper as spot → barrier\n", @@ -926,8 +927,8 @@ }, { "cell_type": "code", - "execution_count": 30, - "id": "e45cd621", + "execution_count": null, + "id": "0fcc8cff", "metadata": { "lines_to_next_cell": 2 }, @@ -1030,7 +1031,7 @@ }, { "cell_type": "markdown", - "id": "27d74a89", + "id": "1241e6b9", "metadata": {}, "source": [ "- **PDE_FD** matches BSM closely across all spots, including down to\n", @@ -1043,7 +1044,7 @@ }, { "cell_type": "markdown", - "id": "32a39baa", + "id": "ab3d11b7", "metadata": {}, "source": [ "### 4.2 Greeks vs the paper's reference values\n", @@ -1056,7 +1057,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c2e94eb6", + "id": "71a1db7c", "metadata": {}, "outputs": [ { @@ -1132,7 +1133,7 @@ }, { "cell_type": "markdown", - "id": "a6d21bb1", + "id": "86b2609c", "metadata": {}, "source": [ "- **BSM** matches the paper closely on delta/gamma; the gap in theta\n", @@ -1167,7 +1168,7 @@ }, { "cell_type": "markdown", - "id": "622075bc", + "id": "3291db36", "metadata": {}, "source": [ "### 4.3 What the Binomial cap warning looks like\n", @@ -1178,8 +1179,8 @@ }, { "cell_type": "code", - "execution_count": 14, - "id": "34f47389", + "execution_count": null, + "id": "4da264e1", "metadata": {}, "outputs": [ { @@ -1209,7 +1210,7 @@ }, { "cell_type": "markdown", - "id": "cb612bd8", + "id": "e4031249", "metadata": {}, "source": [ "The warning tells you exactly what's happening: Boyle-Lau alignment\n", @@ -1220,7 +1221,7 @@ }, { "cell_type": "markdown", - "id": "0cd6b1fd", + "id": "0226d560", "metadata": {}, "source": [ "### 4.4 Numerical greek bumps near the barrier\n", @@ -1246,7 +1247,7 @@ { "cell_type": "code", "execution_count": null, - "id": "07ee4d9e", + "id": "1b366bb2", "metadata": {}, "outputs": [ { @@ -1315,7 +1316,7 @@ }, { "cell_type": "markdown", - "id": "12e0e0fc", + "id": "df136da5", "metadata": {}, "source": [ "## 5) Inception-Triggered Barriers\n", @@ -1323,29 +1324,37 @@ "A barrier spec is **triggered at inception** when the spot at the\n", "pricing date is already on the wrong side of the barrier (under\n", "continuous monitoring) or, for discrete monitoring, the pricing date\n", - "is itself an observation date with spot on the wrong side. The\n", - "package detects this via `BarrierSpec.is_spot_past_barrier(spot)` and routes\n", - "to a closed-form short-circuit:\n", - "\n", - "- **Triggered KO**: the contract is already extinguished. PV is the\n", + "is itself an observation date with spot on the wrong side.\n", + "Monitoring-aware detection lives at the OV level in\n", + "`OptionValuation._barrier_triggered_at_inception()` (`core.py`);\n", + "`BarrierSpec.is_spot_past_barrier(spot)` is a separate, **pure\n", + "geometric** helper exposed on the spec (ignores the monitoring\n", + "schedule). Two pricing regimes collapse to a deterministic answer:\n", + "\n", + "- **Triggered KO**: the contract is extinguished. PV is the\n", " deterministic rebate cashflow:\n", " - 0 if no rebate\n", " - $R$ if rebate is paid AT_HIT (already paid, sitting as cash)\n", " - $R \\cdot \\text{df}(0, T)$ if AT_EXPIRY\n", - "- **Triggered KI**: the contract is already activated, so it *is* the\n", + "- **Triggered KI**: the contract is activated, so it *is* the\n", " underlying vanilla. PV equals the vanilla equivalent's PV; greeks\n", - " delegate to the vanilla equivalent's greeks (a cached `OptionValuation`\n", - " on a `VanillaSpec` with the same option type, strike, maturity, and exercise\n", - " style).\n", - "\n", - "These short-circuits live at the `OptionValuation` level (`core.py`\n", - "§delta / gamma / vega / theta / rho), so they fire regardless of which\n", - "engine you nominally selected — BSM, Binomial, PDE_FD, or Monte Carlo." + " match the vanilla equivalent's greeks (same option type, strike,\n", + " maturity, exercise style).\n", + "\n", + "**Where the handling lives.** PV and engine-native greeks\n", + "(ANALYTICAL / TREE / GRID / PATHWISE / LR) are produced by each\n", + "engine *natively* — every engine recognises inception-triggered\n", + "specs and returns the deterministic value itself. For NUMERICAL\n", + "bump-and-revalue greeks, we short circuit at the OptionValuation \n", + "(dispatcher) level: bumping spot may cross the trigger boundary\n", + "and revalue the un-triggered contract, so OV intercepts and \n", + "either returns the constant-cashflow derivative (KO) or delegates to a cached\n", + "`_vanilla_equivalent_valuation` (KI)." ] }, { "cell_type": "markdown", - "id": "820fe802", + "id": "bf0d5031", "metadata": {}, "source": [ "### 5.1 Detecting a triggered spec\n", @@ -1357,8 +1366,8 @@ }, { "cell_type": "code", - "execution_count": 16, - "id": "afc13c77", + "execution_count": null, + "id": "72aa8e63", "metadata": {}, "outputs": [ { @@ -1391,7 +1400,7 @@ }, { "cell_type": "markdown", - "id": "357c9f8b", + "id": "34079f33", "metadata": {}, "source": [ "For a DOWN barrier, `is_spot_past_barrier(spot) = (spot <= barrier)` — the\n", @@ -1401,7 +1410,7 @@ }, { "cell_type": "markdown", - "id": "c93e7166", + "id": "31f13bda", "metadata": {}, "source": [ "### 5.2 Triggered KO — deterministic cash\n", @@ -1412,8 +1421,8 @@ }, { "cell_type": "code", - "execution_count": 17, - "id": "36bedf78", + "execution_count": null, + "id": "390ba3a5", "metadata": {}, "outputs": [ { @@ -1483,15 +1492,14 @@ }, { "cell_type": "markdown", - "id": "f1a68c42", + "id": "751da91a", "metadata": {}, "source": [ "**Reading the table.**\n", "\n", - "- All four engines return **identical** PVs to many decimal places.\n", - " That's because the OV-level short-circuit fires before any engine\n", - " dispatch — it returns the closed-form deterministic cashflow value\n", - " immediately.\n", + "- All four engines return **identical** PVs to many decimal places —\n", + " each engine independently detects the inception-triggered state\n", + " and returns the deterministic cashflow value.\n", "- **No rebate**: PV = 0 (option dead, nothing payable).\n", "- **AT_HIT rebate=5**: PV = 5.0 — the rebate is \"paid\" at the trigger\n", " moment, which at inception is *now*, so it sits as cash with no\n", @@ -1502,7 +1510,7 @@ }, { "cell_type": "markdown", - "id": "87acb118", + "id": "9fe7d612", "metadata": {}, "source": [ "### 5.3 Triggered KO — greeks\n", @@ -1513,8 +1521,8 @@ }, { "cell_type": "code", - "execution_count": 18, - "id": "c7c51c37", + "execution_count": null, + "id": "beb3fd54", "metadata": {}, "outputs": [ { @@ -1545,7 +1553,7 @@ }, { "cell_type": "markdown", - "id": "1e2bd765", + "id": "2d7b4a5a", "metadata": {}, "source": [ "All five greeks have closed-form values regardless of engine selection.\n", @@ -1557,7 +1565,7 @@ }, { "cell_type": "markdown", - "id": "7326278e", + "id": "a2360acf", "metadata": {}, "source": [ "### 5.4 Triggered KI — collapses to vanilla\n", @@ -1568,8 +1576,8 @@ }, { "cell_type": "code", - "execution_count": 19, - "id": "0321f529", + "execution_count": null, + "id": "7d48ff66", "metadata": {}, "outputs": [ { @@ -1612,10 +1620,13 @@ "ov_van = dp.OptionValuation(ud_triggered, spec_vanilla, dp.PricingMethod.BSM)\n", "\n", "# Force NUMERICAL on both sides so the comparison is apples-to-apples.\n", - "# The triggered-KI short-circuit always routes the greek call through\n", - "# vanilla_equivalent.(NUMERICAL), so passing NUMERICAL on the\n", - "# externally-built vanilla guarantees both sides bump-and-revalue with\n", - "# the same epsilon and produce byte-identical numbers.\n", + "# When the caller requests NUMERICAL greeks on a triggered KI, OV\n", + "# routes the call through vanilla_equivalent.(NUMERICAL); the\n", + "# externally-built vanilla with the same NUMERICAL request then\n", + "# bump-and-revalues with the same epsilon and produces byte-identical\n", + "# numbers. (For engine-native greeks — ANALYTICAL / TREE / GRID /\n", + "# PATHWISE / LR — the engine handles the triggered KI itself, with no\n", + "# OV routing involved.)\n", "NUM = dp.GreekCalculationMethod.NUMERICAL\n", "\n", "LABEL_W = 16\n", @@ -1654,7 +1665,7 @@ }, { "cell_type": "markdown", - "id": "36215d95", + "id": "7b727008", "metadata": {}, "source": [ "Every greek and PV match the vanilla call **exactly**. Internally the\n", @@ -1665,38 +1676,7 @@ }, { "cell_type": "markdown", - "id": "622ad9ff", - "metadata": {}, - "source": [ - "### 5.5 Why the OV-level routing matters\n", - "\n", - "The short-circuit lives at `OptionValuation`, not in any one engine,\n", - "for two reasons:\n", - "\n", - "1. **Engine-agnostic correctness.** All four engines should agree on\n", - " a triggered spec — there's no path-dependence to disagree about,\n", - " just a deterministic cashflow. Centralising the logic at the OV\n", - " level guarantees the agreement.\n", - "2. **Avoids a class of subtle bugs in numerical engines.** If you\n", - " pass a triggered KO to (say) Monte Carlo without the short-circuit,\n", - " the engine has to handle the special case where the very first\n", - " grid point is already past the barrier — historically a source of\n", - " indexing bugs. The short-circuit makes that case unreachable.\n", - "\n", - "Notebook 06a's §5 (Greeks) covered the broader caveats around numerical\n", - "greeks for barriers; the inception-triggered short-circuit is one\n", - "specific manifestation of \"engine-native paths can stumble on\n", - "degenerate barrier states, so we route around them at the OV level\".\n", - "\n", - "*Note* - this engine-agnostic OV level routing only occurs for NUMERICAL \n", - "(bump and revalue) inception-triggered barrier greeks. For TREE/GRID greeks,\n", - "the private Binomial/FD implementer engines handle inception-triggered barriers\n", - "accordingly." - ] - }, - { - "cell_type": "markdown", - "id": "13aef727", + "id": "3a271312", "metadata": {}, "source": [ "## 6) Redundant Barriers — KI Specs Equivalent to Vanilla\n", @@ -1726,8 +1706,8 @@ }, { "cell_type": "code", - "execution_count": 20, - "id": "7cced9bc", + "execution_count": null, + "id": "328435c4", "metadata": {}, "outputs": [ { @@ -1820,7 +1800,7 @@ }, { "cell_type": "markdown", - "id": "5cf09560", + "id": "53947faf", "metadata": {}, "source": [ "Note for PDE_FD, we pass in `PDEParams.for_barriers` to the vanilla valuation, so the \n", @@ -1831,8 +1811,8 @@ }, { "cell_type": "code", - "execution_count": 21, - "id": "40302076", + "execution_count": null, + "id": "b1c7bc5d", "metadata": {}, "outputs": [ { @@ -1924,7 +1904,7 @@ }, { "cell_type": "markdown", - "id": "7301a0a4", + "id": "6af9eadb", "metadata": {}, "source": [ "- **BSM, PDE_FD and Monte Carlo** match the vanilla price exactly\n", @@ -1939,7 +1919,7 @@ }, { "cell_type": "markdown", - "id": "ddbcd2e3", + "id": "f146b908", "metadata": {}, "source": [ "## 7) Rebates — Worked Examples\n", @@ -1952,8 +1932,8 @@ }, { "cell_type": "code", - "execution_count": 22, - "id": "5aea9291", + "execution_count": null, + "id": "48533909", "metadata": {}, "outputs": [ { @@ -2019,17 +1999,18 @@ }, { "cell_type": "markdown", - "id": "24ff7732", + "id": "02f1eb5d", "metadata": {}, "source": [ - "- **DO call AT_HIT vs AT_EXPIRY**: AT_HIT pays $R$ instantly when the\n", - " barrier is touched. AT_EXPIRY pays $R$ at maturity, so its PV is\n", - " the discounted-then-probability-weighted value. Whichever is larger\n", - " depends on the barrier-hit probability and the time to hit (an\n", - " AT_HIT rebate paid early is worth more in PV than the same rebate\n", - " paid at expiry, conditional on the same hit event). In our setup\n", - " the hit probability is moderate and AT_HIT is slightly more\n", - " valuable.\n", + "- **DO call AT_HIT vs AT_EXPIRY**: AT_HIT pays $R$ at the (random)\n", + " hit time $\\tau$; AT_EXPIRY pays $R$ at maturity $T$ if the barrier\n", + " has been touched. Both legs share the same hit indicator\n", + " $\\mathbb{1}_{\\{\\tau \\le T\\}}$, so per hit path the only difference\n", + " is when the cashflow lands. Under positive rates, $\\text{df}(0,\n", + " \\tau) \\ge \\text{df}(0, T)$ with strict inequality whenever $\\tau <\n", + " T$, so the AT_HIT rebate leg is always at least as valuable as\n", + " the AT_EXPIRY rebate leg — strictly so for any KO with positive hit\n", + " probability. \n", "- **DI call no-touch rebate**: paid at expiry **if and only if** the\n", " barrier is never touched (i.e. the option never activated). This\n", " adds value to the DI: the holder either gets the activated vanilla\n", @@ -2042,8 +2023,8 @@ }, { "cell_type": "code", - "execution_count": 23, - "id": "a949a70b", + "execution_count": null, + "id": "d76d0f3c", "metadata": {}, "outputs": [ { @@ -2076,7 +2057,7 @@ }, { "cell_type": "markdown", - "id": "d5701766", + "id": "4ba52284", "metadata": {}, "source": [ "> **Production note.** Across every regime covered in this (and the 06a_barrier_options) notebooks — European and American exercise, continuous and discrete monitoring, barrier close to and far from spot — `PricingMethod.PDE_FD` is the single engine that handles every case at high accuracy (with the default parameters).\n", @@ -2090,7 +2071,7 @@ }, { "cell_type": "markdown", - "id": "1dced8ac", + "id": "05842c9e", "metadata": {}, "source": [ "## 8) Summary\n", @@ -2116,21 +2097,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": ".venv (3.12.3)", "language": "python", "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" } }, "nbformat": 4, From 083ee73319d0181a0c1c6971bb00fb2ac3eef99b Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Wed, 13 May 2026 18:49:13 +0100 Subject: [PATCH 08/10] Update 06a notebook --- tutorials/06a_barrier_options.ipynb | 164 +++++++++++++--------------- 1 file changed, 77 insertions(+), 87 deletions(-) diff --git a/tutorials/06a_barrier_options.ipynb b/tutorials/06a_barrier_options.ipynb index 81d6389..a83ef5f 100644 --- a/tutorials/06a_barrier_options.ipynb +++ b/tutorials/06a_barrier_options.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "bdd47659", + "id": "9938a69f", "metadata": {}, "source": [ "# Barrier Options\n", @@ -42,7 +42,7 @@ }, { "cell_type": "markdown", - "id": "11bfc21b", + "id": "aeb041ee", "metadata": {}, "source": [ "## 1) Notebook Setup\n", @@ -64,8 +64,8 @@ }, { "cell_type": "code", - "execution_count": 1, - "id": "89d12157", + "execution_count": null, + "id": "90e9b5e1", "metadata": {}, "outputs": [], "source": [ @@ -79,8 +79,8 @@ }, { "cell_type": "code", - "execution_count": 2, - "id": "02b85467", + "execution_count": null, + "id": "3a43a809", "metadata": {}, "outputs": [ { @@ -125,7 +125,7 @@ }, { "cell_type": "markdown", - "id": "4a1f10d6", + "id": "131c95ca", "metadata": {}, "source": [ "## 2) Anatomy of a Barrier Option\n", @@ -202,7 +202,7 @@ }, { "cell_type": "markdown", - "id": "47d657a3", + "id": "018e5ae9", "metadata": {}, "source": [ "## 3) Continuous European Pricing — Four Engines\n", @@ -215,8 +215,8 @@ }, { "cell_type": "code", - "execution_count": 3, - "id": "6db6ccda", + "execution_count": null, + "id": "5a5f8d7e", "metadata": {}, "outputs": [], "source": [ @@ -244,7 +244,7 @@ }, { "cell_type": "markdown", - "id": "3085cd25", + "id": "4b6933af", "metadata": {}, "source": [ "### 3.1 BSM analytical (Reiner-Rubinstein)\n", @@ -290,8 +290,8 @@ }, { "cell_type": "code", - "execution_count": 4, - "id": "de8398e8", + "execution_count": null, + "id": "98473ad5", "metadata": {}, "outputs": [ { @@ -325,7 +325,7 @@ }, { "cell_type": "markdown", - "id": "0a1473d7", + "id": "d273bd48", "metadata": {}, "source": [ "The in-out parity holds to machine precision, confirming the analytical\n", @@ -366,7 +366,7 @@ }, { "cell_type": "markdown", - "id": "a597cd7b", + "id": "e4e70fbc", "metadata": {}, "source": [ "### 3.3 Binomial — Boyle-Lau step adjustment\n", @@ -404,7 +404,7 @@ "of steps is resolved:\n", "\n", "**Knock-out approach.** Standard backward induction, but at every monitored\n", - "step (every step under continuous monitoring) any node strictly past the\n", + "step (every step under continuous monitoring) any node at or past the\n", "barrier is **killed** — set to the rebate/discounted rebate value (or 0).\n", "Roll back from maturity to today.\n", "\n", @@ -418,8 +418,8 @@ }, { "cell_type": "code", - "execution_count": 5, - "id": "deff4e8e", + "execution_count": null, + "id": "fe50de91", "metadata": {}, "outputs": [ { @@ -450,7 +450,7 @@ }, { "cell_type": "markdown", - "id": "24c46160", + "id": "55e6887f", "metadata": {}, "source": [ "### 3.4 PDE_FD — truncated grid for KO, in-out parity for KI\n", @@ -505,8 +505,8 @@ }, { "cell_type": "code", - "execution_count": 6, - "id": "34080788", + "execution_count": null, + "id": "d468dd82", "metadata": {}, "outputs": [ { @@ -537,7 +537,7 @@ }, { "cell_type": "markdown", - "id": "5c89bc60", + "id": "4a699e07", "metadata": {}, "source": [ "### 3.5 Monte Carlo — Brownian-bridge continuity correction\n", @@ -588,8 +588,8 @@ }, { "cell_type": "code", - "execution_count": 7, - "id": "fc10eb68", + "execution_count": null, + "id": "ef88a209", "metadata": {}, "outputs": [ { @@ -635,7 +635,7 @@ }, { "cell_type": "markdown", - "id": "ad4b5d3c", + "id": "5848c57f", "metadata": {}, "source": [ "### 3.6 European comparison\n", @@ -646,8 +646,8 @@ }, { "cell_type": "code", - "execution_count": 8, - "id": "b56504ed", + "execution_count": null, + "id": "17115e62", "metadata": {}, "outputs": [ { @@ -678,7 +678,7 @@ }, { "cell_type": "markdown", - "id": "20f03c90", + "id": "8b4ad9ad", "metadata": {}, "source": [ "## 4) American Exercise (Continuous Monitoring)\n", @@ -694,8 +694,8 @@ }, { "cell_type": "code", - "execution_count": 9, - "id": "6efa43c7", + "execution_count": null, + "id": "359b314c", "metadata": {}, "outputs": [], "source": [ @@ -715,7 +715,7 @@ }, { "cell_type": "markdown", - "id": "c920abdb", + "id": "14be3e23", "metadata": {}, "source": [ "### 4.1 Binomial American\n", @@ -734,8 +734,8 @@ }, { "cell_type": "code", - "execution_count": 10, - "id": "5c051ce0", + "execution_count": null, + "id": "b550757c", "metadata": {}, "outputs": [ { @@ -765,7 +765,7 @@ }, { "cell_type": "markdown", - "id": "73169cb8", + "id": "24735ba2", "metadata": {}, "source": [ "### 4.2 PDE_FD American — two-surface coupled PDE for KI\n", @@ -804,8 +804,8 @@ }, { "cell_type": "code", - "execution_count": 11, - "id": "c8827893", + "execution_count": null, + "id": "058d7072", "metadata": {}, "outputs": [ { @@ -831,7 +831,7 @@ }, { "cell_type": "markdown", - "id": "832c34e8", + "id": "3d83282a", "metadata": {}, "source": [ "### 4.3 Monte Carlo American — Longstaff-Schwartz\n", @@ -859,8 +859,9 @@ "*enriched* with barrier-distance features so the fitted continuation\n", "surface respects the barrier discontinuity at $H$. This is controlled\n", "by `MonteCarloParams.barrier_aware_basis` (default `True`) and is gated\n", - "at line 1682 of `_knock_out_step_values` — it is **not** applied to\n", - "knock-ins (there isn't a sharp continuation cliff as there is for KOs).\n", + "inside `_knock_out_step_values` (`monte_carlo.py:1696`) — it is **not**\n", + "applied to knock-ins (there isn't a sharp continuation cliff as there is\n", + "for KOs).\n", "\n", "**Caveat — DO puts and UO calls.** Even with the barrier-aware basis,\n", "LSM remains heavily downward-biased for **truncated-payoff** KO\n", @@ -870,7 +871,7 @@ "represented in any finite path sample. The barrier-aware basis improves\n", "the fit somewhat but doesn't close the bias. Because of this, the\n", "package emits a **warning at `__init__`** for American MC + DOP / UOC\n", - "specs (`monte_carlo.py:1635`), recommending PDE_FD instead. Other\n", + "specs (`monte_carlo.py:1649`), recommending PDE_FD instead. Other\n", "American barrier specs (DI put, UI call, DOC, UI put, ...) don't trigger\n", "the warning — they converge to within typical LSM tolerances.\n", "\n", @@ -879,8 +880,8 @@ }, { "cell_type": "code", - "execution_count": 12, - "id": "6e662cb6", + "execution_count": null, + "id": "3ed282a7", "metadata": {}, "outputs": [ { @@ -906,7 +907,7 @@ }, { "cell_type": "markdown", - "id": "2cdbafa8", + "id": "086658b0", "metadata": {}, "source": [ "### 4.4 American comparison" @@ -914,8 +915,8 @@ }, { "cell_type": "code", - "execution_count": 13, - "id": "13184846", + "execution_count": null, + "id": "abec0438", "metadata": {}, "outputs": [ { @@ -948,7 +949,7 @@ }, { "cell_type": "markdown", - "id": "5fb95f12", + "id": "b2f5fd40", "metadata": {}, "source": [ "## 5) Greeks\n", @@ -995,8 +996,8 @@ }, { "cell_type": "code", - "execution_count": 14, - "id": "f67b5422", + "execution_count": null, + "id": "44c923da", "metadata": {}, "outputs": [ { @@ -1059,7 +1060,7 @@ }, { "cell_type": "markdown", - "id": "5e739a26", + "id": "b29a4c49", "metadata": {}, "source": [ "## 6) Discrete Monitoring — European\n", @@ -1084,8 +1085,8 @@ }, { "cell_type": "code", - "execution_count": 15, - "id": "7f5e2551", + "execution_count": null, + "id": "129df021", "metadata": {}, "outputs": [], "source": [ @@ -1107,7 +1108,7 @@ }, { "cell_type": "markdown", - "id": "cdb19b8d", + "id": "e4e6e3e4", "metadata": {}, "source": [ "### 6.1 BSM analytical — Broadie-Glasserman-Kou continuity correction\n", @@ -1125,10 +1126,11 @@ "to hit) and $-$ for DOWN barriers (push the barrier down). $\\beta = -\\zeta(1/2)/\\sqrt{2\\pi}$\n", "comes from the expected overshoot of a random walk past a barrier.\n", "\n", - "**Accuracy.** The barrier shift itself is of size $O(1/\\sqrt{M})$ and\n", - "captures the leading discrete-vs-continuous monitoring effect. After\n", - "applying that shift, the remaining error is higher order than\n", - "$O(1/\\sqrt{M})$. In practice the accuracy is contract-dependent. Stress configurations — barriers\n", + "**Accuracy.** The shift kills the leading $O(1/\\sqrt{M})$\n", + "discrete-vs-continuous monitoring error. BGK 1997 Theorem 1.1\n", + "proves the residual is $o(1/\\sqrt{M})$ — i.e. shrinks *strictly\n", + "faster* than $1/\\sqrt{M}$ as $M \\to \\infty$. In practice the\n", + "accuracy is contract-dependent. Stress configurations — barriers\n", "close to spot, or contracts where the barrier sits in the part of the\n", "payoff profile that dominates the price (e.g. a deep-ITM DO put with\n", " near-ATM strike) — can show a few percent error.\n", @@ -1144,8 +1146,8 @@ }, { "cell_type": "code", - "execution_count": 16, - "id": "21074998", + "execution_count": null, + "id": "742bbb30", "metadata": {}, "outputs": [ { @@ -1171,7 +1173,7 @@ }, { "cell_type": "markdown", - "id": "66bc11a0", + "id": "73bb72ce", "metadata": {}, "source": [ "### 6.2 Binomial — half-step CRR-layer alignment\n", @@ -1213,8 +1215,8 @@ }, { "cell_type": "code", - "execution_count": 17, - "id": "f2270095", + "execution_count": null, + "id": "780df961", "metadata": {}, "outputs": [ { @@ -1250,7 +1252,7 @@ }, { "cell_type": "markdown", - "id": "2bceb27c", + "id": "77bcc9d4", "metadata": {}, "source": [ "### 6.3 PDE_FD — half-step grid alignment\n", @@ -1289,8 +1291,8 @@ }, { "cell_type": "code", - "execution_count": 18, - "id": "bb3a52b1", + "execution_count": null, + "id": "4d6e2e1d", "metadata": {}, "outputs": [ { @@ -1326,7 +1328,7 @@ }, { "cell_type": "markdown", - "id": "67fa37b1", + "id": "47044018", "metadata": {}, "source": [ "### 6.4 Monte Carlo — monitoring-date injection\n", @@ -1353,8 +1355,8 @@ }, { "cell_type": "code", - "execution_count": 19, - "id": "2def0485", + "execution_count": null, + "id": "daa2e207", "metadata": {}, "outputs": [ { @@ -1386,7 +1388,7 @@ }, { "cell_type": "markdown", - "id": "cf4b2a9e", + "id": "9a5a84ea", "metadata": {}, "source": [ "### 6.5 Discrete European cross-engine table" @@ -1394,8 +1396,8 @@ }, { "cell_type": "code", - "execution_count": 20, - "id": "68ec88d6", + "execution_count": null, + "id": "01f04a1e", "metadata": {}, "outputs": [ { @@ -1440,7 +1442,7 @@ }, { "cell_type": "markdown", - "id": "6ca3ce0b", + "id": "0ec92932", "metadata": {}, "source": [ "## 7) Discrete American\n", @@ -1465,8 +1467,8 @@ }, { "cell_type": "code", - "execution_count": 21, - "id": "68a897b6", + "execution_count": null, + "id": "50881e60", "metadata": {}, "outputs": [ { @@ -1536,7 +1538,7 @@ }, { "cell_type": "markdown", - "id": "43366c76", + "id": "984a7db8", "metadata": {}, "source": [ "## 8) Summary\n", @@ -1544,7 +1546,7 @@ "| Engine | Continuous EU | Continuous AM | Discrete EU | Discrete AM |\n", "|---|---|---|---|---|\n", "| **BSM analytical** | Reiner-Rubinstein closed form | ✗ (no closed form) | BGK continuity correction | ✗ |\n", - "| **Binomial** | Boyle-Lau on-node alignment | Boyle-Lau on-node + free-boundary | Boyle-Lau half-step alignment (Cheuk-Vorst / Boyle-Tian binomial analog) | Boyle-Lau half-step + free-boundary |\n", + "| **Binomial** | Boyle-Lau on-node alignment | Boyle-Lau on-node + free-boundary | Boyle-Tian half-step analog | Half-step + free-boundary |\n", "| **PDE_FD** | Truncated grid (KO) + parity (KI) | Truncated KO + 2-surface KI | Full grid + Boyle-Tian half-step | Full grid + half-step + free-boundary + 2-surface KI |\n", "| **Monte Carlo** | Brownian-bridge correction | LSM with barrier-aware basis | Monitoring-date injection | LSM + monitoring-date injection |\n", "\n", @@ -1613,18 +1615,6 @@ "display_name": ".venv (3.12.3)", "language": "python", "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" } }, "nbformat": 4, From 45a02e3e0d46bf496575c144cdbb20da3663ef99 Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Wed, 13 May 2026 18:50:44 +0100 Subject: [PATCH 09/10] Add AT_HIT>=AT_EXPIRY KO pv comparison --- tests/test_barrier.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_barrier.py b/tests/test_barrier.py index 1648a9a..884e16f 100644 --- a/tests/test_barrier.py +++ b/tests/test_barrier.py @@ -830,6 +830,8 @@ def test_at_hit_and_at_expiry_rebate_differ(self): ) assert pv_at_hit > pv_base assert pv_at_expiry > pv_base + assert pv_at_hit >= pv_at_expiry + # AT_HIT rebate should be worth at least as much as AT_EXPIRY rebate (under positive rates) # =========================================================================== From 8cfb50d6df41a78dde8d4e9dc1abdcb208fc773a Mon Sep 17 00:00:00 2001 From: Jogi Sidhu Date: Wed, 13 May 2026 18:51:07 +0100 Subject: [PATCH 10/10] Update README --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index aa069d8..ab6355d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ import derivatives_pricing as dp pricing_date = dt.datetime(2025, 1, 1) maturity = dt.datetime(2025, 7, 1) -dc = dp.DiscountCurve.flat(rate=0.05, end_time=1.0) +dc = dp.DiscountCurve.flat(rate=0.05) md = dp.MarketData( pricing_date=pricing_date, discount_curve=dc, @@ -163,10 +163,6 @@ examples/ # API usage notebooks tutorials/ # Theory deep-dive notebooks ``` -## Roadmap - -Planned: stochastic volatility models. - Found a bug or have a feature request? [Open an issue](https://github.com/jsidhu06/derivatives-pricing/issues). ## Disclaimer