Functional Weave
Code in Python

invest.bond-yield@1.0.2

impl/typescript.ts

5,973 bytes · the TypeScript implementation · view raw

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,
  };
}