From 62e7dc583ec6240beb3547d794cfc2e0a0d049ac Mon Sep 17 00:00:00 2001 From: Richard Green Date: Mon, 9 May 2016 10:45:55 +0100 Subject: [PATCH] Expanded the verify() function for the IRS Contract --- contracts/src/main/kotlin/contracts/IRS.kt | 330 ++++++++++----- .../src/main/kotlin/contracts/IRSUtils.kt | 20 +- scripts/example-irs-trade.json | 8 +- scripts/example.rates.txt | 3 +- .../kotlin/core/node/subsystems/Services.kt | 2 + .../protocols/UpdateBusinessDayProtocol.kt | 9 +- .../kotlin/protocols/TwoPartyDealProtocol.kt | 5 +- src/test/kotlin/contracts/IRSTests.kt | 390 +++++++++++++++++- src/test/kotlin/core/testutils/TestUtils.kt | 10 +- 9 files changed, 646 insertions(+), 131 deletions(-) diff --git a/contracts/src/main/kotlin/contracts/IRS.kt b/contracts/src/main/kotlin/contracts/IRS.kt index 0666415603..008b382fe5 100644 --- a/contracts/src/main/kotlin/contracts/IRS.kt +++ b/contracts/src/main/kotlin/contracts/IRS.kt @@ -19,15 +19,24 @@ open class UnknownType() { return (other is UnknownType) } - override fun hashCode(): Int { - return 1 - } + override fun hashCode() = 1 } /** * Event superclass - everything happens on a date. */ -open class Event(val date: LocalDate) +open class Event(val date: LocalDate) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Event) return false + + if (date != other.date) return false + + return true + } + + override fun hashCode() = Objects.hash(date) +} /** * Top level PaymentEvent class - represents an obligation to pay an amount on a given date, which may be either in the past or the future. @@ -58,14 +67,30 @@ abstract class RatePaymentEvent(date: LocalDate, abstract val flow: Amount - val days: Int get() = - calculateDaysBetween(accrualStartDate, accrualEndDate, dayCountBasisYear, dayCountBasisDay) + val days: Int get() = calculateDaysBetween(accrualStartDate, accrualEndDate, dayCountBasisYear, dayCountBasisDay) - val dayCountFactor: BigDecimal get() = - // TODO : Fix below (use daycount convention for division) - (BigDecimal(days).divide(BigDecimal(360.0), 8, RoundingMode.HALF_UP)).setScale(4, RoundingMode.HALF_UP) + // TODO : Fix below (use daycount convention for division, not hardcoded 360 etc) + val dayCountFactor: BigDecimal get() = (BigDecimal(days).divide(BigDecimal(360.0), 8, RoundingMode.HALF_UP)).setScale(4, RoundingMode.HALF_UP) - open fun asCSV(): String = "$accrualStartDate,$accrualEndDate,$dayCountFactor,$days,$date,${notional.currency},${notional},$rate,$flow" + open fun asCSV() = "$accrualStartDate,$accrualEndDate,$dayCountFactor,$days,$date,${notional.currency},${notional},$rate,$flow" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RatePaymentEvent) return false + + if (accrualStartDate != other.accrualStartDate) return false + if (accrualEndDate != other.accrualEndDate) return false + if (dayCountBasisDay != other.dayCountBasisDay) return false + if (dayCountBasisYear != other.dayCountBasisYear) return false + if (notional != other.notional) return false + if (rate != other.rate) return false + // if (flow != other.flow) return false // Flow is derived + + return super.equals(other) + } + + override fun hashCode() = super.hashCode() + 31 * Objects.hash(accrualStartDate, accrualEndDate, dayCountBasisDay, + dayCountBasisYear, notional, rate) } /** @@ -114,9 +139,7 @@ class FloatingRatePaymentEvent(date: LocalDate, return Amount(dayCountFactor.times(BigDecimal(notional.pennies)).times(v).toLong(), notional.currency) } - override fun toString(): String { - return "FloatingPaymentEvent $accrualStartDate -> $accrualEndDate : $dayCountFactor : $days : $date : $notional : $rate (fix on $fixingDate): $flow" - } + override fun toString(): String = "FloatingPaymentEvent $accrualStartDate -> $accrualEndDate : $dayCountFactor : $days : $date : $notional : $rate (fix on $fixingDate): $flow" override fun asCSV(): String = "$accrualStartDate,$accrualEndDate,$dayCountFactor,$days,$date,${notional.currency},${notional},$fixingDate,$rate,$flow" @@ -126,6 +149,26 @@ class FloatingRatePaymentEvent(date: LocalDate, fun withNewRate(newRate: Rate): FloatingRatePaymentEvent = FloatingRatePaymentEvent(date, accrualStartDate, accrualEndDate, dayCountBasisDay, dayCountBasisYear, fixingDate, notional, newRate) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other?.javaClass != javaClass) return false + other as FloatingRatePaymentEvent + if (fixingDate != other.fixingDate) return false + return super.equals(other) + } + + override fun hashCode() = super.hashCode() + 31 * Objects.hash(fixingDate) + + // Can't autogenerate as not a data class :-( + fun copy(date: LocalDate = this.date, + accrualStartDate: LocalDate = this.accrualStartDate, + accrualEndDate: LocalDate = this.accrualEndDate, + dayCountBasisDay: DayCountBasisDay = this.dayCountBasisDay, + dayCountBasisYear: DayCountBasisYear = this.dayCountBasisYear, + fixingDate: LocalDate = this.fixingDate, + notional: Amount = this.notional, + rate: Rate = this.rate) = FloatingRatePaymentEvent(date, accrualStartDate, accrualEndDate, dayCountBasisDay, dayCountBasisYear, fixingDate, notional, rate) } @@ -191,18 +234,13 @@ class InterestRateSwap() : Contract { /** * Returns a copy after modifying (applying) the fixing for that date. */ - fun applyFixing(date: LocalDate, newRate: Rate): Calculation { + fun applyFixing(date: LocalDate, newRate: FixedRate): Calculation { val paymentEvent = getFixing(date) val newFloatingLPS = floatingLegPaymentSchedule + (paymentEvent.date to paymentEvent.withNewRate(newRate)) return Calculation(expression = expression, floatingLegPaymentSchedule = newFloatingLPS, fixedLegPaymentSchedule = fixedLegPaymentSchedule) } - - fun exportSchedule() { - - } - } abstract class CommonLeg( @@ -212,13 +250,13 @@ class InterestRateSwap() : Contract { val effectiveDateAdjustment: DateRollConvention?, val terminationDate: LocalDate, val terminationDateAdjustment: DateRollConvention?, - var dayCountBasisDay: DayCountBasisDay, - var dayCountBasisYear: DayCountBasisYear, - var dayInMonth: Int, - var paymentRule: PaymentRule, - var paymentDelay: Int, - var paymentCalendar: BusinessCalendar, - var interestPeriodAdjustment: AccrualAdjustment + val dayCountBasisDay: DayCountBasisDay, + val dayCountBasisYear: DayCountBasisYear, + val dayInMonth: Int, + val paymentRule: PaymentRule, + val paymentDelay: Int, + val paymentCalendar: BusinessCalendar, + val interestPeriodAdjustment: AccrualAdjustment ) { override fun toString(): String { return "Notional=$notional,PaymentFrequency=$paymentFrequency,EffectiveDate=$effectiveDate,EffectiveDateAdjustment:$effectiveDateAdjustment,TerminatationDate=$terminationDate," + @@ -249,24 +287,9 @@ class InterestRateSwap() : Contract { return true } - override fun hashCode(): Int { - var result = notional.hashCode() - result += 31 * result + paymentFrequency.hashCode() - result += 31 * result + effectiveDate.hashCode() - result += 31 * result + (effectiveDateAdjustment?.hashCode() ?: 0) - result += 31 * result + terminationDate.hashCode() - result += 31 * result + (terminationDateAdjustment?.hashCode() ?: 0) - result += 31 * result + dayCountBasisDay.hashCode() - result += 31 * result + dayCountBasisYear.hashCode() - result += 31 * result + dayInMonth - result += 31 * result + paymentRule.hashCode() - result += 31 * result + paymentDelay - result += 31 * result + paymentCalendar.hashCode() - result += 31 * result + interestPeriodAdjustment.hashCode() - return result - } - - + override fun hashCode() = super.hashCode() + 31 * Objects.hash(notional, paymentFrequency, effectiveDate, + effectiveDateAdjustment, terminationDate, effectiveDateAdjustment, terminationDate, terminationDateAdjustment, + dayCountBasisDay, dayCountBasisYear, dayInMonth, paymentRule, paymentDelay, paymentCalendar, interestPeriodAdjustment) } open class FixedLeg( @@ -306,13 +329,27 @@ class InterestRateSwap() : Contract { return true } - override fun hashCode(): Int { - var result = super.hashCode() - result += 31 * result + fixedRatePayer.hashCode() - result += 31 * result + fixedRate.hashCode() - result += 31 * result + rollConvention.hashCode() - return result - } + override fun hashCode() = super.hashCode() + 31 * Objects.hash(fixedRatePayer, fixedRate, rollConvention) + + // Can't autogenerate as not a data class :-( + fun copy(fixedRatePayer: Party = this.fixedRatePayer, + notional: Amount = this.notional, + paymentFrequency: Frequency = this.paymentFrequency, + effectiveDate: LocalDate = this.effectiveDate, + effectiveDateAdjustment: DateRollConvention? = this.effectiveDateAdjustment, + terminationDate: LocalDate = this.terminationDate, + terminationDateAdjustment: DateRollConvention? = this.terminationDateAdjustment, + dayCountBasisDay: DayCountBasisDay = this.dayCountBasisDay, + dayCountBasisYear: DayCountBasisYear = this.dayCountBasisYear, + dayInMonth: Int = this.dayInMonth, + paymentRule: PaymentRule = this.paymentRule, + paymentDelay: Int = this.paymentDelay, + paymentCalendar: BusinessCalendar = this.paymentCalendar, + interestPeriodAdjustment: AccrualAdjustment = this.interestPeriodAdjustment, + fixedRate: FixedRate = this.fixedRate) = FixedLeg( + fixedRatePayer, notional, paymentFrequency, effectiveDate, effectiveDateAdjustment, terminationDate, + terminationDateAdjustment, dayCountBasisDay, dayCountBasisYear, dayInMonth, paymentRule, paymentDelay, + paymentCalendar, interestPeriodAdjustment, fixedRate, rollConvention) } @@ -370,62 +407,168 @@ class InterestRateSwap() : Contract { return true } - override fun hashCode(): Int { - var result = super.hashCode() - result += 31 * result + floatingRatePayer.hashCode() - result += 31 * result + rollConvention.hashCode() - result += 31 * result + fixingRollConvention.hashCode() - result += 31 * result + resetDayInMonth - result += 31 * result + fixingPeriod.hashCode() - result += 31 * result + resetRule.hashCode() - result += 31 * result + fixingsPerPayment.hashCode() - result += 31 * result + fixingCalendar.hashCode() - result += 31 * result + index.hashCode() - result += 31 * result + indexSource.hashCode() - result += 31 * result + indexTenor.hashCode() - return result + override fun hashCode() = super.hashCode() + 31 * Objects.hash(floatingRatePayer, rollConvention, + fixingRollConvention, resetDayInMonth, fixingPeriod, resetRule, fixingsPerPayment, fixingCalendar, + index, indexSource, indexTenor) + + + fun copy(floatingRatePayer: Party = this.floatingRatePayer, + notional: Amount = this.notional, + paymentFrequency: Frequency = this.paymentFrequency, + effectiveDate: LocalDate = this.effectiveDate, + effectiveDateAdjustment: DateRollConvention? = this.effectiveDateAdjustment, + terminationDate: LocalDate = this.terminationDate, + terminationDateAdjustment: DateRollConvention? = this.terminationDateAdjustment, + dayCountBasisDay: DayCountBasisDay = this.dayCountBasisDay, + dayCountBasisYear: DayCountBasisYear = this.dayCountBasisYear, + dayInMonth: Int = this.dayInMonth, + paymentRule: PaymentRule = this.paymentRule, + paymentDelay: Int = this.paymentDelay, + paymentCalendar: BusinessCalendar = this.paymentCalendar, + interestPeriodAdjustment: AccrualAdjustment = this.interestPeriodAdjustment, + rollConvention: DateRollConvention = this.rollConvention, + fixingRollConvention: DateRollConvention = this.fixingRollConvention, + resetDayInMonth: Int = this.resetDayInMonth, + fixingPeriod: DateOffset = this.fixingPeriod, + resetRule: PaymentRule = this.resetRule, + fixingsPerPayment: Frequency = this.fixingsPerPayment, + fixingCalendar: BusinessCalendar = this.fixingCalendar, + index: String = this.index, + indexSource: String = this.indexSource, + indexTenor: Tenor = this.indexTenor + ) = FloatingLeg(floatingRatePayer, notional, paymentFrequency, effectiveDate, effectiveDateAdjustment, + terminationDate, terminationDateAdjustment, dayCountBasisDay, dayCountBasisYear, dayInMonth, + paymentRule, paymentDelay, paymentCalendar, interestPeriodAdjustment, rollConvention, + fixingRollConvention, resetDayInMonth, fixingPeriod, resetRule, fixingsPerPayment, + fixingCalendar, index, indexSource, indexTenor) + } + + // These functions may make more sense to use for basket types, but for now let's leave them here + fun checkLegDates(legs: Array) { + requireThat { + "Effective date is before termination date" by legs.all { it.effectiveDate < it.terminationDate } + "Effective dates are in alignment" by legs.all { it.effectiveDate == legs[0].effectiveDate } + "Termination dates are in alignment" by legs.all { it.terminationDate == legs[0].terminationDate } + } + } + + fun checkLegAmounts(legs: Array) { + requireThat { + "The notional is non zero" by legs.any { it.notional.pennies > (0).toLong() } + "The notional for all legs must be the same" by legs.all { it.notional == legs[0].notional } + } + for (leg: CommonLeg in legs) { + if (leg is FixedLeg) { + requireThat { + // TODO: Confirm: would someone really enter a swap with a negative fixed rate? + "Fixed leg rate must be positive" by leg.fixedRate.isPositive() + } + } } } + // TODO: After business rules discussion, add further checks to the schedules and rates + fun checkSchedules(@Suppress("UNUSED_PARAMETER") legs: Array): Boolean = true + + fun checkRates(@Suppress("UNUSED_PARAMETER") legs: Array): Boolean = true + /** - * verify() with a few examples of what needs to be checked. TODO: Lots more to add. + * Compares two schedules of Floating Leg Payments, returns the difference (i.e. omissions in either leg or changes to the values). + */ + fun getFloatingLegPaymentsDifferences(payments1: Map, payments2: Map): List>> { + val diff1 = payments1.filter { payments1[it.key] != payments2[it.key] } + val diff2 = payments2.filter { payments1[it.key] != payments2[it.key] } + val ret = (diff1.keys + diff2.keys).map { it to Pair(diff1.get(it) as FloatingRatePaymentEvent, diff2.get(it) as FloatingRatePaymentEvent) } + return ret + } + + /** + * verify() with some examples of what needs to be checked. */ override fun verify(tx: TransactionForVerification) { + + // Group by Trade ID for in / out states + val groups = tx.groupStates() { state: InterestRateSwap.State -> state.common.tradeID } + val command = tx.commands.requireSingleCommand() val time = tx.commands.getTimestampByName("Mock Company 0", "Timestamping Service", "Bank A")?.midpoint if (time == null) throw IllegalArgumentException("must be timestamped") - val irs = tx.outStates.filterIsInstance().single() - when (command.value) { - is Commands.Agree -> { - requireThat { - "There are no in states for an agreement" by tx.inStates.isEmpty() - "The fixed rate is non zero" by (irs.fixedLeg.fixedRate != FixedRate(PercentageRatioUnit("0.0"))) - "There are events in the fix schedule" by (irs.calculation.fixedLegPaymentSchedule.size > 0) - "There are events in the float schedule" by (irs.calculation.floatingLegPaymentSchedule.size > 0) - // "There are fixes in the schedule" by (irs.calculation.floatingLegPaymentSchedule!!.size > 0) - // TODO: shortlist of other tests - } - } - is Commands.Fix -> { - requireThat { - // TODO: see previous block - // "There is a fixing supplied" by false // TODO - // "The fixing has been signed by an appropriate oracle" by false // TODO - // "The fixing has arrived at the right time" by false - // "The net payment has been calculated" by false // TODO : Not sure if this is the right place + for ((inputs, outputs, key) in groups) { + when (command.value) { + is Commands.Agree -> { + val irs = outputs.filterIsInstance().single() + requireThat { + "There are no in states for an agreement" by inputs.isEmpty() + "There are events in the fix schedule" by (irs.calculation.fixedLegPaymentSchedule.size > 0) + "There are events in the float schedule" by (irs.calculation.floatingLegPaymentSchedule.size > 0) + "All notionals must be non zero" by ( irs.fixedLeg.notional.pennies > 0 && irs.floatingLeg.notional.pennies > 0) + "The fixed leg rate must be positive" by ( irs.fixedLeg.fixedRate.isPositive() ) + "The currency of the notionals must be the same" by (irs.fixedLeg.notional.currency == irs.floatingLeg.notional.currency) + "All leg notionals must be the same" by (irs.fixedLeg.notional == irs.floatingLeg.notional) + "The effective date is before the termination date for the fixed leg" by (irs.fixedLeg.effectiveDate < irs.fixedLeg.terminationDate) + "The effective date is before the termination date for the floating leg" by (irs.floatingLeg.effectiveDate < irs.floatingLeg.terminationDate) + "The effective dates are aligned" by (irs.floatingLeg.effectiveDate == irs.fixedLeg.effectiveDate) + "The termination dates are aligned" by (irs.floatingLeg.terminationDate == irs.fixedLeg.terminationDate) + "The rates are valid" by checkRates(arrayOf(irs.fixedLeg, irs.floatingLeg)) + "The schedules are valid" by checkSchedules(arrayOf(irs.fixedLeg, irs.floatingLeg)) + + + // TODO: further tests + } + checkLegAmounts(arrayOf(irs.fixedLeg, irs.floatingLeg)) + checkLegDates(arrayOf(irs.fixedLeg, irs.floatingLeg)) } - } - is Commands.Pay -> { - requireThat { - // TODO: see previous block - //"A counterparty must be making a payment" by false // TODO - // "The right counterparty must be receiving the payment" by false // TODO + is Commands.Fix -> { + val irs = outputs.filterIsInstance().single() + val prevIrs = inputs.filterIsInstance().single() + val paymentDifferences = getFloatingLegPaymentsDifferences(prevIrs.calculation.floatingLegPaymentSchedule, irs.calculation.floatingLegPaymentSchedule) + + // Having both of these tests are "redundant" as far as verify() goes, however, by performing both + // we can relay more information back to the user in the case of failure. + requireThat { + "There is at least one difference in the IRS floating leg payment schedules" by !paymentDifferences.isEmpty() + "There is only one change in the IRS floating leg payment schedule" by (paymentDifferences.size == 1) + } + + val changedRates = paymentDifferences.single().second // Ignore the date of the changed rate (we checked that earlier). + val (oldFloatingRatePaymentEvent, newFixedRatePaymentEvent) = changedRates + val fixCommand = tx.commands.requireSingleCommand() + val fixValue = fixCommand.value + // Need to check that everything is the same apart from the new fixed rate entry. + requireThat { + "The fixed leg parties are constant" by ( irs.fixedLeg.fixedRatePayer == prevIrs.fixedLeg.fixedRatePayer) // Although superseded by the below test, this is included for a regression issue + "The fixed leg is constant" by (irs.fixedLeg == prevIrs.fixedLeg) + "The floating leg is constant" by (irs.floatingLeg == prevIrs.floatingLeg) + "The common values are constant" by (irs.common == prevIrs.common) + "The fixed leg payment schedule is constant" by (irs.calculation.fixedLegPaymentSchedule == prevIrs.calculation.fixedLegPaymentSchedule) + "The expression is unchanged" by (irs.calculation.expression == prevIrs.calculation.expression) + "There is only one changed payment in the floating leg" by (paymentDifferences.size == 1) + "There changed payment is a floating payment" by (oldFloatingRatePaymentEvent.rate is ReferenceRate) + "The new payment is a fixed payment" by (newFixedRatePaymentEvent.rate is FixedRate) + "The changed payments dates are aligned" by ( oldFloatingRatePaymentEvent.date == newFixedRatePaymentEvent.date) + "The new payment has the correct rate" by (newFixedRatePaymentEvent.rate.ratioUnit!!.value == fixValue.value) + "The fixing is for the next required date" by (prevIrs.calculation.nextFixingDate() == fixValue.of.forDay) + "The fix payment has the same currency as the notional" by (newFixedRatePaymentEvent.flow.currency == irs.floatingLeg.notional.currency) + // "The fixing is not in the future " by (fixCommand) // The oracle should not have signed this . + } } + is Commands.Pay -> { + requireThat { + "Payments not supported / verifiable yet" by false + } + } + is Commands.Mature -> { + val irs = inputs.filterIsInstance().single() + requireThat { + "No more fixings to be applied" by (irs.calculation.nextFixingDate() == null) + } + } + + else -> throw IllegalArgumentException("Unrecognised verifiable command: ${command.value}") } - else -> throw IllegalArgumentException("Unrecognised verifiable command: ${command.value}") } } @@ -510,6 +653,7 @@ class InterestRateSwap() : Contract { * Just makes printing it out a bit better for those who don't have 80000 column wide monitors. */ fun prettyPrint(): String = toString().replace(",", "\n") + } /** @@ -580,7 +724,7 @@ class InterestRateSwap() : Contract { // TODO: Replace with rates oracle fun generateFix(tx: TransactionBuilder, irs: StateAndRef, fixing: Pair) { tx.addInputState(irs.ref) - tx.addOutputState(irs.state.copy(calculation = irs.state.calculation.applyFixing(fixing.first, fixing.second))) + tx.addOutputState(irs.state.copy(calculation = irs.state.calculation.applyFixing(fixing.first, FixedRate(fixing.second)))) tx.addCommand(Commands.Fix(), listOf(irs.state.floatingLeg.floatingRatePayer.owningKey, irs.state.fixedLeg.fixedRatePayer.owningKey)) } } diff --git a/contracts/src/main/kotlin/contracts/IRSUtils.kt b/contracts/src/main/kotlin/contracts/IRSUtils.kt index da60600fe6..936b73584a 100644 --- a/contracts/src/main/kotlin/contracts/IRSUtils.kt +++ b/contracts/src/main/kotlin/contracts/IRSUtils.kt @@ -24,17 +24,14 @@ open class RatioUnit(value: BigDecimal) { // TODO: Discuss this type return true } - override fun hashCode(): Int { - return value.hashCode() - } - + override fun hashCode() = value.hashCode() } /** * A class to reprecent a percentage in an unambiguous way. */ open class PercentageRatioUnit(percentageAsString: String) : RatioUnit(BigDecimal(percentageAsString).divide(BigDecimal("100"))) { - override fun toString(): String = value.times(BigDecimal(100)).toString() + "%" + override fun toString() = value.times(BigDecimal(100)).toString() + "%" } /** @@ -111,16 +108,23 @@ open class Rate(val ratioUnit: RatioUnit? = null) { * that have not yet happened. Yet-to-be fixed floating rates need to be equal such that schedules can be tested * for equality. */ - override fun hashCode(): Int { - return ratioUnit?.hashCode() ?: 0 - } + override fun hashCode() = ratioUnit?.hashCode() ?: 0 } /** * A very basic subclass to represent a fixed rate. */ class FixedRate(ratioUnit: RatioUnit) : Rate(ratioUnit) { + + constructor(otherRate: Rate) : this(ratioUnit = otherRate.ratioUnit!!) + override fun toString(): String = "$ratioUnit" + + fun isPositive(): Boolean = ratioUnit!!.value > BigDecimal("0.0") + + override fun equals(other: Any?) = other?.javaClass == javaClass && super.equals(other) + + override fun hashCode() = super.hashCode() } /** diff --git a/scripts/example-irs-trade.json b/scripts/example-irs-trade.json index fc8d17eef3..7338404bdc 100644 --- a/scripts/example-irs-trade.json +++ b/scripts/example-irs-trade.json @@ -6,9 +6,9 @@ "currency": "USD" }, "paymentFrequency": "SemiAnnual", - "effectiveDate": "2016-03-16", + "effectiveDate": "2016-03-11", "effectiveDateAdjustment": null, - "terminationDate": "2026-03-16", + "terminationDate": "2026-03-11", "terminationDateAdjustment": null, "fixedRate": { "ratioUnit": { @@ -31,9 +31,9 @@ "currency": "USD" }, "paymentFrequency": "Quarterly", - "effectiveDate": "2016-03-12", + "effectiveDate": "2016-03-11", "effectiveDateAdjustment": null, - "terminationDate": "2026-03-12", + "terminationDate": "2026-03-11", "terminationDateAdjustment": null, "dayCountBasisDay": "D30", "dayCountBasisYear": "Y360", diff --git a/scripts/example.rates.txt b/scripts/example.rates.txt index cb49751d27..2b9893f806 100644 --- a/scripts/example.rates.txt +++ b/scripts/example.rates.txt @@ -5,7 +5,8 @@ ICE LIBOR 2016-03-16 2M = 0.655 EURIBOR 2016-03-15 1M = 0.123 EURIBOR 2016-03-15 2M = 0.111 -ICE LIBOR 2016-03-06 3M = 0.0063515 +# Previous fixings +ICE LIBOR 2016-03-07 3M = 0.0063516 ICE LIBOR 2016-03-07 3M = 0.0063516 ICE LIBOR 2016-03-08 3M = 0.0063517 ICE LIBOR 2016-03-09 3M = 0.0063518 diff --git a/src/main/kotlin/core/node/subsystems/Services.kt b/src/main/kotlin/core/node/subsystems/Services.kt index 4d65544351..c969193418 100644 --- a/src/main/kotlin/core/node/subsystems/Services.kt +++ b/src/main/kotlin/core/node/subsystems/Services.kt @@ -113,6 +113,8 @@ interface KeyManagementService { fun toPrivate(publicKey: PublicKey) = keys[publicKey] ?: throw IllegalStateException("No private key known for requested public key") + fun toKeyPair(publicKey: PublicKey) = KeyPair(publicKey, toPrivate(publicKey)) + /** Generates a new random key and adds it to the exposed map. */ fun freshKey(): KeyPair } diff --git a/src/main/kotlin/demos/protocols/UpdateBusinessDayProtocol.kt b/src/main/kotlin/demos/protocols/UpdateBusinessDayProtocol.kt index fa43e6f642..be8ef4fdb4 100644 --- a/src/main/kotlin/demos/protocols/UpdateBusinessDayProtocol.kt +++ b/src/main/kotlin/demos/protocols/UpdateBusinessDayProtocol.kt @@ -15,6 +15,7 @@ import core.utilities.ANSIProgressRenderer import core.utilities.ProgressTracker import demos.DemoClock import protocols.TwoPartyDealProtocol +import java.security.KeyPair import java.time.LocalDate /** @@ -98,7 +99,13 @@ object UpdateBusinessDayProtocol { progressTracker.childrenFor[FIXING] = TwoPartyDealProtocol.Primary.tracker() progressTracker.currentStep = FIXING - val participant = TwoPartyDealProtocol.Floater(party.address, sessionID, serviceHub.networkMapCache.timestampingNodes[0], dealStateAndRef, serviceHub.keyManagementService.freshKey(), sessionID, progressTracker.childrenFor[FIXING]!!) + val myName = serviceHub.storageService.myLegalIdentity.name + val deal: InterestRateSwap.State = dealStateAndRef.state + val myOldParty = deal.parties.single { it.name == myName } + val keyPair = serviceHub.keyManagementService.toKeyPair(myOldParty.owningKey) + val participant = TwoPartyDealProtocol.Floater(party.address, sessionID, serviceHub.networkMapCache.timestampingNodes[0], dealStateAndRef, + keyPair, + sessionID, progressTracker.childrenFor[FIXING]!!) val result = subProtocol(participant) return result.tx.outRef(0) } diff --git a/src/main/kotlin/protocols/TwoPartyDealProtocol.kt b/src/main/kotlin/protocols/TwoPartyDealProtocol.kt index c0c6f08f82..7880a28113 100644 --- a/src/main/kotlin/protocols/TwoPartyDealProtocol.kt +++ b/src/main/kotlin/protocols/TwoPartyDealProtocol.kt @@ -367,10 +367,9 @@ object TwoPartyDealProtocol { val deal: T = dealToFix.state val myOldParty = deal.parties.single { it.name == myName } val theirOldParty = deal.parties.single { it.name != myName } - val myNewKey = serviceHub.keyManagementService.freshKey().public @Suppress("UNCHECKED_CAST") - val newDeal = deal.withPublicKey(myOldParty, myNewKey).withPublicKey(theirOldParty, handshake.publicKey) as T + val newDeal = deal val oldRef = dealToFix.ref val ptx = TransactionBuilder() @@ -386,7 +385,7 @@ object TwoPartyDealProtocol { } subProtocol(addFixing) - return Pair(ptx, arrayListOf(myNewKey)) + return Pair(ptx, arrayListOf(myOldParty.owningKey)) } } diff --git a/src/test/kotlin/contracts/IRSTests.kt b/src/test/kotlin/contracts/IRSTests.kt index 01ef8e3203..aec35de105 100644 --- a/src/test/kotlin/contracts/IRSTests.kt +++ b/src/test/kotlin/contracts/IRSTests.kt @@ -4,6 +4,7 @@ import core.* import core.node.services.DummyTimestampingAuthority import core.testutils.* import org.junit.Test +import java.math.BigDecimal import java.time.LocalDate import java.util.* @@ -15,9 +16,9 @@ fun createDummyIRS(irsSelect: Int): InterestRateSwap.State { fixedRatePayer = MEGA_CORP, notional = 15900000.DOLLARS, paymentFrequency = Frequency.SemiAnnual, - effectiveDate = LocalDate.of(2016, 3, 16), + effectiveDate = LocalDate.of(2016, 3, 10), effectiveDateAdjustment = null, - terminationDate = LocalDate.of(2026, 3, 16), + terminationDate = LocalDate.of(2026, 3, 10), terminationDateAdjustment = null, fixedRate = FixedRate(PercentageRatioUnit("1.677")), dayCountBasisDay = DayCountBasisDay.D30, @@ -98,7 +99,6 @@ fun createDummyIRS(irsSelect: Int): InterestRateSwap.State { InterestRateSwap.State(fixedLeg = fixedLeg, floatingLeg = floatingLeg, calculation = calculation, common = common) } 2 -> { - // 10y swap, we pay 1.3% fixed 30/360 semi, rec 3m usd libor act/360 Q on 25m notional (mod foll/adj on both sides) // I did a mock up start date 10/03/2015 – 10/03/2025 so you have 5 cashflows on float side that have been preset the rest are unknown @@ -181,7 +181,7 @@ fun createDummyIRS(irsSelect: Int): InterestRateSwap.State { addressForTransfers = "", exposure = UnknownType(), localBusinessDay = BusinessCalendar.getInstance("London"), - tradeID = "trade1", + tradeID = "trade2", hashLegalDocs = "put hash here", dailyInterestAmount = Expression("(CashAmount * InterestRate ) / (fixedLeg.notional.currency.currencyCode.equals('GBP')) ? 365 : 360") ) @@ -197,10 +197,25 @@ class IRSTests { val attachments = MockStorageService().attachments + val exampleIRS = createDummyIRS(1) + + val inState = InterestRateSwap.State( + exampleIRS.fixedLeg, + exampleIRS.floatingLeg, + exampleIRS.calculation, + exampleIRS.common + ) + + val outState = inState.copy() + @Test fun ok() { - val t = trade() - t.verify() + trade().verify() + } + + @Test + fun `ok with groups`() { + tradegroups().verify() } /** @@ -243,7 +258,7 @@ class IRSTests { } /** - * Test the generate + * Test the generate. No explicit exception as if something goes wrong, we'll find out anyway. */ @Test fun generateIRS() { @@ -251,6 +266,9 @@ class IRSTests { generateIRSTxn(1) } + /** + * Testing a simple IRS, add a few fixings and then display as CSV + */ @Test fun `IRS Export test`() { // No transactions etc required - we're just checking simple maths and export functionallity @@ -351,7 +369,14 @@ class IRSTests { } + /** + * Generates a typical transactional history for an IRS. + */ fun trade(): TransactionGroupDSL { + + val ld = LocalDate.of(2016, 3, 8) + val bd = BigDecimal("0.0063518") + val txgroup: TransactionGroupDSL = transactionGroupFor() { transaction("Agreement") { output("irs post agreement") { singleIRS() } @@ -361,18 +386,349 @@ class IRSTests { transaction("Fix") { input("irs post agreement") - output("irs post first fixing") { "irs post agreement".output } - arg(ORACLE_PUBKEY) { InterestRateSwap.Commands.Fix() } - timestamp(TEST_TX_TIME) - } - - transaction("Pay") { - input("irs post first fixing") - output("irs post first payment") { "irs post first fixing".output } - arg(MEGA_CORP_PUBKEY, MINI_CORP_PUBKEY) { InterestRateSwap.Commands.Pay() } + output("irs post first fixing") { + "irs post agreement".output.copy( + "irs post agreement".output.fixedLeg, + "irs post agreement".output.floatingLeg, + "irs post agreement".output.calculation.applyFixing(ld, FixedRate(RatioUnit(bd))), + "irs post agreement".output.common + ) + } + arg(ORACLE_PUBKEY) { + InterestRateSwap.Commands.Fix() + } + arg(ORACLE_PUBKEY) { + Fix(FixOf("ICE LIBOR", ld, Tenor("3M")), bd) + } timestamp(TEST_TX_TIME) } } return txgroup } -} \ No newline at end of file + + @Test + fun `ensure failure occurs when there are inbound states for an agreement command`() { + transaction { + input() { singleIRS() } + output("irs post agreement") { singleIRS() } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "There are no in states for an agreement" + } + } + + @Test + fun `ensure failure occurs when no events in fix schedule`() { + val irs = singleIRS() + val emptySchedule = HashMap() + transaction { + output() { + irs.copy(calculation = irs.calculation.copy(fixedLegPaymentSchedule = emptySchedule)) + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "There are events in the fix schedule" + } + } + + @Test + fun `ensure failure occurs when no events in floating schedule`() { + val irs = singleIRS() + val emptySchedule = HashMap() + transaction { + output() { + irs.copy(calculation = irs.calculation.copy(floatingLegPaymentSchedule = emptySchedule)) + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "There are events in the float schedule" + } + } + + @Test + fun `ensure notionals are non zero`() { + val irs = singleIRS() + transaction { + output() { + irs.copy(irs.fixedLeg.copy(notional = irs.fixedLeg.notional.copy(pennies = 0))) + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "All notionals must be non zero" + } + + transaction { + output() { + irs.copy(irs.fixedLeg.copy(notional = irs.floatingLeg.notional.copy(pennies = 0))) + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "All notionals must be non zero" + } + } + + @Test + fun `ensure positive rate on fixed leg`() { + val irs = singleIRS() + val modifiedIRS = irs.copy(fixedLeg = irs.fixedLeg.copy(fixedRate = FixedRate(PercentageRatioUnit("-0.1")))) + transaction { + output() { + modifiedIRS + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "The fixed leg rate must be positive" + } + } + + /** + * This will be modified once we adapt the IRS to be cross currency + */ + @Test + fun `ensure same currency notionals`() { + val irs = singleIRS() + val modifiedIRS = irs.copy(fixedLeg = irs.fixedLeg.copy(notional = Amount(irs.fixedLeg.notional.pennies, Currency.getInstance("JPY")))) + transaction { + output() { + modifiedIRS + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "The currency of the notionals must be the same" + } + } + + @Test + fun `ensure notional amounts are equal`() { + val irs = singleIRS() + val modifiedIRS = irs.copy(fixedLeg = irs.fixedLeg.copy(notional = Amount(irs.floatingLeg.notional.pennies + 1, irs.floatingLeg.notional.currency))) + transaction { + output() { + modifiedIRS + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "All leg notionals must be the same" + } + } + + @Test + fun `ensure trade date and termination date checks are done pt1`() { + val irs = singleIRS() + val modifiedIRS1 = irs.copy(fixedLeg = irs.fixedLeg.copy(terminationDate = irs.fixedLeg.effectiveDate.minusDays(1))) + transaction { + output() { + modifiedIRS1 + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "The effective date is before the termination date for the fixed leg" + } + + val modifiedIRS2 = irs.copy(floatingLeg = irs.floatingLeg.copy(terminationDate = irs.floatingLeg.effectiveDate.minusDays(1))) + transaction { + output() { + modifiedIRS2 + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "The effective date is before the termination date for the floating leg" + } + } + + @Test + fun `ensure trade date and termination date checks are done pt2`() { + val irs = singleIRS() + + val modifiedIRS3 = irs.copy(floatingLeg = irs.floatingLeg.copy(terminationDate = irs.fixedLeg.terminationDate.minusDays(1))) + transaction { + output() { + modifiedIRS3 + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "The termination dates are aligned" + } + + + val modifiedIRS4 = irs.copy(floatingLeg = irs.floatingLeg.copy(effectiveDate = irs.fixedLeg.effectiveDate.minusDays(1))) + transaction { + output() { + modifiedIRS4 + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + this `fails requirement` "The effective dates are aligned" + } + } + + + @Test + fun `various fixing tests`() { + + val ld = LocalDate.of(2016, 3, 8) + val bd = BigDecimal("0.0063518") + + transaction { + output("irs post agreement") { singleIRS() } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + } + + val oldIRS = singleIRS(1) + val newIRS = oldIRS.copy(oldIRS.fixedLeg, + oldIRS.floatingLeg, + oldIRS.calculation.applyFixing(ld, FixedRate(RatioUnit(bd))), + oldIRS.common) + + transaction { + input() { + oldIRS + + } + + // Templated tweak for reference. A corrent fixing applied should be ok + tweak { + arg(ORACLE_PUBKEY) { + InterestRateSwap.Commands.Fix() + } + timestamp(TEST_TX_TIME) + arg(ORACLE_PUBKEY) { + Fix(FixOf("ICE LIBOR", ld, Tenor("3M")), bd) + } + output() { newIRS } + this.accepts() + } + + // This test makes sure that verify confirms the fixing was applied and there is a difference in the old and new + tweak { + arg(ORACLE_PUBKEY) { InterestRateSwap.Commands.Fix() } + timestamp(TEST_TX_TIME) + arg(ORACLE_PUBKEY) { Fix(FixOf("ICE LIBOR", ld, Tenor("3M")), bd) } + output() { oldIRS } + this`fails requirement` "There is at least one difference in the IRS floating leg payment schedules" + } + + // This tests tries to sneak in a change to another fixing (which may or may not be the latest one) + tweak { + arg(ORACLE_PUBKEY) { InterestRateSwap.Commands.Fix() } + timestamp(TEST_TX_TIME) + arg(ORACLE_PUBKEY) { + Fix(FixOf("ICE LIBOR", ld, Tenor("3M")), bd) + } + + val firstResetKey = newIRS.calculation.floatingLegPaymentSchedule.keys.first() + val firstResetValue = newIRS.calculation.floatingLegPaymentSchedule[firstResetKey] + var modifiedFirstResetValue = firstResetValue!!.copy(notional = Amount(firstResetValue.notional.pennies, Currency.getInstance("JPY"))) + + output() { + newIRS.copy( + newIRS.fixedLeg, + newIRS.floatingLeg, + newIRS.calculation.copy(floatingLegPaymentSchedule = newIRS.calculation.floatingLegPaymentSchedule.plus( + Pair(firstResetKey, modifiedFirstResetValue))), + newIRS.common + ) + } + this`fails requirement` "There is only one change in the IRS floating leg payment schedule" + } + + // This tests modifies the payment currency for the fixing + tweak { + arg(ORACLE_PUBKEY) { InterestRateSwap.Commands.Fix() } + timestamp(TEST_TX_TIME) + arg(ORACLE_PUBKEY) { Fix(FixOf("ICE LIBOR", ld, Tenor("3M")), bd) } + + val latestReset = newIRS.calculation.floatingLegPaymentSchedule.filter { it.value.rate is FixedRate }.maxBy { it.key } + var modifiedLatestResetValue = latestReset!!.value.copy(notional = Amount(latestReset.value.notional.pennies, Currency.getInstance("JPY"))) + + output() { + newIRS.copy( + newIRS.fixedLeg, + newIRS.floatingLeg, + newIRS.calculation.copy(floatingLegPaymentSchedule = newIRS.calculation.floatingLegPaymentSchedule.plus( + Pair(latestReset.key, modifiedLatestResetValue))), + newIRS.common + ) + } + this`fails requirement` "The fix payment has the same currency as the notional" + } + } + } + + + /** + * This returns an example of transactions that are grouped by TradeId and then a fixing applied. + * It's important to make the tradeID different for two reasons, the hashes will be the same and all sorts of confusion will + * result and the grouping won't work either. + * In reality, the only fields that should be in common will be the next fixing date and the reference rate. + */ + fun tradegroups(): TransactionGroupDSL { + val ld1 = LocalDate.of(2016, 3, 8) + val bd1 = BigDecimal("0.0063518") + + val irs = singleIRS() + + val txgroup: TransactionGroupDSL = transactionGroupFor() { + transaction("Agreement") { + output("irs post agreement1") { + irs.copy( + irs.fixedLeg, + irs.floatingLeg, + irs.calculation, + irs.common.copy(tradeID = "t1") + + ) + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + } + + transaction("Agreement") { + output("irs post agreement2") { + irs.copy( + irs.fixedLeg, + irs.floatingLeg, + irs.calculation, + irs.common.copy(tradeID = "t2") + + ) + } + arg(MEGA_CORP_PUBKEY) { InterestRateSwap.Commands.Agree() } + timestamp(TEST_TX_TIME) + } + + transaction("Fix") { + input("irs post agreement1") + input("irs post agreement2") + output("irs post first fixing1") { + "irs post agreement1".output.copy( + "irs post agreement1".output.fixedLeg, + "irs post agreement1".output.floatingLeg, + "irs post agreement1".output.calculation.applyFixing(ld1, FixedRate(RatioUnit(bd1))), + "irs post agreement1".output.common.copy(tradeID = "t1") + ) + } + output("irs post first fixing2") { + "irs post agreement2".output.copy( + "irs post agreement2".output.fixedLeg, + "irs post agreement2".output.floatingLeg, + "irs post agreement2".output.calculation.applyFixing(ld1, FixedRate(RatioUnit(bd1))), + "irs post agreement2".output.common.copy(tradeID = "t2") + ) + } + + arg(ORACLE_PUBKEY) { + InterestRateSwap.Commands.Fix() + } + arg(ORACLE_PUBKEY) { + Fix(FixOf("ICE LIBOR", ld1, Tenor("3M")), bd1) + } + timestamp(TEST_TX_TIME) + } + } + return txgroup + } +} + + diff --git a/src/test/kotlin/core/testutils/TestUtils.kt b/src/test/kotlin/core/testutils/TestUtils.kt index fa5d705dc8..3cead9ae75 100644 --- a/src/test/kotlin/core/testutils/TestUtils.kt +++ b/src/test/kotlin/core/testutils/TestUtils.kt @@ -157,16 +157,16 @@ open class TransactionForTest : AbstractTransactionForTest() { private val inStates = arrayListOf() fun input(s: () -> ContractState) = inStates.add(s()) - protected fun run(time: Instant) { + protected fun runCommandsAndVerify(time: Instant) { val cmds = commandsToAuthenticatedObjects() val tx = TransactionForVerification(inStates, outStates.map { it.state }, emptyList(), cmds, SecureHash.randomSHA256()) tx.verify() } - fun accepts(time: Instant = TEST_TX_TIME) = run(time) + fun accepts(time: Instant = TEST_TX_TIME) = runCommandsAndVerify(time) fun rejects(withMessage: String? = null, time: Instant = TEST_TX_TIME) { val r = try { - run(time) + runCommandsAndVerify(time) false } catch (e: Exception) { val m = e.message @@ -179,7 +179,9 @@ open class TransactionForTest : AbstractTransactionForTest() { if (!r) throw AssertionError("Expected exception but didn't get one") } - // which is uglier?? :) + /** + * Used to confirm that the test, when (implicitly) run against the .verify() method, fails with the text of the message + */ infix fun `fails requirement`(msg: String) = rejects(msg) fun fails_requirement(msg: String) = this.`fails requirement`(msg)