Schedules¶
The Time Axis Is Part of the Model¶
Every projection sits on a sequence of period boundaries — month-ends or year-ends from inception, anniversary dates that anchor commission and ratchet logic, calendar-aware adjustments for business-day conventions. Getting these wrong is how you accidentally pay a quarterly premium thirteen times in a 12-month projection or miss an anniversary that should have triggered a guaranteed step-up.
Gaspatchio attaches the time axis to the frame. You declare it once with af.projection.set(...), and every period-boundary, year-fraction, and anniversary derives consistently from the same source.
Setting the Projection: af.projection.set(...)¶
The primary entry point is af.projection.set(...). It takes a valuation date, an end condition, and a frequency, and returns a new frame with the projection metadata attached:
import datetime as dt
from gaspatchio_core import ActuarialFrame
af = ActuarialFrame({
"policy_id": ["P001", "P002", "P003"],
"issue_age": [30, 45, 60],
"policy_inception": [dt.date(2020, 6, 15), dt.date(2018, 3, 1), dt.date(2023, 1, 1)],
})
af = af.projection.set(
valuation_date=dt.date(2025, 1, 1),
until="maximum_age",
until_value=100,
frequency="monthly",
)
set(...) produces a new frame carrying the projection — assign it back to af so the projection comes with you. Three columns appear on the frame after this call: projection_start_date, projection_end_date, num_proj_months.
Other end conditions: until="term_months" for a fixed number of periods, until="next_anniversary" for products whose horizon is a contract anniversary rather than a calendar duration, until="end_date" for an explicit calendar date. The until_value accepts an int, a column name (string), or a pl.Expr for per-policy values.
Frequency strings accept either English ("monthly", "quarterly", "annual") or Schedule shorthand ("1M", "3M", "1Y").
Opt-in Methods on af.projection¶
Per-period quantities — year fractions, cumulative time, in-force flags, anniversary indicators — are available on af.projection when you ask for them. Add them to the frame only if you want them to appear in the final output:
af.year_fractions = af.projection.year_fractions()
af.t_years = af.projection.t_years()
af.in_force = af.projection.is_in_force()
| Method | Returns | Use For |
|---|---|---|
af.projection.year_fractions() |
per-period width using the bound day count (length n_periods) |
discount factor inputs, period-weighted aggregations |
af.projection.t_years() |
cumulative time-since-start at each boundary (length n_periods + 1) |
natural input to Curve.discount_factor(t) |
af.projection.is_in_force() |
True where the period is active (length n_periods) |
actuarial expressions reading "is this policy alive at t?" |
af.projection.contract_boundary() |
True where the period is terminated (length n_periods) |
rollforward boundary indicator only |
af.projection.period_dates() |
per-period boundary dates | calendar-aware reporting, anniversary alignment |
Most period-by-period roll-ups condition on whether the policy is in force at duration t — exposure aggregations, expected-lives counts, premium and claim runoffs. af.projection.is_in_force() produces that mask directly: True where the policy is in force, length n_periods. Reach for it whenever the calculation needs to ask "is this policy still here at t?".
af.projection.contract_boundary() returns the same information with the truth flipped — True where the policy has terminated. When you pass a boundary mask to af.projection.rollforward(contract_boundary=...), the projection treats the first True period as termination: values from that period onward zero out. That's the shape contract_boundary() gives you. Everywhere else, prefer is_in_force().
The Typed Path: Schedule¶
A Schedule is your projection grid — valuation date, frequency, term, calendar, day-count — as a named, reusable object. af.projection.set(valuation_date=..., until=..., frequency=...) builds one for you in the common case and you never see it. When you want the grid to be explicit — shared across the term and annuity books, pinned to a specific quarter-end so its identity doesn't drift between runs, recorded in the audit file — build it directly and pass it in.
from datetime import date
from gaspatchio_core import Schedule
sched = Schedule.from_calendar_grid(
start_date=date(2025, 1, 31),
n_periods=240,
frequency="1M",
)
af_term = af_term.projection.set(schedule=sched)
af_ann = af_ann.projection.set(schedule=sched) # share grid across books
When to reach for the typed path:
- Sharing the same grid across multiple frames (term + annuity book reconciliation, riders + base contract, sensitivity scenarios)
- Locking the grid down between valuations so you can prove next quarter's run used the same time axis as this quarter's — same period boundaries, same calendar conventions — and so any unintended drift shows up immediately rather than in your movement analysis
- Constructing per-policy schedules with
Schedule.from_inception(inception_column=..., n_periods=..., frequency=...)
Whichever way you built the grid, the audit record is the same. af.projection.canonical_form() and af.projection.source_sha() come out identically whether set(...) built the grid from kwargs or you passed in an explicit Schedule. So you can switch between the two without your quarter-over-quarter version stamps shifting.
af.projection.canonical_form()
# {'kind': 'from_calendar_grid', 'n_periods': 240, 'frequency': '1M',
# 'calendar': 'NullCalendar', 'convention': 'Unadjusted',
# 'day_count': 'OneTwelfth', 'anchor': 'month_end', 'start_date': '2025-01-31'}
af.projection.source_sha()
# 'sha256:7a12c74d650ea13f698fc6f7fe15a7da97eb52d7e268d8985d9c73b3ce981a52'
Record source_sha() alongside each quarterly run. If it changes between quarters without a deliberate release, the time axis has drifted — and your movement analysis has a contributor that nobody put there on purpose.
Calendars and Day Counts¶
The bound Schedule binds three calendar-discipline choices, each with a sensible default. Pass them as kwargs to Schedule.from_calendar_grid(...) if you need to override:
| Choice | Default | When to override |
|---|---|---|
calendar |
NullCalendar (every day is a business day) |
Cross-border products, bond-coupon-style settlement, any model whose anniversaries must skip holidays |
convention |
Unadjusted (no business-day adjustment) |
Insurance contracts that defer benefit payments to the next business day; reinsurance with currency-specific conventions |
day_count |
OneTwelfth (each month is exactly 1/12 of a year) |
Insurance products quoting under Actual/365 or Actual/Actual; reinsurance using Actual/360; bond-style models using 30/360 |
Available calendars: NullCalendar, TARGET (eurozone settlement), UnitedKingdom, UnitedStates, plus JointCalendar(c1, c2) for cross-border products and BespokeCalendar(holidays=...) for bespoke holiday lists.
Available day counts: OneTwelfth, Actual360, Actual365Fixed, ActualActualISDA, Thirty360. These match the conventions you would see in a product SOA filing or a reinsurance treaty.
from gaspatchio_core import Schedule
from gaspatchio_core.schedule import (
UnitedStates, BusinessDayConvention, ActualActualISDA,
)
sched = Schedule.from_calendar_grid(
start_date=date(2025, 1, 31),
n_periods=120,
frequency="1M",
calendar=UnitedStates(),
convention=BusinessDayConvention.MODIFIED_FOLLOWING,
day_count=ActualActualISDA(),
)
af = af.projection.set(schedule=sched)
For the typical life-insurance model — monthly grid, no calendar adjustment, OneTwelfth day count — the defaults are correct and you only need valuation_date, until, until_value, and frequency on af.projection.set(...).
Worked Example: Anniversary Recognition¶
A monthly model where commission is paid on every policy anniversary. The Schedule produces an anniversary indicator — True at each anniversary period, False elsewhere — and the chain pays a fixed commission whenever the indicator fires.
from datetime import date
from gaspatchio_core import Schedule
# Three-year monthly schedule anchored to month-end Jan 31.
sched = Schedule.from_calendar_grid(
start_date=date(2025, 1, 31),
n_periods=36,
frequency="1M",
)
# Period boundaries — start, then end of each period.
dates = sched.period_dates()
print("Inception: ", dates[0])
print("Final eop: ", dates[-1])
print("Total bounds: ", len(dates)) # n_periods + 1
# Anniversary recognition — True at the end of each policy year.
mask = sched.anniversary_mask()
anniv_indices = [i for i, m in enumerate(mask) if m]
print("Anniversaries:", [dates[i] for i in anniv_indices])
Inception: 2025-01-31
Final eop: 2028-01-31
Total bounds: 37
Anniversaries: [datetime.date(2025, 12, 31), datetime.date(2026, 12, 31), datetime.date(2027, 12, 31)]
The indicator is True at indices 11, 23, 35 — one anniversary per twelve-month period, on the contract anniversary in December. Feed it as a list column to a .ratchet(when=...) argument and the rollforward only ratchets on those periods. Or feed it through Polars conditionals to gate any per-period cash flow.
Per-Policy Grids: Schedule.from_inception¶
Where every policy has its own inception date and anniversary recognition needs to be per-row, build a Schedule that references an input column. When the rollforward is prepared, it produces a per-policy boundary grid from that column.
sched = Schedule.from_inception(
inception_column="policy_inception",
n_periods=240,
frequency="1M",
)
af = af.projection.set(schedule=sched)
The inception_column must be a Date column on the input frame. Anniversary indicators for this Schedule are intrinsic — the inception date itself is the anchor, so there is no anchor= parameter.
For per-policy projection horizons without the typed path, pass a column name to until_value:
af = af.projection.set(
valuation_date=dt.date(2025, 1, 1),
until="term_months",
until_value="remaining_term_months", # column name — per-policy
frequency="monthly",
)
Schedule Identity in the Audit File¶
The Schedule's identity rolls up into the rollforward's overall version stamp, so a change to the time axis shows up as a model-level change without you having to track it separately.
sched.canonical_form()
sched.source_sha()
# Same values as af.projection.canonical_form() / af.projection.source_sha()
# when af.projection.set(schedule=sched) was used.
Runnable Companions¶
The patterns above are exercised end-to-end in the typed mini-VA tutorial:
bindings/python/gaspatchio_core/tutorials/level-3-mini-va-typed/base/model.py—from_calendar_gridwithOneTwelfthday count, feedingCurve.discount_factorvia cumulative year fractionsbindings/python/gaspatchio_core/tutorials/level-3-mini-va-typed/steps/07-anniversary-aware/model.py—until="next_anniversary"for per-policy anniversary commissions
The tutorial's README captures rough edges discovered while building those steps and is worth reading alongside this page.
See Also¶
- Curves —
Curve.discount_factor(t)consumesaf.projection.t_years() - Multi-State Rollforwards — anniversary indicator drives ratchet timing
- Inspection — the Schedule's identity rolls up into the rollforward's overall version stamp