@drico2008/fincli 0.1.9 → 0.3.0

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