Esc

    The API is under testing and may change before release.

    Coupon options

    This page lists the options a floating coupon can carry on its own rate: a cap, a floor, a collar, and a digital. It covers the two methods that attach them to a leg, their arguments, and the vols the options are valued off.

    Quick example

    This caps, floors and collars a two-year EURIBOR 3M leg, then puts a digital call and a digital put on it. Each result is printed beside the identity it obeys, an identity that holds whatever the options are worth. The example reads its quotes from the desk sheets; load them once and it runs as shown. The cap vols are stated in the program, because no recipe builds them.

    Rust
    use chrono::NaiveDate;
    use helixio_docs_sheets::{EUR_CLOSE, SOFR_CLOSE_DATE};
    use helixrs::{
        AnyIndex, Collar, CollateralSpec, Digital, DigitalStrike, Direction, Leg, LegTerms, MarketContext, PaymentRule,
        QuoteSheet, RateVolSurface, Schedule, VolConvention, calendar, fixed_rate_leg,
        ibor_leg_on_index, index,
    };
    
    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 eur = CollateralSpec::Currency("EUR".parse()?);
    
        // Curves from the sheet; the caplet vols, which no recipe builds, joined with
        // the market's own verb: normal vols on a grid of expiries in years, for the
        // index's three-month tenor.
        let cap_vols = RateVolSurface::new(
            vec![0.25, 1.0, 2.0, 5.0],
            vec![0.25],
            &[vec![0.0060], vec![0.0070], vec![0.0075], vec![0.0080]],
            VolConvention::Normal,
        )?;
        let sheet = QuoteSheet::from_rows(EUR_CLOSE.iter().copied())?;
        let market = MarketContext::as_of(close)
            .with_recipe_quotes(&eur, &sheet)?
            .with_cap_vols("EUR".parse()?, cap_vols);
        let target = calendar("EUTA")?;
        let quarterly = Schedule::generate(
            date((2026, 10, 5)),
            date((2028, 10, 5)),
            "3M".parse()?,
            target.as_ref(),
            "ModFollowing".parse()?,
            "None".parse()?,
            "ShortInitial".parse()?,
            false,
            None,
            None,
        )?;
        let terms = LegTerms {
            notional: 10_000_000.0,
            day_count: "ACT/360".parse()?,
            currency: "EUR".parse()?,
            direction: Direction::Receive,
            payment: PaymentRule::none(),
        };
        let Some(AnyIndex::Ibor(euribor)) = index("EUR-EURIBOR-3M") else {
            panic!("a registered term index");
        };
        let plain = ibor_leg_on_index(&quarterly, &terms, &euribor, 1.0, 0.0);
        let declares = |leg: &Leg| {
            let projections = leg.projections();
            let needs = leg.risk_factors(&eur, &projections).needs();
            needs.iter().map(|(role, key)| format!("{role} {key}")).collect::<Vec<_>>().join(", ")
        };
        let collared = |cap: Option<f64>, floor: Option<f64>| -> Outcome<Leg> {
            Ok(plain.clone().with_collar(Collar::new(cap, floor)?))
        };
        println!("plain leg declares   {}", declares(&plain));
        println!(
            "capped leg declares  {}",
            declares(&collared(Some(0.02), None)?)
        );
    
        let npv = |leg: &Leg| -> Outcome<f64> { Ok(leg.npv_on(&market, &eur)?) };
    
        // A cap and a floor at the same strike, against the plain leg and a fixed leg
        // at that strike on the same periods.
        let strike = 0.0215;
        let capped = npv(&collared(Some(strike), None)?)?;
        let floored = npv(&collared(None, Some(strike))?)?;
        let fixed = npv(&fixed_rate_leg(&quarterly, &terms, strike))?;
        let base = npv(&plain)?;
        println!("capped   {capped:.2}");
        println!("floored  {floored:.2}");
        println!("plain    {base:.2}");
        println!("fixed    {fixed:.2}");
        println!("capped + floored  {:.2}", capped + floored);
        println!("plain + fixed     {:.2}", base + fixed);
    
        // A 2.00% floor and a 2.30% cap on every coupon.
        let both = npv(&collared(Some(0.0230), Some(0.0200))?)?;
        let pieces = npv(&collared(Some(0.0230), None)?)? + npv(&collared(None, Some(0.0200))?)? - base;
        println!("collared {both:.2}");
        println!("pieces   {pieces:.2}");
    
        // A digital call and a digital put at 2.15%, each paying 1.00% of the period's
        // accrual when the rate ends past the strike, bought, and naked: the coupon
        // pays only the digital, not its own rate.
        let paying_one_percent = DigitalStrike { strike, payoff: Some(0.01), long: true };
        let digital = |call: Option<DigitalStrike>, put: Option<DigitalStrike>| -> Outcome<Leg> {
            Ok(plain.clone().with_digital(Digital::new(call, put, true, Digital::DEFAULT_GAP)?))
        };
        let call = npv(&digital(Some(paying_one_percent), None)?)?;
        let put = npv(&digital(None, Some(paying_one_percent))?)?;
        let one_percent = npv(&fixed_rate_leg(&quarterly, &terms, 0.01))?;
        println!("digital call     {call:.2}");
        println!("digital put      {put:.2}");
        println!("call plus put    {:.2}", call + put);
        println!("fixed at 1.00%   {one_percent:.2}");
    
        Ok(())
    }
    Python
    import datetime as dt
    
    import helixio as hlx
    from desk_sheets import EUR_CLOSE, SOFR_CLOSE_DATE
    
    # Curves from the sheet; the caplet vols, which no recipe builds, joined with
    # the market's own verb: normal vols on a grid of expiries in years, for the
    # index's three-month tenor.
    cap_vols = hlx.vols.RateVolSurface(
        [0.25, 1.0, 2.0, 5.0], [0.25], [[0.0060], [0.0070], [0.0075], [0.0080]], vol_convention="normal"
    )
    market = (
        hlx.MarketContext(SOFR_CLOSE_DATE)
        .with_recipe_quotes("EUR", EUR_CLOSE)
        .with_cap_vols("EUR", cap_vols)
    )
    target = hlx.Calendar("EUTA")
    quarterly = hlx.dates.schedule(dt.date(2026, 10, 5), dt.date(2028, 10, 5), "3M", target)
    paying = quarterly.paying(0, target)
    plain = hlx.Leg(
        hlx.legs.ibor_leg(quarterly, paying, "EUR", notional=10_000_000.0, index="EUR-EURIBOR-3M", day_count="ACT/360"),
        "Receive",
        "EUR",
    )
    print(f"plain leg declares   {', '.join(f'{role} {key}' for role, key in plain.market_data_needs())}")
    print(f"capped leg declares  {', '.join(f'{role} {key}' for role, key in plain.with_collar(cap=0.02).market_data_needs())}")
    
    # A cap and a floor at the same strike, against the plain leg and a fixed leg
    # at that strike on the same periods.
    STRIKE = 0.0215
    capped = plain.with_collar(cap=STRIKE).npv(market)
    floored = plain.with_collar(floor=STRIKE).npv(market)
    fixed = hlx.Leg(
        hlx.legs.fixed_rate_leg(quarterly, paying, "EUR", notional=10_000_000.0, fixed_rate=STRIKE, day_count="ACT/360"),
        "Receive",
        "EUR",
    ).npv(market)
    print(f"capped   {capped:.2f}")
    print(f"floored  {floored:.2f}")
    print(f"plain    {plain.npv(market):.2f}")
    print(f"fixed    {fixed:.2f}")
    print(f"capped + floored  {capped + floored:.2f}")
    print(f"plain + fixed     {plain.npv(market) + fixed:.2f}")
    
    # A 2.00% floor and a 2.30% cap on every coupon.
    collared = plain.with_collar(cap=0.0230, floor=0.0200).npv(market)
    pieces = plain.with_collar(cap=0.0230).npv(market) + plain.with_collar(floor=0.0200).npv(market) - plain.npv(market)
    print(f"collared {collared:.2f}")
    print(f"pieces   {pieces:.2f}")
    
    # A digital call and a digital put at 2.15%, each paying 1.00% of the period's
    # accrual when the rate ends past the strike, bought, and naked: the coupon
    # pays only the digital, not its own rate.
    call = plain.with_digital(call=(STRIKE, 0.01, True), naked=True).npv(market)
    put = plain.with_digital(put=(STRIKE, 0.01, True), naked=True).npv(market)
    one_percent = hlx.Leg(
        hlx.legs.fixed_rate_leg(quarterly, paying, "EUR", notional=10_000_000.0, fixed_rate=0.01, day_count="ACT/360"),
        "Receive",
        "EUR",
    ).npv(market)
    print(f"digital call     {call:.2f}")
    print(f"digital put      {put:.2f}")
    print(f"call plus put    {call + put:.2f}")
    print(f"fixed at 1.00%   {one_percent:.2f}")
    plain leg declares   discount EUR, projection EUR-EURIBOR-3M
    capped leg declares  discount EUR, projection EUR-EURIBOR-3M, cap_vol EUR
    capped   374623.39
    floored  471434.06
    plain    419208.38
    fixed    426849.07
    capped + floored  846057.45
    plain + fixed     846057.45
    collared 422789.20
    pieces   422789.20
    digital call     80817.97
    digital put      117716.48
    call plus put    198534.45
    fixed at 1.00%   198534.45

    The capped leg also declares that it reads the cap vols. The capped and floored legs add up to the plain leg plus the fixed leg at the strike, the collar equals its two pieces less the plain leg, and the digital call and put together equal a fixed leg at their payoff.

    How it's priced

    A coupon capped at KK pays min⁡(r,K)\min(r, K) and one floored at KK pays max⁡(r,K)\max(r, K). Since min⁡(r,K)+max⁡(r,K)=r+K\min(r, K) + \max(r, K) = r + K for every outcome rr, summing over the coupons gives

    Vcap(K)+Vfloor(K)=Vplain+Vfixed(K),V_{\text{cap}}(K) + V_{\text{floor}}(K) = V_{\text{plain}} + V_{\text{fixed}}(K),

    whatever vol the options are valued at. A collar with Kf<KcK_f < K_c pays min⁡(max⁡(r,Kf),Kc)\min(\max(r, K_f), K_c), so Vcollar=Vcap(Kc)+Vfloor(Kf)−VplainV_{\text{collar}} = V_{\text{cap}}(K_c) + V_{\text{floor}}(K_f) - V_{\text{plain}}.

    A cash-or-nothing digital call at KK pays a rate RR when r>Kr > K, and the put pays RR when r<Kr < K. Still to fix, each is replicated by a spread of two caplets (floorlets for the put) a gap gg wide, centred on the strike:

    call≈R C(K−g/2)−C(K+g/2)g,put≈R P(K+g/2)−P(K−g/2)g.\text{call} \approx R\,\frac{C(K - g/2) - C(K + g/2)}{g}, \qquad \text{put} \approx R\,\frac{P(K + g/2) - P(K - g/2)}{g}.

    By put-call parity the two spreads add up to exactly RR, so a naked call and put together are a fixed leg at RR. Why a desk puts options inside a coupon is in how cashflows work.

    API

    Symbol Meaning Rust Python
    min⁡(r,K)\min(r, K), max⁡(r,K)\max(r, K) a cap, a floor, or both Leg::with_collar with Collar::new Leg.with_collar
    digital a payoff past a strike Leg::with_digital with Digital::new and DigitalStrike Leg.with_digital
    cap vols caplet vols by expiry MarketContext::with_cap_vols with a RateVolSurface MarketContext.with_cap_vols with vols.RateVolSurface
    Vfixed(K)V_{\text{fixed}}(K) a fixed leg at the strike fixed_rate_leg legs.fixed_rate_leg
    needs what a leg declares it reads Leg::risk_factors, then RiskFactors::needs Leg.market_data_needs

    Leg.with_collar(...):

    Argument Type Default Meaning
    cap float or None None the rate every coupon is capped at
    floor float or None None the rate every coupon is floored at

    Leg.with_digital(...):

    Argument Type Default Meaning
    call (strike, payoff, long) or None None pays when the rate ends above strike; payoff is the cash rate paid, or None for asset-or-nothing (pays the coupon's rate); long is True when bought
    put (strike, payoff, long) or None None the same, paying when the rate ends below strike
    naked bool False True pays only the options, not the coupon's own rate
    gap float 1e-4 the width of the replicating spread, on the rate

    In Rust the same terms are DigitalStrike { strike, payoff, long } and Digital::new(call, put, naked, gap), with Digital::DEFAULT_GAP for one basis point.

    Defaults and limits

    • Which coupons: with_payoff acts on every IBOR, overnight, CMS and year-on-year inflation coupon on the leg, and on every CPI redemption, whose bounds are then annualised zero-coupon rates from its base date. with_digital acts on IBOR, overnight and CMS coupons only. Fixed coupons are left alone.
    • Vols: an option still to fix on an IBOR or overnight coupon is valued off the currency's cap vols, one on a CMS coupon off the swaption smile, and one on an inflation coupon or a CPI redemption off the index's CPI vols. The leg declares the surface it reads, and a market without it refuses the valuation with the surface named.
    • A printed reset pays its rate clamped by the cap and floor, or the digital's payoff, with no vol read.
    • Refused: a digital with neither side, a gap that isn't positive, or a strike or payoff that isn't finite.
    • Inflation: caps and floors on year-on-year coupons use the same methods, valued off YoY vols; see inflation caps and floors.

    See also

    • Source: crates/helixrs/src/cashflows/coupons.rs, crates/helixrs/src/cashflows/legs.rs, crates/helixrs/src/marketdata/rate_vol.rs
    • Related pages:
      • Cap and floor, the traded product.
      • Legs and coupons, the leg the options attach to.
      • CMS coupons, for options on a swap rate.

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