studies/202602_AmazonConnect -> studies/202602_TEI_Amazon_Connect, rebuilt as pattern Variant 4 (TEI composite reproduction): - teicalc/ self-contained engine (stdlib-only): Forrester's tables as the never-edited verbatim anchor, NPV/ROI/payback + risk adjustment transplanted from core/calculations, ClientDrivers overlay (contacts/ agents/fixed driver map, growth re-base, identity at composite scale), scenario stress with core-identical semantics - one deliverable notebook (business_case.ipynb): widget-pair sidebar drivers, published-vs-overlay KPI columns, cash-flow/waterfall/scenario charts, verification gate, backstage JSON data appendix - gate + tests reproduce the published totals within PDF rounding: NPV $78.7M / ROI 342% / payback <6 months (engine $78,713,492 / 342.48% / 0.7 months); 27 study tests, headless nbconvert green, stage simulation leak-free, exports carry the appendix - old Athena workflow (00_provision..04_export, config.py, seed_data.py) deleted; git history preserves it; root test fixture repointed to teicalc.anchor - docs: study README rewritten; root README points new studies at template/MercuryNotebook; pattern doc stale ctm-token-calculator paths now cite studies/202607_CTM_GenesysCX; Variant 4 cites this study as its realized reference Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
268 lines
10 KiB
Python
268 lines
10 KiB
Python
"""
|
||
Finance engine — the single source of truth for every number in the notebook.
|
||
|
||
Transplanted from the retired shared ``core/calculations`` and
|
||
``core/export/report_data.py`` so the study is self-contained (Mercury
|
||
Notebook Pattern, Required §2/§7). Conventions match the Forrester TEI
|
||
methodology:
|
||
|
||
* The *Initial* investment 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``.
|
||
* Benefits are risk-adjusted *down* (``×(1−rf)``), costs *up* (``×(1+rf)``).
|
||
* Payback runs on risk-adjusted **undiscounted** flows (the PDF's
|
||
"<6 months" uses the Cash Flow Analysis table's nominal RA rows).
|
||
|
||
Everything this module returns for display is keyed by **calendar year**
|
||
(Forrester Year 1/2/3 → 2026/2027/2028); ``initial`` stays a Year-0 scalar
|
||
and never appears inside a ``*_by_year`` dict.
|
||
|
||
This module is stdlib-only on purpose — the repo-root test suite imports it
|
||
without the study's venv.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from collections.abc import Iterable, Sequence
|
||
from copy import deepcopy
|
||
|
||
# ── Timeline ─────────────────────────────────────────────────────────
|
||
|
||
YEARS: list[int] = [2026, 2027, 2028] # Forrester Year 1/2/3; window opens Jan 2026
|
||
YEAR_INDEX: dict[int, int] = {y: i for i, y in enumerate(YEARS, start=1)}
|
||
X_LABELS: list[str] = ["Initial"] + [str(y) for y in YEARS]
|
||
|
||
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||
|
||
|
||
def month_label(m: int) -> str:
|
||
"""Calendar label for a 1-indexed month from Jan of YEARS[0]."""
|
||
return f"{_MONTHS[(m - 1) % 12]} {YEARS[0] + (m - 1) // 12}"
|
||
|
||
|
||
def by_calendar(year_values: dict[str, float]) -> dict[int, float]:
|
||
"""Map Forrester's ``{"1": v, …}`` year-index keys to calendar years."""
|
||
return {YEARS[int(k) - 1]: float(v or 0) for k, v in year_values.items()}
|
||
|
||
|
||
# ── Discounting primitives ───────────────────────────────────────────
|
||
|
||
|
||
def discount_factor(year_index: int, discount_rate: float) -> float:
|
||
"""``1 / (1 + r) ** n``. Year 0 → 1.0 (no discount)."""
|
||
if year_index < 0:
|
||
raise ValueError("year_index must be >= 0")
|
||
return 1.0 / ((1.0 + discount_rate) ** year_index)
|
||
|
||
|
||
def present_value(amount: float, year_index: int, discount_rate: float) -> float:
|
||
"""Discount ``amount`` from end-of-year ``year_index`` to present."""
|
||
return amount * discount_factor(year_index, discount_rate)
|
||
|
||
|
||
def npv(cashflows: Iterable[float], discount_rate: float,
|
||
initial: float = 0.0) -> float:
|
||
"""``initial + Σ CF_n / (1 + r)^n`` — initial undiscounted (TEI)."""
|
||
return initial + sum(
|
||
present_value(float(cf), n, discount_rate)
|
||
for n, cf in enumerate(cashflows, start=1)
|
||
)
|
||
|
||
|
||
def roi_pct(benefits_pv: float, costs_pv: float) -> float:
|
||
"""``(Benefits − Costs) / Costs`` as a percentage; 0 when costs ≤ 0."""
|
||
if costs_pv <= 0:
|
||
return 0.0
|
||
return (benefits_pv - costs_pv) / costs_pv * 100.0
|
||
|
||
|
||
# ── Payback ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def payback_years(initial_cost: float,
|
||
yearly_net: Sequence[float]) -> float | None:
|
||
"""
|
||
Years until cumulative net benefits cover the initial cost, with linear
|
||
interpolation inside the crossing year. ``None`` if never reached.
|
||
"""
|
||
remaining = float(initial_cost)
|
||
if remaining <= 0:
|
||
return 0.0
|
||
for i, cf in enumerate(yearly_net):
|
||
cf = float(cf)
|
||
if cf <= 0:
|
||
remaining += -cf # a net-loss year widens the gap
|
||
continue
|
||
if cf >= remaining:
|
||
return i + remaining / cf
|
||
remaining -= cf
|
||
return None
|
||
|
||
|
||
def payback_months(initial_cost: float,
|
||
yearly_net: Sequence[float]) -> float | None:
|
||
"""Same as :func:`payback_years`, in months."""
|
||
yrs = payback_years(initial_cost, yearly_net)
|
||
return yrs * 12.0 if yrs is not None else None
|
||
|
||
|
||
def payback_label(months: float | None) -> str:
|
||
"""Human label: ``"0.7 months (~Jan 2026)"`` / ``"immediate"`` / ``"beyond 2028"``."""
|
||
if months is None:
|
||
return f"beyond {YEARS[-1]}"
|
||
if months <= 0:
|
||
return "immediate"
|
||
return f"{months:.1f} months (~{month_label(max(1, math.ceil(months)))})"
|
||
|
||
|
||
# ── Risk adjustment (TEI: benefits down, costs up) ───────────────────
|
||
|
||
|
||
def risk_adjust_benefit(amount: float, risk_factor: float) -> float:
|
||
"""``amount × (1 − rf)``, rf 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:
|
||
"""``amount × (1 + rf)``, rf clamped to [0, 1]."""
|
||
rf = max(0.0, min(1.0, float(risk_factor)))
|
||
return amount * (1.0 + rf)
|
||
|
||
|
||
def risk_adjusted_rows(rows: list[dict], table: str) -> list[dict]:
|
||
"""Deep-copied rows with the per-row risk factor applied to every value."""
|
||
adjust = risk_adjust_benefit if table == "benefits" else risk_adjust_cost
|
||
out: list[dict] = []
|
||
for raw in rows:
|
||
row = deepcopy(raw)
|
||
rf = float(row.get("risk_adjustment") or 0.0)
|
||
row["year_values"] = {
|
||
k: adjust(float(v or 0), rf) for k, v in row["year_values"].items()
|
||
}
|
||
if row.get("initial"):
|
||
# Only costs carry an initial; TEI adjusts it upward like the years.
|
||
row["initial"] = risk_adjust_cost(float(row["initial"]), rf) \
|
||
if table == "costs" else float(row["initial"])
|
||
out.append(row)
|
||
return out
|
||
|
||
|
||
# ── Aggregation (calendar-keyed) ─────────────────────────────────────
|
||
|
||
|
||
def _totals_by_year(ra_rows: list[dict]) -> dict[int, float]:
|
||
totals = {y: 0.0 for y in YEARS}
|
||
for row in ra_rows:
|
||
for y, v in by_calendar(row["year_values"]).items():
|
||
totals[y] += v
|
||
return totals
|
||
|
||
|
||
def benefits_by_year(rows: list[dict]) -> dict[int, float]:
|
||
"""Risk-adjusted benefit totals per calendar year."""
|
||
return _totals_by_year(risk_adjusted_rows(rows, "benefits"))
|
||
|
||
|
||
def costs_by_year(rows: list[dict]) -> dict[int, float]:
|
||
"""Risk-adjusted cost totals per calendar year (excludes ``initial``)."""
|
||
return _totals_by_year(risk_adjusted_rows(rows, "costs"))
|
||
|
||
|
||
def initial_costs(rows: list[dict]) -> float:
|
||
"""Risk-adjusted Year-0 outlay (undiscounted)."""
|
||
return sum(
|
||
float(row.get("initial") or 0)
|
||
for row in risk_adjusted_rows(rows, "costs")
|
||
)
|
||
|
||
|
||
# ── Composite summary ────────────────────────────────────────────────
|
||
|
||
|
||
def compute_summary(benefits: list[dict], costs: list[dict],
|
||
discount_rate: float = 0.10) -> dict:
|
||
"""
|
||
The full business-case readout for one set of value rows.
|
||
|
||
Returns KPIs (``benefits_pv``/``costs_pv``/``npv``/``roi_pct``/
|
||
``payback_months``/``payback_label``/``initial_costs``/nominal totals),
|
||
calendar-keyed schedules (``benefits_by_year``/``costs_by_year``/
|
||
``net_by_year``/``cumulative_net_by_year`` — cumulative subtracts the
|
||
initial outlay), and a per-row breakdown under ``rows``.
|
||
"""
|
||
ben_ra = risk_adjusted_rows(benefits, "benefits")
|
||
cost_ra = risk_adjusted_rows(costs, "costs")
|
||
|
||
ben_by = _totals_by_year(ben_ra)
|
||
cost_by = _totals_by_year(cost_ra)
|
||
initial = sum(float(r.get("initial") or 0) for r in cost_ra)
|
||
|
||
benefits_pv = npv([ben_by[y] for y in YEARS], discount_rate)
|
||
costs_pv = npv([cost_by[y] for y in YEARS], discount_rate, initial=initial)
|
||
|
||
net_by = {y: ben_by[y] - cost_by[y] for y in YEARS}
|
||
cum, cum_by = -initial, {}
|
||
for y in YEARS:
|
||
cum += net_by[y]
|
||
cum_by[y] = cum
|
||
|
||
pb_months = payback_months(initial, [net_by[y] for y in YEARS])
|
||
|
||
def _row_breakdown(ra_rows: list[dict], table: str) -> list[dict]:
|
||
out = []
|
||
for row in ra_rows:
|
||
ra_by = by_calendar(row["year_values"])
|
||
init_ra = float(row.get("initial") or 0)
|
||
entry = {
|
||
"field_key": row["field_key"],
|
||
"label": row["label"],
|
||
"category": row["category"],
|
||
"risk_adjustment": row["risk_adjustment"],
|
||
"ra_by_year": ra_by,
|
||
"three_yr_ra": sum(ra_by.values()),
|
||
"pv": npv([ra_by[y] for y in YEARS], discount_rate,
|
||
initial=init_ra if table == "costs" else 0.0),
|
||
}
|
||
if table == "costs":
|
||
entry["initial_ra"] = init_ra
|
||
out.append(entry)
|
||
return out
|
||
|
||
return {
|
||
"discount_rate": discount_rate,
|
||
"benefits_pv": benefits_pv,
|
||
"costs_pv": costs_pv,
|
||
"npv": benefits_pv - costs_pv,
|
||
"roi_pct": roi_pct(benefits_pv, costs_pv),
|
||
"payback_months": pb_months,
|
||
"payback_label": payback_label(pb_months),
|
||
"initial_costs": initial,
|
||
"benefits_nominal": sum(ben_by.values()),
|
||
"costs_nominal": sum(cost_by.values()) + initial,
|
||
"benefits_by_year": ben_by,
|
||
"costs_by_year": cost_by,
|
||
"net_by_year": net_by,
|
||
"cumulative_net_by_year": cum_by,
|
||
"rows": {
|
||
"benefits": _row_breakdown(ben_ra, "benefits"),
|
||
"costs": _row_breakdown(cost_ra, "costs"),
|
||
},
|
||
}
|
||
|
||
|
||
# ── Display helpers ──────────────────────────────────────────────────
|
||
|
||
|
||
def money(v: float) -> str:
|
||
sign, a = ("-" if v < 0 else ""), abs(v)
|
||
return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"
|
||
|
||
|
||
def html_money(v: float) -> str:
|
||
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
|
||
annotations holding several amounts must use the HTML entity instead."""
|
||
return money(v).replace("$", "$")
|