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.
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(())
}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 over periods with accrual fractions and fixed rate is worth to the lender
where is the principal outstanding after period , with and . A bullet loan has until maturity, and straight-line amortisation has . The borrower holds . The par rate makes ; on a floating loan, the par margin does.
Tagged to a borrower with survival and recovery , each servicing flow is weighted by , and a default between and recovers . 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 |
|---|---|---|---|
| , , | a fixed loan | Loan::new |
products.loan(.., fixed_rate=) |
| a floating loan's margin | Loan::floating |
products.loan(.., index=, margin=) |
|
| the straight-line outstanding | Loan::amortising_straight_line |
amortisation="Straight-Line" |
|
| , | the borrower's credit | Loan::issued_by with MarketContext::with_credit_curve |
issuer= with MarketContext.with_credit_curve |
| the par rate | AnyInstrument::par_rate |
Instrument.par_rate |
|
| the par margin | AnyInstrument::par_spread |
Instrument.par_spread |
|
| 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_rateandindex. - Par quotes:
par_ratebelongs to a fixed loan andpar_spreadto 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
issuerkey,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.
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(())
}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.