Yield calculation issues in ex dividend period?

For UK Govt Fixed Coupon Bonds, there seems to be some issues around the yield and accrued interest calculations
when the settlement date is in the ex dividend period.
I have used historic prices and yield data from the UK Debt Management Office
https://www.dmo.gov.uk/data/gilt-market/historical-prices-and-yields/
to write some tests to validate these calculations (test code at the end of this post).
At the above URL we can specify a start and end date and get a excel file with the ISIN, cleanPrice, intAcc, dirtyPrice and Yield To Maturity for each business day in the range.

  1. The accruedInterest() method returns incorrect values of the first ex dividend day.
    DiscountingFixedCouponBondProductPricer#accruedYearFraction() has a check
    if (settlementDate.isAfter(period.getDetachmentDate())) {
    Looks like this should be more like
    if (!settlementDate.isBefore(period.getDetachmentDate())) {
    This fixes the accrued interest and the dirty price calculations.

  2. The yieldFromDirtyPrice() returns incorrect values. There seems to be 2 parts to this.
    a) On the first ex-dividend period after the bond is issued, we get incorrect yield for all the days
    in the ex dividend period, so the 6 business days.
    b) On subsequent ex-dividend periods, we get errors on some days. This seems to be caused by the following
    code in dirtyPriceFromYieldStandard()
    if ((period.hasExCouponPeriod() && !settlementDate.isAfter(period.getDetachmentDate())) ||
    Looks like this should be something like
    if ((period.hasExCouponPeriod() && settlementDate.isBefore(period.getDetachmentDate())) ||

For the errors in the first ex dividend period after issue, I am not sure where the probnlem could be.
I suspect something around the following this code in dirtyPriceFromYieldStandard()
int pow = 0;
for (int loopcpn = 0; loopcpn < nbCoupon; loopcpn++) {
FixedCouponBondPaymentPeriod period = bond.getPeriodicPayments().get(loopcpn);
if ((period.hasExCouponPeriod() && settlementDate.isBefore(period.getDetachmentDate())) ||
(!period.hasExCouponPeriod() && period.getPaymentDate().isAfter(settlementDate))) {
pvAtFirstCoupon += fixedRate * period.getYearFraction() / Math.pow(factorOnPeriod, pow);
++pow;
}
}

Since we have the negative interest during the ex-coupon period, and this error seems to be on the
first ex-dividend period, maybe its something to do with the period.yearFraction() not being 1/2.
Maybe some we need to subtract some amount for this, like
int pow = 0;
for (int loopcpn = 0; loopcpn < nbCoupon; loopcpn++) {
FixedCouponBondPaymentPeriod period = bond.getPeriodicPayments().get(loopcpn);
if ((period.hasExCouponPeriod() && settlementDate.isBefore(period.getDetachmentDate())) ||
(!period.hasExCouponPeriod() && period.getPaymentDate().isAfter(settlementDate))) {
pvAtFirstCoupon += fixedRate * period.getYearFraction() / Math.pow(factorOnPeriod, pow);
++pow;
}
if (loopcpn == 0 && period.hasExCouponPeriod() && !settlementDate.isBefore(period.getDetachmentDate()) && settlementDate.isBefore(period.getEndDate())) {
// subtract an amount here ?
}

}

Test code: We use 2 isins
For GB00BZB26Y51 we test in the first ex-dividend period after issue and
For GB00B582JV65 we are testing at a much later ex dividend period.

package com.opengamma.strata.pricer.bond;

import com.opengamma.strata.basics.ReferenceData;
import com.opengamma.strata.basics.currency.Currency;
import com.opengamma.strata.basics.date.*;
import com.opengamma.strata.basics.schedule.Frequency;
import com.opengamma.strata.basics.schedule.PeriodicSchedule;
import com.opengamma.strata.basics.schedule.StubConvention;
import com.opengamma.strata.product.LegalEntityId;
import com.opengamma.strata.product.SecurityId;
import com.opengamma.strata.product.bond.FixedCouponBond;
import com.opengamma.strata.product.bond.FixedCouponBondYieldConvention;
import com.opengamma.strata.product.bond.ResolvedFixedCouponBond;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Collection;
import java.util.stream.Collectors;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;

@RunWith(Parameterized.class)
public class TestUKTExDividendYieldCalculation {

    private ReferenceData  refData = ReferenceData.standard();

    private static LocalDate localDate(String dateString) {
            return LocalDate.parse(dateString, DateTimeFormatter.ofPattern("dd-MMM-yyyy"));
    }

    private static LocalDate nextWeekday(final LocalDate day) {
        LocalDate nextDay = day.plusDays(1);
        while (nextDay.getDayOfWeek() == DayOfWeek.SATURDAY || nextDay.getDayOfWeek() == DayOfWeek.SUNDAY) {
            nextDay = nextDay.plusDays(1);
        }
        return nextDay;
    }

    @Parameterized.Parameters(name = "{index}: {0} {1} - {4}")
    public static Collection data() {
         return Arrays.asList(new Object[][] {
                // ISIN , Ticker, Issue Date, Matirity date, Settlement Date, Coupon Rate, Clean Price, Interest Accumulated, Dirty Price, Yield
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("20-Feb-2017"), 1.750000, 97.440000, 0.502762, 97.942762, 1.901137   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("21-Feb-2017"), 1.750000, 97.120000, 0.507597, 97.627597, 1.920368   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("22-Feb-2017"), 1.750000, 97.700000, 0.512431, 98.212431, 1.885611   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("23-Feb-2017"), 1.750000, 98.450000, 0.517265, 98.967265, 1.841002   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("24-Feb-2017"), 1.750000, 99.640000, -0.038674, 99.601326, 1.771010   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("27-Feb-2017"), 1.750000, 99.640000, -0.033840, 99.606160, 1.771011   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("28-Feb-2017"), 1.750000, 100.040000, -0.029006, 100.010994, 1.747679   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("01-Mar-2017"), 1.750000, 99.300000, -0.024171, 99.275829, 1.790932   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("02-Mar-2017"), 1.750000, 98.970000, -0.019337, 98.950663, 1.810345   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("03-Mar-2017"), 1.750000, 99.080000, -0.004834, 99.075166, 1.803881   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("06-Mar-2017"), 1.750000, 98.780000, 0.000000, 98.780000, 1.821581   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("07-Mar-2017"), 1.750000, 99.270000, 0.004755, 99.274755, 1.792714   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("08-Mar-2017"), 1.750000, 98.600000, 0.009511, 98.609511, 1.832244   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("09-Mar-2017"), 1.750000, 98.820000, 0.014266, 98.834266, 1.819238   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("10-Mar-2017"), 1.750000, 98.300000, 0.028533, 98.328533, 1.850081   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("13-Mar-2017"), 1.750000, 98.070000, 0.033288, 98.103288, 1.863785   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("14-Mar-2017"), 1.750000, 98.260000, 0.038043, 98.298043, 1.852480   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("15-Mar-2017"), 1.750000, 98.550000, 0.042799, 98.592799, 1.835263   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("16-Mar-2017"), 1.750000, 98.400000, 0.047554, 98.447554, 1.844175   },
                 { "GB00BZB26Y51", "UKT 1.75 07-Sep-2037", localDate("09-Nov-2016"), localDate("07-Sep-2037"), localDate("17-Mar-2017"), 1.750000, 98.590000, 0.061821, 98.651821, 1.832923   },

                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("20-Feb-2017"), 3.750000, 112.310000, 1.729972, 114.039972, 0.253445   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("21-Feb-2017"), 3.750000, 112.230000, 1.740331, 113.970331, 0.272157   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("22-Feb-2017"), 3.750000, 112.360000, 1.750691, 114.110691, 0.235081   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("23-Feb-2017"), 3.750000, 112.420000, 1.761050, 114.181050, 0.216584   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("24-Feb-2017"), 3.750000, 112.510000, -0.082873, 112.427127, 0.184931   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("27-Feb-2017"), 3.750000, 112.540000, -0.072514, 112.467486, 0.174343   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("28-Feb-2017"), 3.750000, 112.550000, -0.062155, 112.487845, 0.169066   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("01-Mar-2017"), 3.750000, 112.430000, -0.051796, 112.378204, 0.198437   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("02-Mar-2017"), 3.750000, 112.410000, -0.041436, 112.368564, 0.201176   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("03-Mar-2017"), 3.750000, 112.470000, -0.010359, 112.459641, 0.177309   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("06-Mar-2017"), 3.750000, 112.430000, 0.000000, 112.430000, 0.185391   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("07-Mar-2017"), 3.750000, 112.490000, 0.010190, 112.500190, 0.166743   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("08-Mar-2017"), 3.750000, 112.420000, 0.020380, 112.440380, 0.182911   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("09-Mar-2017"), 3.750000, 112.410000, 0.030571, 112.440571, 0.183010   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("10-Mar-2017"), 3.750000, 112.350000, 0.061141, 112.411141, 0.191379   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("13-Mar-2017"), 3.750000, 112.360000, 0.071332, 112.431332, 0.186100   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("14-Mar-2017"), 3.750000, 112.430000, 0.081522, 112.511522, 0.164652   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("15-Mar-2017"), 3.750000, 112.520000, 0.091712, 112.611712, 0.137803   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("16-Mar-2017"), 3.750000, 112.400000, 0.101902, 112.501902, 0.167518   },
                 { "GB00B582JV65", "UKT 3.75 07-Sep-2020", localDate("10-Jun-2010"), localDate("07-Sep-2020"), localDate("17-Mar-2017"), 3.750000, 112.380000, 0.132473, 112.512473, 0.165075   }
         }).stream().map(row -> {
             Object[] newRow = row;
             newRow[4] = nextWeekday((LocalDate)row[4]);
             return newRow;
         }).collect(Collectors.toList());
    }

    private String isin;
    private String ticker;
    private LocalDate issueDate;
    private LocalDate maturityDate;
    private LocalDate settlementDate;
    private double couponRate;
    private double cleanPrice;
    private double expectedIntAcc;
    private double expectedDirtyPrice;
    private double expectedYield;

    public TestUKTExDividendYieldCalculation(String isin, String ticker, LocalDate issueDate,
                                             LocalDate maturityDate, LocalDate settlementDate,
                                             double couponRate, double cleanPrice, double expectedIntAcc, double expectedDirtyPrice, double expectedYield) {
        this.isin = isin;
        this.ticker = ticker;
        this.issueDate = issueDate;
        this.maturityDate = maturityDate;
        this.settlementDate = settlementDate;
        this.couponRate = couponRate;
        this.cleanPrice = cleanPrice;
        this.expectedIntAcc = expectedIntAcc;
        this.expectedDirtyPrice = expectedDirtyPrice;
        this.expectedYield = expectedYield;
    }

    @Test
    public void testInterestAndYield() {
        ResolvedFixedCouponBond bond = FixedCouponBond.builder()
                .securityId(SecurityId.of("Bloomberg-Ticker", ticker))
                .dayCount(DayCounts.ACT_ACT_ICMA)
                .fixedRate(couponRate / 100d)
                .legalEntityId(LegalEntityId.of("CMA-Ticker", "UK"))
                .currency(Currency.of("GBP"))
                .notional(1_000_000)
                .accrualSchedule(PeriodicSchedule.of(issueDate, maturityDate, 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.ofBusinessDays(-6, HolidayCalendarIds.GBLO))
                .build()
                .resolve(refData);

        final DiscountingFixedCouponBondProductPricer bondPricer = DiscountingFixedCouponBondProductPricer.DEFAULT;
        final double accruedInterest = bondPricer.accruedInterest(bond, settlementDate);
        final double dirtyPrice = bondPricer.dirtyPriceFromCleanPrice(bond, settlementDate, cleanPrice/100d);
        final double ytm = bondPricer.yieldFromDirtyPrice(bond, settlementDate, dirtyPrice);

        assertThat(accruedInterest).describedAs("accrued interest").isCloseTo(expectedIntAcc * 10000, within(1e-2));
        assertThat(dirtyPrice).describedAs("dirty price").isCloseTo(expectedDirtyPrice / 100d, within(1e-8));
        assertThat(ytm).describedAs("YTM").isCloseTo(expectedYield / 100d, within(1e-8));
    }
}

Here’s a slightly simplified test, without the first two changes proposed by Kannan this fails on 2019-10-14 dirty price, once those two changes are applied it fails on 2019-10-14 yield.

    @Test
    public void uktExDiv() {
        ResolvedFixedCouponBond bond = FixedCouponBond.builder()
                .securityId(SECURITY_ID)
                .dayCount(DayCounts.ACT_ACT_ICMA)
                .fixedRate(0.00875)
                .legalEntityId(ISSUER_ID)
                .currency(GBP)
                .notional(NOTIONAL)
                .accrualSchedule(PeriodicSchedule.of(LocalDate.of(2019, 6, 19), LocalDate.of(2029, 10, 22), 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);
        Map<LocalDate, PriceToYieldValues> inputs = ImmutableMap.<LocalDate, PriceToYieldValues>builder()
                .put(LocalDate.of(2019, 10, 11), new PriceToYieldValues(1.03210, 1.03482541, 0.00545686))
                .put(LocalDate.of(2019, 10, 14), new PriceToYieldValues(1.01620, 1.01600874, 0.00707275))
                .put(LocalDate.of(2019, 10, 15), new PriceToYieldValues(1.02290, 1.02273265, 0.00638684))
                .put(LocalDate.of(2019, 10, 16), new PriceToYieldValues(1.01650, 1.01635656, 0.00704106))
                .put(LocalDate.of(2019, 10, 17), new PriceToYieldValues(1.01800, 1.01788046, 0.00688669))
                .put(LocalDate.of(2019, 10, 18), new PriceToYieldValues(1.01800, 1.01790437, 0.00688620))
                .build();
        for (Map.Entry<LocalDate, PriceToYieldValues> input : inputs.entrySet()) {
            LocalDate settlement = input.getKey();
            double dirtyPrice = PRICER.dirtyPriceFromCleanPrice(bond, settlement, input.getValue().cleanPrice);
            assertThat(dirtyPrice).describedAs("%tF dirty price", settlement).isCloseTo(input.getValue().dirtyPrice, offset(5e-8));
            double yield = PRICER.yieldFromDirtyPrice(bond, settlement, dirtyPrice);
            assertThat(yield).describedAs("%tF yield", settlement).isCloseTo(input.getValue().yield, offset(5e-8));
        }
    }

    private static final class PriceToYieldValues {
        private final double cleanPrice;
        private final double dirtyPrice;
        private final double yield;

        private PriceToYieldValues(final double cleanPrice, final double dirtyPrice, final double yield) {
            this.cleanPrice = cleanPrice;
            this.dirtyPrice = dirtyPrice;
            this.yield = yield;
        }
    }

I believe the DiscountingFixedCouponBondProductPricer#factorToNextCoupon needs to be modified to deal with the situation where a bond goes ex-div. Something like this:

  private double factorToNextCoupon(ResolvedFixedCouponBond bond, LocalDate settlementDate) {
    if (bond.getPeriodicPayments().get(0).getStartDate().isAfter(settlementDate)) {
      return 0d;
    }
    int couponIndex = couponIndex(bond.getPeriodicPayments(), settlementDate);
    FixedCouponBondPaymentPeriod period = bond.getPeriodicPayments().get(couponIndex);
    double factorSpot = accruedYearFraction(bond, settlementDate);
    if (bond.hasExCouponPeriod() && !settlementDate.isBefore(period.getDetachmentDate())) {
      return 1 - (factorSpot * (double) bond.getFrequency().eventsPerYear());
    }
    double factorPeriod = period.getYearFraction();
    return (factorPeriod - factorSpot) * ((double) bond.getFrequency().eventsPerYear());
  }

I also think you need to review every reference to Period#getDetachmentDate and consider introducing a util method similar to QuantLib’s CashFlow::tradingExCoupon which should make those lines of code easier to reason about.

DiscountingFixedCouponBondProductPricer#modifiedDurationFromYieldStandard needs to be changed also

            if ((period.hasExCouponPeriod() && settlementDate.isBefore(period.getDetachmentDate())) ||

to

            if ((period.hasExCouponPeriod() && settlementDate.isBefore(period.getDetachmentDate())) ||

Thank you for pointing out the issue. The yield-to-maturity calculation will be fixed by