Backing Out CDS Spreads from Market Prices with QuantLib
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.