Skip to content
All library documents

Backing Out CDS Spreads from Market Prices with QuantLib

Article Quant Q&A · Author: always_confused

Summary

The document describes an attempt to infer credit default swap spreads from end-of-day prices using QuantLib. The workflow builds currency-specific interest rate curves from swap data, derives a flat hazard rate from each quoted price and tenor, creates a quarterly CDS schedule, and asks QuantLib for the contract’s fair spread. It includes sample interest rate inputs and a sample output where a calculated spread is NaN despite no reported exception.

The example highlights a key modeling issue: the hazard-rate calculation treats the price as if it directly represented survival probability, then uses that rate to price the CDS. That shortcut may not match how the quoted CDS price was constructed, especially when coupon, upfront amount, contract dates, and quote conventions matter. The document does not identify a verified fix, and the displayed data is incomplete, so its code and example should be treated as a debugging question rather than a validated pricing method.

Key ideas

  • The example builds currency-specific discount curves from interest rate quotes before pricing CDS contracts.
  • It infers a flat hazard rate directly from the quoted CDS price and tenor.
  • The sample calculation returns NaN for a CDS fair spread without raising an exception.
  • Price conventions, coupons, upfront payments, dates, and contract terms can affect whether the pricing setup matches the market quote.
  • The document presents a debugging problem but does not establish a working correction.

Tags

Full text
# Calculate CDS spread from EOD price using Quantlib


# Calculate CDS spread from EOD price using Quantlib












On the ICE website it provides EOD CDS prices:

https://www.ice.com/cds-settlement-prices/icc/index-instruments

This is great, as it can be difficult to find CDS data, however CDS are typically quoted using a spread notation, rather than price (with some exceptions of course), therefore I need to back out the spread from the price.

I have tried using Quantlib to back out the spread, however it either returns an incredibly high value (which looks implausible) or a NaN.

Here are my functions (the indents are correct, not sure why the last section appears not to indent):

```
def get_calendar_for_currency(currency):
"""Return the appropriate QuantLib calendar based on the currency code."""
if currency == "USD":
    return ql.UnitedStates(ql.UnitedStates.GovernmentBond)
elif currency == "EUR":
    return ql.TARGET()  # TARGET calendar is often used for EUR
elif currency == "GBP":
    return ql.UnitedKingdom()
elif currency == "SEK":
    return ql.Sweden()
else:
    print(f"Unsupported currency: {currency}")
    return None

def get_interest_rate_curves(currencies, connection_string=CONNECTION_STRING):
"""Generate a dictionary of interest rate curves for each currency."""
engine = create_engine(connection_string)
interest_rate_curves = {}

for currency in currencies:
    # Query to fetch the latest timestamp for the specified currency
    latest_timestamp_query = text(f"""
        SELECT MAX(timestamp) AS latest_timestamp
        FROM fm.tbl_irs_prices
        WHERE ccy = :currency
    """)

    with engine.connect() as connection:
        result = connection.execute(latest_timestamp_query, {"currency": currency})
        latest_timestamp = result.scalar()

    # Query the rates using the latest timestamp
    rates_query = text("""
        SELECT tenor, price
        FROM fm.tbl_irs_prices
        WHERE ccy = :currency AND timestamp = :latest_timestamp
    """)

    with engine.connect() as connection:
        rates_df = pd.read_sql(rates_query, connection, params={"currency": currency, "latest_timestamp": latest_timestamp})

    # Parse tenors and rates
    tenors = [ql.Period(int(t.split()[0]), ql.Years) for t in rates_df['tenor']]
    rates = [r / 100.0 for r in rates_df['price']]  # Convert rates to decimal

    calendar = get_calendar_for_currency(currency)

    # Define other parameters for rate helpers
    convention = ql.ModifiedFollowing
    end_of_month = False
    day_count = ql.Actual360()

    # Construct rate helpers for the interest rate curve
    todays_date = ql.Date().todaysDate()
    rate_helpers = [
        ql.DepositRateHelper(
            ql.QuoteHandle(ql.SimpleQuote(rate)),
            tenor,           # Use tenor directly as a Period
            2,               # Fixing days
            calendar,
            convention,
            end_of_month,
            day_count
        ) for rate, tenor in zip(rates, tenors)
    ]

    yield_curve = ql.PiecewiseFlatForward(todays_date, rate_helpers, ql.Actual365Fixed())
    interest_rate_curves[currency] = ql.YieldTermStructureHandle(yield_curve)
    logging.info(f'{currency} swap curve created from {min(tenors)} to {max(tenors)}.')

return interest_rate_curves

def calculate_spreads_for_dataframe(df, currencies=['EUR', 'USD'], contract_terms=CONTRACT_TERMS, connection_string=CONNECTION_STRING):
# Fetch interest rate curves for all currencies
interest_rate_curves = get_interest_rate_curves(currencies, connection_string)

# Set QuantLib evaluation date
ql.Settings.instance().evaluationDate = ql.Date().todaysDate()

def calculate_cds_spread_for_row(row, interest_rate_curves=interest_rate_curves, contract_terms=contract_terms):
    """
    Calculate the CDS spread for a given row of data, with detailed debugging information.

    Parameters:
        row (pd.Series): The row of CDS data.
        interest_rate_curves (dict): A dictionary of interest rate curves by currency.
        contract_terms (list): A list of contract term dictionaries.

    Returns:
        float: The calculated CDS spread in basis points or None if any error occurs.
    """
    # Extract row details
    price = row['eod_price']
    tenor = row['tenor']
    contract_currency = row['ccy']

    # Display row details if tenor is invalid
    if not isinstance(tenor, str) or len(tenor) < 2:
        print(f"Invalid tenor format for row: {row}")
        return None

    # Determine calendar based on currency
    calendar = get_calendar_for_currency(contract_currency)
    if not calendar:
        print(f"Unsupported currency: {contract_currency} for row: {row}")
        return None

    # Get the interest rate curve for the contract currency
    interest_rate_curve = interest_rate_curves.get(contract_currency)
    if interest_rate_curve is None:
        print(f"No interest rate curve found for currency {contract_currency} in row: {row}")
        return None

    # Look up the contract terms
    contract = next(
        (item for item in contract_terms if item.get("ccy") == contract_currency), None
    )
    if not contract:
        print(f"No contract terms found for currency {contract_currency} in row: {row}")
        return None

    # Retrieve necessary contract data
    recovery_rate = contract["recovery_rate"]
    coupon_rate = contract["coupon"] / 10000

    # Calculate the implied hazard rate without limiting the price range
    try:
        implied_hazard_rate = -np.log(1 - price / 100) / ql.Actual365Fixed().yearFraction(
            ql.Settings.instance().evaluationDate,
            ql.Settings.instance().evaluationDate + ql.Period(tenor)
        )
    except Exception as e:
        print(f"Error calculating hazard rate for row {row}: {e}")
        return None

    # Debug: Output calculated hazard rate
    print(f"Implied hazard rate for row {row}: {implied_hazard_rate}")

    # Create the CDS schedule
    cds_schedule = ql.Schedule(
        ql.Settings.instance().evaluationDate,
        ql.Settings.instance().evaluationDate + ql.Period(tenor),
        ql.Period("3M"),
        calendar,
        ql.Following,
        ql.Following,
        ql.DateGeneration.Forward,
        False
    )

    # Define the probability curve using the implied hazard rate
    probability_curve = ql.FlatHazardRate(
        0, ql.NullCalendar(), ql.QuoteHandle(ql.SimpleQuote(implied_hazard_rate)), ql.Actual365Fixed()
    )
    probability_curve_handle = ql.DefaultProbabilityTermStructureHandle(probability_curve)

    # Define the CDS contract
    cds = ql.CreditDefaultSwap(
        ql.Protection.Seller,
        1000000,  # Notional
        coupon_rate,
        cds_schedule,
        ql.Following,
        ql.Actual360()
    )

    # Use Actual/365Fixed day count for the interest rate curve in the engine
    engine = ql.IsdaCdsEngine(probability_curve_handle, recovery_rate, interest_rate_curve)
    cds.setPricingEngine(engine)

    # Calculate the fair spread
    try:
        spread = cds.fairSpread()
        print(f"Calculated spread for row {row}: {spread}")
    except Exception as e:
        print(f"Error calculating spread for row {row}: {e}")
        return None

    return spread * 10000  # Convert to basis points

# Apply the CDS spread calculation to each row
df['calculated_spread'] = df.apply(calculate_cds_spread_for_row, axis=1)
return df

def read_database(connection_string=CONNECTION_STRING):

engine = create_engine(connection_string)

existing_records_query = f"""
                SELECT *
                FROM fm.tbl_cds_prices
                WHERE category not in ('index_option')
            """

# Execute the query and load existing records
df = pd.read_sql(
    existing_records_query,
    con=engine
    )

return df

if __name__ == "__main__":
df = read_database()
df = calculate_spreads_for_dataframe(df)
print(df)
```

A typical output is as follows:

```
Calculated spread for row clearing_date                                2024-11-18
issuer_name                            Westpac Bkg Corp
instrument_name     WSTP.SNRFOR.USD.MR14.100.2029-12-20
eod_price                                       103.472
category                                    single_name
contract_name                                      WSTP
tenor                                                5Y
coupon                                            100.0
series                                              NaN
version                                                
maturity_date                                2029-12-20
ccy                                                 USD
restructuring                                      MR14
seniority                                        SNRFOR
recovery_rate                                       0.4
quote_convention                                 spread
timestamp                                           NaT
Name: 12690, dtype: object: nan
```

As I don't get any errors, it leads me to believe my logic, rather than my syntax is the problem, so presumably there is a stupid error somewhere here, but I haven't been able to spot it.

Is someone able to point me in the correct direction (of course if there is any info I have missed please say)?

Just trying to include the data as per one of the comments.

Swap data (tbl_irs_prices):

| swap_price_id | tenor | price | cod | quote_time | quote_date | ccy | timestamp |
| 173 | 1 Yr | 2.51 | -0.02 | 17:42:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 174 | 2 Yr | 2.31 | -0.02 | 17:42:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 175 | 3 Yr | 2.28 | -0.02 | 17:40:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 176 | 4 Yr | 2.28 | -0.03 | 17:40:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 177 | 5 Yr | 2.29 | -0.03 | 17:43:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 178 | 6 Yr | 2.30 | -0.04 | 17:43:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 179 | 7 Yr | 2.31 | -0.04 | 17:41:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 180 | 8 Yr | 2.33 | -0.05 | 17:42:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 181 | 9 Yr | 2.34 | -0.06 | 17:43:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 182 | 10 Yr | 2.36 | -0.05 | 17:43:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 183 | 12 Yr | 2.39 | -0.06 | 17:42:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 184 | 15 Yr | 2.41 | -0.06 | 17:43:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 185 | 20 Yr | 2.35 | -0.06 | 17:40:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 186 | 30 Yr | 2.14 | -0.05 | 17:42:00 | 08/11/2024 | EUR | 08/11/2024 16:43 |
| 187 | 1 Yr | 4.55 | 0.03 | 17:40:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 188 | 2 Yr | 4.33 | 0.04 | 17:42:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 189 | 3 Yr | 4.24 | 0.03 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 190 | 4 Yr | 4.18 | 0.02 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 191 | 5 Yr | 4.14 | 0.01 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 192 | 6 Yr | 4.13 | 0.00 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 193 | 7 Yr | 4.12 | -0.01 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 194 | 8 Yr | 4.12 | -0.02 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 195 | 9 Yr | 4.12 | -0.01 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 196 | 10 Yr | 4.13 | -0.01 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 197 | 12 Yr | 4.15 | -0.02 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 198 | 15 Yr | 4.17 | -0.03 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 199 | 20 Yr | 4.15 | -0.03 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 200 | 25 Yr | 4.08 | -0.03 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |
| 201 | 30 Yr | 3.99 | -0.03 | 17:43:00 | 08/11/2024 | USD | 08/11/2024 16:43 |

CDS Data (tbl_cds_prices):

| clearing_date | issuer_name | instrument_name | eod_price | category | contract_name | tenor | coupon | series | version | maturity_date | ccy | restructuring | seniority | recovery_rate | quote_convention | timestamp |
| 08/11/2024 | Anglo Amern plc | AAUK.SNRFOR.EUR.MM14.100.2029-12-20 | 100.318 | single_name | AAUK | 5Y | 100 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Anglo Amern plc | AAUK.SNRFOR.EUR.MM14.25.2029-12-20 | 96.7288 | single_name | AAUK | 5Y | 25 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Anglo Amern plc | AAUK.SNRFOR.EUR.MM14.500.2029-12-20 | 119.461 | single_name | AAUK | 5Y | 500 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SNRFOR.EUR.MM.100.2029-12-20 | 103.069 | single_name | ACAFP | 5Y | 100 | NULL |  | 20/12/2029 | EUR | MM | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SNRFOR.EUR.MM.300.2029-12-20 | 112.769 | single_name | ACAFP | 5Y | 300 | NULL |  | 20/12/2029 | EUR | MM | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SNRFOR.EUR.MM.500.2029-12-20 | 122.469 | single_name | ACAFP | 5Y | 500 | NULL |  | 20/12/2029 | EUR | MM | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SNRFOR.EUR.MM14.100.2029-12-20 | 102.788 | single_name | ACAFP | 5Y | 100 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SNRFOR.EUR.MM14.300.2029-12-20 | 112.468 | single_name | ACAFP | 5Y | 300 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SNRFOR.EUR.MM14.500.2029-12-20 | 122.148 | single_name | ACAFP | 5Y | 500 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SNRLAC.EUR.MM14.100.2029-12-20 | 101.691 | single_name | ACAFP | 5Y | 100 | NULL |  | 20/12/2029 | EUR | MM14 | SNRLAC | NULL | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SNRLAC.EUR.MM14.300.2029-12-20 | 111.31 | single_name | ACAFP | 5Y | 300 | NULL |  | 20/12/2029 | EUR | MM14 | SNRLAC | NULL | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SNRLAC.EUR.MM14.500.2029-12-20 | 120.929 | single_name | ACAFP | 5Y | 500 | NULL |  | 20/12/2029 | EUR | MM14 | SNRLAC | NULL | spread | 08/11/2024 19:11 |
| 08/11/2024 | Cr Agricole SA | ACAFP.SUBLT2.EUR.MM14.100.2029-12-20 | 100.027 | single_name | ACAFP | 5Y | 100 | NULL |  | 20/12/2029 | EUR | MM14 | SUBLT2 | 0.2 | spread | 08/11/2024 19:11 |
| 08/11/2024 | ACCOR | ACCOR.SNRFOR.EUR.MM14.100.2029-12-20 | 101.551 | single_name | ACCOR | 5Y | 100 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | ACCOR | ACCOR.SNRFOR.EUR.MM14.25.2029-12-20 | 97.9427 | single_name | ACCOR | 5Y | 25 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | ACCOR | ACCOR.SNRFOR.EUR.MM14.500.2029-12-20 | 120.798 | single_name | ACCOR | 5Y | 500 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Adecco Group AG | ADECGRO.SNRFOR.EUR.MM14.100.2029-12-20 | 100.158 | single_name | ADECGRO | 5Y | 100 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |
| 08/11/2024 | Adecco Group AG | ADECGRO.SNRFOR.EUR.MM14.25.2029-12-20 | 96.6548 | single_name | ADECGRO | 5Y | 25 | NULL |  | 20/12/2029 | EUR | MM14 | SNRFOR | 0.4 | spread | 08/11/2024 19:11 |

Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.