@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,494 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Forecast Builder
|
|
4
|
+
|
|
5
|
+
Driver-based revenue forecasting with 13-week rolling cash flow projection,
|
|
6
|
+
scenario modeling (base/bull/bear), and trend analysis using simple linear
|
|
7
|
+
regression (standard library only).
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python forecast_builder.py forecast_data.json
|
|
11
|
+
python forecast_builder.py forecast_data.json --format json
|
|
12
|
+
python forecast_builder.py forecast_data.json --scenarios base,bull,bear
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import sys
|
|
19
|
+
from statistics import mean
|
|
20
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
|
|
24
|
+
"""Safely divide two numbers, returning default if denominator is zero."""
|
|
25
|
+
if denominator == 0 or denominator is None:
|
|
26
|
+
return default
|
|
27
|
+
return numerator / denominator
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def simple_linear_regression(
|
|
31
|
+
x_values: List[float], y_values: List[float]
|
|
32
|
+
) -> Tuple[float, float, float]:
|
|
33
|
+
"""
|
|
34
|
+
Simple linear regression using standard library.
|
|
35
|
+
|
|
36
|
+
Returns (slope, intercept, r_squared).
|
|
37
|
+
"""
|
|
38
|
+
n = len(x_values)
|
|
39
|
+
if n < 2 or n != len(y_values):
|
|
40
|
+
return (0.0, 0.0, 0.0)
|
|
41
|
+
|
|
42
|
+
x_mean = mean(x_values)
|
|
43
|
+
y_mean = mean(y_values)
|
|
44
|
+
|
|
45
|
+
ss_xy = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values))
|
|
46
|
+
ss_xx = sum((x - x_mean) ** 2 for x in x_values)
|
|
47
|
+
ss_yy = sum((y - y_mean) ** 2 for y in y_values)
|
|
48
|
+
|
|
49
|
+
slope = safe_divide(ss_xy, ss_xx)
|
|
50
|
+
intercept = y_mean - slope * x_mean
|
|
51
|
+
|
|
52
|
+
# R-squared
|
|
53
|
+
r_squared = safe_divide(ss_xy ** 2, ss_xx * ss_yy) if ss_yy > 0 else 0.0
|
|
54
|
+
|
|
55
|
+
return (slope, intercept, r_squared)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ForecastBuilder:
|
|
59
|
+
"""Driver-based revenue forecasting with scenario modeling."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, data: Dict[str, Any]) -> None:
|
|
62
|
+
"""Initialize the forecast builder."""
|
|
63
|
+
self.historical: List[Dict[str, Any]] = data.get("historical_periods", [])
|
|
64
|
+
self.drivers: Dict[str, Any] = data.get("drivers", {})
|
|
65
|
+
self.assumptions: Dict[str, Any] = data.get("assumptions", {})
|
|
66
|
+
self.cash_flow_inputs: Dict[str, Any] = data.get("cash_flow_inputs", {})
|
|
67
|
+
self.scenarios_config: Dict[str, Any] = data.get("scenarios", {})
|
|
68
|
+
self.forecast_periods: int = data.get("forecast_periods", 12)
|
|
69
|
+
|
|
70
|
+
def analyze_trends(self) -> Dict[str, Any]:
|
|
71
|
+
"""Analyze historical trends using linear regression."""
|
|
72
|
+
if not self.historical:
|
|
73
|
+
return {"error": "No historical data available"}
|
|
74
|
+
|
|
75
|
+
# Extract revenue series
|
|
76
|
+
revenues = [p.get("revenue", 0) for p in self.historical]
|
|
77
|
+
periods = list(range(1, len(revenues) + 1))
|
|
78
|
+
|
|
79
|
+
slope, intercept, r_squared = simple_linear_regression(
|
|
80
|
+
[float(x) for x in periods],
|
|
81
|
+
[float(y) for y in revenues],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Calculate growth rates
|
|
85
|
+
growth_rates = []
|
|
86
|
+
for i in range(1, len(revenues)):
|
|
87
|
+
if revenues[i - 1] > 0:
|
|
88
|
+
growth = (revenues[i] - revenues[i - 1]) / revenues[i - 1]
|
|
89
|
+
growth_rates.append(growth)
|
|
90
|
+
|
|
91
|
+
avg_growth = mean(growth_rates) if growth_rates else 0.0
|
|
92
|
+
|
|
93
|
+
# Seasonality detection (if enough data)
|
|
94
|
+
seasonality_index: List[float] = []
|
|
95
|
+
if len(revenues) >= 4:
|
|
96
|
+
overall_avg = mean(revenues)
|
|
97
|
+
if overall_avg > 0:
|
|
98
|
+
seasonality_index = [r / overall_avg for r in revenues[-4:]]
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"trend": {
|
|
102
|
+
"slope": round(slope, 2),
|
|
103
|
+
"intercept": round(intercept, 2),
|
|
104
|
+
"r_squared": round(r_squared, 4),
|
|
105
|
+
"direction": "upward" if slope > 0 else "downward" if slope < 0 else "flat",
|
|
106
|
+
},
|
|
107
|
+
"growth_rates": [round(g, 4) for g in growth_rates],
|
|
108
|
+
"average_growth_rate": round(avg_growth, 4),
|
|
109
|
+
"seasonality_index": [round(s, 4) for s in seasonality_index],
|
|
110
|
+
"historical_revenues": revenues,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def build_driver_based_forecast(
|
|
114
|
+
self, scenario: str = "base"
|
|
115
|
+
) -> Dict[str, Any]:
|
|
116
|
+
"""
|
|
117
|
+
Build a driver-based revenue forecast.
|
|
118
|
+
|
|
119
|
+
Drivers may include: units, price, customers, ARPU, conversion rate, etc.
|
|
120
|
+
"""
|
|
121
|
+
scenario_adjustments = self.scenarios_config.get(scenario, {})
|
|
122
|
+
growth_adjustment = scenario_adjustments.get("growth_adjustment", 0.0)
|
|
123
|
+
margin_adjustment = scenario_adjustments.get("margin_adjustment", 0.0)
|
|
124
|
+
|
|
125
|
+
base_revenue = 0.0
|
|
126
|
+
if self.historical:
|
|
127
|
+
base_revenue = self.historical[-1].get("revenue", 0)
|
|
128
|
+
|
|
129
|
+
# Driver-based calculation
|
|
130
|
+
unit_drivers = self.drivers.get("units", {})
|
|
131
|
+
price_drivers = self.drivers.get("pricing", {})
|
|
132
|
+
customer_drivers = self.drivers.get("customers", {})
|
|
133
|
+
|
|
134
|
+
base_growth = self.assumptions.get("revenue_growth_rate", 0.05)
|
|
135
|
+
adjusted_growth = base_growth + growth_adjustment
|
|
136
|
+
|
|
137
|
+
base_margin = self.assumptions.get("gross_margin", 0.40)
|
|
138
|
+
adjusted_margin = base_margin + margin_adjustment
|
|
139
|
+
|
|
140
|
+
cogs_pct = 1.0 - adjusted_margin
|
|
141
|
+
opex_pct = self.assumptions.get("opex_pct_revenue", 0.25)
|
|
142
|
+
|
|
143
|
+
forecast_periods: List[Dict[str, Any]] = []
|
|
144
|
+
current_revenue = base_revenue
|
|
145
|
+
|
|
146
|
+
# If we have unit and price drivers, use them
|
|
147
|
+
has_unit_drivers = bool(unit_drivers) and bool(price_drivers)
|
|
148
|
+
|
|
149
|
+
if has_unit_drivers:
|
|
150
|
+
base_units = unit_drivers.get("base_units", 1000)
|
|
151
|
+
unit_growth = unit_drivers.get("growth_rate", 0.03) + growth_adjustment
|
|
152
|
+
base_price = price_drivers.get("base_price", 100)
|
|
153
|
+
price_growth = price_drivers.get("annual_increase", 0.02)
|
|
154
|
+
|
|
155
|
+
current_units = base_units
|
|
156
|
+
current_price = base_price
|
|
157
|
+
|
|
158
|
+
for period in range(1, self.forecast_periods + 1):
|
|
159
|
+
current_units = current_units * (1 + unit_growth / 12)
|
|
160
|
+
if period % 12 == 0:
|
|
161
|
+
current_price = current_price * (1 + price_growth)
|
|
162
|
+
|
|
163
|
+
period_revenue = current_units * current_price
|
|
164
|
+
cogs = period_revenue * cogs_pct
|
|
165
|
+
gross_profit = period_revenue - cogs
|
|
166
|
+
opex = period_revenue * opex_pct
|
|
167
|
+
operating_income = gross_profit - opex
|
|
168
|
+
|
|
169
|
+
forecast_periods.append({
|
|
170
|
+
"period": period,
|
|
171
|
+
"revenue": round(period_revenue, 2),
|
|
172
|
+
"units": round(current_units, 0),
|
|
173
|
+
"price": round(current_price, 2),
|
|
174
|
+
"cogs": round(cogs, 2),
|
|
175
|
+
"gross_profit": round(gross_profit, 2),
|
|
176
|
+
"gross_margin": round(adjusted_margin, 4),
|
|
177
|
+
"opex": round(opex, 2),
|
|
178
|
+
"operating_income": round(operating_income, 2),
|
|
179
|
+
})
|
|
180
|
+
else:
|
|
181
|
+
# Simple growth-based forecast
|
|
182
|
+
monthly_growth = (1 + adjusted_growth) ** (1 / 12) - 1
|
|
183
|
+
|
|
184
|
+
for period in range(1, self.forecast_periods + 1):
|
|
185
|
+
current_revenue = current_revenue * (1 + monthly_growth)
|
|
186
|
+
cogs = current_revenue * cogs_pct
|
|
187
|
+
gross_profit = current_revenue - cogs
|
|
188
|
+
opex = current_revenue * opex_pct
|
|
189
|
+
operating_income = gross_profit - opex
|
|
190
|
+
|
|
191
|
+
forecast_periods.append({
|
|
192
|
+
"period": period,
|
|
193
|
+
"revenue": round(current_revenue, 2),
|
|
194
|
+
"cogs": round(cogs, 2),
|
|
195
|
+
"gross_profit": round(gross_profit, 2),
|
|
196
|
+
"gross_margin": round(adjusted_margin, 4),
|
|
197
|
+
"opex": round(opex, 2),
|
|
198
|
+
"operating_income": round(operating_income, 2),
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
total_revenue = sum(p["revenue"] for p in forecast_periods)
|
|
202
|
+
total_operating_income = sum(p["operating_income"] for p in forecast_periods)
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
"scenario": scenario,
|
|
206
|
+
"growth_rate": round(adjusted_growth, 4),
|
|
207
|
+
"gross_margin": round(adjusted_margin, 4),
|
|
208
|
+
"forecast_periods": forecast_periods,
|
|
209
|
+
"total_revenue": round(total_revenue, 2),
|
|
210
|
+
"total_operating_income": round(total_operating_income, 2),
|
|
211
|
+
"average_monthly_revenue": round(
|
|
212
|
+
safe_divide(total_revenue, len(forecast_periods)), 2
|
|
213
|
+
),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def build_rolling_cash_flow(self, weeks: int = 13) -> Dict[str, Any]:
|
|
217
|
+
"""Build a 13-week rolling cash flow projection."""
|
|
218
|
+
cfi = self.cash_flow_inputs
|
|
219
|
+
|
|
220
|
+
opening_balance = cfi.get("opening_cash_balance", 0)
|
|
221
|
+
weekly_revenue = cfi.get("weekly_revenue", 0)
|
|
222
|
+
collection_rate = cfi.get("collection_rate", 0.85)
|
|
223
|
+
collection_lag_weeks = cfi.get("collection_lag_weeks", 2)
|
|
224
|
+
|
|
225
|
+
# Weekly expenses
|
|
226
|
+
weekly_payroll = cfi.get("weekly_payroll", 0)
|
|
227
|
+
weekly_rent = cfi.get("weekly_rent", 0)
|
|
228
|
+
weekly_operating = cfi.get("weekly_operating", 0)
|
|
229
|
+
weekly_other = cfi.get("weekly_other", 0)
|
|
230
|
+
total_weekly_expenses = weekly_payroll + weekly_rent + weekly_operating + weekly_other
|
|
231
|
+
|
|
232
|
+
# One-time items
|
|
233
|
+
one_time_items: List[Dict[str, Any]] = cfi.get("one_time_items", [])
|
|
234
|
+
|
|
235
|
+
weekly_projections: List[Dict[str, Any]] = []
|
|
236
|
+
running_balance = opening_balance
|
|
237
|
+
|
|
238
|
+
# Revenue pipeline for lagged collections
|
|
239
|
+
revenue_pipeline: List[float] = [0.0] * collection_lag_weeks
|
|
240
|
+
|
|
241
|
+
for week in range(1, weeks + 1):
|
|
242
|
+
# Revenue collections (lagged)
|
|
243
|
+
revenue_pipeline.append(weekly_revenue)
|
|
244
|
+
collections = revenue_pipeline.pop(0) * collection_rate
|
|
245
|
+
|
|
246
|
+
# One-time items for this week
|
|
247
|
+
one_time_inflows = 0.0
|
|
248
|
+
one_time_outflows = 0.0
|
|
249
|
+
one_time_labels: List[str] = []
|
|
250
|
+
for item in one_time_items:
|
|
251
|
+
if item.get("week") == week:
|
|
252
|
+
amount = item.get("amount", 0)
|
|
253
|
+
if amount > 0:
|
|
254
|
+
one_time_inflows += amount
|
|
255
|
+
else:
|
|
256
|
+
one_time_outflows += abs(amount)
|
|
257
|
+
one_time_labels.append(item.get("description", ""))
|
|
258
|
+
|
|
259
|
+
total_inflows = collections + one_time_inflows
|
|
260
|
+
total_outflows = total_weekly_expenses + one_time_outflows
|
|
261
|
+
net_cash_flow = total_inflows - total_outflows
|
|
262
|
+
running_balance += net_cash_flow
|
|
263
|
+
|
|
264
|
+
weekly_projections.append({
|
|
265
|
+
"week": week,
|
|
266
|
+
"collections": round(collections, 2),
|
|
267
|
+
"one_time_inflows": round(one_time_inflows, 2),
|
|
268
|
+
"total_inflows": round(total_inflows, 2),
|
|
269
|
+
"payroll": round(weekly_payroll, 2),
|
|
270
|
+
"rent": round(weekly_rent, 2),
|
|
271
|
+
"operating": round(weekly_operating, 2),
|
|
272
|
+
"other_expenses": round(weekly_other, 2),
|
|
273
|
+
"one_time_outflows": round(one_time_outflows, 2),
|
|
274
|
+
"total_outflows": round(total_outflows, 2),
|
|
275
|
+
"net_cash_flow": round(net_cash_flow, 2),
|
|
276
|
+
"closing_balance": round(running_balance, 2),
|
|
277
|
+
"notes": ", ".join(one_time_labels) if one_time_labels else "",
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
# Summary
|
|
281
|
+
total_inflows = sum(w["total_inflows"] for w in weekly_projections)
|
|
282
|
+
total_outflows = sum(w["total_outflows"] for w in weekly_projections)
|
|
283
|
+
min_balance = min(w["closing_balance"] for w in weekly_projections)
|
|
284
|
+
min_balance_week = next(
|
|
285
|
+
w["week"]
|
|
286
|
+
for w in weekly_projections
|
|
287
|
+
if w["closing_balance"] == min_balance
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
"weeks": weeks,
|
|
292
|
+
"opening_balance": opening_balance,
|
|
293
|
+
"closing_balance": round(running_balance, 2),
|
|
294
|
+
"total_inflows": round(total_inflows, 2),
|
|
295
|
+
"total_outflows": round(total_outflows, 2),
|
|
296
|
+
"net_change": round(total_inflows - total_outflows, 2),
|
|
297
|
+
"minimum_balance": round(min_balance, 2),
|
|
298
|
+
"minimum_balance_week": min_balance_week,
|
|
299
|
+
"cash_runway_weeks": (
|
|
300
|
+
round(safe_divide(running_balance, total_weekly_expenses))
|
|
301
|
+
if total_weekly_expenses > 0
|
|
302
|
+
else None
|
|
303
|
+
),
|
|
304
|
+
"weekly_projections": weekly_projections,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def build_scenario_comparison(
|
|
308
|
+
self, scenarios: Optional[List[str]] = None
|
|
309
|
+
) -> Dict[str, Any]:
|
|
310
|
+
"""Build and compare multiple scenarios."""
|
|
311
|
+
if scenarios is None:
|
|
312
|
+
scenarios = ["base", "bull", "bear"]
|
|
313
|
+
|
|
314
|
+
scenario_results: Dict[str, Any] = {}
|
|
315
|
+
|
|
316
|
+
for scenario in scenarios:
|
|
317
|
+
scenario_results[scenario] = self.build_driver_based_forecast(scenario)
|
|
318
|
+
|
|
319
|
+
# Comparison summary
|
|
320
|
+
comparison: List[Dict[str, Any]] = []
|
|
321
|
+
for scenario in scenarios:
|
|
322
|
+
result = scenario_results[scenario]
|
|
323
|
+
comparison.append({
|
|
324
|
+
"scenario": scenario,
|
|
325
|
+
"total_revenue": result["total_revenue"],
|
|
326
|
+
"total_operating_income": result["total_operating_income"],
|
|
327
|
+
"growth_rate": result["growth_rate"],
|
|
328
|
+
"gross_margin": result["gross_margin"],
|
|
329
|
+
"avg_monthly_revenue": result["average_monthly_revenue"],
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
"scenarios": scenario_results,
|
|
334
|
+
"comparison": comparison,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
def run_full_forecast(
|
|
338
|
+
self, scenarios: Optional[List[str]] = None
|
|
339
|
+
) -> Dict[str, Any]:
|
|
340
|
+
"""Run the complete forecast analysis."""
|
|
341
|
+
trends = self.analyze_trends()
|
|
342
|
+
scenario_comparison = self.build_scenario_comparison(scenarios)
|
|
343
|
+
cash_flow = self.build_rolling_cash_flow()
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"trend_analysis": trends,
|
|
347
|
+
"scenario_comparison": scenario_comparison,
|
|
348
|
+
"rolling_cash_flow": cash_flow,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
def format_text(self, results: Dict[str, Any]) -> str:
|
|
352
|
+
"""Format forecast results as human-readable text."""
|
|
353
|
+
lines: List[str] = []
|
|
354
|
+
lines.append("=" * 70)
|
|
355
|
+
lines.append("FINANCIAL FORECAST REPORT")
|
|
356
|
+
lines.append("=" * 70)
|
|
357
|
+
|
|
358
|
+
def fmt_money(val: float) -> str:
|
|
359
|
+
if abs(val) >= 1e9:
|
|
360
|
+
return f"${val / 1e9:,.2f}B"
|
|
361
|
+
if abs(val) >= 1e6:
|
|
362
|
+
return f"${val / 1e6:,.2f}M"
|
|
363
|
+
if abs(val) >= 1e3:
|
|
364
|
+
return f"${val / 1e3:,.1f}K"
|
|
365
|
+
return f"${val:,.2f}"
|
|
366
|
+
|
|
367
|
+
# Trend Analysis
|
|
368
|
+
trend = results["trend_analysis"]
|
|
369
|
+
if "error" not in trend:
|
|
370
|
+
lines.append(f"\n--- TREND ANALYSIS ---")
|
|
371
|
+
t = trend["trend"]
|
|
372
|
+
lines.append(f" Direction: {t['direction']}")
|
|
373
|
+
lines.append(f" R-squared: {t['r_squared']:.4f}")
|
|
374
|
+
lines.append(
|
|
375
|
+
f" Average Historical Growth: "
|
|
376
|
+
f"{trend['average_growth_rate'] * 100:.1f}%"
|
|
377
|
+
)
|
|
378
|
+
if trend["seasonality_index"]:
|
|
379
|
+
lines.append(
|
|
380
|
+
f" Seasonality Index (last 4): "
|
|
381
|
+
f"{', '.join(f'{s:.2f}' for s in trend['seasonality_index'])}"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Scenario Comparison
|
|
385
|
+
comp = results["scenario_comparison"]["comparison"]
|
|
386
|
+
lines.append(f"\n--- SCENARIO COMPARISON ---")
|
|
387
|
+
lines.append(
|
|
388
|
+
f" {'Scenario':<10s} {'Revenue':>14s} {'Op. Income':>14s} "
|
|
389
|
+
f"{'Growth':>8s} {'Margin':>8s}"
|
|
390
|
+
)
|
|
391
|
+
lines.append(" " + "-" * 62)
|
|
392
|
+
for c in comp:
|
|
393
|
+
lines.append(
|
|
394
|
+
f" {c['scenario']:<10s} {fmt_money(c['total_revenue']):>14s} "
|
|
395
|
+
f"{fmt_money(c['total_operating_income']):>14s} "
|
|
396
|
+
f"{c['growth_rate'] * 100:>7.1f}% "
|
|
397
|
+
f"{c['gross_margin'] * 100:>7.1f}%"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Base scenario detail
|
|
401
|
+
base = results["scenario_comparison"]["scenarios"].get("base", {})
|
|
402
|
+
if base and base.get("forecast_periods"):
|
|
403
|
+
lines.append(f"\n--- BASE CASE MONTHLY FORECAST ---")
|
|
404
|
+
lines.append(
|
|
405
|
+
f" {'Period':>6s} {'Revenue':>12s} {'Gross Profit':>12s} "
|
|
406
|
+
f"{'Op. Income':>12s}"
|
|
407
|
+
)
|
|
408
|
+
lines.append(" " + "-" * 48)
|
|
409
|
+
for p in base["forecast_periods"]:
|
|
410
|
+
lines.append(
|
|
411
|
+
f" {p['period']:>6d} {fmt_money(p['revenue']):>12s} "
|
|
412
|
+
f"{fmt_money(p['gross_profit']):>12s} "
|
|
413
|
+
f"{fmt_money(p['operating_income']):>12s}"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Cash Flow
|
|
417
|
+
cf = results["rolling_cash_flow"]
|
|
418
|
+
lines.append(f"\n--- 13-WEEK ROLLING CASH FLOW ---")
|
|
419
|
+
lines.append(f" Opening Balance: {fmt_money(cf['opening_balance'])}")
|
|
420
|
+
lines.append(f" Closing Balance: {fmt_money(cf['closing_balance'])}")
|
|
421
|
+
lines.append(f" Net Change: {fmt_money(cf['net_change'])}")
|
|
422
|
+
lines.append(
|
|
423
|
+
f" Minimum Balance: {fmt_money(cf['minimum_balance'])} "
|
|
424
|
+
f"(Week {cf['minimum_balance_week']})"
|
|
425
|
+
)
|
|
426
|
+
if cf.get("cash_runway_weeks"):
|
|
427
|
+
lines.append(f" Cash Runway: {cf['cash_runway_weeks']:.0f} weeks")
|
|
428
|
+
|
|
429
|
+
lines.append(f"\n Weekly Detail:")
|
|
430
|
+
lines.append(
|
|
431
|
+
f" {'Wk':>3s} {'Inflows':>10s} {'Outflows':>10s} "
|
|
432
|
+
f"{'Net':>10s} {'Balance':>12s}"
|
|
433
|
+
)
|
|
434
|
+
lines.append(" " + "-" * 50)
|
|
435
|
+
for w in cf["weekly_projections"]:
|
|
436
|
+
notes = f" {w['notes']}" if w["notes"] else ""
|
|
437
|
+
lines.append(
|
|
438
|
+
f" {w['week']:>3d} {fmt_money(w['total_inflows']):>10s} "
|
|
439
|
+
f"{fmt_money(w['total_outflows']):>10s} "
|
|
440
|
+
f"{fmt_money(w['net_cash_flow']):>10s} "
|
|
441
|
+
f"{fmt_money(w['closing_balance']):>12s}{notes}"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
lines.append("\n" + "=" * 70)
|
|
445
|
+
return "\n".join(lines)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def main() -> None:
|
|
449
|
+
"""Main entry point."""
|
|
450
|
+
parser = argparse.ArgumentParser(
|
|
451
|
+
description="Driver-based revenue forecasting with scenario modeling"
|
|
452
|
+
)
|
|
453
|
+
parser.add_argument(
|
|
454
|
+
"input_file",
|
|
455
|
+
help="Path to JSON file with forecast data",
|
|
456
|
+
)
|
|
457
|
+
parser.add_argument(
|
|
458
|
+
"--format",
|
|
459
|
+
choices=["text", "json"],
|
|
460
|
+
default="text",
|
|
461
|
+
help="Output format (default: text)",
|
|
462
|
+
)
|
|
463
|
+
parser.add_argument(
|
|
464
|
+
"--scenarios",
|
|
465
|
+
type=str,
|
|
466
|
+
default="base,bull,bear",
|
|
467
|
+
help="Comma-separated list of scenarios (default: base,bull,bear)",
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
args = parser.parse_args()
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
with open(args.input_file, "r") as f:
|
|
474
|
+
data = json.load(f)
|
|
475
|
+
except FileNotFoundError:
|
|
476
|
+
print(f"Error: File '{args.input_file}' not found.", file=sys.stderr)
|
|
477
|
+
sys.exit(1)
|
|
478
|
+
except json.JSONDecodeError as e:
|
|
479
|
+
print(f"Error: Invalid JSON in '{args.input_file}': {e}", file=sys.stderr)
|
|
480
|
+
sys.exit(1)
|
|
481
|
+
|
|
482
|
+
builder = ForecastBuilder(data)
|
|
483
|
+
scenarios = [s.strip() for s in args.scenarios.split(",")]
|
|
484
|
+
|
|
485
|
+
results = builder.run_full_forecast(scenarios)
|
|
486
|
+
|
|
487
|
+
if args.format == "json":
|
|
488
|
+
print(json.dumps(results, indent=2))
|
|
489
|
+
else:
|
|
490
|
+
print(builder.format_text(results))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
if __name__ == "__main__":
|
|
494
|
+
main()
|