← Files Investment BankingARCHIVED FILE
skills/distressed-recovery-waterfall/scripts/waterfall_engine.py
19.3 KB · Oct 5, 2026 · 18:28 UTC
#!/usr/bin/env python3
"""
Mechanical distressed recovery waterfall helper.
This script performs a simple priority waterfall from distributable value to claims.
It is a calculation aid, not a legal, collateral, intercreditor, or plan analysis.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
PLUGIN_ROOT = Path(__file__).resolve().parents[3]
if str(PLUGIN_ROOT) not in sys.path:
sys.path.insert(0, str(PLUGIN_ROOT))
from shared.artifacts import ( # noqa: E402
artifact_item,
build_minimal_handoff_payload,
dict_rows_to_sheet,
handoff_artifact_item,
write_artifact_manifest,
write_cover_first_workbook,
write_handoff_payload,
)
from shared.model_citations import write_model_citations_from_sheets # noqa: E402
@dataclass
class ClaimClass:
name: str
claim: float
priority: int
notes: str = ""
equity: bool = False
def _number(value: Any, field: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field} must be numeric; got {value!r}") from exc
def load_input(path: Path) -> tuple[str, str, list[dict[str, Any]], list[ClaimClass]]:
data = json.loads(path.read_text())
company = str(data.get("company", "company")).strip() or "company"
currency = str(data.get("currency", "$mm")).strip() or "$mm"
scenarios = data.get("scenarios")
if not isinstance(scenarios, list) or not scenarios:
raise ValueError("input must include a non-empty scenarios list")
classes_raw = data.get("classes")
if not isinstance(classes_raw, list) or not classes_raw:
raise ValueError("input must include a non-empty classes list")
classes: list[ClaimClass] = []
for idx, item in enumerate(classes_raw, start=1):
if not isinstance(item, dict):
raise ValueError(f"class {idx} must be an object")
name = str(item.get("name", f"class {idx}")).strip()
claim = _number(item.get("claim", 0), f"classes[{idx}].claim")
priority_raw = item.get("priority")
if priority_raw is None:
raise ValueError(f"classes[{idx}].priority is required")
priority = int(_number(priority_raw, f"classes[{idx}].priority"))
notes = str(item.get("notes", "")).strip()
equity = bool(item.get("equity", False))
if claim < 0:
raise ValueError(f"classes[{idx}].claim cannot be negative")
classes.append(
ClaimClass(name=name, claim=claim, priority=priority, notes=notes, equity=equity)
)
for idx, scenario in enumerate(scenarios, start=1):
if not isinstance(scenario, dict):
raise ValueError(f"scenario {idx} must be an object")
if "distributable_value" not in scenario:
raise ValueError(f"scenario {idx} missing distributable_value")
_number(scenario["distributable_value"], f"scenarios[{idx}].distributable_value")
return company, currency, scenarios, classes
def allocate_scenario(
value: float, classes: list[ClaimClass]
) -> tuple[list[dict[str, Any]], str | None, float]:
remaining = max(value, 0.0)
rows: list[dict[str, Any]] = []
fulcrum: str | None = None
priorities = sorted({c.priority for c in classes})
for priority in priorities:
group = [c for c in classes if c.priority == priority]
debt_group = [c for c in group if not c.equity]
equity_group = [c for c in group if c.equity]
group_claim = sum(c.claim for c in debt_group)
if group_claim > 0:
available = min(remaining, group_claim)
recovery_ratio = available / group_claim if group_claim else 0.0
for c in group:
if c.equity:
allocation = 0.0
recovery_pct = None
else:
allocation = c.claim * recovery_ratio
recovery_pct = allocation / c.claim if c.claim else None
if fulcrum is None and allocation > 1e-9 and allocation < c.claim - 1e-9:
fulcrum = c.name
rows.append(
{
"priority": c.priority,
"name": c.name,
"claim": c.claim,
"allocation": allocation,
"recovery_pct": recovery_pct,
"notes": c.notes,
}
)
remaining -= available
else:
# Equity or zero-claim classes receive residual value pro rata by count.
count = len(equity_group) if equity_group else len(group)
for c in group:
allocation = remaining / count if count else 0.0
rows.append(
{
"priority": c.priority,
"name": c.name,
"claim": c.claim,
"allocation": allocation,
"recovery_pct": None,
"notes": c.notes or "residual class",
}
)
remaining = 0.0
return rows, fulcrum, remaining
def fmt(value: float, currency: str) -> str:
return f"{currency} {value:,.1f}"
def pct(value: float | None) -> str:
if value is None:
return "n/a"
return f"{value * 100:,.1f}%"
def render_markdown(
company: str, currency: str, scenarios: list[dict[str, Any]], classes: list[ClaimClass]
) -> str:
lines: list[str] = []
lines.append(f"# Mechanical Recovery Waterfall - {company}")
lines.append("")
lines.append(
"> This output is a simple priority waterfall. It does not resolve collateral, deficiency-claim, intercreditor, legal-entity, plan-settlement, voting, tax, or legal-advice issues."
)
lines.append("")
for scenario in scenarios:
name = str(scenario.get("name", "scenario")).strip() or "scenario"
value = _number(scenario.get("distributable_value"), f"scenario {name} distributable_value")
rows, fulcrum, residual = allocate_scenario(value, classes)
lines.append(f"## {name}")
lines.append("")
lines.append(f"Distributable value: **{fmt(value, currency)}**")
lines.append("")
lines.append("| Priority | Class | Claim | Allocation | Recovery | Notes |")
lines.append("|---:|---|---:|---:|---:|---|")
for row in rows:
lines.append(
f"| {row['priority']} | {row['name']} | {fmt(row['claim'], currency)} | "
f"{fmt(row['allocation'], currency)} | {pct(row['recovery_pct'])} | {row['notes']} |"
)
lines.append("")
if fulcrum:
lines.append(f"Mechanical fulcrum: **{fulcrum}**")
elif residual > 1e-9:
lines.append(
f"No impaired debt fulcrum; residual unallocated value: **{fmt(residual, currency)}**"
)
else:
lines.append("No mechanical fulcrum identified.")
lines.append("")
lines.append("## Required senior-banker overlay")
lines.append("")
lines.append("- Confirm issuer, guarantor, collateral, lien, and intercreditor terms.")
lines.append("- Separate collateral pools and deficiency claims if material.")
lines.append(
"- Add DIP, administrative, priority, professional-fee, lease, trade, litigation, pension, tax, and disputed claims."
)
lines.append("- Reconcile model recoveries to market trading prices.")
lines.append(
"- Sensitize valuation, claim size, make-whole, default interest, new money, MIP, and liquidation assumptions."
)
lines.append("- Flag legal conclusions for counsel review.")
lines.append("")
return "\n".join(lines)
def waterfall_rows(
scenarios: list[dict[str, Any]], classes: list[ClaimClass]
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for scenario in scenarios:
name = str(scenario.get("name", "scenario")).strip() or "scenario"
value = _number(scenario.get("distributable_value"), f"scenario {name} distributable_value")
allocations, fulcrum, residual = allocate_scenario(value, classes)
for row in allocations:
rows.append(
{
"scenario": name,
"distributable_value": value,
"priority": row["priority"],
"class": row["name"],
"claim": row["claim"],
"allocation": row["allocation"],
"recovery_pct": "" if row["recovery_pct"] is None else row["recovery_pct"],
"fulcrum_class": fulcrum or "",
"residual_value": residual,
"notes": row["notes"],
}
)
return rows
def main(argv: list[str]) -> int:
if len(argv) != 3:
print("usage: python scripts/waterfall_engine.py input.json output.md", file=sys.stderr)
return 2
input_path = Path(argv[1])
output_path = Path(argv[2])
try:
company, currency, scenarios, classes = load_input(input_path)
markdown = render_markdown(company, currency, scenarios, classes)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(markdown, encoding="utf-8")
rows = waterfall_rows(scenarios, classes)
workbook_path = output_path.parent / "recovery_waterfall.xlsx"
workbook_tables = {
"Capital_Structure": dict_rows_to_sheet(
[
{
"priority": c.priority,
"class": c.name,
"claim": c.claim,
"equity": c.equity,
"notes": c.notes,
}
for c in classes
]
),
"Claims": dict_rows_to_sheet(
[
{"class": c.name, "claim": c.claim, "priority": c.priority, "notes": c.notes}
for c in classes
]
),
"Valuation_Cases": dict_rows_to_sheet(scenarios),
"Waterfall": dict_rows_to_sheet(rows),
"Recoveries_By_Class": dict_rows_to_sheet(rows),
"Fulcrum_Sensitivity": dict_rows_to_sheet(rows),
"Stakeholder_Map": [
["stakeholder", "review_note"],
["Counsel", "Review legal conclusions and priority mechanics."],
["Creditors", "Validate claims and recoveries."],
["Sponsor/equity", "Review residual value only after debt claims."],
],
"Open_Items": [
["item", "owner"],
["Collateral and lien analysis", "Counsel / RX banker"],
["Intercreditor mechanics", "Counsel"],
["Market-implied recoveries", "Banker"],
],
"Sources": [
["source", "path"],
["input_json", str(input_path)],
["support_markdown", str(output_path)],
],
}
write_cover_first_workbook(
workbook_path,
[
["Distressed Recovery Waterfall"],
["Company", company],
["Currency", currency],
[
"First read",
"Use this workbook first. The Markdown file is retained as a support narrative only.",
],
[
"Counsel flag",
"Mechanical output only; legal, collateral, intercreditor, tax, and plan issues require specialist review.",
],
],
workbook_tables,
)
model_citations = write_model_citations_from_sheets(
output_path.parent / "model_citations.json", workbook_path, workbook_tables
)
handoff_results = [
write_handoff_payload(
output_path.parent,
"distressed_recovery_waterfall_to_memo_builder",
build_minimal_handoff_payload(
"distressed_recovery_waterfall_to_memo_builder",
{
"company": company,
"source_log": [
{
"source_id": "SRC-001",
"source_name": "Distressed recovery waterfall workbook",
"source_type": "generated_workbook",
"source_date": "not_provided",
"document_date": "not_provided",
"accessed_date": "not_provided",
"as_of_date": "not_provided",
"source_pointer": str(workbook_path),
"freshness_status": "current",
"conflict_status": "unresolved",
"confidence": "medium",
"native_evidence_label": "mechanical_waterfall_output",
"canonical_evidence_category": "estimate",
"treatment": "mechanical waterfall output; counsel and restructuring specialist review required",
"limitations": "excludes legal, collateral, intercreditor, tax, plan, and trading analysis",
}
],
},
),
"memo-builder",
),
write_handoff_payload(
output_path.parent,
"distressed_recovery_waterfall_to_pitch_deck_builder",
build_minimal_handoff_payload(
"distressed_recovery_waterfall_to_pitch_deck_builder",
{
"company": company,
"source_log": [
{
"source_id": "SRC-001",
"source_name": "Distressed recovery waterfall workbook",
"source_type": "generated_workbook",
"source_date": "not_provided",
"document_date": "not_provided",
"accessed_date": "not_provided",
"as_of_date": "not_provided",
"source_pointer": str(workbook_path),
"freshness_status": "current",
"conflict_status": "unresolved",
"confidence": "medium",
"native_evidence_label": "mechanical_waterfall_output",
"canonical_evidence_category": "estimate",
"treatment": "convert only after specialist review of legal and collateral mechanics",
"limitations": "excludes legal, collateral, intercreditor, tax, plan, and trading analysis",
}
],
},
),
"pitch-deck-builder",
),
write_handoff_payload(
output_path.parent,
"distressed_recovery_waterfall_to_ib_deck_qc",
build_minimal_handoff_payload(
"distressed_recovery_waterfall_to_ib_deck_qc",
{
"company": company,
"source_log": [
{
"source_id": "SRC-001",
"source_name": "Distressed recovery waterfall workbook",
"source_type": "generated_workbook",
"source_date": "not_provided",
"document_date": "not_provided",
"accessed_date": "not_provided",
"as_of_date": "not_provided",
"source_pointer": str(workbook_path),
"freshness_status": "current",
"conflict_status": "unresolved",
"confidence": "medium",
"native_evidence_label": "mechanical_waterfall_output",
"canonical_evidence_category": "estimate",
"treatment": "QC recovery, fulcrum, and specialist-review claims before circulation",
"limitations": "excludes legal, collateral, intercreditor, tax, plan, and trading analysis",
}
],
},
),
"ib-deck-qc",
),
]
write_artifact_manifest(
output_path.parent,
"distressed-recovery-waterfall",
"workbook",
workbook_path,
companion_deliverables=[],
support_artifacts=[
artifact_item(
output_path,
"support_artifact",
"markdown",
"Markdown waterfall support narrative.",
False,
True,
"Markdown is retained as support data; the workbook is the human deliverable.",
),
artifact_item(
output_path.parent / "model_citations.json",
"support_artifact",
"json",
"Workbook cell/range citation ledger for recovery waterfall outputs.",
False,
True,
"Model citations support source-gate traceability and any later standalone memo.",
),
*[handoff_artifact_item(result) for result in handoff_results],
],
blocked_or_partial_status={
"status": "partial",
"reason": "Mechanical waterfall excludes legal, collateral, intercreditor, tax, plan, and market-trading analysis.",
"missing_inputs": [
"Counsel review",
"Collateral and lien analysis",
"Market trading prices",
"Plan and intercreditor mechanics",
],
},
extra={
"model_citations_path": str(output_path.parent / "model_citations.json"),
"model_citation_count": len(model_citations),
"handoffs": [
{
"handoff_contract_name": result["handoff_contract_name"],
"path": result["path"],
"schema_path": result["schema_path"],
"validator_status": result["validator_status"],
"validated_at": result["validated_at"],
"consumer_skill": result["consumer_skill"],
}
for result in handoff_results
],
},
)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"wrote primary workbook to {workbook_path}")
print(f"wrote support markdown to {output_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
SHA-256: 1257bb7fe79247a73218aea8dfedf6f55d216332f089a438404d944101aaae03