@@ -950,6 +950,101 @@ BOOST_AUTO_TEST_CASE(testCallableBondOasWithDifferentNotinals) {
950950 << " clean price with notional 25.0: " << cleanPrice25 << " \n " );
951951}
952952
953+ BOOST_AUTO_TEST_CASE (testOasContinuityThroughExCouponWindow) {
954+
955+ BOOST_TEST_MESSAGE (" Testing OAS continuity when call date crosses ex-coupon period..." );
956+
957+ /* This is a test case inspired by
958+ * https://github.com/lballabio/QuantLib/issues/2236
959+ *
960+ * When a call date falls between the ex-coupon date and the payment date,
961+ * OAS should vary smoothly (within tree discretization noise). Before the
962+ * fix, OAS jumped from ~+154 bps to ~-513 bps at the ex-coupon boundary
963+ * due to an inconsistency between how the tree continuation value includes
964+ * coupons and how the call price uses negative accrued during ex-coupon. */
965+
966+ auto today = Date (31 , January, 2024 );
967+ Settings::instance ().evaluationDate () = today;
968+
969+ Natural settlementDays = 0 ;
970+ auto calendar = UnitedStates (UnitedStates::NYSE);
971+ auto dc = Thirty360 (Thirty360::BondBasis);
972+ auto bdc = Unadjusted;
973+ auto frequency = Quarterly;
974+ Period exCouponPeriod (14 , Days);
975+
976+ auto issueDate = today;
977+ auto maturityDate = Date (31 , January, 2029 );
978+ Real faceAmount = 100.0 ;
979+ std::vector<Rate> coupons = {0.06 };
980+ Real redemption = 100.0 ;
981+
982+ Handle<YieldTermStructure> termStructure (
983+ ext::make_shared<FlatForward>(today, 0.04 , dc));
984+ auto model = ext::make_shared<HullWhite>(termStructure);
985+
986+ Schedule schedule = MakeSchedule ()
987+ .from (issueDate)
988+ .to (maturityDate)
989+ .withFrequency (frequency)
990+ .withCalendar (calendar)
991+ .withConvention (bdc)
992+ .withTerminationDateConvention (bdc)
993+ .backwards ()
994+ .endOfMonth (true );
995+
996+ // First coupon payment date after today
997+ Date firstPaymentDate = schedule[1 ];
998+ Date exCouponDate = firstPaymentDate - exCouponPeriod;
999+
1000+ // Sweep call date from 1 week before ex-coupon to 1 week after payment
1001+ Date sweepStart = exCouponDate - 7 * Days;
1002+ Date sweepEnd = firstPaymentDate + 7 * Days;
1003+
1004+ Real cleanPrice = 100.0 ;
1005+ Compounding compounding = Compounded;
1006+
1007+ Real maxOas = -QL_MAX_REAL;
1008+ Real minOas = QL_MAX_REAL;
1009+
1010+ for (Date callDate = sweepStart; callDate <= sweepEnd; callDate++) {
1011+ CallabilitySchedule callSchedule;
1012+ callSchedule.push_back (ext::make_shared<Callability>(
1013+ Bond::Price (redemption, Bond::Price::Clean),
1014+ Callability::Call,
1015+ callDate));
1016+
1017+ CallableFixedRateBond bond (
1018+ settlementDays, faceAmount, schedule, coupons, dc,
1019+ bdc, redemption, issueDate, callSchedule,
1020+ exCouponPeriod, NullCalendar ());
1021+ bond.setPricingEngine (
1022+ ext::make_shared<TreeCallableFixedRateBondEngine>(
1023+ model, 100 , termStructure));
1024+
1025+ Real oas = bond.OAS (cleanPrice, termStructure, dc,
1026+ compounding, frequency) * 10000.0 ;
1027+ maxOas = std::max (maxOas, oas);
1028+ minOas = std::min (minOas, oas);
1029+ }
1030+
1031+ Real oasRange = maxOas - minOas;
1032+
1033+ // OAS should be reasonably continuous through the ex-coupon window.
1034+ // A range of 50 bps allows for tree discretization noise; the bug
1035+ // produced a range of ~667 bps.
1036+ Real tolerance = 50.0 ;
1037+ if (oasRange > tolerance)
1038+ BOOST_ERROR (" OAS discontinuity across ex-coupon window:\n "
1039+ << std::setprecision (2 ) << std::fixed
1040+ << " min OAS: " << minOas << " bps\n "
1041+ << " max OAS: " << maxOas << " bps\n "
1042+ << " range: " << oasRange << " bps\n "
1043+ << " tolerance: " << tolerance << " bps\n "
1044+ << " (sweep from " << io::iso_date (sweepStart)
1045+ << " to " << io::iso_date (sweepEnd) << " )" );
1046+ }
1047+
9531048BOOST_AUTO_TEST_SUITE_END ()
9541049
9551050BOOST_AUTO_TEST_SUITE_END()
0 commit comments