CDO tranche
This page lists everything you need to book and value a synthetic CDO tranche: the function's arguments and defaults, the base-correlation skew it reads, how it's priced, and an example you can run. A tranche takes the losses of an index pool between an attachment and a detachment point. How tranches are quoted, and why correlation prices them, is covered in how credit products work.
Quick example
The example reads its rates from the desk sheets; load them once and it runs as shown.
This pins the on-the-run CDX.NA.IG curve and a base-correlation skew, then reads the par spread of the 0–3% equity and the 3–7% mezzanine tranches. It prices the equity tranche again under a double-t copula with four degrees of freedom, and on finite pools of 125 and 10,000 names.
use std::sync::Arc;
use chrono::NaiveDate;
use helixio_docs_sheets::{SOFR_CLOSE_DATE, USD_SOFR_CLOSE};
use helixrs::{
AnyInstrument, BaseCorrelation, CdoTranche, CollateralSpec, CreditCurveId, CreditIndexFamily,
CreditIndexId, DayCountFraction, FlatHazard, MarketContext, QuoteSheet, Survival,
};
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")
}
const FAMILY: &str = "CDX.NA.IG";
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let family = FAMILY.parse::<CreditIndexFamily>()?.template();
// The index curve under the series' identity, and the base-correlation skew
// by detachment point.
let series = family.on_the_run(close);
let identity = format!("{FAMILY}.{series}v1");
let skew = BaseCorrelation::new(&[(0.03, 0.35), (0.07, 0.45), (0.15, 0.55), (1.0, 0.75)])?;
let sheet = QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?;
let credit: CreditCurveId = identity.parse()?;
let index: CreditIndexId = identity.parse()?;
let market = MarketContext::as_of(close)
.with_recipe_quotes(&CollateralSpec::Currency("USD".parse()?), &sheet)?
.with_credit_curve(
credit,
Survival::FlatHazard(FlatHazard::new(close, 0.012, DayCountFraction::Act365Fixed)?),
Some(0.4),
)
.with_base_correlation(index, Arc::new(skew));
println!("credit {identity}");
// Equity takes the first 3% of pool losses, mezzanine the next 4%.
let tranche = |attach: f64, detach: f64| -> Outcome<CdoTranche> {
Ok(CdoTranche::standard(&family, close, series, 5, 10_000_000.0, attach, detach, 0.0100, true)?)
};
let par = |t: CdoTranche| -> Outcome<f64> { Ok(AnyInstrument::CdoTranche(Box::new(t)).par_spread(&market)?) };
let equity = par(tranche(0.00, 0.03)?)?;
let mezzanine = par(tranche(0.03, 0.07)?)?;
println!("0-3% par {equity:.6}");
println!("3-7% par {mezzanine:.6}");
// The double-t copula with four degrees of freedom on each factor puts more
// weight on joint defaults than the Gaussian, so the equity tranche's par
// spread moves.
let student = par(tranche(0.00, 0.03)?.with_student_t(4.0, 4.0)?)?;
println!("0-3% t(4) {student:.6}");
// With names= the tranche is valued on a finite pool of identical names: the
// number of defaults is binomial rather than the
// large-pool limit. The more names, the closer the limit; zero names is refused.
let small = par(tranche(0.00, 0.03)?.with_names(125)?)?;
let large = par(tranche(0.00, 0.03)?.with_names(10_000)?)?;
println!("0-3% 125 names {small:.6}");
println!("0-3% 10,000 names {large:.6}");
println!("0-3% large pool {equity:.6}");
let refused = tranche(0.00, 0.03)?.with_names(0).is_err();
println!("zero names refused {}", if refused { "yes" } else { "no" });
Ok(())
}import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
FAMILY = "CDX.NA.IG"
# The index curve under the series' identity, and the base-correlation skew
# by detachment point.
series = hlx.credit.on_the_run(FAMILY, SOFR_CLOSE_DATE)
identity = f"{FAMILY}.{series}v1"
skew = hlx.curves.BaseCorrelation([(0.03, 0.35), (0.07, 0.45), (0.15, 0.55), (1.0, 0.75)])
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("USD", USD_SOFR_CLOSE)
.with_credit_curve(identity, hlx.curves.SurvivalCurve.flat_hazard(SOFR_CLOSE_DATE, 0.012), recovery=0.4)
.with_base_correlation(identity, skew)
)
print(f"credit {identity}")
# Equity takes the first 3% of pool losses, mezzanine the next 4%.
def tranche(attach, detach, **copula):
return hlx.products.cdo_tranche(
FAMILY, SOFR_CLOSE_DATE, series, 5,
pool_notional=10_000_000.0, attach=attach, detach=detach, spread=0.0100, **copula,
)
equity = tranche(0.00, 0.03).par_spread(market)
mezzanine = tranche(0.03, 0.07).par_spread(market)
print(f"0-3% par {equity:.6f}")
print(f"3-7% par {mezzanine:.6f}")
# The double-t copula with four degrees of freedom on each factor puts more
# weight on joint defaults than the Gaussian, so the equity tranche's par
# spread moves.
student = tranche(0.00, 0.03, factor_dof=4.0, idiosyncratic_dof=4.0).par_spread(market)
print(f"0-3% t(4) {student:.6f}")
# With names= the tranche is valued on a finite pool of identical names: the
# number of defaults is binomial rather than the
# large-pool limit. The more names, the closer the limit; zero names is refused.
small = tranche(0.00, 0.03, names=125).par_spread(market)
large = tranche(0.00, 0.03, names=10_000).par_spread(market)
print(f"0-3% 125 names {small:.6f}")
print(f"0-3% 10,000 names {large:.6f}")
print(f"0-3% large pool {equity:.6f}")
try:
tranche(0.00, 0.03, names=0)
refused = False
except ValueError:
refused = True
print(f"zero names refused {'yes' if refused else 'no'}")credit CDX.NA.IG.47v1
0-3% par 0.162213
3-7% par 0.033206
0-3% t(4) 0.198021
0-3% 125 names 0.154253
0-3% 10,000 names 0.161957
0-3% large pool 0.162213
zero names refused yes
The equity tranche takes the first losses, so its par spread is far wider than the mezzanine's. The fatter-tailed copula widens it further. The finite pools approach the large-pool reading as the names increase, and a pool of no names is refused.
How it's priced
Each name defaults by when a latent variable falls below the threshold its default probability sets:
with a common factor and each name's own. Given the names default independently, and the pool loss fraction is the fraction defaulted. The tranche loss is
and its expectation over drives both legs, as for a CDS whose notional erodes with . The par spread sets the legs equal. Under base correlation, a tranche is the difference of the base tranches and , each at its own correlation from the skew.
API
| Argument | Type | Default | Meaning |
|---|---|---|---|
family |
str |
required | the index family, such as "CDX.NA.IG" |
trade_date |
date |
required | the trade date |
series |
int |
required | the index series |
tenor_years |
int |
required | whole years, matured as the index contract is |
pool_notional |
float |
required | the pool's notional; the tranche's is this times its width |
attach |
float |
required | the attachment point, as a decimal of the pool |
detach |
float |
required | the detachment point, as a decimal of the pool |
spread |
float |
required | the running spread the buyer pays |
buys_protection |
bool |
True |
False books the seller |
version |
int |
1 |
the index version |
index_factor |
float |
None |
a pool that has lost names: points on the surviving pool, legs on pool_notional * index_factor |
upfront |
float |
None |
the upfront the buyer pays, as a decimal of the tranche notional |
fixed_recovery |
float |
None |
a recovery the contract fixes; None reads the market's |
recovery_floor |
float or "Market" |
None |
stochastic recovery, falling with the common factor towards the floor |
factor_dof, idiosyncratic_dof |
float or "Market" |
None |
the double-t copula's degrees of freedom; give both or neither |
names |
int |
None |
a finite pool of that many identical names, instead of the large-pool limit |
The skew is curves.BaseCorrelation([(detach, correlation), ...]), pinned with
MarketContext.with_base_correlation(identity, skew) under the index identity. In Rust the tranche
is CdoTranche::standard, with with_student_t and with_recovery_floor.
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| base correlation | by detachment | BaseCorrelation::new |
curves.BaseCorrelation |
| skew pinned | under the index identity | MarketContext::with_base_correlation |
MarketContext.with_base_correlation |
| a tranche on a series | CdoTranche::standard |
products.cdo_tranche |
|
| double-t | CdoTranche::with_student_t |
factor_dof=, idiosyncratic_dof= |
|
| large-pool expected loss | expected_tranche_loss |
credit.expected_tranche_loss |
|
| par spread | legs equal | AnyInstrument::par_spread |
Instrument.par_spread |
Defaults and limits
- Standard points: CDX.NA.IG tranches trade 0–3%, 3–7%, 7–15% and 15–100%; the index's terms are in the credit conventions catalog.
- The market must hold the index curve and a base-correlation skew under the same identity.
- Double-t: give both degrees of freedom or neither; one without the other, or either not
above 2, raises
ValueError."Market"reads the mark pinned withMarketContext.with_copula_dof, with a risk row. - Recovery floor: a floor outside 0, 1) raises
ValueError, and one above the mean recovery is refused at pricing."Market"readsMarketContext.with_recovery_floor. - Finite pool:
namesapplies under the Gaussian copula with a constant recovery only, and zero names raisesValueError.
See also
- Source:
crates/helixrs/src/instruments/credit.rs,crates/helixrs/src/models/credit.rs,crates/helixrs/src/models/student_t.rs,crates/helixrs/src/marketdata/base_correlation.rs - Related pages:
- [How credit products work, the ideas behind the copula.
- Index CDS, for the pool's own curve.
- Credit events, for a tranche eroded by a default.
- Nth-to-default, for the same copula on a small basket.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.