Esc

    The API is under testing and may change before release.

    Discount curves

    This page lists what you need to build a discount curve yourself: from discount factors at dated pillars, or from a single rate. It covers the constructor's arguments, how the curve reads between and past its pillars, and what happens at the edges.

    Quick example

    This builds a curve from four discount factors, reads it between pillars and past the last one, builds a flat curve from one rate, and prices a swap on the first curve. The example reads its quotes from the desk sheets; load them once and it runs as shown.

    Rust
    use chrono::NaiveDate;
    use helixio_docs_sheets::SOFR_CLOSE_DATE;
    use helixrs::{
        CollateralSpec, Curve, DayCountFraction, MarketContext, PiecewiseDiscount, Termination, YieldCurve,
        template,
    };
    
    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 reference = date(SOFR_CLOSE_DATE);
    
        // Four dated discount factors. At a pillar the curve returns its factor
        // exactly; between pillars it interpolates.
        let pillars = [
            (date((2027, 10, 1)), 0.9580),
            (date((2028, 10, 2)), 0.9150),
            (date((2031, 10, 1)), 0.7920),
            (date((2036, 10, 1)), 0.6180),
        ];
        let piecewise = PiecewiseDiscount::from_discount_factors(reference, &pillars)?;
        let scheme = piecewise.interpolation().code();
        let curve = Curve::Piecewise(piecewise);
        let view = curve.view::<f64>();
        println!("parameters     {}", curve.parameter_count());
        println!("interpolation  {scheme}");
        println!("at a pillar    {:.10}", view.discount(pillars[2].0)?);
        println!("between        {:.10}", view.discount(date((2030, 4, 1)))?);
    
        // Past the last pillar the curve holds its last segment's forward, so the
        // factor decays from the last pillar at that one rate.
        let (last, previous) = (pillars[3].0, pillars[2].0);
        let forward = view.forward_rate(previous, last)?;
        let beyond = date((2041, 10, 1));
        let expected = pillars[3].1 * (-forward * (beyond - last).num_days() as f64 / 365.0).exp();
        println!("last forward   {forward:.10}");
        println!("factor 2041    {:.10}", view.discount(beyond)?);
        println!("by the forward {expected:.10}");
    
        // A flat curve is one continuously compounded zero rate on ACT/365: the factor
        // to a date is exp(-r t).
        let flat = Curve::flat(reference, 0.04, DayCountFraction::Act365Fixed)?;
        let five = date((2031, 10, 1));
        let flat_view = flat.view::<f64>();
        println!("flat factor 5Y {:.10}", flat_view.discount(five)?);
    
        // One curve that discounts dollars and projects SOFR prices a SOFR swap. A
        // swap struck at the curve's par rate is worth nothing on it.
        let market = MarketContext::as_of(reference)
            .with_discount(CollateralSpec::Currency("USD".parse()?), curve.clone())
            .with_projection("USD-SOFR", curve.clone());
        let draft = template("USD-SOFR-OIS")?
            .swap(reference)?
            .with_maturity("5Y".parse::<Termination>()?)?
            .with_notional(10_000_000.0);
        let fair = draft.at_par(&market)?;
        let par = fair.par_rate(&market)?;
        println!("5Y par rate    {par:.8}");
        println!("npv at par     {:.2}", fair.npv(&market)?.abs());
    
        Ok(())
    }
    Python
    import datetime as dt
    import math
    
    import helixio as hlx
    from desk_sheets import SOFR_CLOSE_DATE
    
    REFERENCE = SOFR_CLOSE_DATE
    
    # Four dated discount factors. At a pillar the curve returns its factor
    # exactly; between pillars it interpolates.
    PILLARS = [
        (dt.date(2027, 10, 1), 0.9580),
        (dt.date(2028, 10, 2), 0.9150),
        (dt.date(2031, 10, 1), 0.7920),
        (dt.date(2036, 10, 1), 0.6180),
    ]
    curve = hlx.curves.DiscountCurve(REFERENCE, PILLARS)
    print(f"parameters     {curve.parameter_count()}")
    print(f"interpolation  {curve.interpolation()}")
    print(f"at a pillar    {curve.discount(PILLARS[2][0]):.10f}")
    print(f"between        {curve.discount(dt.date(2030, 4, 1)):.10f}")
    
    # Past the last pillar the curve holds its last segment's forward, so the
    # factor decays from the last pillar at that one rate.
    last, previous = PILLARS[-1][0], PILLARS[-2][0]
    forward = curve.forward_rate(previous, last)
    beyond = dt.date(2041, 10, 1)
    expected = PILLARS[-1][1] * math.exp(-forward * (beyond - last).days / 365.0)
    print(f"last forward   {forward:.10f}")
    print(f"factor 2041    {curve.discount(beyond):.10f}")
    print(f"by the forward {expected:.10f}")
    
    # A flat curve is one continuously compounded zero rate on ACT/365: the factor
    # to a date is exp(-r t).
    flat = hlx.curves.DiscountCurve.flat(REFERENCE, 0.04)
    five = dt.date(2031, 10, 1)
    print(f"flat factor 5Y {flat.discount(five):.10f}")
    
    # One curve that discounts dollars and projects SOFR prices a SOFR swap. A
    # swap struck at the curve's par rate is worth nothing on it.
    market = (
        hlx.MarketContext(REFERENCE)
        .with_discount("USD", curve)
        .with_projection("USD-SOFR", curve)
    )
    draft = hlx.templates.get("USD-SOFR-OIS").swap(REFERENCE).with_maturity("5Y").with_notional(10_000_000.0)
    fair = draft.at_par(market)
    par = fair.par_rate(market)
    print(f"5Y par rate    {par:.8f}")
    print(f"npv at par     {abs(fair.npv(market)):.2f}")
    parameters     4
    interpolation  loglinear
    at a pillar    0.7920000000
    between        0.8513937284
    last forward   0.0495602742
    factor 2041    0.4822927548
    by the forward 0.4822927548
    flat factor 5Y 0.8186410341
    5Y par rate    0.04695436
    npv at par     0.00

    At each pillar the curve returns its factor exactly. Past the last pillar it carries on at its last segment's forward rate.

    How it's priced

    A curve holds discount factors D(T1),…,D(Tn)D(T_1), \dots, D(T_n) at its pillars and D(t0)=1D(t_0) = 1 at its reference date t0t_0. Between pillars it reads under its interpolation; log-linear is the default. Past TnT_n it holds the forward rate of its last segment:

    fn=ln⁡D(Tn−1)−ln⁡D(Tn)τ(Tn−1,Tn),D(t)=D(Tn) e−fn τ(Tn,t),t>Tn,f_n = \frac{\ln D(T_{n-1}) - \ln D(T_n)}{\tau(T_{n-1}, T_n)}, \qquad D(t) = D(T_n)\, e^{-f_n\, \tau(T_n, t)}, \quad t > T_n,

    with τ\tau on ACT/365. A flat curve is one continuously compounded zero rate rr on a day count:

    D(t)=e−r τ(t0,t).D(t) = e^{-r\, \tau(t_0, t)}.

    Why curves pass between systems as discount factors is covered in how curves work.

    API

    Symbol Meaning Rust Python
    D(Ti)D(T_i) factors at pillars PiecewiseDiscount::from_discount_factors, wrapped in Curve::Piecewise curves.DiscountCurve(reference, pillars)
    D(t)D(t) the factor to a date YieldCurve::discount on Curve::view curves.DiscountCurve.discount
    fnf_n the last segment's forward YieldCurve::forward_rate curves.DiscountCurve.forward_rate
    e−rτe^{-r\tau} a flat curve Curve::flat curves.DiscountCurve.flat
    — a curve in a market's roles MarketContext::with_discount, MarketContext::with_projection MarketContext.with_discount, MarketContext.with_projection

    curves.DiscountCurve(...):

    Argument Type Default Meaning
    reference date required the curve's reference date, where the factor is 1
    pillars list[tuple[date, float]] required (date, discount factor) pairs; a first pillar of exactly 1.0 on the reference date is dropped, any other factor there is refused
    interpolation Interpolation or str "loglinear" the scheme between pillars; in Rust, .with_interpolation(...)
    tension float or None None the tension for a tension spline
    end_slopes tuple[float, float] or None None the end slopes for a clamped cubic
    long_interpolation Interpolation, str or None None a second scheme the curve switches to past changeover
    changeover date or None None the pillar where long_interpolation takes over
    rate_day_count str or DayCountFraction "ACT/365.FIXED" the day count zero_rate and forward_rate are formed on; it moves no factor

    The schemes interpolation= accepts are the members of curves.Interpolation; in Rust, the variants of Interpolation. Interpolation explains how they differ.

    Scheme Parameters
    LogLinear (the default), BackwardFlat, ForwardFlat, LinearZero none
    NaturalCubic, NotAKnotCubic, MonotoneCubic, MonotoneConvex, Akima, Kruger none
    MonotonicNaturalCubic (a natural spline with its slopes filtered to stay monotone), KrugerZero (Kruger on the zero rate) none
    anchored_linear_zero(anchor) the zero rate at the reference date, where LinearZero's first segment starts
    tension_spline(sigma) the tension sigma
    clamped_cubic(left, right) the two end slopes

    curves.DiscountCurve.flat(...):

    Argument Type Default Meaning
    reference date required the reference date
    rate float required the continuously compounded zero rate
    day_count str or DayCountFraction "ACT/365.FIXED" the day count the rate accrues on

    Defaults and limits

    • Extrapolation: past the last pillar the curve extrapolates at the last segment's forward, silently rather than refusing. A date before the reference date raises ValueError.
    • Interpolation: log-linear unless you choose another; see interpolation.
    • A flat curve has no pillars and no free parameters, so it reports no risk of its own.
    • Marked from quotes: to solve a curve from swap and futures quotes instead of factors, see quote sheets and recipes.

    See also

    • Source: crates/helixrs/src/curves/discount.rs, crates/helixrs/src/curves/flat.rs
    • Related pages:
      • Reading a curve, for the rates a curve answers.
      • Interpolation, for the schemes between pillars.
      • How curves work, for why the factors are the curve.

    From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.