Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions src/derivatives_pricing/stochastic_processes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import pandas as pd
from .market_environment import MarketData, CorrelationContext
from .enums import DayCountConvention
from .utils import calculate_year_fraction, validate_naive_datetime
from .utils import calculate_year_fraction, coerce_positive_float, validate_naive_datetime
from .rates import DiscountCurve
from .exceptions import ConfigurationError, ValidationError

Expand Down Expand Up @@ -121,16 +121,16 @@ class GBMParams:
dividend_curve: DiscountCurve | None = None

def __post_init__(self) -> None:
if self.initial_value is None:
raise ValidationError("GBMParams requires initial_value to be not None")
if self.volatility is None:
raise ValidationError("GBMParams requires volatility to be not None")
if not np.isfinite(float(self.initial_value)):
raise ValidationError("GBMParams requires initial_value to be finite")
if not np.isfinite(float(self.volatility)):
raise ValidationError("GBMParams requires volatility to be finite")
if float(self.volatility) < 0.0:
raise ValidationError("GBMParams requires volatility to be >= 0")
object.__setattr__(
self,
"initial_value",
coerce_positive_float(self.initial_value, name="GBMParams.initial_value"),
)
object.__setattr__(
self,
"volatility",
coerce_positive_float(self.volatility, name="GBMParams.volatility", strict=False),
)
object.__setattr__(
self,
"discrete_dividends",
Expand Down Expand Up @@ -177,16 +177,16 @@ class JDParams:
dividend_curve: DiscountCurve | None = None

def __post_init__(self) -> None:
if self.initial_value is None:
raise ValidationError("JDParams requires initial_value to be not None")
if self.volatility is None:
raise ValidationError("JDParams requires volatility to be not None")
if not np.isfinite(float(self.initial_value)):
raise ValidationError("JDParams requires initial_value to be finite")
if not np.isfinite(float(self.volatility)):
raise ValidationError("JDParams requires volatility to be finite")
if float(self.volatility) < 0.0:
raise ValidationError("JDParams requires volatility to be >= 0")
object.__setattr__(
self,
"initial_value",
coerce_positive_float(self.initial_value, name="JDParams.initial_value"),
)
object.__setattr__(
self,
"volatility",
coerce_positive_float(self.volatility, name="JDParams.volatility", strict=False),
)

if self.lambd is None or self.mu is None or self.delta is None:
raise ValidationError("JDParams requires lambd, mu, and delta to be not None")
Expand Down
64 changes: 63 additions & 1 deletion src/derivatives_pricing/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
import numpy as np

from .enums import DayCountConvention, OptionType
from .exceptions import ArbitrageViolationError, ValidationError
from .exceptions import ArbitrageViolationError, ConfigurationError, ValidationError

if TYPE_CHECKING:
from .rates import DiscountCurve

__all__ = [
"log_timing",
"validate_naive_datetime",
"coerce_positive_float",
"calculate_year_fraction",
"pv_discrete_dividends",
"forward_price",
Expand All @@ -46,6 +47,67 @@ def validate_naive_datetime(
raise ValidationError(f"{field_name} must be timezone-naive datetime")


def coerce_positive_float(
value: object,
*,
name: str,
strict: bool = True,
allow_none: bool = False,
) -> float:
"""Coerce ``value`` to ``float`` and validate it is finite and positive.

Centralises the float-coercion / finite / sign-check pattern used by
dataclasses (``UnderlyingData``, ``GBMParams``, ``BarrierSpec``,
etc.) so that all fields raise a consistent ``ConfigurationError`` for
non-numeric input and ``ValidationError`` for non-finite or wrong-sign
input.

Parameters
----------
value
The user-supplied value. May be int, float, or anything ``float()``
can accept.
name
Human-readable field name used in error messages
(e.g. ``"UnderlyingData.initial_value"``).
strict
If True (default) require ``value > 0``; if False require
``value >= 0``. Use ``strict=False`` for fields where zero is a
meaningful boundary value (volatility, rebate, strike).
allow_none
If True, ``None`` returns ``None`` (caller handles default).
Otherwise raise ``ValidationError``.

Returns
-------
float
The coerced value.

Raises
------
ConfigurationError
If ``value`` cannot be coerced to ``float``.
ValidationError
If ``value`` is None (and ``allow_none`` is False), non-finite,
or violates the sign constraint.
"""
if value is None:
if allow_none:
return None # type: ignore[return-value]
raise ValidationError(f"{name} must be provided")
try:
x = float(value) # type: ignore[arg-type]
except (TypeError, ValueError) as exc:
raise ConfigurationError(f"{name} must be numeric") from exc
if not np.isfinite(x):
raise ValidationError(f"{name} must be finite")
if strict and x <= 0.0:
raise ValidationError(f"{name} must be > 0, got {x}")
if not strict and x < 0.0:
raise ValidationError(f"{name} must be >= 0, got {x}")
return x


@contextmanager
def log_timing(logger, label: str, enabled: bool) -> Iterator[None]:
"""Context manager that logs elapsed time for a code block.
Expand Down
170 changes: 126 additions & 44 deletions src/derivatives_pricing/valuation/barrier_analytical.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,32 +49,6 @@
# ── Shared helpers ──────────────────────────────────────────────────


def _is_triggered(
spot: float,
barrier: float,
direction: BarrierDirection,
) -> bool:
"""Return ``True`` if the barrier is triggered.

Parameters
----------
spot
Current spot price.
barrier
Barrier level.
direction
UP or DOWN.

Returns
-------
bool
``True`` if the barrier is triggered.
"""
if direction is BarrierDirection.UP:
return spot >= barrier
return spot <= barrier


_BG_BETA = 0.5826 # Broadie-Glasserman-Kou constant


Expand Down Expand Up @@ -415,6 +389,58 @@ def _barrier_price_no_rebate(
return B - D if H <= K else A - C


def _deterministic_limit_price(
S: float,
K: float,
H: float,
r: float,
q: float,
T: float,
df_r: float,
option_type: OptionType,
direction: BarrierDirection,
action: BarrierAction,
rebate: float,
rebate_timing: RebateTiming,
) -> float:
"""Closed-form barrier price in the sigma -> 0 (deterministic-drift) limit.

Under GBM with sigma = 0 the spot evolves monotonically from ``S`` to the
forward ``S_T = S * exp((r - q) * T)`` and continuous barrier monitoring
reduces to checking whether the linear path [S, S_T] crosses the barrier.
Caller must have already confirmed the barrier is not triggered at
inception (``_barrier_triggered_at_inception`` returned False).

Notes
-----
Discrete monitoring collapses to continuous monitoring in this limit
because the deterministic path is monotone and the Broadie-Glasserman-Kou
shift ``H * exp(beta * sigma * sqrt(dt))`` collapses to ``H``.
"""
S_T = S * np.exp((r - q) * T)

if direction is BarrierDirection.UP:
hit = S_T >= H
else:
hit = S_T <= H

if option_type is OptionType.CALL:
intrinsic_T = max(S_T - K, 0.0)
else:
intrinsic_T = max(K - S_T, 0.0)
vanilla_pv = df_r * intrinsic_T

if action is BarrierAction.OUT:
if hit:
if rebate > 0.0:
return rebate if rebate_timing is RebateTiming.AT_HIT else df_r * rebate
return 0.0
return vanilla_pv
if hit:
return vanilla_pv
return df_r * rebate if rebate > 0.0 else 0.0


# ── Engine class ──────────────────────────────────────────────────


Expand Down Expand Up @@ -450,16 +476,45 @@ def theta(self) -> float:
``V`` is the closed-form barrier price; ``Δ`` and ``Γ`` come from
central-difference bump-and-revalue around the same closed-form
evaluator (routed through :attr:`valuation_ctx` so repeated calls
hit the OV-level cache). The identity is exact in the
continuation region (triggered-at-inception cases already
short-circuit in :meth:`present_value`) and delivers better
accuracy than a naive forward-difference time bump.
hit the OV-level cache). The identity holds in the continuation
region; for inception-triggered KOs we short-circuit to a
closed-form θ because the contract is no longer PDE-governed
(it's just paid cash, or a deterministic discounted payment).
For inception-triggered KIs we delegate to the vanilla equivalent.

Returned per **calendar day**
"""
ctx = self.valuation_ctx
underlying = self.underlying

# ── Inception-triggered short-circuit ─────────────────────────
# KO triggered: the contract has collapsed to a deterministic
# cashflow and is no longer PDE-governed. The identity then gives
# the wrong answer — e.g. r·V instead of 0 for an AT_HIT rebate
# — so we return the closed-form θ directly.
# KI triggered: the contract is the underlying vanilla, which DOES
# satisfy the BS PDE; the identity would still hold here (with the
# vanilla's δ and γ that OV's NUMERICAL short-circuit already
# provides). We delegate to ``vanilla.theta()`` anyway as a
# precision upgrade — it returns the analytical θ rather than the
# identity evaluated with bumped greeks.
if ctx._barrier_triggered_at_inception():
spec = self.spec
if spec.action is BarrierAction.IN:
return float(ctx._vanilla_equivalent_valuation().theta())
# KO triggered.
if spec.rebate <= 0.0 or spec.rebate_timing is RebateTiming.AT_HIT:
# No rebate or rebate paid immediately → pv has no time
# evolution → θ = 0.
return 0.0
# AT_EXPIRY rebate: pv = R · df_r(T), so dpv/dt = +r · pv;
# per-day θ = r · pv / 365.
T = ctx._maturity_year_fraction()
df_r = float(ctx.discount_curve.df(T))
pv = float(spec.rebate) * df_r
r = -np.log(df_r) / T
return float(r * pv / 365.0)

S = float(underlying.initial_value)
sigma = float(underlying.volatility)
sigma2 = sigma**2
Expand Down Expand Up @@ -515,20 +570,47 @@ def present_value(self) -> float:
H = _broadie_glasserman_adjustment(H, sigma, T, spec.num_observations, spec.direction)

# ── No-rebate barrier value ──
value = _barrier_price_no_rebate(
S,
K,
H,
r,
q,
sigma,
T,
df_r,
df_q,
spec.option_type,
spec.direction,
spec.action,
)
# 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.
with np.errstate(over="raise", invalid="raise", divide="raise"):
try:
value = _barrier_price_no_rebate(
S,
K,
H,
r,
q,
sigma,
T,
df_r,
df_q,
spec.option_type,
spec.direction,
spec.action,
)
except (OverflowError, FloatingPointError, ZeroDivisionError):
return _deterministic_limit_price(
S,
K,
H,
r,
q,
T,
df_r,
spec.option_type,
spec.direction,
spec.action,
spec.rebate,
spec.rebate_timing,
)

# ── Add rebate leg ──
if spec.rebate > 0.0:
Expand Down
Loading
Loading