Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { daysInMonth, epochDayFromIso, formatIsoDate, parseIsoDate } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { addMonths } from "./dates_add_months.ts"; ← from dates.add-months ^1.0.0 · built alongside by fune
import { dayCountFraction } from "./dates_day_count_fraction.ts"; ← from dates.day-count-fraction ^1.0.0 · built alongside by fune
import { FIXED_SCALE, mulFixed, powFixed, rootFixed } from "./math_fractional_power.ts"; ← from math.fractional-power ^1.0.0 · built alongside by fune
import { type BondDayCount, type BondYield } from "./invest_bond_yield_types.ts";
const PRICE = /^[0-9]+(\.[0-9]{1,6})?$/;
const MICRO = 1000000n;
/** n / d rounded half away from zero; d > 0. */
function roundHalfAway(n: bigint, d: bigint): bigint {
const magnitude = ((n < 0n ? -n : n) * 2n + d) / (2n * d);
return n < 0n ? -magnitude : magnitude;
}
function decimal(value: bigint, places: number): string {
const unit = 10n ** BigInt(places);
const magnitude = value < 0n ? -value : value;
return `${value < 0n ? "-" : ""}${magnitude / unit}.${(magnitude % unit).toString().padStart(places, "0")}`;
}
/** The coupon date k periods before maturity; month ends stay month ends. */
function couponDate(maturity: string, k: number, months: number, endOfMonth: boolean): string {
const date = addMonths(maturity, -k * months);
if (!endOfMonth) return date;
const civil = parseIsoDate(date);
return formatIsoDate({ ...civil, day: daysInMonth(civil.year, civil.month) });
}
/**
* Yield to maturity by Excel's YIELD: the annual yield y, compounded at the
* coupon frequency, at which the discounted coupons and redemption at 100
* equal the clean price plus accrued interest, with the first period's
* exponent DSC/E. Solved by bisection on the per-day factor
* z = (1 + y/f)^(-1/E) in 18-place fixed point, so every power is whole.
* With one coupon period or less left, Excel's simple-yield formula, exactly.
*/
export function bondYield(settlement: string, maturity: string, couponBasisPoints: number, cleanPrice: string, frequency: number, basis: BondDayCount): BondYield {
if (frequency !== 1 && frequency !== 2 && frequency !== 4) {
throw new RangeError(`frequency must be 1, 2 or 4, received ${frequency}`);
}
if (!Number.isInteger(couponBasisPoints) || couponBasisPoints < 0 || couponBasisPoints > 100000) {
throw new RangeError(`couponBasisPoints must be a whole number from 0 to 100000, received ${couponBasisPoints}`);
}
if (typeof cleanPrice !== "string" || !PRICE.test(cleanPrice)) {
throw new RangeError(`cleanPrice must be a positive decimal with at most 6 places, received "${cleanPrice}"`);
}
if (basis !== "30-360" && basis !== "act-act") {
throw new RangeError(`basis must be 30-360 or act-act, received "${basis}"`);
}
const [whole, fraction = ""] = cleanPrice.split(".");
const priceMicro = BigInt(whole) * MICRO + BigInt(fraction.padEnd(6, "0"));
if (priceMicro === 0n) throw new RangeError("cleanPrice must be greater than zero");
const settleDay = epochDayFromIso(settlement);
if (settleDay >= epochDayFromIso(maturity)) throw new RangeError("settlement must be before maturity");
const months = 12 / frequency;
const civil = parseIsoDate(maturity);
const endOfMonth = civil.day === daysInMonth(civil.year, civil.month);
let k = 1;
while (epochDayFromIso(couponDate(maturity, k, months, endOfMonth)) > settleDay) {
k += 1;
if (k > 100 * frequency) throw new RangeError("maturity must be within 100 years of settlement");
}
const previous = couponDate(maturity, k, months, endOfMonth);
const next = couponDate(maturity, k - 1, months, endOfMonth);
let a: number;
let e: number;
if (basis === "30-360") {
const f = dayCountFraction(previous, settlement, "30-360");
a = (f.numerator * 360) / f.denominator;
e = 360 / frequency;
} else {
a = settleDay - epochDayFromIso(previous);
e = epochDayFromIso(next) - epochDayFromIso(previous);
}
const dsc = e - a;
if (dsc <= 0) throw new RangeError("by the 30/360 count settlement is not before the next coupon date");
// Everything is scaled by M = 100 * f * E * 10^6 so it is a whole number.
const coupon = BigInt(couponBasisPoints);
const bigE = BigInt(e);
const f = BigInt(frequency);
const couponTerm = coupon * bigE * MICRO; // C * M, C = coupon / (100 f) per 100
const redemptionTerm = 100n * 100n * f * bigE * MICRO; // 100 * M
const target = priceMicro * 100n * f * bigE + coupon * BigInt(a) * MICRO; // (clean + accrued) * M
let settled: bigint;
if (k === 1) {
// ((100 + C) - dirty) / dirty * (f * E / DSC)
const numerator = (redemptionTerm + couponTerm - target) * f * bigE;
settled = roundHalfAway(numerator * 10n ** 12n, target * BigInt(dsc));
} else {
const n = k;
const value = (z: bigint): bigint => {
const step = powFixed(z, e);
let p = powFixed(z, dsc);
let sum = 0n;
for (let i = 1; i <= n; i++) {
sum += couponTerm * p;
if (i < n) p = mulFixed(p, step);
}
return sum + redemptionTerm * p - target * FIXED_SCALE;
};
let lo = 0n;
let hi = rootFixed(2n * FIXED_SCALE, e);
if (value(hi) < 0n) throw new RangeError("the price implies a yield below -50% a coupon period");
while (hi - lo > 1n) {
const mid = (lo + hi) / 2n;
if (value(mid) >= 0n) hi = mid;
else lo = mid;
}
const growth = powFixed(hi, e);
if (growth === 0n) throw new RangeError("the yield is too large to compute");
const perPeriod = (FIXED_SCALE * FIXED_SCALE) / growth - FIXED_SCALE;
settled = roundHalfAway(perPeriod * f, 1000000n);
}
const accruedMicro = roundHalfAway(coupon * BigInt(a) * MICRO, 100n * f * bigE);
return {
yieldBasisPoints: Number(roundHalfAway(settled * 10000n, 10n ** 12n)),
yieldToMaturity: decimal(roundHalfAway(settled, 1000n), 9),
currentYieldBasisPoints: Number(roundHalfAway(coupon * 100n * MICRO, priceMicro)),
accruedInterest: decimal(accruedMicro, 6),
dirtyPrice: decimal(priceMicro + accruedMicro, 6),
previousCoupon: previous,
nextCoupon: next,
couponsRemaining: k,
};
}