diff --git a/modules/pricer/src/main/java/com/opengamma/strata/pricer/bond/DiscountingFixedCouponBondProductPricer.java b/modules/pricer/src/main/java/com/opengamma/strata/pricer/bond/DiscountingFixedCouponBondProductPricer.java index da3c60c614..440f5887ce 100644 --- a/modules/pricer/src/main/java/com/opengamma/strata/pricer/bond/DiscountingFixedCouponBondProductPricer.java +++ b/modules/pricer/src/main/java/com/opengamma/strata/pricer/bond/DiscountingFixedCouponBondProductPricer.java @@ -12,6 +12,7 @@ import static com.opengamma.strata.product.bond.FixedCouponBondYieldConvention.US_STREET; import java.time.LocalDate; +import java.util.List; import java.util.function.Function; import com.google.common.collect.ImmutableList; @@ -624,20 +625,32 @@ private double dirtyPriceFromYieldStandard( LocalDate settlementDate, double yield) { - int nbCoupon = bond.getPeriodicPayments().size(); - double factorOnPeriod = 1 + yield / ((double) bond.getFrequency().eventsPerYear()); + List periodicPayments = bond.getPeriodicPayments(); + int eventsPerYear = bond.getFrequency().eventsPerYear(); + double factorOnPeriod = 1 + yield / ((double) eventsPerYear); double fixedRate = bond.getFixedRate(); double pvAtFirstCoupon = 0; - int pow = 0; - for (int loopcpn = 0; loopcpn < nbCoupon; loopcpn++) { - FixedCouponBondPaymentPeriod period = bond.getPeriodicPayments().get(loopcpn); + double factor = 1; + boolean first = true; + for (FixedCouponBondPaymentPeriod period : periodicPayments) { if ((period.hasExCouponPeriod() && !settlementDate.isAfter(period.getDetachmentDate())) || (!period.hasExCouponPeriod() && period.getPaymentDate().isAfter(settlementDate))) { - pvAtFirstCoupon += fixedRate * period.getYearFraction() / Math.pow(factorOnPeriod, pow); - ++pow; + double yearFraction = period.getYearFraction(); + if (first) { + first = false; + factor = 1; + } else { + if (period.isIsRegular()) { + factor *= factorOnPeriod; + } else { + factor *= Math.pow(factorOnPeriod, yearFraction * eventsPerYear); + } + } + pvAtFirstCoupon += yearFraction / factor; } } - pvAtFirstCoupon += 1d / Math.pow(factorOnPeriod, pow - 1); + pvAtFirstCoupon *= fixedRate; + pvAtFirstCoupon += 1d / factor; return pvAtFirstCoupon * Math.pow(factorOnPeriod, -factorToNextCoupon(bond, settlementDate)); } diff --git a/modules/pricer/src/test/java/com/opengamma/strata/pricer/bond/DiscountingFixedCouponBondProductPricerTest.java b/modules/pricer/src/test/java/com/opengamma/strata/pricer/bond/DiscountingFixedCouponBondProductPricerTest.java index 8d8716529c..a2ca53a3e3 100644 --- a/modules/pricer/src/test/java/com/opengamma/strata/pricer/bond/DiscountingFixedCouponBondProductPricerTest.java +++ b/modules/pricer/src/test/java/com/opengamma/strata/pricer/bond/DiscountingFixedCouponBondProductPricerTest.java @@ -20,6 +20,7 @@ import static org.assertj.core.api.Assertions.offset; import java.time.LocalDate; +import java.time.Month; import java.time.format.DateTimeFormatter; import java.util.List; @@ -115,6 +116,27 @@ public class DiscountingFixedCouponBondProductPricerTest { .yieldConvention(YIELD_CONVENTION) .build() .resolve(REF_DATA); + private static final PeriodicSchedule PERIOD_SCHEDULE_WITH_FIRST_REGULAR_START_DATE = PeriodicSchedule.builder() + .startDate(LocalDate.of(2021, Month.JUNE, 30)) + .firstRegularStartDate(LocalDate.of(2021, Month.OCTOBER, 15)) + .endDate(LocalDate.of(2026, Month.JUNE, 30)) + .frequency(Frequency.P6M) + .businessDayAdjustment(BUSINESS_ADJUST) + .stubConvention(StubConvention.SMART_FINAL) + .build(); + private static final ResolvedFixedCouponBond PRODUCT_WITH_FIRST_REGULAR_START_DATE = FixedCouponBond.builder() + .securityId(SECURITY_ID) + .dayCount(DayCounts.THIRTY_E_360_ISDA) + .fixedRate(0.085) + .legalEntityId(ISSUER_ID) + .currency(EUR) + .notional(1_000_000) + .accrualSchedule(PERIOD_SCHEDULE_WITH_FIRST_REGULAR_START_DATE) + .settlementDateOffset(DaysAdjustment.ofBusinessDays(2, EUR_CALENDAR)) + .yieldConvention(FixedCouponBondYieldConvention.DE_BONDS) + .exCouponPeriod(EX_COUPON) + .build() + .resolve(REF_DATA); // rates provider private static final CurveInterpolator INTERPOLATOR = CurveInterpolators.LINEAR; @@ -151,6 +173,63 @@ public class DiscountingFixedCouponBondProductPricerTest { private static final RatesFiniteDifferenceSensitivityCalculator FD_CAL = new RatesFiniteDifferenceSensitivityCalculator(EPS); + @Test + public void test_yield_act_act_isda() { + PeriodicSchedule period = PeriodicSchedule.builder() + .startDate(LocalDate.of(2021, Month.JUNE, 30)) + .endDate(LocalDate.of(2026, Month.JUNE, 30)) + .frequency(Frequency.P6M) + .businessDayAdjustment(BUSINESS_ADJUST) + .build(); + ResolvedFixedCouponBond bond = FixedCouponBond.builder() + .securityId(SECURITY_ID) + .dayCount(DayCounts.ACT_ACT_ISDA) + .fixedRate(0.085) + .legalEntityId(ISSUER_ID) + .currency(EUR) + .notional(100) + .accrualSchedule(period) + .settlementDateOffset(DaysAdjustment.ofBusinessDays(2, EUR_CALENDAR)) + .yieldConvention(FixedCouponBondYieldConvention.DE_BONDS) + .exCouponPeriod(EX_COUPON) + .build() + .resolve(REF_DATA); + double cleanPrice = 1.05; + LocalDate settlementDate = period.getStartDate(); + double dirtyPrice = PRICER.dirtyPriceFromCleanPrice(bond, settlementDate, cleanPrice); + assertThat(dirtyPrice).isCloseTo(cleanPrice, offset(TOL)); // 2.x. + double yield = PRICER.yieldFromDirtyPrice(bond, settlementDate, dirtyPrice); + assertThat(yield).isCloseTo(0.07286881667273096, offset(TOL)); // 2.x.œœ + } + + @Test + public void test_yield_act_act_icma() { + PeriodicSchedule period = PeriodicSchedule.builder() + .startDate(LocalDate.of(2021, Month.JUNE, 30)) + .endDate(LocalDate.of(2026, Month.JUNE, 30)) + .frequency(Frequency.P6M) + .businessDayAdjustment(BUSINESS_ADJUST) + .build(); + ResolvedFixedCouponBond bond = FixedCouponBond.builder() + .securityId(SECURITY_ID) + .dayCount(DayCounts.ACT_ACT_ICMA) + .fixedRate(0.085) + .legalEntityId(ISSUER_ID) + .currency(EUR) + .notional(100) + .accrualSchedule(period) + .settlementDateOffset(DaysAdjustment.ofBusinessDays(2, EUR_CALENDAR)) + .yieldConvention(FixedCouponBondYieldConvention.DE_BONDS) + .exCouponPeriod(EX_COUPON) + .build() + .resolve(REF_DATA); + double cleanPrice = 1.05; + LocalDate settlementDate = period.getStartDate(); + double dirtyPrice = PRICER.dirtyPriceFromCleanPrice(bond, settlementDate, cleanPrice); + assertThat(dirtyPrice).isCloseTo(cleanPrice, offset(TOL)); // 2.x. + double yield = PRICER.yieldFromDirtyPrice(bond, settlementDate, dirtyPrice); + assertThat(yield).isCloseTo(0.07288818170674201, offset(TOL)); // 2.x.œœ + } //------------------------------------------------------------------------- @Test public void test_presentValue() { @@ -330,6 +409,21 @@ public void test_dirtyPriceFromCleanPrice_ukNewIssue() { assertThat(cleanPrice).isCloseTo(dirtyPrice - accruedInterest / NOTIONAL, offset(NOTIONAL * TOL)); } + @Test + public void test_yieldWithFirstRegularStartDate() { + double cleanPrice = 1.0009; + LocalDate settlementDate = LocalDate.of(2023, Month.JUNE, 6); + double dirtyPrice = PRICER.dirtyPriceFromCleanPrice(PRODUCT_WITH_FIRST_REGULAR_START_DATE, settlementDate, cleanPrice); + assertThat(dirtyPrice).isCloseTo(1.0129416666666667, offset(TOL)); // 2.x. + double yield = PRICER.yieldFromDirtyPrice(PRODUCT_WITH_FIRST_REGULAR_START_DATE, settlementDate, dirtyPrice); + assertThat(yield).isCloseTo(0.08465593560577835, offset(TOL)); + settlementDate = LocalDate.of(2024, Month.APRIL, 26); + double dirtyPriceRbt = PRICER.dirtyPriceFromYield(PRODUCT_WITH_FIRST_REGULAR_START_DATE, settlementDate, yield); + double cleanPriceRbt = PRICER.cleanPriceFromDirtyPrice(PRODUCT_WITH_FIRST_REGULAR_START_DATE, settlementDate, dirtyPriceRbt); + double delta = (cleanPriceRbt - cleanPrice) * PRODUCT_WITH_FIRST_REGULAR_START_DATE.getNotional(); + assertThat(delta).isCloseTo(-100.27459341377387, offset(TOL)); + } + //------------------------------------------------------------------------- @Test public void test_zSpreadFromCurvesAndPV_continuous() { diff --git a/modules/product/src/main/java/com/opengamma/strata/product/bond/FixedCouponBond.java b/modules/product/src/main/java/com/opengamma/strata/product/bond/FixedCouponBond.java index cb5635c88c..9c2ab84f5e 100644 --- a/modules/product/src/main/java/com/opengamma/strata/product/bond/FixedCouponBond.java +++ b/modules/product/src/main/java/com/opengamma/strata/product/bond/FixedCouponBond.java @@ -189,6 +189,7 @@ public ResolvedFixedCouponBond resolve(ReferenceData refData) { .currency(currency) .fixedRate(fixedRate) .yearFraction(unadjustedPeriod.yearFraction(dayCount, unadjustedSchedule)) + .isRegular(period.isRegular(accrualSchedule.getFrequency(), accrualSchedule.calculatedRollConvention())) .build()); } ImmutableList periodicPayments = accrualPeriods.build(); diff --git a/modules/product/src/main/java/com/opengamma/strata/product/bond/FixedCouponBondPaymentPeriod.java b/modules/product/src/main/java/com/opengamma/strata/product/bond/FixedCouponBondPaymentPeriod.java index 611952b06a..8ed478b96c 100644 --- a/modules/product/src/main/java/com/opengamma/strata/product/bond/FixedCouponBondPaymentPeriod.java +++ b/modules/product/src/main/java/com/opengamma/strata/product/bond/FixedCouponBondPaymentPeriod.java @@ -122,6 +122,15 @@ public final class FixedCouponBondPaymentPeriod */ @PropertyDefinition(validate = "ArgChecker.notNegative") private final double yearFraction; + /** + * Indicates if the is period is regular + *

+ * The regular status of the period + *

+ * It true the a full coupon is paid. Otherwise the period is shorter/longer + */ + @PropertyDefinition + private final boolean isRegular; //------------------------------------------------------------------------- // could use @ImmutablePreBuild and @ImmutableValidate but faster inline @@ -135,7 +144,8 @@ private FixedCouponBondPaymentPeriod( LocalDate unadjustedEndDate, LocalDate detachmentDate, double fixedRate, - double yearFraction) { + double yearFraction, + boolean isRegular) { this.currency = ArgChecker.notNull(currency, "currency"); this.notional = notional; this.startDate = ArgChecker.notNull(startDate, "startDate"); @@ -145,6 +155,7 @@ private FixedCouponBondPaymentPeriod( this.detachmentDate = firstNonNull(detachmentDate, endDate); this.fixedRate = fixedRate; this.yearFraction = yearFraction; + this.isRegular = isRegular; // check for unadjusted must be after firstNonNull ArgChecker.inOrderNotEqual(startDate, endDate, "startDate", "endDate"); ArgChecker.inOrderNotEqual( @@ -339,6 +350,19 @@ public double getYearFraction() { return yearFraction; } + //----------------------------------------------------------------------- + /** + * Gets indicates if the is period is regular + *

+ * The regular status of the period + *

+ * It true the a full coupon is paid. Otherwise the period is shorter/longer + * @return the value of the property + */ + public boolean isIsRegular() { + return isRegular; + } + //----------------------------------------------------------------------- /** * Returns a builder that allows this bean to be mutated. @@ -363,7 +387,8 @@ public boolean equals(Object obj) { JodaBeanUtils.equal(unadjustedEndDate, other.unadjustedEndDate) && JodaBeanUtils.equal(detachmentDate, other.detachmentDate) && JodaBeanUtils.equal(fixedRate, other.fixedRate) && - JodaBeanUtils.equal(yearFraction, other.yearFraction); + JodaBeanUtils.equal(yearFraction, other.yearFraction) && + (isRegular == other.isRegular); } return false; } @@ -380,12 +405,13 @@ public int hashCode() { hash = hash * 31 + JodaBeanUtils.hashCode(detachmentDate); hash = hash * 31 + JodaBeanUtils.hashCode(fixedRate); hash = hash * 31 + JodaBeanUtils.hashCode(yearFraction); + hash = hash * 31 + JodaBeanUtils.hashCode(isRegular); return hash; } @Override public String toString() { - StringBuilder buf = new StringBuilder(320); + StringBuilder buf = new StringBuilder(352); buf.append("FixedCouponBondPaymentPeriod{"); buf.append("currency").append('=').append(JodaBeanUtils.toString(currency)).append(',').append(' '); buf.append("notional").append('=').append(JodaBeanUtils.toString(notional)).append(',').append(' '); @@ -395,7 +421,8 @@ public String toString() { buf.append("unadjustedEndDate").append('=').append(JodaBeanUtils.toString(unadjustedEndDate)).append(',').append(' '); buf.append("detachmentDate").append('=').append(JodaBeanUtils.toString(detachmentDate)).append(',').append(' '); buf.append("fixedRate").append('=').append(JodaBeanUtils.toString(fixedRate)).append(',').append(' '); - buf.append("yearFraction").append('=').append(JodaBeanUtils.toString(yearFraction)); + buf.append("yearFraction").append('=').append(JodaBeanUtils.toString(yearFraction)).append(',').append(' '); + buf.append("isRegular").append('=').append(JodaBeanUtils.toString(isRegular)); buf.append('}'); return buf.toString(); } @@ -455,6 +482,11 @@ public static final class Meta extends DirectMetaBean { */ private final MetaProperty yearFraction = DirectMetaProperty.ofImmutable( this, "yearFraction", FixedCouponBondPaymentPeriod.class, Double.TYPE); + /** + * The meta-property for the {@code isRegular} property. + */ + private final MetaProperty isRegular = DirectMetaProperty.ofImmutable( + this, "isRegular", FixedCouponBondPaymentPeriod.class, Boolean.TYPE); /** * The meta-properties. */ @@ -468,7 +500,8 @@ public static final class Meta extends DirectMetaBean { "unadjustedEndDate", "detachmentDate", "fixedRate", - "yearFraction"); + "yearFraction", + "isRegular"); /** * Restricted constructor. @@ -497,6 +530,8 @@ protected MetaProperty metaPropertyGet(String propertyName) { return fixedRate; case -1731780257: // yearFraction return yearFraction; + case 506685202: // isRegular + return isRegular; } return super.metaPropertyGet(propertyName); } @@ -589,6 +624,14 @@ public MetaProperty yearFraction() { return yearFraction; } + /** + * The meta-property for the {@code isRegular} property. + * @return the meta-property, not null + */ + public MetaProperty isRegular() { + return isRegular; + } + //----------------------------------------------------------------------- @Override protected Object propertyGet(Bean bean, String propertyName, boolean quiet) { @@ -611,6 +654,8 @@ protected Object propertyGet(Bean bean, String propertyName, boolean quiet) { return ((FixedCouponBondPaymentPeriod) bean).getFixedRate(); case -1731780257: // yearFraction return ((FixedCouponBondPaymentPeriod) bean).getYearFraction(); + case 506685202: // isRegular + return ((FixedCouponBondPaymentPeriod) bean).isIsRegular(); } return super.propertyGet(bean, propertyName, quiet); } @@ -641,6 +686,7 @@ public static final class Builder extends DirectFieldsBeanBuilder + * The regular status of the period + *

+ * It true the a full coupon is paid. Otherwise the period is shorter/longer + * @param isRegular the new value + * @return this, for chaining, not null + */ + public Builder isRegular(boolean isRegular) { + this.isRegular = isRegular; + return this; + } + //----------------------------------------------------------------------- @Override public String toString() { - StringBuilder buf = new StringBuilder(320); + StringBuilder buf = new StringBuilder(352); buf.append("FixedCouponBondPaymentPeriod.Builder{"); buf.append("currency").append('=').append(JodaBeanUtils.toString(currency)).append(',').append(' '); buf.append("notional").append('=').append(JodaBeanUtils.toString(notional)).append(',').append(' '); @@ -893,7 +960,8 @@ public String toString() { buf.append("unadjustedEndDate").append('=').append(JodaBeanUtils.toString(unadjustedEndDate)).append(',').append(' '); buf.append("detachmentDate").append('=').append(JodaBeanUtils.toString(detachmentDate)).append(',').append(' '); buf.append("fixedRate").append('=').append(JodaBeanUtils.toString(fixedRate)).append(',').append(' '); - buf.append("yearFraction").append('=').append(JodaBeanUtils.toString(yearFraction)); + buf.append("yearFraction").append('=').append(JodaBeanUtils.toString(yearFraction)).append(',').append(' '); + buf.append("isRegular").append('=').append(JodaBeanUtils.toString(isRegular)); buf.append('}'); return buf.toString(); }