Skip to content

Feature: Fast Valuation of Seasoned OIS Swaps #2213

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions LICENSE.TXT
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ QuantLib is
Copyright (C) 2024 Jacques du Toit
Copyright (C) 2024 Jongbong An

Copyright (C) 2025 Paolo D'Elia

QuantLib includes code taken from Peter Jäckel's book "Monte Carlo
Methods in Finance".

Expand Down
125 changes: 79 additions & 46 deletions ql/cashflows/overnightindexedcouponpricer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ namespace QuantLib {
// always one less than the number of interest dates.
return n == interestDates.size() && applyObservationShift ? n - 1 : n;
}

void updateCompoundFactor(Real& compoundFactor, const ext::shared_ptr<OvernightIndex> index, Size position,
const std::vector<Date>& fixingDates, const std::vector<Date>& interestDates, const std::vector<Time>& dt,
const Date& date) {
const Rate fixing = index->fixing(fixingDates[position]);
Time span = (date >= interestDates[position + 1] ?
dt[position] :
index->dayCounter().yearFraction(interestDates[position], date));
compoundFactor *= (1.0 + fixing * span);
}
}

void CompoundingOvernightIndexedCouponPricer::initialize(const FloatingRateCoupon& coupon) {
Expand All @@ -51,47 +61,47 @@ namespace QuantLib {
return averageRate(coupon_->accrualEndDate());
}

Rate CompoundingOvernightIndexedCouponPricer::averageRate(const Date& date) const {
const Date today = Settings::instance().evaluationDate();

const ext::shared_ptr<OvernightIndex> index =
ext::dynamic_pointer_cast<OvernightIndex>(coupon_->index());
const auto& pastFixings = index->timeSeries();

const auto& fixingDates = coupon_->fixingDates();
const auto& valueDates = coupon_->valueDates();
const auto& interestDates = coupon_->interestDates();
const auto& dt = coupon_->dt();
const bool applyObservationShift = coupon_->applyObservationShift();

Size i = 0;
const Size n = determineNumberOfFixings(interestDates, date, applyObservationShift);

Real compoundFactor = 1.0;

// already fixed part
while (i < n && fixingDates[i] < today) {
// rate must have been fixed
const Rate fixing = pastFixings[fixingDates[i]];
QL_REQUIRE(fixing != Null<Real>(),
"Missing " << index->name() << " fixing for " << fixingDates[i]);
Time span = (date >= interestDates[i + 1] ?
dt[i] :
index->dayCounter().yearFraction(interestDates[i], date));
compoundFactor *= (1.0 + fixing * span);
++i;
void CompoundingOvernightIndexedCouponPricer::handlePastFixings(Size& i, const Size n, Real& compoundFactor,
const ext::shared_ptr<OvernightIndex> index, const Dates& fixingDates,
const Dates& valueDates, const Dates& interestDates, const Times& dt,
const Date& today, const Date& date, const bool applyObservationShift) const {
Calendar fixingCalendar = index->fixingCalendar();
Size deltaDays = fixingCalendar.businessDaysBetween(fixingDates[0], today);
Date lastPastFixingDate = n < deltaDays
? fixingCalendar.advance(fixingDates[0], Period(n, Days)) : fixingCalendar.advance(today, Period(-1, Days));
Real indexCompoundedFactor = index->compoundedFactor(fixingDates[0], lastPastFixingDate);

auto calculatePastCompoundFactor = [&](){
while (i < n && fixingDates[i] < today) {
// rate must have been fixed
updateCompoundFactor(compoundFactor, index, i, fixingDates, interestDates, dt, date);
++i;
}
};

if (indexCompoundedFactor == Null<Real>()
|| applyObservationShift
|| coupon_->lockoutDays() != Null<Natural>()) {
calculatePastCompoundFactor();
} else {
compoundFactor = indexCompoundedFactor;
i = fixingCalendar.businessDaysBetween(fixingDates[0],
lastPastFixingDate,
true, true);
}
}

void CompoundingOvernightIndexedCouponPricer::handleTodayFixing(Size& i,
Size n, Real& compoundFactor, const ext::shared_ptr<OvernightIndex> index,
const Dates& fixingDates, const Dates& interestDates, const Times& dt,
const Date& today, const Date& date) const {
// today is a border case
if (i < n && fixingDates[i] == today) {
// might have been fixed
try {
Rate fixing = pastFixings[fixingDates[i]];
Rate fixing = index->fixing(fixingDates[i]);
if (fixing != Null<Real>()) {
Time span = (date >= interestDates[i + 1] ?
dt[i] :
index->dayCounter().yearFraction(interestDates[i], date));
compoundFactor *= (1.0 + fixing * span);
updateCompoundFactor(compoundFactor, index, i, fixingDates, interestDates, dt, date);
++i;
} else {
; // fall through and forecast
Expand All @@ -100,7 +110,13 @@ namespace QuantLib {
; // fall through and forecast
}
}
}

void CompoundingOvernightIndexedCouponPricer::handleFutureFixings(Size& i, Size n,
Real& compoundFactor, const ext::shared_ptr<OvernightIndex> index,
const Dates& valueDates, const Dates& fixingDates,
const Dates& interestDates, const Times& dt, const Date& today,
const Date& date) const {
// forward part using telescopic property in order
// to avoid the evaluation of multiple forward fixings
// where possible.
Expand All @@ -109,15 +125,6 @@ namespace QuantLib {
QL_REQUIRE(!curve.empty(),
"null term structure set to this instance of " << index->name());

const auto effectiveRate = [&index, &fixingDates, &date, &interestDates,
&dt](Size position) {
Rate fixing = index->fixing(fixingDates[position]);
Time span = (date >= interestDates[position + 1] ?
dt[position] :
index->dayCounter().yearFraction(interestDates[position], date));
return span * fixing;
};

if (!coupon_->canApplyTelescopicFormula()) {
// With lookback applied, the telescopic formula cannot be used,
// we need to project each fixing in the coupon.
Expand All @@ -130,7 +137,7 @@ namespace QuantLib {
// Same applies to a case when accrual calculation date does or
// does not occur on an interest date.
while (i < n) {
compoundFactor *= (1.0 + effectiveRate(i));
updateCompoundFactor(compoundFactor, index, i, fixingDates, interestDates, dt, date);
++i;
}
} else {
Expand All @@ -157,7 +164,7 @@ namespace QuantLib {

// With no lockout, the loop is skipped because i = n.
while (i < n) {
compoundFactor *= (1.0 + effectiveRate(i));
updateCompoundFactor(compoundFactor, index, i, fixingDates, interestDates, dt, date);
++i;
}
} else {
Expand All @@ -167,10 +174,36 @@ namespace QuantLib {
// previous date, then we'll add the missing bit.
const DiscountFactor endDiscount = curve->discount(valueDates[n - 1]);
compoundFactor *= startDiscount / endDiscount;
compoundFactor *= (1.0 + effectiveRate(n - 1));
updateCompoundFactor(compoundFactor, index, n - 1, fixingDates, interestDates, dt, date);
}
}
}
}

Rate CompoundingOvernightIndexedCouponPricer::averageRate(const Date& date) const {
const Date today = Settings::instance().evaluationDate();

const ext::shared_ptr<OvernightIndex> index =
ext::dynamic_pointer_cast<OvernightIndex>(coupon_->index());

const auto& fixingDates = coupon_->fixingDates();
const auto& valueDates = coupon_->valueDates();
const auto& interestDates = coupon_->interestDates();
const auto& dt = coupon_->dt();
const bool applyObservationShift = coupon_->applyObservationShift();

Size i = 0;
const Size n = determineNumberOfFixings(interestDates, date, applyObservationShift);
Real compoundFactor = 1.0;

handlePastFixings(i, n, compoundFactor, index, fixingDates, valueDates,
interestDates, dt, today, date, applyObservationShift);

handleTodayFixing(i, n, compoundFactor, index, fixingDates, interestDates,
dt, today, date);

handleFutureFixings(i, n, compoundFactor, index, valueDates, fixingDates,
interestDates, dt, today, date);

const Rate rate = (compoundFactor - 1.0) / coupon_->accruedPeriod(date);
return coupon_->gearing() * rate + coupon_->spread();
Expand Down
15 changes: 15 additions & 0 deletions ql/cashflows/overnightindexedcouponpricer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ namespace QuantLib {

//! CompoudAveragedOvernightIndexedCouponPricer pricer
class CompoundingOvernightIndexedCouponPricer : public FloatingRateCouponPricer {
using Dates = std::vector<Date>;
using Times = std::vector<Time>;
public:
//! \name FloatingRateCoupon interface
//@{
Expand All @@ -52,6 +54,19 @@ namespace QuantLib {

protected:
const OvernightIndexedCoupon* coupon_ = nullptr;

private:
void handlePastFixings(Size& i, const Size n, Real& compoundFactor, const ext::shared_ptr<OvernightIndex> index,
const Dates& fixingDates, const Dates& valueDates, const Dates& interestDates,
const Times& dt, const Date& today, const Date& date, const bool applyObservationShift) const;
void handleTodayFixing(Size& i, Size n, Real& compoundFactor,
const ext::shared_ptr<OvernightIndex> index, const Dates& fixingDates,
const Dates& interestDates, const Times& dt, const Date& today,
const Date& date) const;
void handleFutureFixings(Size& i, Size n, Real& compoundFactor,
const ext::shared_ptr<OvernightIndex> index, const Dates& fixingDates,
const Dates& valueDates, const Dates& interestDates, const Times& dt,
const Date& today, const Date& date) const;
};

/*! pricer for arithmetically averaged overnight indexed coupons
Expand Down
4 changes: 2 additions & 2 deletions ql/index.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ namespace QuantLib {
possible inconsistencies due to "seeing in the
future"
*/
class Index : public Observable, public Observer {
class Index : virtual public Observable, virtual public Observer {
public:
~Index() override = default;
//! Returns the name of the index.
Expand Down Expand Up @@ -91,7 +91,7 @@ namespace QuantLib {
/*! the dates in the TimeSeries must be the actual calendar
dates of the fixings; no settlement days must be used.
*/
void addFixings(const TimeSeries<Real>& t, bool forceOverwrite = false);
virtual void addFixings(const TimeSeries<Real>& t, bool forceOverwrite = false);
//! stores historical fixings at the given dates
/*! the dates passed as arguments must be the actual calendar
dates of the fixings; no settlement days must be used.
Expand Down
98 changes: 88 additions & 10 deletions ql/indexes/iborindex.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

#include <ql/indexes/iborindex.hpp>
#include <ql/termstructures/yieldtermstructure.hpp>
#include <ql/time/schedule.hpp>

Check notice on line 24 in ql/indexes/iborindex.cpp

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

ql/indexes/iborindex.cpp#L24

Include file: <ql/time/schedule.hpp> not found. Please note: Cppcheck does not need standard library headers to get proper results.
#include <utility>

namespace QuantLib {
Expand Down Expand Up @@ -60,16 +61,15 @@

ext::shared_ptr<IborIndex> IborIndex::clone(
const Handle<YieldTermStructure>& h) const {
return ext::make_shared<IborIndex>(
familyName(),
tenor(),
fixingDays(),
currency(),
fixingCalendar(),
businessDayConvention(),
endOfMonth(),
dayCounter(),
h);
return ext::make_shared<IborIndex>(familyName(),
tenor(),
fixingDays(),
currency(),
fixingCalendar(),
businessDayConvention(),
endOfMonth(),
dayCounter(),
h);
}


Expand All @@ -82,6 +82,84 @@
: IborIndex(familyName, 1*Days, settlementDays, curr,
fixCal, Following, false, dc, h) {}

void OvernightIndex::addFixing(const Date& fixingDate, Real fixing, bool forceOverwrite) {
calculated_ = false;
Index::addFixing(fixingDate, fixing, forceOverwrite);
}

void OvernightIndex::addFixings(const TimeSeries<Real>& t, bool forceOverwrite) {
calculated_ = false;
Index::addFixings(t, forceOverwrite);
}

Rate OvernightIndex::compoundedFixings(const Date& fromFixingDate, const Date& toFixingDate) {
calculate();
auto yearFraction = dayCounter_.yearFraction(fromFixingDate, toFixingDate);
auto compIndexEnd = compoundIndex_[toFixingDate];
auto compIndexStart = compoundIndex_[fromFixingDate];

if (compIndexStart == Null<Real>() || compIndexEnd == Null<Real>())
return Null<Real>();

return (compoundIndex_[toFixingDate] / compoundIndex_[fromFixingDate] - 1) / yearFraction;
}
Comment on lines +95 to +105
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this belongs into the index. It should be in the coupon or the coupon pricer—especially because the way the composition is done doesn't depend only on the start and end date, but also on the lookback days and whether or not one must apply the observation shift, which are parameters of the coupon.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, the compounded index should be linked to the Overnight Index itself, unless we are using the same CompoundedOvernightIndexPricer within the same Leg. I don’t see any performance improvements from having a different instance of the CompoundedOvernightIndexPricer for each coupon, especially if each has an associated compoundedIndex attribute; each pricer is going to recalculate those compounded fixings in the past, not leveraging the compounded index.

Aren't the lookback days and the observation shift logics that concern the Coupon itself? I feel like they shouldn't concern the compounded index. I don't really see why the compounded index should have taken into account that logic, since we could have coupons on the same Overnight index with different lookback days, and so on.

Another practical way to store the compoundedIndex could be by utilizing a sort of IndexManager, similar to what you have implemented with the standard fixings.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lookback days and the observation shift concern the Coupon, and they have an effect on the compounding. As an example, let's consider a coupon starting today, Monday May 12th, with lookback days = 2, so we have for instance these initial compounding dates:

date fixing date
Monday May 12th Thursday May 8th
Tuesday May 13th Friday May 9th
Wednesday May 14th Monday May 12th
Thursday May 15th Tuesday May 13th
Friday May 16th Wednesday May 14th
Monday May 19th Thursday May 15th

If the observation shift is set to false, the coupon dates in the first column determine the compounding days; so for instance, the index fixing for Friday May 9th is compounded for 1 day (May 13th to May 14th) and the fixing for Wednesday May 14th is compounded for 3 days (May 16th to May 19th). If the observation shift is set to true, it's the fixing dates in the second column that determine the compounding days, so the index fixing for Friday May 9th is compounded for 3 days and the fixing for Wednesday May 14th is compounded for 1 day. A different number of lookback days would give you other results.

The index alone can't know this. It can only give you single fixings. It's the coupon that should manage this calculation.

--

About having different instances of the coupon pricer: yes, the cache should be shared between pricers, like the IndexManager.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you're saying, and I agree that it’s ultimately the coupon that should handle the compounding, not the compounded index itself. The compounded index can give us the individual fixings (since that’s what it essentially stores), but the logic for compounding over a period is typically managed within the coupon itself.

How would you suggest to tackle this problem then? I was thinking to resister and additional index in in the IndexManager (with the name "<Overnight_index> Comp"), and store the compounded index there since now if I feel like it's the natural place to store it. But again the problems will come from the fact that we might have non-contiguous fixing (calendar-wise I mean), so we could have the comp index for a period (like from the 1st of Janurary to the 31st of Janurary) but then let's say we are missing some fixings (first week of February) and we have the fixing after those. How should we deal with that situation?
My idea is to not store anything and then fallback on normal fixing (stored in the normal index).

Example of CompIndex in IndexManager (assuming that the available fixings start from the 1st of January 2025)
Name: "Estr Comp"

Date CompIndexValue
02-01-2025 1.0000
03-01-2025 1.000081
06-01-2025 1.000324
07-01-2025 1.000405
08-01-2025 1.000487
09-01-2025 1.000568
10-01-2025 ...

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there are fixings for a given week, we should also have the compounded fixings, shouldn't we? In what case do you think we might end up having holes in the compounded series?

I'm not sure if IndexManager is the correct place, because there's not just one compounded series for a given index. The compounded series for 2 lookback days and observation shift is different from the compounded series for 2 lookback days and no observation shift, which is also different from the compounded series for 3 lookback days and no observation shift. You could try to put all of that into a tag, but you might also think about having a dedicated class to store them—I'm not sure.


Real OvernightIndex::compoundedFactor(const Date& fromFixingDate, const Date& toFixingDate) {
calculate();
auto compIndexEnd = compoundIndex_[toFixingDate];
auto compIndexStart = compoundIndex_[fromFixingDate];

if (compIndexStart == Null<Real>() || compIndexEnd == Null<Real>())
return Null<Real>();

return compIndexEnd / compIndexStart;
}

void OvernightIndex::performCalculations() const {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure LazyObject is the right mechanism for this. This calculation will be executed again and again every time the index (or the pricer, if you move the calculation there) receives a notification; for instance, when the forecast curve changes, even though it has no effect on the stored past prices on which this calculation depends.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will the calculation be triggered also when the forecast curve changes as well? Not just when I call the addFixing or addFixings? It's there where I set calculated_ = false.

Copy link
Owner

@lballabio lballabio May 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see, because you've removed the part in update where the lazy object sets calculated_ to false. Hmm, yes, it would possibly work, but I'm not sure if I'm comfortable with changing the way the lazy object works. Also, you might add a fixing through a different instance of the index, and this instance would still have calculated_ set to false and ignore the new fixing.

auto getLastFixingDate = [](Calendar fixingCalendar, std::vector<Date>& fixingDates){
Date lastFixingDay = fixingDates.front();
std::for_each(fixingDates.begin() + 1, fixingDates.end(), [&](Date& fixingDate){
if (fixingCalendar.advance(lastFixingDay, Period(1, Days)) == fixingDate)
lastFixingDay = fixingDate;
});
return lastFixingDay;
};

const auto& ts = Index::timeSeries();
auto fixingDates = ts.dates();
if (fixingDates.empty()) {
compoundIndex_ = TimeSeries<Real>();
return;
}

auto fixingCalendar = InterestRateIndex::fixingCalendar();
auto lastFixingDate = getLastFixingDate(fixingCalendar, fixingDates);
Schedule schedule = MakeSchedule()
.from(fixingDates.front())
.to(lastFixingDate)
Comment on lines +138 to +139
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using performCalculations(), which has no arguments, forced you to calculate compounded values for the whole history, but that might be unnecessary. If possible, I'd calculate the series of compounded values only on the requested range, extending it as needed with each call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's inefficient. Ideally, assuming that one should add fixings from the last fixing day that we have in history in the compounded index, the optimization that can be put in place could be to compute the compounded index from the last past fixing date to the new past date of the next fixing.

.withTenor(Period(1, Days))
.withCalendar(fixingCalendar)
.withConvention(Following)
.withTerminationDateConvention(Following);
std::vector<Real> compIndexValues(schedule.size());
Real lastCompIndexValue = 1.000;
compIndexValues[0] = lastCompIndexValue;

std::transform(schedule.begin(), schedule.end() - 1, schedule.begin() + 1, compIndexValues.begin() + 1,
[&](const Date& d1, const Date& d2) {
lastCompIndexValue *= (1 + ts[d1] * dayCounter_.yearFraction(d1, d2));
return lastCompIndexValue;
});

compoundIndex_ = TimeSeries<Real>(schedule.begin(),
schedule.end(),
compIndexValues.begin());
}

void OvernightIndex::update() {
notifyObservers();
}

ext::shared_ptr<IborIndex> OvernightIndex::clone(
const Handle<YieldTermStructure>& h) const {
return ext::shared_ptr<IborIndex>(
Expand Down
Loading
Loading