Yehor commited on
Commit
914b436
·
verified ·
1 Parent(s): 562379c

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +406 -0
app.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import gradio as gr
3
+ from fpdf import FPDF
4
+ import tempfile
5
+ import os
6
+ import matplotlib.pyplot as plt
7
+ import matplotlib.dates as mdates
8
+
9
+
10
+ # --- PDF Generation Helper Function (Unchanged) ---
11
+ def create_pdf_report(text_content):
12
+ """
13
+ Generates a PDF file from a given text string.
14
+ """
15
+ try:
16
+ temp_dir = tempfile.gettempdir()
17
+ pdf_path = os.path.join(
18
+ temp_dir, next(tempfile._get_candidate_names()) + ".pdf"
19
+ )
20
+
21
+ pdf = FPDF()
22
+ pdf.add_page()
23
+
24
+ pdf.set_font("Courier", size=10)
25
+
26
+ pdf.set_font("Courier", "B", 16)
27
+ pdf.cell(0, 10, "SaaS Metrics Analysis Report", 0, 1, "C")
28
+ pdf.ln(10)
29
+
30
+ pdf.set_font("Courier", size=10)
31
+
32
+ encoded_text = text_content.encode("latin-1", "replace").decode("latin-1")
33
+ pdf.multi_cell(0, 5, text=encoded_text)
34
+
35
+ pdf.output(pdf_path)
36
+
37
+ return pdf_path
38
+ except Exception as e:
39
+ print(f"Error creating PDF: {e}")
40
+ return None
41
+
42
+
43
+ # --- Visualization Helper Function ---
44
+ def create_visualizations(df):
45
+ """
46
+ Generates matplotlib plots from the dataframe.
47
+ """
48
+ try:
49
+ # Ensure plots are closed to prevent memory issues in long-running apps
50
+ plt.close("all")
51
+
52
+ # --- Plot 1: MRR Trend ---
53
+ fig1, ax1 = plt.subplots(figsize=(10, 5))
54
+ ax1.plot(df["Date"], df["MRR_End"], marker="o", linestyle="-", color="#1E88E5")
55
+ ax1.set_title("Monthly Recurring Revenue (MRR) Trend", fontsize=14)
56
+ ax1.set_xlabel("Date", fontsize=12)
57
+ ax1.set_ylabel("MRR ($)", fontsize=12)
58
+ ax1.grid(True, which="both", linestyle="--", linewidth=0.5)
59
+ ax1.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
60
+ ax1.tick_params(axis="x", rotation=45)
61
+ fig1.tight_layout()
62
+
63
+ # --- Plot 2: Customer Growth ---
64
+ fig2, ax2 = plt.subplots(figsize=(10, 5))
65
+ ax2.plot(
66
+ df["Date"],
67
+ df["Total_Customers_End"],
68
+ marker="o",
69
+ linestyle="-",
70
+ color="#43A047",
71
+ )
72
+ ax2.set_title("Customer Growth Trend", fontsize=14)
73
+ ax2.set_xlabel("Date", fontsize=12)
74
+ ax2.set_ylabel("Total Customers", fontsize=12)
75
+ ax2.grid(True, which="both", linestyle="--", linewidth=0.5)
76
+ ax2.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
77
+ ax2.tick_params(axis="x", rotation=45)
78
+ fig2.tight_layout()
79
+
80
+ # --- Plot 3: LTV vs CAC (Last Month) ---
81
+ last_month = df.iloc[-1]
82
+ mrr_now = last_month["MRR_End"]
83
+ active_accounts = last_month["Total_Customers_End"]
84
+ arpa_monthly = calculate_arpa(mrr_now, active_accounts)
85
+ # customer_churn_rate_monthly = calculate_customer_churn_rate(
86
+ # last_month["Churned_Customers"], last_month["Total_Customers_Start"]
87
+ # )
88
+ gross_rev_churn_rate_monthly = calculate_gross_revenue_churn_rate(
89
+ last_month["Churned_Revenue"], last_month["MRR_Start"]
90
+ )
91
+ gross_margin_monthly = calculate_gross_margin(
92
+ last_month["Total_Revenue"], last_month["COGS"]
93
+ )
94
+ ltv = calculate_ltv(
95
+ arpa_monthly, gross_margin_monthly, gross_rev_churn_rate_monthly
96
+ )
97
+ cac_monthly = calculate_cac(
98
+ last_month["Sales_And_Marketing_Spend"], last_month["New_Customers"]
99
+ )
100
+
101
+ fig3, ax3 = plt.subplots(figsize=(8, 5))
102
+ metrics = ["LTV (Lifetime Value)", "CAC (Acquisition Cost)"]
103
+ values = [ltv, cac_monthly]
104
+ bars = ax3.bar(metrics, values, color=["#43A047", "#E53935"])
105
+ ax3.set_title(
106
+ f"LTV vs. CAC for {last_month['Date'].strftime('%Y-%m')}", fontsize=14
107
+ )
108
+ ax3.set_ylabel("Value ($)", fontsize=12)
109
+ # Add value labels on top of bars
110
+ for bar in bars:
111
+ yval = bar.get_height()
112
+ ax3.text(
113
+ bar.get_x() + bar.get_width() / 2.0,
114
+ yval,
115
+ f"${yval:,.0f}",
116
+ va="bottom",
117
+ ha="center",
118
+ )
119
+ fig3.tight_layout()
120
+
121
+ # --- Plot 4: Net Revenue Retention (NRR) Trend ---
122
+ # Calculate NRR for each row if it's not already there
123
+ df["NRR"] = df.apply(
124
+ lambda row: calculate_nrr(
125
+ row["MRR_Start"], row["Expansion_Revenue"], row["Churned_Revenue"]
126
+ ),
127
+ axis=1,
128
+ )
129
+ fig4, ax4 = plt.subplots(figsize=(10, 5))
130
+ ax4.plot(df["Date"], df["NRR"], marker="o", linestyle="-", color="#8E24AA")
131
+ ax4.axhline(y=1.0, color="grey", linestyle="--", label="100% Benchmark")
132
+ ax4.set_title("Net Revenue Retention (NRR) Trend", fontsize=14)
133
+ ax4.set_xlabel("Date", fontsize=12)
134
+ ax4.set_ylabel("NRR", fontsize=12)
135
+ ax4.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f"{y:.0%}"))
136
+ ax4.grid(True, which="both", linestyle="--", linewidth=0.5)
137
+ ax4.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
138
+ ax4.tick_params(axis="x", rotation=45)
139
+ ax4.legend()
140
+ fig4.tight_layout()
141
+
142
+ return fig1, fig2, fig3, fig4
143
+ except Exception as e:
144
+ print(f"Error creating visualizations: {e}")
145
+ return None, None, None, None
146
+
147
+
148
+ # --- Core SaaS Metrics Functions (Unchanged) ---
149
+ def calculate_arr(mrr):
150
+ return mrr * 12
151
+
152
+
153
+ def calculate_yoy_growth(current, prior):
154
+ return (current - prior) / prior if prior > 0 else 0
155
+
156
+
157
+ def calculate_sde(revenue, cogs, op_ex, owner_comp):
158
+ return revenue - cogs - op_ex + owner_comp
159
+
160
+
161
+ def calculate_valuation_revenue(arr, multiple):
162
+ return arr * multiple
163
+
164
+
165
+ def calculate_valuation_sde(sde, multiple):
166
+ return sde * multiple
167
+
168
+
169
+ def calculate_valuation_ebitda(ebitda, multiple):
170
+ return ebitda * multiple
171
+
172
+
173
+ def calculate_rule_of_40(growth_percent, margin_percent):
174
+ return growth_percent + margin_percent
175
+
176
+
177
+ def calculate_arpa(mrr, customers):
178
+ return mrr / customers if customers > 0 else 0
179
+
180
+
181
+ def calculate_customer_churn_rate(churned, start):
182
+ return churned / start if start > 0 else 0
183
+
184
+
185
+ def calculate_gross_revenue_churn_rate(churned_rev, mrr_start):
186
+ return churned_rev / mrr_start if mrr_start > 0 else 0
187
+
188
+
189
+ def calculate_net_revenue_churn_rate(churned_rev, expansion_rev, mrr_start):
190
+ return (churned_rev - expansion_rev) / mrr_start if mrr_start > 0 else 0
191
+
192
+
193
+ def calculate_nrr(mrr_start, expansion, churned):
194
+ return (mrr_start + expansion - churned) / mrr_start if mrr_start > 0 else 0
195
+
196
+
197
+ def calculate_cac(sm_spend, new):
198
+ return sm_spend / new if new > 0 else 0
199
+
200
+
201
+ def calculate_gross_margin(rev, cogs):
202
+ return (rev - cogs) / rev if rev > 0 else 0
203
+
204
+
205
+ def calculate_customer_lifetime(churn_rate):
206
+ return 1 / churn_rate if churn_rate > 0 else 0
207
+
208
+
209
+ def calculate_ltv(arpa, margin, rev_churn):
210
+ return arpa * margin / rev_churn if rev_churn > 0 else 0
211
+
212
+
213
+ def calculate_ltv_cac_ratio(ltv, cac):
214
+ return ltv / cac if cac > 0 else 0
215
+
216
+
217
+ def calculate_cac_payback_period(cac, arpa, margin):
218
+ return cac / (arpa * margin) if arpa * margin > 0 else 0
219
+
220
+
221
+ # --- Modified Main Analysis Function ---
222
+ def analyze_csv(file, revenue_multiple=6.0, sde_multiple=4.0, ebitda_multiple=5.5):
223
+ """
224
+ Analyzes the uploaded CSV and returns a text summary, a PDF, and plots.
225
+ """
226
+ if file is None:
227
+ return "Please upload a CSV file.", None, None, None, None, None
228
+
229
+ try:
230
+ df = pd.read_csv(file)
231
+ df["Date"] = pd.to_datetime(df["Date"])
232
+
233
+ if len(df) < 13:
234
+ return (
235
+ "Insufficient data in CSV. Need at least 13 months for full analysis.",
236
+ None,
237
+ None,
238
+ None,
239
+ None,
240
+ None,
241
+ )
242
+
243
+ # --- Generate Visualizations ---
244
+ plot1, plot2, plot3, plot4 = create_visualizations(df)
245
+
246
+ # --- Set Analysis Period and Assumptions ---
247
+ last_month = df.iloc[-1]
248
+ last_12_months = df.iloc[-13:-1]
249
+ prior_12_months = df.iloc[:12] if len(df) >= 24 else df.iloc[:-13]
250
+
251
+ output = []
252
+
253
+ # --- Calculate Annual Metrics ---
254
+ output.append("=" * 50)
255
+ output.append(
256
+ f"ANALYSIS FOR LAST 12 MONTHS ({last_12_months['Date'].min().strftime('%Y-%m')} to {last_12_months['Date'].max().strftime('%Y-%m')})"
257
+ )
258
+ output.append("=" * 50)
259
+
260
+ total_revenue_last_12m = last_12_months["Total_Revenue"].sum()
261
+ total_cogs_last_12m = last_12_months["COGS"].sum()
262
+ total_opex_last_12m = last_12_months["OpEx"].sum()
263
+ total_owner_comp_last_12m = last_12_months["Owner_Compensation"].sum()
264
+ total_sm_spend_last_12m = last_12_months["Sales_And_Marketing_Spend"].sum()
265
+
266
+ mrr_end_of_year = last_12_months.iloc[-1]["MRR_End"]
267
+ arr_current = calculate_arr(mrr_end_of_year)
268
+ arr_prior = calculate_arr(prior_12_months.iloc[-1]["MRR_End"])
269
+ yoy_growth = calculate_yoy_growth(arr_current, arr_prior)
270
+
271
+ output.append(f"Annual Recurring Revenue (ARR): ${arr_current:,.2f}")
272
+ output.append(f"YoY ARR Growth: {yoy_growth:.2%}")
273
+
274
+ sde_annual = calculate_sde(
275
+ total_revenue_last_12m,
276
+ total_cogs_last_12m,
277
+ (total_opex_last_12m + total_sm_spend_last_12m),
278
+ total_owner_comp_last_12m,
279
+ )
280
+ ebitda_annual = (
281
+ total_revenue_last_12m
282
+ - total_cogs_last_12m
283
+ - total_opex_last_12m
284
+ - total_sm_spend_last_12m
285
+ - total_owner_comp_last_12m
286
+ )
287
+
288
+ output.append(f"Seller's Discretionary Earnings (SDE): ${sde_annual:,.2f}")
289
+ output.append(f"EBITDA: ${ebitda_annual:,.2f}")
290
+
291
+ output.append("\n--- Valuations ---")
292
+ output.append(
293
+ f"Revenue-Based Valuation ({revenue_multiple:.1f}x ARR): ${calculate_valuation_revenue(arr_current, revenue_multiple):,.2f}"
294
+ )
295
+ output.append(
296
+ f"SDE-Based Valuation ({sde_multiple:.1f}x SDE): ${calculate_valuation_sde(sde_annual, sde_multiple):,.2f}"
297
+ )
298
+ output.append(
299
+ f"EBITDA-Based Valuation ({ebitda_multiple:.1f}x EBITDA): ${calculate_valuation_ebitda(ebitda_annual, ebitda_multiple):,.2f}"
300
+ )
301
+
302
+ ebitda_margin_annual = (
303
+ ebitda_annual / total_revenue_last_12m if total_revenue_last_12m > 0 else 0
304
+ )
305
+ rule_of_40_score = calculate_rule_of_40(
306
+ yoy_growth * 100, ebitda_margin_annual * 100
307
+ )
308
+ output.append("\n--- Health Metrics ---")
309
+ output.append(f"EBITDA Margin: {ebitda_margin_annual:.2%}")
310
+ output.append(
311
+ f"Rule of 40 Score: {rule_of_40_score:.2f} (Target > 40 is healthy)"
312
+ )
313
+
314
+ # --- Calculate Monthly Metrics ---
315
+ output.append("\n" + "=" * 50)
316
+ output.append(
317
+ f"ANALYSIS FOR LATEST MONTH ({last_month['Date'].strftime('%Y-%m')})"
318
+ )
319
+ output.append("=" * 50)
320
+
321
+ mrr_now = last_month["MRR_End"]
322
+ arpa_monthly = calculate_arpa(mrr_now, last_month["Total_Customers_End"])
323
+ customer_churn_rate_monthly = calculate_customer_churn_rate(
324
+ last_month["Churned_Customers"], last_month["Total_Customers_Start"]
325
+ )
326
+ gross_rev_churn_rate_monthly = calculate_gross_revenue_churn_rate(
327
+ last_month["Churned_Revenue"], last_month["MRR_Start"]
328
+ )
329
+ net_rev_churn_rate_monthly = calculate_net_revenue_churn_rate(
330
+ last_month["Churned_Revenue"],
331
+ last_month["Expansion_Revenue"],
332
+ last_month["MRR_Start"],
333
+ )
334
+ nrr_monthly = calculate_nrr(
335
+ last_month["MRR_Start"],
336
+ last_month["Expansion_Revenue"],
337
+ last_month["Churned_Revenue"],
338
+ )
339
+
340
+ output.append("--- Revenue & Churn ---")
341
+ output.append(f"Average Revenue Per Account (ARPA): ${arpa_monthly:,.2f}")
342
+ output.append(f"Customer Churn Rate: {customer_churn_rate_monthly:.2%}")
343
+ output.append(f"Gross Revenue Churn Rate: {gross_rev_churn_rate_monthly:.2%}")
344
+ output.append(f"Net Revenue Churn Rate: {net_rev_churn_rate_monthly:.2%}")
345
+ output.append(f"Net Revenue Retention (NRR): {nrr_monthly:.2%}")
346
+
347
+ cac_monthly = calculate_cac(
348
+ last_month["Sales_And_Marketing_Spend"], last_month["New_Customers"]
349
+ )
350
+ gross_margin_monthly = calculate_gross_margin(
351
+ last_month["Total_Revenue"], last_month["COGS"]
352
+ )
353
+ customer_lifetime_months = calculate_customer_lifetime(
354
+ customer_churn_rate_monthly
355
+ )
356
+ ltv = calculate_ltv(
357
+ arpa_monthly, gross_margin_monthly, gross_rev_churn_rate_monthly
358
+ )
359
+ ltv_cac_ratio = calculate_ltv_cac_ratio(ltv, cac_monthly)
360
+ payback_period_months = calculate_cac_payback_period(
361
+ cac_monthly, arpa_monthly, gross_margin_monthly
362
+ )
363
+
364
+ output.append("\n--- Unit Economics ---")
365
+ output.append(f"Gross Margin: {gross_margin_monthly:.2%}")
366
+ output.append(f"Customer Acquisition Cost (CAC): ${cac_monthly:,.2f}")
367
+ output.append(f"Customer Lifetime: {customer_lifetime_months:.1f} months")
368
+ output.append(f"Customer Lifetime Value (LTV): ${ltv:,.2f}")
369
+ output.append(f"LTV:CAC Ratio: {ltv_cac_ratio:.2f}:1 (Target > 3:1 is healthy)")
370
+ output.append(
371
+ f"CAC Payback Period: {payback_period_months:.1f} months (Target < 12 is healthy)"
372
+ )
373
+
374
+ analysis_text = "\n".join(output)
375
+ pdf_file_path = create_pdf_report(analysis_text)
376
+
377
+ return analysis_text, pdf_file_path, plot1, plot2, plot3, plot4
378
+
379
+ except Exception as e:
380
+ return f"Error processing file: {str(e)}", None, None, None, None, None
381
+
382
+
383
+ # --- Updated Gradio Interface ---
384
+ demo = gr.Interface(
385
+ fn=analyze_csv,
386
+ inputs=[
387
+ gr.File(label="Upload SaaS Metrics CSV File", file_types=[".csv"]),
388
+ gr.Number(label="Revenue Multiple", value=6.0),
389
+ gr.Number(label="SDE Multiple", value=4.0),
390
+ gr.Number(label="EBITDA Multiple", value=5.5),
391
+ ],
392
+ outputs=[
393
+ gr.Textbox(label="Analysis Results", lines=20),
394
+ gr.File(label="Download PDF Report"),
395
+ gr.Plot(label="MRR Trend"),
396
+ gr.Plot(label="Customer Growth Trend"),
397
+ gr.Plot(label="LTV vs. CAC (Last Month)"),
398
+ gr.Plot(label="Net Revenue Retention (NRR) Trend"),
399
+ ],
400
+ title="SaaS Metrics Analyzer with Visualizations",
401
+ description="Upload a CSV file with SaaS metrics data. The app will analyze the last 12 months, the latest month, generate key visualizations, and produce a downloadable PDF report.",
402
+ allow_flagging="never",
403
+ )
404
+
405
+ if __name__ == "__main__":
406
+ demo.launch()