@drico2008/fincli 0.1.3 → 0.2.2
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/LICENSE +21 -0
- package/README.md +909 -684
- package/fincli/__init__.py +3 -3
- package/fincli/app/agents/__init__.py +5 -0
- package/fincli/app/agents/registry.py +76 -0
- package/fincli/app/analysis/ai_prompts.py +23 -16
- package/fincli/app/analysis/analyzer.py +107 -100
- package/fincli/app/analysis/assistant_context.py +187 -160
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/indicators.py +1 -1
- package/fincli/app/analysis/market_structure.py +1 -1
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +105 -77
- package/fincli/app/cli/router.py +2143 -1121
- package/fincli/app/connectors/__init__.py +5 -0
- package/fincli/app/connectors/catalog.py +148 -0
- package/fincli/app/connectors/news_connectors.py +412 -0
- package/fincli/app/modules/alerts.py +80 -0
- package/fincli/app/modules/economic_calendar.py +374 -1
- package/fincli/app/modules/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/session_history.py +113 -0
- package/fincli/app/modules/transactions.py +84 -84
- package/fincli/app/modules/user_profile.py +84 -0
- package/fincli/app/plugins/loader.py +72 -0
- package/fincli/app/providers/ai/anthropic_provider.py +8 -7
- package/fincli/app/providers/ai/gemini_provider.py +8 -7
- package/fincli/app/providers/ai/groq_provider.py +8 -7
- package/fincli/app/providers/ai/huggingface_provider.py +8 -7
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/ai/openai_provider.py +8 -7
- package/fincli/app/providers/ai/openrouter_provider.py +8 -7
- package/fincli/app/providers/ai/together_provider.py +8 -7
- package/fincli/app/providers/market/alphavantage_provider.py +194 -0
- package/fincli/app/providers/market/base.py +98 -77
- package/fincli/app/providers/market/custom_provider.py +186 -169
- package/fincli/app/providers/market/manager.py +85 -2
- package/fincli/app/providers/market/news_provider.py +4 -4
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/providers/market/yfinance_provider.py +1 -1
- package/fincli/app/research/__init__.py +7 -0
- package/fincli/app/research/engine.py +75 -0
- package/fincli/app/research/formatter.py +22 -0
- package/fincli/app/research/models.py +18 -0
- package/fincli/app/research/prompt_builder.py +47 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +203 -203
- package/fincli/app/services/news_aggregator.py +90 -0
- package/fincli/app/services/web_research.py +267 -0
- package/fincli/app/storage/cache.py +2 -2
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +201 -85
- package/fincli/app/storage/secrets.py +12 -3
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +270 -258
- package/fincli/app/tui/market_provider_selector.py +6 -1
- package/fincli/app/tui/model_selector.py +11 -3
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +125 -12
- package/npm/bin/fincli.js +9 -2
- package/package.json +23 -23
- package/pyproject.toml +35 -35
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Lightweight web research service for AI assistance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from html import unescape
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
import re
|
|
9
|
+
from urllib.parse import parse_qs, quote_plus, unquote, urlparse
|
|
10
|
+
from xml.etree import ElementTree
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from fincli.app.utils.errors import ProviderError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class WebSearchResult:
|
|
19
|
+
title: str
|
|
20
|
+
url: str
|
|
21
|
+
snippet: str = ""
|
|
22
|
+
content: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WebResearchService:
|
|
26
|
+
"""Search and fetch public web pages without browser automation."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, client: httpx.AsyncClient | None = None, timeout_seconds: float = 6.0) -> None:
|
|
29
|
+
self._client = client
|
|
30
|
+
self.timeout_seconds = timeout_seconds
|
|
31
|
+
|
|
32
|
+
async def research(self, query: str, limit: int = 3) -> list[WebSearchResult]:
|
|
33
|
+
normalized = query.strip()
|
|
34
|
+
if not normalized:
|
|
35
|
+
return []
|
|
36
|
+
search_results = await self.search(normalized, limit=limit)
|
|
37
|
+
enriched: list[WebSearchResult] = []
|
|
38
|
+
for result in search_results[:limit]:
|
|
39
|
+
content = await self.fetch_text(result.url)
|
|
40
|
+
enriched.append(
|
|
41
|
+
WebSearchResult(
|
|
42
|
+
title=result.title,
|
|
43
|
+
url=result.url,
|
|
44
|
+
snippet=result.snippet,
|
|
45
|
+
content=content,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
return enriched
|
|
49
|
+
|
|
50
|
+
async def search(self, query: str, limit: int = 5) -> list[WebSearchResult]:
|
|
51
|
+
errors: list[str] = []
|
|
52
|
+
for searcher in (self._search_duckduckgo, self._search_google_news):
|
|
53
|
+
try:
|
|
54
|
+
results = await searcher(query, limit)
|
|
55
|
+
except ProviderError as exc:
|
|
56
|
+
errors.append(str(exc))
|
|
57
|
+
continue
|
|
58
|
+
if results:
|
|
59
|
+
return results
|
|
60
|
+
detail = "\n".join(f"- {error}" for error in errors) if errors else "Tidak ada hasil publik."
|
|
61
|
+
raise ProviderError(
|
|
62
|
+
"Semua web search provider gagal atau kosong.",
|
|
63
|
+
f"{detail}\nCoba ulangi, sederhanakan query, atau cek koneksi/DNS.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def _search_duckduckgo(self, query: str, limit: int) -> list[WebSearchResult]:
|
|
67
|
+
html = await self._get_text(f"https://duckduckgo.com/html/?q={quote_plus(query)}")
|
|
68
|
+
parser = _DuckDuckGoParser()
|
|
69
|
+
parser.feed(html)
|
|
70
|
+
results: list[WebSearchResult] = []
|
|
71
|
+
seen: set[str] = set()
|
|
72
|
+
for item in parser.results:
|
|
73
|
+
target = _clean_duckduckgo_url(item.url)
|
|
74
|
+
if not target or target in seen:
|
|
75
|
+
continue
|
|
76
|
+
seen.add(target)
|
|
77
|
+
results.append(WebSearchResult(title=_clean_text(item.title), url=target, snippet=_clean_text(item.snippet)))
|
|
78
|
+
if len(results) >= limit:
|
|
79
|
+
break
|
|
80
|
+
return results
|
|
81
|
+
|
|
82
|
+
async def _search_google_news(self, query: str, limit: int) -> list[WebSearchResult]:
|
|
83
|
+
rss = await self._get_text(f"https://news.google.com/rss/search?q={quote_plus(query)}&hl=id&gl=ID&ceid=ID:id")
|
|
84
|
+
try:
|
|
85
|
+
root = ElementTree.fromstring(rss)
|
|
86
|
+
except ElementTree.ParseError as exc:
|
|
87
|
+
raise ProviderError("Google News RSS tidak valid.") from exc
|
|
88
|
+
|
|
89
|
+
results: list[WebSearchResult] = []
|
|
90
|
+
seen: set[str] = set()
|
|
91
|
+
for item in root.findall(".//item"):
|
|
92
|
+
title = _clean_text(item.findtext("title") or "")
|
|
93
|
+
url = _clean_text(item.findtext("link") or "")
|
|
94
|
+
snippet = _clean_text(_html_to_text(item.findtext("description") or ""))
|
|
95
|
+
if not title or not url or url in seen:
|
|
96
|
+
continue
|
|
97
|
+
seen.add(url)
|
|
98
|
+
results.append(WebSearchResult(title=title, url=url, snippet=snippet))
|
|
99
|
+
if len(results) >= limit:
|
|
100
|
+
break
|
|
101
|
+
return results
|
|
102
|
+
|
|
103
|
+
async def fetch_text(self, url: str, max_chars: int = 2400) -> str:
|
|
104
|
+
if not url.startswith(("http://", "https://")):
|
|
105
|
+
return ""
|
|
106
|
+
try:
|
|
107
|
+
html = await self._get_text(url)
|
|
108
|
+
except ProviderError:
|
|
109
|
+
return ""
|
|
110
|
+
text = _html_to_text(html)
|
|
111
|
+
return text[:max_chars]
|
|
112
|
+
|
|
113
|
+
async def _get_text(self, url: str) -> str:
|
|
114
|
+
headers = {
|
|
115
|
+
"User-Agent": "FinCLI/0.1 web research (+https://www.npmjs.com/package/@drico2008/fincli)",
|
|
116
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7",
|
|
117
|
+
}
|
|
118
|
+
close_client = self._client is None
|
|
119
|
+
client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, headers=headers)
|
|
120
|
+
try:
|
|
121
|
+
response = await client.get(url, headers=headers)
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
return response.text
|
|
124
|
+
except httpx.TimeoutException as exc:
|
|
125
|
+
raise ProviderError("Web research timeout.", f"URL: {url}") from exc
|
|
126
|
+
except httpx.HTTPStatusError as exc:
|
|
127
|
+
raise ProviderError(f"Web research gagal: HTTP {exc.response.status_code}.", f"URL: {url}") from exc
|
|
128
|
+
except httpx.RequestError as exc:
|
|
129
|
+
raise ProviderError(f"Web research gagal terhubung: {exc}.", f"URL: {url}") from exc
|
|
130
|
+
finally:
|
|
131
|
+
if close_client:
|
|
132
|
+
await client.aclose()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def should_use_web_research(prompt: str) -> bool:
|
|
136
|
+
"""Detect prompts that benefit from current public web context."""
|
|
137
|
+
normalized = prompt.lower()
|
|
138
|
+
keywords = (
|
|
139
|
+
"terkini",
|
|
140
|
+
"terbaru",
|
|
141
|
+
"hari ini",
|
|
142
|
+
"sekarang",
|
|
143
|
+
"saat ini",
|
|
144
|
+
"update",
|
|
145
|
+
"berita",
|
|
146
|
+
"news",
|
|
147
|
+
"web",
|
|
148
|
+
"search",
|
|
149
|
+
"cari",
|
|
150
|
+
"penyebab",
|
|
151
|
+
"mengapa",
|
|
152
|
+
"kenapa",
|
|
153
|
+
"rupiah",
|
|
154
|
+
"inflasi",
|
|
155
|
+
"suku bunga",
|
|
156
|
+
"bank indonesia",
|
|
157
|
+
"fed",
|
|
158
|
+
"dollar",
|
|
159
|
+
"dolar",
|
|
160
|
+
"yield",
|
|
161
|
+
"minyak",
|
|
162
|
+
"emas",
|
|
163
|
+
)
|
|
164
|
+
return any(keyword in normalized for keyword in keywords)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_web_research_context(results: list[WebSearchResult]) -> str:
|
|
168
|
+
if not results:
|
|
169
|
+
return "Web Research: no public web context returned."
|
|
170
|
+
sections = ["Web Research Context:"]
|
|
171
|
+
for index, result in enumerate(results, start=1):
|
|
172
|
+
sections.extend(
|
|
173
|
+
[
|
|
174
|
+
f"{index}. {result.title}",
|
|
175
|
+
f"URL: {result.url}",
|
|
176
|
+
f"Snippet: {result.snippet or 'N/A'}",
|
|
177
|
+
f"Extract: {result.content or 'N/A'}",
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
return "\n".join(sections)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class _DuckResult:
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
self.title = ""
|
|
186
|
+
self.url = ""
|
|
187
|
+
self.snippet = ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _DuckDuckGoParser(HTMLParser):
|
|
191
|
+
def __init__(self) -> None:
|
|
192
|
+
super().__init__()
|
|
193
|
+
self.results: list[_DuckResult] = []
|
|
194
|
+
self._current: _DuckResult | None = None
|
|
195
|
+
self._capture: str | None = None
|
|
196
|
+
self._buffer: list[str] = []
|
|
197
|
+
|
|
198
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
199
|
+
attr = dict(attrs)
|
|
200
|
+
classes = attr.get("class", "")
|
|
201
|
+
if tag == "a" and "result__a" in classes:
|
|
202
|
+
self._current = _DuckResult()
|
|
203
|
+
self._current.url = attr.get("href", "") or ""
|
|
204
|
+
self._capture = "title"
|
|
205
|
+
self._buffer = []
|
|
206
|
+
elif self._current is not None and tag in {"a", "div"} and "result__snippet" in classes:
|
|
207
|
+
self._capture = "snippet"
|
|
208
|
+
self._buffer = []
|
|
209
|
+
|
|
210
|
+
def handle_data(self, data: str) -> None:
|
|
211
|
+
if self._capture:
|
|
212
|
+
self._buffer.append(data)
|
|
213
|
+
|
|
214
|
+
def handle_endtag(self, tag: str) -> None:
|
|
215
|
+
if self._current is None or self._capture is None:
|
|
216
|
+
return
|
|
217
|
+
if self._capture == "title" and tag == "a":
|
|
218
|
+
self._current.title = _clean_text(" ".join(self._buffer))
|
|
219
|
+
self._capture = None
|
|
220
|
+
self._buffer = []
|
|
221
|
+
elif self._capture == "snippet" and tag in {"a", "div"}:
|
|
222
|
+
self._current.snippet = _clean_text(" ".join(self._buffer))
|
|
223
|
+
self.results.append(self._current)
|
|
224
|
+
self._current = None
|
|
225
|
+
self._capture = None
|
|
226
|
+
self._buffer = []
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class _TextExtractor(HTMLParser):
|
|
230
|
+
def __init__(self) -> None:
|
|
231
|
+
super().__init__()
|
|
232
|
+
self.parts: list[str] = []
|
|
233
|
+
self._skip_depth = 0
|
|
234
|
+
|
|
235
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
236
|
+
if tag in {"script", "style", "noscript", "svg"}:
|
|
237
|
+
self._skip_depth += 1
|
|
238
|
+
|
|
239
|
+
def handle_endtag(self, tag: str) -> None:
|
|
240
|
+
if tag in {"script", "style", "noscript", "svg"} and self._skip_depth:
|
|
241
|
+
self._skip_depth -= 1
|
|
242
|
+
|
|
243
|
+
def handle_data(self, data: str) -> None:
|
|
244
|
+
if not self._skip_depth:
|
|
245
|
+
cleaned = _clean_text(data)
|
|
246
|
+
if cleaned:
|
|
247
|
+
self.parts.append(cleaned)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _html_to_text(html: str) -> str:
|
|
251
|
+
extractor = _TextExtractor()
|
|
252
|
+
extractor.feed(html)
|
|
253
|
+
return _clean_text(" ".join(extractor.parts))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _clean_text(value: str) -> str:
|
|
257
|
+
text = unescape(value)
|
|
258
|
+
text = re.sub(r"\s+", " ", text)
|
|
259
|
+
return text.strip()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _clean_duckduckgo_url(url: str) -> str:
|
|
263
|
+
parsed = urlparse(url)
|
|
264
|
+
if parsed.netloc.endswith("duckduckgo.com") and parsed.path.startswith("/l/"):
|
|
265
|
+
target = parse_qs(parsed.query).get("uddg", [""])[0]
|
|
266
|
+
return unquote(target)
|
|
267
|
+
return url
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Lightweight in-memory cache
|
|
1
|
+
"""Lightweight in-memory TTL cache for provider responses."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -16,7 +16,7 @@ class CacheEntry(Generic[T]):
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class TTLCache(Generic[T]):
|
|
19
|
-
"""Simple TTL cache
|
|
19
|
+
"""Simple runtime TTL cache used by router and provider workflows."""
|
|
20
20
|
|
|
21
21
|
def __init__(self, default_ttl: int = 300) -> None:
|
|
22
22
|
self.default_ttl = default_ttl
|
|
@@ -1,112 +1,146 @@
|
|
|
1
|
-
"""Configuration loading and persistence.
|
|
2
|
-
|
|
3
|
-
Secrets are read from environment variables. Non-secret preferences are stored
|
|
4
|
-
in a local JSON config file under ~/.fincli by default.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from dataclasses import asdict, dataclass, field
|
|
10
|
-
import json
|
|
11
|
-
import os
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from typing import Any
|
|
14
|
-
|
|
1
|
+
"""Configuration loading and persistence.
|
|
2
|
+
|
|
3
|
+
Secrets are read from environment variables. Non-secret preferences are stored
|
|
4
|
+
in a local JSON config file under ~/.fincli by default.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
15
|
try:
|
|
16
|
-
from dotenv import load_dotenv
|
|
16
|
+
from dotenv import dotenv_values, load_dotenv
|
|
17
17
|
except ImportError: # pragma: no cover - dependency exists in normal install
|
|
18
18
|
load_dotenv = None # type: ignore[assignment]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
from fincli.app.utils.
|
|
22
|
-
from fincli.app.
|
|
23
|
-
from fincli.app.storage.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
dotenv_values = None # type: ignore[assignment]
|
|
20
|
+
|
|
21
|
+
from fincli.app.utils.errors import ConfigError
|
|
22
|
+
from fincli.app.utils.formatting import mask_secret
|
|
23
|
+
from fincli.app.storage.config_paths import APP_DIR, CONFIG_FILE
|
|
24
|
+
from fincli.app.storage.secrets import load_local_secrets
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class FinCLISettings:
|
|
29
|
+
ai_provider: str = "openrouter"
|
|
30
|
+
ai_model: str = "openai/gpt-4o-mini"
|
|
30
31
|
market_provider: str = "yfinance"
|
|
31
32
|
news_provider: str = "yfinance"
|
|
32
33
|
market_provider_priority: list[str] = field(default_factory=lambda: ["yfinance"])
|
|
34
|
+
news_provider_priority: list[str] = field(
|
|
35
|
+
default_factory=lambda: ["yfinance", "google_news_rss", "yahoo_finance_rss", "marketaux", "newsapi", "gnews"]
|
|
36
|
+
)
|
|
33
37
|
timezone: str = "Asia/Jakarta"
|
|
34
|
-
default_currency: str = "USD"
|
|
35
|
-
cache_ttl_seconds: int = 300
|
|
36
|
-
theme: str = "fincli-dark"
|
|
37
|
-
|
|
38
|
-
def safe_dict(self) -> dict[str, Any]:
|
|
39
|
-
"""Return display-safe config, including masked secret status."""
|
|
40
|
-
data = asdict(self)
|
|
41
|
-
data["api_keys"] = {
|
|
42
|
-
"openrouter": mask_secret(os.getenv("OPENROUTER_API_KEY")),
|
|
43
|
-
"gemini": mask_secret(os.getenv("GEMINI_API_KEY")),
|
|
44
|
-
"anthropic": mask_secret(os.getenv("ANTHROPIC_API_KEY")),
|
|
45
|
-
"openai": mask_secret(os.getenv("OPENAI_API_KEY")),
|
|
46
|
-
"together": mask_secret(os.getenv("TOGETHER_API_KEY")),
|
|
47
|
-
"huggingface": mask_secret(os.getenv("HUGGINGFACE_API_KEY")),
|
|
48
|
-
"groq": mask_secret(os.getenv("GROQ_API_KEY")),
|
|
49
|
-
"market_data": mask_secret(os.getenv("MARKET_DATA_API_KEY")),
|
|
38
|
+
default_currency: str = "USD"
|
|
39
|
+
cache_ttl_seconds: int = 300
|
|
40
|
+
theme: str = "fincli-dark"
|
|
41
|
+
|
|
42
|
+
def safe_dict(self) -> dict[str, Any]:
|
|
43
|
+
"""Return display-safe config, including masked secret status."""
|
|
44
|
+
data = asdict(self)
|
|
45
|
+
data["api_keys"] = {
|
|
46
|
+
"openrouter": mask_secret(os.getenv("OPENROUTER_API_KEY")),
|
|
47
|
+
"gemini": mask_secret(os.getenv("GEMINI_API_KEY")),
|
|
48
|
+
"anthropic": mask_secret(os.getenv("ANTHROPIC_API_KEY")),
|
|
49
|
+
"openai": mask_secret(os.getenv("OPENAI_API_KEY")),
|
|
50
|
+
"together": mask_secret(os.getenv("TOGETHER_API_KEY")),
|
|
51
|
+
"huggingface": mask_secret(os.getenv("HUGGINGFACE_API_KEY")),
|
|
52
|
+
"groq": mask_secret(os.getenv("GROQ_API_KEY")),
|
|
53
|
+
"market_data": mask_secret(os.getenv("MARKET_DATA_API_KEY")),
|
|
50
54
|
"news_data": mask_secret(os.getenv("NEWS_DATA_API_KEY")),
|
|
51
55
|
"finnhub": mask_secret(os.getenv("FINNHUB_API_KEY")),
|
|
52
56
|
"twelvedata": mask_secret(os.getenv("TWELVE_DATA_API_KEY")),
|
|
57
|
+
"alphavantage": mask_secret(os.getenv("ALPHA_VANTAGE_API_KEY")),
|
|
58
|
+
"marketaux": mask_secret(os.getenv("MARKETAUX_API_KEY")),
|
|
59
|
+
"newsapi": mask_secret(os.getenv("NEWSAPI_API_KEY")),
|
|
60
|
+
"gnews": mask_secret(os.getenv("GNEWS_API_KEY")),
|
|
61
|
+
"stocknewsapi": mask_secret(os.getenv("STOCKNEWSAPI_API_KEY")),
|
|
62
|
+
"apitube": mask_secret(os.getenv("APITUBE_API_KEY")),
|
|
63
|
+
"benzinga": mask_secret(os.getenv("BENZINGA_API_KEY")),
|
|
64
|
+
"polygon": mask_secret(os.getenv("POLYGON_API_KEY")),
|
|
65
|
+
"tiingo": mask_secret(os.getenv("TIINGO_API_KEY")),
|
|
66
|
+
"fmp": mask_secret(os.getenv("FMP_API_KEY")),
|
|
67
|
+
"eodhd": mask_secret(os.getenv("EODHD_API_KEY")),
|
|
68
|
+
"custom_news": mask_secret(os.getenv("CUSTOM_NEWS_API_KEY") or os.getenv("NEWS_DATA_API_KEY")),
|
|
53
69
|
}
|
|
54
70
|
return data
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class ConfigManager:
|
|
58
|
-
"""Load, update, and persist non-secret FinCLI settings."""
|
|
59
|
-
|
|
60
|
-
def __init__(self, config_file: Path = CONFIG_FILE) -> None:
|
|
61
|
-
self.config_file = config_file
|
|
62
|
-
self.settings = self.load()
|
|
63
|
-
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ConfigManager:
|
|
74
|
+
"""Load, update, and persist non-secret FinCLI settings."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, config_file: Path = CONFIG_FILE) -> None:
|
|
77
|
+
self.config_file = config_file
|
|
78
|
+
self.settings = self.load()
|
|
79
|
+
|
|
64
80
|
def load(self) -> FinCLISettings:
|
|
81
|
+
dotenv_loaded_keys: set[str] = set()
|
|
65
82
|
if load_dotenv is not None:
|
|
83
|
+
before_env = dict(os.environ)
|
|
66
84
|
load_dotenv()
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
85
|
+
if dotenv_values is not None:
|
|
86
|
+
dotenv_loaded_keys = {
|
|
87
|
+
key
|
|
88
|
+
for key, value in dotenv_values().items()
|
|
89
|
+
if key and value is not None and (key not in before_env or before_env.get(key, "") == "")
|
|
90
|
+
}
|
|
91
|
+
# API keys saved from FinCLI commands should override stale project .env values,
|
|
92
|
+
# while explicit OS/process environment variables remain respected.
|
|
93
|
+
load_local_secrets(override_keys=dotenv_loaded_keys)
|
|
94
|
+
|
|
95
|
+
if not self.config_file.exists():
|
|
96
|
+
return FinCLISettings()
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
raw = json.loads(self.config_file.read_text(encoding="utf-8"))
|
|
100
|
+
allowed = FinCLISettings.__dataclass_fields__.keys()
|
|
101
|
+
filtered = {key: value for key, value in raw.items() if key in allowed}
|
|
102
|
+
return FinCLISettings(**filtered)
|
|
103
|
+
except Exception as exc: # noqa: BLE001
|
|
104
|
+
raise ConfigError(
|
|
105
|
+
"Config lokal gagal dibaca.",
|
|
106
|
+
"Periksa ~/.fincli/config.json atau hapus file tersebut untuk memakai default.",
|
|
107
|
+
) from exc
|
|
108
|
+
|
|
109
|
+
def save(self) -> None:
|
|
110
|
+
try:
|
|
111
|
+
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
self.config_file.write_text(
|
|
113
|
+
json.dumps(asdict(self.settings), indent=2),
|
|
114
|
+
encoding="utf-8",
|
|
115
|
+
)
|
|
116
|
+
except Exception as exc: # noqa: BLE001
|
|
117
|
+
raise ConfigError("Config lokal gagal disimpan.") from exc
|
|
118
|
+
|
|
119
|
+
def set_ai_model(self, provider: str, model: str) -> None:
|
|
120
|
+
self.settings.ai_provider = provider.strip().lower()
|
|
121
|
+
self.settings.ai_model = model.strip()
|
|
122
|
+
self.save()
|
|
123
|
+
|
|
124
|
+
def set_market_provider(self, provider: str) -> None:
|
|
125
|
+
self.settings.market_provider = provider.strip().lower()
|
|
126
|
+
self.save()
|
|
127
|
+
|
|
102
128
|
def set_news_provider(self, provider: str) -> None:
|
|
103
129
|
self.settings.news_provider = provider.strip().lower()
|
|
104
130
|
self.save()
|
|
105
131
|
|
|
106
|
-
def
|
|
132
|
+
def set_news_provider_priority(self, providers: list[str]) -> None:
|
|
107
133
|
normalized = [provider.strip().lower() for provider in providers if provider.strip()]
|
|
108
134
|
if not normalized:
|
|
109
|
-
normalized = ["yfinance"]
|
|
135
|
+
normalized = ["yfinance", "google_news_rss", "yahoo_finance_rss"]
|
|
136
|
+
self.settings.news_provider_priority = normalized
|
|
137
|
+
self.settings.news_provider = normalized[0]
|
|
138
|
+
self.save()
|
|
139
|
+
|
|
140
|
+
def set_market_provider_priority(self, providers: list[str]) -> None:
|
|
141
|
+
normalized = [provider.strip().lower() for provider in providers if provider.strip()]
|
|
142
|
+
if not normalized:
|
|
143
|
+
normalized = ["yfinance"]
|
|
110
144
|
self.settings.market_provider_priority = normalized
|
|
111
145
|
self.settings.market_provider = normalized[0]
|
|
112
146
|
self.settings.news_provider = normalized[0]
|