Skip to content

Utilities

Utility functions used internally for date manipulation and numerical rounding in financial calculations.

actual_days(start, end)

Returns the absolute number of days between two dates (inclusive of start, exclusive of end), regardless of date order. Times and timezones are ignored by normalizing to calendar dates. Args: start: The start date (pd.Timestamp). end: The end date (pd.Timestamp). Returns: int: The absolute number of days between start and end. Raises: TypeError: If start or end is not a pd.Timestamp.

Source code in curo/utils.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def actual_days(start: pd.Timestamp, end: pd.Timestamp) -> int:
    """
    Returns the absolute number of days between two dates (inclusive of start,
    exclusive of end), regardless of date order. Times and timezones are ignored
    by normalizing to calendar dates.
    Args:
        start: The start date (pd.Timestamp).
        end: The end date (pd.Timestamp).
    Returns:
        int: The absolute number of days between start and end.
    Raises:
        TypeError: If start or end is not a pd.Timestamp.
    """
    if not (isinstance(start, pd.Timestamp) and isinstance(end, pd.Timestamp)):
        raise TypeError("start and end must be pd.Timestamp")
    start_date = start.normalize().date()
    end_date = end.normalize().date()
    return abs((end_date - start_date).days)

roll_month(date, months, day)

Rolls a date by the specified number of months, adjusting to a preferred day.

Parameters:

Name Type Description Default
date Timestamp

The input date to roll (pd.Timestamp).

required
months int

Number of months to roll; positive for forward, negative for backward (int).

required
day int

Preferred day of the month for the resulting date; capped at month-end if invalid (int).

required

Returns:

Type Description
Timestamp

pd.Timestamp: The rolled date, preserving the input timezone and normalized to midnight.

Source code in curo/utils.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def roll_month(date: pd.Timestamp, months: int, day: int) -> pd.Timestamp:
    """
    Rolls a date by the specified number of months, adjusting to a preferred day.

    Args:
        date: The input date to roll (pd.Timestamp).
        months: Number of months to roll; positive for forward, negative for backward (int).
        day: Preferred day of the month for the resulting date; capped at month-end if
            invalid (int).

    Returns:
        pd.Timestamp: The rolled date, preserving the input timezone and normalized to midnight.
    """
    # Roll by months using DateOffset
    new_date = date + pd.offsets.DateOffset(months=months)
    try:
        return new_date.replace(day=day)
    except ValueError:
        # If day is invalid, use the last day of the month
        month_end = new_date + pd.offsets.MonthEnd(0)
        return month_end

roll_day(date, days)

Rolls a date by the specified number of days.

Parameters:

Name Type Description Default
date Timestamp

The input date to roll (pd.Timestamp).

required
days int

Number of days to roll; positive for forward, negative for backward (int).

required

Returns:

Type Description
Timestamp

pd.Timestamp: The rolled date, preserving the input timezone and normalized to midnight.

Source code in curo/utils.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def roll_day(date: pd.Timestamp, days: int) -> pd.Timestamp:
    """
    Rolls a date by the specified number of days.

    Args:
        date: The input date to roll (pd.Timestamp).
        days: Number of days to roll; positive for forward, 
            negative for backward (int).

    Returns:
        pd.Timestamp: The rolled date, preserving the input 
            timezone and normalized to midnight.
    """
    return date + pd.Timedelta(days=days)

roll_date(date, frequency, day)

Rolls a date forward by the frequency implicit period, adjusting to a preferred day.

Parameters:

Name Type Description Default
date Timestamp

The input date to roll (pd.Timestamp).

required
frequency Frequency

The implicit interval to roll date forward (Frequency).

required
day int

Preferred day of the month for the resulting date; capped at month-end if invalid (int). Note: ignored when frequency WEEKLY or FORTNIGHTLY.

required

Returns:

Type Description
Timestamp

pd.Timestamp: The rolled date, preserving the input timezone and normalized to midnight.

Source code in curo/utils.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def roll_date(date: pd.Timestamp, frequency: Frequency, day: int) -> pd.Timestamp:
    """
    Rolls a date forward by the frequency implicit period, adjusting to a 
    preferred day.

    Args:
        date: The input date to roll (pd.Timestamp).
        frequency: The implicit interval to roll date forward (Frequency).
        day: Preferred day of the month for the resulting date; capped at month-end if
            invalid (int). Note: ignored when frequency WEEKLY or FORTNIGHTLY.

    Returns:
        pd.Timestamp: The rolled date, preserving the input timezone and normalized to midnight.
    """
    if frequency == Frequency.WEEKLY:
        date = roll_day(date, 7)
    elif frequency == Frequency.FORTNIGHTLY:
        date = roll_day(date, 14)
    elif frequency == Frequency.MONTHLY:
        date = roll_month(date, 1, day)
    elif frequency == Frequency.QUARTERLY:
        date = roll_month(date, 3, day)
    elif frequency == Frequency.HALF_YEARLY:
        date = roll_month(date, 6, day)
    elif frequency == Frequency.YEARLY:
        date = roll_month(date, 12, day)
    else:
        raise ValueError(f"Unknown frequency: {frequency}")
    return date

to_timestamp(dt)

Converts a date input to a pd.Timestamp normalized to midnight UTC.

Parameters:

Name Type Description Default
dt Optional[Union[Timestamp, datetime, date]]

A pd.Timestamp, datetime.datetime, or datetime.date. If None, returns None.

required

Returns:

Type Description
Optional[Timestamp]

pd.Timestamp: A UTC timestamp with time set to midnight (00:00:00), or None.

Raises:

Type Description
ValidationError

If dt is not a supported type.

Source code in curo/utils.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def to_timestamp(
    dt: Optional[Union[pd.Timestamp, datetime, dt_module.date]]
    ) -> Optional[pd.Timestamp]:
    """
    Converts a date input to a pd.Timestamp normalized to midnight UTC.

    Args:
        dt: A pd.Timestamp, datetime.datetime, or datetime.date. If None, returns None.

    Returns:
        pd.Timestamp: A UTC timestamp with time set to midnight (00:00:00), or None.

    Raises:
        ValidationError: If dt is not a supported type.
    """
    if dt is None:
        return None
    if isinstance(dt, pd.Timestamp):
        return (dt.normalize().tz_localize('UTC')
                if dt.tz is None else dt.tz_convert('UTC').normalize())
    if isinstance(dt, (datetime, dt_module.date)):
        return pd.Timestamp(dt).tz_localize('UTC').normalize()
    raise ValidationError(
        "Date must be pd.Timestamp, datetime.datetime, or datetime.date"
    )

has_month_end_day(date)

Checks if a date is the last day of its month.

Parameters:

Name Type Description Default
date Timestamp

The date to check (pd.Timestamp).

required

Returns:

Name Type Description
bool bool

True if the date is the last day of the month, False otherwise.

Source code in curo/utils.py
129
130
131
132
133
134
135
136
137
138
139
140
def has_month_end_day(date: pd.Timestamp) -> bool:
    """
    Checks if a date is the last day of its month.

    Args:
        date: The date to check (pd.Timestamp).

    Returns:
        bool: True if the date is the last day of the month,
            False otherwise.
    """
    return date == (date + pd.offsets.MonthEnd(0))

gauss_round(num, precision=0)

Performs Gaussian (bankers') rounding to the nearest even number to avoid statistical bias.

Unlike standard rounding, which biases upward, Gaussian rounding selects the nearest even number when a value is exactly halfway between two numbers (e.g., 2.5 rounds to 2, 3.5 to 4).

Note

Ported and modified from JavaScript source by Tim Down.

Parameters:

Name Type Description Default
num float

The number to round (float).

required
precision int

Number of decimal places; can be positive or negative (int, optional). Defaults to 0.

0

Returns:

Name Type Description
float float

The number rounded to the specified precision.

Source code in curo/utils.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def gauss_round(num: float, precision: int = 0) -> float:
    """
    Performs Gaussian (bankers') rounding to the nearest even number to avoid 
    statistical bias.

    Unlike standard rounding, which biases upward, Gaussian rounding selects 
    the nearest even number when a value is exactly halfway between two numbers
    (e.g., 2.5 rounds to 2, 3.5 to 4).

    Note:
        Ported and modified from JavaScript source by 
        Tim Down[](http://stackoverflow.com/a/3109234).

    Args:
        num: The number to round (float).
        precision: Number of decimal places; can be positive or 
            negative (int, optional). Defaults to 0.

    Returns:
        float: The number rounded to the specified precision.
    """
    m = 10 ** precision
    n = np.round(num * m, 8)
    i = np.floor(n)
    f = n - i
    e = 1e-8
    r = i if f > 0.5 - e and f < 0.5 + e and int(i) % 2 == 0 else np.round(n)
    return r / m