Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Loans, prepayment and credit

A loan promises interest and principal payments according to a contractual repayment schedule. Its value also depends on expected prepayment and default. QuantSupport keeps the contractual schedule fixed while the pricer projects these expected receipts under the chosen market assumptions.

Loan<T> stores the contract as a Leg<T> of coupons, redemptions and the initial advance. Coupon notionals and principal redemptions are contractual f64 amounts. Supply a leg directly or use MakeLoan<T> to generate it from repayment and schedule terms.

The builder defaults to monthly payments, French amortization, Actual/365 accrual, a null calendar, unadjusted payments, zero fixing lag, no prepayment or credit, and 40% recovery. Set the calendar, fixing lag, and payment convention to the contract’s actual terms.

Coupons use the same positional list as SteppedCouponBond: Rust accepts with_coupons(Vec<CashflowType<T>>) and Python accepts the corresponding FixedRateCoupon, FloatingRateCoupon, and OptionEmbeddedCoupon objects in coupons=[...]. The final supplied coupon repeats through maturity. For monthly payments, [qs.FixedRateCoupon(rate=qs.InterestRate(rate=0.04))] * 12 followed by [qs.FloatingRateCoupon(index=qs.MarketIndex.EURIBOR12m, spread=0.01)] defines twelve fixed periods followed by floating periods. Lists can also step fixed coupons, spreads or floating indices. Empty lists, non-coupon entries and lists longer than the generated schedule are rejected. OptionEmbeddedCoupon accepts a native PayoffOps expression, such as (2 * qs.PayoffOps.Index + 0.005).max(0.02).min(0.09). The expression evaluates the observed index; the coupon’s separate spread is added afterward. Serialization preserves the complete expression tree.

Contractual principal

The builder computes principal before considering any prepayment function. For initial principal \(B\) and annual amortization rate \(r\), let \(\tau_k\) be the contractual day-count fraction and \(B_f\) the final balloon. With \(B_{k-1}\) the opening scheduled balance,

\[ i_k=r\tau_k,\qquad D_k=\prod_{j=1}^{k}(1+i_j)^{-1},\qquad A=\frac{B-B_fD_n}{\sum_{k=1}^{n}D_k}, \] \[ I_k=B_{k-1}i_k,\qquad R_k=A-I_k. \]

Here \(A\) is the constant scheduled instalment, \(I_k\) interest and \(R_k\) scheduled principal.

These annuity factors use contractual interest accrual; they are independent of market discount curves. Unequal intervals and final stubs use their own fractions. If every contractual fraction is exactly \(1/12\), a 100,000 principal, 6% coupon and 360 payments produce an instalment of 599.550525. This numerical example assumes equal accrual fractions. Actual schedule fractions depend on the payment dates and day counter. At zero interest and with no balloon, the instalment is principal divided by the number of periods.

Rust makers built with MakeInstrument::build_using obtain the initial all-in rate from the first coupon’s fixing or forecast, including spread and payoff rules. An explicit with_initial_amortization_rate overrides that observation. Direct build() construction requires an explicit initial all-in amortization_rate for floating-first French/balloon loans. Python makers use build_using(context) when that rate is omitted. Excel constructs the loan using its explicit construction context. The resolved rate fixes the principal schedule. Subsequent market moves or reset observations change floating interest without changing contractual coupon notionals or redemptions. Otherwise the first fixed coupon supplies the initial amortization rate for every principal period; later coupon steps do not automatically recast principal. Bullet loans repay principal at maturity.

Accrual day counters belong to coupons. A floating coupon uses its explicit day counter, falling back to its index convention when omitted. Fixed coupons use their InterestRate day counter. The loan uses those same conventions for principal amortization and preserves them when generating each cashflow. MakeLoan::with_day_counter and Python Loan(day_counter=...) have been removed; move that setting into the coupon. With an initial amortization rate matching the first all-in floating fixing, French payments are level through the first reset, including months with different accrual lengths. Later resets can change payments because contractual principal remains fixed.

The following Rust fragment builds a floating mortgage; its market and trade are supplied by the surrounding application.

use quantsupport::prelude::*;
use quantsupport::time::calendars::target::TARGET;

let loan = MakeLoan::<DualFwd>::default()
    .with_identifier("EUR-MORTGAGE".into())
    .with_start_date(Date::new(2026, 2, 24))
    .with_maturity_date(Date::new(2056, 2, 24))
    .with_notional(250_000.0)
    .with_currency(Currency::EUR)
    .with_coupons(vec![CashflowType::floating(MarketIndex::EURIBOR12m, 0.01, None, None)])
    .with_initial_amortization_rate(0.05)
    .with_payment_frequency(Frequency::Monthly)
    .with_calendar(Calendar::TARGET(TARGET::new()))
    .with_fixing_lag_business_days(2)
    .build()?;

let contractual_cashflows = loan.contractual_leg().cashflows();
let mut pricer = LoanPricer::new();
pricer.set_discount_policy(Box::new(
    FixedIncomeDiscountPolicy::new(false)
        .with_risk_free_index(Currency::EUR, MarketIndex::ESTR),
));

Coupon observation requests

Payment frequency, reset frequency, and index tenor are independent. RateIndexDetails::frequency() defines the index observation frequency, separately from its quote compounding frequency. The builder reads this metadata rather than matching particular index names. EURIBOR12m, for example, supplies annual resets and a 12-month observation tenor for monthly-payment loans. Explicit builder frequencies and tenors override the metadata. Daily indices default to point observations at the loan’s payment boundaries; specify the loan’s actual reset conventions explicitly when these differ.

Each floating coupon carries a ForwardRateRequest for its applicable reset. Monthly coupons can share the same annual request. The request contains the fixing date and full index observation start/end dates. This separates the rate observation from the coupon’s interest accrual interval. Coupon changes occur at payment accrual boundaries. Each contiguous run of a floating index starts a new reset grid; changing only its spread or bounds preserves the existing grid.

The pricer resolves required historical resets from exact stored fixings. Today’s fixing is used when present and forecast otherwise. Future observations use the index curve and its quote convention. It installs fixings on a cloned leg, leaving the instrument unchanged.

Prepayment functions

A Loan<T> carries a PrepaymentModel<T> function independently of its contractual cashflows. PrepaymentModelConfiguration::Cpr, CprCurve, and Refinancing implement that function for any supported scalar. Custom models passed to MakeLoan<T>::with_custom_prepayment_model use the same T as the coupon templates. For a MakeLoan<DualFwd> already containing the contract terms, a custom function can be attached as in this Rust fragment:

let loan = builder.with_custom_prepayment_model(
    |state: &PrepaymentModelState<DualFwd>| -> Result<DualFwd> {
        // Illustrative annual CPR converted to this interval's probability.
        Ok(DualFwd::scalar(1.0 - 0.88_f64.powf(state.interval_year_fraction())))
    },
).build()?;

Changing a loan’s scalar type requires an explicit target-scalar prepayment model. Use loan.into_scalar::<DualFwd>(Arc::new(model)), and the same method on LoanTrade<T> to preserve its date and side. For portable configurations, pass the same configuration again. For custom models, supply an equivalent implementation for the target scalar; converting an arbitrary model automatically could discard its AD dependencies. Implicit From/Into conversions between loan scalar types are therefore unavailable.

The pricer evaluates the function after scheduled principal. A regular monthly interval converts annual CPR to 1 - (1 - CPR)^(1/12); partial or irregular intervals use actual days divided by 365. Prepayment is a principal receipt. The projected remaining share of the contractual balance reduces future expected interest and scheduled principal proportionally. No contractual leg is modified. For seasoned loans the projection starts from contractual outstanding principal at the evaluation date; prior actual prepayments are not inferred.

Refinancing-incentive models receive an explicitly configured index benchmark. Its observation tenor comes from the index’s metadata. This benchmark excludes replacement-loan lending spreads, fees, and transaction costs. The state exposes it as refinancing_benchmark_rate(), alongside contractual_coupon_rate() and outstanding_principal(). Models return an interval probability through conditional_prepayment_probability(). Native prepayment functions can retain AD dependencies on market rates without changing the cashflows’ f64 notionals.

Valuation and cashflows

LoanPricer implements the standard request handlers. Request::Cashflows uses HandleCashflows over projected legs containing existing cashflow variants: leg 0 contains scheduled coupons/redemptions, leg 1 prepayment redemptions, and leg 2 recovery amounts. The table reports expected signed receipts strictly after the evaluation date. Their discounted sum reproduces Value. The initial advance remains part of the contractual leg but is excluded from receipt PV.

Bounded floating templates retain OptionEmbeddedCoupon. Their floor/cap payoff is applied to the resolved index observation with the loan’s accrual basis. This is a deterministic cashflow projection; it does not add option time value.

With a borrower credit_index, receipts are weighted by survival conditional on the evaluation date. Default between payments pays recovery on opening principal at the period-end payment date. This is a discrete recovery timing approximation. Credit and prepayment affect pricing state, not the contract.

import quantsupport as qs

loan = qs.Loan(
    identifier="EUR-MORTGAGE", start_date=qs.Date(year=2026, month=2, day=24),
    maturity_date=qs.Date(year=2056, month=2, day=24), notional=250_000.0,
    coupons=[qs.FloatingRateCoupon(index=qs.MarketIndex.EURIBOR12m, spread=0.01)],
    currency=qs.Currency.EUR,
    repayment=qs.LoanRepaymentStructure.french(), amortization_rate=0.05,
    payment_frequency=qs.Frequency.Monthly,
    calendar=qs.Calendar.TARGET, fixing_lag=2,
    prepayment=qs.PrepaymentModelConfiguration.constant_annual_cpr(cpr=0.12),
)
trade = qs.Trade(instrument=loan, trade_date=reference_date, side=qs.Side.LongReceive)
discount_policy = qs.FixedIncomeDiscountPolicy(discount_curve_by_currency={qs.Currency.EUR: qs.MarketIndex.ESTR})
with qs.PricingContext(quotes=quotes, curves=curves, fixings=fixings, csa_policy=csa_policy,
                      fixed_income_policy=discount_policy) as context:
    result = context.evaluate(trade=trade, requests=[qs.Request.Value, qs.Request.Cashflows, qs.Request.Sensitivities])
    value = result.price
    expected_receipts = result.cashflows
    risk = result.sensitivities

The Python constructor delegates to the Rust builder. to_dict and from_dict serialize construction parameters; use Python’s json module for files. Typed definitions and their plain serialized dictionaries are both accepted. loan.coupon_fixing_requests() lists unique coupon observations for loading fixings; valuation belongs to the pricer.

context.evaluate(qs.Trade(loan, reference_date), requests) dispatches directly to the core Rust LoanPricer. Use PricerConfiguration.loan(...) with Python arguments and a typed MarketIndex to select fair-rate and refinancing settings:

context.pricer_configurations = [qs.PricerConfiguration.loan(
    refinancing_benchmark_index=qs.MarketIndex.EURIBOR12m,
)]

Omitting this configuration uses the native defaults: no refinancing benchmark and preserved amortization during fair-rate trials. The context’s fixed_income_policy selects discounting. Loans carry their payment currency, while the policy selects the discount curve. The same contract can therefore be valued against ESTR, EURIBOR, or a funding curve by changing the policy. Native Rust valuation requires a configured policy. In Python, the default context policy maps its base currency to its base curve when no explicit fixed-income policy is supplied. The same native settings apply to individual loan trades, portfolio members and scenarios. No Python pricer registration or trade subclass is needed.

Sensitivities are derivatives with respect to labelled market pillars. Multiply a rate-quote derivative by 0.0001 for the first-order PV change from a one-basis-point increase. Native refinancing and survival weights participate in AD; contractual principal schedules stay fixed during market sensitivity calculations.

Fair rate and schedule choice

FairRate bootstraps one fixed coupon or floating spread per contiguous contractual segment at or before origination. A change in fixed rate, spread, coupon type, or floating index starts a segment; index fixing resets do not. First shorten the loan to the first segment’s end and repay all remaining capital there. Solve that segment’s rate so receipt PV equals the original advance PV, lock the result, extend through the next segment, and repeat. Every solved prefix therefore lends at par with its outstanding principal repaid at that prefix’s end. Bounded coupons remain unsupported.

Request::FairRate(index) in Rust and qs.Request.FairRate(index) in Python select one zero-based segment and return its scalar in fair_rate. Only the earlier segments needed for that target are solved. Use separate evaluations for several targets; invalid indices are errors. There is no second fair-rate attribute or implicit array. In Excel, QS.REQUEST.FAIR.RATE(index) supplies the request to QS.EVALUATE, then QS.RESULT.FAIR.RATE reads its scalar.

with context:
    second_rate = context.evaluate(
        trade=trade, requests=[qs.Request.FairRate(1)]
    ).fair_rate

The pricer’s rebuild_amortization_for_fair_rate parameter controls how solver trials treat principal. The default false preserves the already created amortization schedule. With true, each trial regenerates the builder’s French, bullet, or balloon schedule using the first trial coupon’s all-in rate. Once the first segment is solved, its rate stays fixed during later trials. A custom leg needs explicit repayment metadata to use this mode. Neither mode changes the original instrument.

original_configurations = context.pricer_configurations
try:
    for rebuild in [False, True]:
        context.pricer_configurations = [qs.PricerConfiguration.loan(
            rebuild_amortization_for_fair_rate=rebuild,
        )]
        with context:
            fair_rate = context.evaluate(trade, [qs.Request.FairRate(0)]).fair_rate
            print(rebuild, fair_rate)
finally:
    context.pricer_configurations = original_configurations

In Rust, use LoanPricer::new().with_fair_rate_amortization_rebuild(true) for rebuilding. The solver searches within [-5%, 50%], reducing the upper bound when a trial rate would produce invalid amortization. It reports unbracketed solutions as errors. To reprice a preserving-mode solution through the builder, retain the original initial amortization rate while changing the coupon/spread. To reprice a rebuilding-mode solution, use the candidate all-in rate for amortization.

Runnable examples remain in their separate product directories:

  • Rust: cargo run -p valuation-loans.
  • Python: python bindings/python/examples/run_notebooks.py valuation/loans.

Both use the illustrative EUR and borrower-credit inputs in examples/valuation/loans/data/loan_*.json. The mortgage notebook develops amortization, annual EURIBOR resets, composite cost-of-funds discounting, market scenarios, prepayment, credit, and quote sensitivities over thirty years. It reuses one context by assigning context.curves and context.fixed_income_policy. Temporary benchmark and funding-spread shocks use with context, scenario: with qs.Scenario(shocks=[qs.Shock(target=..., value=..., shock_type=...)]). Pricing constructs requested market objects in an independent view. The base context retains its inputs and curves, including when the scope raises an exception.

In Rust, Scenario::new(vec![Shock::new(target, value, ScenarioType::Absolute)]) creates the same combination. scenario.apply(&context)? returns a borrowed MarketDataProvider accepted by pricers and Evaluator; the context stays unchanged. Funding-spread sensitivities use the original zero-rate quote identifiers ZeroRate_EUR_FUNDINGSPREAD_<tenor>. The notebook isolates those derivatives by funding tenor, reports EUR changes for +1 bp, and compares the parallel funding exposure with fully repriced funding stress.