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
day_count_fraction(2026-01-01, 2026-07-01, act-365f)→ numerator 181, denominator 365 ACT/365F over half of 2026: 181 actual daysday_count_fraction(2026-01-01, 2026-07-01, act-360)→ numerator 181, denominator 360 ACT/360 over the same 181 daysday_count_fraction(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.
def day_count_fraction(start_iso: str, end_iso: str, convention: DayCountConvention) -> YearFraction
| start_iso | date | start of the accrual period, ISO |
| end_iso | 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
DayCountConvention = Literal["act-365f", "act-360", "30-360", "30e-360", "act-act-isda"]
@dataclass(frozen=True)
class YearFraction:
"""An exact year fraction; divide only at the point of use."""
#: 0 or more
numerator: int
#: 1 or more; the fraction is in lowest terms
denominator: int
Your code names it in one line, in the file that uses it
from fune.dates.day_count_fraction import day_count_fraction # 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.
from .dates_add_days import days_from_civil, epoch_day_from_iso, is_leap_year, parse_iso_date ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_day_count_fraction_types import DayCountConvention, YearFraction
def _gcd(a: int, b: int) -> int:
while b != 0:
a, b = b, a % b
return a
def _reduced(numerator: int, denominator: int) -> YearFraction:
if numerator == 0:
return YearFraction(numerator=0, denominator=1)
g = _gcd(numerator, denominator)
return YearFraction(numerator=numerator // g, denominator=denominator // g)
def day_count_fraction(start_iso: str, end_iso: str, convention: DayCountConvention) -> YearFraction:
"""The year fraction from ``start_iso`` to ``end_iso`` 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.
"""
start = parse_iso_date(start_iso)
end = parse_iso_date(end_iso)
if start_iso > end_iso:
raise ValueError("startIso %s must not be after endIso %s" % (start_iso, end_iso))
first = epoch_day_from_iso(start_iso)
last = epoch_day_from_iso(end_iso)
actual = last - first
if convention == "act-365f":
return _reduced(actual, 365)
if convention == "act-360":
return _reduced(actual, 360)
if convention in ("30-360", "30e-360"):
d1 = 30 if start.day == 31 else start.day
d2 = end.day
# Bond basis only trims D2 when D1 is at the month end too; Eurobond always does.
if d2 == 31 and (convention == "30e-360" or d1 == 30):
d2 = 30
return _reduced(360 * (end.year - start.year) + 30 * (end.month - start.month) + (d2 - d1), 360)
if convention == "act-act-isda":
# Split the actual days by the calendar year they fall in: [start, end).
leap_days = 0
other_days = 0
for year in range(start.year, end.year + 1):
frm = first if year == start.year else days_from_civil(year, 1, 1)
to = last if year == end.year else days_from_civil(year + 1, 1, 1)
if is_leap_year(year):
leap_days += to - frm
else:
other_days += to - frm
return _reduced(other_days * 366 + leap_days * 365, 365 * 366)
raise ValueError(
'unknown day count convention "%s": expected act-365f, act-360, 30-360, 30e-360 or act-act-isda' % (convention,)
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./dates.day-count-fraction-1.0.0-python.fune, or fetch it from a terminal with fune pull dates.day-count-fraction@1.0.0:python.
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 |