"""Date and time handling for ACTUS contracts.
This module provides the ActusDateTime class and utilities for parsing,
manipulating, and comparing dates according to ACTUS specifications.
Key features:
- ISO 8601 datetime parsing with ACTUS-specific extensions
- Support for 24:00:00 (end of day) and 00:00:00 (start of day)
- Period/cycle arithmetic (e.g., adding '3M' to a date)
- Month-end handling and leap year support
- JAX pytree registration for functional programming
- Business day awareness
References:
ACTUS Technical Specification v1.1, Section 3 (Time)
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
import jax
from jactus.core.types import BusinessDayConvention, Calendar, Cycle, EndOfMonthConvention
[docs]
@dataclass(frozen=True)
class ActusDateTime:
"""Immutable datetime representation for ACTUS contracts.
ACTUS uses ISO 8601 datetime strings with special handling:
- 24:00:00 represents end of day (midnight of next day)
- 00:00:00 represents start of day (midnight)
- Dates can be added/subtracted using cycle notation (e.g., '3M', '1Y')
Attributes:
year: Year (1-9999)
month: Month (1-12)
day: Day of month (1-31)
hour: Hour (0-24, where 24 = end of day)
minute: Minute (0-59)
second: Second (0-59)
Example:
>>> dt = ActusDateTime.from_iso("2024-01-15T00:00:00")
>>> dt.add_period("3M")
ActusDateTime(2024, 4, 15, 0, 0, 0)
References:
ACTUS Technical Specification v1.1, Section 3.1
"""
year: int
month: int
day: int
hour: int = 0
minute: int = 0
second: int = 0
[docs]
def __post_init__(self) -> None:
"""Validate datetime components."""
if not 1 <= self.year <= 9999:
raise ValueError(f"Year must be 1-9999, got {self.year}")
if not 1 <= self.month <= 12:
raise ValueError(f"Month must be 1-12, got {self.month}")
if not 1 <= self.day <= 31:
raise ValueError(f"Day must be 1-31, got {self.day}")
if not 0 <= self.hour <= 24:
raise ValueError(f"Hour must be 0-24, got {self.hour}")
if self.hour == 24 and (self.minute != 0 or self.second != 0):
raise ValueError("24:00:00 is the only valid time with hour=24")
if not 0 <= self.minute <= 59:
raise ValueError(f"Minute must be 0-59, got {self.minute}")
if not 0 <= self.second <= 59:
raise ValueError(f"Second must be 0-59, got {self.second}")
[docs]
@classmethod
def from_iso(cls, iso_string: str) -> ActusDateTime:
"""Parse ISO 8601 datetime string.
Supports formats:
- YYYY-MM-DD
- YYYY-MM-DDTHH:MM:SS
- YYYY-MM-DD HH:MM:SS (space separator)
Special handling for 24:00:00 (end of day).
Args:
iso_string: ISO 8601 formatted datetime string
Returns:
ActusDateTime instance
Raises:
ValueError: If string format is invalid
Example:
>>> ActusDateTime.from_iso("2024-01-15T24:00:00")
ActusDateTime(2024, 1, 15, 24, 0, 0)
"""
return parse_iso_datetime(iso_string)
[docs]
def to_iso(self) -> str:
"""Convert to ISO 8601 string.
Returns:
ISO 8601 formatted string (YYYY-MM-DDTHH:MM:SS)
Example:
>>> dt = ActusDateTime(2024, 1, 15, 12, 30, 0)
>>> dt.to_iso()
'2024-01-15T12:30:00'
"""
return f"{self.year:04d}-{self.month:02d}-{self.day:02d}T{self.hour:02d}:{self.minute:02d}:{self.second:02d}"
[docs]
def to_datetime(self) -> datetime:
"""Convert to Python datetime object.
Note: 24:00:00 is converted to 00:00:00 of the next day.
Returns:
Python datetime object
Example:
>>> dt = ActusDateTime(2024, 1, 15, 24, 0, 0)
>>> dt.to_datetime()
datetime(2024, 1, 16, 0, 0, 0)
"""
if self.hour == 24:
# 24:00:00 means midnight of next day
dt = datetime(self.year, self.month, self.day, 0, 0, 0)
return dt + timedelta(days=1)
return datetime(self.year, self.month, self.day, self.hour, self.minute, self.second)
[docs]
def add_period(
self,
cycle: Cycle,
end_of_month_convention: EndOfMonthConvention = EndOfMonthConvention.SD,
) -> ActusDateTime:
"""Add a period to this datetime.
Periods are specified in ACTUS cycle notation:
- NPS where N=number, P=period type, S=stub indicator
- Period types: D=days, W=weeks, M=months, Q=quarters, H=half-years, Y=years
- Stub indicator: '-' for short stub, '+' for long stub (optional)
Args:
cycle: Period to add (e.g., '3M', '1Y', '2W')
end_of_month_convention: How to handle month-end dates
Returns:
New ActusDateTime after adding period
Example:
>>> dt = ActusDateTime(2024, 1, 31, 0, 0, 0)
>>> dt.add_period("1M", EndOfMonthConvention.EOM)
ActusDateTime(2024, 2, 29, 0, 0, 0) # Leap year
References:
ACTUS Technical Specification v1.1, Section 3.2
"""
return add_period(self, cycle, end_of_month_convention)
[docs]
def is_end_of_month(self) -> bool:
"""Check if this date is the last day of the month.
Returns:
True if this is the last day of the month
Example:
>>> ActusDateTime(2024, 2, 29, 0, 0, 0).is_end_of_month()
True
>>> ActusDateTime(2024, 2, 28, 0, 0, 0).is_end_of_month()
False
"""
# Check if adding one day changes the month
dt = self.to_datetime()
next_day = dt + timedelta(days=1)
return next_day.month != dt.month
[docs]
def days_between(self, other: ActusDateTime) -> int:
"""Calculate actual days between this date and another.
Args:
other: Other datetime
Returns:
Number of days (can be negative if other is earlier)
Example:
>>> dt1 = ActusDateTime(2024, 1, 15, 0, 0, 0)
>>> dt2 = ActusDateTime(2024, 1, 18, 0, 0, 0)
>>> dt1.days_between(dt2)
3
"""
dt1 = self.to_datetime()
dt2 = other.to_datetime()
return (dt2 - dt1).days
[docs]
def years_between(self, other: ActusDateTime) -> float:
"""Calculate approximate years between dates (actual days / 365.25).
Args:
other: Other datetime
Returns:
Approximate years (can be negative)
Example:
>>> dt1 = ActusDateTime(2024, 1, 15, 0, 0, 0)
>>> dt2 = ActusDateTime(2025, 1, 15, 0, 0, 0)
>>> abs(dt1.years_between(dt2) - 1.0) < 0.01
True
"""
days = self.days_between(other)
return days / 365.25
[docs]
def __eq__(self, other: object) -> bool:
"""Check equality with another ActusDateTime."""
if not isinstance(other, ActusDateTime):
return NotImplemented
return (
self.year == other.year
and self.month == other.month
and self.day == other.day
and self.hour == other.hour
and self.minute == other.minute
and self.second == other.second
)
[docs]
def __lt__(self, other: ActusDateTime) -> bool:
"""Check if this datetime is before another."""
return self.to_datetime() < other.to_datetime()
[docs]
def __le__(self, other: ActusDateTime) -> bool:
"""Check if this datetime is before or equal to another."""
return self.to_datetime() <= other.to_datetime()
[docs]
def __gt__(self, other: ActusDateTime) -> bool:
"""Check if this datetime is after another."""
return self.to_datetime() > other.to_datetime()
[docs]
def __ge__(self, other: ActusDateTime) -> bool:
"""Check if this datetime is after or equal to another."""
return self.to_datetime() >= other.to_datetime()
[docs]
def __hash__(self) -> int:
"""Hash for use in dicts/sets."""
return hash((self.year, self.month, self.day, self.hour, self.minute, self.second))
# Register ActusDateTime as a JAX pytree for functional programming
def _actus_datetime_flatten(dt: ActusDateTime) -> tuple[tuple[int, ...], None]:
"""Flatten ActusDateTime for JAX pytree registration."""
return ((dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second), None)
def _actus_datetime_unflatten(aux_data: None, children: tuple[int, ...]) -> ActusDateTime:
"""Unflatten ActusDateTime for JAX pytree registration."""
return ActusDateTime(*children)
jax.tree_util.register_pytree_node(
ActusDateTime,
_actus_datetime_flatten,
_actus_datetime_unflatten,
)
[docs]
def parse_iso_datetime(iso_string: str) -> ActusDateTime:
"""Parse ISO 8601 datetime string into ActusDateTime.
Supports formats:
- YYYY-MM-DD
- YYYY-MM-DDTHH:MM:SS
- YYYY-MM-DD HH:MM:SS
Args:
iso_string: ISO 8601 formatted string
Returns:
ActusDateTime instance
Raises:
ValueError: If format is invalid
Example:
>>> parse_iso_datetime("2024-01-15T12:30:00")
ActusDateTime(2024, 1, 15, 12, 30, 0)
"""
# Try full datetime with T separator
match = re.match(
r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$",
iso_string,
)
if match:
year, month, day, hour, minute, second = map(int, match.groups())
return ActusDateTime(year, month, day, hour, minute, second)
# Try full datetime with space separator
match = re.match(
r"^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$",
iso_string,
)
if match:
year, month, day, hour, minute, second = map(int, match.groups())
return ActusDateTime(year, month, day, hour, minute, second)
# Try date only
match = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", iso_string)
if match:
year, month, day = map(int, match.groups())
return ActusDateTime(year, month, day, 0, 0, 0)
raise ValueError(
f"Invalid ISO 8601 datetime format: {iso_string}. "
f"Expected YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS"
)
[docs]
def parse_cycle(cycle: Cycle) -> tuple[int, str, str]:
"""Parse ACTUS cycle notation.
Cycle format: NPS
- N: number (integer)
- P: period type (D/W/M/Q/H/Y)
- S: stub indicator ('-' short, '+' long) - optional
Args:
cycle: Cycle string (e.g., '3M', '1Y-', '6M+')
Returns:
Tuple of (number, period_type, stub_indicator)
Raises:
ValueError: If cycle format is invalid
Example:
>>> parse_cycle("3M")
(3, 'M', '')
>>> parse_cycle("1Y-")
(1, 'Y', '-')
References:
ACTUS Technical Specification v1.1, Section 3.2
"""
match = re.match(r"^(\d+)([DWMQHY])([-+]?)$", cycle.upper())
if not match:
raise ValueError(
f"Invalid cycle format: {cycle}. "
f"Expected format: NPS where N=number, P=D/W/M/Q/H/Y, S='-'/'+' (optional)"
)
number_str, period_type, stub = match.groups()
return int(number_str), period_type, stub
[docs]
def add_period(
dt: ActusDateTime,
cycle: Cycle,
end_of_month_convention: EndOfMonthConvention = EndOfMonthConvention.SD,
) -> ActusDateTime:
"""Add a period to a datetime according to ACTUS conventions.
Args:
dt: Starting datetime
cycle: Period to add (e.g., '3M', '1Y')
end_of_month_convention: How to handle month-end dates
Returns:
New datetime after adding period
Example:
>>> dt = ActusDateTime(2024, 1, 31, 0, 0, 0)
>>> add_period(dt, "1M", EndOfMonthConvention.EOM)
ActusDateTime(2024, 2, 29, 0, 0, 0)
References:
ACTUS Technical Specification v1.1, Section 3.2, 3.3
"""
number, period_type, _ = parse_cycle(cycle)
# Remember if we started at end of month
started_at_eom = dt.is_end_of_month()
# Convert to Python datetime for arithmetic
py_dt = dt.to_datetime()
# Add the period
if period_type == "D":
new_dt = py_dt + timedelta(days=number)
elif period_type == "W":
new_dt = py_dt + timedelta(weeks=number)
elif period_type in ("M", "Q", "H", "Y"):
# Convert to months
months_map = {"M": 1, "Q": 3, "H": 6, "Y": 12}
months_to_add = number * months_map[period_type]
# Calculate new year and month
total_months = (py_dt.year * 12 + py_dt.month - 1) + months_to_add
new_year = total_months // 12
new_month = (total_months % 12) + 1
# Handle day overflow (e.g., Jan 31 + 1M = Feb 28/29)
# First try same day
try:
new_dt = datetime(
new_year, new_month, py_dt.day, py_dt.hour, py_dt.minute, py_dt.second
)
except ValueError:
# Day doesn't exist in target month, use last day of month
# Find last day by trying from 31 down
for day in range(31, 27, -1):
try:
new_dt = datetime(
new_year, new_month, day, py_dt.hour, py_dt.minute, py_dt.second
)
break
except ValueError:
continue
else:
raise ValueError(f"Unsupported period type: {period_type}")
# Create new ActusDateTime
result = ActusDateTime(
new_dt.year,
new_dt.month,
new_dt.day,
new_dt.hour,
new_dt.minute,
new_dt.second,
)
# Apply end-of-month convention (only for month-based periods)
if (
end_of_month_convention == EndOfMonthConvention.EOM
and started_at_eom
and period_type in ("M", "Q", "H", "Y")
):
# Move to end of target month
result_dt = result.to_datetime()
# Add days until we're at the last day of the month
while True:
next_day = result_dt + timedelta(days=1)
if next_day.month != result_dt.month:
# We're at the last day
break
result_dt = next_day
result = ActusDateTime(
result_dt.year,
result_dt.month,
result_dt.day,
dt.hour,
dt.minute,
dt.second,
)
return result
[docs]
def is_business_day(
dt: ActusDateTime,
calendar: Calendar = Calendar.MONDAY_TO_FRIDAY,
) -> bool:
"""Check if a date is a business day according to the given calendar.
Args:
dt: Date to check
calendar: Business day calendar to use
Returns:
True if date is a business day
Example:
>>> dt = ActusDateTime(2024, 1, 15, 0, 0, 0) # Monday
>>> is_business_day(dt)
True
References:
ACTUS Technical Specification v1.1, Section 3.4
"""
py_dt = dt.to_datetime()
if calendar == Calendar.NO_CALENDAR:
return True
# Check if weekend (applies to all standard calendars)
if py_dt.weekday() >= 5: # Saturday=5, Sunday=6
return False
if calendar == Calendar.MONDAY_TO_FRIDAY:
return True
# Dispatch to full calendar objects for holiday-aware calendars
if calendar in (Calendar.TARGET, Calendar.US_NYSE, Calendar.UK_SETTLEMENT):
from jactus.utilities.calendars import get_calendar
_calendar_name_map = {
Calendar.TARGET: "TARGET",
Calendar.US_NYSE: "NYSE",
Calendar.UK_SETTLEMENT: "UK_SETTLEMENT",
}
cal = get_calendar(_calendar_name_map[calendar])
return cal.is_business_day(dt)
# Unknown calendar - be conservative (treat as business day)
return True
[docs]
def adjust_to_business_day(
dt: ActusDateTime,
convention: BusinessDayConvention,
calendar: Calendar = Calendar.MONDAY_TO_FRIDAY,
) -> ActusDateTime:
"""Adjust a date to a business day according to the given convention.
ACTUS business day conventions have two components:
- **S (Shift)**: Move the payment/settlement date to a business day.
- **C (Calculate)**: Use the original (unadjusted) date for accrual
calculations, even if the payment date was shifted.
Convention naming: ``[S|CS][F|MF|P|MP]``
- ``S`` prefix = Shift only (both payment and calculation use shifted date)
- ``CS`` prefix = Calculate-Shift (payment is shifted, calculation uses
original date)
Currently, all conventions implement the Shift (S) semantics for date
adjustment. The CS distinction is documented for callers that need to
track the original calculation date separately (see ``get_calculation_date``).
Args:
dt: Date to adjust
convention: Business day convention to use
calendar: Business day calendar
Returns:
Adjusted (shifted) date (may be same as input if already a business day)
Example:
>>> dt = ActusDateTime(2024, 1, 13, 0, 0, 0) # Saturday
>>> adjust_to_business_day(dt, BusinessDayConvention.SCF)
ActusDateTime(2024, 1, 15, 0, 0, 0) # Monday
References:
ACTUS Technical Specification v1.1, Section 3.4
"""
if convention == BusinessDayConvention.NULL:
return dt
if is_business_day(dt, calendar):
return dt
# Save original date for Modified conventions
original_dt = dt
py_dt = dt.to_datetime()
original_month = py_dt.month
if "F" in convention.value: # Following
# Move forward to next business day
while not is_business_day(dt, calendar):
py_dt = dt.to_datetime() + timedelta(days=1)
dt = ActusDateTime(py_dt.year, py_dt.month, py_dt.day, dt.hour, dt.minute, dt.second)
# Check if Modified: if we crossed a month boundary, go backward from original instead
if "M" in convention.value and dt.month != original_month:
dt = original_dt
py_dt = original_dt.to_datetime()
while not is_business_day(dt, calendar):
py_dt = py_dt - timedelta(days=1)
dt = ActusDateTime(
py_dt.year, py_dt.month, py_dt.day, dt.hour, dt.minute, dt.second
)
elif "P" in convention.value: # Preceding
# Move backward to previous business day
while not is_business_day(dt, calendar):
py_dt = dt.to_datetime() - timedelta(days=1)
dt = ActusDateTime(py_dt.year, py_dt.month, py_dt.day, dt.hour, dt.minute, dt.second)
# Check if Modified: if we crossed a month boundary, go forward from original instead
if "M" in convention.value and dt.month != original_month:
dt = original_dt
py_dt = original_dt.to_datetime()
while not is_business_day(dt, calendar):
py_dt = py_dt + timedelta(days=1)
dt = ActusDateTime(
py_dt.year, py_dt.month, py_dt.day, dt.hour, dt.minute, dt.second
)
return dt
[docs]
def is_shift_calculate(convention: BusinessDayConvention) -> bool:
"""Check if convention is Shift-Calculate (S prefix).
In SC conventions, both the payment date and the calculation date are
shifted to a business day together.
Args:
convention: Business day convention
Returns:
True if this is a Shift-only convention (SCF, SCMF, SCP, SCMP)
"""
return convention.value.startswith("SC")
[docs]
def is_calculate_shift(convention: BusinessDayConvention) -> bool:
"""Check if convention is Calculate-Shift (CS prefix).
In CS conventions, the payment date is shifted to a business day, but
the calculation date remains the original (unadjusted) date. This means
interest accrual uses the scheduled date, not the actual payment date.
Args:
convention: Business day convention
Returns:
True if this is a Calculate-Shift convention (CSF, CSMF, CSP, CSMP)
"""
return convention.value.startswith("CS")
[docs]
def get_calculation_date(
original_dt: ActusDateTime,
shifted_dt: ActusDateTime,
convention: BusinessDayConvention,
) -> ActusDateTime:
"""Get the date to use for accrual calculations.
For SC (Shift-Calculate) conventions, returns the shifted date.
For CS (Calculate-Shift) conventions, returns the original date.
For NULL convention, returns the original date.
Args:
original_dt: The original scheduled date (before adjustment)
shifted_dt: The business-day-adjusted date
convention: Business day convention
Returns:
Date to use for interest accrual calculations
"""
if is_calculate_shift(convention):
return original_dt
return shifted_dt