dates.day-count-fraction
Year fraction between two dates by ACT/365F, ACT/360, 30/360, 30E/360 or ACT/ACT ISDA, as an exact fraction.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 21 tests, run in TypeScript, Python and Rust.
What it does
The fraction of a year between two dates under the day count convention a loan, bond or swap names. It returns an exact fraction, numerator and denominator in lowest terms, rather than a float: interest is then principal x rate x numerator / denominator in integer arithmetic, with one rounding at the end (math.round-div), and three languages cannot disagree in the fifteenth decimal place.
The conventions are those of the 2006 ISDA Definitions, section 4.16:
For example
dayCountFraction(2026-01-01, 2026-07-01, act-365f)→ numerator 181, denominator 365 ACT/365F over half of 2026: 181 actual daysdayCountFraction(2026-01-01, 2026-07-01, act-360)→ numerator 181, denominator 360 ACT/360 over the same 181 daysdayCountFraction(2026-01-01, 2026-04-01, act-360)→ numerator 1, denominator 4 ACT/360 over 90 days is exactly a quarter, reduced to lowest terms
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function dayCountFraction(startIso: string, endIso: string, convention: DayCountConvention): YearFraction
| startIso | date | start of the accrual period, ISO |
| endIso | date | end of the accrual period, ISO; not before startIso |
| convention | DayCountConvention | which day count basis the contract names |
| returns | YearFraction | the fraction of a year, in lowest terms |
The types it declares, generated into your project
export type DayCountConvention = "act-365f" | "act-360" | "30-360" | "30e-360" | "act-act-isda";
/** An exact year fraction; divide only at the point of use. */
export interface YearFraction {
/** 0 or more */
readonly numerator: number;
/** 1 or more; the fraction is in lowest terms */
readonly denominator: number;
}
Your code names it in one line, in the file that uses it
import { dayCountFraction } from "#fune/dates.day-count-fraction@^1";
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`);
}
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add dates.day-count-fraction
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./dates.day-count-fraction-1.0.0-typescript.fune, or fetch it from a terminal with fune pull dates.day-count-fraction@1.0.0:typescript.
The whole function, every language, is one file too: dates.day-count-fraction-1.0.0.fune, 16,521 bytes, sha256 c19c350e9bcfd350f52a9fa1766f89525c83765d2f239ae9948617001b15a651. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before dates.day-count-fraction
after — your function gets the result and the arguments, and returns the final result.
// fune: after dates.day-count-fraction
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace dates.add-days in dates.day-count-fraction
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show dates.day-count-fraction --steps.
// fune: step dates.day-count-fraction after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| ACT/365F over half of 2026: 181 actual days | 2026-01-01, 2026-07-01, act-365f | → | numerator 181, denominator 365 |
| ACT/360 over the same 181 days | 2026-01-01, 2026-07-01, act-360 | → | numerator 181, denominator 360 |
| ACT/360 over 90 days is exactly a quarter, reduced to lowest terms | 2026-01-01, 2026-04-01, act-360 | → | numerator 1, denominator 4 |
| ACT/365F over a whole leap year is 366/365, more than one | 2024-01-01, 2025-01-01, act-365f | → | numerator 366, denominator 365 |
| ACT/365F across 29 February from the last day of 2023 | 2023-12-31, 2024-12-31, act-365f | → | numerator 366, denominator 365 |
| 30/360 bond basis: 31 Jan to 28 Feb, D1 becomes 30, so 28 days | 2026-01-31, 2026-02-28, 30-360 | → | numerator 7, denominator 90 |
| 30/360 bond basis: 31 Jan to 31 Mar, both ends trimmed, 60 days | 2026-01-31, 2026-03-31, 30-360 | → | numerator 1, denominator 6 |
| 30/360 bond basis keeps D2 = 31 when D1 is not at month end: 76 days | 2026-01-15, 2026-03-31, 30-360 | → | numerator 19, denominator 90 |
| 30E/360 always trims D2 = 31: the same dates give 75 days | 2026-01-15, 2026-03-31, 30e-360 | → | numerator 5, denominator 24 |
| 30E/360 31 Jan to 31 Mar | 2026-01-31, 2026-03-31, 30e-360 | → | numerator 1, denominator 6 |
Show the other 11 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 30/360 has no end-of-February rule: 28 Feb to 1 Mar is 3 days, not 1 | 2026-02-28, 2026-03-01, 30-360 | → | numerator 1, denominator 120 |
| 30/360 over a whole year is exactly 1 | 2025-03-15, 2026-03-15, 30-360 | → | numerator 1, denominator 1 |
| ACT/ACT ISDA splits the days by year: 184/365 + 182/366 | 2023-07-01, 2024-07-01, act-act-isda | → | numerator 66,887, denominator 66,795 |
| ACT/ACT ISDA over a whole leap year is exactly 1 | 2024-01-01, 2025-01-01, act-act-isda | → | numerator 1, denominator 1 |
| ACT/ACT ISDA over two whole years is exactly 2 | 2024-01-01, 2026-01-01, act-act-isda | → | numerator 2, denominator 1 |
| ACT/ACT ISDA within one ordinary year | 2026-01-01, 2026-07-01, act-act-isda | → | numerator 181, denominator 365 |
| ACT/ACT ISDA within one leap year | 2024-01-01, 2024-07-01, act-act-isda | → | numerator 91, denominator 183 |
| the same date twice is zero, as 0/1 | 2026-09-22, 2026-09-22, act-360 | → | numerator 0, denominator 1 |
| a start after the end is an error | 2026-09-23, 2026-09-22, act-365f | → | error: must not be after endIso |
| an unknown convention is an error, not a default | 2026-01-01, 2026-07-01, act-365 | → | error: unknown day count convention "act-365" |
| an impossible date is an error | 2026-02-30, 2026-07-01, act-360 | → | error: is not a real calendar date |
More from the author
- `act-365f` (Actual/365 (Fixed), 4.16(d)): actual days / 365, even in a leap year, so a whole leap year is 366/365. - `act-360` (Actual/360, 4.16(e)): actual days / 360, the money-market basis. - `30-360` (30/360, Bond Basis, 4.16(f)): each month counts as 30 days. D1 = 31 becomes 30; D2 = 31 becomes 30 only if D1 is (now) 30. Fraction is (360 x (Y2 - Y1) + 30 x (M2 - M1) + (D2 - D1)) / 360. - `30e-360` (30E/360, Eurobond Basis, 4.16(g)): as above, but D2 = 31 always becomes 30. - `act-act-isda` (Actual/Actual (ISDA), 4.16(b)): the days falling in a leap year / 366 plus the days falling in a non-leap year / 365. The start date counts, the end date does not.
What it does not do. There is no end-of-February rule in `30-360`: ISDA bond basis has none, unlike the "30/360 US" (NASD / SIA) variant, where the last day of February counts as the 30th; 28 February to 1 March is 3/360 here. It does not implement ACT/ACT ICMA, which needs the coupon schedule as well as two dates, or 30E/360 ISDA, which needs the maturity date. Business day adjustment of the dates is the caller's job, before calling.
The start must not be after the end; the same date twice is 0/1. Actual days come from the dates.add-days kernel, so leap years and centuries are exact.
Files
| Path | Bytes |
|---|---|
| README.md | 1,765 |
| impl/python.py | 2,495 |
| impl/rust.rs | 3,224 |
| impl/typescript.ts | 2,536 |
| vectors.json | 3,476 |