Date Utilities Guide#
The lactuca.dates module provides vectorized date-manipulation and actuarial
age-calculation functions. Date-oriented functions accept the broad range of input
types listed in the DateLike section below. The module covers seven function groups:
date construction and manipulation, component extraction, formatting, duration
calculation, actuarial age, anniversary generation, and calendar utilities.
Accepted date formats (DateLike)#
Date-oriented input functions in lactuca.dates accept DateLike inputs. In the API reference and
error messages, DateLike refers to the set of accepted date input forms.
String and numeric parsing rules depend on config.date_format (see
Configuration):
Format |
Example |
|---|---|
|
|
|
|
ISO string |
|
European date string |
|
US date string |
|
Integer YYYYMMDD |
|
pandas |
|
Polars |
Polars date scalar |
NumPy |
|
Accepted string/number forms by configuration:
config.date_format="ymd": acceptsYYYY-MM-DDandYYYY/MM/DDconfig.date_format="dmy": acceptsDD/MM/YYYYandDD-MM-YYYYconfig.date_format="mdy": acceptsMM/DD/YYYYandMM-DD-YYYYconfig.date_format="ymd_int": accepts 8-digit integers / numeric strings (YYYYMMDD)
Sequence inputs (Python list, NumPy array, pandas/Polars Series, etc.) are
accepted wherever documented. Length-1 sequences remain vectors (return
ndarray of shape (1,)). By contrast, a FormatDates wrapper of length 1
(e.g. from make_date(y, m, d)) is treated as a scalar when re-entering age,
duration, and extractor APIs — so age_exact(make_date(...), make_date(...))
returns a Python float, not a length-1 array. Do not index with [0] for that
pipeline.
Age functions return numpy.ndarray of float64 when any true sequence is
passed. Duration functions return numpy.ndarray of int32 for days/months and
float64 for years whenever any input is a sequence (including length 1).
Date construction functions (make_date, end_of_month, add_duration,
anniversary_dates) return a FormatDates list.
When multiple sequence arguments are provided, lengths must be compatible for
broadcasting (equal lengths, or length 1 to be broadcast). Incompatible lengths
raise ValueError.
Date construction and manipulation#
make_date(year, month, day) → FormatDates#
Constructs one or more dates from integer year, month, and day components. Returns a
FormatDates object — a list of datetime.date values with a .format() method for
string conversion. Any of the three arguments may be a sequence; scalar arguments are
broadcast to match the longest sequence.
from lactuca.dates import make_date
d = make_date(2024, 3, 15)
print(d) # FormatDates([datetime.date(2024, 3, 15)])
print(d.format()) # '2024-03-15'
# Vectorised: build one date per month, same year and day
monthly = make_date(2024, [1, 2, 3, 4, 5, 6], 1)
print(monthly.format())
# ['2024-01-01', '2024-02-01', '2024-03-01', '2024-04-01', '2024-05-01', '2024-06-01']
end_of_month(input_date) → FormatDates#
Returns the last calendar day of the month containing input_date.
from lactuca.dates import end_of_month
print(end_of_month("2024-02-10")) # FormatDates([datetime.date(2024, 2, 29)]) (2024 is leap)
print(end_of_month("2023-02-10")) # FormatDates([datetime.date(2023, 2, 28)])
add_duration(start_date, *, years=0, months=0, days=0) → FormatDates#
Adds an offset of years, months, and/or days to a date. When the resulting day-of-month does not exist in the target month, it is clamped to the last valid day (e.g. January 31 + 1 month → February 28/29).
from lactuca.dates import add_duration
# End-of-month clamping
print(add_duration("2024-01-31", months=1)) # FormatDates([datetime.date(2024, 2, 29)])
# Add one year
print(add_duration("2023-06-15", years=1)) # FormatDates([datetime.date(2024, 6, 15)])
# Combine years, months and days in one call
print(add_duration("2024-01-15", years=1, months=2, days=10)) # FormatDates([datetime.date(2025, 3, 25)])
Date components#
year, month, day#
Extract a single component from a date. Sequence inputs return a NumPy array
(int32).
from lactuca.dates import year, month, day
print(year("2024-07-15")) # 2024
print(month("2024-07-15")) # 7
print(day("2024-07-15")) # 15
quarter(input_date, *, prefix=None, suffix=None)#
Returns the calendar quarter (1–4). Optional prefix and suffix strings are
prepended and appended to the result.
from lactuca.dates import quarter
print(quarter("2024-07-15")) # 3
print(quarter("2024-07-15", prefix="Q")) # 'Q3' (Anglo-Saxon)
print(quarter("2024-07-15", suffix="T")) # '3T' (Spanish)
print(quarter("2024-07-15", prefix="Q", suffix="/2024")) # 'Q3/2024'
# Sequence input
print(quarter(["2024-01-15", "2024-07-15", "2024-12-31"]))
# [1, 3, 4]
Date formatting#
FormatDates and .format()#
Functions such as make_date, end_of_month, add_duration, next_anniversary, and
anniversary_dates return a FormatDates object — a list subclass of
datetime.date values. Call .format(date_format=None) to convert to the
configured output representation.
When date_format=None the global config.date_format setting is used.
.format() returns a scalar when the wrapper contains a single date leaf,
a list for multiple dates, and a nested list (list[list[str|int]]) when
the wrapper holds a multi-policy list-of-lists (e.g. anniversary_dates with
N>1). Values are strings for "ymd", "dmy", and "mdy", and integers for
"ymd_int".
For annual grids (m=1), anniversary_dates re-anchors the original day-of-month
each year (so a start on 29 February keeps 29 February on later leap years). Prefer
add_duration(..., years=N) from the original anchor (or anniversary_dates)
rather than chaining repeated +1 year steps from a clamped February date.
from lactuca.dates import make_date, anniversary_dates
from datetime import date
# Single date — scalar string
dates = make_date(2024, 7, 15)
print(dates.format()) # '2024-07-15'
print(dates.format("dmy")) # '15/07/2024'
# Multiple dates — list of strings
anns = anniversary_dates(date(2024, 1, 1), date(2025, 1, 1), m=4)
print(anns.format())
# ['2024-01-01', '2024-04-01', '2024-07-01', '2024-10-01']
format_date(input_date, date_format=None)#
Formats any DateLike input using the specified output format.
Input parsing follows the global config.date_format setting. When
date_format="ymd_int", the result is an integer in YYYYMMDD form; other
formats return strings.
from lactuca.dates import format_date
print(format_date("2024-07-15", date_format="dmy")) # '15/07/2024'
print(format_date("2024-07-15", date_format="ymd")) # '2024-07-15'
print(format_date("2024-07-15", date_format="ymd_int")) # 20240715 (integer)
Duration calculations#
days_between(date1, date2)#
Returns the integer number of days (\(\text{date}_2 - \text{date}_1\)).
from datetime import date
from lactuca.dates import days_between
print(days_between(date(2024, 1, 1), date(2024, 12, 31))) # 365
months_between(date1, date2)#
Returns the number of complete calendar months elapsed. A month is complete when the destination day-of-month is ≥ the start day-of-month.
from lactuca.dates import months_between
print(months_between("2024-01-15", "2024-06-20")) # 5
years_between(date1, date2, *, method='act_act')#
Returns fractional years between two dates.
|
Convention |
Notes |
|---|---|---|
|
Actual/Actual ISDA |
Year-by-year leap-year accumulation; highest precision |
|
days / 365.25 |
Faster; maximum error < 0.003 years |
from lactuca.dates import years_between
# Aligned 4-year span — both methods agree (1461 days / 365.25 = 4.0 exactly)
print(years_between("2020-01-01", "2024-01-01", method="act_act")) # 4.0
print(years_between("2020-01-01", "2024-01-01", method="exact")) # 4.0
# 3 non-leap years — methods diverge (1095 days / 365.25 < 3.0)
print(years_between("2021-01-01", "2024-01-01", method="act_act")) # 3.0
print(years_between("2021-01-01", "2024-01-01", method="exact")) # 2.9979466119096507
time_diff(date1, date2, *, unit='years', method=None)#
Unified entry point for all duration calculations. unit may be 'days', 'months',
or 'years'. For unit='years', method is passed to years_between (defaults to
'act_act').
from lactuca.dates import time_diff
print(time_diff("2024-01-01", "2024-12-31", unit="days")) # 365
print(time_diff("2024-01-15", "2024-06-20", unit="months")) # 5
print(time_diff("2020-01-01", "2024-01-01", unit="years")) # 4.0
Actuarial age#
All age functions accept scalar dates or sequences. A scalar input returns a Python
float; a sequence returns a numpy.ndarray of float64.
age_exact(birth_date, valuation_date, *, day_count='act_act')#
Exact fractional age in years, with no rounding. Wraps
act_age(birth_date, valuation_date, m=365, method='exact', day_count=day_count).
Uses Actual/Actual ISDA by default; pass day_count='exact' for the faster
days / 365.25 approximation (maximum error ≈ 0.003 years).
from lactuca.dates import age_exact
# On exact birthday — integer result
print(age_exact("1990-01-01", "2024-01-01")) # 34.0
# Mid-year valuation — fractional result (Actual/Actual ISDA)
print(age_exact("1990-01-01", "2024-07-01")) # 34.49726775956284
# Fast approximation (days / 365.25)
print(age_exact("1990-01-01", "2024-07-01", day_count="exact")) # 34.496919917864474
Rounded age conventions#
The three convenience functions use calendar birthday boundaries (not
floor/ceil of the fractional Exact/Actual age). That avoids leap-year cases
where Actual/Actual is slightly under an integer on an exact birthday.
Function |
Notation |
Rule |
Description |
|---|---|---|---|
|
ALB, \([x]\) |
Completed calendar birthdays |
Integer age at last birthday |
|
ANB |
Nearest calendar birthday; midpoint → next |
Half-up at exact midpoint |
|
ANEXT |
Next calendar birthday; on birthday = ALB |
Integer age at next birthday |
age_exact / act_age(..., method='exact') remain fractional day-count ages and
are not the same as floor(age_exact).
years_between may be negative when date2 < date1. Actuarial age APIs require
valuation_date >= birth_date and raise ValueError otherwise.
All three share the same signature:
(birth_date, valuation_date, *, day_count='act_act').
Short-form aliases alb, anb, and anextb are exported at the top-level
lactuca namespace for interactive use and scripting. The name anextb
(not anext) is used to avoid shadowing builtins.anext (Python 3.10+).
import lactuca as lc
birth, val = "1990-06-15", "2024-03-01" # exact age ≈ 33.7
print(lc.alb(birth, val)) # 33.0 (Age Last Birthday)
print(lc.anb(birth, val)) # 34.0 (Age Nearest Birthday)
print(lc.anextb(birth, val)) # 34.0 (Age Next Birthday)
act_age(birth_date, valuation_date, *, m=365, method='exact', day_count='act_act')#
General actuarial age function with selectable rounding frequency m and method.
Calling act_age(birth, val) with the defaults is identical to age_exact(birth, val).
|
Description |
|---|---|
|
No rounding — exact fractional age (same as |
|
For \(m=1\): calendar ALB. For \(m>1\): floor of day-count age to \(1/m\) years. |
|
For \(m=1\): calendar ANB (midpoint → next). For \(m>1\): nearest \(1/m\) of day-count age. |
|
For \(m=1\): calendar ANEXT (on birthday = ALB). For \(m>1\): ceil of day-count age to \(1/m\). |
To compute age rounded down to the nearest completed month (\(m=12\), method='last'):
from datetime import date
from lactuca.dates import act_age
# Monthly ALB: age rounded down to the nearest 1/12 year
print(act_age("1989-07-01", "2024-01-01", m=12, method="last")) # 34.5
# Vectorised: one call for a portfolio of insured persons
births = [date(1960, 1, 1), date(1975, 6, 15), date(1990, 12, 1)]
print(act_age(births, date(2024, 1, 1), m=1, method="last"))
# array([64., 48., 33.])
Note
method='exact' enforces m=365. Passing any other m value raises ValueError.
Anniversary generation#
next_anniversary(birth_date, ref_date)#
Returns the next occurrence of the birth month/day strictly after ref_date.
If ref_date falls exactly on an anniversary, the following year’s anniversary
is returned.
from lactuca.dates import next_anniversary
result = next_anniversary("1990-06-15", "2024-03-01")
print(result) # FormatDates([datetime.date(2024, 6, 15)])
print(result.format()) # '2024-06-15'
anniversary_dates(start_date, end_date, *, m=1, selected_periods=None) → FormatDates#
Returns all \(m\)-thly anniversary dates from start_date (inclusive) to end_date
(exclusive). m must be one of {1, 2, 3, 4, 6, 12, 24, 26, 52, 365}.
m=24 uses a 15-day interval (360-day commercial convention).
m=26 uses a 14-day interval (biweekly, 364-day convention).
m=14 is not supported and raises ValueError; model “14 pagas” as m=12 plus
two extraordinary cash flows instead.
from datetime import date
from lactuca.dates import anniversary_dates
# Annual payment dates over two years
anns = anniversary_dates(date(2024, 1, 1), date(2026, 1, 1), m=1)
print(anns.format())
# ['2024-01-01', '2025-01-01']
# Quarterly payment grid for 2024
quarterly = anniversary_dates(date(2024, 1, 1), date(2025, 1, 1), m=4)
print(quarterly.format())
# ['2024-01-01', '2024-04-01', '2024-07-01', '2024-10-01']
Use selected_periods to restrict to a subset of period indices within the range.
Calendar utilities#
is_leap_year(year)#
Returns True if year is a Gregorian leap year.
from lactuca.dates import is_leap_year
print(is_leap_year(2024)) # True
print(is_leap_year(1900)) # False (divisible by 100 but not by 400)
days_in_year(year) and days_in_month(year, month)#
from lactuca.dates import days_in_year, days_in_month
print(days_in_year(2024)) # 366
print(days_in_year(2023)) # 365
print(days_in_month(2024, 2)) # 29 (leap year)
print(days_in_month(2023, 2)) # 28
Vectorized usage#
All date functions broadcast over Python lists, NumPy arrays, and other sequences:
from datetime import date
from lactuca.dates import age_last_birthday, years_between
# Array of births, single valuation date
births = [date(1960, 3, 15), date(1975, 11, 1), date(1990, 6, 30)]
ages = age_last_birthday(births, date(2024, 1, 1))
print(ages)
# array([63., 48., 33.]) (numpy.ndarray of float64)
# Array of start dates paired with a common end date
starts = ["2020-01-01", "2021-01-01"]
ends = ["2024-01-01", "2024-01-01"]
print(years_between(starts, ends))
# array([4., 3.])
Configuration#
Setting |
Default |
Allowed values |
Description |
|---|---|---|---|
|
|
|
Controls parsing of ambiguous date inputs and default date output format ( |
from lactuca import config
from lactuca.dates import format_date
config.date_format = "dmy"
print(format_date("2024-07-15")) # '15/07/2024'
config.reset_to_defaults() # restore defaults
See also#
Notation and Glossary — age notation: \(x\), \([x]\), \(x_{\text{ANB}}\)
Interest Rates — how
days_per_yearaffects interest factorsDecimal Precision and Rounding — global decimal precision settings