Source code for jactus.utilities.conventions

"""Day count convention implementations for ACTUS contracts.

This module provides year fraction calculations according to various day count
conventions used in financial contracts.

References:
    ACTUS Technical Specification v1.1, Section 4 (Day Count Conventions)
    ISDA 2006 Definitions
"""

from __future__ import annotations

import calendar

from jactus.core.time import ActusDateTime
from jactus.core.types import DayCountConvention
from jactus.utilities.calendars import HolidayCalendar, MondayToFridayCalendar


[docs] def year_fraction( start: ActusDateTime, end: ActusDateTime, convention: DayCountConvention, maturity: ActusDateTime | None = None, calendar: HolidayCalendar | None = None, ) -> float: """Calculate year fraction between two dates using specified convention. Args: start: Start date end: End date convention: Day count convention to use maturity: Maturity date (required for some conventions) calendar: Holiday calendar for BUS/252 convention. If None, defaults to MondayToFridayCalendar (Mon-Fri only, no public holidays). Returns: Year fraction as a float Example: >>> start = ActusDateTime(2024, 1, 15, 0, 0, 0) >>> end = ActusDateTime(2024, 7, 15, 0, 0, 0) >>> year_fraction(start, end, DayCountConvention.AA) 0.5 References: ACTUS Technical Specification v1.1, Section 4.1 """ if convention == DayCountConvention.AA: return _year_fraction_aa(start, end) if convention == DayCountConvention.A360: return _year_fraction_a360(start, end) if convention == DayCountConvention.A365: return _year_fraction_a365(start, end) if convention == DayCountConvention.E30360: return _year_fraction_30e360(start, end) if convention == DayCountConvention.E30360ISDA: if maturity is None: raise ValueError("Maturity date required for 30E/360 ISDA convention") return _year_fraction_30e360_isda(start, end, maturity) if convention == DayCountConvention.B30360: return _year_fraction_30360(start, end) if convention == DayCountConvention.BUS252: return _year_fraction_bus252(start, end, calendar) raise ValueError(f"Unsupported day count convention: {convention}")
def _year_fraction_aa(start: ActusDateTime, end: ActusDateTime) -> float: """Actual/Actual ISDA day count convention. Year fraction = Sum of (days in each year / days in that year) References: ACTUS A/A, ISDA 2006 Section 4.16(b) """ if start >= end: return 0.0 total_fraction = 0.0 current = start while current.year < end.year: # Find end of current year year_end = ActusDateTime(current.year, 12, 31, 0, 0, 0) # Days from current to end of year days_in_period = current.days_between(year_end) + 1 # Include last day days_in_year = 366 if calendar.isleap(current.year) else 365 total_fraction += days_in_period / days_in_year # Move to next year current = ActusDateTime(current.year + 1, 1, 1, 0, 0, 0) # Handle remaining days in final year if current < end: days_in_period = current.days_between(end) days_in_year = 366 if calendar.isleap(end.year) else 365 total_fraction += days_in_period / days_in_year return total_fraction def _year_fraction_a360(start: ActusDateTime, end: ActusDateTime) -> float: """Actual/360 day count convention. Year fraction = actual days / 360 References: ACTUS A/360 """ actual_days = start.days_between(end) return actual_days / 360.0 def _year_fraction_a365(start: ActusDateTime, end: ActusDateTime) -> float: """Actual/365 Fixed day count convention. Year fraction = actual days / 365 References: ACTUS A/365 """ actual_days = start.days_between(end) return actual_days / 365.0 def _year_fraction_30e360(start: ActusDateTime, end: ActusDateTime) -> float: """30E/360 (Eurobond basis) day count convention. Days = (Y2-Y1)*360 + (M2-M1)*30 + (D2-D1) Year fraction = Days / 360 Adjustments: - If D1 = 31, then D1 = 30 - If D2 = 31, then D2 = 30 References: ACTUS 30E/360, ISDA 2006 Section 4.16(g) """ y1, m1, d1 = start.year, start.month, start.day y2, m2, d2 = end.year, end.month, end.day # Apply adjustments if d1 == 31: d1 = 30 if d2 == 31: d2 = 30 days = (y2 - y1) * 360 + (m2 - m1) * 30 + (d2 - d1) return days / 360.0 def _year_fraction_30e360_isda( start: ActusDateTime, end: ActusDateTime, maturity: ActusDateTime ) -> float: """30E/360 ISDA day count convention. Similar to 30E/360 but with different end-of-month handling. Adjustments: - If D1 is last day of February, then D1 = 30 - If D1 = 31, then D1 = 30 - If D2 is last day of February and not the maturity date, then D2 = 30 - If D2 = 31, then D2 = 30 References: ACTUS 30E/360 ISDA, ISDA 2006 Section 4.16(h) """ y1, m1, d1 = start.year, start.month, start.day y2, m2, d2 = end.year, end.month, end.day # Check if dates are last day of February def is_last_day_of_feb(dt: ActusDateTime) -> bool: if dt.month != 2: return False last_day = 29 if calendar.isleap(dt.year) else 28 return dt.day == last_day # Apply adjustments if is_last_day_of_feb(start) or d1 == 31: d1 = 30 if (is_last_day_of_feb(end) and end != maturity) or d2 == 31: d2 = 30 days = (y2 - y1) * 360 + (m2 - m1) * 30 + (d2 - d1) return days / 360.0 def _year_fraction_30360(start: ActusDateTime, end: ActusDateTime) -> float: """30/360 (Bond Basis, US) day count convention. Days = (Y2-Y1)*360 + (M2-M1)*30 + (D2-D1) Year fraction = Days / 360 Adjustments: - If D1 = 31, then D1 = 30 - If D1 = 30 or 31, and D2 = 31, then D2 = 30 References: ACTUS 30/360, ISDA 2006 Section 4.16(f) """ y1, m1, d1 = start.year, start.month, start.day y2, m2, d2 = end.year, end.month, end.day # Apply adjustments if d1 == 31: d1 = 30 if d1 >= 30 and d2 == 31: d2 = 30 days = (y2 - y1) * 360 + (m2 - m1) * 30 + (d2 - d1) return days / 360.0 def _year_fraction_bus252( start: ActusDateTime, end: ActusDateTime, calendar: HolidayCalendar | None = None, ) -> float: """BUS/252 (Brazilian business days) day count convention. Year fraction = business days / 252 Uses the provided holiday calendar to determine business days. If no calendar is provided, defaults to MondayToFridayCalendar (Mon-Fri only, no public holidays). Args: start: Start date end: End date calendar: Holiday calendar for business day determination. References: ACTUS BUS/252 """ if calendar is None: calendar = MondayToFridayCalendar() return calendar.business_days_between(start, end) / 252.0
[docs] def days_between_30_360_methods( start: ActusDateTime, end: ActusDateTime, method: str = "30E/360", ) -> int: """Calculate days between two dates using 30/360 methods. Args: start: Start date end: End date method: One of "30E/360", "30/360", "30E/360 ISDA" Returns: Number of days (can be negative if end < start) Example: >>> start = ActusDateTime(2024, 2, 15, 0, 0, 0) >>> end = ActusDateTime(2024, 8, 15, 0, 0, 0) >>> days_between_30_360_methods(start, end, "30E/360") 180 """ y1, m1, d1 = start.year, start.month, start.day y2, m2, d2 = end.year, end.month, end.day if method == "30E/360": if d1 == 31: d1 = 30 if d2 == 31: d2 = 30 elif method == "30/360": if d1 == 31: d1 = 30 if d1 >= 30 and d2 == 31: d2 = 30 elif method == "30E/360 ISDA": # Simplified - full implementation needs maturity def is_last_day_of_feb(dt: ActusDateTime) -> bool: if dt.month != 2: return False last_day = 29 if calendar.isleap(dt.year) else 28 return dt.day == last_day if is_last_day_of_feb(start) or d1 == 31: d1 = 30 if is_last_day_of_feb(end) or d2 == 31: d2 = 30 else: raise ValueError(f"Unknown method: {method}") return (y2 - y1) * 360 + (m2 - m1) * 30 + (d2 - d1)