Yield / Duration diff on new issue gilts vs Bloomberg

If I look at the new UKT 0.875 2029 bond on Bloomberg and price it at par for settlement 2019-06-20 I am shown a yield of 0.875020 and a modified duration of 9.864 with 1 day accrued of 23.91 per 1m face.

When I perform the same calculation in Strata I see:

  • accrued = 23.907103825136616 (which gives dirty price 1.000023907103825)
  • yield = 0.008613234902641289 (via yieldFromDirtyPrice)
  • mod duration = 10.021424652709157 (via modifiedDurationFromYield)

I wanted to check whether anything like this had been raised before before I dig into this further.

Nothing like this has come up before I’m afraid. Not sure why a new issue would be any different…

It’s almost certainly the first short stub - we’ll come back when we’ve got an explanation of the diff, thx

So based on the official DMO closing data from Tradeweb (see https://www.dmo.gov.uk/data/treasury-bills/prices-and-yields/) UKT 0.875 2029 closed at 99.3 on the 19th with ytm = 0.946254 & mod dur = 9.858415

I believe the following test added to DiscountingFixedCouponBondProductPricerTest should pass but the yield is calculated as 0.931437

  public void uktNewIssue() {
    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);
    LocalDate settlement = LocalDate.of(2019, 6, 20);
    double dirtyPrice = PRICER.dirtyPriceFromCleanPrice(bond, settlement, 0.993);
    assertEquals(dirtyPrice, 0.99302391, 1e-8);
    double yield = PRICER.yieldFromDirtyPrice(bond, settlement, dirtyPrice);
    assertEquals(yield, 0.00946254, 1e-8);
    double modDur = PRICER.modifiedDurationFromYield(bond, settlement, yield);
    assertEquals(modDur, 9.858415, EPS);
  }

Thanks for the test. We’re snowed under with other work needing quant time at the moment, so I don’t expect we’ll be able to resolve this quickly. If you end up finding the problem, I’ll happily review a PR!

I’ll let you know if we do manage to pinpoint what code needs to change - I doubt I will be able to submit a PR though.

FYI there seems to be the same issue with the UKT 0.625 2025: Tradeweb closing price of 100.166 for settlement 2019-07-08 has a dirty price of 100.174538 and a yield of 0.596405 where Strata calculates 0.589425

I’ve spent some time looking at this, I believe your implementation approach is to calculate the PV using discount factors to the next coupon date (pvAfFirstCoupon) and then discount that by an amount which varies based on accrued interest to get the dirty price. I think the issue is then that when calculating factorToNextCoupon you’re assuming that 100% of the coupon period remains, but for a short stub you’re already part way through.

Considering the simplified case of UKT 0.652 2025 for settlement 2019-07-03 if you look at page 5 of https://www.dmo.gov.uk/media/1955/yldeqns.pdf I think this is comparable to the first v^(r/s) term where you have r == s but you should have r = 157 and s = 183. I’ll admit this is not exactly clear e.g. “Number of calendar days in the full quasi-coupon period in which the settlement date occurs (i.e. between the prior quasi-coupon date and the following quasi-coupon date).” sounds like you should use s = 157 but then the formula gives the wrong answer while you get the right answer using 183.

I think if you change DiscountingFixedCouponBondProductPricer line 806 as follows you’ll have the issue fixed

return (factorPeriod - factorSpot) * ((double) bond.getFrequency().eventsPerYear());

All the com.opengamma.strata.pricer tests pass including my new issue test above with this change.

Fixed by https://github.com/OpenGamma/Strata/pull/2096

Thanks for this fix, although I’m not sure that it only applies to GBP bonds, I think it will apply to all issuers - will confirm.

It looks like this is an issue across all bonds, e.g. DE0001102440 has a closing price of 103.782 as of 2019-01-29 and a yield of 0.0800048 according to Bloomberg - I’m not sure of an official source of these numbers though.

Failing test

    @Test
    public void dbrNewIssue() {
        ResolvedFixedCouponBond bond = FixedCouponBond.builder()
                .securityId(SECURITY_ID)
                .dayCount(DayCounts.ACT_ACT_ICMA)
                .fixedRate(0.005)
                .legalEntityId(ISSUER_ID)
                .currency(EUR)
                .notional(NOTIONAL)
                .accrualSchedule(PeriodicSchedule.builder()
                        .startDate(LocalDate.of(2018, 1, 12))
                        .endDate(LocalDate.of(2028, 2, 15))
                        .frequency(Frequency.P12M)
                        .businessDayAdjustment(BusinessDayAdjustment.of(BusinessDayConventions.MODIFIED_FOLLOWING, HolidayCalendarIds.EUTA))
                        .stubConvention(StubConvention.SMART_INITIAL)
                        .rollConvention(RollConventions.EOM)
                        .firstRegularStartDate(LocalDate.of(2019, 2, 15))
                        .build()
                )
                .settlementDateOffset(DaysAdjustment.ofBusinessDays(2, HolidayCalendarIds.EUTA))
                .yieldConvention(FixedCouponBondYieldConvention.DE_BONDS)
                .exCouponPeriod(DaysAdjustment.NONE)
                .build()
                .resolve(REF_DATA);
        LocalDate settlement = LocalDate.of(2019, 1, 31);
        double dirtyPrice = PRICER.dirtyPriceFromCleanPrice(bond, settlement, 1.03782);
        assertThat(dirtyPrice).isCloseTo(1.04308027, offset(1e-8));
        double yield = PRICER.yieldFromDirtyPrice(bond, settlement, dirtyPrice);
        assertThat(yield).isCloseTo(0.000800048, offset(1e-9));
    }

Fix is to remove the bond.getYieldConvention().equals(GB_BUMP_DMO) check on line 807 of DiscountingFixedCouponBondProductPricer