@openfinclaw/findoo-datahub-plugin 2026.3.2 → 2026.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +492 -151
- package/_vendor/claude-skills-finance/SKILL.md +192 -0
- package/_vendor/claude-skills-finance/assets/dcf_analysis_template.md +184 -0
- package/_vendor/claude-skills-finance/assets/expected_output.json +161 -0
- package/_vendor/claude-skills-finance/assets/forecast_report_template.md +177 -0
- package/_vendor/claude-skills-finance/assets/sample_financial_data.json +219 -0
- package/_vendor/claude-skills-finance/assets/variance_report_template.md +122 -0
- package/_vendor/claude-skills-finance/references/financial-ratios-guide.md +396 -0
- package/_vendor/claude-skills-finance/references/forecasting-best-practices.md +294 -0
- package/_vendor/claude-skills-finance/references/valuation-methodology.md +255 -0
- package/_vendor/claude-skills-finance/scripts/budget_variance_analyzer.py +406 -0
- package/_vendor/claude-skills-finance/scripts/dcf_valuation.py +449 -0
- package/_vendor/claude-skills-finance/scripts/forecast_builder.py +494 -0
- package/_vendor/claude-skills-finance/scripts/ratio_calculator.py +432 -0
- package/index.ts +356 -20
- package/openclaw.plugin.json +12 -19
- package/package.json +1 -1
- package/references/cn-market-specifics.md +165 -0
- package/references/crypto-analysis.md +635 -0
- package/references/financial-ratios-cn.md +452 -0
- package/references/hk-market-specifics.md +166 -0
- package/references/macro-cycle-cn.md +409 -0
- package/references/valuation-cn.md +427 -0
- package/skills/README.md +294 -0
- package/skills/a-concept-cycle/skill.md +200 -0
- package/skills/a-convertible-arb/skill.md +294 -0
- package/skills/a-dividend-king/skill.md +187 -0
- package/skills/a-earnings-season/skill.md +221 -0
- package/skills/a-index-timer/skill.md +192 -0
- package/skills/a-ipo-new/skill.md +297 -0
- package/skills/a-northbound-decoder/skill.md +185 -0
- package/skills/a-quant-board/skill.md +286 -0
- package/skills/a-share/skill.md +347 -0
- package/skills/a-share-radar/skill.md +185 -0
- package/skills/cross-asset/skill.md +202 -0
- package/skills/crypto/skill.md +269 -0
- package/skills/crypto-altseason/skill.md +208 -0
- package/skills/crypto-btc-cycle/skill.md +231 -0
- package/skills/crypto-defi-yield/skill.md +181 -0
- package/skills/crypto-funding-arb/skill.md +158 -0
- package/skills/crypto-stablecoin-flow/skill.md +149 -0
- package/skills/data-query/skill.md +124 -30
- package/skills/derivatives/skill.md +188 -35
- package/skills/etf-fund/skill.md +216 -0
- package/skills/factor-screen/skill.md +186 -0
- package/skills/hk-china-internet/skill.md +190 -0
- package/skills/hk-dividend-harvest/skill.md +192 -0
- package/skills/hk-hsi-pulse/skill.md +154 -0
- package/skills/hk-southbound-alpha/skill.md +163 -0
- package/skills/hk-stock/skill.md +295 -0
- package/skills/macro/skill.md +244 -53
- package/skills/risk-monitor/skill.md +171 -0
- package/skills/us-dividend/skill.md +162 -0
- package/skills/us-earnings/skill.md +149 -0
- package/skills/us-equity/skill.md +235 -0
- package/skills/us-etf/skill.md +261 -0
- package/skills/us-sector-rotation/skill.md +223 -0
- package/src/config.ts +4 -5
- package/src/datahub-client.test.ts +4 -7
- package/src/datahub-client.ts +6 -1
- package/src/register-tools.ts +720 -0
- package/src/tool-helpers.ts +89 -0
- package/test/e2e/l3-gateway-bootstrap.live.test.ts +354 -0
- package/test/e2e/l4-skill-tool-chain.live.test.ts +461 -0
- package/test/e2e/l5-browser/data-freshness.live.test.ts +379 -0
- package/test/e2e/l5-browser/market-data-chat.live.test.ts +259 -0
- package/test/e2e/l5-browser/skills-registry.test.ts +282 -0
- package/skills/crypto-defi/skill.md +0 -69
- package/skills/equity/skill.md +0 -64
- package/skills/market-radar/skill.md +0 -47
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
DCF Valuation Model
|
|
4
|
+
|
|
5
|
+
Discounted Cash Flow enterprise and equity valuation with WACC calculation,
|
|
6
|
+
terminal value estimation, and two-way sensitivity analysis.
|
|
7
|
+
|
|
8
|
+
Uses standard library only (math, statistics) - NO numpy/pandas/scipy.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python dcf_valuation.py valuation_data.json
|
|
12
|
+
python dcf_valuation.py valuation_data.json --format json
|
|
13
|
+
python dcf_valuation.py valuation_data.json --projection-years 7
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import sys
|
|
20
|
+
from statistics import mean
|
|
21
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
|
|
25
|
+
"""Safely divide two numbers, returning default if denominator is zero."""
|
|
26
|
+
if denominator == 0 or denominator is None:
|
|
27
|
+
return default
|
|
28
|
+
return numerator / denominator
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DCFModel:
|
|
32
|
+
"""Discounted Cash Flow valuation model."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
"""Initialize the DCF model."""
|
|
36
|
+
self.historical: Dict[str, Any] = {}
|
|
37
|
+
self.assumptions: Dict[str, Any] = {}
|
|
38
|
+
self.wacc: float = 0.0
|
|
39
|
+
self.projected_revenue: List[float] = []
|
|
40
|
+
self.projected_fcf: List[float] = []
|
|
41
|
+
self.projection_years: int = 5
|
|
42
|
+
self.terminal_value_perpetuity: float = 0.0
|
|
43
|
+
self.terminal_value_exit_multiple: float = 0.0
|
|
44
|
+
self.enterprise_value_perpetuity: float = 0.0
|
|
45
|
+
self.enterprise_value_exit_multiple: float = 0.0
|
|
46
|
+
self.equity_value_perpetuity: float = 0.0
|
|
47
|
+
self.equity_value_exit_multiple: float = 0.0
|
|
48
|
+
self.value_per_share_perpetuity: float = 0.0
|
|
49
|
+
self.value_per_share_exit_multiple: float = 0.0
|
|
50
|
+
|
|
51
|
+
def set_historical_financials(self, historical: Dict[str, Any]) -> None:
|
|
52
|
+
"""Set historical financial data."""
|
|
53
|
+
self.historical = historical
|
|
54
|
+
|
|
55
|
+
def set_assumptions(self, assumptions: Dict[str, Any]) -> None:
|
|
56
|
+
"""Set projection assumptions."""
|
|
57
|
+
self.assumptions = assumptions
|
|
58
|
+
self.projection_years = assumptions.get("projection_years", 5)
|
|
59
|
+
|
|
60
|
+
def calculate_wacc(self) -> float:
|
|
61
|
+
"""Calculate Weighted Average Cost of Capital via CAPM."""
|
|
62
|
+
wacc_inputs = self.assumptions.get("wacc_inputs", {})
|
|
63
|
+
|
|
64
|
+
risk_free_rate = wacc_inputs.get("risk_free_rate", 0.04)
|
|
65
|
+
equity_risk_premium = wacc_inputs.get("equity_risk_premium", 0.06)
|
|
66
|
+
beta = wacc_inputs.get("beta", 1.0)
|
|
67
|
+
cost_of_debt = wacc_inputs.get("cost_of_debt", 0.05)
|
|
68
|
+
tax_rate = wacc_inputs.get("tax_rate", 0.25)
|
|
69
|
+
debt_weight = wacc_inputs.get("debt_weight", 0.30)
|
|
70
|
+
equity_weight = wacc_inputs.get("equity_weight", 0.70)
|
|
71
|
+
|
|
72
|
+
# CAPM: Cost of Equity = Risk-Free Rate + Beta * Equity Risk Premium
|
|
73
|
+
cost_of_equity = risk_free_rate + beta * equity_risk_premium
|
|
74
|
+
|
|
75
|
+
# WACC = (E/V * Re) + (D/V * Rd * (1 - T))
|
|
76
|
+
after_tax_cost_of_debt = cost_of_debt * (1 - tax_rate)
|
|
77
|
+
self.wacc = (equity_weight * cost_of_equity) + (
|
|
78
|
+
debt_weight * after_tax_cost_of_debt
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return self.wacc
|
|
82
|
+
|
|
83
|
+
def project_cash_flows(self) -> Tuple[List[float], List[float]]:
|
|
84
|
+
"""Project revenue and free cash flow over the projection period."""
|
|
85
|
+
base_revenue = self.historical.get("revenue", [])
|
|
86
|
+
if not base_revenue:
|
|
87
|
+
raise ValueError("Historical revenue data is required")
|
|
88
|
+
|
|
89
|
+
last_revenue = base_revenue[-1]
|
|
90
|
+
|
|
91
|
+
revenue_growth_rates = self.assumptions.get("revenue_growth_rates", [])
|
|
92
|
+
fcf_margins = self.assumptions.get("fcf_margins", [])
|
|
93
|
+
|
|
94
|
+
# If growth rates not provided for all years, use average or default
|
|
95
|
+
default_growth = self.assumptions.get("default_revenue_growth", 0.05)
|
|
96
|
+
default_fcf_margin = self.assumptions.get("default_fcf_margin", 0.10)
|
|
97
|
+
|
|
98
|
+
self.projected_revenue = []
|
|
99
|
+
self.projected_fcf = []
|
|
100
|
+
current_revenue = last_revenue
|
|
101
|
+
|
|
102
|
+
for year in range(self.projection_years):
|
|
103
|
+
growth = (
|
|
104
|
+
revenue_growth_rates[year]
|
|
105
|
+
if year < len(revenue_growth_rates)
|
|
106
|
+
else default_growth
|
|
107
|
+
)
|
|
108
|
+
fcf_margin = (
|
|
109
|
+
fcf_margins[year]
|
|
110
|
+
if year < len(fcf_margins)
|
|
111
|
+
else default_fcf_margin
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
current_revenue = current_revenue * (1 + growth)
|
|
115
|
+
fcf = current_revenue * fcf_margin
|
|
116
|
+
|
|
117
|
+
self.projected_revenue.append(current_revenue)
|
|
118
|
+
self.projected_fcf.append(fcf)
|
|
119
|
+
|
|
120
|
+
return self.projected_revenue, self.projected_fcf
|
|
121
|
+
|
|
122
|
+
def calculate_terminal_value(self) -> Tuple[float, float]:
|
|
123
|
+
"""Calculate terminal value using both perpetuity growth and exit multiple."""
|
|
124
|
+
if not self.projected_fcf:
|
|
125
|
+
raise ValueError("Must project cash flows before terminal value")
|
|
126
|
+
|
|
127
|
+
terminal_fcf = self.projected_fcf[-1]
|
|
128
|
+
terminal_growth = self.assumptions.get("terminal_growth_rate", 0.025)
|
|
129
|
+
exit_multiple = self.assumptions.get("exit_ev_ebitda_multiple", 12.0)
|
|
130
|
+
|
|
131
|
+
# Perpetuity growth method: TV = FCF * (1+g) / (WACC - g)
|
|
132
|
+
if self.wacc > terminal_growth:
|
|
133
|
+
self.terminal_value_perpetuity = (
|
|
134
|
+
terminal_fcf * (1 + terminal_growth)
|
|
135
|
+
) / (self.wacc - terminal_growth)
|
|
136
|
+
else:
|
|
137
|
+
self.terminal_value_perpetuity = 0.0
|
|
138
|
+
|
|
139
|
+
# Exit multiple method: TV = Terminal EBITDA * Exit Multiple
|
|
140
|
+
terminal_revenue = self.projected_revenue[-1]
|
|
141
|
+
ebitda_margin = self.assumptions.get("terminal_ebitda_margin", 0.20)
|
|
142
|
+
terminal_ebitda = terminal_revenue * ebitda_margin
|
|
143
|
+
self.terminal_value_exit_multiple = terminal_ebitda * exit_multiple
|
|
144
|
+
|
|
145
|
+
return self.terminal_value_perpetuity, self.terminal_value_exit_multiple
|
|
146
|
+
|
|
147
|
+
def calculate_enterprise_value(self) -> Tuple[float, float]:
|
|
148
|
+
"""Calculate enterprise value by discounting projected FCFs and terminal value."""
|
|
149
|
+
if not self.projected_fcf:
|
|
150
|
+
raise ValueError("Must project cash flows first")
|
|
151
|
+
|
|
152
|
+
# Discount projected FCFs
|
|
153
|
+
pv_fcf = 0.0
|
|
154
|
+
for i, fcf in enumerate(self.projected_fcf):
|
|
155
|
+
discount_factor = (1 + self.wacc) ** (i + 1)
|
|
156
|
+
pv_fcf += fcf / discount_factor
|
|
157
|
+
|
|
158
|
+
# Discount terminal values
|
|
159
|
+
terminal_discount = (1 + self.wacc) ** self.projection_years
|
|
160
|
+
|
|
161
|
+
pv_tv_perpetuity = self.terminal_value_perpetuity / terminal_discount
|
|
162
|
+
pv_tv_exit = self.terminal_value_exit_multiple / terminal_discount
|
|
163
|
+
|
|
164
|
+
self.enterprise_value_perpetuity = pv_fcf + pv_tv_perpetuity
|
|
165
|
+
self.enterprise_value_exit_multiple = pv_fcf + pv_tv_exit
|
|
166
|
+
|
|
167
|
+
return self.enterprise_value_perpetuity, self.enterprise_value_exit_multiple
|
|
168
|
+
|
|
169
|
+
def calculate_equity_value(self) -> Tuple[float, float]:
|
|
170
|
+
"""Calculate equity value from enterprise value."""
|
|
171
|
+
net_debt = self.historical.get("net_debt", 0)
|
|
172
|
+
shares_outstanding = self.historical.get("shares_outstanding", 1)
|
|
173
|
+
|
|
174
|
+
self.equity_value_perpetuity = (
|
|
175
|
+
self.enterprise_value_perpetuity - net_debt
|
|
176
|
+
)
|
|
177
|
+
self.equity_value_exit_multiple = (
|
|
178
|
+
self.enterprise_value_exit_multiple - net_debt
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
self.value_per_share_perpetuity = safe_divide(
|
|
182
|
+
self.equity_value_perpetuity, shares_outstanding
|
|
183
|
+
)
|
|
184
|
+
self.value_per_share_exit_multiple = safe_divide(
|
|
185
|
+
self.equity_value_exit_multiple, shares_outstanding
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return self.equity_value_perpetuity, self.equity_value_exit_multiple
|
|
189
|
+
|
|
190
|
+
def sensitivity_analysis(
|
|
191
|
+
self,
|
|
192
|
+
wacc_range: Optional[List[float]] = None,
|
|
193
|
+
growth_range: Optional[List[float]] = None,
|
|
194
|
+
) -> Dict[str, Any]:
|
|
195
|
+
"""
|
|
196
|
+
Two-way sensitivity analysis: WACC vs terminal growth rate.
|
|
197
|
+
|
|
198
|
+
Returns a table of enterprise values using nested lists (no numpy).
|
|
199
|
+
"""
|
|
200
|
+
if wacc_range is None:
|
|
201
|
+
base_wacc = self.wacc
|
|
202
|
+
wacc_range = [
|
|
203
|
+
round(base_wacc - 0.02, 4),
|
|
204
|
+
round(base_wacc - 0.01, 4),
|
|
205
|
+
round(base_wacc, 4),
|
|
206
|
+
round(base_wacc + 0.01, 4),
|
|
207
|
+
round(base_wacc + 0.02, 4),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
if growth_range is None:
|
|
211
|
+
base_growth = self.assumptions.get("terminal_growth_rate", 0.025)
|
|
212
|
+
growth_range = [
|
|
213
|
+
round(base_growth - 0.01, 4),
|
|
214
|
+
round(base_growth - 0.005, 4),
|
|
215
|
+
round(base_growth, 4),
|
|
216
|
+
round(base_growth + 0.005, 4),
|
|
217
|
+
round(base_growth + 0.01, 4),
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
rows = len(wacc_range)
|
|
221
|
+
cols = len(growth_range)
|
|
222
|
+
|
|
223
|
+
# Initialize sensitivity table as nested lists
|
|
224
|
+
ev_table = [[0.0] * cols for _ in range(rows)]
|
|
225
|
+
share_price_table = [[0.0] * cols for _ in range(rows)]
|
|
226
|
+
|
|
227
|
+
terminal_fcf = self.projected_fcf[-1] if self.projected_fcf else 0
|
|
228
|
+
|
|
229
|
+
for i, wacc_val in enumerate(wacc_range):
|
|
230
|
+
for j, growth_val in enumerate(growth_range):
|
|
231
|
+
if wacc_val <= growth_val:
|
|
232
|
+
ev_table[i][j] = float("inf")
|
|
233
|
+
share_price_table[i][j] = float("inf")
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Recalculate PV of projected FCFs with this WACC
|
|
237
|
+
pv_fcf = 0.0
|
|
238
|
+
for k, fcf in enumerate(self.projected_fcf):
|
|
239
|
+
pv_fcf += fcf / ((1 + wacc_val) ** (k + 1))
|
|
240
|
+
|
|
241
|
+
# Terminal value with this growth rate
|
|
242
|
+
tv = (terminal_fcf * (1 + growth_val)) / (wacc_val - growth_val)
|
|
243
|
+
pv_tv = tv / ((1 + wacc_val) ** self.projection_years)
|
|
244
|
+
|
|
245
|
+
ev = pv_fcf + pv_tv
|
|
246
|
+
ev_table[i][j] = round(ev, 2)
|
|
247
|
+
|
|
248
|
+
net_debt = self.historical.get("net_debt", 0)
|
|
249
|
+
shares = self.historical.get("shares_outstanding", 1)
|
|
250
|
+
equity = ev - net_debt
|
|
251
|
+
share_price_table[i][j] = round(
|
|
252
|
+
safe_divide(equity, shares), 2
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"wacc_values": wacc_range,
|
|
257
|
+
"growth_values": growth_range,
|
|
258
|
+
"enterprise_value_table": ev_table,
|
|
259
|
+
"share_price_table": share_price_table,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
def run_full_valuation(self) -> Dict[str, Any]:
|
|
263
|
+
"""Run the complete DCF valuation."""
|
|
264
|
+
self.calculate_wacc()
|
|
265
|
+
self.project_cash_flows()
|
|
266
|
+
self.calculate_terminal_value()
|
|
267
|
+
self.calculate_enterprise_value()
|
|
268
|
+
self.calculate_equity_value()
|
|
269
|
+
sensitivity = self.sensitivity_analysis()
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"wacc": self.wacc,
|
|
273
|
+
"projected_revenue": self.projected_revenue,
|
|
274
|
+
"projected_fcf": self.projected_fcf,
|
|
275
|
+
"terminal_value": {
|
|
276
|
+
"perpetuity_growth": self.terminal_value_perpetuity,
|
|
277
|
+
"exit_multiple": self.terminal_value_exit_multiple,
|
|
278
|
+
},
|
|
279
|
+
"enterprise_value": {
|
|
280
|
+
"perpetuity_growth": self.enterprise_value_perpetuity,
|
|
281
|
+
"exit_multiple": self.enterprise_value_exit_multiple,
|
|
282
|
+
},
|
|
283
|
+
"equity_value": {
|
|
284
|
+
"perpetuity_growth": self.equity_value_perpetuity,
|
|
285
|
+
"exit_multiple": self.equity_value_exit_multiple,
|
|
286
|
+
},
|
|
287
|
+
"value_per_share": {
|
|
288
|
+
"perpetuity_growth": self.value_per_share_perpetuity,
|
|
289
|
+
"exit_multiple": self.value_per_share_exit_multiple,
|
|
290
|
+
},
|
|
291
|
+
"sensitivity_analysis": sensitivity,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
def format_text(self, results: Dict[str, Any]) -> str:
|
|
295
|
+
"""Format valuation results as human-readable text."""
|
|
296
|
+
lines: List[str] = []
|
|
297
|
+
lines.append("=" * 70)
|
|
298
|
+
lines.append("DCF VALUATION ANALYSIS")
|
|
299
|
+
lines.append("=" * 70)
|
|
300
|
+
|
|
301
|
+
def fmt_money(val: float) -> str:
|
|
302
|
+
if val == float("inf"):
|
|
303
|
+
return "N/A (WACC <= growth)"
|
|
304
|
+
if abs(val) >= 1e9:
|
|
305
|
+
return f"${val / 1e9:,.2f}B"
|
|
306
|
+
if abs(val) >= 1e6:
|
|
307
|
+
return f"${val / 1e6:,.2f}M"
|
|
308
|
+
if abs(val) >= 1e3:
|
|
309
|
+
return f"${val / 1e3:,.1f}K"
|
|
310
|
+
return f"${val:,.2f}"
|
|
311
|
+
|
|
312
|
+
lines.append(f"\n--- WACC ---")
|
|
313
|
+
lines.append(f" Weighted Average Cost of Capital: {results['wacc'] * 100:.2f}%")
|
|
314
|
+
|
|
315
|
+
lines.append(f"\n--- REVENUE PROJECTIONS ---")
|
|
316
|
+
for i, rev in enumerate(results["projected_revenue"], 1):
|
|
317
|
+
lines.append(f" Year {i}: {fmt_money(rev)}")
|
|
318
|
+
|
|
319
|
+
lines.append(f"\n--- FREE CASH FLOW PROJECTIONS ---")
|
|
320
|
+
for i, fcf in enumerate(results["projected_fcf"], 1):
|
|
321
|
+
lines.append(f" Year {i}: {fmt_money(fcf)}")
|
|
322
|
+
|
|
323
|
+
lines.append(f"\n--- TERMINAL VALUE ---")
|
|
324
|
+
lines.append(
|
|
325
|
+
f" Perpetuity Growth Method: "
|
|
326
|
+
f"{fmt_money(results['terminal_value']['perpetuity_growth'])}"
|
|
327
|
+
)
|
|
328
|
+
lines.append(
|
|
329
|
+
f" Exit Multiple Method: "
|
|
330
|
+
f"{fmt_money(results['terminal_value']['exit_multiple'])}"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
lines.append(f"\n--- ENTERPRISE VALUE ---")
|
|
334
|
+
lines.append(
|
|
335
|
+
f" Perpetuity Growth Method: "
|
|
336
|
+
f"{fmt_money(results['enterprise_value']['perpetuity_growth'])}"
|
|
337
|
+
)
|
|
338
|
+
lines.append(
|
|
339
|
+
f" Exit Multiple Method: "
|
|
340
|
+
f"{fmt_money(results['enterprise_value']['exit_multiple'])}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
lines.append(f"\n--- EQUITY VALUE ---")
|
|
344
|
+
lines.append(
|
|
345
|
+
f" Perpetuity Growth Method: "
|
|
346
|
+
f"{fmt_money(results['equity_value']['perpetuity_growth'])}"
|
|
347
|
+
)
|
|
348
|
+
lines.append(
|
|
349
|
+
f" Exit Multiple Method: "
|
|
350
|
+
f"{fmt_money(results['equity_value']['exit_multiple'])}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
lines.append(f"\n--- VALUE PER SHARE ---")
|
|
354
|
+
vps = results["value_per_share"]
|
|
355
|
+
lines.append(f" Perpetuity Growth Method: ${vps['perpetuity_growth']:,.2f}")
|
|
356
|
+
lines.append(f" Exit Multiple Method: ${vps['exit_multiple']:,.2f}")
|
|
357
|
+
|
|
358
|
+
# Sensitivity table
|
|
359
|
+
sens = results["sensitivity_analysis"]
|
|
360
|
+
lines.append(f"\n--- SENSITIVITY ANALYSIS (Enterprise Value) ---")
|
|
361
|
+
lines.append(f" WACC vs Terminal Growth Rate")
|
|
362
|
+
lines.append("")
|
|
363
|
+
|
|
364
|
+
header = " {:>10s}".format("WACC \\ g")
|
|
365
|
+
for g in sens["growth_values"]:
|
|
366
|
+
header += f" {g * 100:>8.1f}%"
|
|
367
|
+
lines.append(header)
|
|
368
|
+
lines.append(" " + "-" * (10 + 10 * len(sens["growth_values"])))
|
|
369
|
+
|
|
370
|
+
for i, w in enumerate(sens["wacc_values"]):
|
|
371
|
+
row = f" {w * 100:>9.1f}%"
|
|
372
|
+
for j in range(len(sens["growth_values"])):
|
|
373
|
+
val = sens["enterprise_value_table"][i][j]
|
|
374
|
+
if val == float("inf"):
|
|
375
|
+
row += f" {'N/A':>8s}"
|
|
376
|
+
else:
|
|
377
|
+
row += f" {fmt_money(val):>8s}"
|
|
378
|
+
lines.append(row)
|
|
379
|
+
|
|
380
|
+
lines.append("\n" + "=" * 70)
|
|
381
|
+
return "\n".join(lines)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def main() -> None:
|
|
385
|
+
"""Main entry point."""
|
|
386
|
+
parser = argparse.ArgumentParser(
|
|
387
|
+
description="DCF Valuation Model - Enterprise and equity valuation"
|
|
388
|
+
)
|
|
389
|
+
parser.add_argument(
|
|
390
|
+
"input_file",
|
|
391
|
+
help="Path to JSON file with valuation data",
|
|
392
|
+
)
|
|
393
|
+
parser.add_argument(
|
|
394
|
+
"--format",
|
|
395
|
+
choices=["text", "json"],
|
|
396
|
+
default="text",
|
|
397
|
+
help="Output format (default: text)",
|
|
398
|
+
)
|
|
399
|
+
parser.add_argument(
|
|
400
|
+
"--projection-years",
|
|
401
|
+
type=int,
|
|
402
|
+
default=None,
|
|
403
|
+
help="Number of projection years (overrides input file)",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
args = parser.parse_args()
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
with open(args.input_file, "r") as f:
|
|
410
|
+
data = json.load(f)
|
|
411
|
+
except FileNotFoundError:
|
|
412
|
+
print(f"Error: File '{args.input_file}' not found.", file=sys.stderr)
|
|
413
|
+
sys.exit(1)
|
|
414
|
+
except json.JSONDecodeError as e:
|
|
415
|
+
print(f"Error: Invalid JSON in '{args.input_file}': {e}", file=sys.stderr)
|
|
416
|
+
sys.exit(1)
|
|
417
|
+
|
|
418
|
+
model = DCFModel()
|
|
419
|
+
model.set_historical_financials(data.get("historical", {}))
|
|
420
|
+
|
|
421
|
+
assumptions = data.get("assumptions", {})
|
|
422
|
+
if args.projection_years is not None:
|
|
423
|
+
assumptions["projection_years"] = args.projection_years
|
|
424
|
+
model.set_assumptions(assumptions)
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
results = model.run_full_valuation()
|
|
428
|
+
except ValueError as e:
|
|
429
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
430
|
+
sys.exit(1)
|
|
431
|
+
|
|
432
|
+
if args.format == "json":
|
|
433
|
+
# Handle inf values for JSON serialization
|
|
434
|
+
def sanitize(obj: Any) -> Any:
|
|
435
|
+
if isinstance(obj, float) and math.isinf(obj):
|
|
436
|
+
return None
|
|
437
|
+
if isinstance(obj, dict):
|
|
438
|
+
return {k: sanitize(v) for k, v in obj.items()}
|
|
439
|
+
if isinstance(obj, list):
|
|
440
|
+
return [sanitize(v) for v in obj]
|
|
441
|
+
return obj
|
|
442
|
+
|
|
443
|
+
print(json.dumps(sanitize(results), indent=2))
|
|
444
|
+
else:
|
|
445
|
+
print(model.format_text(results))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if __name__ == "__main__":
|
|
449
|
+
main()
|