From 61efb3b66b2ea6ede80dcf99ed63f5c39122605b Mon Sep 17 00:00:00 2001 From: Zetterberg <119352036+oszette@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:09:56 +0100 Subject: [PATCH] Create `MixedYearOnYearInflationSwapHelper` To be able to bootstrap YoY pillars in a ZC curve, one solution was to create these mixed helpers. --- OREData/ored/marketdata/inflationcurve.cpp | 98 ++++++++++----- QuantExt/qle/CMakeLists.txt | 2 + QuantExt/qle/quantext.hpp | 1 + .../inflation/mixedinflationhelpers.cpp | 118 ++++++++++++++++++ .../inflation/mixedinflationhelpers.hpp | 61 +++++++++ 5 files changed, 249 insertions(+), 31 deletions(-) create mode 100644 QuantExt/qle/termstructures/inflation/mixedinflationhelpers.cpp create mode 100644 QuantExt/qle/termstructures/inflation/mixedinflationhelpers.hpp diff --git a/OREData/ored/marketdata/inflationcurve.cpp b/OREData/ored/marketdata/inflationcurve.cpp index d296e6455e..c2cfd91a71 100644 --- a/OREData/ored/marketdata/inflationcurve.cpp +++ b/OREData/ored/marketdata/inflationcurve.cpp @@ -1,5 +1,6 @@ /* Copyright (C) 2016 Quaternion Risk Management Ltd + Copyright (C) 2026 Skandinaviska Enskilda Banken AB (publ) All rights reserved. This file is part of ORE, a free-software/open-source library @@ -31,8 +32,10 @@ #include #include #include +#include #include #include +#include using namespace QuantLib; using namespace std; @@ -265,7 +268,7 @@ InflationCurve::CurveBuildResults const Handle& nominalTs, const QuantLib::ext::shared_ptr& seasonality) const { CurveBuildResults results; - std::vector> helpers; + std::vector> helpers; // Shall we allow different indices in the segments? // For now we require all segments to use the same index. @@ -293,36 +296,69 @@ InflationCurve::CurveBuildResults << " not found in market data for date " << asof); QL_REQUIRE(md->asofDate() == asof, "MarketDatum asofDate '" << md->asofDate() << "' <> asof '" << asof << "'"); - QL_REQUIRE(md->instrumentType() == MarketDatum::InstrumentType::ZC_INFLATIONSWAP, + QL_REQUIRE(md->instrumentType() == MarketDatum::InstrumentType::ZC_INFLATIONSWAP || + md->instrumentType() == MarketDatum::InstrumentType::YY_INFLATIONSWAP, "MarketDatum " << md << " is not a valid inflation swap quote"); - auto zcq = QuantLib::ext::dynamic_pointer_cast(md); - QL_REQUIRE(zcq, "Could not cast to ZcInflationSwapQuote, internal error."); - CPI::InterpolationType observationInterpolation = convention->interpolated() ? CPI::Linear : CPI::Flat; - Date maturity = swapStart + zcq->term(); - results.latestMaturity = - results.latestMaturity == Date() ? maturity : std::max(results.latestMaturity, maturity); - DLOG("Zero inflation swap " << zcq->name() << " maturity " << maturity << " term " << zcq->term() - << " quote " << zcq->quote()->value()); - auto instrument = QuantLib::ext::make_shared( - zcq->quote(), convention->observationLag(), swapStart, maturity, convention->fixCalendar(), - convention->fixConvention(), convention->dayCounter(), index, observationInterpolation); - - // Unregister with inflation index. See PR #326 on github for details. - instrument->unregisterWithAll(); - instrument->registerWith(zcq->quote()); - - helpers.push_back(instrument); - results.pillarDates.push_back(instrument->pillarDate()); - results.mdQuoteLabels.push_back(md->name()); - results.mdQuoteValues.push_back(md->quote()->value()); - results.rateHelperTypes.push_back("ZeroCouponInflation"); - results.cashflowGenerators.push_back( - std::function()>([instrument, index, asof, nominalTs]() { - return getCashflowReportData( - {instrument->swap()->leg(0), instrument->swap()->leg(1)}, {false, true}, {1.0, 1.0}, - index->currency().code(), {index->currency().code(), index->currency().code()}, asof, - {*nominalTs, *nominalTs}, {1.0, 1.0}, {}, {}, {"Interest", ""}, {1.0E6, 1.0E6}); - })); + + if (md->instrumentType() == MarketDatum::InstrumentType::ZC_INFLATIONSWAP) { + auto zcq = QuantLib::ext::dynamic_pointer_cast(md); + QL_REQUIRE(zcq, "Could not cast to ZcInflationSwapQuote, internal error."); + CPI::InterpolationType observationInterpolation = convention->interpolated() ? CPI::Linear : CPI::Flat; + Date maturity = swapStart + zcq->term(); + results.latestMaturity = + results.latestMaturity == Date() ? maturity : std::max(results.latestMaturity, maturity); + DLOG("Zero inflation swap " << zcq->name() << " maturity " << maturity << " term " << zcq->term() + << " quote " << zcq->quote()->value()); + auto instrument = QuantLib::ext::make_shared( + zcq->quote(), convention->observationLag(), swapStart, maturity, convention->fixCalendar(), + convention->fixConvention(), convention->dayCounter(), index, observationInterpolation); + + // Unregister with inflation index. See PR #326 on github for details. + instrument->unregisterWithAll(); + instrument->registerWith(zcq->quote()); + + helpers.push_back(instrument); + results.pillarDates.push_back(instrument->pillarDate()); + results.mdQuoteLabels.push_back(md->name()); + results.mdQuoteValues.push_back(md->quote()->value()); + results.rateHelperTypes.push_back("ZeroCouponInflation"); + results.cashflowGenerators.push_back( + std::function()>([instrument, index, asof, nominalTs]() { + return getCashflowReportData( + {instrument->swap()->leg(0), instrument->swap()->leg(1)}, {false, true}, {1.0, 1.0}, + index->currency().code(), {index->currency().code(), index->currency().code()}, asof, + {*nominalTs, *nominalTs}, {1.0, 1.0}, {}, {}, {"Interest", ""}, {1.0E6, 1.0E6}); + })); + } else { + auto yyq = QuantLib::ext::dynamic_pointer_cast(md); + QL_REQUIRE(yyq, "Could not cast to YoYInflationSwapQuote, internal error."); + Date maturity = swapStart + yyq->term(); + results.latestMaturity = + results.latestMaturity == Date() ? maturity : std::max(results.latestMaturity, maturity); + auto instrument = QuantLib::ext::make_shared( + yyq->quote(), convention->observationLag(), maturity, convention->fixCalendar(), + convention->fixConvention(), convention->dayCounter(), index, + convention->interpolated() ? CPI::Linear : CPI::Flat); + DLOG("YoY inflation swap " << yyq->name() << " maturity " << maturity << " term " << yyq->term() + << " quote " << yyq->quote()->value()); + + // Unregister with inflation index (and evaluationDate). See PR #326 on github for details. + instrument->unregisterWithAll(); + instrument->registerWith(yyq->quote()); + + helpers.push_back(instrument); + results.pillarDates.push_back(instrument->pillarDate()); + results.mdQuoteLabels.push_back(md->name()); + results.mdQuoteValues.push_back(md->quote()->value()); + results.rateHelperTypes.push_back("MixedYearOnYearInflation"); + results.cashflowGenerators.push_back( + std::function()>([instrument, index, asof, nominalTs]() { + return getCashflowReportData({instrument->swap()->leg(0), instrument->swap()->leg(1)}, + {false, true}, {1.0, 1.0}, index->currency().code(), + {index->currency().code(), index->currency().code()}, asof, + {*nominalTs, *nominalTs}, {1.0, 1.0}, {}, {}); + })); + } } } auto curveObsLag = obsLagFromSegment != 0 * Days ? obsLagFromSegment : config->lag(); @@ -362,7 +398,7 @@ InflationCurve::CurveBuildResults const QuantLib::ext::shared_ptr& seasonality, bool deriveFromZC, const QuantLib::ext::shared_ptr& zcCurve) const { CurveBuildResults results; - std::vector> helpers; + std::vector> helpers; // Shall we allow different indices in the segments? // For now we require all segments to use the same index. diff --git a/QuantExt/qle/CMakeLists.txt b/QuantExt/qle/CMakeLists.txt index 076b559956..58695ed06a 100644 --- a/QuantExt/qle/CMakeLists.txt +++ b/QuantExt/qle/CMakeLists.txt @@ -394,6 +394,7 @@ termstructures/iborfallbackcurve.cpp termstructures/immfraratehelper.cpp termstructures/inflation/constantcpivolatility.cpp termstructures/inflation/cpicurve.cpp +termstructures/inflation/mixedinflationhelpers.cpp termstructures/inflation/cpivolatilitystructure.cpp termstructures/interpolateddiscountcurve2.cpp termstructures/oiscapfloorhelper.cpp @@ -994,6 +995,7 @@ termstructures/inflation/cpipricevolatilitysurface.hpp termstructures/inflation/cpivolatilitystructure.hpp termstructures/inflation/inflationtraits.hpp termstructures/inflation/interpolatedcpiinflationcurve.hpp +termstructures/inflation/mixedinflationhelpers.hpp termstructures/inflation/piecewisecpiinflationcurve.hpp termstructures/interpolatedcorrelationcurve.hpp termstructures/interpolatedcpivolatilitysurface.hpp diff --git a/QuantExt/qle/quantext.hpp b/QuantExt/qle/quantext.hpp index 31d6d496c2..4a2a8b9c25 100644 --- a/QuantExt/qle/quantext.hpp +++ b/QuantExt/qle/quantext.hpp @@ -549,6 +549,7 @@ #include #include #include +#include #include #include #include diff --git a/QuantExt/qle/termstructures/inflation/mixedinflationhelpers.cpp b/QuantExt/qle/termstructures/inflation/mixedinflationhelpers.cpp new file mode 100644 index 0000000000..8e71da1d8b --- /dev/null +++ b/QuantExt/qle/termstructures/inflation/mixedinflationhelpers.cpp @@ -0,0 +1,118 @@ +/* + Copyright (C) 2026 Skandinaviska Enskilda Banken AB (publ) + All rights reserved. + + This file is part of ORE, a free-software/open-source library + for transparent pricing and risk analysis - http://opensourcerisk.org + + ORE is free software: you can redistribute it and/or modify it + under the terms of the Modified BSD License. You should have received a + copy of the license along with this program. + The license is also available online at + + This program is distributed on the basis that it will form a useful + contribution to risk analytics and model standardisation, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the license for more details. +*/ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QuantExt { + +using namespace QuantLib; + +MixedYearOnYearInflationSwapHelper::MixedYearOnYearInflationSwapHelper( + const Handle& quote, const Period& swapObsLag, const Date& maturity, Calendar calendar, + BusinessDayConvention paymentConvention, DayCounter dayCounter, const ext::shared_ptr& zii, + CPI::InterpolationType interpolation) + : RelativeDateBootstrapHelper(quote, false), swapObsLag_(swapObsLag), + maturity_(maturity), calendar_(std::move(calendar)), paymentConvention_(paymentConvention), + dayCounter_(std::move(dayCounter)), interpolation_(interpolation), + nominalTermStructure_(std::move( // any nominal term structure will give the same result; + // when calculating the fair rate, the equal discount factors + // for the payments on the two legs will cancel out. + Handle(ext::make_shared(0, NullCalendar(), 0.0, Actual365Fixed())))) { + + auto ziiClone = zii->clone(termStructureHandle_); + ziiClone->unregisterWith(termStructureHandle_); + + //! Cannot use wrapper in YoYInflationIndex because the forecastFixing doesn't use the underlyingIndex, whereas + //! the YoYInflationIndexWrapper correctly calculates the past (f0) and future cpi (f1) values through the logic + //! in ZeroInflationIndex. + yii_ = ext::make_shared(ziiClone); + + if (detail::CPI::isInterpolated(interpolation_, yii_)) { + Period pShift(yii_->frequency()); + QL_REQUIRE(swapObsLag_ - pShift >= zii->availabilityLag(), + "inconsistency between swap observation lag " + << swapObsLag_ << ", index period " << pShift << " and index availability " + << zii->availabilityLag() << ": need (obsLag-index period) >= availLag"); + } + + registerWith(yii_); + registerWith(nominalTermStructure_); + MixedYearOnYearInflationSwapHelper::initializeDates(); +} + +Real MixedYearOnYearInflationSwapHelper::impliedQuote() const { + yyiis_->deepUpdate(); + return yyiis_->fairRate(); +} + +void MixedYearOnYearInflationSwapHelper::initializeDates() { + //! The start date of the swap is calculated by advance back one year from maturity date. + //! The latest date is then calculated by advancing one year from start. + Date startDate = calendar_.advance(maturity_, Period(-1, Years), paymentConvention_); + Date endDate = calendar_.advance(startDate, Period(1, Years), paymentConvention_); + Schedule schedule = + Schedule({startDate, endDate}); // the schedule is simple enough and they are the same for both legs + + yyiis_ = ext::make_shared( + Swap::Payer, 1000000.0, schedule, quote().empty() || !quote()->isValid() ? 0.0 : quote()->value(), dayCounter_, + schedule, yii_, swapObsLag_, interpolation_, 0.0, dayCounter_, calendar_, paymentConvention_); + //! The swap schedule defines cashflow timing and payment dates. For bootstrapping, the dates need to take the + //! observation lag into account to be inline with the logic of ZeroCouponInflationSwapHelper. + auto fixingPeriod = inflationPeriod(maturity_ - swapObsLag_, yii_->frequency()); + earliestDate_ = latestDate_ = fixingPeriod.first; + yyiis_->setPricingEngine(ext::make_shared(nominalTermStructure_)); +} + +void MixedYearOnYearInflationSwapHelper::setTermStructure(ZeroInflationTermStructure* z) { + bool observer = false; + + ext::shared_ptr temp(z, null_deleter()); + termStructureHandle_.linkTo(std::move(temp), observer); + + RelativeDateBootstrapHelper::setTermStructure(z); +} + +} // namespace QuantExt diff --git a/QuantExt/qle/termstructures/inflation/mixedinflationhelpers.hpp b/QuantExt/qle/termstructures/inflation/mixedinflationhelpers.hpp new file mode 100644 index 0000000000..79a6cd4d61 --- /dev/null +++ b/QuantExt/qle/termstructures/inflation/mixedinflationhelpers.hpp @@ -0,0 +1,61 @@ +/* + Copyright (C) 2026 Skandinaviska Enskilda Banken AB (publ) + All rights reserved. + + This file is part of ORE, a free-software/open-source library + for transparent pricing and risk analysis - http://opensourcerisk.org + + ORE is free software: you can redistribute it and/or modify it + under the terms of the Modified BSD License. You should have received a + copy of the license along with this program. + The license is also available online at + + This program is distributed on the basis that it will form a useful + contribution to risk analytics and model standardisation, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the license for more details. +*/ + +#pragma once + +#include +#include +#include + +namespace QuantExt { + +//! Year-on-year inflation-swap helper using a zero inflation curve as bootstrap target. +class MixedYearOnYearInflationSwapHelper + : public QuantLib::RelativeDateBootstrapHelper { +public: + MixedYearOnYearInflationSwapHelper(const QuantLib::Handle& quote, + const QuantLib::Period& swapObsLag, + const QuantLib::Date& maturity, + QuantLib::Calendar calendar, + QuantLib::BusinessDayConvention paymentConvention, + QuantLib::DayCounter dayCounter, + const QuantLib::ext::shared_ptr& zii, + QuantLib::CPI::InterpolationType interpolation); + + void setTermStructure(QuantLib::ZeroInflationTermStructure*) override; + QuantLib::Real impliedQuote() const override; + + QuantLib::ext::shared_ptr swap() const { return yyiis_; } + +protected: + void initializeDates() override; + +private: + QuantLib::Period swapObsLag_; + QuantLib::Date maturity_; + QuantLib::Calendar calendar_; + QuantLib::BusinessDayConvention paymentConvention_; + QuantLib::DayCounter dayCounter_; + QuantLib::ext::shared_ptr yii_; + QuantLib::CPI::InterpolationType interpolation_; + QuantLib::ext::shared_ptr yyiis_; + QuantLib::Handle nominalTermStructure_; + QuantLib::RelinkableHandle termStructureHandle_; +}; + +} // namespace QuantExt