Skip to content

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.pyfrom_calendar_grid with OneTwelfth day count, feeding Curve.discount_factor via cumulative year fractions
  • bindings/python/gaspatchio_core/tutorials/level-3-mini-va-typed/steps/07-anniversary-aware/model.pyuntil="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

  • CurvesCurve.discount_factor(t) consumes af.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