@openfinclaw/findoo-datahub-plugin 2026.3.2 → 2026.3.10
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 +332 -14
- package/openclaw.plugin.json +2 -2
- 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 +339 -0
- package/test/e2e/l4-skill-tool-chain.live.test.ts +465 -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,255 @@
|
|
|
1
|
+
# Valuation Methodology Guide
|
|
2
|
+
|
|
3
|
+
Comprehensive reference for business valuation approaches including DCF analysis, comparable company analysis, and precedent transactions.
|
|
4
|
+
|
|
5
|
+
## 1. Discounted Cash Flow (DCF) Methodology
|
|
6
|
+
|
|
7
|
+
### Overview
|
|
8
|
+
|
|
9
|
+
DCF is an intrinsic valuation method that estimates the present value of a company's expected future free cash flows, discounted at an appropriate rate reflecting the risk of those cash flows.
|
|
10
|
+
|
|
11
|
+
**Core Principle:** The value of a business equals the present value of all future cash flows it will generate.
|
|
12
|
+
|
|
13
|
+
**Formula:**
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Enterprise Value = Sum of [FCF_t / (1 + WACC)^t] + Terminal Value / (1 + WACC)^n
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Where:
|
|
20
|
+
|
|
21
|
+
- FCF_t = Free Cash Flow in year t
|
|
22
|
+
- WACC = Weighted Average Cost of Capital
|
|
23
|
+
- n = number of projection years
|
|
24
|
+
|
|
25
|
+
### Step 1: Historical Analysis
|
|
26
|
+
|
|
27
|
+
Before projecting, analyze 3-5 years of historical financials:
|
|
28
|
+
|
|
29
|
+
- **Revenue growth rates** - Identify organic vs acquisition-driven growth
|
|
30
|
+
- **Margin trends** - Gross, operating, and net margin trajectories
|
|
31
|
+
- **Capital intensity** - CapEx as % of revenue
|
|
32
|
+
- **Working capital** - Cash conversion cycle trends
|
|
33
|
+
- **Free cash flow conversion** - FCF / Net Income ratio
|
|
34
|
+
|
|
35
|
+
### Step 2: Revenue Projections
|
|
36
|
+
|
|
37
|
+
**Approaches:**
|
|
38
|
+
|
|
39
|
+
1. **Top-down:** Market size x Market share x Pricing
|
|
40
|
+
2. **Bottom-up:** Units x Price, or Customers x ARPU
|
|
41
|
+
3. **Growth rate extrapolation:** Historical growth with decay
|
|
42
|
+
|
|
43
|
+
**Revenue Projection Best Practices:**
|
|
44
|
+
|
|
45
|
+
- Use 5-7 year explicit projection period
|
|
46
|
+
- Growth should converge toward GDP growth by terminal year
|
|
47
|
+
- Support assumptions with market data and management guidance
|
|
48
|
+
- Model revenue by segment/product line when possible
|
|
49
|
+
|
|
50
|
+
### Step 3: Free Cash Flow Calculation
|
|
51
|
+
|
|
52
|
+
**Unlevered Free Cash Flow (UFCF):**
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
UFCF = EBIT x (1 - Tax Rate)
|
|
56
|
+
+ Depreciation & Amortization
|
|
57
|
+
- Capital Expenditures
|
|
58
|
+
- Changes in Net Working Capital
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Key Drivers:**
|
|
62
|
+
|
|
63
|
+
- Operating margin trajectory
|
|
64
|
+
- CapEx as % of revenue (maintenance vs growth)
|
|
65
|
+
- Working capital requirements (DSO, DIO, DPO)
|
|
66
|
+
- Tax rate (effective vs marginal)
|
|
67
|
+
|
|
68
|
+
### Step 4: WACC Calculation
|
|
69
|
+
|
|
70
|
+
**Weighted Average Cost of Capital:**
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
WACC = (E/V x Re) + (D/V x Rd x (1 - T))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Where:
|
|
77
|
+
|
|
78
|
+
- E/V = Equity weight (market value)
|
|
79
|
+
- D/V = Debt weight (market value)
|
|
80
|
+
- Re = Cost of equity
|
|
81
|
+
- Rd = Cost of debt (pre-tax)
|
|
82
|
+
- T = Marginal tax rate
|
|
83
|
+
|
|
84
|
+
#### Cost of Equity (CAPM)
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Re = Rf + Beta x (Rm - Rf) + Size Premium + Company-Specific Risk
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| Component | Description | Typical Range |
|
|
91
|
+
| ------------------------- | ---------------------------------- | ------------- |
|
|
92
|
+
| Risk-Free Rate (Rf) | 10-year Treasury yield | 3.5% - 5.0% |
|
|
93
|
+
| Equity Risk Premium (ERP) | Market return above risk-free | 5.0% - 7.0% |
|
|
94
|
+
| Beta | Systematic risk relative to market | 0.5 - 2.0 |
|
|
95
|
+
| Size Premium | Small-cap additional risk | 0% - 5% |
|
|
96
|
+
| Company-Specific Risk | Unique risk factors | 0% - 5% |
|
|
97
|
+
|
|
98
|
+
**Beta Estimation:**
|
|
99
|
+
|
|
100
|
+
- Use 2-5 year weekly returns against broad market index
|
|
101
|
+
- Unlevered betas for comparability, then re-lever to target capital structure
|
|
102
|
+
- Consider industry median beta for stability
|
|
103
|
+
|
|
104
|
+
#### Cost of Debt
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
Rd = Yield on comparable-maturity corporate bonds
|
|
108
|
+
OR
|
|
109
|
+
Rd = Risk-Free Rate + Credit Spread
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Credit Spread by Rating:**
|
|
113
|
+
| Rating | Typical Spread |
|
|
114
|
+
|--------|---------------|
|
|
115
|
+
| AAA | 0.5% - 1.0% |
|
|
116
|
+
| AA | 1.0% - 1.5% |
|
|
117
|
+
| A | 1.5% - 2.0% |
|
|
118
|
+
| BBB | 2.0% - 3.0% |
|
|
119
|
+
| BB | 3.0% - 5.0% |
|
|
120
|
+
| B | 5.0% - 8.0% |
|
|
121
|
+
|
|
122
|
+
### Step 5: Terminal Value
|
|
123
|
+
|
|
124
|
+
Terminal value typically represents 60-80% of total enterprise value. Use two methods and cross-check.
|
|
125
|
+
|
|
126
|
+
#### Perpetuity Growth Method
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
TV = FCF_n x (1 + g) / (WACC - g)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Where g = terminal growth rate (typically 2.0% - 3.0%, should not exceed long-term GDP growth)
|
|
133
|
+
|
|
134
|
+
**Sensitivity:** Terminal value is highly sensitive to g. A 0.5% change in g can move enterprise value by 15-25%.
|
|
135
|
+
|
|
136
|
+
#### Exit Multiple Method
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
TV = Terminal Year EBITDA x Exit EV/EBITDA Multiple
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Exit Multiple Selection:**
|
|
143
|
+
|
|
144
|
+
- Use current trading multiples of comparable companies
|
|
145
|
+
- Consider whether current multiples are at historical highs/lows
|
|
146
|
+
- Apply a discount for lack of marketability if private
|
|
147
|
+
|
|
148
|
+
**Cross-Check:** Both methods should yield similar results. Large discrepancies signal inconsistent assumptions.
|
|
149
|
+
|
|
150
|
+
### Step 6: Enterprise to Equity Bridge
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
Enterprise Value
|
|
154
|
+
- Net Debt (Total Debt - Cash)
|
|
155
|
+
- Minority Interest
|
|
156
|
+
- Preferred Equity
|
|
157
|
+
+ Equity Method Investments
|
|
158
|
+
= Equity Value
|
|
159
|
+
|
|
160
|
+
Equity Value / Diluted Shares Outstanding = Value Per Share
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Step 7: Sensitivity Analysis
|
|
164
|
+
|
|
165
|
+
Always present results as a range, not a single point estimate.
|
|
166
|
+
|
|
167
|
+
**Standard Sensitivity Tables:**
|
|
168
|
+
|
|
169
|
+
1. WACC vs Terminal Growth Rate
|
|
170
|
+
2. WACC vs Exit Multiple
|
|
171
|
+
3. Revenue Growth vs Operating Margin
|
|
172
|
+
|
|
173
|
+
**Scenario Analysis:**
|
|
174
|
+
|
|
175
|
+
- Base case: Management guidance / consensus estimates
|
|
176
|
+
- Bull case: Upside scenario with faster growth or margin expansion
|
|
177
|
+
- Bear case: Downside scenario with slower growth or margin compression
|
|
178
|
+
|
|
179
|
+
## 2. Comparable Company Analysis
|
|
180
|
+
|
|
181
|
+
### Methodology
|
|
182
|
+
|
|
183
|
+
1. **Select peer group** - Similar size, industry, growth profile, and margins
|
|
184
|
+
2. **Calculate trading multiples** for each peer
|
|
185
|
+
3. **Determine appropriate multiple range**
|
|
186
|
+
4. **Apply to target company's metrics**
|
|
187
|
+
|
|
188
|
+
### Common Multiples
|
|
189
|
+
|
|
190
|
+
| Multiple | When to Use |
|
|
191
|
+
| ---------- | ------------------------------------------- |
|
|
192
|
+
| EV/Revenue | Pre-profit companies, high-growth tech |
|
|
193
|
+
| EV/EBITDA | Most common for mature companies |
|
|
194
|
+
| EV/EBIT | When D&A differs significantly across peers |
|
|
195
|
+
| P/E | Stable earnings, financial services |
|
|
196
|
+
| P/B | Banks, insurance, asset-heavy industries |
|
|
197
|
+
| EV/FCF | Capital-light businesses with clean FCF |
|
|
198
|
+
|
|
199
|
+
### Peer Selection Criteria
|
|
200
|
+
|
|
201
|
+
- **Industry:** Same or closely adjacent sectors
|
|
202
|
+
- **Size:** Within 0.5x to 2x of target revenue/market cap
|
|
203
|
+
- **Geography:** Same primary markets
|
|
204
|
+
- **Growth profile:** Similar revenue growth rates (within 5-10%)
|
|
205
|
+
- **Margin profile:** Similar operating margin structure
|
|
206
|
+
- **Business model:** Comparable revenue mix and customer base
|
|
207
|
+
|
|
208
|
+
### Premium/Discount Adjustments
|
|
209
|
+
|
|
210
|
+
| Factor | Adjustment |
|
|
211
|
+
| --------------- | ------------------------------------ |
|
|
212
|
+
| Higher growth | Premium of 1-3x on EV/EBITDA |
|
|
213
|
+
| Lower margins | Discount of 1-2x |
|
|
214
|
+
| Smaller scale | Discount of 10-20% |
|
|
215
|
+
| Private company | Discount of 15-30% (illiquidity) |
|
|
216
|
+
| Control premium | Premium of 20-40% (for acquisitions) |
|
|
217
|
+
|
|
218
|
+
## 3. Precedent Transaction Analysis
|
|
219
|
+
|
|
220
|
+
### Methodology
|
|
221
|
+
|
|
222
|
+
1. **Identify comparable transactions** in same industry
|
|
223
|
+
2. **Calculate transaction multiples** (EV/Revenue, EV/EBITDA)
|
|
224
|
+
3. **Adjust for market conditions** and deal-specific factors
|
|
225
|
+
4. **Apply adjusted multiples** to target
|
|
226
|
+
|
|
227
|
+
### Key Considerations
|
|
228
|
+
|
|
229
|
+
- Transactions include control premiums (typically 20-40%)
|
|
230
|
+
- Market conditions at time of deal affect multiples
|
|
231
|
+
- Strategic vs financial buyer valuations differ
|
|
232
|
+
- Consider synergy expectations embedded in price
|
|
233
|
+
- More recent transactions carry greater relevance
|
|
234
|
+
|
|
235
|
+
## 4. Valuation Framework Selection
|
|
236
|
+
|
|
237
|
+
| Situation | Primary Method | Secondary Method |
|
|
238
|
+
| ----------------------- | --------------------------------- | -------------------------- |
|
|
239
|
+
| Profitable, stable | DCF | Comparable companies |
|
|
240
|
+
| High growth, pre-profit | Comparable companies (EV/Revenue) | DCF with scenario analysis |
|
|
241
|
+
| M&A target | Precedent transactions | DCF |
|
|
242
|
+
| Asset-heavy, cyclical | Asset-based valuation | Normalized DCF |
|
|
243
|
+
| Financial institution | Dividend discount model | P/B, P/E comps |
|
|
244
|
+
| Distressed | Liquidation value | Restructured DCF |
|
|
245
|
+
|
|
246
|
+
## 5. Common Pitfalls
|
|
247
|
+
|
|
248
|
+
1. **Hockey stick projections** - Unrealistic growth acceleration in later years
|
|
249
|
+
2. **Terminal value dominance** - If TV > 80% of EV, shorten projection period or question assumptions
|
|
250
|
+
3. **Circular references** - WACC depends on equity value which depends on WACC
|
|
251
|
+
4. **Ignoring working capital** - Can significantly affect FCF
|
|
252
|
+
5. **Single-point estimates** - Always present as a range
|
|
253
|
+
6. **Stale comparables** - Market conditions change; update regularly
|
|
254
|
+
7. **Confirmation bias** - Don't work backward from a desired conclusion
|
|
255
|
+
8. **Ignoring dilution** - Use fully diluted shares (treasury stock method for options)
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Budget Variance Analyzer
|
|
4
|
+
|
|
5
|
+
Analyzes actual vs budget vs prior year performance with materiality
|
|
6
|
+
threshold filtering, favorable/unfavorable classification, and
|
|
7
|
+
department/category breakdown.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python budget_variance_analyzer.py budget_data.json
|
|
11
|
+
python budget_variance_analyzer.py budget_data.json --format json
|
|
12
|
+
python budget_variance_analyzer.py budget_data.json --threshold-pct 5 --threshold-amt 25000
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
|
|
22
|
+
"""Safely divide two numbers, returning default if denominator is zero."""
|
|
23
|
+
if denominator == 0 or denominator is None:
|
|
24
|
+
return default
|
|
25
|
+
return numerator / denominator
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BudgetVarianceAnalyzer:
|
|
29
|
+
"""Analyze budget variances with materiality filtering and classification."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
data: Dict[str, Any],
|
|
34
|
+
threshold_pct: float = 10.0,
|
|
35
|
+
threshold_amt: float = 50000.0,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Initialize the analyzer.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
data: Budget data with line items
|
|
42
|
+
threshold_pct: Materiality threshold as percentage (default 10%)
|
|
43
|
+
threshold_amt: Materiality threshold as dollar amount (default $50K)
|
|
44
|
+
"""
|
|
45
|
+
self.line_items: List[Dict[str, Any]] = data.get("line_items", [])
|
|
46
|
+
self.period: str = data.get("period", "Current Period")
|
|
47
|
+
self.company: str = data.get("company", "Company")
|
|
48
|
+
self.threshold_pct = threshold_pct
|
|
49
|
+
self.threshold_amt = threshold_amt
|
|
50
|
+
self.variances: List[Dict[str, Any]] = []
|
|
51
|
+
self.material_variances: List[Dict[str, Any]] = []
|
|
52
|
+
self.summary: Dict[str, Any] = {}
|
|
53
|
+
|
|
54
|
+
def classify_favorability(
|
|
55
|
+
self, line_type: str, variance_amount: float
|
|
56
|
+
) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Classify variance as favorable or unfavorable.
|
|
59
|
+
|
|
60
|
+
Revenue: over budget = favorable
|
|
61
|
+
Expense: under budget = favorable
|
|
62
|
+
"""
|
|
63
|
+
if line_type.lower() in ("revenue", "income", "sales"):
|
|
64
|
+
return "Favorable" if variance_amount > 0 else "Unfavorable"
|
|
65
|
+
else:
|
|
66
|
+
# For expenses, under budget (negative variance) is favorable
|
|
67
|
+
return "Favorable" if variance_amount < 0 else "Unfavorable"
|
|
68
|
+
|
|
69
|
+
def calculate_variances(self) -> List[Dict[str, Any]]:
|
|
70
|
+
"""Calculate variances for all line items."""
|
|
71
|
+
self.variances = []
|
|
72
|
+
|
|
73
|
+
for item in self.line_items:
|
|
74
|
+
name = item.get("name", "Unknown")
|
|
75
|
+
line_type = item.get("type", "expense")
|
|
76
|
+
department = item.get("department", "General")
|
|
77
|
+
category = item.get("category", "Other")
|
|
78
|
+
actual = item.get("actual", 0)
|
|
79
|
+
budget = item.get("budget", 0)
|
|
80
|
+
prior_year = item.get("prior_year", None)
|
|
81
|
+
|
|
82
|
+
# Budget variance
|
|
83
|
+
budget_var_amt = actual - budget
|
|
84
|
+
budget_var_pct = safe_divide(budget_var_amt, budget) * 100
|
|
85
|
+
|
|
86
|
+
# Prior year variance (if available)
|
|
87
|
+
py_var_amt = (actual - prior_year) if prior_year is not None else None
|
|
88
|
+
py_var_pct = (
|
|
89
|
+
safe_divide(py_var_amt, prior_year) * 100
|
|
90
|
+
if prior_year is not None
|
|
91
|
+
else None
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
favorability = self.classify_favorability(line_type, budget_var_amt)
|
|
95
|
+
|
|
96
|
+
is_material = (
|
|
97
|
+
abs(budget_var_pct) >= self.threshold_pct
|
|
98
|
+
or abs(budget_var_amt) >= self.threshold_amt
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
variance_record = {
|
|
102
|
+
"name": name,
|
|
103
|
+
"type": line_type,
|
|
104
|
+
"department": department,
|
|
105
|
+
"category": category,
|
|
106
|
+
"actual": actual,
|
|
107
|
+
"budget": budget,
|
|
108
|
+
"prior_year": prior_year,
|
|
109
|
+
"budget_variance_amount": budget_var_amt,
|
|
110
|
+
"budget_variance_pct": round(budget_var_pct, 2),
|
|
111
|
+
"prior_year_variance_amount": py_var_amt,
|
|
112
|
+
"prior_year_variance_pct": (
|
|
113
|
+
round(py_var_pct, 2) if py_var_pct is not None else None
|
|
114
|
+
),
|
|
115
|
+
"favorability": favorability,
|
|
116
|
+
"is_material": is_material,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
self.variances.append(variance_record)
|
|
120
|
+
|
|
121
|
+
# Filter material variances
|
|
122
|
+
self.material_variances = [v for v in self.variances if v["is_material"]]
|
|
123
|
+
|
|
124
|
+
return self.variances
|
|
125
|
+
|
|
126
|
+
def department_summary(self) -> Dict[str, Dict[str, Any]]:
|
|
127
|
+
"""Summarize variances by department."""
|
|
128
|
+
departments: Dict[str, Dict[str, float]] = {}
|
|
129
|
+
|
|
130
|
+
for v in self.variances:
|
|
131
|
+
dept = v["department"]
|
|
132
|
+
if dept not in departments:
|
|
133
|
+
departments[dept] = {
|
|
134
|
+
"total_actual": 0.0,
|
|
135
|
+
"total_budget": 0.0,
|
|
136
|
+
"total_variance": 0.0,
|
|
137
|
+
"favorable_count": 0,
|
|
138
|
+
"unfavorable_count": 0,
|
|
139
|
+
"line_count": 0,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
departments[dept]["total_actual"] += v["actual"]
|
|
143
|
+
departments[dept]["total_budget"] += v["budget"]
|
|
144
|
+
departments[dept]["total_variance"] += v["budget_variance_amount"]
|
|
145
|
+
departments[dept]["line_count"] += 1
|
|
146
|
+
if v["favorability"] == "Favorable":
|
|
147
|
+
departments[dept]["favorable_count"] += 1
|
|
148
|
+
else:
|
|
149
|
+
departments[dept]["unfavorable_count"] += 1
|
|
150
|
+
|
|
151
|
+
# Add variance percentage
|
|
152
|
+
for dept_data in departments.values():
|
|
153
|
+
dept_data["variance_pct"] = round(
|
|
154
|
+
safe_divide(
|
|
155
|
+
dept_data["total_variance"], dept_data["total_budget"]
|
|
156
|
+
)
|
|
157
|
+
* 100,
|
|
158
|
+
2,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return departments
|
|
162
|
+
|
|
163
|
+
def category_summary(self) -> Dict[str, Dict[str, Any]]:
|
|
164
|
+
"""Summarize variances by category."""
|
|
165
|
+
categories: Dict[str, Dict[str, float]] = {}
|
|
166
|
+
|
|
167
|
+
for v in self.variances:
|
|
168
|
+
cat = v["category"]
|
|
169
|
+
if cat not in categories:
|
|
170
|
+
categories[cat] = {
|
|
171
|
+
"total_actual": 0.0,
|
|
172
|
+
"total_budget": 0.0,
|
|
173
|
+
"total_variance": 0.0,
|
|
174
|
+
"line_count": 0,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
categories[cat]["total_actual"] += v["actual"]
|
|
178
|
+
categories[cat]["total_budget"] += v["budget"]
|
|
179
|
+
categories[cat]["total_variance"] += v["budget_variance_amount"]
|
|
180
|
+
categories[cat]["line_count"] += 1
|
|
181
|
+
|
|
182
|
+
for cat_data in categories.values():
|
|
183
|
+
cat_data["variance_pct"] = round(
|
|
184
|
+
safe_divide(
|
|
185
|
+
cat_data["total_variance"], cat_data["total_budget"]
|
|
186
|
+
)
|
|
187
|
+
* 100,
|
|
188
|
+
2,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return categories
|
|
192
|
+
|
|
193
|
+
def generate_executive_summary(self) -> Dict[str, Any]:
|
|
194
|
+
"""Generate an executive summary of the variance analysis."""
|
|
195
|
+
total_actual = sum(
|
|
196
|
+
v["actual"] for v in self.variances if v["type"].lower() in ("revenue", "income", "sales")
|
|
197
|
+
)
|
|
198
|
+
total_budget = sum(
|
|
199
|
+
v["budget"] for v in self.variances if v["type"].lower() in ("revenue", "income", "sales")
|
|
200
|
+
)
|
|
201
|
+
total_expense_actual = sum(
|
|
202
|
+
v["actual"] for v in self.variances if v["type"].lower() not in ("revenue", "income", "sales")
|
|
203
|
+
)
|
|
204
|
+
total_expense_budget = sum(
|
|
205
|
+
v["budget"] for v in self.variances if v["type"].lower() not in ("revenue", "income", "sales")
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
revenue_variance = total_actual - total_budget
|
|
209
|
+
expense_variance = total_expense_actual - total_expense_budget
|
|
210
|
+
|
|
211
|
+
favorable_count = sum(
|
|
212
|
+
1 for v in self.variances if v["favorability"] == "Favorable"
|
|
213
|
+
)
|
|
214
|
+
unfavorable_count = sum(
|
|
215
|
+
1 for v in self.variances if v["favorability"] == "Unfavorable"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
self.summary = {
|
|
219
|
+
"period": self.period,
|
|
220
|
+
"company": self.company,
|
|
221
|
+
"total_line_items": len(self.variances),
|
|
222
|
+
"material_variances_count": len(self.material_variances),
|
|
223
|
+
"favorable_count": favorable_count,
|
|
224
|
+
"unfavorable_count": unfavorable_count,
|
|
225
|
+
"revenue": {
|
|
226
|
+
"actual": total_actual,
|
|
227
|
+
"budget": total_budget,
|
|
228
|
+
"variance_amount": revenue_variance,
|
|
229
|
+
"variance_pct": round(
|
|
230
|
+
safe_divide(revenue_variance, total_budget) * 100, 2
|
|
231
|
+
),
|
|
232
|
+
},
|
|
233
|
+
"expenses": {
|
|
234
|
+
"actual": total_expense_actual,
|
|
235
|
+
"budget": total_expense_budget,
|
|
236
|
+
"variance_amount": expense_variance,
|
|
237
|
+
"variance_pct": round(
|
|
238
|
+
safe_divide(expense_variance, total_expense_budget) * 100, 2
|
|
239
|
+
),
|
|
240
|
+
},
|
|
241
|
+
"net_impact": revenue_variance - expense_variance,
|
|
242
|
+
"materiality_thresholds": {
|
|
243
|
+
"percentage": self.threshold_pct,
|
|
244
|
+
"amount": self.threshold_amt,
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return self.summary
|
|
249
|
+
|
|
250
|
+
def run_analysis(self) -> Dict[str, Any]:
|
|
251
|
+
"""Run the complete variance analysis."""
|
|
252
|
+
self.calculate_variances()
|
|
253
|
+
dept_summary = self.department_summary()
|
|
254
|
+
cat_summary = self.category_summary()
|
|
255
|
+
exec_summary = self.generate_executive_summary()
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
"executive_summary": exec_summary,
|
|
259
|
+
"all_variances": self.variances,
|
|
260
|
+
"material_variances": self.material_variances,
|
|
261
|
+
"department_summary": dept_summary,
|
|
262
|
+
"category_summary": cat_summary,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
def format_text(self, results: Dict[str, Any]) -> str:
|
|
266
|
+
"""Format results as human-readable text."""
|
|
267
|
+
lines: List[str] = []
|
|
268
|
+
lines.append("=" * 70)
|
|
269
|
+
lines.append("BUDGET VARIANCE ANALYSIS")
|
|
270
|
+
lines.append("=" * 70)
|
|
271
|
+
|
|
272
|
+
summary = results["executive_summary"]
|
|
273
|
+
lines.append(f"\n Company: {summary['company']}")
|
|
274
|
+
lines.append(f" Period: {summary['period']}")
|
|
275
|
+
|
|
276
|
+
def fmt_money(val: float) -> str:
|
|
277
|
+
sign = "+" if val > 0 else ""
|
|
278
|
+
if abs(val) >= 1e6:
|
|
279
|
+
return f"{sign}${val / 1e6:,.2f}M"
|
|
280
|
+
if abs(val) >= 1e3:
|
|
281
|
+
return f"{sign}${val / 1e3:,.1f}K"
|
|
282
|
+
return f"{sign}${val:,.2f}"
|
|
283
|
+
|
|
284
|
+
lines.append(f"\n--- EXECUTIVE SUMMARY ---")
|
|
285
|
+
rev = summary["revenue"]
|
|
286
|
+
exp = summary["expenses"]
|
|
287
|
+
lines.append(
|
|
288
|
+
f" Revenue: Actual {fmt_money(rev['actual'])} vs "
|
|
289
|
+
f"Budget {fmt_money(rev['budget'])} "
|
|
290
|
+
f"({fmt_money(rev['variance_amount'])}, {rev['variance_pct']:+.1f}%)"
|
|
291
|
+
)
|
|
292
|
+
lines.append(
|
|
293
|
+
f" Expenses: Actual {fmt_money(exp['actual'])} vs "
|
|
294
|
+
f"Budget {fmt_money(exp['budget'])} "
|
|
295
|
+
f"({fmt_money(exp['variance_amount'])}, {exp['variance_pct']:+.1f}%)"
|
|
296
|
+
)
|
|
297
|
+
lines.append(f" Net Impact: {fmt_money(summary['net_impact'])}")
|
|
298
|
+
lines.append(
|
|
299
|
+
f" Total Items: {summary['total_line_items']} | "
|
|
300
|
+
f"Material: {summary['material_variances_count']} | "
|
|
301
|
+
f"Favorable: {summary['favorable_count']} | "
|
|
302
|
+
f"Unfavorable: {summary['unfavorable_count']}"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Material variances
|
|
306
|
+
material = results["material_variances"]
|
|
307
|
+
if material:
|
|
308
|
+
lines.append(f"\n--- MATERIAL VARIANCES ---")
|
|
309
|
+
lines.append(
|
|
310
|
+
f" (Threshold: {self.threshold_pct}% or "
|
|
311
|
+
f"${self.threshold_amt:,.0f})"
|
|
312
|
+
)
|
|
313
|
+
for v in material:
|
|
314
|
+
lines.append(
|
|
315
|
+
f"\n {v['name']} ({v['department']})"
|
|
316
|
+
)
|
|
317
|
+
lines.append(
|
|
318
|
+
f" Actual: {fmt_money(v['actual'])} | "
|
|
319
|
+
f"Budget: {fmt_money(v['budget'])}"
|
|
320
|
+
)
|
|
321
|
+
lines.append(
|
|
322
|
+
f" Variance: {fmt_money(v['budget_variance_amount'])} "
|
|
323
|
+
f"({v['budget_variance_pct']:+.1f}%) - {v['favorability']}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Department summary
|
|
327
|
+
dept = results["department_summary"]
|
|
328
|
+
if dept:
|
|
329
|
+
lines.append(f"\n--- DEPARTMENT SUMMARY ---")
|
|
330
|
+
for dept_name, d in dept.items():
|
|
331
|
+
lines.append(
|
|
332
|
+
f" {dept_name}: Variance {fmt_money(d['total_variance'])} "
|
|
333
|
+
f"({d['variance_pct']:+.1f}%) | "
|
|
334
|
+
f"Fav: {d['favorable_count']} / Unfav: {d['unfavorable_count']}"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Category summary
|
|
338
|
+
cat = results["category_summary"]
|
|
339
|
+
if cat:
|
|
340
|
+
lines.append(f"\n--- CATEGORY SUMMARY ---")
|
|
341
|
+
for cat_name, c in cat.items():
|
|
342
|
+
lines.append(
|
|
343
|
+
f" {cat_name}: Variance {fmt_money(c['total_variance'])} "
|
|
344
|
+
f"({c['variance_pct']:+.1f}%)"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
lines.append("\n" + "=" * 70)
|
|
348
|
+
return "\n".join(lines)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main() -> None:
|
|
352
|
+
"""Main entry point."""
|
|
353
|
+
parser = argparse.ArgumentParser(
|
|
354
|
+
description="Analyze budget variances with materiality filtering"
|
|
355
|
+
)
|
|
356
|
+
parser.add_argument(
|
|
357
|
+
"input_file",
|
|
358
|
+
help="Path to JSON file with budget data",
|
|
359
|
+
)
|
|
360
|
+
parser.add_argument(
|
|
361
|
+
"--format",
|
|
362
|
+
choices=["text", "json"],
|
|
363
|
+
default="text",
|
|
364
|
+
help="Output format (default: text)",
|
|
365
|
+
)
|
|
366
|
+
parser.add_argument(
|
|
367
|
+
"--threshold-pct",
|
|
368
|
+
type=float,
|
|
369
|
+
default=10.0,
|
|
370
|
+
help="Materiality threshold percentage (default: 10)",
|
|
371
|
+
)
|
|
372
|
+
parser.add_argument(
|
|
373
|
+
"--threshold-amt",
|
|
374
|
+
type=float,
|
|
375
|
+
default=50000.0,
|
|
376
|
+
help="Materiality threshold dollar amount (default: 50000)",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
args = parser.parse_args()
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
with open(args.input_file, "r") as f:
|
|
383
|
+
data = json.load(f)
|
|
384
|
+
except FileNotFoundError:
|
|
385
|
+
print(f"Error: File '{args.input_file}' not found.", file=sys.stderr)
|
|
386
|
+
sys.exit(1)
|
|
387
|
+
except json.JSONDecodeError as e:
|
|
388
|
+
print(f"Error: Invalid JSON in '{args.input_file}': {e}", file=sys.stderr)
|
|
389
|
+
sys.exit(1)
|
|
390
|
+
|
|
391
|
+
analyzer = BudgetVarianceAnalyzer(
|
|
392
|
+
data,
|
|
393
|
+
threshold_pct=args.threshold_pct,
|
|
394
|
+
threshold_amt=args.threshold_amt,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
results = analyzer.run_analysis()
|
|
398
|
+
|
|
399
|
+
if args.format == "json":
|
|
400
|
+
print(json.dumps(results, indent=2))
|
|
401
|
+
else:
|
|
402
|
+
print(analyzer.format_text(results))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
main()
|