A bond's present value¶
The present value of a coupon bond at a coupon date, its coupons' annuity and its face discounted at the yield a period: P = c (1 - v) / y + F v, with y = yield / f, n = years f, c = F coupon / f and v = (1 + y)^-n = exp(-n log(1 + y)); where the yield is nothing, P = c n + F.
A ratio, in no currency: the face is a number a game gives, 100 for a price per 100 of face, and the game multiplies by its own unit of price. Source: Fabozzi, Bond Markets, Analysis and Strategies, ch. 2. Checked against a reference in numpy, which has its own tests.
- Source
- Fabozzi, Bond Markets, Analysis and Strategies, ch. 2.
- Proof
- curve, limit, invariant.
bond_price.trait
namespace lusine.finance;
float bond_price(float face, float<year^-1> coupon, float<year> years, float<year^-1> yld, float<year^-1> freq)
{
let y = yld / freq;
let n = years * freq;
let c = face * coupon / freq;
let v = exp(-n * log(1.0 + y));
if (y == 0.0) return c * n + face * v;
return c * (1.0 - v) / y + face * v;
}
The price of a coupon bond at a coupon date, its coupons' annuity and its face discounted at the yield a period. A ratio, in no currency: the face is a number, 100 for a price per 100.
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
import numpy as np
import matplotlib.pyplot as plt
from lusine import trait_run
EPS = float(np.finfo(np.float32).eps)
YEAR = 31557600.0 # lusine's year row, the Julian year in seconds: the host stores and reads in the base units
The kernel¶
The law as the library states it, and as this page runs it.
print(trait_run.source('finance/bond_price.trait'))
// lusine.finance.bond_price: the present value of a coupon bond at a coupon date, its coupons' annuity and its face
// discounted at the yield a period: P = c (1 - v) / y + F v, with y = yield / f, n = years f, c = F coupon / f and
// v = (1 + y)^-n = exp(-n log(1 + y)); where the yield is nothing, P = c n + F. A ratio, in no currency: the face is
// a number a game gives, 100 for a price per 100 of face, and the game
// multiplies by its own unit of price.
// Source: Fabozzi, Bond Markets, Analysis and Strategies, ch. 2.
// Checked against a reference in numpy, which has its own tests.
namespace lusine.finance;
float bond_price(float face, float<year^-1> coupon, float<year> years, float<year^-1> yld, float<year^-1> freq)
{
let y = yld / freq;
let n = years * freq;
let c = face * coupon / freq;
let v = exp(-n * log(1.0 + y));
if (y == 0.0) return c * n + face * v;
return c * (1.0 - v) / y + face * v;
}
The program¶
Each section runs the kernel over a lattice of items, one a case: the host sets every parameter, and main() binds the kernel over the items. The host speaks the base units, so a time in years is stored as t * YEAR seconds and an annual rate as r / YEAR, and a result in years is read back the same way; inside, the law runs on those, in binary32. bond below is that call for bonds given in years and annual rates; closed is the independent account, every cash flow discounted and summed in double precision, the sum the kernel takes in closed form.
print(trait_run.law_program('finance/bond_price.trait', 'bond_price'))
def bond(face, coupon, years, yld, freq=2.0):
"""The kernel's price of bonds, the annual figures given in years and read through YEAR."""
return trait_run.law('finance/bond_price.trait', 'bond_price', face=face, coupon=np.asarray(coupon) / YEAR,
years=np.asarray(years) * YEAR, yld=np.asarray(yld) / YEAR, freq=np.asarray(freq) / YEAR)
def closed(face, coupon, years, yld, freq=2.0):
"""The independent account, in double precision: each coupon and the face discounted, summed."""
face, coupon, years, yld = np.broadcast_arrays(*(np.asarray(a, dtype=float) for a in (face, coupon, years, yld)))
out = np.empty(face.shape)
for i in np.ndindex(face.shape):
k = np.arange(1, int(round(years[i] * freq)) + 1)
d = (1 + yld[i] / freq) ** -k.astype(float)
out[i] = np.sum(face[i] * coupon[i] / freq * d) + face[i] * d[-1]
return out
namespace probe;
lusine.version version = { { number = 1 } };
struct item {
host float face;
host float<year^-1> coupon;
host float<year> years;
host float<year^-1> yld;
host float<year^-1> freq;
float result_0;
};
lattice<item, 1> items(spacing = 1 m, boundary = wall);
void main() { items.result_0 = lusine.finance.bond_price(items.face, items.coupon, items.years, items.yld, items.freq); }
Reference: Fabozzi's bond¶
A five-year 6% semi-annual bond at a yield of 8% is 91.889 per 100.
p = bond(100.0, 0.06, 5.0, 0.08)[0]
print(f'{p:.4f} per 100')
assert abs(p - 91.889) < 0.001
91.8891 per 100
Curve: price against yield, falling and convex¶
A ten-year 5% bond from 0% to 15%, against the cash flows summed. The rounding that matters is of 1 + y, half an ulp of 1, which the power carries n periods into the discount and the annuity's (1 - v) / y divides by the yield: the tolerance is that, propagated, and 8 ulps besides. The price falls as the yield rises and bends upward.
y = np.linspace(0.0, 0.15, 61)
p = bond(100.0, 0.05, 10.0, y)
want = closed(100.0, 0.05, 10.0, y)
n, yp, c = 20, y / 2, 100.0 * 0.05 / 2
v = (1 + yp) ** -n
carried = np.where(yp > 0, c * n * v / (2 * np.where(yp > 0, yp, 1) * want), 0.0)
assert np.all(np.abs(p / want - 1) <= EPS * (8 + n / 2 + carried)), np.max(np.abs(p / want - 1) / EPS)
assert np.all(np.diff(p) < 0) and np.all(np.diff(p, 2) > 0)
fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(y * 100, want, label='cash flows summed')
ax.plot(y * 100, p, 'o', ms=3, label='bond_price')
ax.axhline(100.0, lw=0.5)
ax.set_xlabel('yield (%)')
ax.set_ylabel('price per 100')
ax.legend()
plt.show()
Limit: at par, at no yield, and with no coupon¶
A bond whose yield is its coupon prices at par; at no yield the price is its coupons and face undiscounted, c n + F; a zero-coupon bond is its face discounted n periods.
years = np.array([1.0, 5.0, 30.0, 10.0, 7.0])
coupon = np.array([0.05, 0.05, 0.05, 0.05, 0.0])
yld = np.array([0.05, 0.05, 0.05, 0.0, 0.04])
p = bond(100.0, coupon, years, yld)
assert np.all(np.abs(p[:3] - 100.0) <= 8 * EPS * 100.0), p[:3]
assert abs(p[3] - 150.0) <= 4 * EPS * 150.0, p[3]
assert abs(p[4] / (100.0 / 1.02 ** 14) - 1) <= EPS * (8 + 14 / 2), p[4]
print('par:', p[:3], ' no yield:', p[3], ' zero coupon:', p[4])
par: [100. 100. 100.00000763] no yield: 150.0 zero coupon: 75.78752136230469
Invariant: linear in the face, and the long bond moves more¶
A price per 200 of face is twice the price per 100, bit for bit: the face only scales. A rise of one point in the yield costs a ten-year bond more than a two-year one.
p = bond(np.array([100.0, 200.0, 100.0, 100.0, 100.0, 100.0]), 0.03, np.array([10.0, 10.0, 2.0, 2.0, 10.0, 10.0]),
np.array([0.04, 0.04, 0.03, 0.04, 0.03, 0.04]))
assert p[1] == 2 * p[0], (p[0], p[1])
short, long = p[3] / p[2] - 1, p[5] / p[4] - 1
assert long < short < 0
print(f'a point costs the two-year {-100 * short:.2f}% and the ten-year {-100 * long:.2f}%')
a point costs the two-year 1.90% and the ten-year 8.18%