From 8e74b3803df85cc6944a9d3fb4873d250eb06906 Mon Sep 17 00:00:00 2001 From: Michael Booth Date: Sun, 19 Apr 2026 07:37:41 +1000 Subject: [PATCH] feat(math-bridge): add experimental pure PAY wrapper support Co-Authored-By: Oz --- README.md | 10 ++++++++++ src/pdealchemy/math_bridge/parser.py | 9 ++++++++- tests/math_bridge/test_parser.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b68328f..3e20633 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,16 @@ Combined notebook (specification content followed by outputs): ```bash just notebook examples/notebooks/spec_black_scholes_with_results.py ``` +## Experimental pure PAY wrapper +PDEAlchemy now supports a side-effect-free payoff wrapper function in symbolic expressions: +```bash +PAY(max(S - K, 0)) +``` +This exploratory mode is inspired by OpenSourceRisk conventions documented at `opensourcerisk.org`. +Current scope: +- `PAY(...)` is interpreted as a pure identity wrapper around the contained payoff expression. +- This enables exploration of ORE-style payoff authoring without changing core pricing behaviour. +- `LOGPAY(...)` remains unsupported in core pricing and is intentionally treated as out of scope. ## Lint, Type Checks, and Pre-Commit Ruff and ty are configured for progressive quality enforcement: diff --git a/src/pdealchemy/math_bridge/parser.py b/src/pdealchemy/math_bridge/parser.py index 5a9ba29..b135ebd 100644 --- a/src/pdealchemy/math_bridge/parser.py +++ b/src/pdealchemy/math_bridge/parser.py @@ -10,7 +10,14 @@ from pdealchemy.exceptions import MathBridgeError + +def _pay_identity(value: object) -> object: + """Return payoff expression unchanged for side-effect-free PAY support.""" + return value + + _ALLOWED_FUNCTIONS: dict[str, object] = { + "PAY": _pay_identity, "abs": sp.Abs, "exp": sp.exp, "log": sp.log, @@ -62,7 +69,7 @@ def _validate_functions(expression: sp.Expr) -> None: raise MathBridgeError( "Expression uses unsupported function(s).", details=", ".join(unsupported), - suggestion="Use only abs, max, min, exp, log, or sqrt for now.", + suggestion="Use only PAY, abs, max, min, exp, log, or sqrt for now.", ) diff --git a/tests/math_bridge/test_parser.py b/tests/math_bridge/test_parser.py index b990309..071afe9 100644 --- a/tests/math_bridge/test_parser.py +++ b/tests/math_bridge/test_parser.py @@ -56,6 +56,19 @@ def test_parse_expression_rejects_unsupported_functions() -> None: parse_expression("sin(S)", allowed_symbols={"S"}) +def test_parse_expression_supports_pure_pay_wrapper() -> None: + parsed = parse_expression("PAY(max(S - K, 0))", allowed_symbols={"S", "K"}) + compiled = compile_expression(parsed, substitutions={"K": 100.0}) + + assert compiled.symbol_order == ("S",) + assert compiled(125.0) == pytest.approx(25.0) + + +def test_parse_expression_rejects_logpay_function() -> None: + with pytest.raises(MathBridgeError, match="unsupported function"): + parse_expression("LOGPAY(S)", allowed_symbols={"S"}) + + def test_compile_expression_with_substitutions() -> None: parsed = parse_expression("r * S + K", allowed_symbols={"r", "S", "K"}) compiled = compile_expression(parsed, substitutions={"r": 0.05, "K": 1.0}) @@ -76,6 +89,21 @@ def test_build_symbolic_problem_from_config() -> None: assert compiled_drift(100.0) == pytest.approx(5.0) +def test_build_symbolic_problem_supports_pay_wrapper() -> None: + payload = _valid_config() + instrument = payload["instrument"] + assert isinstance(instrument, dict) + instrument["payoff"] = "PAY(max(S - K, 0))" + config_data = PricingConfig.model_validate(payload) + + symbolic_problem = build_symbolic_problem(config_data) + compiled_payoff = compile_expression( + symbolic_problem.payoff, + substitutions=symbolic_problem.parameter_values, + ) + assert compiled_payoff(120.0) == pytest.approx(20.0) + + _FINITE_FLOATS = st.floats( min_value=-1_000.0, max_value=1_000.0,