@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.
Files changed (67) hide show
  1. package/DESIGN.md +492 -151
  2. package/_vendor/claude-skills-finance/SKILL.md +192 -0
  3. package/_vendor/claude-skills-finance/assets/dcf_analysis_template.md +184 -0
  4. package/_vendor/claude-skills-finance/assets/expected_output.json +161 -0
  5. package/_vendor/claude-skills-finance/assets/forecast_report_template.md +177 -0
  6. package/_vendor/claude-skills-finance/assets/sample_financial_data.json +219 -0
  7. package/_vendor/claude-skills-finance/assets/variance_report_template.md +122 -0
  8. package/_vendor/claude-skills-finance/references/financial-ratios-guide.md +396 -0
  9. package/_vendor/claude-skills-finance/references/forecasting-best-practices.md +294 -0
  10. package/_vendor/claude-skills-finance/references/valuation-methodology.md +255 -0
  11. package/_vendor/claude-skills-finance/scripts/budget_variance_analyzer.py +406 -0
  12. package/_vendor/claude-skills-finance/scripts/dcf_valuation.py +449 -0
  13. package/_vendor/claude-skills-finance/scripts/forecast_builder.py +494 -0
  14. package/_vendor/claude-skills-finance/scripts/ratio_calculator.py +432 -0
  15. package/index.ts +332 -14
  16. package/openclaw.plugin.json +2 -2
  17. package/package.json +1 -1
  18. package/references/cn-market-specifics.md +165 -0
  19. package/references/crypto-analysis.md +635 -0
  20. package/references/financial-ratios-cn.md +452 -0
  21. package/references/hk-market-specifics.md +166 -0
  22. package/references/macro-cycle-cn.md +409 -0
  23. package/references/valuation-cn.md +427 -0
  24. package/skills/README.md +294 -0
  25. package/skills/a-concept-cycle/skill.md +200 -0
  26. package/skills/a-convertible-arb/skill.md +294 -0
  27. package/skills/a-dividend-king/skill.md +187 -0
  28. package/skills/a-earnings-season/skill.md +221 -0
  29. package/skills/a-index-timer/skill.md +192 -0
  30. package/skills/a-ipo-new/skill.md +297 -0
  31. package/skills/a-northbound-decoder/skill.md +185 -0
  32. package/skills/a-quant-board/skill.md +286 -0
  33. package/skills/a-share/skill.md +347 -0
  34. package/skills/a-share-radar/skill.md +185 -0
  35. package/skills/cross-asset/skill.md +202 -0
  36. package/skills/crypto/skill.md +269 -0
  37. package/skills/crypto-altseason/skill.md +208 -0
  38. package/skills/crypto-btc-cycle/skill.md +231 -0
  39. package/skills/crypto-defi-yield/skill.md +181 -0
  40. package/skills/crypto-funding-arb/skill.md +158 -0
  41. package/skills/crypto-stablecoin-flow/skill.md +149 -0
  42. package/skills/data-query/skill.md +124 -30
  43. package/skills/derivatives/skill.md +188 -35
  44. package/skills/etf-fund/skill.md +216 -0
  45. package/skills/factor-screen/skill.md +186 -0
  46. package/skills/hk-china-internet/skill.md +190 -0
  47. package/skills/hk-dividend-harvest/skill.md +192 -0
  48. package/skills/hk-hsi-pulse/skill.md +154 -0
  49. package/skills/hk-southbound-alpha/skill.md +163 -0
  50. package/skills/hk-stock/skill.md +295 -0
  51. package/skills/macro/skill.md +244 -53
  52. package/skills/risk-monitor/skill.md +171 -0
  53. package/skills/us-dividend/skill.md +162 -0
  54. package/skills/us-earnings/skill.md +149 -0
  55. package/skills/us-equity/skill.md +235 -0
  56. package/skills/us-etf/skill.md +261 -0
  57. package/skills/us-sector-rotation/skill.md +223 -0
  58. package/src/config.ts +4 -5
  59. package/src/datahub-client.test.ts +4 -7
  60. package/src/datahub-client.ts +6 -1
  61. package/src/register-tools.ts +720 -0
  62. package/src/tool-helpers.ts +89 -0
  63. package/test/e2e/l3-gateway-bootstrap.live.test.ts +339 -0
  64. package/test/e2e/l4-skill-tool-chain.live.test.ts +465 -0
  65. package/skills/crypto-defi/skill.md +0 -69
  66. package/skills/equity/skill.md +0 -64
  67. 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()