@drico2008/fincli 0.3.0 → 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.
- package/README.md +217 -217
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +29 -27
- package/fincli/app/analysis/analyzer.py +34 -34
- package/fincli/app/analysis/assistant_context.py +3 -3
- package/fincli/app/cli/commands.py +33 -27
- package/fincli/app/cli/router.py +1633 -1105
- package/fincli/app/diagnostics/__init__.py +2 -0
- package/fincli/app/diagnostics/capabilities.py +44 -0
- package/fincli/app/diagnostics/runtime.py +106 -0
- package/fincli/app/main.py +6 -1
- package/fincli/app/modules/economic_calendar.py +512 -512
- package/fincli/app/modules/portfolio_risk.py +305 -305
- package/fincli/app/modules/trading.py +142 -0
- package/fincli/app/plugins/loader.py +72 -72
- package/fincli/app/providers/market/finnhub_provider.py +51 -2
- package/fincli/app/providers/market/symbols.py +95 -2
- package/fincli/app/providers/reliability.py +82 -65
- package/fincli/app/research/__init__.py +8 -8
- package/fincli/app/research/engine.py +119 -112
- package/fincli/app/research/exporter.py +91 -91
- package/fincli/app/research/formatter.py +25 -24
- package/fincli/app/research/models.py +22 -21
- package/fincli/app/research/prompt_builder.py +53 -51
- package/fincli/app/services/data_quality.py +27 -0
- package/fincli/app/services/data_trust.py +117 -0
- package/fincli/app/services/macro_data.py +158 -50
- package/fincli/app/services/market_data.py +183 -79
- package/fincli/app/services/market_overview.py +131 -142
- package/fincli/app/services/news_aggregator.py +95 -95
- package/fincli/app/storage/config.py +6 -3
- package/fincli/app/storage/database.py +130 -117
- package/fincli/app/storage/provider_metrics.py +61 -61
- package/fincli/app/storage/secrets.py +128 -128
- package/npm/bin/fincli.js +65 -42
- package/package.json +7 -7
- 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
-
|
|
15
|
-
- Do not
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
f"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
f"
|
|
39
|
-
f"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
f"
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
]
|