Files
palladium/core/calculations/payback.py
Robert Helewka a2420ed692 refactor: restructure repo into core/app modules with per-study folders
Reorganize Palladium codebase into a modular architecture with `core/`
shared logic and `app/` Streamlit UI, separating per-study assets into
`studies/YYYYMM_<Vendor>/` folders containing notebooks, seed data, and
configuration. Update README to reflect new structure, add `.gitignore`
entries for `.env` and study exports, and refresh component documentation.
2026-05-20 22:28:12 -04:00

64 lines
1.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Payback period.
Linear interpolation within the crossing year: returns the moment when the
running net cashflow first turns non-negative.
Inputs are *risk-adjusted, undiscounted* cashflows by convention (TEI shows
"<6 months" payback for the Amazon Connect composite using the risk-adjusted
nominal cashflows from the Cash Flow Analysis table).
"""
from __future__ import annotations
from collections.abc import Iterable
def payback_years(
initial_cost: float,
yearly_net_benefits: Iterable[float],
) -> float | None:
"""
Years until cumulative net benefits cover the initial cost.
Args:
initial_cost: positive number — undiscounted year-0 outlay.
yearly_net_benefits: sequence of (benefits costs) per year, Y1+.
Returns:
Float number of years, or ``None`` if payback is never reached
within the supplied horizon.
Example::
>>> # Amazon Connect: initial cost $1.196M, Y1 net $19.998M
>>> # → ~0.06 years ≈ 0.7 months — well under 6 months.
>>> round(payback_years(1_196_250, [19_997_953, 31_562_489, 47_443_905]), 3)
0.06
"""
remaining = float(initial_cost)
if remaining <= 0:
return 0.0
cumulative_year = 0
for cf in yearly_net_benefits:
cumulative_year += 1
cf = float(cf)
if cf <= 0:
remaining += -cf # net loss this year increases the gap
continue
if cf >= remaining:
# Crossing happens partway through this year.
fraction = remaining / cf
return (cumulative_year - 1) + fraction
remaining -= cf
return None
def payback_months(
initial_cost: float,
yearly_net_benefits: Iterable[float],
) -> float | None:
"""Same as :func:`payback_years`, expressed in months."""
yrs = payback_years(initial_cost, yearly_net_benefits)
return yrs * 12.0 if yrs is not None else None