Functional Weave
Code in TypeScript

dates.day-count-fraction@1.0.0

impl/typescript.ts

2,536 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 { daysFromCivil, epochDayFromIso, isLeapYear, parseIsoDate } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { type DayCountConvention, type YearFraction } from "./dates_day_count_fraction_types.ts";

function gcd(a: number, b: number): number {
  while (b !== 0) [a, b] = [b, a % b];
  return a;
}

function reduced(numerator: number, denominator: number): YearFraction {
  if (numerator === 0) return { numerator: 0, denominator: 1 };
  const g = gcd(numerator, denominator);
  return { numerator: numerator / g, denominator: denominator / g };
}

/**
 * The year fraction from `startIso` to `endIso` under a day count convention
 * (2006 ISDA Definitions 4.16), as an exact fraction in lowest terms. Kept
 * exact so the caller multiplies principal and rate by the numerator and
 * rounds once, instead of rounding a float year fraction first.
 */
export function dayCountFraction(startIso: string, endIso: string, convention: DayCountConvention): YearFraction {
  const start = parseIsoDate(startIso);
  const end = parseIsoDate(endIso);
  if (startIso > endIso) {
    throw new RangeError(`startIso ${startIso} must not be after endIso ${endIso}`);
  }
  const actual = epochDayFromIso(endIso) - epochDayFromIso(startIso);
  switch (convention) {
    case "act-365f":
      return reduced(actual, 365);
    case "act-360":
      return reduced(actual, 360);
    case "30-360":
    case "30e-360": {
      const d1 = start.day === 31 ? 30 : start.day;
      let d2 = end.day;
      // Bond basis only trims D2 when D1 is at the month end too; Eurobond always does.
      if (d2 === 31 && (convention === "30e-360" || d1 === 30)) d2 = 30;
      return reduced(360 * (end.year - start.year) + 30 * (end.month - start.month) + (d2 - d1), 360);
    }
    case "act-act-isda": {
      // Split the actual days by the calendar year they fall in: [start, end).
      let leapDays = 0;
      let otherDays = 0;
      const first = epochDayFromIso(startIso);
      const last = epochDayFromIso(endIso);
      for (let year = start.year; year <= end.year; year++) {
        const from = year === start.year ? first : daysFromCivil(year, 1, 1);
        const to = year === end.year ? last : daysFromCivil(year + 1, 1, 1);
        if (isLeapYear(year)) leapDays += to - from;
        else otherDays += to - from;
      }
      return reduced(otherDays * 366 + leapDays * 365, 365 * 366);
    }
    default:
      throw new RangeError(`unknown day count convention "${convention}": expected act-365f, act-360, 30-360, 30e-360 or act-act-isda`);
  }
}