@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
@@ -1,638 +1,1077 @@
1
- """Command parsing and routing."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- from concurrent.futures import ThreadPoolExecutor
7
- from dataclasses import dataclass
8
- from datetime import date
9
- import os
10
- import shlex
11
- from typing import Any
12
-
13
- from rich.panel import Panel
14
- from rich.table import Table
15
-
16
- from fincli.app.cli.commands import CommandRegistry
1
+ """Command parsing and routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass
8
+ from datetime import date
9
+ import io
10
+ import os
11
+ import shlex
12
+ from typing import Any
13
+
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+
18
+ from fincli.app.cli.commands import CommandRegistry
17
19
  from fincli.app.analysis.analyzer import build_market_analysis_prompt, build_technical_ai_summary
18
- from fincli.app.analysis.assistant_context import (
19
- build_fincli_assistant_prompt,
20
- coding_refusal,
21
- extract_market_symbols,
22
- is_coding_request,
23
- )
24
- from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
25
- from fincli.app.analysis.market_structure import MarketStructureSummary, analyze_market_structure
26
- from fincli.app.analysis.technical_debate import TechnicalDebate, format_debate, run_technical_debate
27
- from fincli.app.analysis.technical_signal import TechnicalSignal, format_signal
20
+ from fincli.app.analysis.backtest import BacktestResult, run_backtest
21
+ from fincli.app.analysis.gameplay_plan import format_gameplay_context
22
+ from fincli.app.agents.registry import Agent, AgentRegistry
23
+ from fincli.app.analysis.assistant_context import (
24
+ build_web_research_answer_prompt,
25
+ build_fincli_assistant_prompt,
26
+ coding_refusal,
27
+ extract_market_symbols,
28
+ is_coding_request,
29
+ )
30
+ from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
31
+ from fincli.app.analysis.market_structure import MarketStructureSummary, analyze_market_structure
32
+ from fincli.app.analysis.multi_timeframe import MultiTimeframeAnalysis, analyze_multi_timeframe
33
+ from fincli.app.analysis.technical_debate import TechnicalDebate, format_debate, run_technical_debate
34
+ from fincli.app.analysis.technical_signal import TechnicalSignal, format_signal
28
35
  from fincli.app.modules.economic_calendar import (
29
36
  EconomicCalendarService,
30
37
  EconomicEvent,
38
+ PublicEconomicCalendarService,
39
+ calendar_summary,
31
40
  default_calendar_window,
32
- fallback_events,
33
- filter_events,
34
- )
35
- from fincli.app.modules.exporter import export_rows
36
- from fincli.app.modules.journal_analytics import JournalStats, build_journal_review_prompt, calculate_journal_stats
37
- from fincli.app.modules.journal import JournalService
38
- from fincli.app.modules.portfolio import PortfolioService
39
- from fincli.app.modules.scanner import ScanResult, scan_symbols
41
+ economic_event_rows,
42
+ fallback_events,
43
+ filter_events,
44
+ )
45
+ from fincli.app.modules.alerts import AlertCheckResult, AlertService, evaluate_alert
46
+ from fincli.app.modules.exporter import export_rows
47
+ from fincli.app.modules.journal_analytics import JournalStats, build_journal_review_prompt, calculate_journal_stats
48
+ from fincli.app.modules.journal import JournalService
49
+ from fincli.app.modules.portfolio import PortfolioService
50
+ from fincli.app.modules.scanner import ScanResult, scan_symbols
51
+ from fincli.app.modules.session_history import SessionHistoryService
40
52
  from fincli.app.modules.transactions import TransactionService
53
+ from fincli.app.modules.user_profile import UserProfile, UserProfileService
41
54
  from fincli.app.modules.watchlist import WatchlistService
42
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
43
- from fincli.app.providers.ai.manager import AIProviderManager
44
- from fincli.app.providers.market.base import BaseMarketProvider, FundamentalSnapshot, NewsItem, Quote
45
- from fincli.app.providers.market.manager import MarketProviderManager
55
+ from fincli.app.connectors.catalog import Connector, ConnectorCatalog
56
+ from fincli.app.connectors.news_connectors import (
57
+ NewsConnectorCatalog,
58
+ NewsConnectorManager,
59
+ NewsConnectorSpec,
60
+ news_connector_secret_key,
61
+ )
62
+ from fincli.app.modules.reports import write_market_report
63
+ from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
64
+ from fincli.app.providers.ai.manager import AIProviderManager
65
+ from fincli.app.providers.market.base import (
66
+ BaseMarketProvider,
67
+ FundamentalSnapshot,
68
+ NewsItem,
69
+ ProviderEntitlement,
70
+ Quote,
71
+ SymbolSearchResult,
72
+ )
73
+ from fincli.app.providers.market.manager import MarketProviderManager
74
+ from fincli.app.providers.market.symbols import provider_symbol_matrix, search_symbol_catalog
46
75
  from fincli.app.providers.market.yfinance_provider import YahooTable, YFinanceProvider
76
+ from fincli.app.plugins.loader import PluginLoader, PluginManifest
47
77
  from fincli.app.services.market_data import MarketDataService
48
78
  from fincli.app.services.market_overview import MarketOverview, build_market_overview
49
- from fincli.app.storage.cache import TTLCache
50
- from fincli.app.storage.config import ConfigManager
51
- from fincli.app.storage.database import FinCLIDatabase
52
- from fincli.app.storage.market_cache import MarketCache
53
- from fincli.app.storage.secrets import save_secret
54
- from fincli.app.utils.errors import CommandError, FinCLIError
55
-
56
-
57
- @dataclass(slots=True)
58
- class CommandResult:
59
- renderable: Any
60
- status: str = "ready"
61
- clear: bool = False
62
- should_exit: bool = False
63
-
64
-
65
- class CommandRouter:
66
- """Route slash commands to services."""
67
-
68
- def __init__(
69
- self,
70
- config: ConfigManager | None = None,
71
- db: FinCLIDatabase | None = None,
72
- registry: CommandRegistry | None = None,
73
- market_provider: BaseMarketProvider | None = None,
74
- ai_provider: BaseAIProvider | None = None,
75
- ) -> None:
76
- self.config = config or ConfigManager()
77
- self.db = db or FinCLIDatabase()
78
- self.registry = registry or CommandRegistry()
79
- self.cache: TTLCache[object] = TTLCache(self.config.settings.cache_ttl_seconds)
80
- self.market_cache = MarketCache(self.db)
81
- self.market_manager = MarketProviderManager()
82
- self.market_service = self._build_market_service(market_provider)
83
- self.market_provider = self.market_service.primary_provider
84
- self.ai_provider = ai_provider or AIProviderManager().create(self.config.settings.ai_provider)
85
- self.watchlist = WatchlistService(self.db)
86
- self.portfolio = PortfolioService(self.db)
79
+ from fincli.app.services.macro_data import MacroDataService, MacroIndicator
80
+ from fincli.app.services.news_aggregator import NewsAggregator, NewsDesk
81
+ from fincli.app.services.web_research import (
82
+ WebResearchService,
83
+ WebSearchResult,
84
+ build_web_research_context,
85
+ should_use_web_research,
86
+ )
87
+ from fincli.app.research import ResearchEngine, format_research_brief
88
+ from fincli.app.storage.cache import TTLCache
89
+ from fincli.app.storage.config import ConfigManager
90
+ from fincli.app.storage.database import FinCLIDatabase
91
+ from fincli.app.storage.market_cache import MarketCache
92
+ from fincli.app.storage.secrets import save_secret
93
+ from fincli.app.utils.errors import CommandError, FinCLIError
94
+ from fincli.app.utils.formatting import AIResponseView, MarkdownBlock, semantic_text
95
+
96
+
97
+ @dataclass(slots=True)
98
+ class CommandResult:
99
+ renderable: Any
100
+ status: str = "ready"
101
+ clear: bool = False
102
+ should_exit: bool = False
103
+
104
+
105
+ class CommandRouter:
106
+ """Route slash commands to services."""
107
+
108
+ def __init__(
109
+ self,
110
+ config: ConfigManager | None = None,
111
+ db: FinCLIDatabase | None = None,
112
+ registry: CommandRegistry | None = None,
113
+ market_provider: BaseMarketProvider | None = None,
114
+ ai_provider: BaseAIProvider | None = None,
115
+ ) -> None:
116
+ self.config = config or ConfigManager()
117
+ self.db = db or FinCLIDatabase()
118
+ self.registry = registry or CommandRegistry()
119
+ self.cache: TTLCache[object] = TTLCache(self.config.settings.cache_ttl_seconds)
120
+ self.market_cache = MarketCache(self.db)
121
+ self.market_manager = MarketProviderManager()
122
+ self.market_service = self._build_market_service(market_provider)
123
+ self.market_provider = self.market_service.primary_provider
124
+ self.ai_provider = ai_provider or AIProviderManager().create(self.config.settings.ai_provider)
125
+ self.watchlist = WatchlistService(self.db)
126
+ self.portfolio = PortfolioService(self.db)
127
+ self.alerts = AlertService(self.db)
87
128
  self.transactions = TransactionService(self.db, self.portfolio)
88
129
  self.journal = JournalService(self.db)
89
-
90
- def route(self, raw: str) -> CommandResult:
91
- raw = raw.strip()
92
- if not raw:
93
- return CommandResult(Panel("Ketik /help untuk melihat command.", title="FinCLI"))
94
- if not raw.startswith("/"):
95
- return CommandResult(Panel("Command harus diawali slash. Contoh: /help", title="Invalid Input"))
96
-
97
- try:
98
- if raw.lower().startswith("/export "):
99
- export_parts = raw.split(maxsplit=3)
100
- if len(export_parts) == 4:
101
- return self._export(export_parts[1:])
102
-
103
- parts = shlex.split(raw)
104
- if not parts:
105
- raise CommandError("Command kosong.")
106
-
107
- root = parts[0].lower()
108
- args = parts[1:]
109
-
110
- if root == "/help":
111
- return CommandResult(self._help_table())
112
- if root == "/dashboard":
113
- return CommandResult(self._dashboard())
114
- if root == "/clear":
115
- return CommandResult("", clear=True)
116
- if root == "/exit":
117
- return CommandResult("Keluar dari FinCLI.", should_exit=True)
118
- if root == "/config":
119
- return CommandResult(self._config_panel())
120
- if root == "/ai_model":
121
- return self._ai_model(args)
122
- if root == "/news_model":
123
- return self._news_model(args)
130
+ self.user_profiles = UserProfileService(self.db)
131
+ self.history = SessionHistoryService(self.db)
132
+ self.session_id = self.history.start_session()
133
+ self.web_research = WebResearchService()
134
+ self.macro_data = MacroDataService()
135
+ self.agent_registry = AgentRegistry()
136
+ self.connector_catalog = ConnectorCatalog()
137
+ self.news_connector_catalog = NewsConnectorCatalog()
138
+ self.news_connectors = NewsConnectorManager(self.news_connector_catalog)
139
+
140
+ def route(self, raw: str) -> CommandResult:
141
+ result = self._route(raw)
142
+ self._record_history(raw, result)
143
+ return result
144
+
145
+ def _route(self, raw: str) -> CommandResult:
146
+ raw = raw.strip()
147
+ if not raw:
148
+ return CommandResult(Panel("Ketik /help untuk melihat command.", title="FinCLI"))
149
+ if not raw.startswith("/"):
150
+ return CommandResult(Panel("Command harus diawali slash. Contoh: /help", title="Invalid Input"))
151
+
152
+ try:
153
+ if raw.lower().startswith("/export "):
154
+ export_parts = raw.split(maxsplit=3)
155
+ if len(export_parts) == 4:
156
+ return self._export(export_parts[1:])
157
+
158
+ parts = _split_command(raw)
159
+ if not parts:
160
+ raise CommandError("Command kosong.")
161
+
162
+ root = parts[0].lower()
163
+ args = parts[1:]
164
+
165
+ if root == "/help":
166
+ return CommandResult(self._help_table())
167
+ if root == "/dashboard":
168
+ return CommandResult(self._dashboard())
169
+ if root == "/clear":
170
+ return CommandResult("", clear=True)
171
+ if root == "/exit":
172
+ return CommandResult("Keluar dari FinCLI.", should_exit=True)
173
+ if root == "/config":
174
+ return CommandResult(self._config_panel())
175
+ if root == "/history":
176
+ return self._history(args)
177
+ if root == "/ai_model":
178
+ return self._ai_model(args)
179
+ if root == "/news_model":
180
+ return self._news_model(args)
124
181
  if root == "/provider":
125
182
  return self._provider(args)
183
+ if root == "/symbol":
184
+ return self._symbol(args)
185
+ if root == "/research":
186
+ return self._research(args)
187
+ if root == "/macro":
188
+ return self._macro(args)
189
+ if root == "/profile":
190
+ return self._profile(args)
191
+ if root == "/doctor":
192
+ return self._doctor(args)
193
+ if root == "/setup":
194
+ return self._setup(args)
195
+ if root == "/agent":
196
+ return self._agent(args)
197
+ if root == "/connector":
198
+ return self._connector(args)
199
+ if root == "/plugin":
200
+ return self._plugin(args)
126
201
  if root == "/cache":
127
202
  return self._cache(args)
128
- if root == "/watchlist":
129
- return self._watchlist(args)
130
- if root == "/portfolio":
131
- return self._portfolio(args)
132
- if root == "/tx":
133
- return self._tx(args)
134
- if root == "/journal":
135
- return self._journal(args)
136
- if root in {"/price", "/quote"}:
137
- return self._price(args)
138
- if root == "/market":
139
- return self._market(args)
140
- if root == "/technical":
141
- return self._technical(args)
142
- if root == "/structure":
143
- return self._structure(args)
144
- if root == "/news":
145
- return self._news(args)
146
- if root == "/funda":
147
- return self._fundamentals(args)
148
- if root == "/yahoo":
149
- return self._yahoo(args)
150
- if root == "/ai":
151
- return self._ai(args)
152
- if root == "/analyze":
153
- return self._analyze(args)
154
- if root == "/scan":
155
- return self._scan(args)
156
- if root == "/calendar":
157
- return self._calendar(args)
158
- if root == "/export":
159
- return self._export(args)
160
-
161
- raise CommandError(f"Command tidak dikenal: {root}", "Gunakan /help untuk melihat daftar command.")
162
- except FinCLIError as exc:
163
- message = str(exc)
164
- if exc.help_text:
165
- message = f"{message}\n\n{exc.help_text}"
166
- return CommandResult(Panel(message, title="Error", border_style="red"), status="error")
203
+ if root == "/watchlist":
204
+ return self._watchlist(args)
205
+ if root == "/portfolio":
206
+ return self._portfolio(args)
207
+ if root == "/tx":
208
+ return self._tx(args)
209
+ if root == "/journal":
210
+ return self._journal(args)
211
+ if root == "/alert":
212
+ return self._alert(args)
213
+ if root == "/quote":
214
+ return self._quote(args)
215
+ if root == "/market":
216
+ return self._market(args)
217
+ if root == "/technical":
218
+ return self._technical(args)
219
+ if root == "/mtf":
220
+ return self._mtf(args)
221
+ if root == "/backtest":
222
+ return self._backtest(args)
223
+ if root == "/structure":
224
+ return self._structure(args)
225
+ if root == "/news":
226
+ return self._news(args)
227
+ if root == "/web":
228
+ return self._web(args)
229
+ if root == "/funda":
230
+ return self._fundamentals(args)
231
+ if root == "/yahoo":
232
+ return self._yahoo(args)
233
+ if root == "/ai":
234
+ return self._ai(args)
235
+ if root == "/analyze":
236
+ return self._analyze(args)
237
+ if root == "/scan":
238
+ return self._scan(args)
239
+ if root == "/report":
240
+ return self._report(args)
241
+ if root == "/calendar":
242
+ return self._calendar(args)
243
+ if root == "/export":
244
+ return self._export(args)
245
+
246
+ raise CommandError(f"Command tidak dikenal: {root}", "Gunakan /help untuk melihat daftar command.")
247
+ except FinCLIError as exc:
248
+ message = str(exc)
249
+ if exc.help_text:
250
+ message = f"{message}\n\n{exc.help_text}"
251
+ return CommandResult(Panel(message, title="Error", border_style="red"), status="error")
167
252
  except ValueError as exc:
168
253
  return CommandResult(
169
254
  Panel(f"Format command tidak valid: {exc}\nGunakan quote untuk teks panjang.", title="Error"),
170
255
  status="error",
171
256
  )
172
-
173
- def _help_table(self) -> Table:
174
- table = Table(title="FinCLI v0.1 Commands", expand=True)
175
- table.add_column("Command", style="cyan", no_wrap=True)
176
- table.add_column("Group", style="magenta")
177
- table.add_column("Fungsi", style="white")
178
- table.add_column("Contoh", style="green")
179
- for command in self.registry.all():
180
- table.add_row(command.name, command.group, command.description, command.example)
181
- return table
182
-
183
- def _dashboard(self) -> Table:
184
- return _format_dashboard(
185
- provider_chain=[provider.name for provider in self.market_service.providers],
186
- watchlist_rows=self.watchlist.list(),
187
- portfolio_rows=self.portfolio.list(),
188
- journal_stats=calculate_journal_stats(self.journal.list(limit=10_000)),
189
- realized_pnl=self.transactions.realized_pnl_total(),
190
- quote_getter=self._safe_quote,
191
- portfolio_value_getter=self._portfolio_market_values,
192
- )
193
-
194
- def _config_panel(self) -> Panel:
195
- safe = self.config.settings.safe_dict()
196
- lines = [
197
- f"AI provider : {safe['ai_provider']}",
198
- f"AI model : {safe['ai_model']}",
199
- f"Market provider : {safe['market_provider']}",
200
- f"News provider : {safe['news_provider']}",
201
- f"Timezone : {safe['timezone']}",
202
- f"Default currency : {safe['default_currency']}",
203
- f"Cache TTL : {safe['cache_ttl_seconds']}s",
204
- f"Theme : {safe['theme']}",
205
- "",
206
- "API key status:",
207
- ]
208
- lines.extend(f"- {key}: {value}" for key, value in safe["api_keys"].items())
209
- return Panel("\n".join(lines), title="Active Config", border_style="cyan")
210
-
211
- def _ai_model(self, args: list[str]) -> CommandResult:
212
- if len(args) == 0:
213
- current = self.config.settings
214
- return CommandResult(Panel(f"{current.ai_provider} / {current.ai_model}", title="Active AI Model"))
215
- if args[0].lower() == "key":
216
- if len(args) < 3:
217
- raise CommandError("Format: /ai_model key <provider> <api_key>")
218
- provider = args[1].lower()
219
- info = AIProviderManager().get(provider)
220
- if info is None:
221
- raise CommandError(f"AI provider tidak dikenal: {provider}")
222
- save_secret(info.env_key, args[2])
223
- if self.config.settings.ai_provider == provider:
224
- self.ai_provider = AIProviderManager().create(provider)
257
+ except Exception as exc: # noqa: BLE001
225
258
  return CommandResult(
226
259
  Panel(
227
- f"API key AI untuk {provider} disimpan di ~/.fincli/secrets.env.\nKey tidak ditampilkan di terminal.",
228
- title="AI API Key Saved",
229
- border_style="green",
230
- )
260
+ (
261
+ f"Unexpected command error: {type(exc).__name__}: {exc}\n\n"
262
+ "Command tidak dieksekusi penuh. Gunakan /doctor untuk cek konfigurasi atau coba ulang command."
263
+ ),
264
+ title="Error",
265
+ border_style="red",
266
+ ),
267
+ status="error",
231
268
  )
232
- if len(args) < 2:
233
- raise CommandError("Format: /ai_model <provider> <model>")
234
- self.config.set_ai_model(args[0], args[1])
235
- self.ai_provider = AIProviderManager().create(args[0])
236
- return CommandResult(Panel(f"AI model aktif: {args[0]} / {args[1]}", title="AI Model Updated"))
237
-
269
+
270
+ def _help_table(self) -> Table:
271
+ table = Table(title="FinCLI v0.2.2 Commands", expand=True)
272
+ table.add_column("Command", style="cyan", no_wrap=True)
273
+ table.add_column("Group", style="magenta")
274
+ table.add_column("Fungsi", style="white")
275
+ table.add_column("Contoh", style="green")
276
+ for command in self.registry.all():
277
+ table.add_row(command.name, command.group, command.description, command.example)
278
+ return table
279
+
280
+ def _record_history(self, raw: str, result: CommandResult) -> None:
281
+ normalized = raw.strip().lower()
282
+ if not normalized or normalized.startswith("/history"):
283
+ return
284
+ try:
285
+ preview = _render_history_preview(result.renderable)
286
+ self.history.record_event(self.session_id, raw, result.status, preview)
287
+ except FinCLIError:
288
+ return
289
+
290
+ def _history(self, args: list[str]) -> CommandResult:
291
+ action = args[0].lower() if args else "current"
292
+ if action in {"current", "show"}:
293
+ session_id = self.session_id if action == "current" or len(args) == 1 else args[1]
294
+ if action == "show" and len(args) < 2:
295
+ raise CommandError("Format: /history show <session_id>")
296
+ session = self.history.get_session(session_id)
297
+ if not session:
298
+ raise CommandError(f"Session tidak ditemukan: {session_id}")
299
+ events = self.history.get_events(session_id)
300
+ return CommandResult(_format_session_events(session, events, current=session_id == self.session_id))
301
+ if action in {"sessions", "list"}:
302
+ sessions = self.history.list_sessions()
303
+ return CommandResult(_format_sessions(sessions, self.session_id))
304
+ if action == "save":
305
+ title = " ".join(args[1:]).strip()
306
+ if not title:
307
+ raise CommandError("Format: /history save <session_title>")
308
+ self.history.save_session(self.session_id, title)
309
+ return CommandResult(Panel(f"Current session disimpan sebagai: {title}", title="History", border_style="green"))
310
+ if action == "delete":
311
+ if len(args) < 2:
312
+ raise CommandError("Format: /history delete <session_id>")
313
+ if args[1] == self.session_id:
314
+ self.history.clear_events(self.session_id)
315
+ self.history.save_session(self.session_id, "FinCLI session")
316
+ return CommandResult(Panel("Current session dikosongkan.", title="History", border_style="yellow"))
317
+ self.history.delete_session(args[1])
318
+ return CommandResult(Panel(f"Session dihapus: {args[1]}", title="History", border_style="green"))
319
+ if action == "clear":
320
+ target = args[1].lower() if len(args) >= 2 else "current"
321
+ if target == "all":
322
+ self.history.clear_all()
323
+ self.session_id = self.history.start_session()
324
+ return CommandResult(Panel("Semua history session dihapus. Session baru dibuat.", title="History"))
325
+ self.history.clear_events(self.session_id)
326
+ return CommandResult(Panel("Current session history dikosongkan.", title="History"))
327
+ raise CommandError(
328
+ "Format: /history [current|sessions|show <id>|save <title>|delete <id>|clear current|clear all]"
329
+ )
330
+
331
+ def _dashboard(self) -> Table:
332
+ return _format_dashboard(
333
+ provider_chain=[provider.name for provider in self.market_service.providers],
334
+ watchlist_rows=self.watchlist.list(),
335
+ portfolio_rows=self.portfolio.list(),
336
+ journal_stats=calculate_journal_stats(self.journal.list(limit=10_000)),
337
+ realized_pnl=self.transactions.realized_pnl_total(),
338
+ quote_getter=self._safe_quote,
339
+ portfolio_value_getter=self._portfolio_market_values,
340
+ )
341
+
342
+ def _config_panel(self) -> Panel:
343
+ safe = self.config.settings.safe_dict()
344
+ lines = [
345
+ f"AI provider : {safe['ai_provider']}",
346
+ f"AI model : {safe['ai_model']}",
347
+ f"Market provider : {safe['market_provider']}",
348
+ f"News provider : {safe['news_provider']}",
349
+ f"News priority : {', '.join(safe.get('news_provider_priority', []))}",
350
+ f"Timezone : {safe['timezone']}",
351
+ f"Default currency : {safe['default_currency']}",
352
+ f"Cache TTL : {safe['cache_ttl_seconds']}s",
353
+ f"Theme : {safe['theme']}",
354
+ "",
355
+ "API key status:",
356
+ ]
357
+ lines.extend(f"- {key}: {value}" for key, value in safe["api_keys"].items())
358
+ return Panel("\n".join(lines), title="Active Config", border_style="cyan")
359
+
360
+ def _ai_model(self, args: list[str]) -> CommandResult:
361
+ if len(args) == 0:
362
+ current = self.config.settings
363
+ return CommandResult(Panel(f"{current.ai_provider} / {current.ai_model}", title="Active AI Model"))
364
+ if args[0].lower() == "key":
365
+ if len(args) < 3:
366
+ raise CommandError("Format: /ai_model key <provider> <api_key>")
367
+ provider = args[1].lower()
368
+ info = AIProviderManager().get(provider)
369
+ if info is None:
370
+ raise CommandError(f"AI provider tidak dikenal: {provider}")
371
+ save_secret(info.env_key, args[2])
372
+ model = self.config.settings.ai_model if self.config.settings.ai_provider == provider else info.default_model
373
+ self.config.set_ai_model(provider, model)
374
+ self.ai_provider = AIProviderManager().create(provider)
375
+ return CommandResult(
376
+ Panel(
377
+ (
378
+ f"API key AI untuk {provider} disimpan global di ~/.fincli/secrets.env.\n"
379
+ f"Provider aktif disimpan: {provider} / {model}.\n"
380
+ "Key tidak ditampilkan di terminal dan dipakai lintas session."
381
+ ),
382
+ title="AI API Key Saved",
383
+ border_style="green",
384
+ )
385
+ )
386
+ if len(args) < 2:
387
+ raise CommandError("Format: /ai_model <provider> <model>")
388
+ self.config.set_ai_model(args[0], args[1])
389
+ self.ai_provider = AIProviderManager().create(args[0])
390
+ return CommandResult(Panel(f"AI model aktif: {args[0]} / {args[1]}", title="AI Model Updated"))
391
+
238
392
  def _news_model(self, args: list[str]) -> CommandResult:
239
393
  if len(args) == 0:
240
394
  current = self.config.settings
241
- chain = ", ".join(current.market_provider_priority or [current.market_provider])
395
+ chain = ", ".join(current.news_provider_priority or [current.news_provider])
242
396
  return CommandResult(
243
397
  Panel(
244
398
  (
245
399
  f"Market: {current.market_provider}\n"
246
400
  f"News: {current.news_provider}\n"
247
401
  f"Fallback priority: {chain}\n\n"
248
- "Di TUI, gunakan /news_model untuk membuka provider selector."
402
+ "Commands:\n"
403
+ "- /news_model list\n"
404
+ "- /news_model search <query>\n"
405
+ "- /news_model use <provider>\n"
406
+ "- /news_model priority google_news_rss,yfinance,marketaux\n"
407
+ "- /news_model key <provider> <api_key> [base_url]"
249
408
  ),
250
409
  title="Active Data Provider",
251
410
  )
252
411
  )
253
- if args[0].lower() == "key":
254
- if len(args) < 3:
255
- raise CommandError("Format: /news_model key <provider> <api_key> [base_url untuk custom]")
256
- provider = args[1].lower()
257
- env_keys = _market_provider_secret_keys(provider)
258
- if not env_keys:
259
- raise CommandError(f"Provider {provider} tidak membutuhkan API key atau tidak dikenal.")
260
- save_secret(env_keys[0], args[2])
261
- if provider == "custom" and len(args) >= 4:
262
- save_secret("MARKET_DATA_BASE_URL", args[3])
263
- self._refresh_market_service()
264
- self.cache.clear()
265
- extra = "\nBase URL custom juga disimpan." if provider == "custom" and len(args) >= 4 else ""
412
+ action = args[0].lower()
413
+ if action == "list":
414
+ return CommandResult(_format_news_connectors(self.news_connector_catalog.free_first()[:120], "all"))
415
+ if action == "search":
416
+ query = " ".join(args[1:]).strip()
417
+ if not query:
418
+ raise CommandError("Format: /news_model search <query>")
419
+ return CommandResult(_format_news_connectors(self.news_connector_catalog.search(query), query))
420
+ if action == "priority":
421
+ if len(args) < 2:
422
+ raise CommandError("Format: /news_model priority google_news_rss,yfinance,marketaux")
423
+ providers = [provider.strip().lower() for provider in args[1].split(",") if provider.strip()]
424
+ self._validate_news_providers(providers)
425
+ self.config.set_news_provider_priority(providers)
266
426
  return CommandResult(
267
427
  Panel(
268
- f"API key market/news untuk {provider} disimpan di ~/.fincli/secrets.env.{extra}\n"
269
- "Key tidak ditampilkan di terminal.",
270
- title="Market API Key Saved",
428
+ f"News fallback priority disimpan: {', '.join(self.config.settings.news_provider_priority)}",
429
+ title="News Priority Updated",
271
430
  border_style="green",
272
431
  )
273
432
  )
274
- self.config.set_market_provider(args[0])
275
- self.config.set_news_provider(args[0])
276
- self.config.set_market_provider_priority([args[0], *self._priority_tail(args[0])])
277
- self._refresh_market_service()
278
- self.cache.clear()
279
- return CommandResult(Panel(f"Provider market/news aktif: {args[0]}", title="Provider Updated"))
280
-
281
- def _provider(self, args: list[str]) -> CommandResult:
282
- if args and args[0].lower() == "list":
283
- return CommandResult(_format_provider_list())
284
- if args and args[0].lower() == "key" and len(args) >= 2 and args[1].lower() == "status":
285
- return CommandResult(_format_provider_key_status(self.market_manager))
286
- if args and args[0].lower() == "use":
433
+ if action == "use":
287
434
  if len(args) < 2:
288
- raise CommandError("Format: /provider use <provider>")
435
+ raise CommandError("Format: /news_model use <provider>")
289
436
  provider = args[1].lower()
290
- self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
291
- self._refresh_market_service()
292
- self.cache.clear()
293
- return CommandResult(Panel(f"Provider market aktif: {provider}", title="Provider Updated"))
294
- if args and args[0].lower() == "priority":
295
- if len(args) < 2:
296
- raise CommandError("Format: /provider priority finnhub,yfinance")
297
- providers = [provider.strip() for provider in args[1].split(",") if provider.strip()]
298
- self.config.set_market_provider_priority(providers)
299
- self._refresh_market_service()
300
- self.cache.clear()
301
- return CommandResult(Panel(f"Provider priority: {', '.join(providers)}", title="Provider Priority"))
302
- if args and args[0].lower() == "status":
303
- settings = self.config.settings
304
- provider_status = self._provider_health_text()
305
- text = (
306
- f"Market provider: {settings.market_provider} (active: {self.market_provider.name})\n"
307
- f"News provider : {settings.news_provider} (active: {self.market_provider.name} fallback)\n"
308
- f"Provider chain : {', '.join(provider.name for provider in self.market_service.providers)}\n"
309
- f"AI provider : {settings.ai_provider} (active: {self.ai_provider.name})\n"
310
- f"{provider_status}"
437
+ self._validate_news_providers([provider])
438
+ current = [item for item in self.config.settings.news_provider_priority if item != provider]
439
+ self.config.set_news_provider_priority([provider, *current])
440
+ return CommandResult(
441
+ Panel(
442
+ f"News primary provider: {provider}\nFallback: {', '.join(self.config.settings.news_provider_priority)}",
443
+ title="News Provider Updated",
444
+ border_style="green",
445
+ )
311
446
  )
312
- return CommandResult(Panel(text, title="Provider Status", border_style="yellow"))
313
- if args and args[0].lower() == "test":
314
- if len(args) < 2:
315
- raise CommandError("Format: /provider test [provider] <symbol>")
316
- if len(args) >= 3:
317
- provider = self.market_manager.create(args[1])
318
- quote = self.market_service.run(provider.quote(args[2]))
447
+ if action == "key":
448
+ if len(args) < 3:
449
+ raise CommandError("Format: /news_model key <provider> <api_key> [base_url untuk custom]")
450
+ provider = args[1].lower()
451
+ env_key = news_connector_secret_key(provider)
452
+ env_keys = (env_key,) if env_key else _market_provider_secret_keys(provider)
453
+ if not env_keys:
454
+ raise CommandError(f"Provider {provider} tidak membutuhkan API key atau tidak dikenal.")
455
+ save_secret(env_keys[0], args[2])
456
+ if provider == "custom_news" and len(args) >= 4:
457
+ save_secret("CUSTOM_NEWS_BASE_URL", args[3])
458
+ elif provider == "custom" and len(args) >= 4:
459
+ save_secret("MARKET_DATA_BASE_URL", args[3])
460
+ if self.market_manager.get(provider) is not None:
461
+ self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
462
+ self.config.set_news_provider(provider)
463
+ self._refresh_market_service()
319
464
  else:
320
- quote = self._get_quote(args[1])
321
- return CommandResult(_format_quote(quote))
322
- raise CommandError(
323
- "Format: /provider status, /provider list, /provider key status, /provider use <provider>, "
324
- "/provider priority finnhub,yfinance, atau /provider test [provider] <symbol>"
325
- )
326
-
327
- def _cache(self, args: list[str]) -> CommandResult:
328
- if args and args[0].lower() == "stats":
329
- stats = self.market_cache.stats()
330
- lines = [
331
- f"Runtime cache TTL : {self.config.settings.cache_ttl_seconds}s",
332
- f"Persistent entries: {stats['total']}",
333
- f"- quote : {stats['quote']}",
334
- f"- history : {stats['history']}",
335
- f"- news : {stats['news']}",
336
- f"- fundamentals : {stats['fundamentals']}",
337
- ]
338
- return CommandResult(Panel("\n".join(lines), title="Cache Stats", border_style="cyan"))
339
- if args and args[0].lower() == "clear":
465
+ self.config.set_news_provider_priority([provider, *self._news_priority_tail(provider)])
340
466
  self.cache.clear()
341
- cleared = self.market_cache.clear()
342
- return CommandResult(Panel(f"Runtime cache dan persistent cache dibersihkan ({cleared} entry).", title="Cache"))
343
- raise CommandError("Format: /cache clear atau /cache stats")
344
-
345
- def _watchlist(self, args: list[str]) -> CommandResult:
346
- if not args:
347
- rows = self.watchlist.list()
348
- table = Table(title="Watchlist", expand=True)
349
- table.add_column("Symbol", style="cyan")
350
- table.add_column("Price", justify="right")
351
- table.add_column("Currency")
352
- table.add_column("Status")
353
- table.add_column("Group")
354
- table.add_column("Created")
355
- for row in rows:
356
- quote = self._safe_quote(str(row["symbol"]))
357
- table.add_row(
358
- str(row["symbol"]),
359
- _fmt(quote.price) if quote else "N/A",
360
- quote.currency if quote else "-",
361
- quote.status if quote else "unavailable",
362
- str(row["group_name"]),
363
- str(row["created_at"]),
364
- )
365
- if not rows:
366
- table.add_row("-", "-", "-", "-", "Belum ada data. Gunakan /watchlist add AAPL", "-")
367
- return CommandResult(table)
368
-
369
- action = args[0].lower()
370
- if action == "add" and len(args) >= 2:
371
- self.watchlist.add(args[1], args[2] if len(args) >= 3 else "default")
372
- return CommandResult(Panel(f"{args[1].upper()} ditambahkan ke watchlist.", title="Watchlist"))
373
- if action == "remove" and len(args) >= 2:
374
- self.watchlist.remove(args[1])
375
- return CommandResult(Panel(f"{args[1].upper()} dihapus dari watchlist.", title="Watchlist"))
376
- raise CommandError("Format: /watchlist, /watchlist add <symbol>, /watchlist remove <symbol>")
377
-
378
- def _portfolio(self, args: list[str]) -> CommandResult:
379
- if not args:
380
- rows = self.portfolio.list()
381
- table = Table(title="Portfolio", expand=True)
382
- table.add_column("Symbol", style="cyan")
383
- table.add_column("Qty", justify="right")
384
- table.add_column("Avg Price", justify="right")
385
- table.add_column("Current", justify="right")
386
- table.add_column("PnL", justify="right")
387
- table.add_column("PnL %", justify="right")
388
- table.add_column("Currency")
389
- table.add_column("Updated")
390
- for row in rows:
391
- current_price, pnl, pnl_percent = self._portfolio_market_values(row)
392
- table.add_row(
393
- str(row["symbol"]),
394
- f"{float(row['quantity']):,.8g}",
395
- f"{float(row['average_price']):,.4f}",
396
- _fmt(current_price),
397
- _fmt(pnl),
398
- _fmt(pnl_percent),
399
- str(row["currency"]),
400
- str(row["updated_at"]),
401
- )
402
- if not rows:
403
- table.add_row(
404
- "-",
405
- "-",
406
- "-",
407
- "-",
408
- "-",
409
- "-",
410
- "-",
411
- "Belum ada posisi. Gunakan /portfolio add BTC-USD 0.05 65000",
412
- )
413
- return CommandResult(table)
414
-
415
- action = args[0].lower()
416
- if action == "performance":
417
- return CommandResult(self._portfolio_performance_table())
418
- if action == "add" and len(args) >= 4:
419
- try:
420
- quantity = float(args[2])
421
- average_price = float(args[3])
422
- except ValueError as exc:
423
- raise CommandError("Quantity dan average price harus angka.") from exc
424
- self.portfolio.add(args[1], quantity, average_price, args[4] if len(args) >= 5 else "USD")
425
- return CommandResult(Panel(f"Posisi {args[1].upper()} disimpan.", title="Portfolio"))
426
- if action == "remove" and len(args) >= 2:
427
- self.portfolio.remove(args[1])
428
- return CommandResult(Panel(f"Posisi {args[1].upper()} dihapus.", title="Portfolio"))
429
- raise CommandError(
430
- "Format: /portfolio, /portfolio performance, /portfolio add <symbol> <qty> <avg_price>, "
431
- "/portfolio remove <symbol>"
432
- )
433
-
434
- def _tx(self, args: list[str]) -> CommandResult:
435
- if not args or args[0].lower() == "list":
436
- return CommandResult(_format_transactions(self.transactions.list()))
437
-
438
- if args[0].lower() == "add":
439
- if len(args) < 5:
440
- raise CommandError("Format: /tx add <buy|sell> <symbol> <qty> <price> [currency]")
441
- try:
442
- quantity = float(args[3])
443
- price = float(args[4])
444
- except ValueError as exc:
445
- raise CommandError("Quantity dan price harus angka.") from exc
446
- tx = self.transactions.add(
447
- action=args[1],
448
- symbol=args[2],
449
- quantity=quantity,
450
- price=price,
451
- currency=args[5] if len(args) >= 6 else "USD",
452
- )
467
+ extra = "\nBase URL custom juga disimpan." if provider in {"custom", "custom_news"} and len(args) >= 4 else ""
453
468
  return CommandResult(
454
469
  Panel(
455
470
  (
456
- f"Transaction saved: {tx['action']} {tx['symbol']} "
457
- f"{_fmt(float(tx['quantity']))} @ {_fmt(float(tx['price']))} "
458
- f"| Realized PnL {_fmt(float(tx['realized_pnl']))}"
471
+ f"API key market/news untuk {provider} disimpan global di ~/.fincli/secrets.env.{extra}\n"
472
+ f"Provider news aktif disimpan: {provider}.\n"
473
+ "Key tidak ditampilkan di terminal dan dipakai lintas session."
459
474
  ),
460
- title="Transaction",
475
+ title="News API Key Saved",
461
476
  border_style="green",
462
477
  )
463
478
  )
464
-
465
- raise CommandError("Format: /tx add <buy|sell> <symbol> <qty> <price> [currency] atau /tx list")
466
-
467
- def _journal(self, args: list[str]) -> CommandResult:
468
- if not args:
469
- rows = self.journal.list()
470
- return CommandResult(self._journal_table(rows, "Journal"))
471
-
472
- if args[0].lower() == "stats":
473
- rows = self.journal.list(limit=10_000)
474
- stats = calculate_journal_stats(rows)
475
- return CommandResult(_format_journal_stats(stats))
476
-
477
- if args[0].lower() == "review":
478
- rows = self.journal.list(limit=10_000)
479
- stats = calculate_journal_stats(rows)
480
- prompt = build_journal_review_prompt(rows, stats)
481
- response = self._run_async(self.ai_provider.complete(AIRequest(prompt=prompt, model=self.config.settings.ai_model)))
482
- if not isinstance(response, AIResponse):
483
- raise CommandError("AI provider mengembalikan data tidak valid.")
484
- return CommandResult(f"Journal Review\n{_format_ai_response(response)}\n\nDisclaimer: bukan nasihat keuangan.")
485
-
486
- if args[0].lower() == "add":
487
- if len(args) < 3:
488
- raise CommandError('Format: /journal add <instrument> <bias> "entry reason"')
489
- self.journal.add(args[1], bias=args[2], entry_reason=args[3] if len(args) >= 4 else "")
490
- return CommandResult(Panel(f"Journal untuk {args[1].upper()} ditambahkan.", title="Journal"))
491
-
492
- rows = self.journal.list(args[0])
493
- return CommandResult(self._journal_table(rows, f"Journal {args[0].upper()}"))
494
-
495
- def _journal_table(self, rows: list[dict[str, object]], title: str) -> Table:
496
- table = Table(title=title, expand=True)
497
- table.add_column("ID", justify="right")
498
- table.add_column("Instrument", style="cyan")
499
- table.add_column("Bias")
500
- table.add_column("Entry Reason")
501
- table.add_column("Created")
502
- for row in rows:
503
- table.add_row(
504
- str(row["id"]),
505
- str(row["instrument"]),
506
- str(row["bias"]),
507
- str(row["entry_reason"]),
508
- str(row["created_at"]),
509
- )
510
- if not rows:
511
- table.add_row("-", "-", "-", 'Belum ada journal. Gunakan /journal add BTC-USD bullish "Alasan entry"', "-")
512
- return table
513
-
514
- def _price(self, args: list[str]) -> CommandResult:
515
- if not args:
516
- raise CommandError("Format: /price <symbol>")
517
- symbol = args[0].upper()
518
- cache_key = f"quote:{symbol}"
519
- cached = self.cache.get(cache_key)
520
- quote = cached if isinstance(cached, Quote) else self._run_async(self.market_service.quote(symbol))
521
- if not isinstance(quote, Quote):
522
- raise CommandError("Provider quote mengembalikan data tidak valid.")
523
- self.cache.set(cache_key, quote)
524
- return CommandResult(_format_quote(quote))
525
-
526
- def _technical(self, args: list[str]) -> CommandResult:
527
- if not args:
528
- raise CommandError("Format: /technical <symbol> [interval]")
529
- symbol = args[0].upper()
530
- interval = args[1] if len(args) >= 2 else "1d"
531
- candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=interval))
532
- if not candles:
533
- raise CommandError(f"Data teknikal kosong untuk {symbol}.")
534
- summary = summarize_technical_indicators(candles)
535
- structure = analyze_market_structure(candles)
536
- debate = run_technical_debate(summary, structure, candles)
537
- signal = debate.judge_signal
538
- ai_summary = build_technical_ai_summary(symbol, interval, candles)
539
- return CommandResult(_format_technical(symbol, interval, summary, signal, ai_summary, debate))
540
-
541
- def _market(self, args: list[str]) -> CommandResult:
542
- if not args:
543
- raise CommandError("Format: /market <symbol> [interval]")
544
- symbol = args[0].upper()
545
- interval = args[1] if len(args) >= 2 else "1d"
546
- overview = self._run_async(build_market_overview(symbol, self.market_service, interval))
547
- return CommandResult(_format_market_overview(overview))
548
-
549
- def _structure(self, args: list[str]) -> CommandResult:
550
- if not args:
551
- raise CommandError("Format: /structure <symbol> [interval]")
552
- symbol = args[0].upper()
553
- interval = args[1] if len(args) >= 2 else "1d"
554
- candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=interval))
555
- if not candles:
556
- raise CommandError(f"Data struktur market kosong untuk {symbol}.")
557
- structure = analyze_market_structure(candles)
558
- return CommandResult(_format_structure(symbol, interval, structure))
559
-
560
- def _news(self, args: list[str]) -> CommandResult:
479
+ provider = args[0].lower()
480
+ if self.market_manager.get(provider) is not None:
481
+ self.config.set_market_provider(provider)
482
+ self.config.set_news_provider(provider)
483
+ self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
484
+ self._refresh_market_service()
485
+ self.cache.clear()
486
+ return CommandResult(Panel(f"Provider market/news aktif: {provider}", title="Provider Updated"))
487
+ self._validate_news_providers([provider])
488
+ self.config.set_news_provider_priority([provider, *self._news_priority_tail(provider)])
489
+ self.cache.clear()
490
+ return CommandResult(Panel(f"Provider news aktif: {provider}", title="News Provider Updated"))
491
+
492
+ def _provider(self, args: list[str]) -> CommandResult:
493
+ if args and args[0].lower() == "list":
494
+ return CommandResult(_format_provider_list())
495
+ if args and args[0].lower() in {"entitlement", "entitlements"}:
496
+ return CommandResult(_format_provider_entitlements(self.market_manager.entitlements()))
497
+ if args and args[0].lower() == "metrics":
498
+ return CommandResult(_format_provider_metrics(self.market_service))
499
+ if args and args[0].lower() == "key" and len(args) >= 2 and args[1].lower() == "status":
500
+ return CommandResult(_format_provider_key_status(self.market_manager))
501
+ if args and args[0].lower() == "use":
502
+ if len(args) < 2:
503
+ raise CommandError("Format: /provider use <provider>")
504
+ provider = args[1].lower()
505
+ self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
506
+ self._refresh_market_service()
507
+ self.cache.clear()
508
+ return CommandResult(Panel(f"Provider market aktif: {provider}", title="Provider Updated"))
509
+ if args and args[0].lower() == "priority":
510
+ if len(args) < 2:
511
+ raise CommandError("Format: /provider priority finnhub,yfinance")
512
+ providers = [provider.strip() for provider in args[1].split(",") if provider.strip()]
513
+ self.config.set_market_provider_priority(providers)
514
+ self._refresh_market_service()
515
+ self.cache.clear()
516
+ return CommandResult(Panel(f"Provider priority: {', '.join(providers)}", title="Provider Priority"))
517
+ if args and args[0].lower() == "status":
518
+ settings = self.config.settings
519
+ provider_status = self._provider_health_text()
520
+ text = (
521
+ f"Market provider: {settings.market_provider} (active: {self.market_provider.name})\n"
522
+ f"News provider : {settings.news_provider} (active: {self.market_provider.name} fallback)\n"
523
+ f"Provider chain : {', '.join(provider.name for provider in self.market_service.providers)}\n"
524
+ f"AI provider : {settings.ai_provider} (active: {self.ai_provider.name})\n"
525
+ f"{provider_status}"
526
+ )
527
+ return CommandResult(Panel(text, title="Provider Status", border_style="yellow"))
528
+ if args and args[0].lower() == "test":
529
+ if len(args) < 2:
530
+ raise CommandError("Format: /provider test [provider] <symbol>")
531
+ if len(args) >= 3:
532
+ provider = self.market_manager.create(args[1])
533
+ quote = self.market_service.run(provider.quote(args[2]))
534
+ else:
535
+ quote = self._get_quote(args[1])
536
+ return CommandResult(_format_quote(quote))
537
+ raise CommandError(
538
+ "Format: /provider status, /provider list, /provider entitlement, /provider key status, /provider use <provider>, "
539
+ "/provider priority finnhub,yfinance, atau /provider test [provider] <symbol>"
540
+ )
541
+
542
+ def _symbol(self, args: list[str]) -> CommandResult:
561
543
  if not args:
562
- raise CommandError("Format: /news <symbol>")
563
- symbol = args[0].upper()
564
- items = self._run_async(self.market_service.news(symbol, limit=5))
565
- return CommandResult(_format_news(symbol, items))
566
-
567
- def _fundamentals(self, args: list[str]) -> CommandResult:
544
+ raise CommandError("Format: /symbol <query> atau /symbol normalize <symbol>")
545
+ action = args[0].lower()
546
+ if action in {"normalize", "norm"}:
547
+ if len(args) < 2:
548
+ raise CommandError("Format: /symbol normalize <symbol>")
549
+ return CommandResult(_format_symbol_matrix(args[1]))
550
+ query = " ".join(args)
551
+ results = search_symbol_catalog(query)
552
+ return CommandResult(_format_symbol_search(query, results))
553
+
554
+ def _research(self, args: list[str]) -> CommandResult:
568
555
  if not args:
569
- raise CommandError("Format: /funda <symbol>")
556
+ raise CommandError("Format: /research <symbol> [--quick|--deep] [timeframe]")
570
557
  symbol = args[0].upper()
571
- snapshot = self._run_async(self.market_service.fundamentals(symbol))
572
- return CommandResult(_format_fundamentals(snapshot))
573
-
574
- def _yahoo(self, args: list[str]) -> CommandResult:
558
+ mode = "deep" if any(arg.lower() == "--deep" for arg in args[1:]) else "quick"
559
+ timeframe = next((arg for arg in args[1:] if not arg.startswith("--")), "1d")
560
+ engine = ResearchEngine(self.market_service, self.ai_provider, self.config.settings.ai_model)
561
+ brief = self._run_async(engine.build(symbol, timeframe=timeframe, mode=mode))
562
+ return CommandResult(format_research_brief(brief))
563
+
564
+ def _macro(self, args: list[str]) -> CommandResult:
565
+ query = " ".join(args).strip()
566
+ rows = self.macro_data.indicators(query)
567
+ return CommandResult(_format_macro_dashboard(query or "global", rows))
568
+
569
+ def _profile(self, args: list[str]) -> CommandResult:
575
570
  if not args:
576
- raise CommandError(
577
- "Format: /yahoo <symbol> [history|statistics|profile|financials|balance|cashflow|analysis|holders|news] [period] [interval]"
571
+ return CommandResult(_format_user_profile(self.user_profiles.get()))
572
+ action = args[0].lower()
573
+ if action == "set":
574
+ if len(args) < 6:
575
+ raise CommandError('Format: /profile set "Nama" <equity> <currency> <leverage> <years>')
576
+ profile = self.user_profiles.save(args[1], float(args[2]), args[3], args[4], float(args[5]))
577
+ return CommandResult(_format_user_profile(profile))
578
+ if action in {"clear", "delete", "reset"}:
579
+ self.user_profiles.clear()
580
+ return CommandResult(Panel("Profile lokal dihapus.", title="Profile", border_style="yellow"))
581
+ raise CommandError('Format: /profile, /profile set "Nama" <equity> <currency> <leverage> <years>, /profile clear')
582
+
583
+ def _doctor(self, args: list[str]) -> CommandResult:
584
+ table = Table(title="FinCLI Doctor", expand=True)
585
+ table.add_column("Check", style="cyan", no_wrap=True)
586
+ table.add_column("Status")
587
+ table.add_column("Detail", overflow="fold")
588
+ table.add_row("Version", "ok", "FinCLI v0.2.2 command surface loaded.")
589
+ table.add_row("Database", "ok", str(self.db.db_file))
590
+ table.add_row("Market Provider", "ok", ", ".join(provider.name for provider in self.market_service.providers))
591
+ profile = self.user_profiles.get()
592
+ table.add_row("Profile", "ok" if profile else "missing", profile.gameplay if profile else "Run /profile set ...")
593
+ table.add_row("AI Provider", "configured", f"{self.config.settings.ai_provider} / {self.config.settings.ai_model}")
594
+ table.caption = "Doctor checks local wiring only; provider entitlement still depends on your API key/account."
595
+ return CommandResult(table)
596
+
597
+ def _setup(self, args: list[str]) -> CommandResult:
598
+ return CommandResult(
599
+ Panel(
600
+ "\n".join(
601
+ [
602
+ "Recommended setup:",
603
+ '1. /profile set "Nama" <equity> <currency> <leverage> <years>',
604
+ "2. /ai_model key <provider> <api_key>",
605
+ "3. /news_model key <provider> <api_key>",
606
+ "4. /provider priority yfinance,alphavantage,twelvedata,finnhub",
607
+ "5. /research AAPL --quick",
608
+ "6. /analyze XAUUSD 1d",
609
+ ]
610
+ ),
611
+ title="FinCLI Setup",
612
+ border_style="cyan",
578
613
  )
579
- symbol = args[0].upper()
580
- section = args[1].lower() if len(args) >= 2 else "statistics"
581
- period = args[2] if len(args) >= 3 else "6mo"
582
- interval = args[3] if len(args) >= 4 else "1d"
583
- provider = YFinanceProvider()
584
- table = self._run_async(provider.yahoo_table(symbol, section, period=period, interval=interval))
585
- if not isinstance(table, YahooTable):
586
- raise CommandError("YFinance provider mengembalikan data tabel tidak valid.")
587
- return CommandResult(_format_yahoo_table(table))
588
-
589
- def _ai(self, args: list[str]) -> CommandResult:
590
- if not args:
591
- raise CommandError("Format: /ai <pertanyaan>")
592
- prompt = " ".join(args)
593
- if is_coding_request(prompt):
594
- response = AIResponse(provider="fincli", model="local-policy", content=coding_refusal())
595
- return CommandResult(_format_ai_response(response))
614
+ )
596
615
 
597
- market_context = self._freechat_market_context(prompt)
598
- assistant_prompt = build_fincli_assistant_prompt(prompt, market_context)
599
- request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
600
- response = self._run_async(self.ai_provider.complete(request))
601
- if not isinstance(response, AIResponse):
602
- raise CommandError("AI provider mengembalikan data tidak valid.")
603
- return CommandResult(_format_ai_response(response))
616
+ def _agent(self, args: list[str]) -> CommandResult:
617
+ action = args[0].lower() if args else "list"
618
+ if action in {"list", "ls"}:
619
+ category = args[1].lower() if len(args) >= 2 else ""
620
+ agents = self.agent_registry.by_category(category) if category else list(self.agent_registry.all())
621
+ return CommandResult(_format_agents(agents, category or "all"))
622
+ if action == "show":
623
+ if len(args) < 2:
624
+ raise CommandError("Format: /agent show <slug>")
625
+ agent = self.agent_registry.get(args[1])
626
+ if agent is None:
627
+ raise CommandError(f"Agent tidak ditemukan: {args[1]}")
628
+ return CommandResult(_format_agent(agent))
629
+ raise CommandError("Format: /agent list [category] atau /agent show <slug>")
630
+
631
+ def _connector(self, args: list[str]) -> CommandResult:
632
+ action = args[0].lower() if args else "list"
633
+ if action in {"list", "ls"}:
634
+ category = args[1].lower() if len(args) >= 2 else ""
635
+ connectors = self.connector_catalog.by_category(category) if category else list(self.connector_catalog.all())
636
+ return CommandResult(_format_connectors(connectors, category or "all"))
637
+ if action in {"search", "find"}:
638
+ if len(args) < 2:
639
+ raise CommandError("Format: /connector search <query>")
640
+ query = " ".join(args[1:])
641
+ return CommandResult(_format_connectors(self.connector_catalog.find(query), query))
642
+ raise CommandError("Format: /connector list [category] atau /connector search <query>")
643
+
644
+ def _plugin(self, args: list[str]) -> CommandResult:
645
+ action = args[0].lower() if args else "list"
646
+ if action in {"list", "ls", "status"}:
647
+ plugins = PluginLoader().discover()
648
+ return CommandResult(_format_plugins(plugins, status_only=action == "status"))
649
+ raise CommandError("Format: /plugin list atau /plugin status")
604
650
 
605
- def _analyze(self, args: list[str]) -> CommandResult:
651
+ def _cache(self, args: list[str]) -> CommandResult:
652
+ if args and args[0].lower() == "stats":
653
+ stats = self.market_cache.stats()
654
+ lines = [
655
+ f"Runtime cache TTL : {self.config.settings.cache_ttl_seconds}s",
656
+ f"Persistent entries: {stats['total']}",
657
+ f"- quote : {stats['quote']}",
658
+ f"- history : {stats['history']}",
659
+ f"- news : {stats['news']}",
660
+ f"- fundamentals : {stats['fundamentals']}",
661
+ ]
662
+ return CommandResult(Panel("\n".join(lines), title="Cache Stats", border_style="cyan"))
663
+ if args and args[0].lower() == "clear":
664
+ self.cache.clear()
665
+ cleared = self.market_cache.clear()
666
+ return CommandResult(Panel(f"Runtime cache dan persistent cache dibersihkan ({cleared} entry).", title="Cache"))
667
+ raise CommandError("Format: /cache clear atau /cache stats")
668
+
669
+ def _watchlist(self, args: list[str]) -> CommandResult:
670
+ if not args:
671
+ rows = self.watchlist.list()
672
+ table = Table(title="Watchlist", expand=True)
673
+ table.add_column("Symbol", style="cyan")
674
+ table.add_column("Price", justify="right")
675
+ table.add_column("Currency")
676
+ table.add_column("Status")
677
+ table.add_column("Group")
678
+ table.add_column("Created")
679
+ for row in rows:
680
+ quote = self._safe_quote(str(row["symbol"]))
681
+ table.add_row(
682
+ str(row["symbol"]),
683
+ _fmt(quote.price) if quote else "N/A",
684
+ quote.currency if quote else "-",
685
+ quote.status if quote else "unavailable",
686
+ str(row["group_name"]),
687
+ str(row["created_at"]),
688
+ )
689
+ if not rows:
690
+ table.add_row("-", "-", "-", "-", "Belum ada data. Gunakan /watchlist add AAPL", "-")
691
+ return CommandResult(table)
692
+
693
+ action = args[0].lower()
694
+ if action == "add" and len(args) >= 2:
695
+ self.watchlist.add(args[1], args[2] if len(args) >= 3 else "default")
696
+ return CommandResult(Panel(f"{args[1].upper()} ditambahkan ke watchlist.", title="Watchlist"))
697
+ if action == "remove" and len(args) >= 2:
698
+ self.watchlist.remove(args[1])
699
+ return CommandResult(Panel(f"{args[1].upper()} dihapus dari watchlist.", title="Watchlist"))
700
+ raise CommandError("Format: /watchlist, /watchlist add <symbol>, /watchlist remove <symbol>")
701
+
702
+ def _portfolio(self, args: list[str]) -> CommandResult:
703
+ if not args:
704
+ rows = self.portfolio.list()
705
+ table = Table(title="Portfolio", expand=True)
706
+ table.add_column("Symbol", style="cyan")
707
+ table.add_column("Qty", justify="right")
708
+ table.add_column("Avg Price", justify="right")
709
+ table.add_column("Current", justify="right")
710
+ table.add_column("PnL", justify="right")
711
+ table.add_column("PnL %", justify="right")
712
+ table.add_column("Currency")
713
+ table.add_column("Updated")
714
+ for row in rows:
715
+ current_price, pnl, pnl_percent = self._portfolio_market_values(row)
716
+ table.add_row(
717
+ str(row["symbol"]),
718
+ f"{float(row['quantity']):,.8g}",
719
+ f"{float(row['average_price']):,.4f}",
720
+ _fmt(current_price),
721
+ _fmt(pnl),
722
+ _fmt(pnl_percent),
723
+ str(row["currency"]),
724
+ str(row["updated_at"]),
725
+ )
726
+ if not rows:
727
+ table.add_row(
728
+ "-",
729
+ "-",
730
+ "-",
731
+ "-",
732
+ "-",
733
+ "-",
734
+ "-",
735
+ "Belum ada posisi. Gunakan /portfolio add BTC-USD 0.05 65000",
736
+ )
737
+ return CommandResult(table)
738
+
739
+ action = args[0].lower()
740
+ if action == "performance":
741
+ return CommandResult(self._portfolio_performance_table())
742
+ if action == "add" and len(args) >= 4:
743
+ try:
744
+ quantity = float(args[2])
745
+ average_price = float(args[3])
746
+ except ValueError as exc:
747
+ raise CommandError("Quantity dan average price harus angka.") from exc
748
+ self.portfolio.add(args[1], quantity, average_price, args[4] if len(args) >= 5 else "USD")
749
+ return CommandResult(Panel(f"Posisi {args[1].upper()} disimpan.", title="Portfolio"))
750
+ if action == "remove" and len(args) >= 2:
751
+ self.portfolio.remove(args[1])
752
+ return CommandResult(Panel(f"Posisi {args[1].upper()} dihapus.", title="Portfolio"))
753
+ raise CommandError(
754
+ "Format: /portfolio, /portfolio performance, /portfolio add <symbol> <qty> <avg_price>, "
755
+ "/portfolio remove <symbol>"
756
+ )
757
+
758
+ def _tx(self, args: list[str]) -> CommandResult:
759
+ if not args or args[0].lower() == "list":
760
+ return CommandResult(_format_transactions(self.transactions.list()))
761
+
762
+ if args[0].lower() == "add":
763
+ if len(args) < 5:
764
+ raise CommandError("Format: /tx add <buy|sell> <symbol> <qty> <price> [currency]")
765
+ try:
766
+ quantity = float(args[3])
767
+ price = float(args[4])
768
+ except ValueError as exc:
769
+ raise CommandError("Quantity dan price harus angka.") from exc
770
+ tx = self.transactions.add(
771
+ action=args[1],
772
+ symbol=args[2],
773
+ quantity=quantity,
774
+ price=price,
775
+ currency=args[5] if len(args) >= 6 else "USD",
776
+ )
777
+ return CommandResult(
778
+ Panel(
779
+ (
780
+ f"Transaction saved: {tx['action']} {tx['symbol']} "
781
+ f"{_fmt(float(tx['quantity']))} @ {_fmt(float(tx['price']))} "
782
+ f"| Realized PnL {_fmt(float(tx['realized_pnl']))}"
783
+ ),
784
+ title="Transaction",
785
+ border_style="green",
786
+ )
787
+ )
788
+
789
+ raise CommandError("Format: /tx add <buy|sell> <symbol> <qty> <price> [currency] atau /tx list")
790
+
791
+ def _journal(self, args: list[str]) -> CommandResult:
792
+ if not args:
793
+ rows = self.journal.list()
794
+ return CommandResult(self._journal_table(rows, "Journal"))
795
+
796
+ if args[0].lower() == "stats":
797
+ rows = self.journal.list(limit=10_000)
798
+ stats = calculate_journal_stats(rows)
799
+ return CommandResult(_format_journal_stats(stats))
800
+
801
+ if args[0].lower() == "review":
802
+ rows = self.journal.list(limit=10_000)
803
+ stats = calculate_journal_stats(rows)
804
+ prompt = build_journal_review_prompt(rows, stats)
805
+ response = self._run_async(self.ai_provider.complete(AIRequest(prompt=prompt, model=self.config.settings.ai_model)))
806
+ if not isinstance(response, AIResponse):
807
+ raise CommandError("AI provider mengembalikan data tidak valid.")
808
+ return CommandResult(
809
+ MarkdownBlock("Journal Review", _format_ai_response(response), "Disclaimer: bukan nasihat keuangan.")
810
+ )
811
+
812
+ if args[0].lower() == "add":
813
+ if len(args) < 3:
814
+ raise CommandError('Format: /journal add <instrument> <bias> "entry reason"')
815
+ self.journal.add(args[1], bias=args[2], entry_reason=args[3] if len(args) >= 4 else "")
816
+ return CommandResult(Panel(f"Journal untuk {args[1].upper()} ditambahkan.", title="Journal"))
817
+
818
+ rows = self.journal.list(args[0])
819
+ return CommandResult(self._journal_table(rows, f"Journal {args[0].upper()}"))
820
+
821
+ def _journal_table(self, rows: list[dict[str, object]], title: str) -> Table:
822
+ table = Table(title=title, expand=True)
823
+ table.add_column("ID", justify="right")
824
+ table.add_column("Instrument", style="cyan")
825
+ table.add_column("Bias")
826
+ table.add_column("Entry Reason")
827
+ table.add_column("Created")
828
+ for row in rows:
829
+ table.add_row(
830
+ str(row["id"]),
831
+ str(row["instrument"]),
832
+ str(row["bias"]),
833
+ str(row["entry_reason"]),
834
+ str(row["created_at"]),
835
+ )
836
+ if not rows:
837
+ table.add_row("-", "-", "-", 'Belum ada journal. Gunakan /journal add BTC-USD bullish "Alasan entry"', "-")
838
+ return table
839
+
840
+ def _alert(self, args: list[str]) -> CommandResult:
841
+ action = args[0].lower() if args else "list"
842
+ if action in {"list", "ls"}:
843
+ return CommandResult(_format_alerts(self.alerts.list()))
844
+ if action == "add":
845
+ if len(args) < 4:
846
+ raise CommandError("Format: /alert add <symbol> <above|below|>|< > <price> [note]")
847
+ symbol = args[1]
848
+ condition = args[2]
849
+ try:
850
+ target = float(args[3])
851
+ except ValueError as exc:
852
+ raise CommandError("Target alert harus angka.") from exc
853
+ note = " ".join(args[4:]).strip()
854
+ self.alerts.add(symbol, condition, target, note)
855
+ return CommandResult(Panel(f"Alert ditambahkan: {symbol.upper()} {condition} {target:g}", title="Alert"))
856
+ if action in {"remove", "delete", "rm"}:
857
+ if len(args) < 2:
858
+ raise CommandError("Format: /alert remove <id>")
859
+ self.alerts.remove(int(args[1]))
860
+ return CommandResult(Panel(f"Alert dihapus: {args[1]}", title="Alert"))
861
+ if action == "check":
862
+ checked: list[AlertCheckResult] = []
863
+ for alert in self.alerts.list(active_only=True):
864
+ quote = self._safe_quote(str(alert["symbol"]))
865
+ result = evaluate_alert(alert, quote.price if quote else None)
866
+ checked.append(result)
867
+ if result.triggered:
868
+ self.alerts.mark_triggered(result.id)
869
+ return CommandResult(_format_alert_checks(checked))
870
+ raise CommandError("Format: /alert, /alert add <symbol> <above|below> <price>, /alert remove <id>, /alert check")
871
+
872
+ def _quote(self, args: list[str]) -> CommandResult:
873
+ if not args:
874
+ raise CommandError("Format: /quote <symbol>")
875
+ symbol = args[0].upper()
876
+ cache_key = f"quote:{symbol}"
877
+ cached = self.cache.get(cache_key)
878
+ quote = cached if isinstance(cached, Quote) else self._run_async(self.market_service.quote(symbol))
879
+ if not isinstance(quote, Quote):
880
+ raise CommandError("Provider quote mengembalikan data tidak valid.")
881
+ self.cache.set(cache_key, quote)
882
+ return CommandResult(_format_quote(quote))
883
+
884
+ def _technical(self, args: list[str]) -> CommandResult:
885
+ if not args:
886
+ raise CommandError("Format: /technical <symbol> [interval]")
887
+ symbol = args[0].upper()
888
+ interval = args[1] if len(args) >= 2 else "1d"
889
+ candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=interval))
890
+ if not candles:
891
+ raise CommandError(f"Data teknikal kosong untuk {symbol}.")
892
+ summary = summarize_technical_indicators(candles)
893
+ structure = analyze_market_structure(candles)
894
+ debate = run_technical_debate(summary, structure, candles)
895
+ signal = debate.judge_signal
896
+ ai_summary = build_technical_ai_summary(symbol, interval, candles)
897
+ return CommandResult(_format_technical(symbol, interval, summary, signal, ai_summary, debate))
898
+
899
+ def _mtf(self, args: list[str]) -> CommandResult:
900
+ if not args:
901
+ raise CommandError("Format: /mtf <symbol> [timeframes comma-separated]")
902
+ symbol = args[0].upper()
903
+ timeframes = _parse_timeframes(args[1] if len(args) >= 2 else "1d,1h,15m")
904
+ analysis = self._run_async(analyze_multi_timeframe(symbol, self.market_service, timeframes=timeframes))
905
+ return CommandResult(_format_multi_timeframe(analysis))
906
+
907
+ def _backtest(self, args: list[str]) -> CommandResult:
908
+ if not args:
909
+ raise CommandError("Format: /backtest <symbol> [sma_cross|rsi_reversion] [interval]")
910
+ symbol = args[0].upper()
911
+ strategy = args[1].lower() if len(args) >= 2 else "sma_cross"
912
+ interval = args[2].lower() if len(args) >= 3 else "1d"
913
+ candles = self._run_async(self.market_service.history(symbol, period="2y", interval=interval))
914
+ result = run_backtest(symbol, candles, strategy=strategy, interval=interval)
915
+ return CommandResult(_format_backtest(result))
916
+
917
+ def _market(self, args: list[str]) -> CommandResult:
918
+ if not args:
919
+ raise CommandError("Format: /market <symbol> [interval]")
920
+ symbol = args[0].upper()
921
+ interval = args[1] if len(args) >= 2 else "1d"
922
+ overview = self._run_async(build_market_overview(symbol, self.market_service, interval))
923
+ return CommandResult(_format_market_overview(overview))
924
+
925
+ def _structure(self, args: list[str]) -> CommandResult:
926
+ if not args:
927
+ raise CommandError("Format: /structure <symbol> [interval]")
928
+ symbol = args[0].upper()
929
+ interval = args[1] if len(args) >= 2 else "1d"
930
+ candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=interval))
931
+ if not candles:
932
+ raise CommandError(f"Data struktur market kosong untuk {symbol}.")
933
+ structure = analyze_market_structure(candles)
934
+ return CommandResult(_format_structure(symbol, interval, structure))
935
+
936
+ def _news(self, args: list[str]) -> CommandResult:
606
937
  if not args:
607
- raise CommandError("Format: /analyze <symbol> [timeframe]")
938
+ raise CommandError("Format: /news <symbol> [1d-30d]")
608
939
  symbol = args[0].upper()
609
- timeframe = args[1] if len(args) >= 2 else "1d"
610
- candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=timeframe))
611
- if not candles:
612
- raise CommandError(f"Data market kosong untuk {symbol}.")
940
+ lookback_days = _parse_news_lookback(args[1:]) if len(args) > 1 else None
941
+ desk = self._run_async(
942
+ NewsAggregator(
943
+ self.market_service,
944
+ self.news_connectors,
945
+ self.config.settings.news_provider_priority,
946
+ ).latest(symbol, limit=12, lookback_days=lookback_days)
947
+ )
948
+ return CommandResult(_format_news_desk(desk))
949
+
950
+ def _fundamentals(self, args: list[str]) -> CommandResult:
951
+ if not args:
952
+ raise CommandError("Format: /funda <symbol>")
953
+ symbol = args[0].upper()
954
+ snapshot = self._run_async(self.market_service.fundamentals(symbol))
955
+ return CommandResult(_format_fundamentals(snapshot))
956
+
957
+ def _yahoo(self, args: list[str]) -> CommandResult:
958
+ if not args:
959
+ raise CommandError(
960
+ "Format: /yahoo <symbol> [history|statistics|profile|financials|balance|cashflow|analysis|holders|news] [period] [interval]"
961
+ )
962
+ symbol = args[0].upper()
963
+ section = args[1].lower() if len(args) >= 2 else "statistics"
964
+ period = args[2] if len(args) >= 3 else "6mo"
965
+ interval = args[3] if len(args) >= 4 else "1d"
966
+ provider = YFinanceProvider()
967
+ table = self._run_async(provider.yahoo_table(symbol, section, period=period, interval=interval))
968
+ if not isinstance(table, YahooTable):
969
+ raise CommandError("YFinance provider mengembalikan data tabel tidak valid.")
970
+ return CommandResult(_format_yahoo_table(table))
971
+
972
+ def _ai(self, args: list[str]) -> CommandResult:
973
+ if not args:
974
+ raise CommandError("Format: /ai <pertanyaan>")
975
+ prompt = " ".join(args)
976
+ if is_coding_request(prompt):
977
+ response = AIResponse(provider="fincli", model="local-policy", content=coding_refusal())
978
+ return CommandResult(_format_ai_response(response))
979
+
980
+ market_context = self._freechat_market_context(prompt)
981
+ web_context = self._freechat_web_context(prompt)
982
+ if web_context:
983
+ market_context = f"{market_context}\n\n{web_context}".strip()
984
+ assistant_prompt = build_fincli_assistant_prompt(prompt, market_context)
985
+ request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
986
+ response = self._run_async(self.ai_provider.complete(request))
987
+ if not isinstance(response, AIResponse):
988
+ raise CommandError("AI provider mengembalikan data tidak valid.")
989
+ return CommandResult(_format_ai_response(response))
990
+
991
+ def _web(self, args: list[str]) -> CommandResult:
992
+ if not args:
993
+ raise CommandError("Format: /web <query>")
994
+ if args[0].lower() in {"sources", "source", "raw"}:
995
+ source_query = " ".join(args[1:]).strip()
996
+ if not source_query:
997
+ raise CommandError("Format: /web sources <query>")
998
+ results = self._run_async(self.web_research.research(source_query, limit=5))
999
+ return CommandResult(_format_web_results(source_query, results))
1000
+
1001
+ query = " ".join(args)
1002
+ results = self._run_async(self.web_research.research(query, limit=5))
1003
+ context = build_web_research_context(results)
1004
+ assistant_prompt = build_web_research_answer_prompt(query, context)
1005
+ request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
1006
+ response = self._run_async(self.ai_provider.complete(request))
1007
+ if not isinstance(response, AIResponse):
1008
+ raise CommandError("AI provider mengembalikan data tidak valid.")
1009
+ return CommandResult(_format_ai_response(response))
1010
+
1011
+ def _analyze(self, args: list[str]) -> CommandResult:
1012
+ if not args:
1013
+ raise CommandError("Format: /analyze <symbol> [timeframe]")
1014
+ symbol = args[0].upper()
1015
+ timeframe = args[1] if len(args) >= 2 else "1d"
1016
+ candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=timeframe))
1017
+ if not candles:
1018
+ raise CommandError(f"Data market kosong untuk {symbol}.")
613
1019
  technical = summarize_technical_indicators(candles)
614
1020
  structure = analyze_market_structure(candles)
615
1021
  news_context = self._analysis_context(symbol)
616
- prompt = build_market_analysis_prompt(symbol, timeframe, candles, technical, structure, news_context)
1022
+ gameplay_context = format_gameplay_context(self.user_profiles.get(), symbol)
1023
+ prompt = build_market_analysis_prompt(symbol, timeframe, candles, technical, structure, news_context, gameplay_context)
617
1024
  request = AIRequest(prompt=prompt, model=self.config.settings.ai_model)
618
- response = self._run_async(self.ai_provider.complete(request))
619
- if not isinstance(response, AIResponse):
620
- raise CommandError("AI provider mengembalikan data tidak valid.")
621
- return CommandResult(f"AI Market Analysis: {symbol}\n{_format_ai_response(response)}\n\nDisclaimer: bukan nasihat keuangan.")
622
-
623
- def _scan(self, args: list[str]) -> CommandResult:
624
- if not args or args[0].lower() != "watchlist":
625
- raise CommandError("Format: /scan watchlist [filter] [interval]")
626
- rows = self.watchlist.list()
627
- symbols = [str(row["symbol"]) for row in rows]
628
- if not symbols:
629
- return CommandResult(Panel("Watchlist kosong. Gunakan /watchlist add AAPL.", title="Scan"))
630
- filter_expression = args[1] if len(args) >= 2 else ""
631
- interval = args[2] if len(args) >= 3 else "1d"
632
- results = self._run_async(scan_symbols(symbols, self.market_service, filter_expression, interval=interval))
633
- return CommandResult(_format_scan_results(results, filter_expression or "all", interval))
634
-
635
- def _calendar(self, args: list[str]) -> CommandResult:
1025
+ response = self._run_async(self.ai_provider.complete(request))
1026
+ if not isinstance(response, AIResponse):
1027
+ raise CommandError("AI provider mengembalikan data tidak valid.")
1028
+ return CommandResult(
1029
+ MarkdownBlock(f"AI Market Analysis: {symbol}", _format_ai_response(response), "Disclaimer: bukan nasihat keuangan.")
1030
+ )
1031
+
1032
+ def _scan(self, args: list[str]) -> CommandResult:
1033
+ if args and args[0].lower() == "export":
1034
+ return self._scan_export(args[1:])
1035
+ if not args or args[0].lower() != "watchlist":
1036
+ raise CommandError("Format: /scan watchlist [filter] [interval]")
1037
+ rows = self.watchlist.list()
1038
+ symbols = [str(row["symbol"]) for row in rows]
1039
+ if not symbols:
1040
+ return CommandResult(Panel("Watchlist kosong. Gunakan /watchlist add AAPL.", title="Scan"))
1041
+ filter_expression = args[1] if len(args) >= 2 else ""
1042
+ interval = args[2] if len(args) >= 3 else "1d"
1043
+ results = self._run_async(scan_symbols(symbols, self.market_service, filter_expression, interval=interval))
1044
+ return CommandResult(_format_scan_results(results, filter_expression or "all", interval))
1045
+
1046
+ def _scan_export(self, args: list[str]) -> CommandResult:
1047
+ if len(args) < 2:
1048
+ raise CommandError("Format: /scan export <csv|json> <path> [filter] [interval]")
1049
+ export_format = args[0].lower()
1050
+ target = args[1]
1051
+ filter_expression = args[2] if len(args) >= 3 else ""
1052
+ interval = args[3] if len(args) >= 4 else "1d"
1053
+ rows = self.watchlist.list()
1054
+ symbols = [str(row["symbol"]) for row in rows]
1055
+ if not symbols:
1056
+ raise CommandError("Watchlist kosong. Gunakan /watchlist add AAPL.")
1057
+ results = self._run_async(scan_symbols(symbols, self.market_service, filter_expression, interval=interval))
1058
+ written = export_rows(_scan_result_rows(results), export_format, target)
1059
+ return CommandResult(Panel(f"Scan export selesai: {written}", title="Scan Export", border_style="green"))
1060
+
1061
+ def _report(self, args: list[str]) -> CommandResult:
1062
+ if len(args) < 4 or args[0].lower() != "market":
1063
+ raise CommandError("Format: /report market <symbol> <md|json> <path> [interval]")
1064
+ symbol = args[1].upper()
1065
+ report_format = args[2].lower()
1066
+ target = args[3]
1067
+ interval = args[4] if len(args) >= 5 else "1d"
1068
+ overview = self._run_async(build_market_overview(symbol, self.market_service, interval))
1069
+ written = write_market_report(overview, report_format, target)
1070
+ return CommandResult(Panel(f"Market report selesai: {written}", title="Market Report", border_style="green"))
1071
+
1072
+ def _calendar(self, args: list[str]) -> CommandResult:
1073
+ if args and args[0].lower() == "export":
1074
+ return self._calendar_export(args[1:])
636
1075
  start, end, country, impact = _parse_calendar_args(args)
637
1076
  service = EconomicCalendarService(api_key=os.getenv("FINNHUB_API_KEY"))
638
1077
  source = "finnhub"
@@ -640,224 +1079,273 @@ class CommandRouter:
640
1079
  try:
641
1080
  events = self._run_async(service.events(start, end))
642
1081
  except FinCLIError as exc:
643
- events = fallback_events(start, end)
644
- source = "fallback"
645
- note = f"{exc} Menggunakan fallback kategori event; isi FINNHUB_API_KEY untuk data aktual."
1082
+ events, source, note = self._calendar_public_or_static_fallback(start, end, exc)
646
1083
  events = filter_events(events, country=country, impact=impact)
647
1084
  return CommandResult(_format_calendar(events, start, end, source, note))
648
-
649
- def _run_async(self, awaitable: Any) -> Any:
650
- try:
651
- asyncio.get_running_loop()
652
- except RuntimeError:
653
- return asyncio.run(awaitable)
654
-
655
- with ThreadPoolExecutor(max_workers=1) as executor:
656
- future = executor.submit(asyncio.run, awaitable)
657
- return future.result()
658
-
659
- def _portfolio_market_values(self, row: dict[str, object]) -> tuple[float | None, float | None, float | None]:
660
- try:
661
- symbol = str(row["symbol"])
662
- quantity = float(row["quantity"])
663
- average_price = float(row["average_price"])
664
- quote = self._get_quote(symbol)
665
- current_price = quote.price
666
- if current_price is None:
667
- return None, None, None
668
- pnl = (current_price - average_price) * quantity
669
- invested = average_price * quantity
670
- pnl_percent = (pnl / invested * 100) if invested else None
671
- return current_price, pnl, pnl_percent
672
- except FinCLIError:
673
- return None, None, None
674
- except (TypeError, ValueError, KeyError):
675
- return None, None, None
676
-
677
- def _portfolio_performance_table(self) -> Table:
678
- positions = self.portfolio.list()
679
- realized = self.transactions.realized_pnl_total()
680
- cost_basis = 0.0
681
- market_value = 0.0
682
- unrealized = 0.0
683
- for row in positions:
684
- quantity = float(row["quantity"])
685
- average_price = float(row["average_price"])
686
- current_price, pnl, _ = self._portfolio_market_values(row)
687
- cost_basis += quantity * average_price
688
- if current_price is not None:
689
- market_value += quantity * current_price
690
- if pnl is not None:
691
- unrealized += pnl
692
-
693
- table = Table(title="Portfolio Performance", expand=True)
694
- table.add_column("Metric", style="cyan")
695
- table.add_column("Value", justify="right")
696
- table.add_row("Cost Basis", _fmt(cost_basis))
697
- table.add_row("Market Value", _fmt(market_value))
698
- table.add_row("Unrealized PnL", _fmt(unrealized))
699
- table.add_row("Realized PnL", _fmt(realized))
700
- table.add_row("Total PnL", _fmt(realized + unrealized))
701
- return table
702
-
703
- def _get_quote(self, symbol: str) -> Quote:
704
- normalized = symbol.upper()
705
- cache_key = f"quote:{normalized}"
706
- cached = self.cache.get(cache_key)
707
- if isinstance(cached, Quote):
708
- return cached
709
- quote = self._run_async(self.market_service.quote(normalized))
710
- if not isinstance(quote, Quote):
711
- raise CommandError("Provider quote mengembalikan data tidak valid.")
712
- self.cache.set(cache_key, quote)
713
- return quote
714
-
715
- def _provider_health_text(self) -> str:
716
- try:
717
- status = self._run_async(self.market_service.status())
718
- return (
719
- f"Provider health: {status.status}\n"
720
- f"Provider realtime: {status.realtime}\n"
721
- f"Provider message: {status.message}"
722
- )
723
- except (FinCLIError, AttributeError) as exc:
724
- return f"Provider health: unavailable ({exc})"
725
-
726
- def _safe_quote(self, symbol: str) -> Quote | None:
727
- try:
728
- return self._get_quote(symbol)
729
- except FinCLIError:
730
- return None
731
-
732
- def _analysis_context(self, symbol: str) -> str:
733
- sections: list[str] = []
734
- try:
735
- news_items = self._run_async(self.market_service.news(symbol, limit=3))
736
- sections.append(_format_news_context(news_items))
737
- except (FinCLIError, AttributeError) as exc:
738
- sections.append(f"News unavailable: {exc}")
739
- try:
740
- fundamentals = self._run_async(self.market_service.fundamentals(symbol))
741
- sections.append(_format_fundamental_context(fundamentals))
742
- except (FinCLIError, AttributeError) as exc:
743
- sections.append(f"Fundamentals unavailable: {exc}")
744
- return "\n\n".join(sections)
745
-
746
- def _freechat_market_context(self, prompt: str) -> str:
747
- symbols = extract_market_symbols(prompt)
748
- if not symbols:
749
- return ""
750
-
751
- sections = [
752
- "FinCLI provider chain: "
753
- + ", ".join(provider.name for provider in self.market_service.providers)
754
- + ". Realtime status depends on the active provider and API key."
755
- ]
756
- for symbol in symbols:
757
- sections.append(self._symbol_freechat_context(symbol))
758
- return "\n\n".join(sections)
759
-
760
- def _symbol_freechat_context(self, symbol: str) -> str:
761
- lines = [f"Symbol: {symbol}"]
1085
+
1086
+ def _calendar_export(self, args: list[str]) -> CommandResult:
1087
+ if len(args) < 2:
1088
+ raise CommandError("Format: /calendar export <csv|json> <path> [today|week|from to] [country=US] [impact=high]")
1089
+ export_format = args[0].lower()
1090
+ target = args[1]
1091
+ start, end, country, impact = _parse_calendar_args(args[2:])
1092
+ service = EconomicCalendarService(api_key=os.getenv("FINNHUB_API_KEY"))
762
1093
  try:
763
- quote = self._get_quote(symbol)
764
- lines.append(
765
- f"Quote: price={_fmt(quote.price)} {quote.currency}; provider={quote.provider}; "
766
- f"status={quote.status}; timestamp={quote.timestamp.isoformat(timespec='seconds')}"
767
- )
768
- except (FinCLIError, AttributeError, ValueError) as exc:
769
- lines.append(f"Quote: unavailable ({exc})")
770
-
1094
+ events = self._run_async(service.events(start, end))
1095
+ except FinCLIError as exc:
1096
+ events, _, _ = self._calendar_public_or_static_fallback(start, end, exc)
1097
+ events = filter_events(events, country=country, impact=impact)
1098
+ written = export_rows(economic_event_rows(events), export_format, target)
1099
+ return CommandResult(Panel(f"Calendar export selesai: {written}", title="Calendar Export", border_style="green"))
1100
+
1101
+ def _calendar_public_or_static_fallback(
1102
+ self, start: date, end: date, provider_error: FinCLIError
1103
+ ) -> tuple[list[EconomicEvent], str, str]:
1104
+ if not os.getenv("FINNHUB_API_KEY"):
1105
+ return fallback_events(start, end), "fallback", _calendar_fallback_note(provider_error, False)
771
1106
  try:
772
- candles = self._run_async(self.market_service.history(symbol, period="6mo", interval="1d"))
773
- if candles:
774
- technical = summarize_technical_indicators(candles)
775
- structure = analyze_market_structure(candles)
776
- debate = run_technical_debate(technical, structure, candles)
777
- signal = debate.judge_signal
778
- lines.extend(
779
- [
780
- f"OHLCV: {len(candles)} daily candles available.",
781
- (
782
- "Technical: "
783
- f"close={_fmt(technical.latest_close)}; trend={technical.trend_bias}; "
784
- f"RSI={_fmt(technical.rsi)}; MACD={_fmt(technical.macd)}/{_fmt(technical.macd_signal)}; "
785
- f"support={_fmt(technical.support)}; resistance={_fmt(technical.resistance)}; "
786
- f"ATR={_fmt(technical.atr)}"
787
- ),
788
- (
789
- "Structure: "
790
- f"trend={structure.trend}; pattern={structure.latest_pattern}; "
791
- f"BOS={structure.break_of_structure}; CHoCH={structure.change_of_character}; "
792
- f"liquidity={structure.liquidity_area or 'N/A'}; risk_zone={structure.risk_zone or 'N/A'}"
793
- ),
794
- (
795
- "Debate Signal: "
796
- f"{signal.label}; confidence={signal.confidence}; score={signal.score}; "
797
- f"judge_reasoning={'; '.join(debate.judge_reasoning[:2])}"
798
- ),
799
- ]
1107
+ events = self._run_async(PublicEconomicCalendarService().events(start, end))
1108
+ if events:
1109
+ return (
1110
+ events,
1111
+ "public",
1112
+ (
1113
+ "Finnhub calendar unavailable for the current key, plan, or rate limit. "
1114
+ "Using public economic calendar fallback; verify critical events with official sources."
1115
+ ),
800
1116
  )
801
- else:
802
- lines.append("OHLCV: unavailable (provider returned no candles).")
803
- except (FinCLIError, AttributeError, ValueError) as exc:
804
- lines.append(f"OHLCV/Technical: unavailable ({exc})")
805
-
806
- try:
807
- fundamentals = self._run_async(self.market_service.fundamentals(symbol))
808
- lines.append(
809
- "Fundamentals: "
810
- f"provider={fundamentals.provider}; market_cap={_fmt(fundamentals.market_cap)}; "
811
- f"pe={_fmt(fundamentals.pe_ratio)}; eps={_fmt(fundamentals.eps)}; "
812
- f"revenue={_fmt(fundamentals.revenue)}; sector={fundamentals.sector or 'N/A'}; "
813
- f"industry={fundamentals.industry or 'N/A'}"
814
- )
815
- except (FinCLIError, AttributeError, ValueError) as exc:
816
- lines.append(f"Fundamentals: unavailable ({exc})")
817
-
818
- try:
819
- news_items = self._run_async(self.market_service.news(symbol, limit=3))
820
- if news_items:
821
- lines.append("News:")
822
- for item in news_items:
823
- published = item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown time"
824
- summary = f" - {item.summary}" if item.summary else ""
825
- lines.append(f"- {item.title} ({item.source}, {published}){summary}")
826
- else:
827
- lines.append("News: no recent items from active provider.")
828
- except (FinCLIError, AttributeError, ValueError) as exc:
829
- lines.append(f"News: unavailable ({exc})")
830
-
831
- return "\n".join(lines)
832
-
833
- def _export(self, args: list[str]) -> CommandResult:
834
- if len(args) < 3 or args[0].lower() not in {"journal", "portfolio"}:
835
- raise CommandError("Format: /export <journal|portfolio> <csv|json> <path>")
836
- dataset = args[0].lower()
837
- export_format = args[1].lower()
838
- target = args[2]
839
- rows = self.journal.list(limit=10_000) if dataset == "journal" else self.portfolio.list()
840
- written = export_rows(rows, export_format, target)
841
- return CommandResult(Panel(f"Export {dataset} selesai: {written}", title="Export", border_style="green"))
842
-
843
- def _build_market_service(self, injected_provider: BaseMarketProvider | None = None) -> MarketDataService:
844
- if injected_provider is not None:
845
- return MarketDataService(
846
- [injected_provider],
847
- cache=self.market_cache,
848
- cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
849
- )
850
- priority = self.config.settings.market_provider_priority or [self.config.settings.market_provider]
851
- return MarketDataService(
852
- self.market_manager.create_many(priority),
853
- cache=self.market_cache,
854
- cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
855
- )
856
-
857
- def _refresh_market_service(self) -> None:
858
- self.market_service = self._build_market_service()
859
- self.market_provider = self.market_service.primary_provider
1117
+ except FinCLIError as public_error:
1118
+ note = _calendar_static_fallback_note(provider_error, public_error)
1119
+ return fallback_events(start, end), "fallback", note
1120
+ return fallback_events(start, end), "fallback", _calendar_static_fallback_note(provider_error, None)
860
1121
 
1122
+ def _run_async(self, awaitable: Any) -> Any:
1123
+ try:
1124
+ asyncio.get_running_loop()
1125
+ except RuntimeError:
1126
+ return asyncio.run(awaitable)
1127
+
1128
+ with ThreadPoolExecutor(max_workers=1) as executor:
1129
+ future = executor.submit(asyncio.run, awaitable)
1130
+ return future.result()
1131
+
1132
+ def _portfolio_market_values(self, row: dict[str, object]) -> tuple[float | None, float | None, float | None]:
1133
+ try:
1134
+ symbol = str(row["symbol"])
1135
+ quantity = float(row["quantity"])
1136
+ average_price = float(row["average_price"])
1137
+ quote = self._get_quote(symbol)
1138
+ current_price = quote.price
1139
+ if current_price is None:
1140
+ return None, None, None
1141
+ pnl = (current_price - average_price) * quantity
1142
+ invested = average_price * quantity
1143
+ pnl_percent = (pnl / invested * 100) if invested else None
1144
+ return current_price, pnl, pnl_percent
1145
+ except FinCLIError:
1146
+ return None, None, None
1147
+ except (TypeError, ValueError, KeyError):
1148
+ return None, None, None
1149
+
1150
+ def _portfolio_performance_table(self) -> Table:
1151
+ positions = self.portfolio.list()
1152
+ realized = self.transactions.realized_pnl_total()
1153
+ cost_basis = 0.0
1154
+ market_value = 0.0
1155
+ unrealized = 0.0
1156
+ for row in positions:
1157
+ quantity = float(row["quantity"])
1158
+ average_price = float(row["average_price"])
1159
+ current_price, pnl, _ = self._portfolio_market_values(row)
1160
+ cost_basis += quantity * average_price
1161
+ if current_price is not None:
1162
+ market_value += quantity * current_price
1163
+ if pnl is not None:
1164
+ unrealized += pnl
1165
+
1166
+ table = Table(title="Portfolio Performance", expand=True)
1167
+ table.add_column("Metric", style="cyan")
1168
+ table.add_column("Value", justify="right")
1169
+ table.add_row("Cost Basis", _fmt(cost_basis))
1170
+ table.add_row("Market Value", _fmt(market_value))
1171
+ table.add_row("Unrealized PnL", _fmt(unrealized))
1172
+ table.add_row("Realized PnL", _fmt(realized))
1173
+ table.add_row("Total PnL", _fmt(realized + unrealized))
1174
+ return table
1175
+
1176
+ def _get_quote(self, symbol: str) -> Quote:
1177
+ normalized = symbol.upper()
1178
+ cache_key = f"quote:{normalized}"
1179
+ cached = self.cache.get(cache_key)
1180
+ if isinstance(cached, Quote):
1181
+ return cached
1182
+ quote = self._run_async(self.market_service.quote(normalized))
1183
+ if not isinstance(quote, Quote):
1184
+ raise CommandError("Provider quote mengembalikan data tidak valid.")
1185
+ self.cache.set(cache_key, quote)
1186
+ return quote
1187
+
1188
+ def _provider_health_text(self) -> str:
1189
+ try:
1190
+ status = self._run_async(self.market_service.status())
1191
+ return (
1192
+ f"Provider health: {status.status}\n"
1193
+ f"Provider realtime: {status.realtime}\n"
1194
+ f"Provider message: {status.message}"
1195
+ )
1196
+ except (FinCLIError, AttributeError) as exc:
1197
+ return f"Provider health: unavailable ({exc})"
1198
+
1199
+ def _safe_quote(self, symbol: str) -> Quote | None:
1200
+ try:
1201
+ return self._get_quote(symbol)
1202
+ except FinCLIError:
1203
+ return None
1204
+
1205
+ def _analysis_context(self, symbol: str) -> str:
1206
+ sections: list[str] = []
1207
+ try:
1208
+ news_items = self._run_async(self.market_service.news(symbol, limit=3))
1209
+ sections.append(_format_news_context(news_items))
1210
+ except (FinCLIError, AttributeError) as exc:
1211
+ sections.append(f"News unavailable: {exc}")
1212
+ try:
1213
+ fundamentals = self._run_async(self.market_service.fundamentals(symbol))
1214
+ sections.append(_format_fundamental_context(fundamentals))
1215
+ except (FinCLIError, AttributeError) as exc:
1216
+ sections.append(f"Fundamentals unavailable: {exc}")
1217
+ return "\n\n".join(sections)
1218
+
1219
+ def _freechat_market_context(self, prompt: str) -> str:
1220
+ symbols = extract_market_symbols(prompt)
1221
+ if not symbols:
1222
+ return ""
1223
+
1224
+ sections = [
1225
+ "FinCLI provider chain: "
1226
+ + ", ".join(provider.name for provider in self.market_service.providers)
1227
+ + ". Realtime status depends on the active provider and API key."
1228
+ ]
1229
+ for symbol in symbols:
1230
+ sections.append(self._symbol_freechat_context(symbol))
1231
+ return "\n\n".join(sections)
1232
+
1233
+ def _freechat_web_context(self, prompt: str) -> str:
1234
+ if not should_use_web_research(prompt):
1235
+ return ""
1236
+ cache_key = f"web:{prompt.lower()[:180]}"
1237
+ cached = self.cache.get(cache_key)
1238
+ if isinstance(cached, str):
1239
+ return cached
1240
+ try:
1241
+ results = self._run_async(self.web_research.research(prompt, limit=3))
1242
+ except FinCLIError as exc:
1243
+ return f"Web Research: unavailable ({exc})"
1244
+ context = build_web_research_context(results)
1245
+ self.cache.set(cache_key, context)
1246
+ return context
1247
+
1248
+ def _symbol_freechat_context(self, symbol: str) -> str:
1249
+ lines = [f"Symbol: {symbol}"]
1250
+ try:
1251
+ quote = self._get_quote(symbol)
1252
+ lines.append(
1253
+ f"Quote: price={_fmt(quote.price)} {quote.currency}; provider={quote.provider}; "
1254
+ f"status={quote.status}; timestamp={quote.timestamp.isoformat(timespec='seconds')}"
1255
+ )
1256
+ except (FinCLIError, AttributeError, ValueError) as exc:
1257
+ lines.append(f"Quote: unavailable ({exc})")
1258
+
1259
+ try:
1260
+ candles = self._run_async(self.market_service.history(symbol, period="6mo", interval="1d"))
1261
+ if candles:
1262
+ technical = summarize_technical_indicators(candles)
1263
+ structure = analyze_market_structure(candles)
1264
+ debate = run_technical_debate(technical, structure, candles)
1265
+ signal = debate.judge_signal
1266
+ lines.extend(
1267
+ [
1268
+ f"OHLCV: {len(candles)} daily candles available.",
1269
+ (
1270
+ "Technical: "
1271
+ f"close={_fmt(technical.latest_close)}; trend={technical.trend_bias}; "
1272
+ f"RSI={_fmt(technical.rsi)}; MACD={_fmt(technical.macd)}/{_fmt(technical.macd_signal)}; "
1273
+ f"support={_fmt(technical.support)}; resistance={_fmt(technical.resistance)}; "
1274
+ f"ATR={_fmt(technical.atr)}"
1275
+ ),
1276
+ (
1277
+ "Structure: "
1278
+ f"trend={structure.trend}; pattern={structure.latest_pattern}; "
1279
+ f"BOS={structure.break_of_structure}; CHoCH={structure.change_of_character}; "
1280
+ f"liquidity={structure.liquidity_area or 'N/A'}; risk_zone={structure.risk_zone or 'N/A'}"
1281
+ ),
1282
+ (
1283
+ "Debate Signal: "
1284
+ f"{signal.label}; confidence={signal.confidence}; score={signal.score}; "
1285
+ f"judge_reasoning={'; '.join(debate.judge_reasoning[:2])}"
1286
+ ),
1287
+ ]
1288
+ )
1289
+ else:
1290
+ lines.append("OHLCV: unavailable (provider returned no candles).")
1291
+ except (FinCLIError, AttributeError, ValueError) as exc:
1292
+ lines.append(f"OHLCV/Technical: unavailable ({exc})")
1293
+
1294
+ try:
1295
+ fundamentals = self._run_async(self.market_service.fundamentals(symbol))
1296
+ lines.append(
1297
+ "Fundamentals: "
1298
+ f"provider={fundamentals.provider}; market_cap={_fmt(fundamentals.market_cap)}; "
1299
+ f"pe={_fmt(fundamentals.pe_ratio)}; eps={_fmt(fundamentals.eps)}; "
1300
+ f"revenue={_fmt(fundamentals.revenue)}; sector={fundamentals.sector or 'N/A'}; "
1301
+ f"industry={fundamentals.industry or 'N/A'}"
1302
+ )
1303
+ except (FinCLIError, AttributeError, ValueError) as exc:
1304
+ lines.append(f"Fundamentals: unavailable ({exc})")
1305
+
1306
+ try:
1307
+ news_items = self._run_async(self.market_service.news(symbol, limit=3))
1308
+ if news_items:
1309
+ lines.append("News:")
1310
+ for item in news_items:
1311
+ published = item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown time"
1312
+ summary = f" - {item.summary}" if item.summary else ""
1313
+ lines.append(f"- {item.title} ({item.source}, {published}){summary}")
1314
+ else:
1315
+ lines.append("News: no recent items from active provider.")
1316
+ except (FinCLIError, AttributeError, ValueError) as exc:
1317
+ lines.append(f"News: unavailable ({exc})")
1318
+
1319
+ return "\n".join(lines)
1320
+
1321
+ def _export(self, args: list[str]) -> CommandResult:
1322
+ if len(args) < 3 or args[0].lower() not in {"journal", "portfolio"}:
1323
+ raise CommandError("Format: /export <journal|portfolio> <csv|json> <path>")
1324
+ dataset = args[0].lower()
1325
+ export_format = args[1].lower()
1326
+ target = args[2]
1327
+ rows = self.journal.list(limit=10_000) if dataset == "journal" else self.portfolio.list()
1328
+ written = export_rows(rows, export_format, target)
1329
+ return CommandResult(Panel(f"Export {dataset} selesai: {written}", title="Export", border_style="green"))
1330
+
1331
+ def _build_market_service(self, injected_provider: BaseMarketProvider | None = None) -> MarketDataService:
1332
+ if injected_provider is not None:
1333
+ return MarketDataService(
1334
+ [injected_provider],
1335
+ cache=self.market_cache,
1336
+ cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1337
+ )
1338
+ priority = self.config.settings.market_provider_priority or [self.config.settings.market_provider]
1339
+ return MarketDataService(
1340
+ self.market_manager.create_many(priority),
1341
+ cache=self.market_cache,
1342
+ cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1343
+ )
1344
+
1345
+ def _refresh_market_service(self) -> None:
1346
+ self.market_service = self._build_market_service()
1347
+ self.market_provider = self.market_service.primary_provider
1348
+
861
1349
  def _priority_tail(self, active_provider: str) -> list[str]:
862
1350
  active = active_provider.lower()
863
1351
  existing = self.config.settings.market_provider_priority or ["yfinance"]
@@ -866,243 +1354,468 @@ class CommandRouter:
866
1354
  tail.append("yfinance")
867
1355
  return tail
868
1356
 
1357
+ def _news_priority_tail(self, active_provider: str) -> list[str]:
1358
+ active = active_provider.lower()
1359
+ existing = self.config.settings.news_provider_priority or ["yfinance", "google_news_rss", "yahoo_finance_rss"]
1360
+ tail = [provider for provider in existing if provider != active]
1361
+ if active != "yfinance" and "yfinance" not in tail:
1362
+ tail.append("yfinance")
1363
+ if active != "google_news_rss" and "google_news_rss" not in tail:
1364
+ tail.append("google_news_rss")
1365
+ return tail
869
1366
 
870
- def _format_quote(quote: Quote) -> str:
871
- price = "N/A" if quote.price is None else f"{quote.price:,.4f}"
872
- return (
873
- f"Quote: {quote.symbol}\n"
874
- f"Price: {price} {quote.currency}\n"
875
- f"Provider: {quote.provider}\n"
876
- f"Status: {quote.status}\n"
877
- f"Timestamp: {quote.timestamp.isoformat(timespec='seconds')}\n"
878
- "Catatan: yfinance fallback biasanya delayed, bukan realtime."
879
- )
880
-
881
-
882
- def _format_dashboard(
883
- provider_chain: list[str],
884
- watchlist_rows: list[dict[str, object]],
885
- portfolio_rows: list[dict[str, object]],
886
- journal_stats: JournalStats,
887
- realized_pnl: float,
888
- quote_getter: Any,
889
- portfolio_value_getter: Any,
890
- ) -> Table:
891
- table = Table(title="FinCLI Dashboard", expand=True)
892
- table.add_column("Area", style="cyan", no_wrap=True)
893
- table.add_column("Summary", style="white")
894
- table.add_column("Next Action", style="dim")
895
-
896
- table.add_row(
897
- "Provider Chain",
898
- ", ".join(provider_chain) if provider_chain else "N/A",
899
- "/provider status | /provider priority finnhub,yfinance",
900
- )
901
-
902
- watchlist_symbols = [str(row["symbol"]) for row in watchlist_rows]
903
- quote_bits: list[str] = []
904
- for symbol in watchlist_symbols[:4]:
905
- quote = quote_getter(symbol)
906
- quote_bits.append(f"{symbol} {_fmt(quote.price) if quote else 'N/A'}")
907
- table.add_row(
908
- "Watchlist",
909
- f"{len(watchlist_rows)} symbol(s)" + (f" | {', '.join(quote_bits)}" if quote_bits else ""),
910
- "/watchlist add AAPL | /scan watchlist trend=bullish",
911
- )
912
-
913
- market_value = 0.0
914
- unrealized = 0.0
915
- for row in portfolio_rows:
916
- current_price, pnl, _ = portfolio_value_getter(row)
917
- if current_price is not None:
918
- market_value += float(row["quantity"]) * current_price
919
- if pnl is not None:
920
- unrealized += pnl
921
- portfolio_summary = (
922
- f"{len(portfolio_rows)} position(s) | Market Value {_fmt(market_value)} | "
923
- f"Unrealized PnL {_fmt(unrealized)} | Realized PnL {_fmt(realized_pnl)}"
924
- if portfolio_rows
925
- else "No local portfolio positions"
926
- )
1367
+ def _validate_news_providers(self, providers: list[str]) -> None:
1368
+ market_names = {provider.name for provider in self.market_service.providers}
1369
+ known = {"yfinance", *market_names}
1370
+ known.update(connector.slug for connector in self.news_connector_catalog.all())
1371
+ unknown = [provider for provider in providers if provider not in known]
1372
+ if unknown:
1373
+ raise CommandError(
1374
+ f"News provider tidak dikenal: {', '.join(unknown)}",
1375
+ "Gunakan /news_model list atau /news_model search <query> untuk melihat provider yang tersedia.",
1376
+ )
1377
+
1378
+
1379
+ def _format_quote(quote: Quote) -> str:
1380
+ price = "N/A" if quote.price is None else f"{quote.price:,.4f}"
1381
+ return (
1382
+ f"Quote: {quote.symbol}\n"
1383
+ f"Price: {price} {quote.currency}\n"
1384
+ f"Provider: {quote.provider}\n"
1385
+ f"Status: {quote.status}\n"
1386
+ f"Timestamp: {quote.timestamp.isoformat(timespec='seconds')}\n"
1387
+ "Catatan: yfinance fallback biasanya delayed, bukan realtime."
1388
+ )
1389
+
1390
+
1391
+ def _format_sessions(sessions: list[dict[str, object]], current_session_id: str) -> Table:
1392
+ table = Table(title="FinCLI Sessions", expand=True)
1393
+ table.add_column("Current", justify="center", width=7)
1394
+ table.add_column("Session ID", style="cyan", no_wrap=True)
1395
+ table.add_column("Title", style="white")
1396
+ table.add_column("Events", justify="right")
1397
+ table.add_column("Updated", style="dim")
1398
+ for session in sessions:
1399
+ session_id = str(session["id"])
1400
+ table.add_row(
1401
+ "*" if session_id == current_session_id else "",
1402
+ session_id,
1403
+ str(session["title"]),
1404
+ str(session["event_count"]),
1405
+ str(session["updated_at"]),
1406
+ )
1407
+ if not sessions:
1408
+ table.add_row("-", "-", "Belum ada session.", "0", "-")
1409
+ table.caption = "/history current | /history show <session_id> | /history delete <session_id>"
1410
+ return table
1411
+
1412
+
1413
+ def _format_session_events(session: dict[str, object], events: list[dict[str, object]], current: bool = False) -> Table:
1414
+ marker = "current" if current else "saved"
1415
+ table = Table(title=f"Session {session['id']} ({marker}) - {session['title']}", expand=True)
1416
+ table.add_column("#", justify="right", width=4)
1417
+ table.add_column("Time", style="dim", no_wrap=True)
1418
+ table.add_column("Status", style="cyan", no_wrap=True)
1419
+ table.add_column("Command", style="white")
1420
+ table.add_column("Output Preview", style="dim")
1421
+ for event in events:
1422
+ table.add_row(
1423
+ str(event["id"]),
1424
+ str(event["created_at"]),
1425
+ str(event["status"]),
1426
+ str(event["command"]),
1427
+ str(event["output_preview"] or "")[:180],
1428
+ )
1429
+ if not events:
1430
+ table.add_row("-", "-", "-", "Belum ada command di session ini.", "")
1431
+ table.caption = "/history sessions | /history save <title> | /history clear current"
1432
+ return table
1433
+
1434
+
1435
+ def _render_history_preview(renderable: Any) -> str:
1436
+ if renderable is None:
1437
+ return ""
1438
+ if isinstance(renderable, str):
1439
+ return renderable[:1200]
1440
+ console = Console(width=100, record=True, force_terminal=False, file=io.StringIO())
1441
+ try:
1442
+ console.print(renderable)
1443
+ return console.export_text(clear=False).strip()[:1200]
1444
+ except Exception:
1445
+ return str(renderable)[:1200]
1446
+
1447
+
1448
+ def _format_dashboard(
1449
+ provider_chain: list[str],
1450
+ watchlist_rows: list[dict[str, object]],
1451
+ portfolio_rows: list[dict[str, object]],
1452
+ journal_stats: JournalStats,
1453
+ realized_pnl: float,
1454
+ quote_getter: Any,
1455
+ portfolio_value_getter: Any,
1456
+ ) -> Table:
1457
+ table = Table(title="FinCLI Dashboard", expand=True)
1458
+ table.add_column("Area", style="cyan", no_wrap=True)
1459
+ table.add_column("Summary", style="white")
1460
+ table.add_column("Next Action", style="dim")
1461
+
1462
+ table.add_row(
1463
+ "Provider Chain",
1464
+ ", ".join(provider_chain) if provider_chain else "N/A",
1465
+ "/provider status | /provider priority finnhub,yfinance",
1466
+ )
1467
+
1468
+ watchlist_symbols = [str(row["symbol"]) for row in watchlist_rows]
1469
+ quote_bits: list[str] = []
1470
+ for symbol in watchlist_symbols[:4]:
1471
+ quote = quote_getter(symbol)
1472
+ quote_bits.append(f"{symbol} {_fmt(quote.price) if quote else 'N/A'}")
1473
+ table.add_row(
1474
+ "Watchlist",
1475
+ f"{len(watchlist_rows)} symbol(s)" + (f" | {', '.join(quote_bits)}" if quote_bits else ""),
1476
+ "/watchlist add AAPL | /scan watchlist trend=bullish",
1477
+ )
1478
+
1479
+ market_value = 0.0
1480
+ unrealized = 0.0
1481
+ for row in portfolio_rows:
1482
+ current_price, pnl, _ = portfolio_value_getter(row)
1483
+ if current_price is not None:
1484
+ market_value += float(row["quantity"]) * current_price
1485
+ if pnl is not None:
1486
+ unrealized += pnl
1487
+ portfolio_summary = (
1488
+ f"{len(portfolio_rows)} position(s) | Market Value {_fmt(market_value)} | "
1489
+ f"Unrealized PnL {_fmt(unrealized)} | Realized PnL {_fmt(realized_pnl)}"
1490
+ if portfolio_rows
1491
+ else "No local portfolio positions"
1492
+ )
927
1493
  table.add_row("Portfolio", portfolio_summary, "/tx add buy AAPL 10 185 | /portfolio performance")
928
-
929
- table.add_row(
930
- "Journal",
931
- (
932
- f"{journal_stats.total_entries} entries | Win Rate {_fmt(journal_stats.win_rate)} | "
933
- f"Top {journal_stats.top_instrument}"
934
- ),
935
- "/journal stats | /journal review",
936
- )
937
-
1494
+
1495
+ table.add_row(
1496
+ "Journal",
1497
+ (
1498
+ f"{journal_stats.total_entries} entries | Win Rate {_fmt(journal_stats.win_rate)} | "
1499
+ f"Top {journal_stats.top_instrument}"
1500
+ ),
1501
+ "/journal stats | /journal review",
1502
+ )
1503
+
938
1504
  table.add_row(
939
1505
  "Market",
940
1506
  "Use /market for compact quote + technical + structure + news + fundamentals.",
941
1507
  "/market AAPL 1d | /analyze AAPL 1d",
942
1508
  )
1509
+ if unrealized != 0 or realized_pnl != 0:
1510
+ table.add_row(
1511
+ "Risk Color",
1512
+ semantic_text(f"Total PnL {_fmt(realized_pnl + unrealized)} {'gain' if realized_pnl + unrealized >= 0 else 'loss'}"),
1513
+ "green=positive | red=negative | yellow=caution",
1514
+ )
943
1515
  return table
944
-
945
-
1516
+
1517
+
946
1518
  def _format_market_overview(overview: MarketOverview) -> Table:
947
- table = Table(title=f"Market Overview: {overview.symbol} | {overview.timeframe}", expand=True)
948
- table.add_column("Section", style="cyan", no_wrap=True)
949
- table.add_column("Value", style="white")
950
- table.add_column("Context", style="dim")
951
-
952
- quality = overview.data_quality
953
- table.add_row(
954
- "Data Quality",
955
- f"{quality.score}/100",
956
- f"quote={quality.quote}; ohlcv={quality.ohlcv}; news={quality.news}; fundamentals={quality.fundamentals}; provider={quality.provider}",
957
- )
1519
+ table = Table(title=f"Market Overview: {overview.symbol} | {overview.timeframe}", expand=True)
1520
+ table.add_column("Section", style="cyan", no_wrap=True)
1521
+ table.add_column("Value", style="white")
1522
+ table.add_column("Context", style="dim")
1523
+
1524
+ quality = overview.data_quality
1525
+ table.add_row(
1526
+ "Data Quality",
1527
+ f"{quality.score}/100",
1528
+ f"quote={quality.quote}; ohlcv={quality.ohlcv}; news={quality.news}; fundamentals={quality.fundamentals}; provider={quality.provider}",
1529
+ )
958
1530
  table.add_row(
959
1531
  "Quote",
960
1532
  f"{_fmt(overview.quote.price)} {overview.quote.currency}",
961
- f"{overview.quote.provider} | {overview.quote.status} | {overview.quote.timestamp.isoformat(timespec='seconds')}",
1533
+ semantic_text(f"{overview.quote.provider} | {overview.quote.status} | {overview.quote.timestamp.isoformat(timespec='seconds')}"),
962
1534
  )
963
1535
  table.add_row(
964
1536
  "Technical",
965
- f"RSI {_fmt(overview.technical.rsi)} | Trend {overview.technical.trend_bias}",
1537
+ semantic_text(f"RSI {_fmt(overview.technical.rsi)} | Trend {overview.technical.trend_bias}"),
966
1538
  f"MACD {_fmt(overview.technical.macd)} / Signal {_fmt(overview.technical.macd_signal)} | ATR {_fmt(overview.technical.atr)}",
967
1539
  )
968
- table.add_row(
969
- "Key Levels",
970
- f"Support {_fmt(overview.technical.support)} | Resistance {_fmt(overview.technical.resistance)}",
971
- f"Bollinger {_fmt(overview.technical.bollinger_lower)} - {_fmt(overview.technical.bollinger_upper)}",
972
- )
1540
+ table.add_row(
1541
+ "Key Levels",
1542
+ f"Support {_fmt(overview.technical.support)} | Resistance {_fmt(overview.technical.resistance)}",
1543
+ f"Bollinger {_fmt(overview.technical.bollinger_lower)} - {_fmt(overview.technical.bollinger_upper)}",
1544
+ )
973
1545
  table.add_row(
974
1546
  "Market Structure",
975
- f"{overview.structure.trend} | {overview.structure.latest_pattern}",
1547
+ semantic_text(f"{overview.structure.trend} | {overview.structure.latest_pattern}"),
976
1548
  f"BOS={overview.structure.break_of_structure}; CHoCH={overview.structure.change_of_character}; Liquidity={overview.structure.liquidity_area}",
977
1549
  )
978
-
979
- if overview.fundamentals is not None:
980
- table.add_row(
981
- "Fundamentals",
982
- f"P/E {_fmt(overview.fundamentals.pe_ratio)} | EPS {_fmt(overview.fundamentals.eps)}",
983
- f"Sector={overview.fundamentals.sector or 'N/A'}; Industry={overview.fundamentals.industry or 'N/A'}; Market Cap={_fmt(overview.fundamentals.market_cap)}",
1550
+
1551
+ if overview.fundamentals is not None:
1552
+ table.add_row(
1553
+ "Fundamentals",
1554
+ f"P/E {_fmt(overview.fundamentals.pe_ratio)} | EPS {_fmt(overview.fundamentals.eps)}",
1555
+ f"Sector={overview.fundamentals.sector or 'N/A'}; Industry={overview.fundamentals.industry or 'N/A'}; Market Cap={_fmt(overview.fundamentals.market_cap)}",
1556
+ )
1557
+ else:
1558
+ table.add_row("Fundamentals", "N/A", "Provider did not return fundamentals.")
1559
+
1560
+ if overview.news:
1561
+ latest_news = overview.news[0]
1562
+ table.add_row(
1563
+ "Latest News",
1564
+ latest_news.title,
1565
+ f"{latest_news.source} | {latest_news.published_at.isoformat(timespec='seconds') if latest_news.published_at else 'unknown time'}",
1566
+ )
1567
+ else:
1568
+ table.add_row("Latest News", "N/A", "Provider did not return recent news.")
1569
+
1570
+ table.add_row("Disclaimer", "Informational only", "Bukan nasihat keuangan.")
1571
+ return table
1572
+
1573
+
1574
+ def _format_technical(
1575
+ symbol: str,
1576
+ interval: str,
1577
+ summary: TechnicalSummary,
1578
+ signal: TechnicalSignal | None = None,
1579
+ ai_summary: str = "",
1580
+ debate: TechnicalDebate | None = None,
1581
+ ) -> str:
1582
+ signal_text = format_signal(signal) if signal is not None else "Signal: CAUTION\nSignal Reasoning:\n- Signal unavailable."
1583
+ debate_text = format_debate(debate) if debate is not None else "Technical Debate:\n- Debate unavailable."
1584
+ return (
1585
+ f"Technical Analysis: {symbol}\n"
1586
+ f"Timeframe: {interval}\n"
1587
+ f"Latest Close: {_fmt(summary.latest_close)}\n"
1588
+ f"Trend Bias: {summary.trend_bias}\n"
1589
+ f"SMA 5: {_fmt(summary.sma_fast)}\n"
1590
+ f"SMA 20: {_fmt(summary.sma_slow)}\n"
1591
+ f"EMA 12: {_fmt(summary.ema_fast)}\n"
1592
+ f"RSI 14: {_fmt(summary.rsi)}\n"
1593
+ f"MACD: {_fmt(summary.macd)} | Signal: {_fmt(summary.macd_signal)}\n"
1594
+ f"Bollinger: upper {_fmt(summary.bollinger_upper)} | lower {_fmt(summary.bollinger_lower)}\n"
1595
+ f"ATR 14: {_fmt(summary.atr)}\n"
1596
+ f"Support: {_fmt(summary.support)} | Resistance: {_fmt(summary.resistance)}\n"
1597
+ f"Volume Latest: {_fmt(summary.volume_latest)}\n"
1598
+ f"\n{signal_text}\n"
1599
+ f"\n{debate_text}\n"
1600
+ f"\n{ai_summary}\n"
1601
+ "Disclaimer: analisis ini bersifat informasional, bukan nasihat keuangan."
1602
+ )
1603
+
1604
+
1605
+ def _format_structure(symbol: str, interval: str, structure: MarketStructureSummary) -> str:
1606
+ return (
1607
+ f"Market Structure: {symbol}\n"
1608
+ f"Timeframe: {interval}\n"
1609
+ f"Trend: {structure.trend}\n"
1610
+ f"Latest Pattern: {structure.latest_pattern}\n"
1611
+ f"Break of Structure: {structure.break_of_structure}\n"
1612
+ f"Change of Character: {structure.change_of_character}\n"
1613
+ f"Support: {_fmt(structure.support)}\n"
1614
+ f"Resistance: {_fmt(structure.resistance)}\n"
1615
+ f"Liquidity Area: {structure.liquidity_area or 'N/A'}\n"
1616
+ f"Risk Zone: {structure.risk_zone or 'N/A'}\n"
1617
+ "Disclaimer: struktur pasar ini bersifat skenario, bukan nasihat keuangan."
1618
+ )
1619
+
1620
+
1621
+ def _format_multi_timeframe(analysis: MultiTimeframeAnalysis) -> Table:
1622
+ table = Table(title=f"Multi-Timeframe Analysis: {analysis.symbol}", expand=True)
1623
+ table.add_column("Timeframe", style="cyan", no_wrap=True)
1624
+ table.add_column("Status", no_wrap=True)
1625
+ table.add_column("Candles", justify="right")
1626
+ table.add_column("Close", justify="right")
1627
+ table.add_column("Trend")
1628
+ table.add_column("Structure")
1629
+ table.add_column("RSI", justify="right")
1630
+ table.add_column("MACD", justify="right")
1631
+ table.add_column("Support / Resistance", overflow="fold")
1632
+ table.add_column("Note", overflow="fold")
1633
+ for frame in analysis.frames:
1634
+ table.add_row(
1635
+ frame.timeframe,
1636
+ frame.status,
1637
+ str(frame.candles),
1638
+ _fmt(frame.latest_close),
1639
+ semantic_text(frame.trend_bias),
1640
+ semantic_text(frame.structure_trend),
1641
+ _fmt(frame.rsi),
1642
+ _fmt(frame.macd),
1643
+ f"{_fmt(frame.support)} / {_fmt(frame.resistance)}",
1644
+ frame.note or "-",
1645
+ )
1646
+ table.caption = (
1647
+ f"Alignment: {analysis.alignment} | Bias: {analysis.bias} | Score: {analysis.score} | "
1648
+ f"Risk: {analysis.risk_note}"
1649
+ )
1650
+ return table
1651
+
1652
+
1653
+ def _format_backtest(result: BacktestResult) -> Table:
1654
+ table = Table(title=f"Backtest: {result.symbol} | {result.strategy} | {result.interval}", expand=True)
1655
+ table.add_column("Metric", style="cyan", no_wrap=True)
1656
+ table.add_column("Value", style="white")
1657
+ table.add_row("Candles", str(result.candles))
1658
+ table.add_row("Trades", str(len(result.trades)))
1659
+ table.add_row("Total Return", semantic_text(f"{result.total_return_percent:.2f}% {'gain' if result.total_return_percent >= 0 else 'loss'}"))
1660
+ table.add_row("Win Rate", f"{result.win_rate:.2f}%")
1661
+ table.add_row("Max Drawdown", semantic_text(f"{result.max_drawdown_percent:.2f}% drawdown"))
1662
+ table.add_row("Exposure", f"{result.exposure_percent:.2f}%")
1663
+ if result.trades:
1664
+ latest = result.trades[-1]
1665
+ table.add_row(
1666
+ "Latest Trade",
1667
+ (
1668
+ f"entry={latest.entry_price:.4f}; exit={latest.exit_price:.4f}; "
1669
+ f"pnl={latest.pnl_percent:.2f}%; reason={latest.reason}"
1670
+ ),
1671
+ )
1672
+ table.add_row("Notes", " ".join(result.notes))
1673
+ table.caption = "Educational backtest only. Fees, slippage, spreads, liquidity, and execution risk are not modeled."
1674
+ return table
1675
+
1676
+
1677
+ def _format_alerts(rows: list[dict[str, object]]) -> Table:
1678
+ table = Table(title="Price Alerts", expand=True)
1679
+ table.add_column("ID", justify="right", no_wrap=True)
1680
+ table.add_column("Symbol", style="cyan", no_wrap=True)
1681
+ table.add_column("Condition")
1682
+ table.add_column("Target", justify="right")
1683
+ table.add_column("Status")
1684
+ table.add_column("Note", overflow="fold")
1685
+ table.add_column("Created")
1686
+ for row in rows:
1687
+ table.add_row(
1688
+ str(row["id"]),
1689
+ str(row["symbol"]),
1690
+ str(row["condition"]),
1691
+ _fmt(float(row["target"])),
1692
+ semantic_text("active hold" if int(row["active"]) else f"triggered {row['triggered_at']}"),
1693
+ str(row["note"] or "-"),
1694
+ str(row["created_at"]),
984
1695
  )
985
- else:
986
- table.add_row("Fundamentals", "N/A", "Provider did not return fundamentals.")
987
-
988
- if overview.news:
989
- latest_news = overview.news[0]
990
- table.add_row(
991
- "Latest News",
992
- latest_news.title,
993
- f"{latest_news.source} | {latest_news.published_at.isoformat(timespec='seconds') if latest_news.published_at else 'unknown time'}",
1696
+ if not rows:
1697
+ table.add_row("-", "-", "-", "-", "-", "No alerts. Use /alert add AAPL above 200.", "-")
1698
+ return table
1699
+
1700
+
1701
+ def _format_alert_checks(results: list[AlertCheckResult]) -> Table:
1702
+ table = Table(title="Alert Check", expand=True)
1703
+ table.add_column("ID", justify="right", no_wrap=True)
1704
+ table.add_column("Symbol", style="cyan")
1705
+ table.add_column("Condition")
1706
+ table.add_column("Target", justify="right")
1707
+ table.add_column("Current", justify="right")
1708
+ table.add_column("Triggered", justify="center")
1709
+ table.add_column("Note", overflow="fold")
1710
+ for result in results:
1711
+ table.add_row(
1712
+ str(result.id),
1713
+ result.symbol,
1714
+ result.condition,
1715
+ _fmt(result.target),
1716
+ _fmt(result.current_price),
1717
+ semantic_text("YES breakout confirmed" if result.triggered else "no hold"),
1718
+ result.note or "-",
994
1719
  )
995
- else:
996
- table.add_row("Latest News", "N/A", "Provider did not return recent news.")
997
-
998
- table.add_row("Disclaimer", "Informational only", "Bukan nasihat keuangan.")
999
- return table
1000
-
1001
-
1002
- def _format_technical(
1003
- symbol: str,
1004
- interval: str,
1005
- summary: TechnicalSummary,
1006
- signal: TechnicalSignal | None = None,
1007
- ai_summary: str = "",
1008
- debate: TechnicalDebate | None = None,
1009
- ) -> str:
1010
- signal_text = format_signal(signal) if signal is not None else "Signal: CAUTION\nSignal Reasoning:\n- Signal unavailable."
1011
- debate_text = format_debate(debate) if debate is not None else "Technical Debate:\n- Debate unavailable."
1012
- return (
1013
- f"Technical Analysis: {symbol}\n"
1014
- f"Timeframe: {interval}\n"
1015
- f"Latest Close: {_fmt(summary.latest_close)}\n"
1016
- f"Trend Bias: {summary.trend_bias}\n"
1017
- f"SMA 5: {_fmt(summary.sma_fast)}\n"
1018
- f"SMA 20: {_fmt(summary.sma_slow)}\n"
1019
- f"EMA 12: {_fmt(summary.ema_fast)}\n"
1020
- f"RSI 14: {_fmt(summary.rsi)}\n"
1021
- f"MACD: {_fmt(summary.macd)} | Signal: {_fmt(summary.macd_signal)}\n"
1022
- f"Bollinger: upper {_fmt(summary.bollinger_upper)} | lower {_fmt(summary.bollinger_lower)}\n"
1023
- f"ATR 14: {_fmt(summary.atr)}\n"
1024
- f"Support: {_fmt(summary.support)} | Resistance: {_fmt(summary.resistance)}\n"
1025
- f"Volume Latest: {_fmt(summary.volume_latest)}\n"
1026
- f"\n{signal_text}\n"
1027
- f"\n{debate_text}\n"
1028
- f"\n{ai_summary}\n"
1029
- "Disclaimer: analisis ini bersifat informasional, bukan nasihat keuangan."
1030
- )
1031
-
1032
-
1033
- def _format_structure(symbol: str, interval: str, structure: MarketStructureSummary) -> str:
1034
- return (
1035
- f"Market Structure: {symbol}\n"
1036
- f"Timeframe: {interval}\n"
1037
- f"Trend: {structure.trend}\n"
1038
- f"Latest Pattern: {structure.latest_pattern}\n"
1039
- f"Break of Structure: {structure.break_of_structure}\n"
1040
- f"Change of Character: {structure.change_of_character}\n"
1041
- f"Support: {_fmt(structure.support)}\n"
1042
- f"Resistance: {_fmt(structure.resistance)}\n"
1043
- f"Liquidity Area: {structure.liquidity_area or 'N/A'}\n"
1044
- f"Risk Zone: {structure.risk_zone or 'N/A'}\n"
1045
- "Disclaimer: struktur pasar ini bersifat skenario, bukan nasihat keuangan."
1046
- )
1047
-
1048
-
1049
- def _format_scan_results(results: list[ScanResult], filter_expression: str, interval: str) -> Table:
1050
- table = Table(title=f"Scan Watchlist | {filter_expression} | {interval}", expand=True)
1051
- table.add_column("Symbol", style="cyan")
1052
- table.add_column("Close", justify="right")
1053
- table.add_column("RSI", justify="right")
1054
- table.add_column("Trend")
1055
- table.add_column("Support", justify="right")
1056
- table.add_column("Resistance", justify="right")
1057
- table.add_column("Reason")
1058
- for result in results:
1059
- table.add_row(
1720
+ if not results:
1721
+ table.add_row("-", "-", "-", "-", "-", "-", "No active alerts.")
1722
+ return table
1723
+
1724
+
1725
+ def _format_scan_results(results: list[ScanResult], filter_expression: str, interval: str) -> Table:
1726
+ table = Table(title=f"Scan Watchlist | {filter_expression} | {interval}", expand=True)
1727
+ table.add_column("Symbol", style="cyan")
1728
+ table.add_column("Close", justify="right")
1729
+ table.add_column("RSI", justify="right")
1730
+ table.add_column("Trend")
1731
+ table.add_column("Support", justify="right")
1732
+ table.add_column("Resistance", justify="right")
1733
+ table.add_column("Reason")
1734
+ for result in results:
1735
+ table.add_row(
1060
1736
  result.symbol,
1061
1737
  _fmt(result.latest_close),
1062
1738
  _fmt(result.rsi),
1063
- result.trend_bias,
1739
+ semantic_text(result.trend_bias),
1064
1740
  _fmt(result.support),
1065
1741
  _fmt(result.resistance),
1066
- result.reason,
1742
+ semantic_text(result.reason),
1067
1743
  )
1068
- if not results:
1069
- table.add_row("-", "-", "-", "-", "-", "-", "Tidak ada symbol yang match.")
1070
- return table
1744
+ if not results:
1745
+ table.add_row("-", "-", "-", "-", "-", "-", "Tidak ada symbol yang match.")
1746
+ return table
1747
+
1748
+
1749
+ def _scan_result_rows(results: list[ScanResult]) -> list[dict[str, object]]:
1750
+ return [
1751
+ {
1752
+ "symbol": item.symbol,
1753
+ "latest_close": item.latest_close,
1754
+ "rsi": item.rsi,
1755
+ "trend_bias": item.trend_bias,
1756
+ "support": item.support,
1757
+ "resistance": item.resistance,
1758
+ "matched": item.matched,
1759
+ "reason": item.reason,
1760
+ }
1761
+ for item in results
1762
+ ]
1763
+
1764
+
1765
+ def _parse_timeframes(value: str) -> tuple[str, ...]:
1766
+ frames = tuple(frame.strip().lower() for frame in value.split(",") if frame.strip())
1767
+ if not frames:
1768
+ raise CommandError("Timeframe tidak valid. Contoh: /mtf AAPL 1d,1h,15m")
1769
+ if len(frames) > 6:
1770
+ raise CommandError("Maksimal 6 timeframe dalam satu /mtf.")
1771
+ return frames
1772
+
1773
+
1774
+ def _parse_news_lookback(args: list[str]) -> int | None:
1775
+ if not args:
1776
+ return None
1777
+ raw = args[0].strip().lower()
1778
+ if len(args) > 1 or not raw.endswith("d") or not raw[:-1].isdigit():
1779
+ raise CommandError("Format: /news <symbol> [1d-30d]")
1780
+ days = int(raw[:-1])
1781
+ if days < 1 or days > 30:
1782
+ raise CommandError("Lookback /news maksimal 30d. Contoh: /news TSLA 7d")
1783
+ return days
1071
1784
 
1072
1785
 
1073
1786
  def _parse_calendar_args(args: list[str]) -> tuple[date, date, str | None, str | None]:
1074
- country: str | None = None
1075
- impact: str | None = None
1076
- positional: list[str] = []
1077
-
1078
- for arg in args:
1079
- normalized = arg.lower()
1080
- if normalized.startswith("country="):
1081
- country = arg.split("=", 1)[1].upper()
1082
- elif normalized.startswith("impact="):
1083
- impact = arg.split("=", 1)[1].lower()
1084
- elif normalized in {"high", "medium", "low"}:
1085
- impact = normalized
1086
- elif len(arg) in {2, 3} and arg.isalpha():
1087
- country = arg.upper()
1088
- else:
1089
- positional.append(arg)
1090
-
1091
- if not positional:
1092
- start, end = default_calendar_window("week")
1093
- elif positional[0].lower() in {"today", "week"}:
1094
- start, end = default_calendar_window(positional[0].lower())
1095
- elif len(positional) >= 2:
1096
- start = _parse_date_arg(positional[0])
1097
- end = _parse_date_arg(positional[1])
1098
- else:
1099
- raise CommandError("Format: /calendar [today|week|<from YYYY-MM-DD> <to YYYY-MM-DD>] [country=US] [impact=high]")
1100
-
1101
- if end < start:
1102
- raise CommandError("Tanggal akhir calendar tidak boleh lebih kecil dari tanggal awal.")
1103
- return start, end, country, impact
1104
-
1105
-
1787
+ country: str | None = None
1788
+ impact: str | None = None
1789
+ positional: list[str] = []
1790
+
1791
+ for arg in args:
1792
+ normalized = arg.lower()
1793
+ if normalized.startswith("country="):
1794
+ country = arg.split("=", 1)[1].upper()
1795
+ elif normalized.startswith("impact="):
1796
+ impact = arg.split("=", 1)[1].lower()
1797
+ elif normalized in {"high", "medium", "low"}:
1798
+ impact = normalized
1799
+ elif len(arg) in {2, 3} and arg.isalpha():
1800
+ country = arg.upper()
1801
+ else:
1802
+ positional.append(arg)
1803
+
1804
+ if not positional:
1805
+ start, end = default_calendar_window("week")
1806
+ elif positional[0].lower() in {"today", "week"}:
1807
+ start, end = default_calendar_window(positional[0].lower())
1808
+ elif len(positional) >= 2:
1809
+ start = _parse_date_arg(positional[0])
1810
+ end = _parse_date_arg(positional[1])
1811
+ else:
1812
+ raise CommandError("Format: /calendar [today|week|<from YYYY-MM-DD> <to YYYY-MM-DD>] [country=US] [impact=high]")
1813
+
1814
+ if end < start:
1815
+ raise CommandError("Tanggal akhir calendar tidak boleh lebih kecil dari tanggal awal.")
1816
+ return start, end, country, impact
1817
+
1818
+
1106
1819
  def _parse_date_arg(value: str) -> date:
1107
1820
  try:
1108
1821
  return date.fromisoformat(value)
@@ -1110,15 +1823,30 @@ def _parse_date_arg(value: str) -> date:
1110
1823
  raise CommandError("Tanggal calendar harus format YYYY-MM-DD.") from exc
1111
1824
 
1112
1825
 
1826
+ def _calendar_fallback_note(exc: FinCLIError, has_key: bool) -> str:
1827
+ if has_key:
1828
+ return (
1829
+ "FinCLI memakai fallback kategori event. "
1830
+ "Periksa API key, entitlement/plan Finnhub, atau rate-limit untuk data aktual."
1831
+ )
1832
+ return "FinCLI memakai fallback kategori event. Isi FINNHUB_API_KEY untuk data aktual."
1833
+
1834
+
1835
+ def _calendar_static_fallback_note(provider_error: FinCLIError, public_error: FinCLIError | None) -> str:
1836
+ _ = provider_error, public_error
1837
+ return (
1838
+ "Using static macro fallback. Finnhub calendar endpoint is unavailable for the current key/plan "
1839
+ "or provider rate limit, and public calendar fallback is temporarily unavailable. "
1840
+ "Check /provider key status, Finnhub calendar entitlement, and try again later."
1841
+ )
1842
+
1843
+
1113
1844
  def _format_calendar(events: list[EconomicEvent], start: date, end: date, source: str, note: str) -> Table:
1114
1845
  table = Table(title=f"Economic Calendar | {start.isoformat()} to {end.isoformat()} | {source}", expand=True)
1115
- table.add_column("Time", style="cyan", no_wrap=True)
1116
- table.add_column("Country")
1117
- table.add_column("Impact")
1118
- table.add_column("Event", style="white")
1119
- table.add_column("Actual", justify="right")
1120
- table.add_column("Estimate", justify="right")
1121
- table.add_column("Previous", justify="right")
1846
+ table.add_column("Time", style="cyan", no_wrap=True, width=16, max_width=16)
1847
+ table.add_column("Country", no_wrap=True, width=7, max_width=7)
1848
+ table.add_column("Impact", no_wrap=True, width=6, max_width=6)
1849
+ table.add_column("Event", style="white", overflow="fold")
1122
1850
 
1123
1851
  for event in events:
1124
1852
  event_time = event.time.isoformat(timespec="minutes") if event.time else "TBA"
@@ -1127,178 +1855,472 @@ def _format_calendar(events: list[EconomicEvent], start: date, end: date, source
1127
1855
  event.country,
1128
1856
  event.impact,
1129
1857
  event.event,
1130
- event.actual or "-",
1131
- event.estimate or "-",
1132
- event.previous or "-",
1133
1858
  )
1134
1859
 
1135
1860
  if not events:
1136
- table.add_row("-", "-", "-", "Tidak ada event yang cocok dengan filter.", "-", "-", "-")
1137
- table.add_row("Note", source, "-", note, "-", "-", "-")
1138
- return table
1139
-
1140
-
1141
- def _format_provider_list() -> Table:
1142
- table = Table(title="Market Providers", expand=True)
1143
- table.add_column("Name", style="cyan")
1144
- table.add_column("Realtime")
1145
- table.add_column("Status")
1146
- table.add_column("Notes")
1147
- for provider in MarketProviderManager().list_providers():
1148
- table.add_row(provider.name, str(provider.realtime), provider.status, provider.notes)
1861
+ table.add_row("-", "-", "-", "Tidak ada event yang cocok dengan filter.")
1862
+ summary = calendar_summary(events)
1863
+ table.add_row(
1864
+ "Summary",
1865
+ source,
1866
+ "-",
1867
+ f"total={summary['total']}; high={summary.get('high', 0)}; medium={summary.get('medium', 0)}; low={summary.get('low', 0)}",
1868
+ )
1869
+ table.add_row("Note", source, "-", note)
1149
1870
  return table
1150
-
1151
-
1871
+
1872
+
1873
+ def _format_provider_list() -> Table:
1874
+ table = Table(title="Market Providers", expand=True)
1875
+ table.add_column("Name", style="cyan")
1876
+ table.add_column("Realtime")
1877
+ table.add_column("Status")
1878
+ table.add_column("Notes")
1879
+ for provider in MarketProviderManager().list_providers():
1880
+ table.add_row(provider.name, str(provider.realtime), provider.status, provider.notes)
1881
+ return table
1882
+
1883
+
1884
+ def _format_provider_entitlements(items: list[ProviderEntitlement]) -> Table:
1885
+ table = Table(title="Provider Entitlements and Data Labels", expand=True)
1886
+ table.add_column("Provider", style="cyan", no_wrap=True)
1887
+ table.add_column("Status", no_wrap=True)
1888
+ table.add_column("Realtime Label", style="yellow", no_wrap=True)
1889
+ table.add_column("Asset Classes", overflow="fold")
1890
+ table.add_column("Capabilities", overflow="fold")
1891
+ table.add_column("Limitations", overflow="fold")
1892
+ for item in items:
1893
+ table.add_row(
1894
+ item.provider,
1895
+ item.status,
1896
+ item.realtime_label,
1897
+ ", ".join(item.asset_classes),
1898
+ ", ".join(item.capabilities),
1899
+ "; ".join(item.limitations),
1900
+ )
1901
+ return table
1902
+
1903
+
1152
1904
  def _format_provider_key_status(manager: MarketProviderManager) -> Table:
1153
1905
  table = Table(title="Market Provider API Key Status", expand=True)
1154
1906
  table.add_column("Provider", style="cyan")
1155
1907
  table.add_column("Key")
1156
1908
  table.add_column("Status")
1157
- table.add_column("Source")
1158
- for row in manager.key_status():
1909
+ table.add_column("Source")
1910
+ for row in manager.key_status():
1159
1911
  table.add_row(row["provider"], row["key"], row["status"], row["source"])
1160
1912
  return table
1161
1913
 
1162
1914
 
1915
+ def _format_provider_metrics(service: MarketDataService) -> Table:
1916
+ table = Table(title="Provider Metrics", expand=True)
1917
+ table.add_column("Metric", style="cyan", no_wrap=True)
1918
+ table.add_column("Value", overflow="fold")
1919
+ table.add_row("Active Provider", service.primary_provider.name)
1920
+ table.add_row("Provider Chain", ", ".join(provider.name for provider in service.providers))
1921
+ table.add_row("Last Errors", "\n".join(service.last_errors) if service.last_errors else "none")
1922
+ table.add_row("Runtime Label", "realtime/delayed depends on provider entitlement and API plan")
1923
+ table.caption = "Use /provider entitlement for static capability labels and /provider test <symbol> for live checks."
1924
+ return table
1925
+
1926
+
1927
+ def _format_symbol_search(query: str, results: list[SymbolSearchResult]) -> Table:
1928
+ table = Table(title=f"Symbol Search: {query}", expand=True)
1929
+ table.add_column("Symbol", style="cyan", no_wrap=True)
1930
+ table.add_column("Name", overflow="fold")
1931
+ table.add_column("Class", no_wrap=True)
1932
+ table.add_column("Exchange", no_wrap=True)
1933
+ table.add_column("Currency", no_wrap=True)
1934
+ table.add_column("Provider Symbols", overflow="fold")
1935
+ table.add_column("Notes", overflow="fold")
1936
+ for result in results:
1937
+ table.add_row(
1938
+ result.symbol,
1939
+ result.name,
1940
+ result.asset_class,
1941
+ result.exchange or "-",
1942
+ result.currency or "-",
1943
+ _provider_symbol_text(result.provider_symbols or {}),
1944
+ result.notes or "-",
1945
+ )
1946
+ if not results:
1947
+ table.add_row("-", "No local symbol match.", "-", "-", "-", "-", "Try /symbol normalize <symbol>.")
1948
+ table.caption = "Use /symbol normalize <symbol> to inspect provider-specific normalization for any symbol."
1949
+ return table
1950
+
1951
+
1952
+ def _format_symbol_matrix(symbol: str) -> Table:
1953
+ matrix = provider_symbol_matrix(symbol)
1954
+ table = Table(title=f"Provider Symbol Normalization: {symbol}", expand=True)
1955
+ table.add_column("Provider", style="cyan", no_wrap=True)
1956
+ table.add_column("Normalized Symbol", style="white")
1957
+ table.add_column("Asset Class", no_wrap=True)
1958
+ table.add_column("Original", style="dim")
1959
+ for provider, resolved in matrix.items():
1960
+ table.add_row(provider, resolved.symbol, resolved.asset_class, resolved.original)
1961
+ table.caption = "Normalization does not guarantee provider entitlement. Check /provider entitlement and provider plan."
1962
+ return table
1963
+
1964
+
1965
+ def _provider_symbol_text(provider_symbols: dict[str, str]) -> str:
1966
+ return " | ".join(f"{provider}:{symbol}" for provider, symbol in provider_symbols.items())
1967
+
1968
+
1163
1969
  def _market_provider_secret_keys(provider: str) -> tuple[str, ...]:
1164
1970
  return {
1165
1971
  "custom": ("MARKET_DATA_API_KEY", "MARKET_DATA_BASE_URL"),
1166
1972
  "finnhub": ("FINNHUB_API_KEY",),
1167
1973
  "twelvedata": ("TWELVE_DATA_API_KEY",),
1974
+ "alphavantage": ("ALPHA_VANTAGE_API_KEY",),
1168
1975
  }.get(provider.lower(), ())
1169
1976
 
1170
1977
 
1171
- def _format_transactions(rows: list[dict[str, object]]) -> Table:
1172
- table = Table(title="Transaction Ledger", expand=True)
1173
- table.add_column("ID", justify="right")
1174
- table.add_column("Action")
1175
- table.add_column("Symbol", style="cyan")
1176
- table.add_column("Qty", justify="right")
1177
- table.add_column("Price", justify="right")
1178
- table.add_column("Realized PnL", justify="right")
1179
- table.add_column("Created")
1978
+ def _format_macro_dashboard(query: str, rows: list[MacroIndicator]) -> Table:
1979
+ table = Table(title=f"Macro Dashboard: {query.title()}", expand=True)
1980
+ table.add_column("Indicator", style="cyan", no_wrap=True)
1981
+ table.add_column("Region", no_wrap=True)
1982
+ table.add_column("Value", justify="right")
1983
+ table.add_column("Period", no_wrap=True)
1984
+ table.add_column("Source", no_wrap=True)
1985
+ table.add_column("Note", overflow="fold")
1180
1986
  for row in rows:
1181
- table.add_row(
1182
- str(row["id"]),
1183
- str(row["action"]),
1184
- str(row["symbol"]),
1185
- _fmt(float(row["quantity"])),
1186
- _fmt(float(row["price"])),
1187
- _fmt(float(row["realized_pnl"])),
1188
- str(row["created_at"]),
1189
- )
1987
+ table.add_row(row.name, row.region, row.value, row.period, row.source, row.note)
1190
1988
  if not rows:
1191
- table.add_row("-", "-", "-", "-", "-", "-", "Belum ada transaksi. Gunakan /tx add buy AAPL 10 100")
1989
+ table.add_row("-", "-", "-", "-", "Fallback", "No macro rows matched the query.")
1990
+ table.caption = "Fallback rows are connector-ready placeholders. Use provider keys later for exact values."
1192
1991
  return table
1193
1992
 
1194
1993
 
1195
- def _format_journal_stats(stats: JournalStats) -> Table:
1196
- table = Table(title="Journal Stats", expand=True)
1197
- table.add_column("Metric", style="cyan")
1198
- table.add_column("Value", justify="right")
1199
- table.add_row("Total Entries", str(stats.total_entries))
1200
- table.add_row("Wins", str(stats.wins))
1201
- table.add_row("Losses", str(stats.losses))
1202
- table.add_row("Win Rate", _fmt(stats.win_rate))
1203
- table.add_row("Top Instrument", stats.top_instrument)
1204
- table.add_row("Top Emotion", stats.top_emotion)
1205
- table.add_row("Top Tags", ", ".join(stats.top_tags) if stats.top_tags else "N/A")
1994
+ def _format_user_profile(profile: UserProfile | None) -> Table:
1995
+ table = Table(title="User Gameplay Profile", expand=True)
1996
+ table.add_column("Field", style="cyan", no_wrap=True)
1997
+ table.add_column("Value", overflow="fold")
1998
+ if profile is None:
1999
+ table.add_row("Status", "Not configured")
2000
+ table.add_row("Setup", '/profile set "Nama" <equity> <currency> <leverage> <years>')
2001
+ table.add_row("Use", "Profile is used by /analyze for SL/TP and risk-context wording.")
2002
+ return table
2003
+ table.add_row("Name", profile.name)
2004
+ table.add_row("Equity", f"{profile.equity:g} {profile.currency}")
2005
+ table.add_row("Leverage", profile.leverage)
2006
+ table.add_row("Investment Years", f"{profile.years_in_investment:g}")
2007
+ table.add_row("Gameplay", profile.gameplay)
2008
+ table.add_row("Analyze Usage", "Used by /analyze to constrain Signal, SL, TP1, TP2, TP3, and Reason.")
1206
2009
  return table
1207
2010
 
1208
2011
 
1209
- def _format_news(symbol: str, items: list[NewsItem]) -> str:
1210
- if not items:
1211
- return f"News: {symbol}\nBelum ada news dari provider aktif."
1212
- lines = [f"News: {symbol}"]
1213
- for index, item in enumerate(items, start=1):
1214
- published = item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown time"
1215
- url = f"\n URL: {item.url}" if item.url else ""
1216
- summary = f"\n Summary: {item.summary}" if item.summary else ""
1217
- lines.append(f"{index}. {item.title}\n Source: {item.source} | Published: {published}{summary}{url}")
1218
- return "\n".join(lines)
2012
+ def _format_agents(agents: list[Agent], label: str) -> Table:
2013
+ table = Table(title=f"FinCLI Agents: {label}", expand=True)
2014
+ table.add_column("Slug", style="cyan", no_wrap=True)
2015
+ table.add_column("Name", no_wrap=True)
2016
+ table.add_column("Category", no_wrap=True)
2017
+ table.add_column("Framework", overflow="fold")
2018
+ table.add_column("Role", overflow="fold")
2019
+ for agent in agents:
2020
+ table.add_row(agent.slug, agent.name, agent.category, agent.framework, agent.role)
2021
+ if not agents:
2022
+ table.add_row("-", "-", "-", "-", "No agents matched.")
2023
+ return table
1219
2024
 
1220
2025
 
1221
- def _format_fundamentals(snapshot: FundamentalSnapshot) -> str:
1222
- return (
1223
- f"Fundamental Snapshot: {snapshot.symbol}\n"
1224
- f"Provider: {snapshot.provider}\n"
1225
- f"Currency: {snapshot.currency}\n"
1226
- f"Market Cap: {_fmt(snapshot.market_cap)}\n"
1227
- f"P/E Ratio: {_fmt(snapshot.pe_ratio)}\n"
1228
- f"EPS: {_fmt(snapshot.eps)}\n"
1229
- f"Revenue: {_fmt(snapshot.revenue)}\n"
1230
- f"Beta: {_fmt(snapshot.beta)}\n"
1231
- f"Sector: {snapshot.sector or 'N/A'}\n"
1232
- f"Industry: {snapshot.industry or 'N/A'}"
2026
+ def _format_agent(agent: Agent) -> Panel:
2027
+ return Panel(
2028
+ "\n".join(
2029
+ [
2030
+ f"Name : {agent.name}",
2031
+ f"Slug : {agent.slug}",
2032
+ f"Category : {agent.category}",
2033
+ f"Framework : {agent.framework}",
2034
+ f"Role : {agent.role}",
2035
+ "",
2036
+ "Usage : use as a thinking lens for /research and future multi-agent analysis.",
2037
+ ]
2038
+ ),
2039
+ title="FinCLI Agent",
2040
+ border_style="cyan",
1233
2041
  )
1234
2042
 
1235
2043
 
1236
- def _format_yahoo_table(dataset: YahooTable) -> Table:
1237
- table = Table(title=f"Yahoo Finance {dataset.section}: {dataset.symbol}", expand=True)
1238
- for index, column in enumerate(dataset.columns):
1239
- table.add_column(str(column), style="cyan" if index == 0 else "white", overflow="fold")
1240
-
1241
- for row in dataset.rows:
1242
- normalized = [str(value) for value in row[: len(dataset.columns)]]
1243
- normalized += [""] * max(0, len(dataset.columns) - len(normalized))
1244
- table.add_row(*normalized)
1245
-
1246
- if not dataset.rows:
1247
- table.add_row(*(["No data returned by yfinance/Yahoo."] + [""] * (len(dataset.columns) - 1)))
1248
-
1249
- note = dataset.note or "Data source: yfinance/Yahoo Finance. Realtime/delayed status depends on exchange coverage."
1250
- table.caption = f"{note}\nSource: {dataset.source_url}"
2044
+ def _format_connectors(connectors: list[Connector], label: str) -> Table:
2045
+ table = Table(title=f"Connector Catalog: {label}", expand=True)
2046
+ table.add_column("Name", style="cyan", no_wrap=True)
2047
+ table.add_column("Category", no_wrap=True)
2048
+ table.add_column("Access", no_wrap=True)
2049
+ table.add_column("Coverage", overflow="fold")
2050
+ for connector in connectors:
2051
+ table.add_row(connector.name, connector.category, connector.access, connector.coverage)
2052
+ if not connectors:
2053
+ table.add_row("-", "-", "-", "No connectors matched.")
2054
+ table.caption = "Catalog entries are roadmap-ready; active adapters depend on implementation and entitlement."
1251
2055
  return table
1252
2056
 
1253
2057
 
1254
- def _format_news_context(items: list[NewsItem]) -> str:
1255
- if not items:
1256
- return "News: no recent news from active provider."
1257
- lines = ["News:"]
1258
- for item in items:
1259
- published = item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown time"
1260
- summary = f" - {item.summary}" if item.summary else ""
1261
- lines.append(f"- {item.title} ({item.source}, {published}){summary}")
1262
- return "\n".join(lines)
1263
-
1264
-
1265
- def _format_fundamental_context(snapshot: FundamentalSnapshot) -> str:
1266
- return (
1267
- "Fundamentals:\n"
1268
- f"- Symbol: {snapshot.symbol}\n"
1269
- f"- Currency: {snapshot.currency}\n"
1270
- f"- Market Cap: {_fmt(snapshot.market_cap)}\n"
1271
- f"- P/E Ratio: {_fmt(snapshot.pe_ratio)}\n"
1272
- f"- EPS: {_fmt(snapshot.eps)}\n"
1273
- f"- Revenue: {_fmt(snapshot.revenue)}\n"
1274
- f"- Beta: {_fmt(snapshot.beta)}\n"
1275
- f"- Sector: {snapshot.sector or 'N/A'}\n"
1276
- f"- Industry: {snapshot.industry or 'N/A'}"
2058
+ def _format_news_connectors(connectors: list[NewsConnectorSpec], label: str) -> Table:
2059
+ table = Table(title=f"News Connector Catalog: {label}", expand=True)
2060
+ table.add_column("Slug", style="cyan", no_wrap=True)
2061
+ table.add_column("Name", overflow="fold")
2062
+ table.add_column("Access", no_wrap=True)
2063
+ table.add_column("Category", no_wrap=True)
2064
+ table.add_column("API Key", no_wrap=True)
2065
+ table.add_column("Status", overflow="fold")
2066
+ for connector in connectors:
2067
+ status = "active rss" if connector.access == "public-rss" else "api-key ready"
2068
+ if connector.slug == "custom_news":
2069
+ status = "custom endpoint"
2070
+ table.add_row(
2071
+ connector.slug,
2072
+ connector.name,
2073
+ connector.access,
2074
+ connector.category,
2075
+ connector.env_key or "-",
2076
+ status,
2077
+ )
2078
+ if not connectors:
2079
+ table.add_row("-", "No news connectors matched.", "-", "-", "-", "-")
2080
+ table.caption = (
2081
+ "Use /news_model use <slug> for primary, /news_model priority a,b,c for fallback order, "
2082
+ "and /news_model key <slug> <api_key> for API-key providers."
1277
2083
  )
2084
+ return table
1278
2085
 
1279
2086
 
1280
- def _format_ai_response(response: AIResponse) -> str:
1281
- return (
1282
- f"Provider: {response.provider}\n"
1283
- f"Model: {response.model}\n"
1284
- f"Response:\n{response.content}"
1285
- )
2087
+ def _format_plugins(plugins: list[PluginManifest], status_only: bool = False) -> Table:
2088
+ table = Table(title="FinCLI Plugins" if not status_only else "FinCLI Plugin Status", expand=True)
2089
+ table.add_column("Name", style="cyan", no_wrap=True)
2090
+ table.add_column("Version", no_wrap=True)
2091
+ table.add_column("Status", no_wrap=True)
2092
+ table.add_column("Capabilities", overflow="fold")
2093
+ table.add_column("Commands", overflow="fold")
2094
+ if not status_only:
2095
+ table.add_column("Description", overflow="fold")
2096
+ for plugin in plugins:
2097
+ row = [
2098
+ plugin.name,
2099
+ plugin.version,
2100
+ plugin.status,
2101
+ ", ".join(plugin.capabilities) or "-",
2102
+ ", ".join(plugin.commands) or "-",
2103
+ ]
2104
+ if not status_only:
2105
+ row.append(plugin.description or "-")
2106
+ table.add_row(*row)
2107
+ if not plugins:
2108
+ empty = ["-", "-", "no plugins", "-", "-"]
2109
+ if not status_only:
2110
+ empty.append("Create ~/.fincli/plugins/<name>/plugin.json to register a local plugin.")
2111
+ table.add_row(*empty)
2112
+ table.caption = "Plugins are manifest-only in v0.2.2; FinCLI does not execute plugin code yet."
2113
+ return table
1286
2114
 
1287
2115
 
1288
- def _fmt(value: float | None) -> str:
1289
- if value is None:
1290
- return "N/A"
1291
- return f"{value:,.4f}"
2116
+ def _format_transactions(rows: list[dict[str, object]]) -> Table:
2117
+ table = Table(title="Transaction Ledger", expand=True)
2118
+ table.add_column("ID", justify="right")
2119
+ table.add_column("Action")
2120
+ table.add_column("Symbol", style="cyan")
2121
+ table.add_column("Qty", justify="right")
2122
+ table.add_column("Price", justify="right")
2123
+ table.add_column("Realized PnL", justify="right")
2124
+ table.add_column("Created")
2125
+ for row in rows:
2126
+ table.add_row(
2127
+ str(row["id"]),
2128
+ str(row["action"]),
2129
+ str(row["symbol"]),
2130
+ _fmt(float(row["quantity"])),
2131
+ _fmt(float(row["price"])),
2132
+ _fmt(float(row["realized_pnl"])),
2133
+ str(row["created_at"]),
2134
+ )
2135
+ if not rows:
2136
+ table.add_row("-", "-", "-", "-", "-", "-", "Belum ada transaksi. Gunakan /tx add buy AAPL 10 100")
2137
+ return table
2138
+
2139
+
2140
+ def _format_journal_stats(stats: JournalStats) -> Table:
2141
+ table = Table(title="Journal Stats", expand=True)
2142
+ table.add_column("Metric", style="cyan")
2143
+ table.add_column("Value", justify="right")
2144
+ table.add_row("Total Entries", str(stats.total_entries))
2145
+ table.add_row("Wins", str(stats.wins))
2146
+ table.add_row("Losses", str(stats.losses))
2147
+ table.add_row("Win Rate", _fmt(stats.win_rate))
2148
+ table.add_row("Top Instrument", stats.top_instrument)
2149
+ table.add_row("Top Emotion", stats.top_emotion)
2150
+ table.add_row("Top Tags", ", ".join(stats.top_tags) if stats.top_tags else "N/A")
2151
+ return table
2152
+
2153
+
2154
+ def _format_news(symbol: str, items: list[NewsItem]) -> str:
2155
+ if not items:
2156
+ return f"News: {symbol}\nBelum ada news dari provider aktif."
2157
+ lines = [f"News: {symbol}"]
2158
+ for index, item in enumerate(items, start=1):
2159
+ published = item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown time"
2160
+ url = f"\n URL: {item.url}" if item.url else ""
2161
+ summary = f"\n Summary: {item.summary}" if item.summary else ""
2162
+ lines.append(f"{index}. {item.title}\n Source: {item.source} | Published: {published}{summary}{url}")
2163
+ return "\n".join(lines)
1292
2164
 
1293
2165
 
1294
- class UnavailableAIProvider:
1295
- """Default AI provider used until a concrete API client is configured."""
2166
+ def _format_news_desk(desk: NewsDesk) -> Table:
2167
+ table = Table(title=f"News Desk: {desk.symbol}", expand=True)
2168
+ table.add_column("Time", style="dim", no_wrap=True)
2169
+ table.add_column("Source", style="cyan", no_wrap=True)
2170
+ table.add_column("Headline", style="white", overflow="fold")
2171
+ table.add_column("Summary", overflow="fold")
2172
+ table.add_column("Analysis", overflow="fold")
2173
+ for item in desk.items:
2174
+ published = item.published_at.isoformat(timespec="minutes") if item.published_at else "unknown"
2175
+ table.add_row(published, item.source, item.title, item.summary or "-", _news_item_analysis(item))
2176
+ if not desk.items:
2177
+ table.add_row("-", "-", "No news from active providers.", desk.note, "-")
2178
+ lookback = f" | Lookback: {desk.lookback_days}d" if desk.lookback_days else ""
2179
+ table.caption = f"Providers: {', '.join(desk.provider_chain)}{lookback} | {desk.note}"
2180
+ return table
1296
2181
 
1297
- def __init__(self, provider_name: str) -> None:
1298
- self.name = provider_name
1299
2182
 
1300
- async def complete(self, request: AIRequest) -> AIResponse:
1301
- raise CommandError(
1302
- f"AI provider {self.name} belum siap dipakai.",
1303
- "Set API key di .env dan gunakan provider client Phase 2 lanjutan, atau injeksi provider untuk testing.",
1304
- )
2183
+ def _news_item_analysis(item: NewsItem) -> str:
2184
+ text = f"{item.title} {item.summary}".lower()
2185
+ bullish_words = ("beat", "beats", "rise", "rises", "rally", "rallies", "higher", "growth", "upgrade", "bullish", "record")
2186
+ bearish_words = ("miss", "falls", "falling", "lower", "sink", "sinks", "down", "cut", "downgrade", "bearish", "weak")
2187
+ caution_words = ("risk", "uncertain", "probe", "lawsuit", "volatility", "warning", "recall", "delay")
2188
+ bullish = sum(1 for word in bullish_words if word in text)
2189
+ bearish = sum(1 for word in bearish_words if word in text)
2190
+ caution = sum(1 for word in caution_words if word in text)
2191
+ if caution and caution >= max(bullish, bearish):
2192
+ bias = "caution"
2193
+ elif bullish > bearish:
2194
+ bias = "bullish"
2195
+ elif bearish > bullish:
2196
+ bias = "bearish"
2197
+ else:
2198
+ bias = "neutral"
2199
+ if item.published_at is None:
2200
+ freshness = "date unknown"
2201
+ else:
2202
+ freshness = "fresh" if _news_age_days(item) <= 3 else "older context"
2203
+ return semantic_text(f"{bias} | {freshness} | verify source before trading")
2204
+
2205
+
2206
+ def _news_age_days(item: NewsItem) -> int:
2207
+ if item.published_at is None:
2208
+ return 999
2209
+ published = item.published_at
2210
+ if published.tzinfo is None:
2211
+ from datetime import timezone
2212
+
2213
+ published = published.replace(tzinfo=timezone.utc)
2214
+ from datetime import datetime, timezone
2215
+
2216
+ return max((datetime.now(timezone.utc) - published).days, 0)
2217
+
2218
+
2219
+ def _format_web_results(query: str, results: list[WebSearchResult]) -> Table:
2220
+ table = Table(title=f"Web Research: {query}", expand=True)
2221
+ table.add_column("#", justify="right", width=3)
2222
+ table.add_column("Title", style="cyan", overflow="fold")
2223
+ table.add_column("Snippet / Extract", overflow="fold")
2224
+ table.add_column("URL", style="dim", overflow="fold")
2225
+ for index, result in enumerate(results, start=1):
2226
+ extract = result.content[:500] if result.content else result.snippet
2227
+ table.add_row(str(index), result.title, extract or "-", result.url)
2228
+ if not results:
2229
+ table.add_row("-", "No results", "Search providers returned no public context.", "-")
2230
+ table.caption = "Web context is public web data; verify source quality before using it for financial decisions."
2231
+ return table
2232
+
2233
+
2234
+ def _format_fundamentals(snapshot: FundamentalSnapshot) -> str:
2235
+ return (
2236
+ f"Fundamental Snapshot: {snapshot.symbol}\n"
2237
+ f"Provider: {snapshot.provider}\n"
2238
+ f"Currency: {snapshot.currency}\n"
2239
+ f"Market Cap: {_fmt(snapshot.market_cap)}\n"
2240
+ f"P/E Ratio: {_fmt(snapshot.pe_ratio)}\n"
2241
+ f"EPS: {_fmt(snapshot.eps)}\n"
2242
+ f"Revenue: {_fmt(snapshot.revenue)}\n"
2243
+ f"Beta: {_fmt(snapshot.beta)}\n"
2244
+ f"Sector: {snapshot.sector or 'N/A'}\n"
2245
+ f"Industry: {snapshot.industry or 'N/A'}"
2246
+ )
2247
+
2248
+
2249
+ def _format_yahoo_table(dataset: YahooTable) -> Table:
2250
+ table = Table(title=f"Yahoo Finance {dataset.section}: {dataset.symbol}", expand=True)
2251
+ for index, column in enumerate(dataset.columns):
2252
+ table.add_column(str(column), style="cyan" if index == 0 else "white", overflow="fold")
2253
+
2254
+ for row in dataset.rows:
2255
+ normalized = [str(value) for value in row[: len(dataset.columns)]]
2256
+ normalized += [""] * max(0, len(dataset.columns) - len(normalized))
2257
+ table.add_row(*normalized)
2258
+
2259
+ if not dataset.rows:
2260
+ table.add_row(*(["No data returned by yfinance/Yahoo."] + [""] * (len(dataset.columns) - 1)))
2261
+
2262
+ note = dataset.note or "Data source: yfinance/Yahoo Finance. Realtime/delayed status depends on exchange coverage."
2263
+ table.caption = f"{note}\nSource: {dataset.source_url}"
2264
+ return table
2265
+
2266
+
2267
+ def _format_news_context(items: list[NewsItem]) -> str:
2268
+ if not items:
2269
+ return "News: no recent news from active provider."
2270
+ lines = ["News:"]
2271
+ for item in items:
2272
+ published = item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown time"
2273
+ summary = f" - {item.summary}" if item.summary else ""
2274
+ lines.append(f"- {item.title} ({item.source}, {published}){summary}")
2275
+ return "\n".join(lines)
2276
+
2277
+
2278
+ def _format_fundamental_context(snapshot: FundamentalSnapshot) -> str:
2279
+ return (
2280
+ "Fundamentals:\n"
2281
+ f"- Symbol: {snapshot.symbol}\n"
2282
+ f"- Currency: {snapshot.currency}\n"
2283
+ f"- Market Cap: {_fmt(snapshot.market_cap)}\n"
2284
+ f"- P/E Ratio: {_fmt(snapshot.pe_ratio)}\n"
2285
+ f"- EPS: {_fmt(snapshot.eps)}\n"
2286
+ f"- Revenue: {_fmt(snapshot.revenue)}\n"
2287
+ f"- Beta: {_fmt(snapshot.beta)}\n"
2288
+ f"- Sector: {snapshot.sector or 'N/A'}\n"
2289
+ f"- Industry: {snapshot.industry or 'N/A'}"
2290
+ )
2291
+
2292
+
2293
+ def _format_ai_response(response: AIResponse) -> AIResponseView:
2294
+ return AIResponseView(response)
2295
+
2296
+
2297
+ def _fmt(value: float | None) -> str:
2298
+ if value is None:
2299
+ return "N/A"
2300
+ return f"{value:,.4f}"
2301
+
2302
+
2303
+ def _split_command(raw: str) -> list[str]:
2304
+ parts = shlex.split(raw, posix=os.name != "nt")
2305
+ if os.name == "nt":
2306
+ return [_strip_wrapping_quotes(part) for part in parts]
2307
+ return parts
2308
+
2309
+
2310
+ def _strip_wrapping_quotes(value: str) -> str:
2311
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
2312
+ return value[1:-1]
2313
+ return value
2314
+
2315
+
2316
+ class UnavailableAIProvider:
2317
+ """Default AI provider used until a concrete API client is configured."""
2318
+
2319
+ def __init__(self, provider_name: str) -> None:
2320
+ self.name = provider_name
2321
+
2322
+ async def complete(self, request: AIRequest) -> AIResponse:
2323
+ raise CommandError(
2324
+ f"AI provider {self.name} belum siap dipakai.",
2325
+ "Gunakan /ai_model untuk memilih provider dan /ai_model key <provider> <api_key> untuk menyimpan API key.",
2326
+ )