Esc

    The API is under testing and may change before release.

    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.

    Rust
    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(())
    }
    Python
    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 tt when a latent variable falls below the threshold its default probability p(t)p(t) sets:

    Xi=ρ M+1−ρ εi,default  ⟺  Xi≤F−1(p(t)),X_i = \sqrt{\rho}\, M + \sqrt{1 - \rho}\, \varepsilon_i, \qquad \text{default} \iff X_i \le F^{-1}\bigl(p(t)\bigr),

    with MM a common factor and εi\varepsilon_i each name's own. Given MM the names default independently, and the pool loss fraction is L=(1−R)×L = (1 - R) \times the fraction defaulted. The tranche loss is

    L[a,d)=min⁡(max⁡(L−a,0), d−a)d−a,L_{[a,d)} = \frac{\min\bigl(\max(L - a, 0),\ d - a\bigr)}{d - a},

    and its expectation over MM drives both legs, as for a CDS whose notional erodes with L[a,d)L_{[a,d)}. The par spread sets the legs equal. Under base correlation, a tranche [a,d)[a, d) is the difference of the base tranches [0,d)[0, d) and [0,a)[0, a), 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 ρ\rho by detachment BaseCorrelation::new curves.BaseCorrelation
    skew pinned under the index identity MarketContext::with_base_correlation MarketContext.with_base_correlation
    [a,d)[a, d) a tranche on a series CdoTranche::standard products.cdo_tranche
    double-t νM,νε\nu_M, \nu_\varepsilon CdoTranche::with_student_t factor_dof=, idiosyncratic_dof=
    E[L[a,d)]\mathbb{E}[L_{[a,d)}] 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 with MarketContext.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" reads MarketContext.with_recovery_floor.
    • Finite pool: names applies under the Gaussian copula with a constant recovery only, and zero names raises ValueError.

    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.