Wednesday, February 12, 2025

Building Curves with QuantLib

   


M 917 536 3378

maksim_kozyarchuk@yahoo.com








Constructing yield curves is a foundational task in quantitative finance. In this article, we assume that you are already familiar with the general principles and best practices of curve construction. Our focus will be on the QuantLib implementation, some useful engineering practices, and building automated tests. In particular, we’ll zoom in on the creation of OIS swap curves.

For those new to QuantLib, I recommend visiting Luigi’s Implementing QuantLib website and browsing his YouTube playlist. While these sources provide a comprehensive introduction, our discussion here will remain narrowly focused on the practical aspects of curve construction.



QuantLib at a High Level

The typical workflow for pricing a security using curves in QuantLib involves four main steps:

  1. Construct the Security:
    QuantLib offers a variety of security classes. For the purpose of this paper, we will focus on OIS swaps, which can be modeled using the OvernightIndexedSwap class or, more conveniently, with the MakeOIS function.

  2. Select an Appropriate Pricing Engine:
    For OIS swaps, the ql.DiscountingSwapEngine is commonly used.

  3. Prepare the Required Inputs:
    The pricing engine requires a yield curve to project forward rates and discount cash flows. In our case, we will create a flat yield curve.  ( Will expand on this in the following sections) 

  4. Link the Pieces Together:
    Finally, we combine the security, pricing engine, and yield curve. The following code extract demonstrates how these components fit together. (For more details on RelinkableYieldTermStructureHandle, please refer to the QuantLib documentation.)

import QuantLib as ql


today = ql.Date(12, ql.February, 2025)

ql.Settings.instance().evaluationDate =  today

yts = ql.RelinkableYieldTermStructureHandle()

swap = ql.MakeOIS(ql.Period("5Y"), ql.Sofr(yts), 0.04)

curve = ql.FlatForward(today, 0.05, ql.Actual360())

yts.linkTo(curve)

swap.setPricingEngine(ql.DiscountingSwapEngine(yts))

print( swap.NPV() )




Building the curve

Yield curves in QuantLib are modeled using the YieldTermStructure interface. There are several concrete implementations of this interface—such as FlatForward, which was demonstrated in the earlier example. When building swap curves, the PiecewiseYieldCurve class (a templated C++ class) is the tool of choice. This class supports multiple interpolation methods and dimensions, and its various configurations are exposed in Python through classes such as:

  • PiecewiseLogCubicDiscount

  • PiecewiseFlatForward

  • PiecewiseCubicZero

  • PiecewiseLogLinearDiscount

The choice of interpolation method significantly impacts the shape of the curve and, by extension, the valuation and risk profile of swaps priced using that curve. By default, the Kupala-Nich application uses PiecewiseLogCubicDiscount. For more details on interpolation methods and dimensions, please refer to the QuantLib documentation.

These curve-building classes offer several overloaded constructors that generally fall into one of two categories:

  1. Explicit Reference Date: The reference date is provided as the first parameter. This method is useful when replicating a specific historical curve.

  2. Settlement Days: The first parameter is the number of settlement days, which relies on the evaluation date set in ql.Settings. This is typically the preferred method for generic use cases.

In the Kupala-Nich application, we rely on the constructor that takes five parameters:

ql.PiecewiseLogCubicDiscount(settlement_days, calendar, helpers, day_count, BOOTSTRAP_PARAMS)

Here:

  • settlement_days, calendar, and day_count follow market conventions for the given curve.

  • helpers encapsulate the market data used for curve calibration.

  • BOOTSTRAP_PARAMS capture settings related to the bootstrapping algorithm’s accuracy and other technical parameters.

The next section will explore how to build these helpers from swap market data


Constructing OIS Swap Curves

To construct an OIS swap curve, you first need reliable market data. In QuantLib, this data is represented as a series of RateHelper objects, which are then passed into the interpolation function. When building an OIS curve from GTR data, the process typically involves:

  1. Obtaining the Market Data:

    • Retrieve the Spot OIS Rate from the relevant publishing body (e.g., NYFed for SOFR).

    • Distill trade data from GTR into a series of quotes, each defined by a rate and tenor.

  2. Creating RateHelper Objects:

    • Use ql.DepositRateHelper for the spot OIS rate.

    • Use ql.OISRateHelper for the OIS swap rates.

A minimal setup for a ql.OISRateHelper requires four parameters:

  • Settlement Days: The number of days to settlement, in line with market conventions.

  • Tenor: The time-to-maturity of the market quote.

  • OIS Rate: The quoted rate for the OIS instrument.

  • Index Object: The overnight index referenced by the swap (this object encapsulates calendars, day count conventions, and roll conventions).

When your market quotes come from standardized sources like Bloomberg, these parameters are typically sufficient. However, bootstrapping from trade data introduces additional considerations:

  • Pillar Dates:
    The PiecewiseYieldCurve classes use pillar dates as fixed nodes for interpolation. These dates are usually determined automatically from the tenor of the helper instruments and must be unique. When building a curve from trade data, you might need to filter or aggregate the quotes to ensure a unique pillar date for each tenor. While it is possible to adjust or create custom pillar dates, this topic goes beyond the scope of this paper.

  • Payment Frequency:
    Although the OIS Index typically defines a standard payment frequency, trade-level quotes may differ. You can pass a custom payment frequency when constructing a ql.OISRateHelper, but be cautious: multiple payment frequencies can complicate the uniqueness of pillar dates.

  • Forward Start:
    OIS swaps that start on IMM dates can be more liquid for certain tenors compared to spot-starting swaps. The forwardStart parameter allows you to incorporate these quotes, again while ensuring that pillar dates remain unique.

  • Overnight Spread:
    If a quote includes an additional spread on top of the OIS rate, you can handle this using the overnightSpread parameter in ql.OISRateHelper.

  • Upfront Fees:
    Many quotes from GTR are provided with upfront fees rather than as OIS rates. Currently, incorporating these upfront fees into the quote typically requires a multistep calibration process. I welcome any suggestions or advice on handling upfront fees more seamlessly.


Engineering Practices and Automated Testing for Curve Construction

When building code that interfaces with QuantLib, it's common to tightly couple your business logic with QuantLib-specific implementations. While this can be effective for rapid prototyping, it often results in code that is difficult to test and maintain over the long term.

A recommended approach is to use dependency injection. By abstracting your business logic away from QuantLib via well-defined interfaces, you can easily swap out QuantLib components for stubs during unit testing. This separation allows you to test your business logic in isolation and then integrate the actual QuantLib implementations for integration and production scenarios. Since much of QuantLib adheres to strict interface contracts, this design pattern also makes it simple to experiment with different interpolation methods or curve models without modifying your core business logic.

With this design in place, your tests naturally fall into several categories:

  • Unit Tests:
    These tests use QuantLib stubs, giving you precise control over the inputs and behavior, and should constitute the majority of your test suite. They validate the correctness of your business logic in isolation.

  • Integration Tests:
    These tests ensure that your code interacts correctly with the actual QuantLib library, rather than just the stubs. They help catch issues arising from the integration between your abstractions and QuantLib's implementations.

  • Smoke Tests:
    Smoke tests perform end-to-end validations, confirming that the overall calibration process and NPV calculations work as expected. These tests may be part of your integration suite or maintained as a separate set.

Additional Considerations for Integration and Smoke Testing

  • Global Settings:
    QuantLib relies on global variables, such as those accessed via ql.Settings.instance(). It’s important to lock down these globals for each test run (especially the evaluationDate) to ensure consistency across tests.

  • Simplicity in Test Setups:
    Integration tests can be complex because they often require detailed calculations to determine expected valuations or discount factors. Where possible, use simple setups that allow you to eyeball results for sanity checks. This approach saves time when debugging test failures that arise from changes in business logic.

  • Platform Differences:
    Different builds of QuantLib across platforms might yield slight variations in calculated values. Therefore, using assertions like assertAlmostEquals is advisable when validating numerical outputs.

  • End-to-End Debugging Tests:
    When troubleshooting real-world valuation issues, it can be invaluable to create end-to-end tests that simulate actual scenarios. However, once the issue is resolved, consider replacing these tests with focused unit tests that target the specific changes in business logic. This keeps your test suite maintainable and efficient.


Sample Code

Below is a simplified code snippet that brings together the concepts discussed:

import QuantLib as ql


BOOTSTRAP_PARAMS = ql.IterativeBootstrap(accuracy=1e-13,

                               minValue=None,

                               maxValue=None,

                               maxAttempts=20,

                               maxFactor=1.5,

                               minFactor=1.5,

                               dontThrow=False,

                               dontThrowSteps=20,

                               maxEvaluations=200)


def init_helpers(curve_data, index, calendar, day_count, settlement_days):

    helpers = ql.RateHelperVector()

    for p in curve_data:

        if p.product == 'SWAP':

            helpers.append(ql.OISRateHelper(settlement_days,              

             ql.Period(p.tenor), ql.QuoteHandle(ql.SimpleQuote(p.rate)), index,

             paymentFrequency=p.frequency, forwardStart=p.forward_start) )

        elif p.product == 'RATE':

            helpers.append(ql.DepositRateHelper(

             ql.QuoteHandle(ql.SimpleQuote(p.rate )), ql.Period(p.tenor),

             settlement_days, calendar, ql.ModifiedFollowing, False, day_count))


def build_curve( valuation_date: ql.Date,

                curve_data,

                interpolation_function = ql.PiecewiseLinearZero,

                calendar = ql.TARGET(),

                day_count = ql.Actual360(),

                index_class = ql.Sofr,

                settlement_days = 2):


    ql.Settings.instance().evaluationDate = valuation_date

    yts = ql.RelinkableYieldTermStructureHandle()

    index = index_class(yts)

    helpers = init_helpers(curve_data, index, calendar, day_count, settlement_days)

    params = [settlement_days, calendar, helpers, day_count, BOOTSTRAP_PARAMS]

    curve = interpolation_function(*params)

    curve.enableExtrapolation()

    yts.linkTo(curve)

    engine = ql.DiscountingSwapEngine(yts)


    return curve, engine


A Few Closing Words

This article only scratches the surface of the complexities involved in implementing effective solutions with QuantLib. One significant challenge is that QuantLib was not designed with serverless computing in mind—it’s optimized for long-lived processes that leverage large in-memory caches. In future articles, I plan to take a deeper dive into this topic and expand the product coverage.

As always, your thoughts and feedback are welcome.









No comments: