Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions dcaf/finance/amortization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
-------
Expand All @@ -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
Expand All @@ -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).

Expand Down Expand Up @@ -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
-------
Expand All @@ -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()


Expand All @@ -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.

Expand Down Expand Up @@ -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
-------
Expand Down Expand Up @@ -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,
)


Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
),
)

Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -575,17 +604,25 @@ 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:
continue
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.

Expand Down Expand Up @@ -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] = []
Expand All @@ -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)
Expand Down
32 changes: 27 additions & 5 deletions dcaf/finance/construction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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
-----
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)


Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
-----
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
-------
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions dcaf/tax/_macrs_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading