Credit calibration
This page lists what you need to solve a survival curve from credit default swaps quoted at par spreads: the ladder, the two survival forms, the recovery, and what the solve hands back.
Quick example
This solves one name's survival curve from four par spreads in both forms, checks that each form prices every spread back, and compares the two at ten years, between the pillars. The example reads its rates from the desk sheets; load them once and it runs as shown.
use chrono::NaiveDate;
use helixio_docs_sheets::{SOFR_CLOSE_DATE, USD_SOFR_CLOSE};
use helixrs::{
AnyInstrument, CollateralSpec, CreditCurveId, CreditDefaultSwap, Currency, DayCountFraction,
FixingStore, FxRates, MarketContext, QuoteSheet, Rung, Survival, SurvivalProbability, Tolerance,
calibrate_survival, calibrate_survival_density, cds_standard,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let ten_years = date((2036, 10, 1));
let usd: Currency = "USD".parse()?;
let acme: CreditCurveId = "ACME/SeniorUnSec/XR14/USD".parse()?;
// Each rung is a standard CDS struck at its par spread, so the curve is solved
// until each is worth nothing. The discount curve comes from the sheet.
let sheet = QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?;
let market = MarketContext::as_of(close).with_recipe_quotes(&CollateralSpec::Currency(usd), &sheet)?;
let (discount, _) = market.curve(&"discount(USD)".parse()?).expect("the USD curve");
let template = cds_standard();
let mut ladder = Vec::new();
for (years, spread) in [(3, 0.0080), (5, 0.0105), (7, 0.0122), (10, 0.0135)] {
let cds = CreditDefaultSwap::standard(&template, close, "ACME", usd, years, 1.0, spread, true);
let instrument = AnyInstrument::Cds(cds);
let maturity = instrument.contract_dates()[1].1;
ladder.push(Rung::par(maturity, instrument, spread));
}
let (fixings, fx) = (FixingStore::default(), FxRates::default());
let act365 = DayCountFraction::Act365Fixed;
let hazards = calibrate_survival(close, &discount, &ladder, Some(0.4), &fixings, &fx, Tolerance::default(), act365)?;
let density = calibrate_survival_density(close, &discount, &ladder, Some(0.4), &fixings, &fx, Tolerance::default(), act365)?;
for rung in &ladder {
println!("{} spread {:.4}", rung.pillar, rung.quote);
}
// Filed under the name's credit identity, each form prices every swap back to
// its quote, within the solve's tolerance of 1e-12 per unit notional.
let forms = [Survival::Isda(hazards.curve.clone()), Survival::Density(density.curve.clone())];
let mut worst: f64 = 0.0;
for form in &forms {
let credit = market.clone().with_credit_curve(acme.clone(), form.clone(), Some(0.4));
for rung in &ladder {
worst = worst.max((rung.instrument.par_spread(&credit)? - rung.quote).abs());
}
}
println!("largest par-spread miss, both forms {:.6} bp", worst * 1e4);
// The forms agree where the ladder pins them and differ between, because one
// holds the hazard flat between pillars and the other the default density linear.
let flat = forms[0].view::<f64>().survival_probability(ten_years)?;
let linear = forms[1].view::<f64>().survival_probability(ten_years)?;
println!("10Y survival, flat hazards {flat:.8}");
println!("10Y survival, default density {linear:.8}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
ACME = "ACME/SeniorUnSec/XR14/USD"
TEN_YEARS = dt.date(2036, 10, 1)
# Each rung is a standard CDS struck at its par spread, so the curve is solved
# until each is worth nothing. The discount curve comes from the sheet.
market = hlx.MarketContext(SOFR_CLOSE_DATE).with_recipe_quotes("USD", USD_SOFR_CLOSE)
discount = market.curve("discount(USD)")
ladder = []
for years, spread in [(3, 0.0080), (5, 0.0105), (7, 0.0122), (10, 0.0135)]:
cds = hlx.products.credit_default_swap(SOFR_CLOSE_DATE, "ACME", "USD", years, notional=1.0, coupon=spread)
ladder.append((dict(cds.contract_dates())["maturity"], cds, spread))
hazards = hlx.calibration.survival(SOFR_CLOSE_DATE, discount, ladder, recovery=0.4)
density = hlx.calibration.survival_density(SOFR_CLOSE_DATE, discount, ladder, recovery=0.4)
rungs = hazards.rungs().to_dict()
for pillar, quote in zip(rungs["pillar"], rungs["quote"]):
print(f"{pillar} spread {quote:.4f}")
# Filed under the name's credit identity, each form prices every swap back to
# its quote, within the solve's tolerance of 1e-12 per unit notional.
worst = 0.0
for solved in (hazards, density):
credit = market.with_credit_curve(ACME, solved.curve(), recovery=0.4)
worst = max(worst, *(abs(cds.par_spread(credit) - spread) for _, cds, spread in ladder))
print(f"largest par-spread miss, both forms {worst * 1e4:.6f} bp")
# The forms agree where the ladder pins them and differ between, because one
# holds the hazard flat between pillars and the other the default density linear.
flat = hazards.curve().survival(TEN_YEARS)
linear = density.curve().survival(TEN_YEARS)
print(f"10Y survival, flat hazards {flat:.8f}")
print(f"10Y survival, default density {linear:.8f}")2029-12-20 spread 0.0080
2031-12-20 spread 0.0105
2033-12-20 spread 0.0122
2036-12-20 spread 0.0135
largest par-spread miss, both forms 0.000000 bp
10Y survival, flat hazards 0.78679325
10Y survival, default density 0.78755105
The pillars are the standard quarterly CDS maturities, not the trade date plus whole years. Both forms hit every quote; they only differ between pillars.
How it's priced
Each rung is a standard CDS struck at its quoted spread, so the solve looks for the survival curve on which every rung is worth nothing, given the recovery. It solves one pillar at a time, then sweeps the ladder again until no pillar moves.
- Flat hazards hold the hazard rate constant between pillars:
- A default density holds linear between pillars instead, with the first segment flat.
Both have one parameter per rung, so both reprice every quote exactly. Why the sweep is needed, and when to pick which form, is in how calibration works.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| CDS rung | a standard CDS struck at its par spread | CreditDefaultSwap::standard, Rung::par |
products.credit_default_swap with coupon= the spread |
| form | flat hazards between pillars | calibrate_survival |
calibration.survival |
| density form | a linear default density between pillars | calibrate_survival_density |
calibration.survival_density |
| survival to | SurvivalProbability::survival_probability |
curves.SurvivalCurve.survival |
|
| filed curve | the curve under its credit identity | MarketContext::with_credit_curve |
MarketContext.with_credit_curve |
Both Python solves take the same arguments:
| Argument | Type | Default | Meaning |
|---|---|---|---|
reference |
date |
required | the curve's reference date, where survival is 1 |
discount |
DiscountCurve |
required | the rates curve the swaps discount on |
rungs |
list[Rung] or list[tuple[date, Instrument, float]] |
required | one CDS per pillar with its par spread |
recovery |
float or None |
None |
the name's market recovery; a contract that fixes its own needs none |
market |
MarketContext or None |
None |
where fixings and FX are read, when a rung needs them |
day_count |
str or DayCountFraction |
"ACT/365.FIXED" |
the day count the curve measures time on |
tolerance |
Tolerance or None |
the defaults | how hard the solve tries; see bootstrapping |
The result, a CalibratedSurvival, has curve(), rungs() (pillar, quote), solves()
(pillar, iterations, residual) and jacobian(), how far each parameter moves per unit move
in each quote. In Rust these are the fields curve, solves and provenance.
Defaults and limits
- Pillars: a rung's pillar is its contract maturity, and pillars must rise strictly.
- Recovery: a survival curve has no meaning without one. Pass the recovery the quotes assume, and file the curve with the same recovery.
- Credit identity: the market files the curve under
ENTITY/SENIORITY/DOCCLAUSE/CCY; the accepted spellings are listed in survival curves.
See also
- Source:
crates/helixrs/src/calibration.rs,crates/helixrs/src/curves/survival.rs - Related pages:
- Survival curves, for reading a survival curve.
- Credit default swaps, the instrument each rung is.
- The Jacobian, for the ladder each solve carries.
- Inflation calibration, the same idea for an index.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.