diff --git a/dcaf/finance/amortization.py b/dcaf/finance/amortization.py index 80a033f..3774d9e 100644 --- a/dcaf/finance/amortization.py +++ b/dcaf/finance/amortization.py @@ -36,10 +36,10 @@ from dcaf.shared.time import ( PeriodWindow, _calendar_period_windows, + add_periods, elapsed_hours, period_start, period_window_event_date, - time_delta_per_period, ) @@ -113,6 +113,8 @@ def builder( ProFormaCategory.FINANCING_PRINCIPAL ), principal_tax_treatment: TaxTreatment | str = TaxTreatment.NONE, + *, + end_of_month: bool = True, ) -> AmortizationBuilder: """Return an ``AmortizationBuilder`` for fluent schedule configuration. @@ -142,6 +144,9 @@ def builder( Pro-forma category applied to principal cashflows. principal_tax_treatment : TaxTreatment or str, optional Tax treatment applied to principal cashflows. + end_of_month : bool, optional + Whether a schedule whose first payment is on the last day of a month + keeps every payment on the last day of its month. Default is ``True``. Returns ------- @@ -161,6 +166,7 @@ def builder( interest_tax_treatment=interest_tax_treatment, principal_pro_forma_category=principal_pro_forma_category, principal_tax_treatment=principal_tax_treatment, + end_of_month=end_of_month, ) @classmethod @@ -182,6 +188,8 @@ def build( ProFormaCategory.FINANCING_PRINCIPAL ), principal_tax_treatment: TaxTreatment | str = TaxTreatment.NONE, + *, + end_of_month: bool = True, ) -> AmortizationSchedule: """Build and return an ``AmortizationSchedule`` directly (no rules). @@ -214,6 +222,9 @@ def build( Pro-forma category applied to principal cashflows. principal_tax_treatment : TaxTreatment or str, optional Tax treatment applied to principal cashflows. + end_of_month : bool, optional + Whether a schedule whose first payment is on the last day of a month + keeps every payment on the last day of its month. Default is ``True``. Returns ------- @@ -233,6 +244,7 @@ def build( interest_tax_treatment=interest_tax_treatment, principal_pro_forma_category=principal_pro_forma_category, principal_tax_treatment=principal_tax_treatment, + end_of_month=end_of_month, ).build() @@ -253,6 +265,8 @@ def amortize( ProFormaCategory.FINANCING_PRINCIPAL ), principal_tax_treatment: TaxTreatment | str = TaxTreatment.NONE, + *, + end_of_month: bool = True, ) -> AmortizationSchedule: """Build a standard fixed-payment amortization schedule. @@ -288,6 +302,9 @@ def amortize( Default is ``ProFormaCategory.FINANCING_PRINCIPAL``. principal_tax_treatment : TaxTreatment or str, optional Tax treatment applied to principal cashflows. Default is ``TaxTreatment.NONE``. + end_of_month : bool, optional + Whether a schedule whose first payment is on the last day of a month keeps every + payment on the last day of its month. Default is ``True``. Returns ------- @@ -318,6 +335,7 @@ def amortize( interest_tax_treatment=interest_tax_treatment, principal_pro_forma_category=principal_pro_forma_category, principal_tax_treatment=principal_tax_treatment, + end_of_month=end_of_month, ) @@ -327,19 +345,23 @@ def _calendarize_cashflow_stream( frequency: Period, timing: TimingConvention, day_count_convention: DayCountConvention, + end_of_month: bool = True, ) -> CashFlowStream: """Allocate nominal payment intervals into calendar booking periods. Each original flow represents the interval from its anchored date to the - next anchored date. Amounts are split by elapsed hours, with a final - residual preserving each flow exactly, then aggregated by calendar period. + next anchored date; the final flow runs to the next boundary of the schedule + anchored at the first payment date. Amounts are split by elapsed hours, with + a final residual preserving each flow exactly, then aggregated by calendar + period. """ if not stream.entries: return stream - delta = time_delta_per_period(frequency) grouped: dict[date, list[tuple[CashFlow, date, date, float]]] = {} - schedule_end = stream.entries[0].date + delta * len(stream.entries) + schedule_end = add_periods( + stream.entries[0].date, len(stream.entries), frequency, end_of_month=end_of_month + ) for flow_index, flow in enumerate(stream.entries): source_end = ( stream.entries[flow_index + 1].date @@ -385,6 +407,7 @@ def _calendarize_amortization_schedule( frequency: Period, timing: TimingConvention, day_count_convention: DayCountConvention, + end_of_month: bool = True, ) -> AmortizationSchedule: """Calendarize all schedule components without changing total principal or interest.""" return AmortizationSchedule( @@ -393,18 +416,21 @@ def _calendarize_amortization_schedule( frequency=frequency, timing=timing, day_count_convention=day_count_convention, + end_of_month=end_of_month, ), interest=_calendarize_cashflow_stream( schedule.interest, frequency=frequency, timing=timing, day_count_convention=day_count_convention, + end_of_month=end_of_month, ), principal=_calendarize_cashflow_stream( schedule.principal, frequency=frequency, timing=timing, day_count_convention=day_count_convention, + end_of_month=end_of_month, ), ) @@ -435,6 +461,8 @@ def __init__( ProFormaCategory.FINANCING_PRINCIPAL ), principal_tax_treatment: TaxTreatment | str = TaxTreatment.NONE, + *, + end_of_month: bool = True, ) -> None: if term <= 0: raise ValueError("term must be positive") @@ -443,6 +471,7 @@ def __init__( self._term = term self._start_date = start_date self._frequency: _PeriodEnum = parse_period(str(frequency)) + self._end_of_month = end_of_month self._label = label self._interest_label = interest_label self._principal_label = principal_label @@ -575,10 +604,9 @@ def _resolve_date_range(self, from_date: date | None, to_date: date | None) -> s Zero-based indices of periods whose payment dates fall within ``[from_date, to_date)``. """ - delta = time_delta_per_period(self._frequency.value) indices: list[int] = [] for i in range(self._term): - payment_date = self._start_date + delta * i + payment_date = self._payment_date(i) if from_date is not None and payment_date < from_date: continue if to_date is not None and payment_date >= to_date: @@ -586,6 +614,15 @@ def _resolve_date_range(self, from_date: date | None, to_date: date | None) -> s indices.append(i) return set(indices) + def _payment_date(self, period_index: int) -> date: + """Return the date of payment *period_index*, anchored at the first payment date.""" + return add_periods( + self._start_date, + period_index, + self._frequency.value, + end_of_month=self._end_of_month, + ) + def rate_change(self, from_period: int, annual_rate: float) -> Self: """Change the annual interest rate starting at a given period. @@ -762,7 +799,6 @@ def build(self) -> AmortizationSchedule: """ ppy = _payment_periods_per_year(self._frequency) default_config = _PeriodConfig(periodic_rate=self._annual_rate / ppy, pays_principal=True) - delta = time_delta_per_period(self._frequency.value) balance = self._principal total_flows: list[CashFlow] = [] @@ -773,9 +809,7 @@ def build(self) -> AmortizationSchedule: config = self._apply_rules(i, default_config) interest, principal, total = self._compute_amounts(balance, config, i, default_config) balance -= principal - t, intr, princ = self._make_cashflows( - self._start_date + delta * i, interest, principal, total - ) + t, intr, princ = self._make_cashflows(self._payment_date(i), interest, principal, total) total_flows.append(t) interest_flows.append(intr) principal_flows.append(princ) diff --git a/dcaf/finance/construction.py b/dcaf/finance/construction.py index def9b82..acd4349 100644 --- a/dcaf/finance/construction.py +++ b/dcaf/finance/construction.py @@ -34,8 +34,8 @@ from dcaf.shared.time import ( PeriodWindow, _calendar_period_windows, + add_periods, period_window_event_date, - time_delta_per_period, timedelta_fractional_years, ) @@ -351,6 +351,11 @@ class ConstructionSpendConfig: day_count_convention : {"actual/365-no-leap", "actual/365-fixed", "actual/actual"}, optional Day-count convention used for annual construction escalation and construction-period interest. + end_of_month : bool, optional + Whether a construction start on the last day of a month keeps every + debt-servicing boundary on the last day of its month. Also applies to + the implicit escalation. A user-supplied escalation policy keeps its own + setting. Default is ``True``. Notes ----- @@ -382,6 +387,7 @@ class ConstructionSpendConfig: amount_reference_date: date | None = None day_count_convention: DayCountConvention = "actual/actual" timing: TimingConvention = "end" + end_of_month: bool = True def __post_init__(self) -> None: if self.total_cost <= 0: @@ -421,6 +427,7 @@ def _construction_simple_escalation(config: ConstructionSpendConfig) -> Constant rate=config.escalation, period=cast(Period, parse_period(str(config.escalation_period)).value), day_count_convention=config.day_count_convention, + end_of_month=config.end_of_month, ) @@ -444,18 +451,20 @@ def _iter_period_boundaries( start_date: date, end_date: date, period: _PeriodEnum, + end_of_month: bool = True, ) -> list[tuple[date, date]]: - """Generate inclusive-exclusive period boundaries over a construction window.""" - delta = time_delta_per_period(period.value) + """Generate half-open periods anchored at *start_date*, the last cut at *end_date*.""" boundaries: list[tuple[date, date]] = [] period_index = 0 while True: - current = start_date + delta * period_index + current = add_periods(start_date, period_index, period.value, end_of_month=end_of_month) if current >= end_date: return boundaries - next_date = start_date + delta * (period_index + 1) + next_date = add_periods( + start_date, period_index + 1, period.value, end_of_month=end_of_month + ) period_end = next_date if next_date <= end_date else end_date boundaries.append((current, period_end)) period_index += 1 @@ -601,6 +610,7 @@ def _build_cashflows( config.start_date, config.end_date, _debt_servicing_period(config), + config.end_of_month, ): while draw_index < len(scheduled_draws) and scheduled_draws[draw_index][0] <= service_start: debt_balance += scheduled_draws[draw_index][1] @@ -669,6 +679,10 @@ class ConstructionSpendBuilder: amount_reference_date : date, optional Date at which ``total_cost`` is known. Escalation is evaluated from this date to each spend booking date. Defaults to ``start_date``. + end_of_month : bool, optional + Whether a construction start on the last day of a month keeps every + debt-servicing boundary, and the implicit escalation, on the last day of + its month. Default is ``True``. Notes ----- @@ -704,6 +718,7 @@ def __init__( escalation_period: Period = "year", amount_reference_date: date | None = None, day_count_convention: DayCountConvention = "actual/actual", + end_of_month: bool = True, ) -> None: self._config = ConstructionSpendConfig( total_cost=total_cost, @@ -717,6 +732,7 @@ def __init__( escalation_period=parse_period(str(escalation_period)), amount_reference_date=amount_reference_date, day_count_convention=day_count_convention, + end_of_month=end_of_month, ) self._escalation_policy: EscalationPolicy | None = None @@ -1029,6 +1045,7 @@ def construction_spend_schedule( amount_reference_date: date | None = None, day_count_convention: DayCountConvention = "actual/actual", escalation_policy: EscalationPolicy | None = None, + end_of_month: bool = True, ) -> CashFlowStream: """Build a construction spend schedule directly. @@ -1069,6 +1086,10 @@ def construction_spend_schedule( ``amount_reference_date``. day_count_convention : DayCountConvention, optional Day-count convention used for annual escalation and construction interest. + end_of_month : bool, optional + Whether a construction start on the last day of a month keeps every + debt-servicing boundary on the last day of its month. Also applies to the + implicit escalation, but not to ``escalation_policy``. Default is ``True``. Returns ------- @@ -1135,6 +1156,7 @@ def construction_spend_schedule( escalation_period=escalation_period, amount_reference_date=amount_reference_date, day_count_convention=day_count_convention, + end_of_month=end_of_month, ) if policy_override is not None: builder = builder.escalation_policy(policy_override) diff --git a/dcaf/tax/_macrs_tables.py b/dcaf/tax/_macrs_tables.py index 2fb5893..3f9b0fc 100644 --- a/dcaf/tax/_macrs_tables.py +++ b/dcaf/tax/_macrs_tables.py @@ -69,8 +69,8 @@ 0.0590, 0.0591, 0.0590, 0.0591, 0.0590, 0.0591, 0.0221), 3: (0.0375, 0.0963, 0.0866, 0.0780, 0.0702, 0.0631, 0.0590, 0.0590, 0.0591, 0.0590, 0.0591, 0.0590, 0.0591, 0.0590, 0.0591, 0.0369), - 4: (0.0125, 0.0988, 0.0889, 0.0800, 0.0720, 0.0648, 0.0590, 0.0590, 0.0591, - 0.0590, 0.0591, 0.0590, 0.0591, 0.0590, 0.0591, 0.0517), + 4: (0.0125, 0.0988, 0.0889, 0.0800, 0.0720, 0.0648, 0.0590, 0.0590, 0.0590, + 0.0591, 0.0590, 0.0591, 0.0590, 0.0591, 0.0590, 0.0517), }), 20: MappingProxyType({ 1: (0.06563, 0.07000, 0.06482, 0.05996, 0.05546, 0.05130, 0.04746, 0.04459, @@ -84,7 +84,7 @@ 0.04461, 0.04460, 0.04461, 0.04460, 0.02788), 4: (0.00938, 0.07430, 0.06872, 0.06357, 0.05880, 0.05439, 0.05031, 0.04654, 0.04458, 0.04458, 0.04458, 0.04458, 0.04458, 0.04458, 0.04458, 0.04458, - 0.04458, 0.04458, 0.04458, 0.04459, 0.03901), + 0.04458, 0.04459, 0.04458, 0.04459, 0.03901), }), }) # fmt: on diff --git a/dcaf/tax/depreciation.py b/dcaf/tax/depreciation.py index 4af201a..72a5df3 100644 --- a/dcaf/tax/depreciation.py +++ b/dcaf/tax/depreciation.py @@ -12,7 +12,7 @@ from datetime import date from typing import assert_never -from dcaf.shared.time import time_delta_per_period +from dcaf.shared.time import add_periods from dcaf.shared.types import ( MACRSConvention, MACRSPropertyClass, @@ -296,6 +296,8 @@ def macrs_schedule( label: str = "MACRS Depreciation", pro_forma_category: ProFormaCategory | str | None = ProFormaCategory.DEPRECIATION, tax_treatment: TaxTreatment | str = TaxTreatment.DEDUCTIBLE, + *, + end_of_month: bool = True, ) -> CashFlowStream: """ Generate a MACRS depreciation schedule. @@ -306,6 +308,9 @@ def macrs_schedule( Depreciable basis (positive number). Flows will be negative. placed_in_service : date Date the asset is placed in service; first depreciation is on this date. + Year *k* is dated at yearly boundary *k* anchored at this date (see + :func:`~dcaf.shared.time.add_periods`), so a Feb. 29 placement is dated + Feb. 28 in non-leap years and Feb. 29 again in leap years. property_class : MACRSPropertyClass IRS property class (3, 5, 7, 10, 15, or 20). convention : MACRSConvention, optional @@ -319,6 +324,10 @@ def macrs_schedule( Pro-forma category for each flow. Default is ``"depreciation"``. tax_treatment : TaxTreatment or str, optional Tax treatment for each flow. Default is ``"deductible"``. + end_of_month : bool, optional + Whether a placement on the last day of a month dates every year on the last + day of its month, so a Feb. 28 placement reaches Feb. 29 in leap years. + Default is ``True``. Returns ------- @@ -346,7 +355,7 @@ def macrs_schedule( ) entries: list[CashFlow] = [] for i, rate in enumerate(rates): - dep_date = date(placed_in_service.year + i, placed_in_service.month, placed_in_service.day) + dep_date = add_periods(placed_in_service, i, "year", end_of_month=end_of_month) entries.append( CashFlow( amount=-cost_basis * rate, @@ -376,6 +385,8 @@ def vdb_schedule( label: str = "VDB Depreciation", pro_forma_category: ProFormaCategory | str | None = ProFormaCategory.DEPRECIATION, tax_treatment: TaxTreatment | str = TaxTreatment.DEDUCTIBLE, + *, + end_of_month: bool = True, ) -> CashFlowStream: """ Generate a variable declining balance depreciation schedule. @@ -410,8 +421,9 @@ def vdb_schedule( values both candidates and returns the higher-NPV schedule. schedule_dates : Sequence[date] | None, optional Explicit dates for convention-aware schedule entries. When provided, - depreciation flows are placed on these dates instead of - ``placed_in_service + n * frequency``. Dates before + depreciation flows are placed on these dates instead of boundary ``n`` + of the schedule anchored at ``placed_in_service`` (see + :func:`~dcaf.shared.time.add_periods`). Dates before ``placed_in_service`` are ignored; a matching date is included. valuation_rate : float | None, optional Annual discount rate used when ``convention`` is @@ -429,6 +441,10 @@ def vdb_schedule( Pro-forma category for each flow. Default is ``"depreciation"``. tax_treatment : TaxTreatment or str, optional Tax treatment for each flow. Default is ``"deductible"``. + end_of_month : bool, optional + Whether a placement on the last day of a month dates every monthly, + quarterly, or yearly period on the last day of its month. Ignored when + ``schedule_dates`` is given. Default is ``True``. Returns ------- @@ -508,14 +524,11 @@ def vdb_schedule( if convention != "none": if normalized_schedule_dates is None: - delta = time_delta_per_period(frequency) period_count = life + (1 if terminal_catch_up else 0) - generated_dates: list[date] = [] - current_date = placed_in_service - for _ in range(period_count): - generated_dates.append(current_date) - current_date += delta - candidate_dates: Sequence[date] = tuple(generated_dates) + candidate_dates: Sequence[date] = tuple( + add_periods(placed_in_service, k, frequency, end_of_month=end_of_month) + for k in range(period_count) + ) else: candidate_dates = normalized_schedule_dates @@ -577,8 +590,6 @@ def vdb_schedule( tax_treatment=resolved_tax_treatment, ) - delta = time_delta_per_period(frequency) - current_date = placed_in_service resolved_category, resolved_tax_treatment = normalize_cashflow_classification( pro_forma_category, tax_treatment ) @@ -597,13 +608,14 @@ def vdb_schedule( entries.append( CashFlow( amount=-depreciation, - date=current_date, + date=add_periods( + placed_in_service, period_number - 1, frequency, end_of_month=end_of_month + ), label=label, is_cash=False, pro_forma_category=resolved_category, tax_treatment=resolved_tax_treatment, ) ) - current_date += delta return CashFlowStream(entries) diff --git a/dcaf/tax/incentives.py b/dcaf/tax/incentives.py index 06eabbd..3b8258a 100644 --- a/dcaf/tax/incentives.py +++ b/dcaf/tax/incentives.py @@ -13,9 +13,8 @@ from datetime import date -from dateutil.relativedelta import relativedelta - from dcaf.finance.escalation import EscalationPolicy +from dcaf.shared.time import add_periods from dcaf.shared.types import ( DayCountConvention, Period, @@ -48,6 +47,7 @@ def ptc( escalation_policy: EscalationPolicy | None = None, frequency: Period = "year", timing: TimingConvention = "end", + end_of_month: bool = True, ) -> CashFlowStream: """ Compute Production Tax Credit cashflows from a generation stream. @@ -77,7 +77,8 @@ def ptc( years : int Number of calendar years of PTC eligibility, measured from the earliest generation period start. The anniversary ending the eligibility interval - is exclusive. + is exclusive and is the yearly boundary ``years`` of the schedule anchored + at that start (see :func:`~dcaf.shared.time.add_periods`). escalation : float, optional Compound escalation rate for the PTC value, interpreted over ``escalation_period``. With the default ``escalation_period="year"``, @@ -108,6 +109,11 @@ def ptc( timing : {"begin", "middle", "end"}, optional Position of each credit date within its effective settlement overlap. Default is ``"end"``. + end_of_month : bool, optional + Whether eligibility starting on the last day of a month ends on the last + day of its month, so a start on Feb. 28 reaches Feb. 29 in a leap year. + It does not yet apply to the implicit PTC rate escalation. Default is + ``True``. Returns ------- @@ -165,7 +171,7 @@ def ptc( return CashFlowStream() eligibility_start = min(entry.period_start for entry in generation_stream.entries) - eligibility_end = eligibility_start + relativedelta(years=years) + eligibility_end = add_periods(eligibility_start, years, "year", end_of_month=end_of_month) settlements = _generation_settlements( generation_stream.entries, frequency=frequency, diff --git a/tests/unit/test_finance_tax_anchoring.py b/tests/unit/test_finance_tax_anchoring.py new file mode 100644 index 0000000..7654e13 --- /dev/null +++ b/tests/unit/test_finance_tax_anchoring.py @@ -0,0 +1,359 @@ +# © 2026 Battelle Energy Alliance, LLC +# ALL RIGHTS RESERVED +"""Tests for anchored schedule dates in debt, construction, depreciation, and tax credit schedules. + +Every finance and tax schedule places date *k* at ``start + k * period``, computed from the +schedule start rather than from the previous date. Each schedule takes ``end_of_month`` +(default ``True``): when the start is the last day of its month, every monthly, quarterly, or +yearly date is the last day of its month. +""" + +from datetime import date, timedelta +import inspect + +import pytest +from hypothesis import example, given +from hypothesis import strategies as st + +from dcaf.finance.amortization import ( + AmortizationSchedule, + _calendarize_amortization_schedule, + amortize, +) +from dcaf.finance.construction import ( + ConstructionFinancing, + ConstructionSpendBuilder, + construction_spend_schedule, +) +from dcaf.streams.generation import Generation, GenerationStream +from dcaf.tax.depreciation import macrs_schedule, vdb_schedule +from dcaf.tax.incentives import ptc +from strategies import ANCHOR_DATES, FREQUENCIES, anchored_boundary + +END_OF_MONTH = st.booleans() +NON_DAILY_FREQUENCIES = st.sampled_from(["month", "quarter", "year"]) + + +@pytest.mark.parametrize( + "function", + [ + AmortizationSchedule.builder, + AmortizationSchedule.build, + amortize, + construction_spend_schedule, + ConstructionSpendBuilder, + vdb_schedule, + macrs_schedule, + ptc, + ], +) +def test_end_of_month_defaults_to_true(function): + """Every finance and tax schedule follows month-ends unless told otherwise.""" + parameter = inspect.signature(function).parameters["end_of_month"] + assert parameter.kind is inspect.Parameter.KEYWORD_ONLY + assert parameter.default is True + + +# === Amortization === + + +@given(ANCHOR_DATES, st.integers(1, 60), FREQUENCIES, END_OF_MONTH) +@example(date(2030, 4, 30), 3, "month", True) # Apr. 30, May 31, Jun. 30 +@example(date(2030, 4, 30), 3, "month", False) # Apr. 30, May 30, Jun. 30 +@example(date(2030, 1, 31), 3, "month", True) # Jan. 31, Feb. 28, Mar. 31 +def test_amortization_payment_dates_are_anchored(start, term, frequency, end_of_month): + """Payment *k* falls on boundary *k* of the schedule anchored at the first payment date.""" + schedule = AmortizationSchedule.build( + 100_000.0, 0.05, term, start, frequency, end_of_month=end_of_month + ) + expected = [anchored_boundary(start, k, frequency, end_of_month) for k in range(term)] + for stream in (schedule.total, schedule.interest, schedule.principal): + assert [flow.date for flow in stream] == expected + + +@given( + ANCHOR_DATES, + st.integers(1, 36), + FREQUENCIES, + END_OF_MONTH, + st.integers(-40, 400), + st.integers(1, 400), +) +@example(date(2030, 4, 30), 6, "month", True, 31, 1) # selects only May 31 +@example(date(2030, 4, 30), 6, "month", False, 31, 1) # May 31 is not a payment date +def test_amortization_interest_free_dates_select_anchored_payments( + start, term, frequency, end_of_month, offset_days, span_days +): + """An interest-free date window ``[from_date, to_date)`` covers exactly the payments whose + anchored dates fall inside it.""" + from_date = start + timedelta(days=offset_days) + to_date = from_date + timedelta(days=span_days) + schedule = ( + AmortizationSchedule.builder( + 100_000.0, 0.05, term, start, frequency, end_of_month=end_of_month + ) + .interest_free(from_date=from_date, to_date=to_date) + .build() + ) + payment_dates = [anchored_boundary(start, k, frequency, end_of_month) for k in range(term)] + expected_free = [from_date <= payment_date < to_date for payment_date in payment_dates] + assert [flow.amount == 0.0 for flow in schedule.interest] == expected_free + + +@pytest.mark.parametrize( + ("end_of_month", "april_share"), + [ + # One payment covers [Apr. 30, May 31): 1 of its 31 days is in April. + (True, 1 / 31), + # One payment covers [Apr. 30, May 30): 1 of its 30 days is in April. + (False, 1 / 30), + ], +) +def test_calendarized_amortization_spreads_over_the_anchored_payment_period( + end_of_month, april_share +): + """Spreading payments over calendar months treats the final payment as covering the + interval up to the next anchored boundary.""" + schedule = AmortizationSchedule.build( + 100_000.0, 0.06, 1, date(2030, 4, 30), "month", end_of_month=end_of_month + ) + calendarized = _calendarize_amortization_schedule( + schedule, + frequency="month", + timing="end", + day_count_convention="actual/actual", + end_of_month=end_of_month, + ) + interest = schedule.interest.entries[0].amount + april, may = calendarized.interest.entries + assert april.date == date(2030, 4, 30) + assert april.amount == pytest.approx(interest * april_share) + assert april.amount + may.amount == pytest.approx(interest) + + +# === Construction === + + +@given(ANCHOR_DATES, st.integers(1, 1500), NON_DAILY_FREQUENCIES, END_OF_MONTH) +@example(date(2030, 4, 30), 92, "month", True) # Apr. 30 -> Jul. 31 on month-ends +@example(date(2030, 4, 30), 92, "month", False) # Apr. 30 -> Jul. 31 off month-ends +@example(date(2030, 6, 30), 200, "quarter", True) # Jun. 30, Sep. 30, Dec. 31 +def test_construction_interest_periods_are_anchored(start, days, servicing_period, end_of_month): + """Construction interest is booked at the end of each servicing period anchored at the + construction start, with the last period cut at the exclusive construction end.""" + end = start + timedelta(days=days) + stream = construction_spend_schedule( + 1_000_000.0, + start, + end, + # Spend booked at period begin draws debt on the start date, so every servicing period + # accrues interest. + timing="begin", + financing=ConstructionFinancing.debt( + 0.5, interest_rate=0.06, servicing_period=servicing_period + ), + end_of_month=end_of_month, + ) + + expected = [] + k = 1 + while anchored_boundary(start, k - 1, servicing_period, end_of_month) < end: + expected.append(min(anchored_boundary(start, k, servicing_period, end_of_month), end)) + k += 1 + interest_dates = [flow.date for flow in stream if flow.label == "Capitalized Interest"] + assert interest_dates == expected + + +@pytest.mark.parametrize( + ("end_of_month", "expected_dates"), + [ + (True, [date(2030, 5, 31), date(2030, 6, 30), date(2030, 7, 31)]), + ( + False, + [date(2030, 5, 30), date(2030, 6, 30), date(2030, 7, 30), date(2030, 7, 31)], + ), + ], +) +def test_construction_interest_from_a_30_day_month_end(end_of_month, expected_dates): + """Monthly construction interest from Apr. 30 is booked on month-ends under the month-end + rule, and on the 30th, plus a final one-day stub, without it.""" + stream = construction_spend_schedule( + 1_000_000.0, + date(2030, 4, 30), + date(2030, 7, 31), + timing="begin", + financing=ConstructionFinancing.debt(0.5, interest_rate=0.06), + end_of_month=end_of_month, + ) + interest_dates = [flow.date for flow in stream if flow.label == "Capitalized Interest"] + assert interest_dates == expected_dates + + +@pytest.mark.parametrize( + ("end_of_month", "periods"), + [ + (True, 1.0), + # Without the month-end rule, May 31 is one day into the 31-day month [May 30, Jun. 30). + (False, 1 + 1 / 31), + ], +) +def test_construction_escalation_follows_end_of_month(end_of_month, periods): + """The implicit construction escalation uses the same month-end rule as the schedule. One + flat month of spend booked May 31 escalates monthly from an Apr. 30 reference date.""" + stream = construction_spend_schedule( + 1_000_000.0, + date(2030, 5, 1), + date(2030, 6, 1), + escalation=0.01, + escalation_period="month", + amount_reference_date=date(2030, 4, 30), + end_of_month=end_of_month, + ) + (spend,) = stream.entries + assert spend.date == date(2030, 5, 31) + assert spend.amount == pytest.approx(-1_000_000.0 * 1.01**periods) + + +def test_construction_builder_passes_end_of_month_through(): + """The construction builder records the month-end rule and builds the same schedule as the + direct function.""" + kwargs = dict( + timing="begin", + financing=ConstructionFinancing.debt(0.5, interest_rate=0.06), + end_of_month=False, + ) + builder = ConstructionSpendBuilder(1_000_000.0, date(2030, 4, 30), date(2030, 7, 31), **kwargs) + assert builder.config.end_of_month is False + assert builder.build() == construction_spend_schedule( + 1_000_000.0, date(2030, 4, 30), date(2030, 7, 31), **kwargs + ) + + +# === Depreciation === + + +@given(ANCHOR_DATES, st.integers(1, 40), FREQUENCIES, END_OF_MONTH) +@example(date(2030, 1, 31), 3, "month", True) # Jan. 31, Feb. 28, Mar. 31 +@example(date(2030, 1, 31), 3, "month", False) # Jan. 31, Feb. 28, Mar. 31 +@example(date(2030, 4, 30), 3, "month", True) # Apr. 30, May 31, Jun. 30 +@example(date(2028, 2, 29), 5, "year", True) # returns to Feb. 29 in 2032 +def test_vdb_schedule_dates_are_anchored(placed_in_service, life, frequency, end_of_month): + """Depreciation period *k* is dated at boundary *k* of the schedule anchored at the + placed-in-service date, never drifting after a clamped short month.""" + stream = vdb_schedule( + 1000.0, 0.0, placed_in_service, life, frequency, end_of_month=end_of_month + ) + expected = [ + anchored_boundary(placed_in_service, k, frequency, end_of_month) for k in range(life) + ] + assert [flow.date for flow in stream] == expected + + +@given( + ANCHOR_DATES, + st.integers(1, 40), + FREQUENCIES, + END_OF_MONTH, + st.sampled_from(["half-year", "mid-quarter"]), + st.booleans(), +) +@example(date(2030, 1, 31), 3, "month", True, "half-year", True) # Jan. 31 -> Mar. 31 +@example(date(2028, 2, 29), 5, "year", False, "mid-quarter", True) # Feb. 29 -> 2033 +def test_vdb_schedule_convention_dates_are_anchored( + placed_in_service, life, frequency, end_of_month, convention, terminal_catch_up +): + """Convention-aware schedules without explicit ``schedule_dates`` place each flow on a + boundary of the schedule anchored at the placed-in-service date, starting there.""" + stream = vdb_schedule( + 1000.0, + 0.0, + placed_in_service, + life, + frequency, + convention=convention, + terminal_catch_up=terminal_catch_up, + end_of_month=end_of_month, + ) + boundaries = [ + anchored_boundary(placed_in_service, k, frequency, end_of_month) + for k in range(life + terminal_catch_up) + ] + dates = [flow.date for flow in stream] + assert dates[0] == placed_in_service + assert all(flow_date in boundaries for flow_date in dates) + assert dates == sorted(set(dates)) + + +@given( + ANCHOR_DATES, + st.sampled_from([3, 5, 7, 10, 15, 20]), + st.sampled_from(["half-year", "mid-quarter"]), + END_OF_MONTH, +) +@example(date(2028, 2, 29), 5, "half-year", True) # leap-day placement no longer raises +@example(date(2028, 2, 29), 5, "half-year", False) +@example(date(2029, 2, 28), 5, "half-year", True) # Feb. 29, 2032 under the month-end rule +def test_macrs_schedule_dates_are_anchored( + placed_in_service, property_class, convention, end_of_month +): + """MACRS year *k* is dated at yearly boundary *k* anchored at the placed-in-service date, + including a Feb. 29 placement, and the schedule still recovers the full basis.""" + stream = macrs_schedule( + 1000.0, placed_in_service, property_class, convention, end_of_month=end_of_month + ) + expected = [ + anchored_boundary(placed_in_service, k, "year", end_of_month) for k in range(stream.count()) + ] + assert [flow.date for flow in stream] == expected + assert stream.sum() == pytest.approx(-1000.0) + + +@pytest.mark.parametrize( + ("placed_in_service", "end_of_month", "expected"), + [ + ( + date(2028, 2, 29), + True, + [date(2028, 2, 29), date(2029, 2, 28), date(2030, 2, 28), date(2031, 2, 28)], + ), + ( + date(2029, 2, 28), + True, + [date(2029, 2, 28), date(2030, 2, 28), date(2031, 2, 28), date(2032, 2, 29)], + ), + ( + date(2029, 2, 28), + False, + [date(2029, 2, 28), date(2030, 2, 28), date(2031, 2, 28), date(2032, 2, 28)], + ), + ], +) +def test_macrs_schedule_february_placements(placed_in_service, end_of_month, expected): + """A 3-year MACRS schedule (four half-year-convention entries) placed on a February + month-end, worked out by hand.""" + stream = macrs_schedule(1000.0, placed_in_service, 3, end_of_month=end_of_month) + assert [flow.date for flow in stream] == expected + + +# === Production tax credit === + + +@given(ANCHOR_DATES, st.integers(1, 20), END_OF_MONTH) +@example(date(2029, 2, 28), 3, True) # eligibility ends Feb. 29, 2032 +@example(date(2029, 2, 28), 3, False) # eligibility ends Feb. 28, 2032 +@example(date(2028, 2, 29), 1, True) # eligibility ends Feb. 28, 2029 +def test_ptc_eligibility_ends_on_the_anchored_anniversary(start, years, end_of_month): + """PTC eligibility is ``[start, start + years)`` with the end on the anchored yearly + boundary: generation on the day before that boundary earns credit, and generation on the + boundary does not.""" + eligibility_end = anchored_boundary(start, years, "year", end_of_month) + generation = GenerationStream( + [ + Generation(1.0, start), + Generation(1.0, eligibility_end - timedelta(days=1)), + Generation(1.0, eligibility_end), + ] + ) + credits = ptc(generation, rate_per_mwh=1.0, years=years, end_of_month=end_of_month) + assert credits.sum() == pytest.approx(2.0) + assert max(flow.date for flow in credits) < eligibility_end