@drico2008/fincli 0.3.1 → 0.4.0

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 (37) hide show
  1. package/README.md +217 -217
  2. package/fincli/__init__.py +1 -1
  3. package/fincli/app/analysis/ai_prompts.py +29 -27
  4. package/fincli/app/analysis/analyzer.py +34 -34
  5. package/fincli/app/analysis/assistant_context.py +3 -3
  6. package/fincli/app/cli/commands.py +33 -27
  7. package/fincli/app/cli/router.py +1633 -1105
  8. package/fincli/app/diagnostics/__init__.py +2 -0
  9. package/fincli/app/diagnostics/capabilities.py +44 -0
  10. package/fincli/app/diagnostics/runtime.py +106 -0
  11. package/fincli/app/main.py +6 -1
  12. package/fincli/app/modules/economic_calendar.py +512 -512
  13. package/fincli/app/modules/portfolio_risk.py +305 -305
  14. package/fincli/app/modules/trading.py +142 -0
  15. package/fincli/app/plugins/loader.py +72 -72
  16. package/fincli/app/providers/market/finnhub_provider.py +51 -2
  17. package/fincli/app/providers/market/symbols.py +95 -2
  18. package/fincli/app/providers/reliability.py +82 -65
  19. package/fincli/app/research/__init__.py +8 -8
  20. package/fincli/app/research/engine.py +119 -112
  21. package/fincli/app/research/exporter.py +91 -91
  22. package/fincli/app/research/formatter.py +25 -24
  23. package/fincli/app/research/models.py +22 -21
  24. package/fincli/app/research/prompt_builder.py +53 -51
  25. package/fincli/app/services/data_quality.py +27 -0
  26. package/fincli/app/services/data_trust.py +117 -0
  27. package/fincli/app/services/macro_data.py +158 -50
  28. package/fincli/app/services/market_data.py +183 -79
  29. package/fincli/app/services/market_overview.py +131 -142
  30. package/fincli/app/services/news_aggregator.py +95 -95
  31. package/fincli/app/storage/config.py +6 -3
  32. package/fincli/app/storage/database.py +130 -117
  33. package/fincli/app/storage/provider_metrics.py +61 -61
  34. package/fincli/app/storage/secrets.py +128 -128
  35. package/npm/bin/fincli.js +65 -65
  36. package/package.json +7 -7
  37. package/pyproject.toml +1 -1
@@ -1,27 +1,28 @@
1
- """Rich renderers for research workspace."""
2
-
3
- from __future__ import annotations
4
-
5
- from rich.table import Table
6
-
7
- from fincli.app.research.models import ResearchBrief
8
- from fincli.app.utils.formatting import semantic_text
9
-
10
-
11
- def format_research_brief(brief: ResearchBrief) -> Table:
12
- table = Table(title=f"Research Center: {brief.symbol} | {brief.mode} | Research Brief v2", expand=True)
13
- table.add_column("Section", style="cyan", no_wrap=True)
14
- table.add_column("Description", overflow="fold")
15
- table.add_row("Snapshot", semantic_text(brief.snapshot))
1
+ """Rich renderers for research workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.table import Table
6
+
7
+ from fincli.app.research.models import ResearchBrief
8
+ from fincli.app.utils.formatting import semantic_text
9
+
10
+
11
+ def format_research_brief(brief: ResearchBrief) -> Table:
12
+ table = Table(title=f"Research Center: {brief.symbol} | {brief.mode} | Research Brief v2", expand=True)
13
+ table.add_column("Section", style="cyan", no_wrap=True)
14
+ table.add_column("Description", overflow="fold")
15
+ table.add_row("Snapshot", semantic_text(brief.snapshot))
16
16
  table.add_row("Signal", semantic_text(brief.signal))
17
17
  table.add_row("Risk", semantic_text(brief.risk))
18
+ table.add_row("Trust Gate", semantic_text(brief.trust_gate))
18
19
  table.add_row("Missing Data", semantic_text(brief.missing_data))
19
- table.add_row("Source Quality", semantic_text(brief.source_quality))
20
- table.add_row("Decision Points", semantic_text(" | ".join(brief.decision_points[:2])))
21
- if brief.mode == "report":
22
- table.add_row("Report Notes", semantic_text("\n".join(f"- {item}" for item in brief.report_notes)))
23
- if brief.ai_summary:
24
- table.add_row("AI Summary", brief.ai_summary)
25
- table.add_row("Final Summary", semantic_text(brief.final_summary))
26
- table.caption = "Research output is informational only. Not financial advice."
27
- return table
20
+ table.add_row("Source Quality", semantic_text(brief.source_quality))
21
+ table.add_row("Decision Points", semantic_text(" | ".join(brief.decision_points[:2])))
22
+ if brief.mode == "report":
23
+ table.add_row("Report Notes", semantic_text("\n".join(f"- {item}" for item in brief.report_notes)))
24
+ if brief.ai_summary:
25
+ table.add_row("AI Summary", brief.ai_summary)
26
+ table.add_row("Final Summary", semantic_text(brief.final_summary))
27
+ table.caption = "Research output is informational only. Not financial advice."
28
+ return table
@@ -1,24 +1,25 @@
1
- """Models for FinCLI research workspace."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
-
7
- from fincli.app.services.market_overview import MarketOverview
8
-
9
-
10
- @dataclass(frozen=True, slots=True)
11
- class ResearchBrief:
12
- symbol: str
13
- mode: str
14
- overview: MarketOverview
15
- snapshot: str
16
- signal: str
1
+ """Models for FinCLI research workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fincli.app.services.market_overview import MarketOverview
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ResearchBrief:
12
+ symbol: str
13
+ mode: str
14
+ overview: MarketOverview
15
+ snapshot: str
16
+ signal: str
17
17
  risk: str
18
18
  missing_data: str
19
19
  source_quality: str
20
- decision_points: list[str]
21
- risks: list[str]
22
- final_summary: str
23
- ai_summary: str = ""
24
- report_notes: tuple[str, ...] = ()
20
+ trust_gate: str
21
+ decision_points: list[str]
22
+ risks: list[str]
23
+ final_summary: str
24
+ ai_summary: str = ""
25
+ report_notes: tuple[str, ...] = ()
@@ -1,54 +1,56 @@
1
- """Prompt builder for deep research mode."""
2
-
3
- from __future__ import annotations
4
-
5
- from fincli.app.research.models import ResearchBrief
6
-
7
-
8
- RESEARCH_WORKSPACE_PROMPT = """
9
- You are FinCLI Research Workspace, operating as Research Engine v2.
10
-
11
- Rules:
12
- - Build a concise investment/trading research note from the provided data only.
1
+ """Prompt builder for deep research mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.research.models import ResearchBrief
6
+
7
+
8
+ RESEARCH_WORKSPACE_PROMPT = """
9
+ You are FinCLI Research Workspace, operating as Research Engine v2.
10
+
11
+ Rules:
12
+ - Build a concise investment/trading research note from the provided data only.
13
13
  - Output must focus on snapshot, signal, risk, missing data, source quality.
14
- - Do not invent price, news, fundamentals, or certainty.
15
- - Do not copy the opening summary as the final summary.
16
- - Prioritize decision-useful points over long explanation.
17
- - Separate facts, interpretation, and risk.
18
- - Keep output short: max 8 bullets plus final summary.
19
- - Use slash-command context correctly: FinCLI commands start with "/", not "fincli".
20
- - This is educational market research, not financial advice.
21
- """.strip()
22
-
23
-
24
- def build_research_prompt(brief: ResearchBrief) -> str:
25
- overview = brief.overview
26
- news = "\n".join(f"- {item.title} ({item.source}) {item.summary}" for item in overview.news) or "- No news."
27
- fundamentals = overview.fundamentals
28
- fundamentals_text = (
29
- "No fundamentals."
30
- if fundamentals is None
31
- else (
32
- f"market_cap={fundamentals.market_cap}; pe={fundamentals.pe_ratio}; eps={fundamentals.eps}; "
33
- f"revenue={fundamentals.revenue}; sector={fundamentals.sector}; industry={fundamentals.industry}"
34
- )
35
- )
36
- return (
37
- f"{RESEARCH_WORKSPACE_PROMPT}\n\n"
38
- f"Symbol: {brief.symbol}\n"
39
- f"Mode: {brief.mode}\n"
40
- "Required focus: snapshot, signal, risk, missing data, source quality.\n"
41
- f"Snapshot: {brief.snapshot}\n"
42
- f"Signal: {brief.signal}\n"
14
+ - Obey the Data Trust Gate. If it says caution/no directional signal, do not produce buy/sell conviction.
15
+ - Do not invent price, news, fundamentals, or certainty.
16
+ - Do not copy the opening summary as the final summary.
17
+ - Prioritize decision-useful points over long explanation.
18
+ - Separate facts, interpretation, and risk.
19
+ - Keep output short: max 8 bullets plus final summary.
20
+ - Use slash-command context correctly: FinCLI commands start with "/", not "fincli".
21
+ - This is educational market research, not financial advice.
22
+ """.strip()
23
+
24
+
25
+ def build_research_prompt(brief: ResearchBrief) -> str:
26
+ overview = brief.overview
27
+ news = "\n".join(f"- {item.title} ({item.source}) {item.summary}" for item in overview.news) or "- No news."
28
+ fundamentals = overview.fundamentals
29
+ fundamentals_text = (
30
+ "No fundamentals."
31
+ if fundamentals is None
32
+ else (
33
+ f"market_cap={fundamentals.market_cap}; pe={fundamentals.pe_ratio}; eps={fundamentals.eps}; "
34
+ f"revenue={fundamentals.revenue}; sector={fundamentals.sector}; industry={fundamentals.industry}"
35
+ )
36
+ )
37
+ return (
38
+ f"{RESEARCH_WORKSPACE_PROMPT}\n\n"
39
+ f"Symbol: {brief.symbol}\n"
40
+ f"Mode: {brief.mode}\n"
41
+ "Required focus: snapshot, signal, risk, missing data, source quality.\n"
42
+ f"Snapshot: {brief.snapshot}\n"
43
+ f"Signal: {brief.signal}\n"
43
44
  f"Risk: {brief.risk}\n"
45
+ f"Data Trust Gate: {brief.trust_gate}\n"
44
46
  f"Missing Data: {brief.missing_data}\n"
45
- f"Source Quality: {brief.source_quality}\n"
46
- f"Quote: {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status})\n"
47
- f"Data Quality: {overview.data_quality.score}/100; OHLCV={overview.data_quality.ohlcv}; News={overview.data_quality.news}; Fundamentals={overview.data_quality.fundamentals}\n"
48
- f"Technical: trend={overview.technical.trend_bias}; rsi={overview.technical.rsi}; macd={overview.technical.macd}; support={overview.technical.support}; resistance={overview.technical.resistance}; atr={overview.technical.atr}\n"
49
- f"Structure: trend={overview.structure.trend}; pattern={overview.structure.latest_pattern}; bos={overview.structure.break_of_structure}; choch={overview.structure.change_of_character}\n"
50
- f"Decision Points:\n{chr(10).join(f'- {point}' for point in brief.decision_points)}\n"
51
- f"Risks:\n{chr(10).join(f'- {risk}' for risk in brief.risks)}\n"
52
- f"News:\n{news}\n"
53
- f"Fundamentals: {fundamentals_text}\n"
54
- )
47
+ f"Source Quality: {brief.source_quality}\n"
48
+ f"Quote: {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status})\n"
49
+ f"Data Quality: {overview.data_quality.score}/100; OHLCV={overview.data_quality.ohlcv}; News={overview.data_quality.news}; Fundamentals={overview.data_quality.fundamentals}\n"
50
+ f"Technical: trend={overview.technical.trend_bias}; rsi={overview.technical.rsi}; macd={overview.technical.macd}; support={overview.technical.support}; resistance={overview.technical.resistance}; atr={overview.technical.atr}\n"
51
+ f"Structure: trend={overview.structure.trend}; pattern={overview.structure.latest_pattern}; bos={overview.structure.break_of_structure}; choch={overview.structure.change_of_character}\n"
52
+ f"Decision Points:\n{chr(10).join(f'- {point}' for point in brief.decision_points)}\n"
53
+ f"Risks:\n{chr(10).join(f'- {risk}' for risk in brief.risks)}\n"
54
+ f"News:\n{news}\n"
55
+ f"Fundamentals: {fundamentals_text}\n"
56
+ )
@@ -0,0 +1,27 @@
1
+ """Standard data quality report model for FinCLI outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class DataQualityReport:
10
+ score: int
11
+ quote: str
12
+ ohlcv: str
13
+ news: str
14
+ fundamentals: str
15
+ provider: str
16
+ tier: str
17
+ freshness: str
18
+ reliability_status: str
19
+ missing_fields: tuple[str, ...]
20
+ label: str
21
+
22
+ def compact(self) -> str:
23
+ missing = ", ".join(self.missing_fields) if self.missing_fields else "none"
24
+ return (
25
+ f"{self.score}/100 | tier={self.tier} | reliability={self.reliability_status} | "
26
+ f"freshness={self.freshness} | missing={missing}"
27
+ )
@@ -0,0 +1,117 @@
1
+ """Data trust policy for AI/research outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from fincli.app.providers.reliability import STATUS_OK, STATUS_PARTIAL_DATA, STATUS_UNAVAILABLE
9
+ from fincli.app.services.data_quality import DataQualityReport
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class DataTrustGate:
14
+ level: str
15
+ action: str
16
+ confidence_cap: int
17
+ max_signal_strength: str
18
+ reasons: tuple[str, ...]
19
+ required_verification: tuple[str, ...]
20
+
21
+ def compact(self) -> str:
22
+ reasons = "; ".join(self.reasons) if self.reasons else "none"
23
+ verify = "; ".join(self.required_verification) if self.required_verification else "none"
24
+ return (
25
+ f"level={self.level} | action={self.action} | confidence_cap={self.confidence_cap}% | "
26
+ f"max_signal={self.max_signal_strength} | reasons={reasons} | verify={verify}"
27
+ )
28
+
29
+ def prompt_context(self) -> str:
30
+ return (
31
+ "Data Trust Gate:\n"
32
+ f"- Trust Level: {self.level}\n"
33
+ f"- AI Action: {self.action}\n"
34
+ f"- Confidence Cap: {self.confidence_cap}%\n"
35
+ f"- Max Signal Strength: {self.max_signal_strength}\n"
36
+ f"- Reasons: {', '.join(self.reasons) if self.reasons else 'none'}\n"
37
+ f"- Required Verification: {', '.join(self.required_verification) if self.required_verification else 'none'}"
38
+ )
39
+
40
+
41
+ def build_data_trust_gate(
42
+ quality: DataQualityReport,
43
+ provider_metrics: dict[str, Any] | None = None,
44
+ ) -> DataTrustGate:
45
+ """Convert data quality and provider runtime metrics into an AI confidence policy."""
46
+ reasons: list[str] = []
47
+ verify: list[str] = []
48
+ metrics = provider_metrics or {}
49
+
50
+ if quality.reliability_status == STATUS_UNAVAILABLE or "quote" in quality.missing_fields or "ohlcv" in quality.missing_fields:
51
+ reasons.append(f"critical market data unavailable: {quality.compact()}")
52
+ verify.extend(("quote availability", "OHLCV history availability", "provider entitlement"))
53
+ return DataTrustGate(
54
+ level="blocked",
55
+ action="no_directional_signal",
56
+ confidence_cap=20,
57
+ max_signal_strength="caution only",
58
+ reasons=tuple(reasons),
59
+ required_verification=tuple(verify),
60
+ )
61
+
62
+ if quality.reliability_status == STATUS_PARTIAL_DATA or quality.score < 65 or quality.missing_fields:
63
+ reasons.append(f"partial data quality: {quality.compact()}")
64
+ if quality.missing_fields:
65
+ verify.append("missing data: " + ", ".join(quality.missing_fields))
66
+
67
+ weak_metrics = _weak_metric_reasons(metrics)
68
+ reasons.extend(weak_metrics)
69
+ if weak_metrics:
70
+ verify.append("provider reliability/fallback chain")
71
+
72
+ if quality.score >= 85 and quality.reliability_status == STATUS_OK and not weak_metrics:
73
+ return DataTrustGate(
74
+ level="strong",
75
+ action="normal_scenario_analysis",
76
+ confidence_cap=80,
77
+ max_signal_strength="candidate buy/sell allowed with confirmation",
78
+ reasons=tuple(reasons),
79
+ required_verification=tuple(verify),
80
+ )
81
+
82
+ if quality.score >= 65 and quality.reliability_status == STATUS_OK and len(weak_metrics) <= 1:
83
+ return DataTrustGate(
84
+ level="usable",
85
+ action="moderated_scenario_analysis",
86
+ confidence_cap=60,
87
+ max_signal_strength="watchlist bias only",
88
+ reasons=tuple(reasons),
89
+ required_verification=tuple(verify),
90
+ )
91
+
92
+ return DataTrustGate(
93
+ level="limited",
94
+ action="caution_first_analysis",
95
+ confidence_cap=45,
96
+ max_signal_strength="caution or wait-for-confirmation only",
97
+ reasons=tuple(reasons) or (f"data quality below production threshold: {quality.compact()}",),
98
+ required_verification=tuple(verify) or ("provider data quality",),
99
+ )
100
+
101
+
102
+ def _weak_metric_reasons(provider_metrics: dict[str, Any]) -> list[str]:
103
+ reasons: list[str] = []
104
+ for provider, metric in provider_metrics.items():
105
+ calls = int(getattr(metric, "calls", 0))
106
+ if calls <= 0:
107
+ continue
108
+ success_rate = float(getattr(metric, "success_rate", 0.0))
109
+ errors = int(getattr(metric, "errors", 0))
110
+ fallbacks = int(getattr(metric, "fallbacks", 0))
111
+ if calls >= 2 and success_rate < 50:
112
+ reasons.append(f"{provider} success rate weak ({success_rate:.1f}% over {calls} calls)")
113
+ if errors >= 2:
114
+ reasons.append(f"{provider} returned {errors} error(s)")
115
+ if fallbacks >= 2:
116
+ reasons.append(f"{provider} needed {fallbacks} fallback(s)")
117
+ return reasons
@@ -1,50 +1,158 @@
1
- """Macro data service with offline-first fallback rows."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from datetime import date
7
-
8
-
9
- @dataclass(frozen=True, slots=True)
10
- class MacroIndicator:
11
- name: str
12
- region: str
13
- value: str
14
- period: str
15
- source: str
16
- note: str
17
-
18
-
19
- class MacroDataService:
20
- """Return macro context from free fallback datasets.
21
-
22
- v0.3.1 keeps this deterministic/offline so /macro remains usable without API keys.
23
- Provider-backed DBnomics/FRED/World Bank adapters can hydrate this shape later.
24
- """
25
-
26
- def indicators(self, query: str = "") -> list[MacroIndicator]:
27
- normalized = query.strip().lower()
28
- rows = _fallback_rows()
29
- if not normalized:
30
- return rows
31
- filtered = [
32
- row
33
- for row in rows
34
- if normalized in row.region.lower()
35
- or normalized in row.name.lower()
36
- or normalized in row.note.lower()
37
- ]
38
- return filtered or rows[:5]
39
-
40
-
41
- def _fallback_rows() -> list[MacroIndicator]:
42
- period = date.today().strftime("%Y")
43
- return [
44
- MacroIndicator("Policy Rate", "United States", "provider required", period, "Fallback", "Watch FRED/Fed data for rate path."),
45
- MacroIndicator("Inflation", "United States", "provider required", period, "Fallback", "CPI trend drives USD, yields, and risk assets."),
46
- MacroIndicator("GDP Growth", "World", "provider required", period, "Fallback", "Use World Bank/IMF for actual country values."),
47
- MacroIndicator("Policy Rate", "Indonesia", "provider required", period, "Fallback", "BI rate, USD strength, and capital flow affect IDR."),
48
- MacroIndicator("Inflation", "Indonesia", "provider required", period, "Fallback", "Inflation surprise can affect BI policy expectations."),
49
- MacroIndicator("PMI", "Euro Area", "provider required", period, "Fallback", "Growth momentum proxy for EUR and European equities."),
50
- ]
1
+ """Macro data service with offline-first fallback rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+ import os
8
+ from typing import Any, Awaitable
9
+ import asyncio
10
+ from concurrent.futures import ThreadPoolExecutor
11
+
12
+ import httpx
13
+
14
+ from fincli.app.utils.errors import ProviderError, RateLimitError
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class MacroIndicator:
19
+ name: str
20
+ region: str
21
+ value: str
22
+ period: str
23
+ source: str
24
+ note: str
25
+
26
+
27
+ class MacroDataService:
28
+ """Return macro context from free fallback datasets.
29
+
30
+ v0.4.0 keeps this deterministic/offline so /macro remains usable without API keys.
31
+ Provider-backed DBnomics/FRED/World Bank adapters can hydrate this shape later.
32
+ """
33
+
34
+ def indicators(self, query: str = "") -> list[MacroIndicator]:
35
+ normalized = query.strip().lower()
36
+ rows = _fallback_rows()
37
+ if not normalized:
38
+ return rows
39
+ filtered = [
40
+ row
41
+ for row in rows
42
+ if normalized in row.region.lower()
43
+ or normalized in row.name.lower()
44
+ or normalized in row.note.lower()
45
+ ]
46
+ return filtered or rows[:5]
47
+
48
+ def alpha_vantage_indicator(self, indicator: str, region: str = "us") -> list[MacroIndicator]:
49
+ service = AlphaVantageEconomicService(api_key=os.getenv("ALPHA_VANTAGE_API_KEY"))
50
+ return service.run(service.indicator(indicator, region))
51
+
52
+
53
+ class AlphaVantageEconomicService:
54
+ """Fetch no-frills US macro indicators from Alpha Vantage economic endpoints."""
55
+
56
+ FUNCTIONS = {
57
+ "cpi": ("CPI", "monthly", "CPI"),
58
+ "inflation": ("INFLATION", "annual", "Inflation"),
59
+ "unemployment": ("UNEMPLOYMENT", "monthly", "Unemployment"),
60
+ "nfp": ("NONFARM_PAYROLL", "monthly", "Nonfarm Payroll"),
61
+ "nonfarm_payroll": ("NONFARM_PAYROLL", "monthly", "Nonfarm Payroll"),
62
+ "fed_funds": ("FEDERAL_FUNDS_RATE", "monthly", "Federal Funds Rate"),
63
+ "federal_funds_rate": ("FEDERAL_FUNDS_RATE", "monthly", "Federal Funds Rate"),
64
+ "gdp": ("REAL_GDP", "annual", "Real GDP"),
65
+ "real_gdp": ("REAL_GDP", "annual", "Real GDP"),
66
+ "gdp_per_capita": ("REAL_GDP_PER_CAPITA", "annual", "Real GDP Per Capita"),
67
+ "real_gdp_per_capita": ("REAL_GDP_PER_CAPITA", "annual", "Real GDP Per Capita"),
68
+ }
69
+
70
+ def __init__(
71
+ self,
72
+ api_key: str | None,
73
+ base_url: str = "https://www.alphavantage.co/query",
74
+ client: httpx.AsyncClient | None = None,
75
+ ) -> None:
76
+ self.api_key = api_key or ""
77
+ self.base_url = base_url
78
+ self._client = client
79
+
80
+ async def indicator(self, indicator: str, region: str = "us") -> list[MacroIndicator]:
81
+ normalized_region = region.strip().lower() or "us"
82
+ if normalized_region not in {"us", "usa", "united states"}:
83
+ raise ProviderError("Alpha Vantage macro endpoint FinCLI saat ini hanya mendukung region US.")
84
+ if not self.api_key:
85
+ raise ProviderError("ALPHA_VANTAGE_API_KEY belum diatur.", "Gunakan /news_model key alphavantage <api_key>.")
86
+ function, interval, label = self._function(indicator)
87
+
88
+ close_client = self._client is None
89
+ client = self._client or httpx.AsyncClient(timeout=30)
90
+ try:
91
+ response = await client.get(
92
+ self.base_url,
93
+ params={"function": function, "interval": interval, "apikey": self.api_key},
94
+ )
95
+ if response.status_code == 429:
96
+ raise RateLimitError("Alpha Vantage macro terkena rate limit.")
97
+ response.raise_for_status()
98
+ payload = response.json()
99
+ except httpx.TimeoutException as exc:
100
+ raise ProviderError("Alpha Vantage macro timeout.") from exc
101
+ except httpx.HTTPStatusError as exc:
102
+ raise ProviderError(f"Alpha Vantage macro gagal: HTTP {exc.response.status_code}.") from exc
103
+ except ValueError as exc:
104
+ raise ProviderError("Response Alpha Vantage macro bukan JSON valid.") from exc
105
+ finally:
106
+ if close_client:
107
+ await client.aclose()
108
+
109
+ if isinstance(payload, dict) and ("Error Message" in payload or "Information" in payload or "Note" in payload):
110
+ message = str(payload.get("Error Message") or payload.get("Information") or payload.get("Note"))
111
+ raise ProviderError(f"Alpha Vantage macro gagal: {message}")
112
+ rows = payload.get("data") if isinstance(payload, dict) else None
113
+ if not isinstance(rows, list) or not rows:
114
+ raise ProviderError("Alpha Vantage macro tidak mengembalikan data.")
115
+ result: list[MacroIndicator] = []
116
+ for row in rows[:12]:
117
+ if not isinstance(row, dict):
118
+ continue
119
+ period = str(row.get("date") or "-")
120
+ value = str(row.get("value") or "-")
121
+ result.append(
122
+ MacroIndicator(
123
+ name=label,
124
+ region="United States",
125
+ value=value,
126
+ period=period,
127
+ source="Alpha Vantage",
128
+ note=f"function={function}; interval={interval}",
129
+ )
130
+ )
131
+ return result
132
+
133
+ def run(self, awaitable: Awaitable[Any]) -> Any:
134
+ try:
135
+ asyncio.get_running_loop()
136
+ except RuntimeError:
137
+ return asyncio.run(awaitable)
138
+ with ThreadPoolExecutor(max_workers=1) as executor:
139
+ future = executor.submit(asyncio.run, awaitable)
140
+ return future.result()
141
+
142
+ def _function(self, indicator: str) -> tuple[str, str, str]:
143
+ normalized = indicator.strip().lower().replace(" ", "_").replace("-", "_")
144
+ if normalized not in self.FUNCTIONS:
145
+ raise ProviderError("Macro indicator tidak dikenal.", "Gunakan /cpi us, /nfp us, /gdp us, /fed funds us.")
146
+ return self.FUNCTIONS[normalized]
147
+
148
+
149
+ def _fallback_rows() -> list[MacroIndicator]:
150
+ period = date.today().strftime("%Y")
151
+ return [
152
+ MacroIndicator("Policy Rate", "United States", "provider required", period, "Fallback", "Watch FRED/Fed data for rate path."),
153
+ MacroIndicator("Inflation", "United States", "provider required", period, "Fallback", "CPI trend drives USD, yields, and risk assets."),
154
+ MacroIndicator("GDP Growth", "World", "provider required", period, "Fallback", "Use World Bank/IMF for actual country values."),
155
+ MacroIndicator("Policy Rate", "Indonesia", "provider required", period, "Fallback", "BI rate, USD strength, and capital flow affect IDR."),
156
+ MacroIndicator("Inflation", "Indonesia", "provider required", period, "Fallback", "Inflation surprise can affect BI policy expectations."),
157
+ MacroIndicator("PMI", "Euro Area", "provider required", period, "Fallback", "Growth momentum proxy for EUR and European equities."),
158
+ ]