Skip to content

A bond's duration, live

A bond's modified duration, the relative fall of its price per unit of yield, D = −(1/P) dP/dy, by the central difference of its prices a bump either side of its yield, D = (P(y − h) − P(y + h)) / (2 h P), in years (lusine.finance.duration). For a bullet bond it is Macaulay's duration over (1 + y/f). The prices are the game's, here the library's lusine.finance.bond_price at the three yields, as a game would run it before this kernel. Below, a 5% bond at 4% paying twice a year is priced on your GPU as you read at tenors from one year to thirty, and bonds with no coupon at 1, 5, 10 and 30 years, each at its yield and a basis point either side, and each one's duration taken from the three prices.

This page runs the program on your GPU through WebGPU, so it needs a browser with WebGPU (a recent Chrome, Edge, Safari or Firefox). Without it the program does not run and nothing is drawn below.

What it shows

  • The strip above the controls is green while every check holds, and turns red for good once one fails.
  • The tenor: the chart draws the 5% bond's duration against its tenor, a point a step, beside Macaulay's over (1 + y/f) in float64 and the tenor itself: the duration rising, and ever further below the tenor. Below it, the tenor, the duration and Macaulay's.
  • The checks: Macaulay's in float64, every bond's duration within its tolerance of Macaulay's duration over (1 + y/f), each cash flow's time weighted by its present value, in float64: each price within the bond price's binary32 bound, the difference of two over 2 h, and the central difference's own error, below a part in 10⁵ (a bond with no coupon has one cash flow, and Macaulay's duration is its tenor); rising, below the tenor, the 5% bond's duration rising with the tenor and below it; a price that does not move, three prices of 97 having no duration, exactly; a ratio, in no currency, prices per 200 of face, every one doubled, giving every bond the same duration bit for bit. Each is held at every step.

Every number here is computed by the program on the GPU; the page only reads the values back and draws them. The bonds' face, yield and coupons a year, the bump and the price that does not move are the program's settings, which the host may write; this page offers no control to change them yet.

The program

The bonds, the sweep and the checks, the panels the page draws, and the one procedure, main, run each step: every bond's three prices and its duration, the tenor a year on, and the checks.

// The duration's live page: a 5% bond at 4% from one year to thirty, and bonds with no coupon, each priced by the
// library's bond_price at its yield and a basis point either side and its modified duration taken from those prices by
// the library's kernel, held to Macaulay's in float64, with the page's checks computed here on the GPU and shown by the
// panels the program declares.

namespace duration;

lusine.version version = { { number = 1 } };

// The reader's settings: the bonds' face, yield and coupons a year; the bump, a basis point; and a price that does not
// move, 97.
host struct {
  float face = 100.0; float<year^-1> yld = 0.04 year^-1; float<year^-1> freq = 2.0 year^-1;
  float<year^-1> bump = 0.0001 year^-1; float flat = 97.0;
} settings;

// Thirty-four bonds: a 5% bond of 1 to 30 years and bonds with no coupon of 1, 5, 10 and 30, each with Macaulay's
// duration over (1 + y / f) in float64, its cash flows' present-value-weighted time, and its tolerance: each price
// within the bond price's bound, the difference of two over 2 h, and the central difference's own error, below a part
// in 10^5; the prices at the yield and a bump either side, the kernel's duration, and whether it is within the
// tolerance; and the duration from the prices per 200 of face, each doubled.
struct bond {
  float<year^-1> coupon; float<year> years; float<year> reference; float<year> tolerance; float price; float down;
  float up; float<year> duration; bool holds; float<year> doubled;
};
host table<bond> bonds(capacity = 34) = {
  { coupon = 0.05 year^-1, years = 1.0 year, reference = 0.9684930611940159 year,
    tolerance = 0.012157003731148234 year },
  { coupon = 0.05 year^-1, years = 2.0 year, reference = 1.8909636489845314 year,
    tolerance = 0.01464167324706786 year },
  { coupon = 0.05 year^-1, years = 3.0 year, reference = 2.7703836110315887 year,
    tolerance = 0.017002120418481443 year },
  { coupon = 0.05 year^-1, years = 4.0 year, reference = 3.609465279848172 year,
    tolerance = 0.01924866120274625 year },
  { coupon = 0.05 year^-1, years = 5.0 year, reference = 4.410689847574737 year,
    tolerance = 0.021390673830552844 year },
  { coupon = 0.05 year^-1, years = 6.0 year, reference = 5.176332085090108 year,
    tolerance = 0.02343669906850351 year },
  { coupon = 0.05 year^-1, years = 7.0 year, reference = 5.908481942852614 year,
    tolerance = 0.025394527890043206 year },
  { coupon = 0.05 year^-1, years = 8.0 year, reference = 6.609063482476581 year,
    tolerance = 0.027271278366426257 year },
  { coupon = 0.05 year^-1, years = 9.0 year, reference = 7.279851515686089 year,
    tolerance = 0.029073463297358867 year },
  { coupon = 0.05 year^-1, years = 10.0 year, reference = 7.922486267785758 year,
    tolerance = 0.030807049860823787 year },
  { coupon = 0.05 year^-1, years = 11.0 year, reference = 8.538486333656225 year,
    tolerance = 0.03247751236331661 year },
  { coupon = 0.05 year^-1, years = 12.0 year, reference = 9.129260153550218 year,
    tolerance = 0.03408987900735156 year },
  { coupon = 0.05 year^-1, years = 13.0 year, reference = 9.696116202067278 year,
    tolerance = 0.035648773456306265 year },
  { coupon = 0.05 year^-1, years = 14.0 year, reference = 10.240272055370575 year,
    tolerance = 0.03715845186242255 year },
  { coupon = 0.05 year^-1, years = 15.0 year, reference = 10.762862477974542 year,
    tolerance = 0.03862283592801182 year },
  { coupon = 0.05 year^-1, years = 16.0 year, reference = 11.264946650468264 year,
    tolerance = 0.0400455424893671 year },
  { coupon = 0.05 year^-1, years = 17.0 year, reference = 11.747514642692892 year,
    tolerance = 0.04142991004491685 year },
  { coupon = 0.05 year^-1, years = 18.0 year, reference = 12.211493222629828 year,
    tolerance = 0.042779022591623625 year },
  { coupon = 0.05 year^-1, years = 19.0 year, reference = 12.657751079146967 year,
    tolerance = 0.04409573108478388 year },
  { coupon = 0.05 year^-1, years = 20.0 year, reference = 13.087103526437508 year,
    tolerance = 0.04538267279479048 year },
  { coupon = 0.05 year^-1, years = 21.0 year, reference = 13.500316749178976 year,
    tolerance = 0.04664228879890159 year },
  { coupon = 0.05 year^-1, years = 22.0 year, reference = 13.898111639897948 year,
    tolerance = 0.04787683981564621 year },
  { coupon = 0.05 year^-1, years = 23.0 year, reference = 14.281167273550489 year,
    tolerance = 0.049088420563388406 year },
  { coupon = 0.05 year^-1, years = 24.0 year, reference = 14.650124058754066 year,
    tolerance = 0.05027897280209985 year },
  { coupon = 0.05 year^-1, years = 25.0 year, reference = 15.005586600296633 year,
    tolerance = 0.05145029719800245 year },
  { coupon = 0.05 year^-1, years = 26.0 year, reference = 15.348126303387922 year,
    tolerance = 0.05260406413397511 year },
  { coupon = 0.05 year^-1, years = 27.0 year, reference = 15.678283746510777 year,
    tolerance = 0.053741823574083664 year },
  { coupon = 0.05 year^-1, years = 28.0 year, reference = 15.996570846596006 year,
    tolerance = 0.05486501407796612 year },
  { coupon = 0.05 year^-1, years = 29.0 year, reference = 16.30347283751483 year,
    tolerance = 0.05597497104981164 year },
  { coupon = 0.05 year^-1, years = 30.0 year, reference = 16.599450080501164 year,
    tolerance = 0.05707293429708044 year },
  { coupon = 0.0 year^-1, years = 1.0 year, reference = 0.9803921568627451 year,
    tolerance = 0.01073863998113894 year },
  { coupon = 0.0 year^-1, years = 5.0 year, reference = 4.901960784313726 year,
    tolerance = 0.0155462272494447 year },
  { coupon = 0.0 year^-1, years = 10.0 year, reference = 9.803921568627452 year,
    tolerance = 0.0215557113348269 year },
  { coupon = 0.0 year^-1, years = 30.0 year, reference = 29.41176470588235 year,
    tolerance = 0.0455936476763557 year },
};

// The sweep: the step, from 0 to 29 and round again; the 5% bond's tenor, its duration and Macaulay's, and the duration
// at the step before.
struct { integer step = -1; float<year> tenor; float<year> duration; float<year> macaulay; float<year> before; } sweep;

// The checks: every bond's duration within its tolerance of Macaulay's; the 5% bond's rising with the tenor and below
// it; a price that does not move having no duration; and prices per 200 of face, every one doubled, the same duration
// bit for bit.
struct { bool macaulay = true; bool tenor = true; bool flat = true; bool face = true; } checks;

// A strip of lamps the page draws, lit green while every check holds and red once one fails.
struct lamp { vector<float, 4> colour; };
lattice<lamp, 2> lamps(spacing = 1 m, boundary = wall);

vector<float, 4> lamp_colour(bool holds)
{
  return holds ? vector<float, 4>(0.15, 0.6, 0.3, 1.0) : vector<float, 4>(0.75, 0.15, 0.15, 1.0);
}

float eps() { return 1.0 / 8388608.0; }

bool near(float<year> duration, float<year> reference, float<year> tolerance)
{
  return abs(duration - reference) <= tolerance;
}

integer failing(table<bool> holds) { return count(holds) where !holds; }
integer unequal(table<float<year>> duration, table<float<year>> doubled)
{
  return count(duration) where duration != doubled;
}

float<year> from_table(table<float<year>> years, table<float<year^-1>> coupon, table<float<year>> values,
                       float<year> tenor)
{
  let t = tenor;
  return sum(values) where years == t && coupon > 0.0 year^-1;
}

output table<lusine.widget> panels = {
  lusine.series { { panel = "the tenor", chart = "years", quantity = sweep.duration, steps = 30,
                    against = sweep.tenor, label = "duration" } },
  lusine.series { { panel = "the tenor", chart = "years", quantity = sweep.macaulay, steps = 30,
                    against = sweep.tenor, label = "Macaulay's / (1 + y/f)" } },
  lusine.series { { panel = "the tenor", chart = "years", quantity = sweep.tenor, steps = 30, against = sweep.tenor,
                    label = "the tenor" } },
  lusine.value { { panel = "the tenor", quantity = sweep.tenor, label = "tenor" } },
  lusine.value { { panel = "the tenor", quantity = sweep.duration, label = "duration" } },
  lusine.value { { panel = "the tenor", quantity = sweep.macaulay, label = "Macaulay's" } },
  lusine.value { { panel = "the checks", quantity = checks.macaulay, label = "Macaulay's in float64" } },
  lusine.value { { panel = "the checks", quantity = checks.tenor, label = "rising, below the tenor" } },
  lusine.value { { panel = "the checks", quantity = checks.flat, label = "a price that does not move" } },
  lusine.value { { panel = "the checks", quantity = checks.face, label = "a ratio, in no currency" } },
};

void main()
{
  bonds.price = lusine.finance.bond_price(settings.face, bonds.coupon, bonds.years, settings.yld, settings.freq);
  bonds.down = lusine.finance.bond_price(settings.face, bonds.coupon, bonds.years, yld = settings.yld - settings.bump,
                                         settings.freq);
  bonds.up = lusine.finance.bond_price(settings.face, bonds.coupon, bonds.years, yld = settings.yld + settings.bump,
                                       settings.freq);
  bonds.duration = lusine.finance.duration(bonds.price, price_down = bonds.down, price_up = bonds.up, settings.bump);
  bonds.holds = near(bonds.duration, bonds.reference, bonds.tolerance);
  bonds.doubled = lusine.finance.duration(price = 2.0 * bonds.price, price_down = 2.0 * bonds.down,
                                          price_up = 2.0 * bonds.up, settings.bump);
  sweep.step = sweep.step >= 29 ? 0 : sweep.step + 1;
  sweep.tenor = float(sweep.step + 1) * 1.0 year;
  sweep.before = sweep.duration;
  sweep.duration = from_table(years = bonds.years, bonds.coupon, values = bonds.duration, sweep.tenor);
  sweep.macaulay = from_table(years = bonds.years, bonds.coupon, values = bonds.reference, sweep.tenor);
  checks.macaulay = checks.macaulay && failing(holds = bonds.holds) == 0;
  checks.tenor = checks.tenor && sweep.duration < sweep.tenor && (sweep.step == 0 || sweep.duration > sweep.before);
  checks.flat = checks.flat
                && lusine.finance.duration(price = settings.flat, price_down = settings.flat, price_up = settings.flat,
                                           settings.bump) == 0.0 year;
  checks.face = checks.face && unequal(bonds.duration, bonds.doubled) == 0;
  lamps.colour = lamp_colour(holds = checks.macaulay && checks.tenor && checks.flat && checks.face);
}

The kernel's other proof (the same bonds read back on the host) is on its proof page.