Files
breakpilot-core/pitch-deck/scripts/add-tantieme-and-explanations.py
Benjamin Admin 01e2e0fc4b feat(pitch-deck): Finanzplan-Tooling + formel-getriebene Versionen Base/Bull/Bear
8 neue Skripte erweitern die Excel-Finanzpläne deutlich:
- add-kunden-formulas: Neukunden-Lookup + kumulativer Churn (SUMPRODUCT-basiert)
- add-price-formulas: jährliche Preiserhöhung Jan via Treiber
- add-inflation-formulas: Inflation auf Betriebskosten + Büromiete-Logik
- add-tantieme-and-explanations: Gründer-Tantieme 2028-2030 + Erläuterungen
  in Cohort-Analyse + Sensitivity-Sheets
- apply-bueromiete: 1000€/Monat ab Sep 2026 mit Inflation
- apply-number-formatting: Euro / Count / Percent per Label-Klassifikation
- cleanup-finanzplan-labels: 'kategorie — '-Präfix entfernt
- copy-extra-sheets: Charts/Cohort/Sensitivity/Hiring-Plan von Series-A
  auf 400k Base/Bull/Bear übertragen (inkl. 12 Chart-Objekten)

Neue Excel-Dateien (für L-Bank Wandeldarlehen 400k Pitch):
- Finanzplan-Wandeldarlehen-400k.xlsx (Base)
- Finanzplan-Wandeldarlehen-400k-Bull.xlsx
- Finanzplan-Wandeldarlehen-400k-Bear.xlsx
- Finanzplan-Series-A-Ambitioniert.xlsx (Series-A Variante)

Inhaltliche Anpassungen (400k Base/Bull/Bear):
- Channel-Provision Bechtle/Cancom → Channel-Partner Provision, Format Euro
- GuV: 'Steuerbares Einkommen' → 'Zu versteuerndes Einkommen (nach Verlustvortrag)',
  Formel um Zinserträge/-aufwand erweitert
- IT-Recht/Datenschutzjurist auf 100% (6666 € statt 3333 €)
- Series-A-Investor in WD-Sheet auf 0 € (nicht eingeplant in 400k Variante)
- Mitarbeiter +1 Monat verschoben (außer Gründer = Okt 2026)
- 3 Enterprise-Neukunden zusätzlich (Apr 2027, Jun 2027, Okt 2029)
- Marketing-Agentur Cut ~33% pro Szenario (Base 4%, Bull 5%, Bear 2%)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:23:12 +02:00

280 lines
10 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.
#!/usr/bin/env python3
"""Add Tantieme driver + founder bonus logic + explanation text to Sensitivity/Cohort.
Changes per file (Wandeldarlehen-400k Base/Bull/Bear + Series-A):
1. Treiber: append 'Tantieme Gründer' block (2028, 2029, 2030, default 0%).
2. Personalkosten: modify rows 27 (Benjamin) and 28 (Sharang) for columns from
Jan 2028 onwards — wrap base brutto in (1 + tantieme_for_year). Cols B-R
(Aug 2026 to Dec 2027) stay untouched.
3. Cohort-Analyse: add 'Erläuterung' block at the bottom explaining what the
sheet shows and how to read it.
4. Sensitivity: add 'Erläuterung' block explaining baseline column + reading.
Usage:
python3 pitch-deck/scripts/add-tantieme-and-explanations.py --dry-run
python3 pitch-deck/scripts/add-tantieme-and-explanations.py
"""
from __future__ import annotations
import argparse
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from openpyxl import load_workbook
from openpyxl.styles import Alignment, Font
EXPORTS = Path(__file__).resolve().parent.parent / "exports"
TARGETS = [
"Finanzplan-Series-A-Ambitioniert.xlsx",
"Finanzplan-Wandeldarlehen-400k.xlsx",
"Finanzplan-Wandeldarlehen-400k-Bull.xlsx",
"Finanzplan-Wandeldarlehen-400k-Bear.xlsx",
]
# Founders detected by column-A label ('Benjamin', 'Sharang') — actual row varies
# by file (400k: rows 27/28, Series-A: rows 39/40).
FOUNDER_NAME_HINTS = ("Benjamin", "Sharang")
# Year when Tantieme starts (Jan)
TANTIEME_START_YEAR = 2028
TANTIEME_END_YEAR = 2030
TANTIEME_DEFAULT = 0.0 # 0% by default; user sets 0.20 to 1.00
COHORT_EXPLANATION = [
"Erläuterung",
"",
"Was zeigt diese Tabelle? Jede Zeile entspricht einer Akquise-Kohorte (= alle",
"Kunden, die in diesem Monat neu gewonnen wurden). Die Spalten M0, M1, M2 …",
"zeigen, wie viele dieser Kunden nach 0, 1, 2 … Monaten noch aktiv sind.",
"Die Werte sinken nach Akquise durch Churn (B5 = Retention pro Monat).",
"",
"Warum nützlich? Die Cohort-Analyse macht sichtbar, wie schnell Kunden",
"abwandern und wie lang die durchschnittliche Customer Lifetime ist. Daraus",
"ergibt sich der LTV: Lifetime (Monate) × ARPU × Bruttomarge.",
"",
"Beispiel: Bei monatlicher Churn-Rate 1,5% verbleiben nach 12 Monaten ~83%,",
"nach 24 Monaten ~70%. Lifetime ≈ 1 / 0,015 = 67 Monate (~5,6 Jahre).",
"Das Beobachtungsfenster ist auf 24 Monate begrenzt (B6 änderbar).",
]
SENSITIVITY_EXPLANATION = [
"Erläuterung",
"",
"Was ist Spalte E? Der 'Baseline'-Wert für EBIT 2030 ($B$5 = GuV!F16). In",
"jeder Zeile gleich, weil keine Variable verändert wird = Erwartung wenn",
"alle Annahmen wie geplant. Wenn EBIT 2030 nicht sinnvoll erscheint, hängt",
"das von Umsatz-, Personal- und Aufwand-Annahmen ab — siehe Treiber-Sheet.",
"",
"Was zeigen die Spalten -30% bis +30%? Pro Zeile (= eine Variable) wird",
"diese eine Annahme um den genannten Prozentsatz variiert. Andere Annahmen",
"bleiben Baseline. Ergebnis: EBIT 2030 unter dieser Variation.",
"",
"Beispiel: Zeile 'Monatliche Churn-Rate', Spalte '+30%'. Bedeutung: Wenn die",
"Churn-Rate 30% schlechter ist als geplant, wie hoch wäre der EBIT? Die",
"Differenz zur Baseline (E) = Sensitivität dieses Faktors.",
"",
"Wie interpretieren? Die Variable mit der größten Spannweite zwischen -30%",
"und +30% ist am riskantesten (Tornado-Logik). 2D-Heatmap (Row 26+) zeigt",
"kombinierte Effekte von Churn × ARPU auf LTV/CAC — gesund: > 3.",
"",
"Limitierung: One-at-a-Time ignoriert Wechselwirkungen (z.B. ändert sich",
"Preis-Erhöhung → auch Churn). Für Investoren ist OAT trotzdem üblich.",
]
def add_tantieme_to_treiber(ws_t) -> dict[int, int]:
"""Append Tantieme driver block after row 81. Returns {year: row}."""
start = ws_t.max_row + 2 # blank then header
r = start
ws_t.cell(row=r, column=1).value = "Tantieme Gründer (% Brutto, ab Jan 2028)"
r += 1
refs: dict[int, int] = {}
for yr in range(TANTIEME_START_YEAR, TANTIEME_END_YEAR + 1):
ws_t.cell(row=r, column=1).value = f"Tantieme Gründer {yr}"
ws_t.cell(row=r, column=2).value = TANTIEME_DEFAULT
ws_t.cell(row=r, column=2).number_format = "0%"
refs[yr] = r
r += 1
return refs
def find_jan_2028_col(ws_pk) -> int:
for c in range(2, ws_pk.max_column + 1):
y = ws_pk.cell(row=1, column=c).value
m = ws_pk.cell(row=2, column=c).value
if y == 2028 and m == 1:
return c
raise RuntimeError("Could not locate Jan 2028 column in Personalkosten")
# Match the base brutto subexpression we want to wrap: $D$X*(1+$E$X/100)^(<col>$1-$G$X)
BASE_BRUTTO_RE = re.compile(
r"(\$D\$(\d+)\*\(1\+\$E\$\2/100\)\^\(([A-Z]+)\$1-\$G\$\2\))"
)
def wrap_with_tantieme(formula: str, tantieme_first: int, tantieme_last: int) -> str | None:
"""Wrap the base-brutto subexpression with (1 + tantieme_for_year). Returns
the new formula, or None if no match or already wrapped (idempotent).
"""
# Idempotency: skip if a Tantieme multiplier with these rows already exists
if f"INDEX(Treiber!$B${tantieme_first}:$B${tantieme_last}" in formula:
return None
m = BASE_BRUTTO_RE.search(formula)
if not m:
return None
base_expr = m.group(1)
col_letter = m.group(3)
tantieme_factor = (
f"(1+INDEX(Treiber!$B${tantieme_first}:$B${tantieme_last},"
f"{col_letter}$1-{TANTIEME_START_YEAR - 1}))"
)
new_expr = f"{base_expr}*{tantieme_factor}"
return formula.replace(base_expr, new_expr)
def find_founder_brutto_rows(ws_pk) -> list[int]:
rows: list[int] = []
for r in range(1, ws_pk.max_row + 1):
a = ws_pk.cell(row=r, column=1).value
if not isinstance(a, str):
continue
if "— Brutto" in a and any(hint in a for hint in FOUNDER_NAME_HINTS):
rows.append(r)
return rows
def apply_tantieme_to_personalkosten(ws_pk, refs: dict[int, int]) -> int:
jan_2028 = find_jan_2028_col(ws_pk)
tantieme_first = min(refs.values())
tantieme_last = max(refs.values())
rows = find_founder_brutto_rows(ws_pk)
n = 0
for row in rows:
for c in range(jan_2028, ws_pk.max_column + 1):
cell = ws_pk.cell(row=row, column=c)
if not isinstance(cell.value, str) or not cell.value.startswith("="):
continue
new_formula = wrap_with_tantieme(cell.value, tantieme_first, tantieme_last)
if new_formula is not None:
cell.value = new_formula
n += 1
return n
def add_explanation_block(ws, lines: list[str], start_row: int) -> int:
"""Write lines starting at start_row, col A. Returns last row written."""
bold = Font(bold=True, size=12)
wrap = Alignment(wrap_text=True, vertical="top")
r = start_row
for line in lines:
cell = ws.cell(row=r, column=1, value=line)
if line == "Erläuterung":
cell.font = bold
else:
cell.alignment = wrap
r += 1
return r - 1
def process_file(path: Path, dry_run: bool) -> dict:
wb = load_workbook(path)
stats: dict = {}
# 1 — Tantieme driver in Treiber
if "Treiber" not in wb.sheetnames or "Personalkosten" not in wb.sheetnames:
return {"error": "missing required sheets"}
# Detect existing Tantieme block to avoid duplicating
ws_t = wb["Treiber"]
existing_row = None
for r in range(1, ws_t.max_row + 1):
v = ws_t.cell(row=r, column=1).value
if isinstance(v, str) and v.startswith("Tantieme Gründer (%"):
existing_row = r
break
if existing_row:
# Already present: read existing references, don't re-append
refs = {}
r = existing_row + 1
for yr in range(TANTIEME_START_YEAR, TANTIEME_END_YEAR + 1):
refs[yr] = r
r += 1
stats["tantieme_block"] = "preserved"
else:
refs = add_tantieme_to_treiber(ws_t)
stats["tantieme_block"] = f"added rows {min(refs.values())}-{max(refs.values())}"
# 2 — Apply Tantieme to founder Brutto rows
ws_pk = wb["Personalkosten"]
n_formulas = apply_tantieme_to_personalkosten(ws_pk, refs)
stats["formulas_wrapped"] = n_formulas
# 3 — Cohort-Analyse explanation
if "Cohort-Analyse" in wb.sheetnames:
ws_co = wb["Cohort-Analyse"]
# Append at row 49 (after content at row 47). Idempotent: don't re-add.
existing = any(
ws_co.cell(row=r, column=1).value == "Erläuterung"
for r in range(48, min(ws_co.max_row + 1, 70))
)
if not existing:
last = add_explanation_block(ws_co, COHORT_EXPLANATION, start_row=49)
stats["cohort_explanation"] = f"rows 49-{last}"
else:
stats["cohort_explanation"] = "already present"
# 4 — Sensitivity explanation
if "Sensitivity" in wb.sheetnames:
ws_se = wb["Sensitivity"]
existing = any(
ws_se.cell(row=r, column=1).value == "Erläuterung"
for r in range(38, min(ws_se.max_row + 1, 70))
)
if not existing:
last = add_explanation_block(ws_se, SENSITIVITY_EXPLANATION, start_row=39)
stats["sensitivity_explanation"] = f"rows 39-{last}"
else:
stats["sensitivity_explanation"] = "already present"
if not dry_run:
wb.save(path)
return stats
def backup(path: Path) -> Path:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
bk = path.with_name(f"{path.stem}.BACKUP-pre-tantieme-{ts}{path.suffix}")
shutil.copy2(path, bk)
return bk
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--no-backup", action="store_true")
args = ap.parse_args()
for name in TARGETS:
path = EXPORTS / name
if not path.exists():
print(f" skip {name}: not found")
continue
if not args.dry_run and not args.no_backup:
bk = backup(path)
print(f" ✓ backup: {bk.name}")
stats = process_file(path, dry_run=args.dry_run)
print(f"\n === {name} ===")
for k, v in stats.items():
print(f" {k}: {v}")
return 0
if __name__ == "__main__":
sys.exit(main())