from .dates_add_days import days_from_civil, epoch_day_from_iso, is_leap_year, parse_iso_date 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,) )