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.
This commit is contained in:
31
core/calculations/__init__.py
Normal file
31
core/calculations/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Pure-python TEI financial math."""
|
||||
|
||||
from core.calculations.npv import (
|
||||
discount_factor,
|
||||
npv,
|
||||
present_value,
|
||||
present_value_series,
|
||||
)
|
||||
from core.calculations.payback import payback_months, payback_years
|
||||
from core.calculations.roi import roi, roi_percentage
|
||||
from core.calculations.scenarios import (
|
||||
SCENARIOS,
|
||||
apply_scenario,
|
||||
risk_adjust_benefit,
|
||||
risk_adjust_cost,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SCENARIOS",
|
||||
"apply_scenario",
|
||||
"discount_factor",
|
||||
"npv",
|
||||
"payback_months",
|
||||
"payback_years",
|
||||
"present_value",
|
||||
"present_value_series",
|
||||
"risk_adjust_benefit",
|
||||
"risk_adjust_cost",
|
||||
"roi",
|
||||
"roi_percentage",
|
||||
]
|
||||
78
core/calculations/npv.py
Normal file
78
core/calculations/npv.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Net Present Value and discounting.
|
||||
|
||||
Convention (matches the Forrester TEI methodology used in the Amazon Connect
|
||||
study):
|
||||
|
||||
* The *Initial* investment (column "Initial" in TEI tables) is **not**
|
||||
discounted — it occurs at time zero.
|
||||
* Year-N cash flows are discounted at the end of the year:
|
||||
``PV = CF_n / (1 + r) ** n`` for ``n = 1, 2, …``.
|
||||
|
||||
That matches the PDF note: *"The initial investment column contains costs
|
||||
incurred at 'time 0' or at the beginning of Year 1 that are not discounted.
|
||||
All other cash flows are discounted using the discount rate at the end of
|
||||
the year."*
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def discount_factor(year: int, discount_rate: float) -> float:
|
||||
"""Return ``1 / (1 + r) ** year``. Year 0 → 1.0 (no discount)."""
|
||||
if year < 0:
|
||||
raise ValueError("year must be >= 0")
|
||||
return 1.0 / ((1.0 + discount_rate) ** year)
|
||||
|
||||
|
||||
def present_value(amount: float, year: int, discount_rate: float) -> float:
|
||||
"""Discount ``amount`` from end-of-year ``year`` to present."""
|
||||
return amount * discount_factor(year, discount_rate)
|
||||
|
||||
|
||||
def present_value_series(
|
||||
cashflows: Iterable[float],
|
||||
discount_rate: float,
|
||||
start_year: int = 1,
|
||||
) -> float:
|
||||
"""
|
||||
Sum the present value of a stream of year-end cashflows.
|
||||
|
||||
Args:
|
||||
cashflows: iterable of year-N amounts (Y1, Y2, …).
|
||||
discount_rate: e.g. 0.10 for 10%.
|
||||
start_year: year of the first element. Default 1 (skip year-0).
|
||||
"""
|
||||
total = 0.0
|
||||
for offset, cf in enumerate(cashflows):
|
||||
total += present_value(cf, start_year + offset, discount_rate)
|
||||
return total
|
||||
|
||||
|
||||
def npv(
|
||||
cashflows: Iterable[float],
|
||||
discount_rate: float,
|
||||
initial: float = 0.0,
|
||||
) -> float:
|
||||
"""
|
||||
Net Present Value.
|
||||
|
||||
Args:
|
||||
cashflows: year-end cashflows for Year 1, Year 2, …
|
||||
discount_rate: e.g. 0.10
|
||||
initial: undiscounted year-0 cashflow (negative for a cost,
|
||||
positive for a one-off benefit).
|
||||
|
||||
Returns:
|
||||
``initial + Σ CF_n / (1 + r)^n`` for ``n = 1..len(cashflows)``.
|
||||
|
||||
Example::
|
||||
|
||||
>>> # Amazon Connect total benefits PV ≈ $101.7M
|
||||
>>> benefits = [27_279_019, 40_333_658, 57_983_794]
|
||||
>>> round(npv(benefits, 0.10) / 1_000_000, 1)
|
||||
101.7
|
||||
"""
|
||||
return initial + present_value_series(cashflows, discount_rate, start_year=1)
|
||||
63
core/calculations/payback.py
Normal file
63
core/calculations/payback.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
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
|
||||
27
core/calculations/roi.py
Normal file
27
core/calculations/roi.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Return on Investment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def roi(benefits_pv: float, costs_pv: float) -> float:
|
||||
"""
|
||||
Return on Investment as a fraction.
|
||||
|
||||
``ROI = (Benefits − Costs) / Costs``
|
||||
|
||||
Costs here are expressed as a positive present-value amount (the absolute
|
||||
cost). Returns ``0.0`` when costs are zero (rather than dividing by zero).
|
||||
|
||||
Example::
|
||||
|
||||
>>> round(roi(101_696_791, 22_983_076), 2) # Amazon Connect: 342%
|
||||
3.42
|
||||
"""
|
||||
if costs_pv <= 0:
|
||||
return 0.0
|
||||
return (benefits_pv - costs_pv) / costs_pv
|
||||
|
||||
|
||||
def roi_percentage(benefits_pv: float, costs_pv: float) -> float:
|
||||
"""ROI as a percentage (e.g. 342.0 for 342%)."""
|
||||
return roi(benefits_pv, costs_pv) * 100.0
|
||||
128
core/calculations/scenarios.py
Normal file
128
core/calculations/scenarios.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Scenario modelling and risk adjustment.
|
||||
|
||||
Forrester TEI applies a *downward* risk adjustment to benefits (subtract
|
||||
``risk_factor × benefit``) and an *upward* adjustment to costs (add
|
||||
``risk_factor × cost``). Scenarios scale both adoption (cashflow magnitude)
|
||||
and uncertainty (risk factor).
|
||||
|
||||
Default scenario multipliers are sensible starting points. Override per
|
||||
study by editing the study's ``config.py`` and passing a custom dict to
|
||||
:func:`apply_scenario`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
#: Default scenario multipliers used by Palladium notebooks.
|
||||
#:
|
||||
#: * ``adoption`` scales nominal annual values up or down.
|
||||
#: * ``risk_delta`` is *added* to the benefit's risk_adjustment factor and
|
||||
#: *subtracted* from the cost's risk_adjustment factor (conservative
|
||||
#: = more uncertainty on benefits, less padding on costs).
|
||||
SCENARIOS: dict[str, dict[str, float]] = {
|
||||
"conservative": {"adoption": 0.80, "risk_delta": 0.10},
|
||||
"moderate": {"adoption": 1.00, "risk_delta": 0.00},
|
||||
"aggressive": {"adoption": 1.15, "risk_delta": -0.05},
|
||||
}
|
||||
|
||||
|
||||
def risk_adjust_benefit(amount: float, risk_factor: float) -> float:
|
||||
"""
|
||||
Apply a downward risk adjustment to a benefit.
|
||||
|
||||
``adjusted = amount × (1 − risk_factor)``.
|
||||
|
||||
``risk_factor`` is clamped to ``[0, 1]``.
|
||||
"""
|
||||
rf = max(0.0, min(1.0, float(risk_factor)))
|
||||
return amount * (1.0 - rf)
|
||||
|
||||
|
||||
def risk_adjust_cost(amount: float, risk_factor: float) -> float:
|
||||
"""
|
||||
Apply an upward risk adjustment to a cost.
|
||||
|
||||
``adjusted = amount × (1 + risk_factor)``.
|
||||
|
||||
``risk_factor`` is clamped to ``[0, 1]``.
|
||||
"""
|
||||
rf = max(0.0, min(1.0, float(risk_factor)))
|
||||
return amount * (1.0 + rf)
|
||||
|
||||
|
||||
def _scale_yearly(values: Iterable[float], factor: float) -> list[float]:
|
||||
return [float(v) * factor for v in values]
|
||||
|
||||
|
||||
def apply_scenario(
|
||||
items: list[dict],
|
||||
scenario: str = "moderate",
|
||||
*,
|
||||
multipliers: dict[str, dict[str, float]] | None = None,
|
||||
table: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Return a deep-copied list of value-rows with scenario adjustments applied.
|
||||
|
||||
Each item is expected to have:
|
||||
- ``table`` (``'benefits'`` or ``'costs'``) — required for sign of
|
||||
risk_delta. If absent, defaults to ``benefits`` unless ``table=``
|
||||
is passed explicitly.
|
||||
- ``year_values`` (dict of year-string → float) **or** a scalar
|
||||
``value``.
|
||||
- ``risk_adjustment`` (optional float).
|
||||
- ``initial`` (optional, costs only) — scaled by adoption.
|
||||
|
||||
Args:
|
||||
items: rows shaped like the ``_normalize_value`` output of
|
||||
:class:`core.tei_client.TEIClient`.
|
||||
scenario: key into ``multipliers`` (default ``SCENARIOS``).
|
||||
multipliers: override map. Same shape as ``SCENARIOS``.
|
||||
table: force a table when items lack one.
|
||||
|
||||
Returns:
|
||||
A new list — original items are not mutated.
|
||||
"""
|
||||
cfg = (multipliers or SCENARIOS).get(scenario)
|
||||
if cfg is None:
|
||||
raise KeyError(f"Unknown scenario: {scenario!r}")
|
||||
adoption = float(cfg.get("adoption", 1.0))
|
||||
risk_delta = float(cfg.get("risk_delta", 0.0))
|
||||
|
||||
out: list[dict] = []
|
||||
for raw in items:
|
||||
item: dict[str, Any] = deepcopy(raw)
|
||||
item_table = item.get("table") or table or "benefits"
|
||||
item["table"] = item_table
|
||||
|
||||
# Adoption scaling
|
||||
if "year_values" in item and isinstance(item["year_values"], dict):
|
||||
item["year_values"] = {
|
||||
k: float(v) * adoption for k, v in item["year_values"].items()
|
||||
}
|
||||
if "value" in item and item["value"] is not None:
|
||||
try:
|
||||
item["value"] = float(item["value"]) * adoption
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "initial" in item and item["initial"] is not None:
|
||||
try:
|
||||
item["initial"] = float(item["initial"]) * adoption
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Risk adjustment delta
|
||||
ra = item.get("risk_adjustment")
|
||||
if ra is None:
|
||||
ra = 0.0
|
||||
if item_table == "benefits":
|
||||
new_ra = float(ra) + risk_delta
|
||||
else: # costs: adverse scenario should *raise* costs less, so subtract
|
||||
new_ra = float(ra) - risk_delta
|
||||
item["risk_adjustment"] = max(0.0, min(1.0, new_ra))
|
||||
out.append(item)
|
||||
return out
|
||||
Reference in New Issue
Block a user