Skip to content

Anchored schedule boundaries with an end-of-month rule - #45

Merged
dylanjm merged 5 commits into
mainfrom
anchored-boundaries
Oct 15, 2026
Merged

dylanjm merged 5 commits into
mainfrom
anchored-boundaries

Conversation

@j-bryan

@j-bryan j-bryan commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #38 (does not close it). First PR in stack #51: #45 ← #46 ← #49 ← #52 ← #53.

Schedule boundaries are now counted from the schedule start instead of from the previous boundary, so a schedule starting late in a month no longer drifts to an earlier day.

  • add_periods(start, count, frequency, *, end_of_month=True) (new, exported from dcaf.shared): boundary k of a schedule anchored at start. Keeps the start's day of the month, clamped in shorter months. With end_of_month=True and a month-end start, every month, quarter, or year boundary is the last day of its month. Daily schedules ignore the flag. Negative counts step backward under the same rule.
  • elapsed_periods(..., *, end_of_month=True): months and quarters are measured against those boundaries, i.e. whole scheduled periods plus the elapsed share of the days in the period containing the end date, so every full scheduled period measures exactly 1.0. Years still use the day-count convention; days are unchanged. Replaces the private elapsed_months.
  • period_windows(..., *, end_of_month=True): windows run between anchored boundaries. A fractional final window is the requested share of the next scheduled period's days.
  • Tests: tests/unit/test_period_anchoring.py checks boundaries, measurement, and windows against hand-worked schedules and properties (the month a boundary lands in, the day it keeps, monotonicity, sign symmetry), not against a reimplementation. tests/unit/strategies.py gains month-end and late-month date strategies.
  • Stream tests: the fractional-tail tests for CashFlowStream.from_recurring and GenerationStream.from_capacity size the tail against the next anchored boundary. A Jan 30 monthly start with 1.5 periods now expects 15 tail days (Feb 28 to Mar 30), not 14. These tests and Debt, construction, depreciation, and PTC schedules use anchored dates #49's still use a temporary anchored_boundary helper in strategies.py; Property tests use independent references instead of reimplemented calendar oracles #52 replaces it with add_periods and removes it.

Behavior changes

With end_of_month=True as the default, these change for every existing caller:

  • Schedules starting on the 29th–31st no longer drift (Jan 31 monthly: Feb 28, Mar 31, Apr 30 instead of Feb 28, Mar 28, Apr 28). A yearly Feb 29 start returns to Feb 29 in leap years.
  • Schedules starting on the last day of a shorter month follow month-ends (Apr 30 monthly: May 31, Jun 30; Jun 30 quarterly: Dec 31, Mar 31).
  • A partial quarter is its share of the days in the quarter (Jan 1 to Feb 6 is 36/90 = 0.4). Before, it was elapsed months divided by 3 (0.393).
  • Monthly and quarterly escalation from a month-end reference date compounds one full step per scheduled month-end (Apr 30 to May 31 is exactly 1 month).

Affected callers: CashFlowStream.from_recurring, GenerationStream.from_capacity / with_capacity, EnergyProject sections given an explicit period count, and ConstantRateEscalation with month or quarter periods. These callers can't pass end_of_month yet.

Not in this PR (why #38 stays open)

  • Builder schedules that run to timeline.operations_end still step from the previous boundary.
  • vdb_schedule dates, AmortizationBuilder and construction interest periods, and the PTC eligibility end don't use add_periods yet.
  • macrs_schedule raises for a Feb 29 placed-in-service date.
  • Passing end_of_month through stream factories, escalation, finance/tax schedules, and the builder.
  • Removing dateutil.

Testing

uv run ruff check, uv run ruff format --check, uv run mypy dcaf/, and uv run pytest pass (925 tests), and the Hypothesis properties also pass at 3,000 examples each.

🤖 Generated with Claude Code

@j-bryan
j-bryan added this pull request to stack #47 October 8, 2026 18:54
@j-bryan
j-bryan removed this pull request from stack #47 October 12, 2026 16:02
@j-bryan
j-bryan changed the base branch from pbt-primitives to main October 12, 2026 16:02
@j-bryan
j-bryan added this pull request to stack #51 October 12, 2026 16:02
j-bryan and others added 4 commits October 12, 2026 11:01
The fractional-tail tests for CashFlowStream.from_recurring and
GenerationStream.from_capacity sized the tail with add_period, which steps
from the previous boundary and so reproduces the month-end drift that
anchored boundaries remove. A Jan 30 monthly start with 1.5 periods expected
14 tail days (Feb 28 to Mar 28) instead of 15 (Feb 28 to Mar 30).

tail_days now takes the next boundary explicitly, the tests compute it with
anchored_boundary, and add_period is removed. Both tests gain the Jan 30 case
as an explicit example.

Co-Authored-By: Claude <noreply@anthropic.com>

@dylanjm dylanjm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes are good, tests implemented recreating date logic directly.

@j-bryan
j-bryan force-pushed the anchored-boundaries branch from 947245b to 770843a Compare October 13, 2026 21:34
…an oracle

The anchoring tests no longer reimplement add_periods or elapsed_periods. Boundary dates are
checked by the month they land in and the day they keep, partial periods by their bounds and
monotonicity, and exact values are worked out by hand.

Co-Authored-By: Claude <noreply@anthropic.com>
@j-bryan
j-bryan force-pushed the anchored-boundaries branch from ea543c9 to 45de20c Compare October 13, 2026 22:04
@dylanjm
dylanjm merged commit 545006b into main Oct 15, 2026
1 check passed
@dylanjm
dylanjm deleted the anchored-boundaries branch October 15, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants