Skip to content

quanttoolbox.bond

bond.pricing

Python alternatives

Hybrid: QuantLib-Python has far more complete bond conventions (day counts, calendars, callable/amortizing structures) than this flat-rate, cash-flow-list version — reach for it once real-world conventions matter. Keep this module for quick, dependency-free pricing/YTM against an explicit cash-flow schedule, and for bond_portfolio_quadratic_form(_vs_benchmark): sector-level MD/DTS-targeting quadratic risk forms with no equivalent found elsewhere. See Library alternatives for the full reasoning.

quanttoolbox.bond.pricing

Bond present value, yield to maturity, current yield, and quadratic-form bond-portfolio risk against sector-level modified-duration / DTS targets.

Ported from HSF toolbox bond/{compute_bond_price,compute_bond_ytm, compute_coupon_yield,quadratic_form_bond_portfolio1, quadratic_form_bond_portfolio2}.m.

Translation notes:

  • compute_bond_ytm.m hand-rolls a bisection loop (stopping once b - a <= 1e-5, bracketed in [0, 1]) to find the rate that reprices a bond to a target price. Ported here via the package's existing optim.bisection.bisection rather than reimplementing the loop, since price(rate) is exactly the monotone bracket-and-root-find problem that already solves.
  • quadratic_form_bond_portfolio1.m/2.m depend on quadratic_form/ quadratic_form_risk -- defined in the HSF toolbox's hsf/ folder, not bond/ -- ported alongside as quanttoolbox.sustainable_finance.risk, since they're generic sector-based quadratic-risk building blocks, not bond-specific (see that module's docstring). bond_portfolio_metrics.m (portfolio-level modified duration / DTS, also in hsf/) is ported there too rather than duplicated here.
  • quadratic_form_bond_portfolio1.m/2.m are duplicated verbatim between bond/ and hsf/ in the original source; ported once, here.

BondPortfolioQuadraticForm(qf, q, r, c, md, dts) dataclass

Quadratic-form risk of a bond portfolio: qf(w) = 0.5 w'Qw - w'R + c, combining modified-duration risk, DTS (duration-times-spread) risk, and a linear carry term.

BondPortfolioQuadraticFormVsBenchmark(qf, q, r, c, md, dts, q_as, r_as, c_as, q_md, r_md, c_md, q_dts, r_dts, c_dts) dataclass

Same shape as BondPortfolioQuadraticForm, plus the individual active-share (AS)/MD/DTS terms the combined (q, r, c) was built from (each re-centered around the benchmark weights b).

bond_portfolio_quadratic_form(sector, varphi_md, md, md_star, varphi_dts, dts, dts_star, gamma_carry, carry, w)

Combine sector-level modified-duration and DTS quadratic-risk terms with a linear carry term into a single bond-portfolio quadratic form, evaluated at weights w.

Original: bond/quadratic_form_bond_portfolio1.m

Source code in src/quanttoolbox/bond/pricing.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def bond_portfolio_quadratic_form(
    sector: np.ndarray,
    varphi_md: float,
    md: np.ndarray,
    md_star: np.ndarray,
    varphi_dts: float,
    dts: np.ndarray,
    dts_star: np.ndarray,
    gamma_carry: float,
    carry: np.ndarray,
    w: np.ndarray,
) -> BondPortfolioQuadraticForm:
    """Combine sector-level modified-duration and DTS quadratic-risk terms
    with a linear carry term into a single bond-portfolio quadratic form,
    evaluated at weights `w`.

    Original: bond/quadratic_form_bond_portfolio1.m
    """
    md_result = quadratic_form_risk(sector, md, md_star, w)
    dts_result = quadratic_form_risk(sector, dts, dts_star, w)

    carry = np.asarray(carry, dtype=float)
    w = np.asarray(w, dtype=float)

    q = varphi_md * md_result.q + varphi_dts * dts_result.q
    r = gamma_carry * carry + varphi_md * md_result.r + varphi_dts * dts_result.r
    c = varphi_md * md_result.c + varphi_dts * dts_result.c
    qf = 0.5 * w @ q @ w - w @ r + c

    return BondPortfolioQuadraticForm(
        qf=float(qf), q=q, r=r, c=float(c), md=md_result, dts=dts_result
    )

bond_portfolio_quadratic_form_vs_benchmark(sector, varphi_as, varphi_md, md, md_star, varphi_dts, dts, dts_star, gamma_carry, carry, w, b)

Same as bond_portfolio_quadratic_form, but expressed relative to a benchmark weight vector b: adds an active-share quadratic penalty centered at b (0.5(w-b)'(w-b) up to the constant, expanded into the (Q, R, c) form), and re-centers the MD/DTS quadratic forms around b too.

md_star/dts_star default to per-sector zero targets when None (matching the original's isempty(...) fallback).

Original: bond/quadratic_form_bond_portfolio2.m

Source code in src/quanttoolbox/bond/pricing.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
def bond_portfolio_quadratic_form_vs_benchmark(
    sector: np.ndarray,
    varphi_as: float,
    varphi_md: float,
    md: np.ndarray,
    md_star: np.ndarray | None,
    varphi_dts: float,
    dts: np.ndarray,
    dts_star: np.ndarray | None,
    gamma_carry: float,
    carry: np.ndarray,
    w: np.ndarray,
    b: np.ndarray,
) -> BondPortfolioQuadraticFormVsBenchmark:
    """Same as `bond_portfolio_quadratic_form`, but expressed relative to a
    benchmark weight vector `b`: adds an active-share quadratic penalty
    centered at `b` (``0.5(w-b)'(w-b)`` up to the constant, expanded into
    the (Q, R, c) form), and re-centers the MD/DTS quadratic forms around
    `b` too.

    `md_star`/`dts_star` default to per-sector zero targets when `None`
    (matching the original's `isempty(...)` fallback).

    Original: bond/quadratic_form_bond_portfolio2.m
    """
    n_sector = np.unique(np.asarray(sector)).shape[0]
    if md_star is None:
        md_star = np.zeros(n_sector)
    if dts_star is None:
        dts_star = np.zeros(n_sector)

    md_result = quadratic_form_risk(sector, md, md_star, w)
    dts_result = quadratic_form_risk(sector, dts, dts_star, w)

    w = np.asarray(w, dtype=float)
    b = np.asarray(b, dtype=float)
    carry = np.asarray(carry, dtype=float)
    n = w.shape[0]

    q_as = np.eye(n)
    r_as = b
    c_as = 0.5 * (b @ b)

    q_md = md_result.q
    r_md = md_result.r + md_result.q @ b
    c_md = 0.5 * b @ md_result.q @ b + b @ md_result.r + md_result.c

    q_dts = dts_result.q
    r_dts = dts_result.r + dts_result.q @ b
    c_dts = 0.5 * b @ dts_result.q @ b + b @ dts_result.r + dts_result.c

    q = varphi_as * q_as + varphi_md * q_md + varphi_dts * q_dts
    r = gamma_carry * carry + varphi_as * r_as + varphi_md * r_md + varphi_dts * r_dts
    c = gamma_carry * (b @ carry) + varphi_as * c_as + varphi_md * c_md + varphi_dts * c_dts
    qf = 0.5 * w @ q @ w - w @ r + c

    return BondPortfolioQuadraticFormVsBenchmark(
        qf=float(qf),
        q=q,
        r=r,
        c=float(c),
        md=md_result,
        dts=dts_result,
        q_as=q_as,
        r_as=r_as,
        c_as=float(c_as),
        q_md=q_md,
        r_md=r_md,
        c_md=float(c_md),
        q_dts=q_dts,
        r_dts=r_dts,
        c_dts=float(c_dts),
    )

bond_price(maturities, cash_flows, rate, method=1)

Present value of a bond's cash flows under a flat discount rate.

method=1 (default): continuous discounting, exp(-t * rate). method=2: discrete (annually-compounded) discounting, 1 / (1 + rate)**t.

Original: bond/compute_bond_price.m

Source code in src/quanttoolbox/bond/pricing.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def bond_price(
    maturities: np.ndarray | float,
    cash_flows: np.ndarray,
    rate: float,
    method: int = 1,
) -> float:
    """Present value of a bond's cash flows under a flat discount rate.

    method=1 (default): continuous discounting, ``exp(-t * rate)``.
    method=2: discrete (annually-compounded) discounting,
    ``1 / (1 + rate)**t``.

    Original: bond/compute_bond_price.m
    """
    t = np.asarray(maturities, dtype=float)
    ct = np.asarray(cash_flows, dtype=float)

    if method == 1:
        discount = np.exp(-t * rate)
    else:
        discount = 1.0 / (1.0 + rate) ** t

    return float(np.sum(ct * discount))

bond_ytm(maturities, cash_flows, price, config=None, method=1)

Yield to maturity: the flat rate that reprices cash_flows to price (bisection search over the rate bracket [0, 1], i.e. 0%-100% annual yield).

method selects the same discounting convention as bond_price: method=1 (default, matches the original) is continuous compounding (exp(-t*rate)); method=2 is discrete annual compounding (1/(1+rate)**t) -- added for callers whose own pricing convention is discrete (e.g. HSF-Notebooks chapter 4c), not part of the original MATLAB source, which only ever calls this with continuous compounding.

Original: bond/compute_bond_ytm.m (bisection loop replaced by optim.bisection.bisection -- see module docstring)

Source code in src/quanttoolbox/bond/pricing.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def bond_ytm(
    maturities: np.ndarray | float,
    cash_flows: np.ndarray,
    price: float,
    config: BisectionConfig | None = None,
    method: int = 1,
) -> float:
    """Yield to maturity: the flat rate that reprices `cash_flows` to
    `price` (bisection search over the rate bracket [0, 1], i.e. 0%-100%
    annual yield).

    `method` selects the same discounting convention as `bond_price`:
    method=1 (default, matches the original) is continuous compounding
    (``exp(-t*rate)``); method=2 is discrete annual compounding
    (``1/(1+rate)**t``) -- added for callers whose own pricing convention
    is discrete (e.g. HSF-Notebooks chapter 4c), not part of the original
    MATLAB source, which only ever calls this with continuous compounding.

    Original: bond/compute_bond_ytm.m (bisection loop replaced by
    `optim.bisection.bisection` -- see module docstring)
    """
    if config is None:
        config = BisectionConfig(tol=1e-5)  # matches the original's (b-a) > 1e-5 stopping test

    def f(rate: np.ndarray) -> float:
        return bond_price(maturities, cash_flows, float(rate), method=method) - price

    return float(bisection(f, 0.0, 1.0, config=config))

coupon_yield(maturities, cash_flows, rate)

Current yield: first cash flow / present value.

Original: bond/compute_coupon_yield.m

Source code in src/quanttoolbox/bond/pricing.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def coupon_yield(
    maturities: np.ndarray | float,
    cash_flows: np.ndarray,
    rate: float,
) -> float:
    """Current yield: first cash flow / present value.

    Original: bond/compute_coupon_yield.m
    """
    ct = np.asarray(cash_flows, dtype=float)
    price = bond_price(maturities, cash_flows, rate, method=1)
    return float(ct[0] / price)