@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -684
  3. package/fincli/__init__.py +3 -3
  4. package/fincli/app/agents/__init__.py +5 -0
  5. package/fincli/app/agents/registry.py +76 -0
  6. package/fincli/app/analysis/ai_prompts.py +23 -16
  7. package/fincli/app/analysis/analyzer.py +107 -100
  8. package/fincli/app/analysis/assistant_context.py +187 -160
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/indicators.py +1 -1
  12. package/fincli/app/analysis/market_structure.py +1 -1
  13. package/fincli/app/analysis/multi_timeframe.py +180 -0
  14. package/fincli/app/analysis/trading_methods.py +144 -0
  15. package/fincli/app/cli/commands.py +105 -77
  16. package/fincli/app/cli/router.py +2143 -1121
  17. package/fincli/app/connectors/__init__.py +5 -0
  18. package/fincli/app/connectors/catalog.py +148 -0
  19. package/fincli/app/connectors/news_connectors.py +412 -0
  20. package/fincli/app/modules/alerts.py +80 -0
  21. package/fincli/app/modules/economic_calendar.py +374 -1
  22. package/fincli/app/modules/reports.py +151 -0
  23. package/fincli/app/modules/scanner.py +111 -93
  24. package/fincli/app/modules/session_history.py +113 -0
  25. package/fincli/app/modules/transactions.py +84 -84
  26. package/fincli/app/modules/user_profile.py +84 -0
  27. package/fincli/app/plugins/loader.py +72 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +8 -7
  29. package/fincli/app/providers/ai/gemini_provider.py +8 -7
  30. package/fincli/app/providers/ai/groq_provider.py +8 -7
  31. package/fincli/app/providers/ai/huggingface_provider.py +8 -7
  32. package/fincli/app/providers/ai/manager.py +60 -60
  33. package/fincli/app/providers/ai/openai_provider.py +8 -7
  34. package/fincli/app/providers/ai/openrouter_provider.py +8 -7
  35. package/fincli/app/providers/ai/together_provider.py +8 -7
  36. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  37. package/fincli/app/providers/market/base.py +98 -77
  38. package/fincli/app/providers/market/custom_provider.py +186 -169
  39. package/fincli/app/providers/market/manager.py +85 -2
  40. package/fincli/app/providers/market/news_provider.py +4 -4
  41. package/fincli/app/providers/market/symbols.py +143 -0
  42. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  43. package/fincli/app/providers/market/yfinance_provider.py +1 -1
  44. package/fincli/app/research/__init__.py +7 -0
  45. package/fincli/app/research/engine.py +75 -0
  46. package/fincli/app/research/formatter.py +22 -0
  47. package/fincli/app/research/models.py +18 -0
  48. package/fincli/app/research/prompt_builder.py +47 -0
  49. package/fincli/app/services/macro_data.py +50 -0
  50. package/fincli/app/services/market_data.py +203 -203
  51. package/fincli/app/services/news_aggregator.py +90 -0
  52. package/fincli/app/services/web_research.py +267 -0
  53. package/fincli/app/storage/cache.py +2 -2
  54. package/fincli/app/storage/config.py +122 -88
  55. package/fincli/app/storage/database.py +201 -85
  56. package/fincli/app/storage/secrets.py +12 -3
  57. package/fincli/app/tui/components.py +68 -50
  58. package/fincli/app/tui/layout.py +270 -258
  59. package/fincli/app/tui/market_provider_selector.py +6 -1
  60. package/fincli/app/tui/model_selector.py +11 -3
  61. package/fincli/app/tui/theme.py +134 -74
  62. package/fincli/app/utils/formatting.py +125 -12
  63. package/npm/bin/fincli.js +9 -2
  64. package/package.json +23 -23
  65. 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 scaffold for provider responses."""
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 for Phase 1. Provider modules can replace this later."""
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
- from fincli.app.utils.errors import ConfigError
21
- from fincli.app.utils.formatting import mask_secret
22
- from fincli.app.storage.config_paths import APP_DIR, CONFIG_FILE
23
- from fincli.app.storage.secrets import load_local_secrets
24
-
25
-
26
- @dataclass(slots=True)
27
- class FinCLISettings:
28
- ai_provider: str = "openrouter"
29
- ai_model: str = "openai/gpt-4o-mini"
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
- load_local_secrets()
68
-
69
- if not self.config_file.exists():
70
- return FinCLISettings()
71
-
72
- try:
73
- raw = json.loads(self.config_file.read_text(encoding="utf-8"))
74
- allowed = FinCLISettings.__dataclass_fields__.keys()
75
- filtered = {key: value for key, value in raw.items() if key in allowed}
76
- return FinCLISettings(**filtered)
77
- except Exception as exc: # noqa: BLE001
78
- raise ConfigError(
79
- "Config lokal gagal dibaca.",
80
- "Periksa ~/.fincli/config.json atau hapus file tersebut untuk memakai default.",
81
- ) from exc
82
-
83
- def save(self) -> None:
84
- try:
85
- self.config_file.parent.mkdir(parents=True, exist_ok=True)
86
- self.config_file.write_text(
87
- json.dumps(asdict(self.settings), indent=2),
88
- encoding="utf-8",
89
- )
90
- except Exception as exc: # noqa: BLE001
91
- raise ConfigError("Config lokal gagal disimpan.") from exc
92
-
93
- def set_ai_model(self, provider: str, model: str) -> None:
94
- self.settings.ai_provider = provider.strip().lower()
95
- self.settings.ai_model = model.strip()
96
- self.save()
97
-
98
- def set_market_provider(self, provider: str) -> None:
99
- self.settings.market_provider = provider.strip().lower()
100
- self.save()
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 set_market_provider_priority(self, providers: list[str]) -> None:
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]