From d3731441bd00eb7b38147b9b4f7da40cfd8b3a0b Mon Sep 17 00:00:00 2001 From: Yang Ye Date: Sun, 1 Nov 2026 15:35:42 +0800 Subject: [PATCH 1/2] Guard Fd2dBlackScholesVanillaEngine against a non-basket payoff Fd2dBlackScholesVanillaEngine::calculate() dynamic_pointer_casts arguments_.payoff to BasketPayoff but, unlike its sibling FdNdimBlackScholesVanillaEngine (which already QL_REQUIREs the cast to succeed), had no check at all. A failed cast silently produced a null payoff_, later dereferenced unconditionally in FdmLogBasketInnerValue::innerValue() -- undefined behavior instead of a clean error. Added a regression test that drives the engine directly through its own getArguments()/calculate() interface (the same entry point BasketOption itself uses, but without its constructor-level type safety) with a non-basket payoff. Confirmed it fails (an uncaught Boost shared_ptr null-dereference assertion, not a crash) without the fix and passes (a clean QuantLib::Error) with it. Co-worked with Claude Code --- .../basket/fd2dblackscholesvanillaengine.cpp | 1 + test-suite/basketoption.cpp | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/ql/pricingengines/basket/fd2dblackscholesvanillaengine.cpp b/ql/pricingengines/basket/fd2dblackscholesvanillaengine.cpp index e09d8239d36..007b0746a2b 100644 --- a/ql/pricingengines/basket/fd2dblackscholesvanillaengine.cpp +++ b/ql/pricingengines/basket/fd2dblackscholesvanillaengine.cpp @@ -53,6 +53,7 @@ namespace QuantLib { // 1. Payoff const ext::shared_ptr payoff = ext::dynamic_pointer_cast(arguments_.payoff); + QL_REQUIRE(payoff, "basket payoff expected"); // 2. Mesher const Time maturity = p1_->time(arguments_.exercise->lastDate()); diff --git a/test-suite/basketoption.cpp b/test-suite/basketoption.cpp index 1c71da9adf4..e8970040543 100644 --- a/test-suite/basketoption.cpp +++ b/test-suite/basketoption.cpp @@ -3033,6 +3033,74 @@ BOOST_AUTO_TEST_CASE(testGaussianCopulaSpreadEngineParityAcrossVols) { } } +BOOST_AUTO_TEST_CASE(testFd2dBlackScholesVanillaEngineRejectsNonBasketPayoff) { + BOOST_TEST_MESSAGE( + "Testing Fd2dBlackScholesVanillaEngine's guard against a " + "non-basket payoff..."); + + // Fd2dBlackScholesVanillaEngine::calculate() dynamic_pointer_casts + // arguments_.payoff to BasketPayoff but, unlike its sibling + // FdNdimBlackScholesVanillaEngine (which QL_REQUIREs the cast to + // succeed), used to have no check at all: a null cast would silently + // flow into FdmLogBasketInnerValue::innerValue()'s unconditional + // (*payoff_)(x) dereference. That is a null-pointer dereference + // (undefined behavior), not a catchable exception, so it cannot safely + // be exercised pre-fix in an automated test -- this test instead + // demonstrates the fixed engine's behavior: driving it directly through + // its own PricingEngine::getArguments()/calculate() interface (the same + // entry point BasketOption itself uses, but without BasketOption's + // constructor-level type safety) with a non-basket payoff throws a + // clean QuantLib::Error instead. + + DayCounter dc = Actual360(); + Date today = Settings::instance().evaluationDate(); + + ext::shared_ptr spot1(new SimpleQuote(100.0)); + ext::shared_ptr spot2(new SimpleQuote(100.0)); + ext::shared_ptr qTS1 = flatRate(today, 0.0, dc); + ext::shared_ptr qTS2 = flatRate(today, 0.0, dc); + ext::shared_ptr rTS = flatRate(today, 0.05, dc); + ext::shared_ptr volTS1 = flatVol(today, 0.2, dc); + ext::shared_ptr volTS2 = flatVol(today, 0.2, dc); + + ext::shared_ptr p1( + new BlackScholesMertonProcess(Handle(spot1), + Handle(qTS1), + Handle(rTS), + Handle(volTS1))); + ext::shared_ptr p2( + new BlackScholesMertonProcess(Handle(spot2), + Handle(qTS2), + Handle(rTS), + Handle(volTS2))); + + ext::shared_ptr exercise( + new EuropeanExercise(today + Period(1, Years))); + + ext::shared_ptr engine( + new Fd2dBlackScholesVanillaEngine(p1, p2, 0.0, 20, 20, 10)); + + // Sanity check: a genuine basket payoff, through the normal BasketOption + // path, still prices fine with the fix in place. + BasketOption basketOption( + ext::make_shared( + ext::make_shared(Option::Call, 100.0)), + exercise); + basketOption.setPricingEngine(engine); + BOOST_CHECK_NO_THROW(basketOption.NPV()); + + // Now drive the same engine instance directly, bypassing BasketOption's + // constructor-level BasketPayoff requirement, with a plain (non-basket) + // payoff -- exactly the misuse the sibling N-dim engine's own guard + // exists to catch. + auto* arguments = dynamic_cast(engine->getArguments()); + BOOST_REQUIRE(arguments != nullptr); + arguments->payoff = ext::make_shared(Option::Call, 100.0); + arguments->exercise = exercise; + + BOOST_CHECK_THROW(engine->calculate(), Error); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE_END() From ab3d85efc70d82c5dfb5ec412249754730934a56 Mon Sep 17 00:00:00 2001 From: Yang Ye Date: Sun, 1 Nov 2026 22:12:43 +0800 Subject: [PATCH 2/2] Use ext::make_shared in new test per review feedback The new quote/process/exercise/engine allocations wrapped raw new expressions in shared pointers instead of using ext::make_shared, per the repository's own factory convention. Switched all of them. Co-worked with Claude Code --- test-suite/basketoption.cpp | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/test-suite/basketoption.cpp b/test-suite/basketoption.cpp index e8970040543..c4f107226ae 100644 --- a/test-suite/basketoption.cpp +++ b/test-suite/basketoption.cpp @@ -3055,30 +3055,25 @@ BOOST_AUTO_TEST_CASE(testFd2dBlackScholesVanillaEngineRejectsNonBasketPayoff) { DayCounter dc = Actual360(); Date today = Settings::instance().evaluationDate(); - ext::shared_ptr spot1(new SimpleQuote(100.0)); - ext::shared_ptr spot2(new SimpleQuote(100.0)); + auto spot1 = ext::make_shared(100.0); + auto spot2 = ext::make_shared(100.0); ext::shared_ptr qTS1 = flatRate(today, 0.0, dc); ext::shared_ptr qTS2 = flatRate(today, 0.0, dc); ext::shared_ptr rTS = flatRate(today, 0.05, dc); ext::shared_ptr volTS1 = flatVol(today, 0.2, dc); ext::shared_ptr volTS2 = flatVol(today, 0.2, dc); - ext::shared_ptr p1( - new BlackScholesMertonProcess(Handle(spot1), - Handle(qTS1), - Handle(rTS), - Handle(volTS1))); - ext::shared_ptr p2( - new BlackScholesMertonProcess(Handle(spot2), - Handle(qTS2), - Handle(rTS), - Handle(volTS2))); - - ext::shared_ptr exercise( - new EuropeanExercise(today + Period(1, Years))); - - ext::shared_ptr engine( - new Fd2dBlackScholesVanillaEngine(p1, p2, 0.0, 20, 20, 10)); + auto p1 = ext::make_shared( + Handle(spot1), Handle(qTS1), + Handle(rTS), Handle(volTS1)); + auto p2 = ext::make_shared( + Handle(spot2), Handle(qTS2), + Handle(rTS), Handle(volTS2)); + + auto exercise = ext::make_shared(today + Period(1, Years)); + + ext::shared_ptr engine = + ext::make_shared(p1, p2, 0.0, 20, 20, 10); // Sanity check: a genuine basket payoff, through the normal BasketOption // path, still prices fine with the fix in place.