Issue with bond pricing?

The DiscountingFixedCouponBondProductPricer#presentValueCouponFromZSpread method is called from #zSpreadFromCurvesAndDirtyPrice and calculates the sum of cashflows where the ex-div date is after the settlement date however this causes a large spike in z-spread when a bond is settling on the ex-div date.
I wanted to check if line 846 should be changed from period.getDetachmentDate().isAfter(referenceDate) to !period.getDetachmentDate().isBefore(referenceDate)?
As an example GB00B582JV65 goes ex-div today (2019-02-27) and when I price it on YAS in Bloomberg for trading 25th/26th/27th at the same price the Z-Spread is as expected pretty close for each of the three days, however when I replicate this in my unit tests it blows out for the 26th.
I ran the com.opengamma.strata.pricer unit tests with this change and they all pass…

Thanks for the question. We’ve taken a look, but we are generally reluctant to go too far down the route of trying to replicate Bloomberg. Strata tries to implement what is correct rather than what Bloomberg says. Our view is that the coupon is excluded if settlement date is on the ex-div date whatever trade date we choose. so our logic is correct.

In this case, I note that many methods in the class use similar logic, comparing the detachment date using isAfter(). It seems unlikely that changing one case would be wise without considering all the other cases.

You are right that there should be tests for this case. Currently the tests for this are for presentValue(), not presentValueWithZSpread()

I’ll put a test case together next week - do you have a preferred short cut to getting a LegalEntityDiscountingProvider for use in a test?

Just extending DiscountingFixedCouponBondProductPricerTest should be a good place to start.

So this is a basic illustration of the problem, when I run an equivalent to this using real curve data the difference is significantly larger:

  public void test_zSpreadFromCurvesAndPV_acrossExDivDate() {
    LocalDate twoDayBeforeExDiv = LocalDate.of(2019, 2, 25);
    LocalDate oneDayBeforeExDiv = LocalDate.of(2019, 2, 26);
    LocalDate exDiv = LocalDate.of(2019, 2, 27);
    ResolvedFixedCouponBond bond = FixedCouponBond.builder()
            .securityId(SECURITY_ID)
            .dayCount(DayCounts.ACT_ACT_ICMA)
            .fixedRate(0.0375)
            .legalEntityId(ISSUER_ID)
            .currency(GBP)
            .notional(NOTIONAL)
            .accrualSchedule(PeriodicSchedule.of(LocalDate.of(2010, 9, 7), LocalDate.of(2020, 9, 7), Frequency.P6M, BusinessDayAdjustment.of(BusinessDayConventions.MODIFIED_FOLLOWING, HolidayCalendarIds.GBLO), StubConvention.SMART_INITIAL, false))
            .settlementDateOffset(DaysAdjustment.ofBusinessDays(1, HolidayCalendarIds.GBLO))
            .yieldConvention(FixedCouponBondYieldConvention.GB_BUMP_DMO)
            .exCouponPeriod(DaysAdjustment.ofCalendarDays(-8, BusinessDayAdjustment.of(BusinessDayConventions.MODIFIED_FOLLOWING, HolidayCalendarIds.GBLO)))
            .build()
            .resolve(REF_DATA);

      final List<LocalDate> dates = ImmutableList.of(twoDayBeforeExDiv, oneDayBeforeExDiv, exDiv);
      final List<Double> zSpreads = dates.stream().map(d -> {
      final LegalEntityDiscountingProvider dateProvider = ImmutableLegalEntityDiscountingProvider.builder()
              .issuerCurves(ImmutableMap.of(Pair.of(GROUP_ISSUER, GBP), ZeroRateDiscountFactors.of(GBP, d, CURVE_ISSUER)))
              .issuerCurveGroups(ImmutableMap.of(ISSUER_ID, GROUP_ISSUER))
              .repoCurves(ImmutableMap.of(Pair.of(GROUP_REPO, GBP), ZeroRateDiscountFactors.of(GBP, d, CURVE_REPO)))
              .repoCurveSecurityGroups(ImmutableMap.of(SECURITY_ID, GROUP_REPO))
              .valuationDate(d)
              .build();
      LocalDate settlement = DaysAdjustment.ofBusinessDays(1, HolidayCalendarIds.GBLO).adjust(d, REF_DATA);
      double dirtyPrice = PRICER.dirtyPriceFromCleanPrice(bond, settlement, 1.04494d);
      double zSpread = PRICER.zSpreadFromCurvesAndDirtyPrice(bond, dateProvider, REF_DATA, dirtyPrice, PERIODIC, 2);
      assertEquals(PRICER.dirtyPriceFromCurvesWithZSpread(bond, dateProvider, REF_DATA, zSpread, PERIODIC, 2), dirtyPrice, TOL);
      return zSpread;
    }).collect(Collectors.toList());
    for (int i = 0; i < zSpreads.size(); ++i) {
      assertEquals(zSpreads.get(i), -.025, 5e-3, dates.get(i).format(DateTimeFormatter.ISO_DATE));
    }
  }

Did you get a chance to look at this test case? Are you saying that you believe this Z-Spread jump is correct?

Thanks for the test case. This is our proposed fix:

Thank you, that’s great, I will pick it up in the next release!

I did some testing using 2.3.0 - it seems that the change you made has caused the YTM value to blow out, e.g. add the following to the test case after verifying z-spread

List<Double> ytms = dates.stream().map(d -> {
  LocalDate settlement = DaysAdjustment.ofBusinessDays(1, HolidayCalendarIds.GBLO).adjust(d, REF_DATA);
  double dirtyPrice = PRICER.dirtyPriceFromCleanPrice(bond, settlement, 1.04494d);
  return PRICER.yieldFromDirtyPrice(bond, settlement, dirtyPrice);
}).collect(Collectors.toList());

for (int i = 0; i < ytms.size(); ++i) {
  assertEquals(ytms.get(i), .007, 1e-3, dates.get(i).format(DateTimeFormatter.ISO_DATE));
}

It looks like reverting your change and applying mine to line 846 allows both the z-spread and yield to maturity to remain stable.

v2.31 and now v2.3.2 released as proposed

That is great, thank you Stephen - my own tests now pass too