Esc

    The API is under testing and may change before release.

    Loan

    This page lists what you need to book and value a loan: the loan and callable_loan calls and their arguments, fixed and floating interest, amortisation, the borrower's credit, and the par quotes. A loan draws a principal on its start date, pays interest each period and repays the principal, at the end or in instalments.

    Quick example

    The example reads its quotes from the desk sheets; load them once and it runs as shown.

    This lends ten million for five years at 5.00% on the USD close, reads the par rate and books the same loan from the borrower's side. Then it lends at SOFR plus 150bp and reads the par margin.

    Rust
    use chrono::NaiveDate;
    use helixio_docs_sheets::{SOFR_CLOSE_DATE, USD_SOFR_CLOSE};
    use helixrs::{AnyInstrument, CollateralSpec, Currency, DayCountFraction, Loan, MarketContext, QuoteSheet, Schedule};
    
    type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
    
    const NOTIONAL: f64 = 10_000_000.0;
    
    fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
        NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
    }
    
    /// A five-year fixed loan at `rate` on ACT/360, from the lender's side unless `borrower`.
    fn fixed(schedule: &Schedule, usd: Currency, rate: f64, borrower: bool) -> AnyInstrument {
        AnyInstrument::Loan(Box::new(Loan::new(schedule, usd, NOTIONAL, rate, DayCountFraction::Act360, borrower)))
    }
    
    /// The lender's side of a five-year SOFR loan at `margin` over.
    fn floating(schedule: &Schedule, usd: Currency, margin: f64) -> Outcome<AnyInstrument> {
        Ok(AnyInstrument::Loan(Box::new(Loan::floating(
            schedule,
            usd,
            NOTIONAL,
            "USD-SOFR",
            margin,
            DayCountFraction::Act360,
            false,
        )?)))
    }
    
    fn main() -> Outcome<()> {
        let usd: Currency = "USD".parse()?;
        let market = MarketContext::as_of(date(SOFR_CLOSE_DATE)).with_recipe_quotes(
            &CollateralSpec::Currency(usd),
            &QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?,
        )?;
        // Five annual periods from spot.
        let schedule = Schedule::from_dates((0..6).map(|i| date((2026 + i, 10, 5))).collect())?;
    
        // Lend ten million for five years at 5.00%, read the rate that is fair, and
        // book the same loan from the borrower's side.
        let loan = fixed(&schedule, usd, 0.05, false);
        let par = loan.par_rate(&market)?;
        let at_par = fixed(&schedule, usd, par, false).npv(&market)?;
        let borrowed = fixed(&schedule, usd, 0.05, true);
        println!("npv         {:.2}", loan.npv(&market)?);
        println!("par rate    {par:.8}");
        println!("at par      {:.6}", at_par.abs());
        println!("borrower    {:.2}", borrowed.npv(&market)?);
    
        // A SOFR loan at 150bp over, and the par margin: the spread that makes it worth
        // nothing.
        let floater = floating(&schedule, usd, 0.015)?;
        let margin = floater.par_spread(&market)?;
        let at_margin = floating(&schedule, usd, margin)?.npv(&market)?;
        println!("npv         {:.2}", floater.npv(&market)?);
        println!("par margin  {margin:.8}");
        println!("at margin   {:.6}", at_margin.abs());
    
        Ok(())
    }
    Python
    import datetime as dt
    
    import helixio as hlx
    from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
    
    NOTIONAL = 10_000_000.0
    market = hlx.MarketContext(SOFR_CLOSE_DATE).with_recipe_quotes("USD", USD_SOFR_CLOSE)
    # Five annual periods from spot.
    schedule = hlx.dates.Schedule.from_dates([dt.date(2026 + i, 10, 5) for i in range(6)])
    
    def fixed(rate, borrower=False):
        """A five-year fixed loan at `rate` on ACT/360, from the lender's side by default."""
        return hlx.products.loan(
            schedule, "USD", notional=NOTIONAL, fixed_rate=rate, day_count="ACT/360", borrower=borrower
        )
    
    def floating(margin):
        """The lender's side of a five-year SOFR loan at `margin` over."""
        return hlx.products.loan(
            schedule, "USD", notional=NOTIONAL, index="USD-SOFR", margin=margin, day_count="ACT/360", borrower=False
        )
    
    # Lend ten million for five years at 5.00%, read the rate that is fair, and
    # book the same loan from the borrower's side.
    loan = fixed(0.05)
    par = loan.par_rate(market)
    at_par = fixed(par).npv(market)
    borrowed = fixed(0.05, borrower=True)
    print(f"npv         {loan.npv(market):.2f}")
    print(f"par rate    {par:.8f}")
    print(f"at par      {abs(at_par):.6f}")
    print(f"borrower    {borrowed.npv(market):.2f}")
    
    # A SOFR loan at 150bp over, and the par margin: the spread that makes it worth
    # nothing.
    floater = floating(0.015)
    margin = floater.par_spread(market)
    at_margin = floating(margin).npv(market)
    print(f"npv         {floater.npv(market):.2f}")
    print(f"par margin  {margin:.8f}")
    print(f"at margin   {abs(at_margin):.6f}")
    npv         128303.01
    par rate    0.04709919
    at par      0.000000
    borrower    -128303.01
    npv         663435.22
    par margin  0.00000034
    at margin   0.000000

    5.00% is above the par rate, so the loan is worth something to the lender and exactly the opposite to the borrower. On a curve that both projects SOFR and discounts it, the index leg reprices to par, so the par margin is close to zero and the 150bp margin is the lender's value.

    How it's priced

    A loan of notional NN over periods i=1,…,ni = 1, \dots, n with accrual fractions τi\tau_i and fixed rate KK is worth to the lender

    V=−N P(t0)+∑i=1nNi−1Kτi P(ti)+∑i=1n(Ni−1−Ni) P(ti),V = -N\,P(t_0) + \sum_{i=1}^{n} N_{i-1} K \tau_i\,P(t_i) + \sum_{i=1}^{n} (N_{i-1} - N_i)\,P(t_i),

    where NiN_i is the principal outstanding after period ii, with N0=NN_0 = N and Nn=0N_n = 0. A bullet loan has Ni=NN_i = N until maturity, and straight-line amortisation has Ni=N(1−i/n)N_i = N(1 - i/n). The borrower holds −V-V. The par rate K∗K^\ast makes V=0V = 0; on a floating loan, the par margin m∗m^\ast does.

    Tagged to a borrower with survival Q(t)Q(t) and recovery RR, each servicing flow is weighted by Q(ti)Q(t_i), and a default between ti−1t_{i-1} and tit_i recovers R Ni−1R\,N_{i-1}. The drawdown stays risk-free. Why credit is what a lender is paid for is covered in how financing works.

    API

    products.loan(schedule, currency, *, notional, day_count, ...):

    Argument Type Default Meaning
    schedule Schedule required the period dates: drawn on the first, repaid by the last
    currency str required the loan's currency
    notional float required the principal drawn
    day_count str or DayCountFraction required how each period's interest accrues
    fixed_rate float none the fixed rate; give this or index
    index str none a term or overnight index code, such as "USD-SOFR", to float the interest on
    margin float 0.0 the spread over index
    borrower bool True books the borrower's side; False books the lender's
    amortisation str none: a bullet "Straight-Line" repays notional / n at each period end
    issuer str none the borrower's credit curve key, such as "ACME/SeniorUnSec/XR14/USD"

    products.callable_loan(loan, schedule, issuer=None, *, exercise="bermudan") adds rights to a loan:

    Argument Type Default Meaning
    loan Instrument required the loan the rights sit on
    schedule list[tuple] required (date, price, "call" | "put"), with an optional fourth element, the price basis ("clean", the default, or "dirty"): the borrower may prepay on a call, the lender demand repayment on a put, at price per 100 of the principal outstanding, plus accrued on a clean basis
    issuer str none the borrower's credit
    exercise str "bermudan" how the dates are exercised, as for a callable bond

    In Rust a fixed loan is Loan::new(schedule, currency, notional, rate, day_count, borrower), a floating one Loan::floating(schedule, currency, notional, index, margin, day_count, borrower), then .amortising_straight_line() and .issued_by(key). The rights are CallableLoan::new(loan, rights) with Callability::call(date, price) and Callability::put.

    Symbol Meaning Rust Python
    NN, KK, τi\tau_i a fixed loan Loan::new products.loan(.., fixed_rate=)
    mm a floating loan's margin Loan::floating products.loan(.., index=, margin=)
    NiN_i the straight-line outstanding Loan::amortising_straight_line amortisation="Straight-Line"
    QQ, RR the borrower's credit Loan::issued_by with MarketContext::with_credit_curve issuer= with MarketContext.with_credit_curve
    K∗K^\ast the par rate AnyInstrument::par_rate Instrument.par_rate
    m∗m^\ast the par margin AnyInstrument::par_spread Instrument.par_spread
    VV its value AnyInstrument::npv Instrument.npv

    Defaults and limits

    • Side: a loan is the borrower's unless you pass borrower=False, because the borrower books a loan most often. The two sides hold opposite values.
    • Rate: give exactly one of fixed_rate and index.
    • Par quotes: par_rate belongs to a fixed loan and par_spread to a floating one; asking a loan for the other one is refused.
    • Credit: only the servicing is weighted by survival. The drawdown, which the lender pays, stays risk-free. The market needs the borrower's credit curve and a recovery rate under the issuer key, with_credit_curve(key, curve, recovery).
    • Rights: a callable loan is valued on the currency's Hull-White lattice, so the market needs with_hull_white(currency, mean_reversion, vol). A floating loan exercises on its interest dates only.
    • A deposit: booked from the lender's side, a loan is a term deposit, and with a put it's a puttable deposit.

    More examples

    The rows of the loan's cashflow table checked by hand, straight-line amortisation, the borrower's credit, and the borrower's and lender's rights. The rows and their present values add up to the loan's value; amortising lowers the value at an above-par rate; tagging the loan to its borrower lowers its value and raises its par rate; and the prepayment right costs the lender while a put adds to its value.

    Rust
    use std::sync::Arc;
    
    use chrono::NaiveDate;
    use helixio_docs_sheets::{SOFR_CLOSE_DATE, USD_SOFR_CLOSE};
    use helixrs::{
        AnyInstrument, CallableLoan, Callability, CollateralSpec, Currency, DayCountFraction,
        FlatHazard, Loan, MarketContext, QuoteSheet, Schedule, Survival,
    };
    
    type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
    
    const NOTIONAL: f64 = 10_000_000.0;
    const BORROWER: &str = "ACME/SeniorUnSec/XR14/USD";
    
    fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
        NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
    }
    
    /// The lender's side of a five-year fixed loan at `rate` on ACT/360.
    fn fixed(schedule: &Schedule, usd: Currency, rate: f64) -> Loan {
        Loan::new(schedule, usd, NOTIONAL, rate, DayCountFraction::Act360, false)
    }
    
    fn boxed(loan: Loan) -> AnyInstrument {
        AnyInstrument::Loan(Box::new(loan))
    }
    
    fn joined(values: &[f64]) -> String {
        values.iter().map(|v| format!("{v:.0}")).collect::<Vec<_>>().join("  ")
    }
    
    fn main() -> Outcome<()> {
        let usd: Currency = "USD".parse()?;
        let close = date(SOFR_CLOSE_DATE);
        let market = MarketContext::as_of(close).with_recipe_quotes(
            &CollateralSpec::Currency(usd),
            &QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?,
        )?;
        let schedule = Schedule::from_dates((0..6).map(|i| date((2026 + i, 10, 5))).collect())?;
        let loan = boxed(fixed(&schedule, usd, 0.05));
    
        // The drawdown paid, five interest rows and the repayment received. Each
        // interest row is the notional times the rate times its ACT/360 fraction, and
        // the rows' present values add up to the loan's value.
        let rows = loan.cashflow_records(&market)?;
        let kind = rows["kind"].text().expect("text");
        let side = rows["direction"].text().expect("text");
        let pay = rows["payment_date"].dates().expect("dates");
        let amount = rows["amount"].numbers().expect("numbers");
        for i in 0..rows.len() {
            println!(
                "{:<10} {:<7} {}  {:14.2}",
                kind[i],
                side[i],
                pay[i].expect("a payment date"),
                amount[i]
            );
        }
        let first = kind.iter().position(|k| k == "Fixed").expect("an interest row");
        let (start, end) = (
            rows["accrual_start"].dates().expect("dates")[first].expect("a start"),
            rows["accrual_end"].dates().expect("dates")[first].expect("an end"),
        );
        #[allow(clippy::cast_precision_loss)] // days in a year
        let by_hand = NOTIONAL * 0.05 * (end - start).num_days() as f64 / 360.0;
        let total: f64 = rows["pv"].numbers().expect("numbers").iter().sum();
        println!("first interest by hand  {by_hand:.2}");
        println!("sum of pv   {total:.2}");
        println!("npv         {:.2}", loan.npv(&market)?);
    
        // Amortising in a straight line: the principal falls by a fifth each period,
        // and each period's interest accrues on what is still outstanding.
        let amortising = boxed(fixed(&schedule, usd, 0.05).amortising_straight_line());
        let amortising_rows = amortising.cashflow_records(&market)?;
        let amortising_kind = amortising_rows["kind"].text().expect("text");
        let notional = amortising_rows["notional"].numbers().expect("numbers");
        let amortising_amount = amortising_rows["amount"].numbers().expect("numbers");
        let outstanding: Vec<f64> = (0..amortising_rows.len())
            .filter(|&i| amortising_kind[i] == "Fixed")
            .map(|i| notional[i])
            .collect();
        let repaid: Vec<f64> = (0..amortising_rows.len())
            .filter(|&i| amortising_kind[i] == "Redemption")
            .map(|i| amortising_amount[i])
            .skip(1)
            .collect();
        println!("outstanding {}", joined(&outstanding));
        println!("repaid      {}", joined(&repaid));
        let amortised = amortising.npv(&market)?;
        println!("npv         {amortised:.2}");
    
        // Tagged to its borrower, the servicing is survival-weighted with 40% recovery
        // on the principal outstanding, while the drawdown the lender pays is not: the
        // loan is worth less to the lender, and its fair rate is higher.
        let survival = Survival::FlatHazard(FlatHazard::new(close, 0.02, DayCountFraction::Act365Fixed)?);
        let risky = market
            .clone()
            .with_credit_curve(BORROWER.parse()?, Arc::new(survival), Some(0.40));
        let tagged = boxed(fixed(&schedule, usd, 0.05).issued_by(BORROWER.parse()?));
        let par = loan.par_rate(&risky)?;
        println!("riskless    {:.2}  par rate {par:.8}", loan.npv(&risky)?);
        println!("tagged      {:.2}  par rate {:.8}", tagged.npv(&risky)?, tagged.par_rate(&risky)?);
    
        // On the Hull-White lattice: the borrower's right to prepay at par each year
        // costs the lender, and a lender's right to demand repayment is worth having.
        let lattice = market.clone().with_hull_white(usd, 0.03, 0.01);
        let rights: Vec<NaiveDate> = (0..4).map(|i| date((2027 + i, 10, 5))).collect();
        let prepayable = AnyInstrument::CallableLoan(Box::new(CallableLoan::new(
            fixed(&schedule, usd, 0.05),
            rights.iter().map(|&d| Callability::call(d, 100.0)).collect(),
        )?));
        let puttable = AnyInstrument::CallableLoan(Box::new(CallableLoan::new(
            fixed(&schedule, usd, 0.05),
            rights.iter().map(|&d| Callability::put(d, 100.0)).collect(),
        )?));
        let bullet = loan.npv(&lattice)?;
        println!("bullet      {bullet:.2}");
        println!("prepayable  {:.2}", prepayable.npv(&lattice)?);
        println!("puttable    {:.2}", puttable.npv(&lattice)?);
    
        Ok(())
    }
    Python
    import datetime as dt
    
    import helixio as hlx
    from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
    
    NOTIONAL = 10_000_000.0
    BORROWER = "ACME/SeniorUnSec/XR14/USD"
    market = hlx.MarketContext(SOFR_CLOSE_DATE).with_recipe_quotes("USD", USD_SOFR_CLOSE)
    schedule = hlx.dates.Schedule.from_dates([dt.date(2026 + i, 10, 5) for i in range(6)])
    
    def fixed(rate, **kw):
        """The lender's side of a five-year fixed loan at `rate` on ACT/360."""
        return hlx.products.loan(
            schedule, "USD", notional=NOTIONAL, fixed_rate=rate, day_count="ACT/360", borrower=False, **kw
        )
    
    loan = fixed(0.05)
    
    # The drawdown paid, five interest rows and the repayment received. Each
    # interest row is the notional times the rate times its ACT/360 fraction, and
    # the rows' present values add up to the loan's value.
    rows = loan.cashflow_records(market).to_dict()
    for kind, side, pay, amount in zip(rows["kind"], rows["direction"], rows["payment_date"], rows["amount"]):
        print(f"{kind:<10} {side:<7} {pay}  {amount:14.2f}")
    first = rows["kind"].index("Fixed")
    days = (rows["accrual_end"][first] - rows["accrual_start"][first]).days
    by_hand = NOTIONAL * 0.05 * days / 360
    total = sum(rows["pv"])
    print(f"first interest by hand  {by_hand:.2f}")
    print(f"sum of pv   {total:.2f}")
    print(f"npv         {loan.npv(market):.2f}")
    
    # Amortising in a straight line: the principal falls by a fifth each period,
    # and each period's interest accrues on what is still outstanding.
    amortising = fixed(0.05, amortisation="Straight-Line")
    amortising_rows = amortising.cashflow_records(market).to_dict()
    outstanding = [n for k, n in zip(amortising_rows["kind"], amortising_rows["notional"]) if k == "Fixed"]
    repaid = [a for k, a in zip(amortising_rows["kind"], amortising_rows["amount"]) if k == "Redemption"][1:]
    print("outstanding " + "  ".join(f"{n:.0f}" for n in outstanding))
    print("repaid      " + "  ".join(f"{a:.0f}" for a in repaid))
    amortised = amortising.npv(market)
    print(f"npv         {amortised:.2f}")
    
    # Tagged to its borrower, the servicing is survival-weighted with 40% recovery
    # on the principal outstanding, while the drawdown the lender pays is not: the
    # loan is worth less to the lender, and its fair rate is higher.
    risky = market.with_credit_curve(BORROWER, hlx.curves.SurvivalCurve.flat_hazard(SOFR_CLOSE_DATE, 0.02), 0.40)
    tagged = fixed(0.05, issuer=BORROWER)
    par = loan.par_rate(risky)
    print(f"riskless    {loan.npv(risky):.2f}  par rate {par:.8f}")
    print(f"tagged      {tagged.npv(risky):.2f}  par rate {tagged.par_rate(risky):.8f}")
    
    # On the Hull-White lattice: the borrower's right to prepay at par each year
    # costs the lender, and a lender's right to demand repayment is worth having.
    lattice = market.with_hull_white("USD", 0.03, 0.01)
    rights = [dt.date(2027 + i, 10, 5) for i in range(4)]
    prepayable = hlx.products.callable_loan(loan, [(d, 100.0, "call") for d in rights])
    puttable = hlx.products.callable_loan(loan, [(d, 100.0, "put") for d in rights])
    bullet = loan.npv(lattice)
    print(f"bullet      {bullet:.2f}")
    print(f"prepayable  {prepayable.npv(lattice):.2f}")
    print(f"puttable    {puttable.npv(lattice):.2f}")
    Redemption Pay     2026-10-05     10000000.00
    Fixed      Receive 2027-10-05       506944.44
    Fixed      Receive 2028-10-05       508333.33
    Fixed      Receive 2029-10-05       506944.44
    Fixed      Receive 2030-10-05       506944.44
    Fixed      Receive 2031-10-05       506944.44
    Redemption Receive 2031-10-05     10000000.00
    first interest by hand  506944.44
    sum of pv   128303.01
    npv         128303.01
    outstanding 10000000  8000000  6000000  4000000  2000000
    repaid      2000000  2000000  2000000  2000000  2000000
    npv         93305.08
    riskless    128303.01  par rate 0.04709919
    tagged      -410531.71  par rate 0.05983554
    bullet      128303.01
    prepayable  -85485.37
    puttable    277915.77

    See also

    • Source: crates/helixrs/src/instruments/loan.rs
    • Related pages:
      • How financing works, for credit and prepayment.
      • Fee, for the arrangement fee that usually sits beside a loan.
      • Money-market deposit, for the one-period loan a curve starts from.
      • Legs and coupons, for the two legs a loan is.
      • Callable bond, for the lattice and its exercise.
      • Survival curves, for the borrower's credit curve.

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