Adjust the bond parameters and drag the yield slider. The chart shows three lines: the exact price (blue curve), the duration-only linear approximation (dashed), and the duration + convexity quadratic correction (dotted).
<!-- Bond price vs. yield chart: exact price (blue), duration approx (red dashed), duration+convexity approx (green dotted) -->
<div class="controls">
<label>Coupon: <span id="couponVal">5.0</span>%
<input type="range" id="coupon" min="0" max="12" step="0.5" value="5">
</label>
<label>Maturity: <span id="maturityVal">10</span> yrs
<input type="range" id="maturity" min="1" max="30" step="1" value="10">
</label>
<label>Yield: <span id="yieldVal">5.0</span>%
<input type="range" id="yield" min="0.5" max="15" step="0.25" value="5">
</label>
</div>
<canvas id="chart" width="560" height="220"></canvas>
<div class="metrics" id="metrics"></div>
<div class="legend">
<span class="leg-exact">Exact price</span>
<span class="leg-dur">Duration only</span>
<span class="leg-conv">Duration + Convexity</span>
</div>
/* base reset */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .45rem; margin-bottom: .6rem; }
label { font-size: .85rem; color: #444; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label span { font-weight: 700; color: #1d3557; min-width: 2.4rem; text-align: right; }
input[type=range] { flex: 1; min-width: 120px; accent-color: #1d3557; }
canvas { width: 100%; height: auto; display: block; border: 1px solid #dde3ea; border-radius: 8px; }
.metrics { font-size: .82rem; color: #333; margin: .45rem 0 .3rem;
display: flex; gap: 1rem; flex-wrap: wrap; }
.metrics span { background: #eef2f6; border-radius: 6px; padding: .2rem .55rem; }
.metrics b { color: #1d3557; }
.legend { font-size: .78rem; color: #555; display: flex; gap: 1rem; flex-wrap: wrap; }
.leg-exact::before { content: ""; display: inline-block; width: 22px; height: 3px;
background: #1d6fb5; border-radius: 2px; vertical-align: middle; margin-right: 4px; }
.leg-dur::before { content: ""; display: inline-block; width: 22px; height: 2px;
border-top: 2px dashed #c0392b; vertical-align: middle; margin-right: 4px; }
.leg-conv::before { content: ""; display: inline-block; width: 22px; height: 2px;
border-top: 2px dotted #27ae60; vertical-align: middle; margin-right: 4px; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Compute full bond price: semiannual coupons, face value 100
function bondPrice(coupon, n, y) {
// semiannual coupon rate and discount rate
const c = coupon / 2 / 100;
const r = y / 2 / 100;
const face = 100;
if (r === 0) return face + c * face * n * 2;
let pv = 0;
for (let t = 1; t <= n * 2; t++) pv += (c * face) / Math.pow(1 + r, t);
pv += face / Math.pow(1 + r, n * 2);
return pv;
}
// Macaulay duration: weighted-average time to cash flows (in years)
function macaulayDuration(coupon, n, y) {
const c = coupon / 2 / 100;
const r = y / 2 / 100;
const face = 100;
const price = bondPrice(coupon, n, y);
let num = 0;
for (let t = 1; t <= n * 2; t++) {
const cf = c * face;
num += (t / 2) * cf / Math.pow(1 + r, t);
}
num += n * face / Math.pow(1 + r, n * 2);
return num / price;
}
// Convexity: normalized second derivative of price w.r.t. yield
function convexity(coupon, n, y) {
const c = coupon / 2 / 100;
const r = y / 2 / 100;
const face = 100;
const price = bondPrice(coupon, n, y);
let num = 0;
for (let t = 1; t <= n * 2; t++) {
const cf = c * face;
num += t * (t + 1) * cf / Math.pow(1 + r, t);
}
num += (n * 2) * (n * 2 + 1) * face / Math.pow(1 + r, n * 2);
return num / (price * Math.pow(1 + r, 2) * 4);
}
// Main draw function: reads sliders, computes metrics, draws chart
function draw() {
const coupon = parseFloat(document.getElementById('coupon').value);
const maturity = parseInt(document.getElementById('maturity').value);
const yieldPct = parseFloat(document.getElementById('yield').value);
document.getElementById('couponVal').textContent = coupon.toFixed(1);
document.getElementById('maturityVal').textContent = maturity;
document.getElementById('yieldVal').textContent = yieldPct.toFixed(2);
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// yield range for x-axis: current yield +/- 6 percentage points
const yMin = Math.max(0.25, yieldPct - 6);
const yMax = yieldPct + 6;
const steps = 200;
const prices = [];
for (let i = 0; i <= steps; i++) {
const y = yMin + (yMax - yMin) * i / steps;
prices.push({ y, p: bondPrice(coupon, maturity, y) });
}
const pMin = Math.min(...prices.map(o => o.p)) * 0.97;
const pMax = Math.max(...prices.map(o => o.p)) * 1.02;
const px = (y) => 36 + (y - yMin) / (yMax - yMin) * (W - 46);
const py = (p) => H - 22 - (p - pMin) / (pMax - pMin) * (H - 38);
// draw axis grid lines and labels
ctx.strokeStyle = '#e0e7ef'; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = yMin + (yMax - yMin) * i / 4;
const x = px(y);
ctx.beginPath(); ctx.moveTo(x, 12); ctx.lineTo(x, H - 22); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '10px system-ui';
ctx.textAlign = 'center';
ctx.fillText(y.toFixed(1) + '%', x, H - 8);
}
for (let i = 0; i <= 3; i++) {
const p = pMin + (pMax - pMin) * i / 3;
const y2 = py(p);
ctx.beginPath(); ctx.moveTo(36, y2); ctx.lineTo(W - 10, y2); ctx.stroke();
ctx.textAlign = 'right';
ctx.fillText(p.toFixed(0), 32, y2 + 3);
}
const P0 = bondPrice(coupon, maturity, yieldPct);
const D = macaulayDuration(coupon, maturity, yieldPct);
const Dmod = D / (1 + yieldPct / 2 / 100);
const Cvx = convexity(coupon, maturity, yieldPct);
// first-order (duration) and second-order (convexity) approximation functions
function durLine(y) {
const dy = (y - yieldPct) / 100;
return P0 * (1 - Dmod * dy);
}
function convLine(y) {
const dy = (y - yieldPct) / 100;
return P0 * (1 - Dmod * dy + 0.5 * Cvx * dy * dy);
}
// draw the duration-only linear approximation (dashed red)
ctx.beginPath(); ctx.setLineDash([6, 4]); ctx.strokeStyle = '#c0392b'; ctx.lineWidth = 1.8;
prices.forEach(({ y }, i) => {
const x = px(y), yp = py(durLine(y));
if (i === 0) ctx.moveTo(x, yp); else ctx.lineTo(x, yp);
});
ctx.stroke();
// draw the duration+convexity quadratic approximation (dotted green)
ctx.beginPath(); ctx.setLineDash([2, 3]); ctx.strokeStyle = '#27ae60'; ctx.lineWidth = 1.8;
prices.forEach(({ y }, i) => {
const x = px(y), yp = py(convLine(y));
if (i === 0) ctx.moveTo(x, yp); else ctx.lineTo(x, yp);
});
ctx.stroke();
// draw the exact price curve (solid blue)
ctx.beginPath(); ctx.setLineDash([]); ctx.strokeStyle = '#1d6fb5'; ctx.lineWidth = 2.4;
prices.forEach(({ y, p }, i) => {
const x = px(y), yp = py(p);
if (i === 0) ctx.moveTo(x, yp); else ctx.lineTo(x, yp);
});
ctx.stroke();
// dot at current yield
const cx2 = px(yieldPct), cy2 = py(P0);
ctx.beginPath(); ctx.setLineDash([]);
ctx.arc(cx2, cy2, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#1d3557'; ctx.fill();
// update the metrics strip below the chart
document.getElementById('metrics').innerHTML =
'<span><b>Price:</b> ' + P0.toFixed(2) + '</span>' +
'<span><b>Mac. Duration:</b> ' + D.toFixed(2) + ' yrs</span>' +
'<span><b>Mod. Duration:</b> ' + Dmod.toFixed(2) + '</span>' +
'<span><b>Convexity:</b> ' + Cvx.toFixed(2) + '</span>';
}
document.getElementById('coupon').addEventListener('input', draw);
document.getElementById('maturity').addEventListener('input', draw);
document.getElementById('yield').addEventListener('input', draw);
draw();
<!-- Bond price vs. yield chart: exact price (blue), duration approx (red dashed), duration+convexity approx (green dotted) -->
<div class="controls">
<label>Coupon: <span id="couponVal">5.0</span>%
<input type="range" id="coupon" min="0" max="12" step="0.5" value="5">
</label>
<label>Maturity: <span id="maturityVal">10</span> yrs
<input type="range" id="maturity" min="1" max="30" step="1" value="10">
</label>
<label>Yield: <span id="yieldVal">5.0</span>%
<input type="range" id="yield" min="0.5" max="15" step="0.25" value="5">
</label>
</div>
<canvas id="chart" width="560" height="220"></canvas>
<div class="metrics" id="metrics"></div>
<div class="legend">
<span class="leg-exact">Exact price</span>
<span class="leg-dur">Duration only</span>
<span class="leg-conv">Duration + Convexity</span>
</div>
/* base reset */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .45rem; margin-bottom: .6rem; }
label { font-size: .85rem; color: #444; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label span { font-weight: 700; color: #1d3557; min-width: 2.4rem; text-align: right; }
input[type=range] { flex: 1; min-width: 120px; accent-color: #1d3557; }
canvas { width: 100%; height: auto; display: block; border: 1px solid #dde3ea; border-radius: 8px; }
.metrics { font-size: .82rem; color: #333; margin: .45rem 0 .3rem;
display: flex; gap: 1rem; flex-wrap: wrap; }
.metrics span { background: #eef2f6; border-radius: 6px; padding: .2rem .55rem; }
.metrics b { color: #1d3557; }
.legend { font-size: .78rem; color: #555; display: flex; gap: 1rem; flex-wrap: wrap; }
.leg-exact::before { content: ""; display: inline-block; width: 22px; height: 3px;
background: #1d6fb5; border-radius: 2px; vertical-align: middle; margin-right: 4px; }
.leg-dur::before { content: ""; display: inline-block; width: 22px; height: 2px;
border-top: 2px dashed #c0392b; vertical-align: middle; margin-right: 4px; }
.leg-conv::before { content: ""; display: inline-block; width: 22px; height: 2px;
border-top: 2px dotted #27ae60; vertical-align: middle; margin-right: 4px; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Compute full bond price: semiannual coupons, face value 100
function bondPrice(coupon, n, y) {
// semiannual coupon rate and discount rate
const c = coupon / 2 / 100;
const r = y / 2 / 100;
const face = 100;
if (r === 0) return face + c * face * n * 2;
let pv = 0;
for (let t = 1; t <= n * 2; t++) pv += (c * face) / Math.pow(1 + r, t);
pv += face / Math.pow(1 + r, n * 2);
return pv;
}
// Macaulay duration: weighted-average time to cash flows (in years)
function macaulayDuration(coupon, n, y) {
const c = coupon / 2 / 100;
const r = y / 2 / 100;
const face = 100;
const price = bondPrice(coupon, n, y);
let num = 0;
for (let t = 1; t <= n * 2; t++) {
const cf = c * face;
num += (t / 2) * cf / Math.pow(1 + r, t);
}
num += n * face / Math.pow(1 + r, n * 2);
return num / price;
}
// Convexity: normalized second derivative of price w.r.t. yield
function convexity(coupon, n, y) {
const c = coupon / 2 / 100;
const r = y / 2 / 100;
const face = 100;
const price = bondPrice(coupon, n, y);
let num = 0;
for (let t = 1; t <= n * 2; t++) {
const cf = c * face;
num += t * (t + 1) * cf / Math.pow(1 + r, t);
}
num += (n * 2) * (n * 2 + 1) * face / Math.pow(1 + r, n * 2);
return num / (price * Math.pow(1 + r, 2) * 4);
}
// Main draw function: reads sliders, computes metrics, draws chart
function draw() {
const coupon = parseFloat(document.getElementById('coupon').value);
const maturity = parseInt(document.getElementById('maturity').value);
const yieldPct = parseFloat(document.getElementById('yield').value);
document.getElementById('couponVal').textContent = coupon.toFixed(1);
document.getElementById('maturityVal').textContent = maturity;
document.getElementById('yieldVal').textContent = yieldPct.toFixed(2);
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// yield range for x-axis: current yield +/- 6 percentage points
const yMin = Math.max(0.25, yieldPct - 6);
const yMax = yieldPct + 6;
const steps = 200;
const prices = [];
for (let i = 0; i <= steps; i++) {
const y = yMin + (yMax - yMin) * i / steps;
prices.push({ y, p: bondPrice(coupon, maturity, y) });
}
const pMin = Math.min(...prices.map(o => o.p)) * 0.97;
const pMax = Math.max(...prices.map(o => o.p)) * 1.02;
const px = (y) => 36 + (y - yMin) / (yMax - yMin) * (W - 46);
const py = (p) => H - 22 - (p - pMin) / (pMax - pMin) * (H - 38);
// draw axis grid lines and labels
ctx.strokeStyle = '#e0e7ef'; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = yMin + (yMax - yMin) * i / 4;
const x = px(y);
ctx.beginPath(); ctx.moveTo(x, 12); ctx.lineTo(x, H - 22); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '10px system-ui';
ctx.textAlign = 'center';
ctx.fillText(y.toFixed(1) + '%', x, H - 8);
}
for (let i = 0; i <= 3; i++) {
const p = pMin + (pMax - pMin) * i / 3;
const y2 = py(p);
ctx.beginPath(); ctx.moveTo(36, y2); ctx.lineTo(W - 10, y2); ctx.stroke();
ctx.textAlign = 'right';
ctx.fillText(p.toFixed(0), 32, y2 + 3);
}
const P0 = bondPrice(coupon, maturity, yieldPct);
const D = macaulayDuration(coupon, maturity, yieldPct);
const Dmod = D / (1 + yieldPct / 2 / 100);
const Cvx = convexity(coupon, maturity, yieldPct);
// first-order (duration) and second-order (convexity) approximation functions
function durLine(y) {
const dy = (y - yieldPct) / 100;
return P0 * (1 - Dmod * dy);
}
function convLine(y) {
const dy = (y - yieldPct) / 100;
return P0 * (1 - Dmod * dy + 0.5 * Cvx * dy * dy);
}
// draw the duration-only linear approximation (dashed red)
ctx.beginPath(); ctx.setLineDash([6, 4]); ctx.strokeStyle = '#c0392b'; ctx.lineWidth = 1.8;
prices.forEach(({ y }, i) => {
const x = px(y), yp = py(durLine(y));
if (i === 0) ctx.moveTo(x, yp); else ctx.lineTo(x, yp);
});
ctx.stroke();
// draw the duration+convexity quadratic approximation (dotted green)
ctx.beginPath(); ctx.setLineDash([2, 3]); ctx.strokeStyle = '#27ae60'; ctx.lineWidth = 1.8;
prices.forEach(({ y }, i) => {
const x = px(y), yp = py(convLine(y));
if (i === 0) ctx.moveTo(x, yp); else ctx.lineTo(x, yp);
});
ctx.stroke();
// draw the exact price curve (solid blue)
ctx.beginPath(); ctx.setLineDash([]); ctx.strokeStyle = '#1d6fb5'; ctx.lineWidth = 2.4;
prices.forEach(({ y, p }, i) => {
const x = px(y), yp = py(p);
if (i === 0) ctx.moveTo(x, yp); else ctx.lineTo(x, yp);
});
ctx.stroke();
// dot at current yield
const cx2 = px(yieldPct), cy2 = py(P0);
ctx.beginPath(); ctx.setLineDash([]);
ctx.arc(cx2, cy2, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#1d3557'; ctx.fill();
// update the metrics strip below the chart
document.getElementById('metrics').innerHTML =
'<span><b>Price:</b> ' + P0.toFixed(2) + '</span>' +
'<span><b>Mac. Duration:</b> ' + D.toFixed(2) + ' yrs</span>' +
'<span><b>Mod. Duration:</b> ' + Dmod.toFixed(2) + '</span>' +
'<span><b>Convexity:</b> ' + Cvx.toFixed(2) + '</span>';
}
document.getElementById('coupon').addEventListener('input', draw);
document.getElementById('maturity').addEventListener('input', draw);
document.getElementById('yield').addEventListener('input', draw);
draw();
Notice that the duration line is tangent to the price curve at the current yield. As you move the yield away, the gap grows — that gap is exactly what convexity corrects. The correction is always positive: convexity bends the approximation back toward the true price, in both directions.
Comments
Loading comments...