diff --git a/README.md b/README.md
index bda9f84..ab6355d 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`
@@ -93,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,
@@ -164,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
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/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/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/binomial.py b/src/derivatives_pricing/valuation/binomial.py
index a8b9921..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
@@ -958,37 +965,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 +993,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..0617687 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
@@ -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,14 +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 (UOC
- # tighter, DOP tighter, UIP wider).
+ # 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()
@@ -1449,25 +1441,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 +1503,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 +1612,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/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 a1b6e06..d367ea2 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
+ 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
- spatial grids: spot or log-spot
@@ -386,30 +390,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 +459,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 +478,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))
@@ -1634,7 +1629,7 @@ class _FDAmericanValuation(_FDValuationBase):
# ═══════════════════════════════════════════════════════════════════════════
-# Barrier option PDE
+# Barrier option FD
# ═══════════════════════════════════════════════════════════════════════════
@@ -1849,14 +1844,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
diff --git a/tests/test_barrier.py b/tests/test_barrier.py
index 69ef5d9..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)
# ===========================================================================
@@ -3692,3 +3694,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."
+ )
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,)
diff --git a/tutorials/06a_barrier_options.ipynb b/tutorials/06a_barrier_options.ipynb
index 93167fa..a83ef5f 100644
--- a/tutorials/06a_barrier_options.ipynb
+++ b/tutorials/06a_barrier_options.ipynb
@@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "markdown",
- "id": "d30456c4",
+ "id": "9938a69f",
"metadata": {},
"source": [
"# Barrier Options\n",
@@ -42,7 +42,7 @@
},
{
"cell_type": "markdown",
- "id": "c8721ac8",
+ "id": "aeb041ee",
"metadata": {},
"source": [
"## 1) Notebook Setup\n",
@@ -64,8 +64,8 @@
},
{
"cell_type": "code",
- "execution_count": 1,
- "id": "9a281303",
+ "execution_count": null,
+ "id": "90e9b5e1",
"metadata": {},
"outputs": [],
"source": [
@@ -79,8 +79,8 @@
},
{
"cell_type": "code",
- "execution_count": 2,
- "id": "d363b6de",
+ "execution_count": null,
+ "id": "3a43a809",
"metadata": {},
"outputs": [
{
@@ -125,7 +125,7 @@
},
{
"cell_type": "markdown",
- "id": "f5795f70",
+ "id": "131c95ca",
"metadata": {},
"source": [
"## 2) Anatomy of a Barrier Option\n",
@@ -202,7 +202,7 @@
},
{
"cell_type": "markdown",
- "id": "4b119be7",
+ "id": "018e5ae9",
"metadata": {},
"source": [
"## 3) Continuous European Pricing — Four Engines\n",
@@ -215,8 +215,8 @@
},
{
"cell_type": "code",
- "execution_count": 3,
- "id": "70de9924",
+ "execution_count": null,
+ "id": "5a5f8d7e",
"metadata": {},
"outputs": [],
"source": [
@@ -244,7 +244,7 @@
},
{
"cell_type": "markdown",
- "id": "6cb59998",
+ "id": "4b6933af",
"metadata": {},
"source": [
"### 3.1 BSM analytical (Reiner-Rubinstein)\n",
@@ -290,8 +290,8 @@
},
{
"cell_type": "code",
- "execution_count": 4,
- "id": "506a9297",
+ "execution_count": null,
+ "id": "98473ad5",
"metadata": {},
"outputs": [
{
@@ -325,7 +325,7 @@
},
{
"cell_type": "markdown",
- "id": "47ba7bf2",
+ "id": "d273bd48",
"metadata": {},
"source": [
"The in-out parity holds to machine precision, confirming the analytical\n",
@@ -366,7 +366,7 @@
},
{
"cell_type": "markdown",
- "id": "3eba970f",
+ "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": "1f6d28fd",
+ "execution_count": null,
+ "id": "fe50de91",
"metadata": {},
"outputs": [
{
@@ -450,7 +450,7 @@
},
{
"cell_type": "markdown",
- "id": "b15f5a8a",
+ "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": "32230ece",
+ "execution_count": null,
+ "id": "d468dd82",
"metadata": {},
"outputs": [
{
@@ -537,7 +537,7 @@
},
{
"cell_type": "markdown",
- "id": "6ced2b05",
+ "id": "4a699e07",
"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",
@@ -585,8 +588,8 @@
},
{
"cell_type": "code",
- "execution_count": 7,
- "id": "20fa3920",
+ "execution_count": null,
+ "id": "ef88a209",
"metadata": {},
"outputs": [
{
@@ -632,7 +635,7 @@
},
{
"cell_type": "markdown",
- "id": "a1516da7",
+ "id": "5848c57f",
"metadata": {},
"source": [
"### 3.6 European comparison\n",
@@ -643,8 +646,8 @@
},
{
"cell_type": "code",
- "execution_count": 8,
- "id": "f6776991",
+ "execution_count": null,
+ "id": "17115e62",
"metadata": {},
"outputs": [
{
@@ -675,7 +678,7 @@
},
{
"cell_type": "markdown",
- "id": "0c6e1a63",
+ "id": "8b4ad9ad",
"metadata": {},
"source": [
"## 4) American Exercise (Continuous Monitoring)\n",
@@ -691,8 +694,8 @@
},
{
"cell_type": "code",
- "execution_count": 9,
- "id": "26d55e5f",
+ "execution_count": null,
+ "id": "359b314c",
"metadata": {},
"outputs": [],
"source": [
@@ -712,7 +715,7 @@
},
{
"cell_type": "markdown",
- "id": "229f07e5",
+ "id": "14be3e23",
"metadata": {},
"source": [
"### 4.1 Binomial American\n",
@@ -731,8 +734,8 @@
},
{
"cell_type": "code",
- "execution_count": 10,
- "id": "34517590",
+ "execution_count": null,
+ "id": "b550757c",
"metadata": {},
"outputs": [
{
@@ -762,7 +765,7 @@
},
{
"cell_type": "markdown",
- "id": "80fc67e0",
+ "id": "24735ba2",
"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",
@@ -801,8 +804,8 @@
},
{
"cell_type": "code",
- "execution_count": 11,
- "id": "eeb2d307",
+ "execution_count": null,
+ "id": "058d7072",
"metadata": {},
"outputs": [
{
@@ -828,7 +831,7 @@
},
{
"cell_type": "markdown",
- "id": "8a9e5650",
+ "id": "3d83282a",
"metadata": {},
"source": [
"### 4.3 Monte Carlo American — Longstaff-Schwartz\n",
@@ -856,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",
@@ -867,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",
@@ -876,8 +880,8 @@
},
{
"cell_type": "code",
- "execution_count": 12,
- "id": "f92098d3",
+ "execution_count": null,
+ "id": "3ed282a7",
"metadata": {},
"outputs": [
{
@@ -903,7 +907,7 @@
},
{
"cell_type": "markdown",
- "id": "3f98bf1f",
+ "id": "086658b0",
"metadata": {},
"source": [
"### 4.4 American comparison"
@@ -911,8 +915,8 @@
},
{
"cell_type": "code",
- "execution_count": 13,
- "id": "8e3c9de8",
+ "execution_count": null,
+ "id": "abec0438",
"metadata": {},
"outputs": [
{
@@ -945,7 +949,7 @@
},
{
"cell_type": "markdown",
- "id": "80cf9a1b",
+ "id": "b2f5fd40",
"metadata": {},
"source": [
"## 5) Greeks\n",
@@ -960,9 +964,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 +979,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",
@@ -994,8 +996,8 @@
},
{
"cell_type": "code",
- "execution_count": 14,
- "id": "7b1284d1",
+ "execution_count": null,
+ "id": "44c923da",
"metadata": {},
"outputs": [
{
@@ -1058,7 +1060,7 @@
},
{
"cell_type": "markdown",
- "id": "08fbf7a0",
+ "id": "b29a4c49",
"metadata": {},
"source": [
"## 6) Discrete Monitoring — European\n",
@@ -1083,8 +1085,8 @@
},
{
"cell_type": "code",
- "execution_count": 15,
- "id": "d2e7a6fb",
+ "execution_count": null,
+ "id": "129df021",
"metadata": {},
"outputs": [],
"source": [
@@ -1106,7 +1108,7 @@
},
{
"cell_type": "markdown",
- "id": "1b6a609a",
+ "id": "e4e6e3e4",
"metadata": {},
"source": [
"### 6.1 BSM analytical — Broadie-Glasserman-Kou continuity correction\n",
@@ -1124,12 +1126,14 @@
"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 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",
+ " 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",
@@ -1142,8 +1146,8 @@
},
{
"cell_type": "code",
- "execution_count": 16,
- "id": "a8fcbc38",
+ "execution_count": null,
+ "id": "742bbb30",
"metadata": {},
"outputs": [
{
@@ -1169,7 +1173,7 @@
},
{
"cell_type": "markdown",
- "id": "4008deaa",
+ "id": "73bb72ce",
"metadata": {},
"source": [
"### 6.2 Binomial — half-step CRR-layer alignment\n",
@@ -1182,16 +1186,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",
@@ -1221,8 +1215,8 @@
},
{
"cell_type": "code",
- "execution_count": 17,
- "id": "2dea6a7a",
+ "execution_count": null,
+ "id": "780df961",
"metadata": {},
"outputs": [
{
@@ -1258,7 +1252,7 @@
},
{
"cell_type": "markdown",
- "id": "dfb98d00",
+ "id": "77bcc9d4",
"metadata": {},
"source": [
"### 6.3 PDE_FD — half-step grid alignment\n",
@@ -1297,8 +1291,8 @@
},
{
"cell_type": "code",
- "execution_count": 18,
- "id": "6c3054c8",
+ "execution_count": null,
+ "id": "4d6e2e1d",
"metadata": {},
"outputs": [
{
@@ -1334,7 +1328,7 @@
},
{
"cell_type": "markdown",
- "id": "0b39503e",
+ "id": "47044018",
"metadata": {},
"source": [
"### 6.4 Monte Carlo — monitoring-date injection\n",
@@ -1361,8 +1355,8 @@
},
{
"cell_type": "code",
- "execution_count": 19,
- "id": "ce408786",
+ "execution_count": null,
+ "id": "daa2e207",
"metadata": {},
"outputs": [
{
@@ -1394,7 +1388,7 @@
},
{
"cell_type": "markdown",
- "id": "cec308cd",
+ "id": "9a5a84ea",
"metadata": {},
"source": [
"### 6.5 Discrete European cross-engine table"
@@ -1402,8 +1396,8 @@
},
{
"cell_type": "code",
- "execution_count": 20,
- "id": "7b14e4e4",
+ "execution_count": null,
+ "id": "01f04a1e",
"metadata": {},
"outputs": [
{
@@ -1448,7 +1442,7 @@
},
{
"cell_type": "markdown",
- "id": "e178854b",
+ "id": "0ec92932",
"metadata": {},
"source": [
"## 7) Discrete American\n",
@@ -1473,8 +1467,8 @@
},
{
"cell_type": "code",
- "execution_count": 21,
- "id": "8772dd5a",
+ "execution_count": null,
+ "id": "50881e60",
"metadata": {},
"outputs": [
{
@@ -1544,7 +1538,7 @@
},
{
"cell_type": "markdown",
- "id": "5e95ecb6",
+ "id": "984a7db8",
"metadata": {},
"source": [
"## 8) Summary\n",
@@ -1552,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 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-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",
@@ -1618,21 +1612,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,
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,