@drico2008/fincli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +217 -217
  2. package/fincli/__init__.py +1 -1
  3. package/fincli/app/analysis/ai_prompts.py +29 -27
  4. package/fincli/app/analysis/analyzer.py +34 -34
  5. package/fincli/app/analysis/assistant_context.py +3 -3
  6. package/fincli/app/cli/commands.py +33 -27
  7. package/fincli/app/cli/router.py +1633 -1105
  8. package/fincli/app/diagnostics/__init__.py +2 -0
  9. package/fincli/app/diagnostics/capabilities.py +44 -0
  10. package/fincli/app/diagnostics/runtime.py +106 -0
  11. package/fincli/app/main.py +6 -1
  12. package/fincli/app/modules/economic_calendar.py +512 -512
  13. package/fincli/app/modules/portfolio_risk.py +305 -305
  14. package/fincli/app/modules/trading.py +142 -0
  15. package/fincli/app/plugins/loader.py +72 -72
  16. package/fincli/app/providers/market/finnhub_provider.py +51 -2
  17. package/fincli/app/providers/market/symbols.py +95 -2
  18. package/fincli/app/providers/reliability.py +82 -65
  19. package/fincli/app/research/__init__.py +8 -8
  20. package/fincli/app/research/engine.py +119 -112
  21. package/fincli/app/research/exporter.py +91 -91
  22. package/fincli/app/research/formatter.py +25 -24
  23. package/fincli/app/research/models.py +22 -21
  24. package/fincli/app/research/prompt_builder.py +53 -51
  25. package/fincli/app/services/data_quality.py +27 -0
  26. package/fincli/app/services/data_trust.py +117 -0
  27. package/fincli/app/services/macro_data.py +158 -50
  28. package/fincli/app/services/market_data.py +183 -79
  29. package/fincli/app/services/market_overview.py +131 -142
  30. package/fincli/app/services/news_aggregator.py +95 -95
  31. package/fincli/app/storage/config.py +6 -3
  32. package/fincli/app/storage/database.py +130 -117
  33. package/fincli/app/storage/provider_metrics.py +61 -61
  34. package/fincli/app/storage/secrets.py +128 -128
  35. package/npm/bin/fincli.js +65 -65
  36. package/package.json +7 -7
  37. package/pyproject.toml +1 -1
@@ -16,10 +16,10 @@ from rich.panel import Panel
16
16
  from rich.table import Table
17
17
 
18
18
  from fincli.app.cli.commands import CommandRegistry
19
- from fincli.app.analysis.analyzer import build_market_analysis_prompt, build_technical_ai_summary
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
19
+ from fincli.app.analysis.analyzer import build_market_analysis_prompt, build_technical_ai_summary
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
23
  from fincli.app.analysis.assistant_context import (
24
24
  build_web_research_answer_prompt,
25
25
  build_fincli_assistant_prompt,
@@ -32,74 +32,85 @@ from fincli.app.analysis.market_structure import MarketStructureSummary, analyze
32
32
  from fincli.app.analysis.multi_timeframe import MultiTimeframeAnalysis, analyze_multi_timeframe
33
33
  from fincli.app.analysis.technical_debate import TechnicalDebate, format_debate, run_technical_debate
34
34
  from fincli.app.analysis.technical_signal import TechnicalSignal, format_signal
35
- from fincli.app.modules.economic_calendar import (
36
- EconomicCalendarService,
37
- EconomicEvent,
38
- PublicEconomicCalendarService,
39
- calendar_summary,
40
- default_calendar_window,
41
- economic_event_rows,
35
+ from fincli.app.modules.economic_calendar import (
36
+ EconomicCalendarService,
37
+ EconomicEvent,
38
+ PublicEconomicCalendarService,
39
+ calendar_summary,
40
+ default_calendar_window,
41
+ economic_event_rows,
42
42
  fallback_events,
43
43
  filter_events,
44
44
  )
45
45
  from fincli.app.modules.alerts import AlertCheckResult, AlertService, evaluate_alert
46
46
  from fincli.app.modules.exporter import export_rows
47
47
  from fincli.app.modules.journal_analytics import JournalStats, build_journal_review_prompt, calculate_journal_stats
48
- from fincli.app.modules.journal import JournalService
49
- from fincli.app.modules.portfolio import PortfolioService
50
- from fincli.app.modules.portfolio_risk import PortfolioRiskReport, build_portfolio_risk
51
- from fincli.app.modules.scanner import ScanResult, scan_symbols
52
- from fincli.app.modules.session_history import SessionHistoryService
53
- from fincli.app.modules.transactions import TransactionService
54
- from fincli.app.modules.user_profile import UserProfile, UserProfileService
55
- from fincli.app.modules.watchlist import WatchlistService
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
48
+ from fincli.app.modules.journal import JournalService
49
+ from fincli.app.modules.portfolio import PortfolioService
50
+ from fincli.app.modules.portfolio_risk import PortfolioRiskReport, build_portfolio_risk
51
+ from fincli.app.modules.scanner import ScanResult, scan_symbols
52
+ from fincli.app.modules.session_history import SessionHistoryService
53
+ from fincli.app.modules.transactions import TransactionService
54
+ from fincli.app.modules.trading import (
55
+ BrokerCatalog,
56
+ BrokerIntegration,
57
+ PaperTradingEngine,
58
+ RealtimeConnector,
59
+ RealtimeConnectorCatalog,
60
+ )
61
+ from fincli.app.modules.user_profile import UserProfile, UserProfileService
62
+ from fincli.app.modules.watchlist import WatchlistService
63
+ from fincli.app.connectors.catalog import Connector, ConnectorCatalog
64
+ from fincli.app.connectors.news_connectors import (
65
+ NewsConnectorCatalog,
66
+ NewsConnectorManager,
67
+ NewsConnectorSpec,
68
+ news_connector_secret_key,
69
+ )
70
+ from fincli.app.diagnostics.capabilities import capability_rows, capability_summary
71
+ from fincli.app.diagnostics.runtime import check_runtime_environment
72
+ from fincli.app.modules.reports import write_market_report
64
73
  from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
65
74
  from fincli.app.providers.ai.manager import AIProviderManager
66
- from fincli.app.providers.market.base import (
67
- BaseMarketProvider,
68
- FundamentalSnapshot,
69
- NewsItem,
70
- ProviderEntitlement,
75
+ from fincli.app.providers.market.base import (
76
+ BaseMarketProvider,
77
+ FundamentalSnapshot,
78
+ NewsItem,
79
+ ProviderEntitlement,
71
80
  Quote,
72
81
  SymbolSearchResult,
73
- )
74
- from fincli.app.providers.market.manager import MarketProviderManager
75
- from fincli.app.providers.market.symbols import provider_symbol_matrix, search_symbol_catalog
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
84
- from fincli.app.services.market_data import MarketDataService
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
88
- from fincli.app.services.web_research import (
82
+ )
83
+ from fincli.app.providers.market.manager import MarketProviderManager
84
+ from fincli.app.providers.market.symbols import SymbolResolver, search_symbol_catalog
85
+ from fincli.app.providers.market.yfinance_provider import YahooTable, YFinanceProvider
86
+ from fincli.app.providers.reliability import (
87
+ STATUS_OK,
88
+ STATUS_PARTIAL_DATA,
89
+ STATUS_SCHEDULE_ONLY,
90
+ STATUS_UNAVAILABLE,
91
+ )
92
+ from fincli.app.plugins.loader import PluginLoader, PluginManifest
93
+ from fincli.app.services.market_data import MarketDataService
94
+ from fincli.app.services.market_overview import MarketOverview, build_market_overview
95
+ from fincli.app.services.data_quality import DataQualityReport
96
+ from fincli.app.services.data_trust import build_data_trust_gate
97
+ from fincli.app.services.macro_data import MacroDataService, MacroIndicator
98
+ from fincli.app.services.news_aggregator import NewsAggregator, NewsDesk
99
+ from fincli.app.services.web_research import (
89
100
  WebResearchService,
90
101
  WebSearchResult,
91
102
  build_web_research_context,
92
103
  should_use_web_research,
93
- )
94
- from fincli.app.research import ResearchEngine, format_research_brief, write_research_report
104
+ )
105
+ from fincli.app.research import ResearchEngine, format_research_brief, write_research_report
95
106
  from fincli.app.storage.cache import TTLCache
96
107
  from fincli.app.storage.config import ConfigManager
97
108
  from fincli.app.storage.database import FinCLIDatabase
98
- from fincli.app.storage.market_cache import MarketCache
99
- from fincli.app.storage.provider_metrics import ProviderMetricsStore
100
- from fincli.app.storage.secrets import clear_secrets, read_secrets, save_secret
109
+ from fincli.app.storage.market_cache import MarketCache
110
+ from fincli.app.storage.provider_metrics import ProviderMetricsStore
111
+ from fincli.app.storage.secrets import clear_secrets, read_secrets, save_secret
101
112
  from fincli.app.utils.errors import CommandError, FinCLIError
102
- from fincli.app.utils.formatting import AIResponseView, MarkdownBlock, semantic_text
113
+ from fincli.app.utils.formatting import AIResponseView, MarkdownBlock, semantic_text
103
114
 
104
115
 
105
116
  @dataclass(slots=True)
@@ -124,29 +135,38 @@ class CommandRouter:
124
135
  self.config = config or ConfigManager()
125
136
  self.db = db or FinCLIDatabase()
126
137
  self.registry = registry or CommandRegistry()
127
- self.cache: TTLCache[object] = TTLCache(self.config.settings.cache_ttl_seconds)
128
- self.market_cache = MarketCache(self.db)
129
- self.provider_metrics_store = ProviderMetricsStore(self.db)
130
- self.market_manager = MarketProviderManager()
138
+ self.cache: TTLCache[object] = TTLCache(self.config.settings.cache_ttl_seconds)
139
+ self.market_cache = MarketCache(self.db)
140
+ self.provider_metrics_store = ProviderMetricsStore(self.db)
141
+ self.market_manager = MarketProviderManager()
142
+ self.symbol_resolver = SymbolResolver()
131
143
  self.market_service = self._build_market_service(market_provider)
132
144
  self.market_provider = self.market_service.primary_provider
133
145
  self.ai_provider = ai_provider or AIProviderManager().create(self.config.settings.ai_provider)
134
146
  self.watchlist = WatchlistService(self.db)
135
147
  self.portfolio = PortfolioService(self.db)
136
148
  self.alerts = AlertService(self.db)
137
- self.transactions = TransactionService(self.db, self.portfolio)
138
- self.journal = JournalService(self.db)
139
- self.user_profiles = UserProfileService(self.db)
140
- self.history = SessionHistoryService(self.db)
141
- self.session_id = self.history.start_session()
142
- self.web_research = WebResearchService()
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)
149
+ self.transactions = TransactionService(self.db, self.portfolio)
150
+ self.paper_trading = PaperTradingEngine(self.db)
151
+ self.broker_catalog = BrokerCatalog()
152
+ self.realtime_connector_catalog = RealtimeConnectorCatalog()
153
+ self.journal = JournalService(self.db)
154
+ self.user_profiles = UserProfileService(self.db)
155
+ self.history = SessionHistoryService(self.db)
156
+ self.session_id = self.history.start_session()
157
+ self.web_research = WebResearchService()
158
+ self.macro_data = MacroDataService()
159
+ self.agent_registry = AgentRegistry()
160
+ self.connector_catalog = ConnectorCatalog()
161
+ self.news_connector_catalog = NewsConnectorCatalog()
162
+ self.news_connectors = NewsConnectorManager(self.news_connector_catalog)
148
163
 
149
164
  def route(self, raw: str) -> CommandResult:
165
+ if not isinstance(raw, str):
166
+ return CommandResult(
167
+ Panel("Command harus berupa teks. Contoh: /help", title="Error", border_style="red"),
168
+ status="error",
169
+ )
150
170
  result = self._route(raw)
151
171
  self._record_history(raw, result)
152
172
  return result
@@ -156,7 +176,10 @@ class CommandRouter:
156
176
  if not raw:
157
177
  return CommandResult(Panel("Ketik /help untuk melihat command.", title="FinCLI"))
158
178
  if not raw.startswith("/"):
159
- return CommandResult(Panel("Command harus diawali slash. Contoh: /help", title="Invalid Input"))
179
+ return CommandResult(
180
+ Panel("Command harus diawali slash. Contoh: /help", title="Invalid Input", border_style="red"),
181
+ status="error",
182
+ )
160
183
 
161
184
  try:
162
185
  if raw.lower().startswith("/export "):
@@ -187,32 +210,36 @@ class CommandRouter:
187
210
  return self._ai_model(args)
188
211
  if root == "/news_model":
189
212
  return self._news_model(args)
190
- if root == "/provider":
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)
214
- if root == "/cache":
215
- return self._cache(args)
213
+ if root == "/provider":
214
+ return self._provider(args)
215
+ if root == "/symbol":
216
+ return self._symbol(args)
217
+ if root == "/research":
218
+ return self._research(args)
219
+ if root == "/macro":
220
+ return self._macro(args)
221
+ if root in {"/cpi", "/nfp", "/gdp", "/inflation", "/unemployment"}:
222
+ return self._macro_indicator(root[1:], args)
223
+ if root == "/fed" and args and args[0].lower() == "funds":
224
+ return self._macro_indicator("fed_funds", args[1:])
225
+ if root == "/profile":
226
+ return self._profile(args)
227
+ if root == "/doctor":
228
+ return self._doctor(args)
229
+ if root == "/setup":
230
+ return self._setup(args)
231
+ if root == "/secrets":
232
+ return self._secrets(args)
233
+ if root == "/privacy":
234
+ return self._privacy(args)
235
+ if root == "/agent":
236
+ return self._agent(args)
237
+ if root == "/connector":
238
+ return self._connector(args)
239
+ if root == "/plugin":
240
+ return self._plugin(args)
241
+ if root == "/cache":
242
+ return self._cache(args)
216
243
  if root == "/watchlist":
217
244
  return self._watchlist(args)
218
245
  if root == "/portfolio":
@@ -233,6 +260,8 @@ class CommandRouter:
233
260
  return self._mtf(args)
234
261
  if root == "/backtest":
235
262
  return self._backtest(args)
263
+ if root == "/trading":
264
+ return self._trading(args)
236
265
  if root == "/structure":
237
266
  return self._structure(args)
238
267
  if root == "/news":
@@ -262,26 +291,26 @@ class CommandRouter:
262
291
  if exc.help_text:
263
292
  message = f"{message}\n\n{exc.help_text}"
264
293
  return CommandResult(Panel(message, title="Error", border_style="red"), status="error")
265
- except ValueError as exc:
266
- return CommandResult(
267
- Panel(f"Format command tidak valid: {exc}\nGunakan quote untuk teks panjang.", title="Error"),
268
- status="error",
269
- )
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
- )
294
+ except ValueError as exc:
295
+ return CommandResult(
296
+ Panel(f"Format command tidak valid: {exc}\nGunakan quote untuk teks panjang.", title="Error"),
297
+ status="error",
298
+ )
299
+ except Exception as exc: # noqa: BLE001
300
+ return CommandResult(
301
+ Panel(
302
+ (
303
+ f"Unexpected command error: {type(exc).__name__}: {exc}\n\n"
304
+ "Command tidak dieksekusi penuh. Gunakan /doctor untuk cek konfigurasi atau coba ulang command."
305
+ ),
306
+ title="Error",
307
+ border_style="red",
308
+ ),
309
+ status="error",
310
+ )
282
311
 
283
312
  def _help_table(self) -> Table:
284
- table = Table(title="FinCLI v0.3.1 Commands", expand=True)
313
+ table = Table(title="FinCLI v0.4.0 Commands", expand=True)
285
314
  table.add_column("Command", style="cyan", no_wrap=True)
286
315
  table.add_column("Group", style="magenta")
287
316
  table.add_column("Fungsi", style="white")
@@ -290,19 +319,19 @@ class CommandRouter:
290
319
  table.add_row(command.name, command.group, command.description, command.example)
291
320
  return table
292
321
 
293
- def _record_history(self, raw: str, result: CommandResult) -> None:
294
- normalized = raw.strip().lower()
295
- if (
296
- not normalized
297
- or normalized.startswith("/history")
298
- or normalized.startswith("/privacy purge")
299
- or normalized.startswith("/secrets clear")
300
- ):
301
- return
322
+ def _record_history(self, raw: str, result: CommandResult) -> None:
323
+ normalized = raw.strip().lower()
324
+ if (
325
+ not normalized
326
+ or normalized.startswith("/history")
327
+ or normalized.startswith("/privacy purge")
328
+ or normalized.startswith("/secrets clear")
329
+ ):
330
+ return
302
331
  try:
303
332
  preview = _render_history_preview(result.renderable)
304
333
  self.history.record_event(self.session_id, raw, result.status, preview)
305
- except FinCLIError:
334
+ except Exception:
306
335
  return
307
336
 
308
337
  def _history(self, args: list[str]) -> CommandResult:
@@ -362,12 +391,14 @@ class CommandRouter:
362
391
  lines = [
363
392
  f"AI provider : {safe['ai_provider']}",
364
393
  f"AI model : {safe['ai_model']}",
365
- f"Market provider : {safe['market_provider']}",
366
- f"News provider : {safe['news_provider']}",
367
- f"News priority : {', '.join(safe.get('news_provider_priority', []))}",
368
- f"Timezone : {safe['timezone']}",
394
+ f"Market provider : {safe['market_provider']}",
395
+ f"News provider : {safe['news_provider']}",
396
+ f"News priority : {', '.join(safe.get('news_provider_priority', []))}",
397
+ f"Timezone : {safe['timezone']}",
369
398
  f"Default currency : {safe['default_currency']}",
370
399
  f"Cache TTL : {safe['cache_ttl_seconds']}s",
400
+ f"Provider timeout : {safe['provider_timeout_seconds']}s",
401
+ f"Circuit breaker : {safe['provider_circuit_breaker_failure_threshold']} failures / {safe['provider_circuit_breaker_cooldown_seconds']}s cooldown",
371
402
  f"Theme : {safe['theme']}",
372
403
  "",
373
404
  "API key status:",
@@ -407,115 +438,126 @@ class CommandRouter:
407
438
  self.ai_provider = AIProviderManager().create(args[0])
408
439
  return CommandResult(Panel(f"AI model aktif: {args[0]} / {args[1]}", title="AI Model Updated"))
409
440
 
410
- def _news_model(self, args: list[str]) -> CommandResult:
411
- if len(args) == 0:
412
- current = self.config.settings
413
- chain = ", ".join(current.news_provider_priority or [current.news_provider])
414
- return CommandResult(
415
- Panel(
416
- (
417
- f"Market: {current.market_provider}\n"
418
- f"News: {current.news_provider}\n"
419
- f"Fallback priority: {chain}\n\n"
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]"
426
- ),
427
- title="Active Data Provider",
428
- )
429
- )
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":
466
- if len(args) < 3:
467
- raise CommandError("Format: /news_model key <provider> <api_key> [base_url untuk custom]")
468
- provider = args[1].lower()
469
- env_key = news_connector_secret_key(provider)
470
- env_keys = (env_key,) if env_key else _market_provider_secret_keys(provider)
471
- if not env_keys:
472
- raise CommandError(f"Provider {provider} tidak membutuhkan API key atau tidak dikenal.")
473
- save_secret(env_keys[0], args[2])
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:
477
- save_secret("MARKET_DATA_BASE_URL", args[3])
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)])
484
- self.cache.clear()
485
- extra = "\nBase URL custom juga disimpan." if provider in {"custom", "custom_news"} and len(args) >= 4 else ""
486
- return CommandResult(
487
- Panel(
488
- (
489
- f"API key market/news untuk {provider} disimpan global di ~/.fincli/secrets.env.{extra}\n"
490
- f"Provider news aktif disimpan: {provider}.\n"
491
- "Key tidak ditampilkan di terminal dan dipakai lintas session."
492
- ),
493
- title="News API Key Saved",
494
- border_style="green",
495
- )
496
- )
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)
501
- self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
502
- self._refresh_market_service()
503
- self.cache.clear()
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"))
441
+ def _news_model(self, args: list[str]) -> CommandResult:
442
+ if len(args) == 0:
443
+ current = self.config.settings
444
+ chain = ", ".join(current.news_provider_priority or [current.news_provider])
445
+ return CommandResult(
446
+ Panel(
447
+ (
448
+ f"Market: {current.market_provider}\n"
449
+ f"News: {current.news_provider}\n"
450
+ f"Fallback priority: {chain}\n\n"
451
+ "Commands:\n"
452
+ "- /news_model list\n"
453
+ "- /news_model search <query>\n"
454
+ "- /news_model use <provider>\n"
455
+ "- /news_model priority google_news_rss,yfinance,marketaux\n"
456
+ "- /news_model key <provider> <api_key> [base_url]"
457
+ ),
458
+ title="Active Data Provider",
459
+ )
460
+ )
461
+ action = args[0].lower()
462
+ if action == "list":
463
+ return CommandResult(_format_news_connectors(self.news_connector_catalog.free_first()[:120], "all"))
464
+ if action == "search":
465
+ query = " ".join(args[1:]).strip()
466
+ if not query:
467
+ raise CommandError("Format: /news_model search <query>")
468
+ return CommandResult(_format_news_connectors(self.news_connector_catalog.search(query), query))
469
+ if action == "priority":
470
+ if len(args) < 2:
471
+ raise CommandError("Format: /news_model priority google_news_rss,yfinance,marketaux")
472
+ providers = [provider.strip().lower() for provider in args[1].split(",") if provider.strip()]
473
+ self._validate_news_providers(providers)
474
+ self.config.set_news_provider_priority(providers)
475
+ return CommandResult(
476
+ Panel(
477
+ f"News fallback priority disimpan: {', '.join(self.config.settings.news_provider_priority)}",
478
+ title="News Priority Updated",
479
+ border_style="green",
480
+ )
481
+ )
482
+ if action == "use":
483
+ if len(args) < 2:
484
+ raise CommandError("Format: /news_model use <provider>")
485
+ provider = args[1].lower()
486
+ self._validate_news_providers([provider])
487
+ current = [item for item in self.config.settings.news_provider_priority if item != provider]
488
+ self.config.set_news_provider_priority([provider, *current])
489
+ return CommandResult(
490
+ Panel(
491
+ f"News primary provider: {provider}\nFallback: {', '.join(self.config.settings.news_provider_priority)}",
492
+ title="News Provider Updated",
493
+ border_style="green",
494
+ )
495
+ )
496
+ if action == "key":
497
+ if len(args) < 3:
498
+ raise CommandError("Format: /news_model key <provider> <api_key> [base_url untuk custom]")
499
+ provider = args[1].lower()
500
+ env_key = news_connector_secret_key(provider)
501
+ env_keys = (env_key,) if env_key else _market_provider_secret_keys(provider)
502
+ if not env_keys:
503
+ raise CommandError(f"Provider {provider} tidak membutuhkan API key atau tidak dikenal.")
504
+ save_secret(env_keys[0], args[2])
505
+ if provider == "custom_news" and len(args) >= 4:
506
+ save_secret("CUSTOM_NEWS_BASE_URL", args[3])
507
+ elif provider == "custom" and len(args) >= 4:
508
+ save_secret("MARKET_DATA_BASE_URL", args[3])
509
+ if self.market_manager.get(provider) is not None:
510
+ self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
511
+ self.config.set_news_provider(provider)
512
+ self._refresh_market_service()
513
+ else:
514
+ self.config.set_news_provider_priority([provider, *self._news_priority_tail(provider)])
515
+ self.cache.clear()
516
+ extra = "\nBase URL custom juga disimpan." if provider in {"custom", "custom_news"} and len(args) >= 4 else ""
517
+ return CommandResult(
518
+ Panel(
519
+ (
520
+ f"API key market/news untuk {provider} disimpan global di ~/.fincli/secrets.env.{extra}\n"
521
+ f"Provider news aktif disimpan: {provider}.\n"
522
+ "Key tidak ditampilkan di terminal dan dipakai lintas session."
523
+ ),
524
+ title="News API Key Saved",
525
+ border_style="green",
526
+ )
527
+ )
528
+ provider = args[0].lower()
529
+ if self.market_manager.get(provider) is not None:
530
+ self.config.set_market_provider(provider)
531
+ self.config.set_news_provider(provider)
532
+ self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
533
+ self._refresh_market_service()
534
+ self.cache.clear()
535
+ return CommandResult(Panel(f"Provider market/news aktif: {provider}", title="Provider Updated"))
536
+ self._validate_news_providers([provider])
537
+ self.config.set_news_provider_priority([provider, *self._news_priority_tail(provider)])
538
+ self.cache.clear()
539
+ return CommandResult(Panel(f"Provider news aktif: {provider}", title="News Provider Updated"))
509
540
 
510
541
  def _provider(self, args: list[str]) -> CommandResult:
511
542
  if args and args[0].lower() == "list":
512
543
  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))
544
+ if args and args[0].lower() in {"entitlement", "entitlements"}:
545
+ return CommandResult(_format_provider_entitlements(self.market_manager.entitlements()))
546
+ if args and args[0].lower() == "metrics":
547
+ return CommandResult(_format_provider_metrics(self.market_service))
517
548
  if args and args[0].lower() == "key" and len(args) >= 2 and args[1].lower() == "status":
518
549
  return CommandResult(_format_provider_key_status(self.market_manager))
550
+ if args and args[0].lower() in {"insider", "insiders"}:
551
+ if len(args) < 2:
552
+ raise CommandError("Format: /provider insider <symbol>")
553
+ provider = self.market_manager.create("finnhub")
554
+ rows = self._run_async(provider.insider_transactions(args[1].upper()))
555
+ return CommandResult(_format_insider_transactions(args[1].upper(), rows))
556
+ if args and args[0].lower() == "ipo":
557
+ start, end, _, _ = _parse_calendar_args(args[1:] or ["week"])
558
+ provider = self.market_manager.create("finnhub")
559
+ rows = self._run_async(provider.ipo_calendar(start, end))
560
+ return CommandResult(_format_ipo_calendar(rows, start, end))
519
561
  if args and args[0].lower() == "use":
520
562
  if len(args) < 2:
521
563
  raise CommandError("Format: /provider use <provider>")
@@ -554,195 +596,314 @@ class CommandRouter:
554
596
  return CommandResult(_format_quote(quote))
555
597
  raise CommandError(
556
598
  "Format: /provider status, /provider list, /provider entitlement, /provider key status, /provider use <provider>, "
557
- "/provider priority finnhub,yfinance, atau /provider test [provider] <symbol>"
599
+ "/provider priority finnhub,yfinance, /provider insider <symbol>, /provider ipo [week|from to], "
600
+ "atau /provider test [provider] <symbol>"
558
601
  )
559
602
 
560
- def _symbol(self, args: list[str]) -> CommandResult:
561
- if not args:
562
- raise CommandError("Format: /symbol <query> atau /symbol normalize <symbol>")
563
- action = args[0].lower()
564
- if action in {"normalize", "norm"}:
603
+ def _symbol(self, args: list[str]) -> CommandResult:
604
+ if not args:
605
+ raise CommandError("Format: /symbol search <query>, /symbol resolve <symbol> [--asset <class>], atau /symbol normalize <symbol>")
606
+ action = args[0].lower()
607
+ if action in {"resolve", "normalize", "norm"}:
608
+ if len(args) < 2:
609
+ raise CommandError("Format: /symbol resolve <symbol> [--asset <class>]")
610
+ asset_class = _extract_option_value(args[2:], "--asset")
611
+ return CommandResult(_format_symbol_matrix(args[1], self.symbol_resolver, asset_class=asset_class))
612
+ if action == "search":
565
613
  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))
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()))
611
- action = args[0].lower()
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.1 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
- )
653
- )
654
-
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()
661
- return CommandResult(
662
- Panel(
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",
666
- )
667
- )
668
- raise CommandError("Format: /secrets status atau /secrets clear")
669
-
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()
674
- return CommandResult(
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
- )
688
- )
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
- )
707
- )
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")
744
-
745
- def _cache(self, args: list[str]) -> CommandResult:
614
+ raise CommandError("Format: /symbol search <query>")
615
+ query = " ".join(args[1:])
616
+ else:
617
+ query = " ".join(args)
618
+ results = search_symbol_catalog(query)
619
+ return CommandResult(_format_symbol_search(query, results))
620
+
621
+ def _research(self, args: list[str]) -> CommandResult:
622
+ if not args:
623
+ raise CommandError("Format: /research <symbol> [--deep|--report] [timeframe] [--export <md|json> <path>]")
624
+ symbol = args[0].upper()
625
+ export_format: str | None = None
626
+ export_target: str | None = None
627
+ if "--export" in args:
628
+ export_index = args.index("--export")
629
+ if len(args) <= export_index + 2:
630
+ raise CommandError("Format export: /research <symbol> --report --export <md|json> <path>")
631
+ export_format = args[export_index + 1]
632
+ export_target = args[export_index + 2]
633
+ flags = {arg.lower() for arg in args[1:] if arg.startswith("--")}
634
+ if "--report" in flags:
635
+ mode = "report"
636
+ elif "--deep" in flags:
637
+ mode = "deep"
638
+ else:
639
+ mode = "quick"
640
+ ignored: set[int] = set()
641
+ if "--export" in args:
642
+ export_index = args.index("--export")
643
+ ignored.update({export_index, export_index + 1, export_index + 2})
644
+ timeframe = next((arg for index, arg in enumerate(args[1:], start=1) if index not in ignored and not arg.startswith("--")), "1d")
645
+ engine = ResearchEngine(self.market_service, self.ai_provider, self.config.settings.ai_model)
646
+ brief = self._run_async(engine.build(symbol, timeframe=timeframe, mode=mode))
647
+ if export_format and export_target:
648
+ written = write_research_report(brief, export_format, export_target)
649
+ return CommandResult(Panel(f"Research export selesai: {written}", title="Research Export", border_style="green"))
650
+ return CommandResult(format_research_brief(brief))
651
+
652
+ def _macro(self, args: list[str]) -> CommandResult:
653
+ query = " ".join(args).strip()
654
+ rows = self.macro_data.indicators(query)
655
+ return CommandResult(_format_macro_dashboard(query or "global", rows))
656
+
657
+ def _macro_indicator(self, indicator: str, args: list[str]) -> CommandResult:
658
+ if indicator == "gdp" and args[:2] and " ".join(args[:2]).lower() == "per capita":
659
+ indicator = "gdp_per_capita"
660
+ args = args[2:]
661
+ region = args[0] if args else "us"
662
+ try:
663
+ rows = self.macro_data.alpha_vantage_indicator(indicator, region)
664
+ except FinCLIError as exc:
665
+ rows = [_macro_error_row(indicator, region, exc)]
666
+ return CommandResult(_format_macro_indicator(indicator, region, rows))
667
+
668
+ def _profile(self, args: list[str]) -> CommandResult:
669
+ if not args:
670
+ return CommandResult(_format_user_profile(self.user_profiles.get()))
671
+ action = args[0].lower()
672
+ if action == "set":
673
+ if len(args) < 6:
674
+ raise CommandError('Format: /profile set "Nama" <equity> <currency> <leverage> <years>')
675
+ profile = self.user_profiles.save(args[1], float(args[2]), args[3], args[4], float(args[5]))
676
+ return CommandResult(_format_user_profile(profile))
677
+ if action in {"clear", "delete", "reset"}:
678
+ self.user_profiles.clear()
679
+ return CommandResult(Panel("Profile lokal dihapus.", title="Profile", border_style="yellow"))
680
+ raise CommandError('Format: /profile, /profile set "Nama" <equity> <currency> <leverage> <years>, /profile clear')
681
+
682
+ def _doctor(self, args: list[str]) -> CommandResult:
683
+ full = bool(args and args[0].lower() in {"full", "deep"})
684
+ live = "--live" in {arg.lower() for arg in args}
685
+ live_symbol = _doctor_live_symbol(args)
686
+ table = Table(title="FinCLI Doctor Full" if full else "FinCLI Doctor", expand=True)
687
+ table.add_column("Check", style="cyan", no_wrap=True)
688
+ table.add_column("Status")
689
+ table.add_column("Detail", overflow="fold")
690
+ table.add_row("Version", "ok", "FinCLI v0.4.0 command surface loaded.")
691
+ for check in check_runtime_environment():
692
+ style = "green" if check.status == "ok" else "yellow" if check.status in {"warning", "info"} else "red"
693
+ table.add_row(check.name, f"[{style}]{check.status}[/]", check.detail)
694
+ table.add_row("Database", "ok", str(self.db.db_file))
695
+ table.add_row("Market Provider", "ok", ", ".join(provider.name for provider in self.market_service.providers))
696
+ table.add_row("Provider Timeout", "ok", f"{self.config.settings.provider_timeout_seconds}s per provider call")
697
+ table.add_row(
698
+ "Circuit Breaker",
699
+ "ok",
700
+ (
701
+ f"{self.config.settings.provider_circuit_breaker_failure_threshold} failures -> "
702
+ f"{self.config.settings.provider_circuit_breaker_cooldown_seconds}s cooldown"
703
+ ),
704
+ )
705
+ profile = self.user_profiles.get()
706
+ table.add_row("Profile", "ok" if profile else "missing", profile.gameplay if profile else "Run /profile set ...")
707
+ table.add_row("AI Provider", "configured", f"{self.config.settings.ai_provider} / {self.config.settings.ai_model}")
708
+ if full:
709
+ for name, status, detail in self._doctor_full_checks():
710
+ style = "green" if status == "ok" else "yellow" if status in {"warning", "info"} else "red"
711
+ table.add_row(name, f"[{style}]{status}[/]", detail)
712
+ if live:
713
+ for name, status, detail in self._doctor_live_checks(live_symbol):
714
+ style = "green" if status == "ok" else "yellow" if status in {"warning", "info"} else "red"
715
+ table.add_row(name, f"[{style}]{status}[/]", detail)
716
+ table.caption = (
717
+ "Doctor full checks local wiring, command coverage, database/cache, and provider configuration. "
718
+ "Use /doctor full --live [SYMBOL] for optional live quote verification. "
719
+ "Provider entitlement still depends on API key/account plan."
720
+ )
721
+ return CommandResult(table)
722
+
723
+ def _doctor_full_checks(self) -> list[tuple[str, str, str]]:
724
+ checks: list[tuple[str, str, str]] = []
725
+ try:
726
+ tables = self.db.query("SELECT name FROM sqlite_master WHERE type = 'table'")
727
+ checks.append(("Database Schema", "ok", f"{len(tables)} table(s) available"))
728
+ except FinCLIError as exc:
729
+ checks.append(("Database Schema", "error", str(exc)))
730
+
731
+ try:
732
+ stats = self.market_cache.stats()
733
+ checks.append(("Market Cache", "ok", ", ".join(f"{key}={value}" for key, value in stats.items())))
734
+ except FinCLIError as exc:
735
+ checks.append(("Market Cache", "error", str(exc)))
736
+
737
+ key_rows = self.market_manager.key_status()
738
+ missing_keys = [row["provider"] for row in key_rows if row["status"] == "not set"]
739
+ configured_keys = [row["provider"] for row in key_rows if row["status"] not in {"not set", "not required"}]
740
+ checks.append(
741
+ (
742
+ "Market API Keys",
743
+ "warning" if missing_keys else "ok",
744
+ f"configured={len(configured_keys)}; missing={', '.join(missing_keys) if missing_keys else 'none'}",
745
+ )
746
+ )
747
+
748
+ for provider in self.market_service.providers:
749
+ provider_name = getattr(provider, "name", "unknown")
750
+ try:
751
+ status = self._run_async(provider.status())
752
+ checks.append((f"Provider:{provider_name}", "ok" if status.status in {"ok", "configured", "fallback"} else "warning", status.message))
753
+ except Exception as exc: # noqa: BLE001
754
+ checks.append((f"Provider:{provider_name}", "error", str(exc)))
755
+
756
+ registry_roots = {command.name.split()[0] for command in self.registry.all()}
757
+ router_roots = _router_roots()
758
+ hidden = sorted(router_roots - registry_roots)
759
+ stale = sorted(registry_roots - router_roots)
760
+ if hidden or stale:
761
+ checks.append(
762
+ (
763
+ "Command Coverage",
764
+ "warning",
765
+ f"hidden={', '.join(hidden) if hidden else 'none'}; stale={', '.join(stale) if stale else 'none'}",
766
+ )
767
+ )
768
+ else:
769
+ checks.append(("Command Coverage", "ok", f"{len(registry_roots)} registry root command(s) covered by router"))
770
+
771
+ metric_snapshot = self.market_service.provider_metrics_snapshot()
772
+ checks.append(("Provider Metrics", "ok", f"session_providers={len(metric_snapshot)}; persistent_store=enabled"))
773
+ checks.append(("Capability Matrix", "ok", capability_summary()))
774
+ for capability in capability_rows():
775
+ checks.append(
776
+ (
777
+ f"Capability:{capability.command}",
778
+ "ok",
779
+ f"needs={', '.join(capability.needs)}; {capability.note}",
780
+ )
781
+ )
782
+ return checks
783
+
784
+ def _doctor_live_checks(self, symbol: str) -> list[tuple[str, str, str]]:
785
+ try:
786
+ quote = self._run_async(asyncio.wait_for(self.market_service.quote(symbol), self.config.settings.provider_timeout_seconds))
787
+ except Exception as exc: # noqa: BLE001
788
+ return [("Live Quote Test", "error", f"{symbol}: {type(exc).__name__}: {exc}")]
789
+ return [
790
+ (
791
+ "Live Quote Test",
792
+ "ok" if quote.price is not None else "warning",
793
+ f"{quote.symbol} {quote.price if quote.price is not None else 'N/A'} {quote.currency}; provider={quote.provider}; status={quote.status}",
794
+ )
795
+ ]
796
+
797
+ def _setup(self, args: list[str]) -> CommandResult:
798
+ return CommandResult(
799
+ Panel(
800
+ "\n".join(
801
+ [
802
+ "Recommended setup:",
803
+ '1. /profile set "Nama" <equity> <currency> <leverage> <years>',
804
+ "2. /ai_model key <provider> <api_key>",
805
+ "3. /news_model key <provider> <api_key>",
806
+ "4. /provider priority yfinance,alphavantage,twelvedata,finnhub",
807
+ "5. /research AAPL --quick",
808
+ "6. /analyze XAUUSD 1d",
809
+ ]
810
+ ),
811
+ title="FinCLI Setup",
812
+ border_style="cyan",
813
+ )
814
+ )
815
+
816
+ def _secrets(self, args: list[str]) -> CommandResult:
817
+ action = args[0].lower() if args else "status"
818
+ if action == "status":
819
+ return CommandResult(_format_secrets_status(read_secrets()))
820
+ if action == "clear":
821
+ cleared = clear_secrets()
822
+ return CommandResult(
823
+ Panel(
824
+ f"{cleared} local secret(s) cleared from ~/.fincli/secrets.env. Current process keys from that store were removed.",
825
+ title="Secrets Cleared",
826
+ border_style="yellow",
827
+ )
828
+ )
829
+ raise CommandError("Format: /secrets status atau /secrets clear")
830
+
831
+ def _privacy(self, args: list[str]) -> CommandResult:
832
+ action = args[0].lower() if args else "status"
833
+ if action == "status":
834
+ stats = self.market_cache.stats()
835
+ return CommandResult(
836
+ Panel(
837
+ "\n".join(
838
+ [
839
+ f"Secrets stored : {len(read_secrets())}",
840
+ f"Session events : {len(self.history.get_events(self.session_id))}",
841
+ f"Persistent cache rows: {stats['total']}",
842
+ "Purge scope : secrets + current session history + runtime/persistent cache",
843
+ "Portfolio, journal, alerts, and profile are not deleted by /privacy purge.",
844
+ ]
845
+ ),
846
+ title="Privacy Status",
847
+ border_style="cyan",
848
+ )
849
+ )
850
+ if action == "purge":
851
+ secrets_cleared = clear_secrets()
852
+ self.history.clear_events(self.session_id)
853
+ self.cache.clear()
854
+ cache_cleared = self.market_cache.clear()
855
+ return CommandResult(
856
+ Panel(
857
+ (
858
+ f"Privacy state purged.\n"
859
+ f"- secrets cleared: {secrets_cleared}\n"
860
+ f"- current session history cleared\n"
861
+ f"- runtime cache cleared\n"
862
+ f"- persistent market cache rows cleared: {cache_cleared}\n\n"
863
+ "Portfolio, journal, alerts, and profile were kept."
864
+ ),
865
+ title="Privacy Purge",
866
+ border_style="yellow",
867
+ )
868
+ )
869
+ raise CommandError("Format: /privacy status atau /privacy purge")
870
+
871
+ def _agent(self, args: list[str]) -> CommandResult:
872
+ action = args[0].lower() if args else "list"
873
+ if action in {"list", "ls"}:
874
+ category = args[1].lower() if len(args) >= 2 else ""
875
+ agents = self.agent_registry.by_category(category) if category else list(self.agent_registry.all())
876
+ return CommandResult(_format_agents(agents, category or "all"))
877
+ if action == "show":
878
+ if len(args) < 2:
879
+ raise CommandError("Format: /agent show <slug>")
880
+ agent = self.agent_registry.get(args[1])
881
+ if agent is None:
882
+ raise CommandError(f"Agent tidak ditemukan: {args[1]}")
883
+ return CommandResult(_format_agent(agent))
884
+ raise CommandError("Format: /agent list [category] atau /agent show <slug>")
885
+
886
+ def _connector(self, args: list[str]) -> CommandResult:
887
+ action = args[0].lower() if args else "list"
888
+ if action in {"list", "ls"}:
889
+ category = args[1].lower() if len(args) >= 2 else ""
890
+ connectors = self.connector_catalog.by_category(category) if category else list(self.connector_catalog.all())
891
+ return CommandResult(_format_connectors(connectors, category or "all"))
892
+ if action in {"search", "find"}:
893
+ if len(args) < 2:
894
+ raise CommandError("Format: /connector search <query>")
895
+ query = " ".join(args[1:])
896
+ return CommandResult(_format_connectors(self.connector_catalog.find(query), query))
897
+ raise CommandError("Format: /connector list [category] atau /connector search <query>")
898
+
899
+ def _plugin(self, args: list[str]) -> CommandResult:
900
+ action = args[0].lower() if args else "list"
901
+ if action in {"list", "ls", "status"}:
902
+ plugins = PluginLoader().discover()
903
+ return CommandResult(_format_plugins(plugins, status_only=action == "status"))
904
+ raise CommandError("Format: /plugin list atau /plugin status")
905
+
906
+ def _cache(self, args: list[str]) -> CommandResult:
746
907
  if args and args[0].lower() == "stats":
747
908
  stats = self.market_cache.stats()
748
909
  lines = [
@@ -830,11 +991,11 @@ class CommandRouter:
830
991
  )
831
992
  return CommandResult(table)
832
993
 
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())
994
+ action = args[0].lower()
995
+ if action == "risk":
996
+ return CommandResult(_format_portfolio_risk(self._portfolio_risk_report()))
997
+ if action == "performance":
998
+ return CommandResult(self._portfolio_performance_table())
838
999
  if action == "add" and len(args) >= 4:
839
1000
  try:
840
1001
  quantity = float(args[2])
@@ -846,10 +1007,10 @@ class CommandRouter:
846
1007
  if action == "remove" and len(args) >= 2:
847
1008
  self.portfolio.remove(args[1])
848
1009
  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
- )
1010
+ raise CommandError(
1011
+ "Format: /portfolio, /portfolio risk, /portfolio performance, /portfolio add <symbol> <qty> <avg_price>, "
1012
+ "/portfolio remove <symbol>"
1013
+ )
853
1014
 
854
1015
  def _tx(self, args: list[str]) -> CommandResult:
855
1016
  if not args or args[0].lower() == "list":
@@ -1010,6 +1171,34 @@ class CommandRouter:
1010
1171
  result = run_backtest(symbol, candles, strategy=strategy, interval=interval)
1011
1172
  return CommandResult(_format_backtest(result))
1012
1173
 
1174
+ def _trading(self, args: list[str]) -> CommandResult:
1175
+ if not args:
1176
+ return CommandResult(_format_trading_overview(self.realtime_connector_catalog, self.broker_catalog))
1177
+ action = args[0].lower()
1178
+ if action in {"realtime", "feeds", "feed"}:
1179
+ return CommandResult(_format_realtime_connectors(self.realtime_connector_catalog.all()))
1180
+ if action in {"brokers", "broker"}:
1181
+ return CommandResult(_format_brokers(self.broker_catalog.all()))
1182
+ if action == "paper":
1183
+ return self._trading_paper(args[1:])
1184
+ raise CommandError("Format: /trading, /trading realtime, /trading brokers, /trading paper buy|sell <symbol> <qty> <market|limit> [price]")
1185
+
1186
+ def _trading_paper(self, args: list[str]) -> CommandResult:
1187
+ if not args or args[0].lower() in {"orders", "list"}:
1188
+ return CommandResult(_format_paper_orders(self.paper_trading.list_orders()))
1189
+ if len(args) < 4:
1190
+ raise CommandError("Format: /trading paper <buy|sell> <symbol> <qty> <market|limit> [price]")
1191
+ side = args[0].lower()
1192
+ symbol = args[1].upper()
1193
+ try:
1194
+ quantity = float(args[2])
1195
+ price = float(args[4]) if len(args) >= 5 else None
1196
+ except ValueError as exc:
1197
+ raise CommandError("Quantity dan price paper order harus angka.") from exc
1198
+ order_type = args[3].lower()
1199
+ order = self.paper_trading.place_order(side, symbol, quantity, order_type, price=price)
1200
+ return CommandResult(_format_paper_order(order))
1201
+
1013
1202
  def _market(self, args: list[str]) -> CommandResult:
1014
1203
  if not args:
1015
1204
  raise CommandError("Format: /market <symbol> [interval]")
@@ -1029,19 +1218,19 @@ class CommandRouter:
1029
1218
  structure = analyze_market_structure(candles)
1030
1219
  return CommandResult(_format_structure(symbol, interval, structure))
1031
1220
 
1032
- def _news(self, args: list[str]) -> CommandResult:
1033
- if not args:
1034
- raise CommandError("Format: /news <symbol> [1d-30d]")
1035
- symbol = args[0].upper()
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))
1221
+ def _news(self, args: list[str]) -> CommandResult:
1222
+ if not args:
1223
+ raise CommandError("Format: /news <symbol> [1d-30d]")
1224
+ symbol = args[0].upper()
1225
+ lookback_days = _parse_news_lookback(args[1:]) if len(args) > 1 else None
1226
+ desk = self._run_async(
1227
+ NewsAggregator(
1228
+ self.market_service,
1229
+ self.news_connectors,
1230
+ self.config.settings.news_provider_priority,
1231
+ ).latest(symbol, limit=12, lookback_days=lookback_days)
1232
+ )
1233
+ return CommandResult(_format_news_desk(desk))
1045
1234
 
1046
1235
  def _fundamentals(self, args: list[str]) -> CommandResult:
1047
1236
  if not args:
@@ -1112,22 +1301,22 @@ class CommandRouter:
1112
1301
  candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=timeframe))
1113
1302
  if not candles:
1114
1303
  raise CommandError(f"Data market kosong untuk {symbol}.")
1115
- technical = summarize_technical_indicators(candles)
1116
- structure = analyze_market_structure(candles)
1117
- news_context = self._analysis_context(symbol)
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,
1129
- )
1130
- request = AIRequest(prompt=prompt, model=self.config.settings.ai_model)
1304
+ technical = summarize_technical_indicators(candles)
1305
+ structure = analyze_market_structure(candles)
1306
+ news_context = self._analysis_context(symbol)
1307
+ gameplay_context = format_gameplay_context(self.user_profiles.get(), symbol)
1308
+ grounding_context = self._ai_grounding_context(symbol, timeframe)
1309
+ prompt = build_market_analysis_prompt(
1310
+ symbol,
1311
+ timeframe,
1312
+ candles,
1313
+ technical,
1314
+ structure,
1315
+ news_context,
1316
+ gameplay_context,
1317
+ grounding_context=grounding_context,
1318
+ )
1319
+ request = AIRequest(prompt=prompt, model=self.config.settings.ai_model)
1131
1320
  response = self._run_async(self.ai_provider.complete(request))
1132
1321
  if not isinstance(response, AIResponse):
1133
1322
  raise CommandError("AI provider mengembalikan data tidak valid.")
@@ -1178,16 +1367,16 @@ class CommandRouter:
1178
1367
  def _calendar(self, args: list[str]) -> CommandResult:
1179
1368
  if args and args[0].lower() == "export":
1180
1369
  return self._calendar_export(args[1:])
1181
- start, end, country, impact = _parse_calendar_args(args)
1182
- service = EconomicCalendarService(api_key=os.getenv("FINNHUB_API_KEY"))
1183
- source = "finnhub"
1184
- note = "Aktual dari provider Finnhub."
1185
- try:
1186
- events = self._run_async(service.events(start, end))
1187
- except FinCLIError as exc:
1188
- events, source, note = self._calendar_public_or_static_fallback(start, end, exc)
1189
- events = filter_events(events, country=country, impact=impact)
1190
- return CommandResult(_format_calendar(events, start, end, source, note))
1370
+ start, end, country, impact = _parse_calendar_args(args)
1371
+ service = EconomicCalendarService(api_key=os.getenv("FINNHUB_API_KEY"))
1372
+ source = "finnhub"
1373
+ note = "Aktual dari provider Finnhub."
1374
+ try:
1375
+ events = self._run_async(service.events(start, end))
1376
+ except FinCLIError as exc:
1377
+ events, source, note = self._calendar_public_or_static_fallback(start, end, exc)
1378
+ events = filter_events(events, country=country, impact=impact)
1379
+ return CommandResult(_format_calendar(events, start, end, source, note))
1191
1380
 
1192
1381
  def _calendar_export(self, args: list[str]) -> CommandResult:
1193
1382
  if len(args) < 2:
@@ -1195,37 +1384,37 @@ class CommandRouter:
1195
1384
  export_format = args[0].lower()
1196
1385
  target = args[1]
1197
1386
  start, end, country, impact = _parse_calendar_args(args[2:])
1198
- service = EconomicCalendarService(api_key=os.getenv("FINNHUB_API_KEY"))
1199
- try:
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)
1212
- try:
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)
1227
-
1228
- def _run_async(self, awaitable: Any) -> Any:
1387
+ service = EconomicCalendarService(api_key=os.getenv("FINNHUB_API_KEY"))
1388
+ try:
1389
+ events = self._run_async(service.events(start, end))
1390
+ except FinCLIError as exc:
1391
+ events, _, _ = self._calendar_public_or_static_fallback(start, end, exc)
1392
+ events = filter_events(events, country=country, impact=impact)
1393
+ written = export_rows(economic_event_rows(events), export_format, target)
1394
+ return CommandResult(Panel(f"Calendar export selesai: {written}", title="Calendar Export", border_style="green"))
1395
+
1396
+ def _calendar_public_or_static_fallback(
1397
+ self, start: date, end: date, provider_error: FinCLIError
1398
+ ) -> tuple[list[EconomicEvent], str, str]:
1399
+ if not os.getenv("FINNHUB_API_KEY"):
1400
+ return fallback_events(start, end), "fallback", _calendar_fallback_note(provider_error, False)
1401
+ try:
1402
+ events = self._run_async(PublicEconomicCalendarService().events(start, end))
1403
+ if events:
1404
+ return (
1405
+ events,
1406
+ "public",
1407
+ (
1408
+ "Finnhub calendar unavailable for the current key, plan, or rate limit. "
1409
+ "Using public economic calendar fallback; verify critical events with official sources."
1410
+ ),
1411
+ )
1412
+ except FinCLIError as public_error:
1413
+ note = _calendar_static_fallback_note(provider_error, public_error)
1414
+ return fallback_events(start, end), "fallback", note
1415
+ return fallback_events(start, end), "fallback", _calendar_static_fallback_note(provider_error, None)
1416
+
1417
+ def _run_async(self, awaitable: Any) -> Any:
1229
1418
  try:
1230
1419
  asyncio.get_running_loop()
1231
1420
  except RuntimeError:
@@ -1253,26 +1442,26 @@ class CommandRouter:
1253
1442
  except (TypeError, ValueError, KeyError):
1254
1443
  return None, None, None
1255
1444
 
1256
- def _portfolio_performance_table(self) -> Table:
1257
- risk = self._portfolio_risk_report()
1258
-
1259
- table = Table(title="Portfolio Performance", expand=True)
1260
- table.add_column("Metric", style="cyan")
1261
- table.add_column("Value", justify="right")
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})")
1268
- return table
1269
-
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())
1445
+ def _portfolio_performance_table(self) -> Table:
1446
+ risk = self._portfolio_risk_report()
1447
+
1448
+ table = Table(title="Portfolio Performance", expand=True)
1449
+ table.add_column("Metric", style="cyan")
1450
+ table.add_column("Value", justify="right")
1451
+ table.add_row("Cost Basis", _fmt(risk.total_cost_basis))
1452
+ table.add_row("Market Value", _fmt(risk.total_market_value))
1453
+ table.add_row("Unrealized PnL", _fmt(risk.unrealized_pnl))
1454
+ table.add_row("Realized PnL", _fmt(risk.realized_pnl))
1455
+ table.add_row("Total PnL", _fmt(risk.total_pnl))
1456
+ table.add_row("Health Score", f"{risk.health.score}/100 ({risk.health.label})")
1457
+ return table
1458
+
1459
+ def _portfolio_risk_report(self) -> PortfolioRiskReport:
1460
+ positions = self.portfolio.list()
1461
+ values: dict[str, tuple[float | None, float | None, float | None]] = {}
1462
+ for row in positions:
1463
+ values[str(row["symbol"]).upper()] = self._portfolio_market_values(row)
1464
+ return build_portfolio_risk(positions, values, self.transactions.realized_pnl_total(), profile=self.user_profiles.get())
1276
1465
 
1277
1466
  def _get_quote(self, symbol: str) -> Quote:
1278
1467
  normalized = symbol.upper()
@@ -1286,26 +1475,26 @@ class CommandRouter:
1286
1475
  self.cache.set(cache_key, quote)
1287
1476
  return quote
1288
1477
 
1289
- def _provider_health_text(self) -> str:
1290
- try:
1291
- status = self._run_async(self.market_service.status())
1292
- base = (
1293
- f"Provider health: {status.status}\n"
1294
- f"Provider realtime: {status.realtime}\n"
1295
- f"Provider message: {status.message}"
1296
- )
1297
- except (FinCLIError, AttributeError) as exc:
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)
1478
+ def _provider_health_text(self) -> str:
1479
+ try:
1480
+ status = self._run_async(self.market_service.status())
1481
+ base = (
1482
+ f"Provider health: {status.status}\n"
1483
+ f"Provider realtime: {status.realtime}\n"
1484
+ f"Provider message: {status.message}"
1485
+ )
1486
+ except (FinCLIError, AttributeError) as exc:
1487
+ base = f"Provider health: unavailable ({exc})"
1488
+
1489
+ results = getattr(self.market_service, "provider_results", [])[-6:]
1490
+ if not results:
1491
+ return f"{base}\nRecent provider results: none"
1492
+ lines = ["Recent provider results:"]
1493
+ for result in results:
1494
+ missing = f"; missing={', '.join(result.missing_fields)}" if result.missing_fields else ""
1495
+ message = f"; {result.message}" if result.message and result.message != "ok" else ""
1496
+ lines.append(f"- {result.provider}.{result.operation}: {result.status}{missing}{message}")
1497
+ return f"{base}\n" + "\n".join(lines)
1309
1498
 
1310
1499
  def _safe_quote(self, symbol: str) -> Quote | None:
1311
1500
  try:
@@ -1313,7 +1502,7 @@ class CommandRouter:
1313
1502
  except FinCLIError:
1314
1503
  return None
1315
1504
 
1316
- def _analysis_context(self, symbol: str) -> str:
1505
+ def _analysis_context(self, symbol: str) -> str:
1317
1506
  sections: list[str] = []
1318
1507
  try:
1319
1508
  news_items = self._run_async(self.market_service.news(symbol, limit=3))
@@ -1325,36 +1514,47 @@ class CommandRouter:
1325
1514
  sections.append(_format_fundamental_context(fundamentals))
1326
1515
  except (FinCLIError, AttributeError) as exc:
1327
1516
  sections.append(f"Fundamentals unavailable: {exc}")
1328
- return "\n\n".join(sections)
1329
-
1330
- def _ai_grounding_context(self, symbol: str, timeframe: str) -> str:
1331
- try:
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}"
1339
- )
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})"
1345
- )
1346
-
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")
1352
- else:
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)
1517
+ return "\n\n".join(sections)
1518
+
1519
+ def _ai_grounding_context(self, symbol: str, timeframe: str) -> str:
1520
+ try:
1521
+ overview = self._run_async(build_market_overview(symbol, self.market_service, timeframe))
1522
+ quality = overview.data_quality
1523
+ missing = ", ".join(quality.missing_fields) if quality.missing_fields else "none"
1524
+ quality_text = (
1525
+ f"Data Quality: {quality.score}/100 | tier={quality.tier} | freshness={quality.freshness}\n"
1526
+ f"Provider Reliability: {quality.reliability_status} | provider={quality.provider}\n"
1527
+ f"Missing Data: {missing}"
1528
+ )
1529
+ gate = build_data_trust_gate(quality, self.market_service.provider_metrics_snapshot())
1530
+ gate_text = gate.prompt_context()
1531
+ except FinCLIError as exc:
1532
+ quality_text = (
1533
+ "Data Quality: unavailable\n"
1534
+ "Provider Reliability: unavailable\n"
1535
+ f"Missing Data: market overview unavailable ({exc})"
1536
+ )
1537
+ gate_text = (
1538
+ "Data Trust Gate:\n"
1539
+ "- Trust Level: blocked\n"
1540
+ "- AI Action: no_directional_signal\n"
1541
+ "- Confidence Cap: 20%\n"
1542
+ "- Max Signal Strength: caution only\n"
1543
+ "- Reasons: market overview unavailable\n"
1544
+ "- Required Verification: provider data availability"
1545
+ )
1546
+
1547
+ metric_lines = []
1548
+ for provider in self.market_service.providers:
1549
+ metric = self.market_service.provider_metrics_snapshot().get(provider.name)
1550
+ if metric is None:
1551
+ metric_lines.append(f"- {provider.name}: calls=0; success_rate=0.00%; errors=0; fallbacks=0")
1552
+ else:
1553
+ metric_lines.append(
1554
+ f"- {provider.name}: calls={metric.calls}; success_rate={metric.success_rate:.2f}%; "
1555
+ f"errors={metric.errors}; fallbacks={metric.fallbacks}; avg_latency={metric.avg_latency_ms:.2f}ms"
1556
+ )
1557
+ return f"{quality_text}\n{gate_text}\nProvider Metrics:\n" + "\n".join(metric_lines)
1358
1558
 
1359
1559
  def _freechat_market_context(self, prompt: str) -> str:
1360
1560
  symbols = extract_market_symbols(prompt)
@@ -1471,51 +1671,59 @@ class CommandRouter:
1471
1671
  def _build_market_service(self, injected_provider: BaseMarketProvider | None = None) -> MarketDataService:
1472
1672
  if injected_provider is not None:
1473
1673
  return MarketDataService(
1474
- [injected_provider],
1475
- cache=self.market_cache,
1476
- cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1477
- metrics_store=self.provider_metrics_store,
1478
- )
1674
+ [injected_provider],
1675
+ cache=self.market_cache,
1676
+ cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1677
+ provider_timeout_seconds=self.config.settings.provider_timeout_seconds,
1678
+ metrics_store=self.provider_metrics_store,
1679
+ symbol_resolver=self.symbol_resolver,
1680
+ circuit_breaker_failure_threshold=self.config.settings.provider_circuit_breaker_failure_threshold,
1681
+ circuit_breaker_cooldown_seconds=self.config.settings.provider_circuit_breaker_cooldown_seconds,
1682
+ )
1479
1683
  priority = self.config.settings.market_provider_priority or [self.config.settings.market_provider]
1480
1684
  return MarketDataService(
1481
- self.market_manager.create_many(priority),
1482
- cache=self.market_cache,
1483
- cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1484
- metrics_store=self.provider_metrics_store,
1485
- )
1685
+ self.market_manager.create_many(priority),
1686
+ cache=self.market_cache,
1687
+ cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1688
+ provider_timeout_seconds=self.config.settings.provider_timeout_seconds,
1689
+ metrics_store=self.provider_metrics_store,
1690
+ symbol_resolver=self.symbol_resolver,
1691
+ circuit_breaker_failure_threshold=self.config.settings.provider_circuit_breaker_failure_threshold,
1692
+ circuit_breaker_cooldown_seconds=self.config.settings.provider_circuit_breaker_cooldown_seconds,
1693
+ )
1486
1694
 
1487
1695
  def _refresh_market_service(self) -> None:
1488
1696
  self.market_service = self._build_market_service()
1489
1697
  self.market_provider = self.market_service.primary_provider
1490
1698
 
1491
- def _priority_tail(self, active_provider: str) -> list[str]:
1492
- active = active_provider.lower()
1493
- existing = self.config.settings.market_provider_priority or ["yfinance"]
1494
- tail = [provider for provider in existing if provider != active]
1495
- if active != "yfinance" and "yfinance" not in tail:
1496
- tail.append("yfinance")
1497
- return tail
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
1508
-
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
- )
1699
+ def _priority_tail(self, active_provider: str) -> list[str]:
1700
+ active = active_provider.lower()
1701
+ existing = self.config.settings.market_provider_priority or ["yfinance"]
1702
+ tail = [provider for provider in existing if provider != active]
1703
+ if active != "yfinance" and "yfinance" not in tail:
1704
+ tail.append("yfinance")
1705
+ return tail
1706
+
1707
+ def _news_priority_tail(self, active_provider: str) -> list[str]:
1708
+ active = active_provider.lower()
1709
+ existing = self.config.settings.news_provider_priority or ["yfinance", "google_news_rss", "yahoo_finance_rss"]
1710
+ tail = [provider for provider in existing if provider != active]
1711
+ if active != "yfinance" and "yfinance" not in tail:
1712
+ tail.append("yfinance")
1713
+ if active != "google_news_rss" and "google_news_rss" not in tail:
1714
+ tail.append("google_news_rss")
1715
+ return tail
1716
+
1717
+ def _validate_news_providers(self, providers: list[str]) -> None:
1718
+ market_names = {provider.name for provider in self.market_service.providers}
1719
+ known = {"yfinance", *market_names}
1720
+ known.update(connector.slug for connector in self.news_connector_catalog.all())
1721
+ unknown = [provider for provider in providers if provider not in known]
1722
+ if unknown:
1723
+ raise CommandError(
1724
+ f"News provider tidak dikenal: {', '.join(unknown)}",
1725
+ "Gunakan /news_model list atau /news_model search <query> untuk melihat provider yang tersedia.",
1726
+ )
1519
1727
 
1520
1728
 
1521
1729
  def _format_quote(quote: Quote) -> str:
@@ -1632,7 +1840,7 @@ def _format_dashboard(
1632
1840
  if portfolio_rows
1633
1841
  else "No local portfolio positions"
1634
1842
  )
1635
- table.add_row("Portfolio", portfolio_summary, "/tx add buy AAPL 10 185 | /portfolio performance")
1843
+ table.add_row("Portfolio", portfolio_summary, "/tx add buy AAPL 10 185 | /portfolio performance")
1636
1844
 
1637
1845
  table.add_row(
1638
1846
  "Journal",
@@ -1643,57 +1851,57 @@ def _format_dashboard(
1643
1851
  "/journal stats | /journal review",
1644
1852
  )
1645
1853
 
1646
- table.add_row(
1647
- "Market",
1648
- "Use /market for compact quote + technical + structure + news + fundamentals.",
1649
- "/market AAPL 1d | /analyze AAPL 1d",
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
- )
1657
- return table
1658
-
1659
-
1660
- def _format_market_overview(overview: MarketOverview) -> Table:
1661
- table = Table(title=f"Market Overview: {overview.symbol} | {overview.timeframe}", expand=True)
1854
+ table.add_row(
1855
+ "Market",
1856
+ "Use /market for compact quote + technical + structure + news + fundamentals.",
1857
+ "/market AAPL 1d | /analyze AAPL 1d",
1858
+ )
1859
+ if unrealized != 0 or realized_pnl != 0:
1860
+ table.add_row(
1861
+ "Risk Color",
1862
+ semantic_text(f"Total PnL {_fmt(realized_pnl + unrealized)} {'gain' if realized_pnl + unrealized >= 0 else 'loss'}"),
1863
+ "green=positive | red=negative | yellow=caution",
1864
+ )
1865
+ return table
1866
+
1867
+
1868
+ def _format_market_overview(overview: MarketOverview) -> Table:
1869
+ table = Table(title=f"Market Overview: {overview.symbol} | {overview.timeframe}", expand=True)
1662
1870
  table.add_column("Section", style="cyan", no_wrap=True)
1663
1871
  table.add_column("Value", style="white")
1664
1872
  table.add_column("Context", style="dim")
1665
1873
 
1666
1874
  quality = overview.data_quality
1667
- table.add_row(
1668
- "Data Quality",
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
- ),
1676
- )
1677
- table.add_row(
1678
- "Quote",
1679
- f"{_fmt(overview.quote.price)} {overview.quote.currency}",
1680
- semantic_text(f"{overview.quote.provider} | {overview.quote.status} | {overview.quote.timestamp.isoformat(timespec='seconds')}"),
1681
- )
1682
- table.add_row(
1683
- "Technical",
1684
- semantic_text(f"RSI {_fmt(overview.technical.rsi)} | Trend {overview.technical.trend_bias}"),
1685
- f"MACD {_fmt(overview.technical.macd)} / Signal {_fmt(overview.technical.macd_signal)} | ATR {_fmt(overview.technical.atr)}",
1686
- )
1875
+ table.add_row(
1876
+ "Data Quality",
1877
+ semantic_text(quality.compact()),
1878
+ (
1879
+ f"quote={quality.quote}; ohlcv={quality.ohlcv}; news={quality.news}; "
1880
+ f"fundamentals={quality.fundamentals}; provider={quality.provider}; "
1881
+ f"Reliability={quality.reliability_status}; "
1882
+ f"Missing={', '.join(quality.missing_fields) if quality.missing_fields else 'none'}"
1883
+ ),
1884
+ )
1885
+ table.add_row(
1886
+ "Quote",
1887
+ f"{_fmt(overview.quote.price)} {overview.quote.currency}",
1888
+ semantic_text(f"{overview.quote.provider} | {overview.quote.status} | {overview.quote.timestamp.isoformat(timespec='seconds')}"),
1889
+ )
1890
+ table.add_row(
1891
+ "Technical",
1892
+ semantic_text(f"RSI {_fmt(overview.technical.rsi)} | Trend {overview.technical.trend_bias}"),
1893
+ f"MACD {_fmt(overview.technical.macd)} / Signal {_fmt(overview.technical.macd_signal)} | ATR {_fmt(overview.technical.atr)}",
1894
+ )
1687
1895
  table.add_row(
1688
1896
  "Key Levels",
1689
1897
  f"Support {_fmt(overview.technical.support)} | Resistance {_fmt(overview.technical.resistance)}",
1690
1898
  f"Bollinger {_fmt(overview.technical.bollinger_lower)} - {_fmt(overview.technical.bollinger_upper)}",
1691
1899
  )
1692
- table.add_row(
1693
- "Market Structure",
1694
- semantic_text(f"{overview.structure.trend} | {overview.structure.latest_pattern}"),
1695
- f"BOS={overview.structure.break_of_structure}; CHoCH={overview.structure.change_of_character}; Liquidity={overview.structure.liquidity_area}",
1696
- )
1900
+ table.add_row(
1901
+ "Market Structure",
1902
+ semantic_text(f"{overview.structure.trend} | {overview.structure.latest_pattern}"),
1903
+ f"BOS={overview.structure.break_of_structure}; CHoCH={overview.structure.change_of_character}; Liquidity={overview.structure.liquidity_area}",
1904
+ )
1697
1905
 
1698
1906
  if overview.fundamentals is not None:
1699
1907
  table.add_row(
@@ -1714,64 +1922,64 @@ def _format_market_overview(overview: MarketOverview) -> Table:
1714
1922
  else:
1715
1923
  table.add_row("Latest News", "N/A", "Provider did not return recent news.")
1716
1924
 
1717
- table.add_row("Disclaimer", "Informational only", "Bukan nasihat keuangan.")
1718
- return table
1719
-
1720
-
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}",
1739
- )
1740
- table.add_row(
1741
- "Concentration Risk",
1742
- f"{report.concentration.level}: {report.concentration.top_symbol}",
1743
- f"{report.concentration.top_weight:.2f}%",
1744
- )
1745
- table.add_row("Concentration Risk", report.concentration.note, "")
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
1772
-
1773
-
1774
- def _format_technical(
1925
+ table.add_row("Disclaimer", "Informational only", "Bukan nasihat keuangan.")
1926
+ return table
1927
+
1928
+
1929
+ def _format_portfolio_risk(report: PortfolioRiskReport) -> Table:
1930
+ table = Table(title="Portfolio Risk v3 | Portfolio Risk v2 compatible", expand=True)
1931
+ table.add_column("Section", style="cyan", no_wrap=True)
1932
+ table.add_column("Metric", style="white", overflow="fold")
1933
+ table.add_column("Value", justify="right", overflow="fold")
1934
+
1935
+ table.add_row("Health Score", report.health.label, f"{report.health.score}/100")
1936
+ table.add_row("Health Notes", ", ".join(report.health.notes), "")
1937
+ table.add_row("PnL Detail", "Cost Basis", _fmt(report.total_cost_basis))
1938
+ table.add_row("PnL Detail", "Market Value", _fmt(report.total_market_value))
1939
+ table.add_row("PnL Detail", "Realized PnL", semantic_text(_fmt(report.realized_pnl)))
1940
+ table.add_row("PnL Detail", "Unrealized PnL", semantic_text(_fmt(report.unrealized_pnl)))
1941
+ table.add_row("PnL Detail", "Total PnL", semantic_text(_fmt(report.total_pnl)))
1942
+ table.add_row("Drawdown Estimate", "Unrealized drawdown vs cost basis", f"{report.drawdown_estimate:.2f}%")
1943
+ table.add_row(
1944
+ "Risk Budget",
1945
+ f"{report.risk_budget.profile_gameplay} | {report.risk_budget.note}",
1946
+ f"{_fmt(report.risk_budget.risk_per_trade)} / {_fmt(report.risk_budget.max_portfolio_risk)} {report.risk_budget.currency}",
1947
+ )
1948
+ table.add_row(
1949
+ "Concentration Risk",
1950
+ f"{report.concentration.level}: {report.concentration.top_symbol}",
1951
+ f"{report.concentration.top_weight:.2f}%",
1952
+ )
1953
+ table.add_row("Concentration Risk", report.concentration.note, "")
1954
+
1955
+ if report.exposure_by_asset_class:
1956
+ for exposure in report.exposure_by_asset_class.values():
1957
+ table.add_row(
1958
+ "Exposure by Asset Class",
1959
+ f"{exposure.asset_class} ({exposure.count} position(s))",
1960
+ f"{_fmt(exposure.market_value)} | {exposure.weight:.2f}%",
1961
+ )
1962
+ else:
1963
+ table.add_row("Exposure by Asset Class", "No positions", "-")
1964
+ if report.currency_exposure:
1965
+ for exposure in report.currency_exposure.values():
1966
+ table.add_row(
1967
+ "Currency Exposure",
1968
+ f"{exposure.currency} ({exposure.count} position(s))",
1969
+ f"{_fmt(exposure.market_value)} | {exposure.weight:.2f}%",
1970
+ )
1971
+ else:
1972
+ table.add_row("Currency Exposure", "No positions", "-")
1973
+ if report.asset_class_warnings:
1974
+ for warning in report.asset_class_warnings:
1975
+ table.add_row("Asset-Class Cap Warning", f"{warning.level}: {warning.note}", f"cap {warning.cap:.2f}%")
1976
+ else:
1977
+ table.add_row("Asset-Class Cap Warning", "none", "-")
1978
+ table.caption = "Portfolio Risk v3 is local analytics only. It is not financial advice."
1979
+ return table
1980
+
1981
+
1982
+ def _format_technical(
1775
1983
  symbol: str,
1776
1984
  interval: str,
1777
1985
  summary: TechnicalSummary,
@@ -1818,7 +2026,7 @@ def _format_structure(symbol: str, interval: str, structure: MarketStructureSumm
1818
2026
  )
1819
2027
 
1820
2028
 
1821
- def _format_multi_timeframe(analysis: MultiTimeframeAnalysis) -> Table:
2029
+ def _format_multi_timeframe(analysis: MultiTimeframeAnalysis) -> Table:
1822
2030
  table = Table(title=f"Multi-Timeframe Analysis: {analysis.symbol}", expand=True)
1823
2031
  table.add_column("Timeframe", style="cyan", no_wrap=True)
1824
2032
  table.add_column("Status", no_wrap=True)
@@ -1833,13 +2041,13 @@ def _format_multi_timeframe(analysis: MultiTimeframeAnalysis) -> Table:
1833
2041
  for frame in analysis.frames:
1834
2042
  table.add_row(
1835
2043
  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),
2044
+ frame.status,
2045
+ str(frame.candles),
2046
+ _fmt(frame.latest_close),
2047
+ semantic_text(frame.trend_bias),
2048
+ semantic_text(frame.structure_trend),
2049
+ _fmt(frame.rsi),
2050
+ _fmt(frame.macd),
1843
2051
  f"{_fmt(frame.support)} / {_fmt(frame.resistance)}",
1844
2052
  frame.note or "-",
1845
2053
  )
@@ -1850,15 +2058,15 @@ def _format_multi_timeframe(analysis: MultiTimeframeAnalysis) -> Table:
1850
2058
  return table
1851
2059
 
1852
2060
 
1853
- def _format_backtest(result: BacktestResult) -> Table:
2061
+ def _format_backtest(result: BacktestResult) -> Table:
1854
2062
  table = Table(title=f"Backtest: {result.symbol} | {result.strategy} | {result.interval}", expand=True)
1855
2063
  table.add_column("Metric", style="cyan", no_wrap=True)
1856
2064
  table.add_column("Value", style="white")
1857
2065
  table.add_row("Candles", str(result.candles))
1858
2066
  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'}"))
2067
+ table.add_row("Total Return", semantic_text(f"{result.total_return_percent:.2f}% {'gain' if result.total_return_percent >= 0 else 'loss'}"))
1860
2068
  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"))
2069
+ table.add_row("Max Drawdown", semantic_text(f"{result.max_drawdown_percent:.2f}% drawdown"))
1862
2070
  table.add_row("Exposure", f"{result.exposure_percent:.2f}%")
1863
2071
  if result.trades:
1864
2072
  latest = result.trades[-1]
@@ -1874,7 +2082,7 @@ def _format_backtest(result: BacktestResult) -> Table:
1874
2082
  return table
1875
2083
 
1876
2084
 
1877
- def _format_alerts(rows: list[dict[str, object]]) -> Table:
2085
+ def _format_alerts(rows: list[dict[str, object]]) -> Table:
1878
2086
  table = Table(title="Price Alerts", expand=True)
1879
2087
  table.add_column("ID", justify="right", no_wrap=True)
1880
2088
  table.add_column("Symbol", style="cyan", no_wrap=True)
@@ -1887,12 +2095,12 @@ def _format_alerts(rows: list[dict[str, object]]) -> Table:
1887
2095
  table.add_row(
1888
2096
  str(row["id"]),
1889
2097
  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
- )
2098
+ str(row["condition"]),
2099
+ _fmt(float(row["target"])),
2100
+ semantic_text("active hold" if int(row["active"]) else f"triggered {row['triggered_at']}"),
2101
+ str(row["note"] or "-"),
2102
+ str(row["created_at"]),
2103
+ )
1896
2104
  if not rows:
1897
2105
  table.add_row("-", "-", "-", "-", "-", "No alerts. Use /alert add AAPL above 200.", "-")
1898
2106
  return table
@@ -1911,12 +2119,12 @@ def _format_alert_checks(results: list[AlertCheckResult]) -> Table:
1911
2119
  table.add_row(
1912
2120
  str(result.id),
1913
2121
  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
- )
2122
+ result.condition,
2123
+ _fmt(result.target),
2124
+ _fmt(result.current_price),
2125
+ semantic_text("YES breakout confirmed" if result.triggered else "no hold"),
2126
+ result.note or "-",
2127
+ )
1920
2128
  if not results:
1921
2129
  table.add_row("-", "-", "-", "-", "-", "-", "No active alerts.")
1922
2130
  return table
@@ -1933,14 +2141,14 @@ def _format_scan_results(results: list[ScanResult], filter_expression: str, inte
1933
2141
  table.add_column("Reason")
1934
2142
  for result in results:
1935
2143
  table.add_row(
1936
- result.symbol,
1937
- _fmt(result.latest_close),
1938
- _fmt(result.rsi),
1939
- semantic_text(result.trend_bias),
1940
- _fmt(result.support),
1941
- _fmt(result.resistance),
1942
- semantic_text(result.reason),
1943
- )
2144
+ result.symbol,
2145
+ _fmt(result.latest_close),
2146
+ _fmt(result.rsi),
2147
+ semantic_text(result.trend_bias),
2148
+ _fmt(result.support),
2149
+ _fmt(result.resistance),
2150
+ semantic_text(result.reason),
2151
+ )
1944
2152
  if not results:
1945
2153
  table.add_row("-", "-", "-", "-", "-", "-", "Tidak ada symbol yang match.")
1946
2154
  return table
@@ -1962,28 +2170,37 @@ def _scan_result_rows(results: list[ScanResult]) -> list[dict[str, object]]:
1962
2170
  ]
1963
2171
 
1964
2172
 
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
1984
-
1985
-
1986
- def _parse_calendar_args(args: list[str]) -> tuple[date, date, str | None, str | None]:
2173
+ def _parse_timeframes(value: str) -> tuple[str, ...]:
2174
+ frames = tuple(frame.strip().lower() for frame in value.split(",") if frame.strip())
2175
+ if not frames:
2176
+ raise CommandError("Timeframe tidak valid. Contoh: /mtf AAPL 1d,1h,15m")
2177
+ if len(frames) > 6:
2178
+ raise CommandError("Maksimal 6 timeframe dalam satu /mtf.")
2179
+ return frames
2180
+
2181
+
2182
+ def _parse_news_lookback(args: list[str]) -> int | None:
2183
+ if not args:
2184
+ return None
2185
+ raw = args[0].strip().lower()
2186
+ if len(args) > 1 or not raw.endswith("d") or not raw[:-1].isdigit():
2187
+ raise CommandError("Format: /news <symbol> [1d-30d]")
2188
+ days = int(raw[:-1])
2189
+ if days < 1 or days > 30:
2190
+ raise CommandError("Lookback /news maksimal 30d. Contoh: /news TSLA 7d")
2191
+ return days
2192
+
2193
+
2194
+ def _extract_option_value(args: list[str], option: str) -> str | None:
2195
+ if option not in args:
2196
+ return None
2197
+ index = args.index(option)
2198
+ if len(args) <= index + 1:
2199
+ raise CommandError(f"Format opsi: {option} <value>")
2200
+ return args[index + 1]
2201
+
2202
+
2203
+ def _parse_calendar_args(args: list[str]) -> tuple[date, date, str | None, str | None]:
1987
2204
  country: str | None = None
1988
2205
  impact: str | None = None
1989
2206
  positional: list[str] = []
@@ -2016,77 +2233,111 @@ def _parse_calendar_args(args: list[str]) -> tuple[date, date, str | None, str |
2016
2233
  return start, end, country, impact
2017
2234
 
2018
2235
 
2019
- def _parse_date_arg(value: str) -> date:
2020
- try:
2021
- return date.fromisoformat(value)
2022
- except ValueError as exc:
2023
- raise CommandError("Tanggal calendar harus format YYYY-MM-DD.") from exc
2024
-
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
-
2044
- def _format_calendar(events: list[EconomicEvent], start: date, end: date, source: str, note: str) -> Table:
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")
2054
-
2055
- for event in events:
2056
- event_time = event.time.isoformat(timespec="minutes") if event.time else "TBA"
2057
- table.add_row(
2058
- event_time,
2059
- event.country,
2060
- event.impact,
2061
- event.event,
2062
- )
2063
-
2064
- if not events:
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)
2075
- return table
2076
-
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
2236
+ def _parse_date_arg(value: str) -> date:
2237
+ try:
2238
+ return date.fromisoformat(value)
2239
+ except ValueError as exc:
2240
+ raise CommandError("Tanggal calendar harus format YYYY-MM-DD.") from exc
2241
+
2242
+
2243
+ def _calendar_fallback_note(exc: FinCLIError, has_key: bool) -> str:
2244
+ if has_key:
2245
+ return (
2246
+ "FinCLI memakai fallback kategori event. "
2247
+ "Periksa API key, entitlement/plan Finnhub, atau rate-limit untuk data aktual."
2248
+ )
2249
+ return "FinCLI memakai fallback kategori event. Isi FINNHUB_API_KEY untuk data aktual."
2250
+
2251
+
2252
+ def _calendar_static_fallback_note(provider_error: FinCLIError, public_error: FinCLIError | None) -> str:
2253
+ _ = provider_error, public_error
2254
+ return (
2255
+ "Using static macro fallback. Finnhub calendar endpoint is unavailable for the current key/plan "
2256
+ "or provider rate limit, and public calendar fallback is temporarily unavailable. "
2257
+ "Check /provider key status, Finnhub calendar entitlement, and try again later."
2258
+ )
2259
+
2260
+
2261
+ def _format_calendar(events: list[EconomicEvent], start: date, end: date, source: str, note: str) -> Table:
2262
+ reliability = _calendar_reliability_status(events, source, note)
2263
+ quality = _calendar_data_quality(events, source, reliability)
2264
+ table = Table(
2265
+ title=f"Economic Calendar | {start.isoformat()} to {end.isoformat()} | {source} | {reliability}",
2266
+ expand=True,
2267
+ )
2268
+ table.add_column("Time", style="cyan", no_wrap=True, width=16, max_width=16)
2269
+ table.add_column("Country", no_wrap=True, width=7, max_width=7)
2270
+ table.add_column("Impact", no_wrap=True, width=6, max_width=6)
2271
+ table.add_column("Event", style="white", overflow="fold")
2272
+ table.add_column("Actual", justify="right", no_wrap=True, width=10, max_width=14)
2273
+ table.add_column("Forecast", justify="right", no_wrap=True, width=10, max_width=14)
2274
+ table.add_column("Prev", justify="right", no_wrap=True, width=10, max_width=14)
2275
+
2276
+ for event in events:
2277
+ event_time = event.time.isoformat(timespec="minutes") if event.time else "TBA"
2278
+ table.add_row(
2279
+ event_time,
2280
+ event.country,
2281
+ event.impact,
2282
+ event.event,
2283
+ event.actual or "-",
2284
+ event.estimate or "-",
2285
+ event.previous or "-",
2286
+ )
2287
+
2288
+ if not events:
2289
+ table.add_row("-", "-", "-", "Tidak ada event yang cocok dengan filter.", "-", "-", "-")
2290
+ summary = calendar_summary(events)
2291
+ table.add_row(
2292
+ "Summary",
2293
+ source,
2294
+ "-",
2295
+ f"total={summary['total']}; high={summary.get('high', 0)}; medium={summary.get('medium', 0)}; "
2296
+ f"low={summary.get('low', 0)}; reliability={reliability}",
2297
+ "-",
2298
+ "-",
2299
+ "-",
2300
+ )
2301
+ table.add_row("Note", source, "-", note, "-", "-", "-")
2302
+ table.caption = f"Data Quality: {quality.compact()}"
2303
+ return table
2304
+
2305
+
2306
+ def _calendar_reliability_status(events: list[EconomicEvent], source: str, note: str) -> str:
2307
+ normalized_source = source.lower()
2308
+ normalized_note = note.lower()
2309
+ if normalized_source == "finnhub" and events:
2310
+ return STATUS_OK
2311
+ if normalized_source == "fallback":
2312
+ return STATUS_SCHEDULE_ONLY
2313
+ if "static macro fallback" in normalized_note or "fallback kategori" in normalized_note:
2314
+ return STATUS_SCHEDULE_ONLY
2315
+ if events:
2316
+ return STATUS_PARTIAL_DATA
2317
+ return STATUS_UNAVAILABLE
2318
+
2319
+
2320
+ def _calendar_data_quality(events: list[EconomicEvent], source: str, reliability: str) -> DataQualityReport:
2321
+ score = 70 if events else 20
2322
+ if reliability == STATUS_OK:
2323
+ score = 90
2324
+ elif reliability == STATUS_SCHEDULE_ONLY:
2325
+ score = 45 if events else 25
2326
+ missing = () if reliability == STATUS_OK else ("actual", "estimate", "previous")
2327
+ tier = "strong" if score >= 85 else "usable" if score >= 65 else "partial" if score >= 40 else "weak"
2328
+ return DataQualityReport(
2329
+ score=score,
2330
+ quote="not_applicable",
2331
+ ohlcv="not_applicable",
2332
+ news="not_applicable",
2333
+ fundamentals=f"{len(events)} calendar event(s)",
2334
+ provider=source,
2335
+ tier=tier,
2336
+ freshness="calendar_window",
2337
+ reliability_status=reliability,
2338
+ missing_fields=missing,
2339
+ label=f"{tier} | {reliability}",
2340
+ )
2090
2341
 
2091
2342
 
2092
2343
  def _format_provider_list() -> Table:
@@ -2120,68 +2371,72 @@ def _format_provider_entitlements(items: list[ProviderEntitlement]) -> Table:
2120
2371
  return table
2121
2372
 
2122
2373
 
2123
- def _format_provider_key_status(manager: MarketProviderManager) -> Table:
2124
- table = Table(title="Market Provider API Key Status", expand=True)
2125
- table.add_column("Provider", style="cyan")
2126
- table.add_column("Key")
2127
- table.add_column("Status")
2374
+ def _format_provider_key_status(manager: MarketProviderManager) -> Table:
2375
+ table = Table(title="Market Provider API Key Status", expand=True)
2376
+ table.add_column("Provider", style="cyan")
2377
+ table.add_column("Key")
2378
+ table.add_column("Status")
2128
2379
  table.add_column("Source")
2129
2380
  for row in manager.key_status():
2130
- table.add_row(row["provider"], row["key"], row["status"], row["source"])
2131
- return table
2132
-
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:
2381
+ table.add_row(row["provider"], row["key"], row["status"], row["source"])
2382
+ return table
2383
+
2384
+
2385
+ def _format_secrets_status(secrets: dict[str, str]) -> Table:
2386
+ table = Table(title="Local Secrets Status", expand=True)
2387
+ table.add_column("Key", style="cyan", no_wrap=True)
2388
+ table.add_column("Status", no_wrap=True)
2389
+ table.add_column("Source")
2390
+ for key in sorted(secrets):
2391
+ table.add_row(key, "set", "~/.fincli/secrets.env")
2392
+ if not secrets:
2393
+ table.add_row("-", "empty", "No local secrets stored.")
2394
+ table.caption = "Values are never printed. Use /secrets clear before publishing screenshots or sharing a machine."
2395
+ return table
2396
+
2397
+
2398
+ def _format_provider_metrics(service: MarketDataService) -> Table:
2399
+ table = Table(title="Provider Metrics Dashboard", expand=True)
2400
+ table.add_column("Provider", style="cyan", no_wrap=True)
2401
+ table.add_column("Session Calls", justify="right")
2402
+ table.add_column("All-Time Calls", justify="right")
2403
+ table.add_column("Success Rate", justify="right")
2404
+ table.add_column("Avg Latency", justify="right")
2405
+ table.add_column("Fallback Count", justify="right")
2406
+ table.add_column("Error Count", justify="right")
2407
+ table.add_column("Circuit", no_wrap=True)
2408
+ table.add_column("Failure Streak", justify="right")
2409
+ table.add_column("Last Status", no_wrap=True)
2410
+
2411
+ metrics = service.provider_metrics_snapshot()
2412
+ persisted = service.metrics_store.snapshot() if getattr(service, "metrics_store", None) is not None else {}
2413
+ for provider in service.providers:
2414
+ name = provider.name
2415
+ metric = metrics.get(name)
2416
+ persisted_metric = persisted.get(name)
2417
+ if metric is None:
2418
+ table.add_row(name, "0", str(persisted_metric.calls if persisted_metric else 0), "0.00%", "0.00ms", "0", "0", "closed", "0", "not_called")
2419
+ continue
2420
+ table.add_row(
2421
+ name,
2422
+ str(metric.calls),
2423
+ str(persisted_metric.calls if persisted_metric else metric.calls),
2424
+ f"{metric.success_rate:.2f}%",
2425
+ f"{metric.avg_latency_ms:.2f}ms",
2426
+ str(metric.fallbacks),
2427
+ str(metric.errors),
2428
+ "open" if metric.circuit_open else "closed",
2429
+ str(metric.consecutive_failures),
2430
+ metric.last_status,
2431
+ )
2432
+ table.caption = (
2433
+ f"Active provider: {service.primary_provider.name}. "
2434
+ "Session metrics reset per run; all-time calls persist in local SQLite."
2435
+ )
2436
+ return table
2437
+
2438
+
2439
+ def _format_symbol_search(query: str, results: list[SymbolSearchResult]) -> Table:
2185
2440
  table = Table(title=f"Symbol Search: {query}", expand=True)
2186
2441
  table.add_column("Symbol", style="cyan", no_wrap=True)
2187
2442
  table.add_column("Name", overflow="fold")
@@ -2201,20 +2456,26 @@ def _format_symbol_search(query: str, results: list[SymbolSearchResult]) -> Tabl
2201
2456
  result.notes or "-",
2202
2457
  )
2203
2458
  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."
2459
+ table.add_row("-", "No local symbol match.", "-", "-", "-", "-", "Try /symbol resolve <symbol>.")
2460
+ table.caption = "Use /symbol resolve <symbol> to inspect provider-specific normalization for any symbol."
2206
2461
  return table
2207
2462
 
2208
2463
 
2209
- def _format_symbol_matrix(symbol: str) -> Table:
2210
- matrix = provider_symbol_matrix(symbol)
2464
+ def _format_symbol_matrix(
2465
+ symbol: str,
2466
+ resolver: SymbolResolver | None = None,
2467
+ asset_class: str | None = None,
2468
+ ) -> Table:
2469
+ matrix = (resolver or SymbolResolver()).matrix(symbol)
2211
2470
  table = Table(title=f"Provider Symbol Normalization: {symbol}", expand=True)
2212
2471
  table.add_column("Provider", style="cyan", no_wrap=True)
2213
2472
  table.add_column("Normalized Symbol", style="white")
2214
2473
  table.add_column("Asset Class", no_wrap=True)
2474
+ table.add_column("Confidence", no_wrap=True)
2215
2475
  table.add_column("Original", style="dim")
2216
2476
  for provider, resolved in matrix.items():
2217
- table.add_row(provider, resolved.symbol, resolved.asset_class, resolved.original)
2477
+ asset = asset_class or resolved.asset_class
2478
+ table.add_row(provider, resolved.symbol, asset, resolved.confidence, resolved.original)
2218
2479
  table.caption = "Normalization does not guarantee provider entitlement. Check /provider entitlement and provider plan."
2219
2480
  return table
2220
2481
 
@@ -2223,154 +2484,333 @@ def _provider_symbol_text(provider_symbols: dict[str, str]) -> str:
2223
2484
  return " | ".join(f"{provider}:{symbol}" for provider, symbol in provider_symbols.items())
2224
2485
 
2225
2486
 
2226
- def _market_provider_secret_keys(provider: str) -> tuple[str, ...]:
2227
- return {
2228
- "custom": ("MARKET_DATA_API_KEY", "MARKET_DATA_BASE_URL"),
2229
- "finnhub": ("FINNHUB_API_KEY",),
2230
- "twelvedata": ("TWELVE_DATA_API_KEY",),
2231
- "alphavantage": ("ALPHA_VANTAGE_API_KEY",),
2232
- }.get(provider.lower(), ())
2233
-
2234
-
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")
2243
- for row in rows:
2244
- table.add_row(row.name, row.region, row.value, row.period, row.source, row.note)
2245
- if not rows:
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."
2248
- return table
2249
-
2250
-
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.")
2266
- return table
2267
-
2268
-
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.")
2280
- return table
2281
-
2282
-
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",
2298
- )
2299
-
2300
-
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
2313
-
2314
-
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
-
2343
-
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.1; FinCLI does not execute plugin code yet."
2370
- return table
2371
-
2372
-
2373
- def _format_transactions(rows: list[dict[str, object]]) -> Table:
2487
+ def _market_provider_secret_keys(provider: str) -> tuple[str, ...]:
2488
+ return {
2489
+ "custom": ("MARKET_DATA_API_KEY", "MARKET_DATA_BASE_URL"),
2490
+ "finnhub": ("FINNHUB_API_KEY",),
2491
+ "twelvedata": ("TWELVE_DATA_API_KEY",),
2492
+ "alphavantage": ("ALPHA_VANTAGE_API_KEY",),
2493
+ }.get(provider.lower(), ())
2494
+
2495
+
2496
+ def _format_macro_dashboard(query: str, rows: list[MacroIndicator]) -> Table:
2497
+ table = Table(title=f"Macro Dashboard: {query.title()}", expand=True)
2498
+ table.add_column("Indicator", style="cyan", no_wrap=True)
2499
+ table.add_column("Region", no_wrap=True)
2500
+ table.add_column("Value", justify="right")
2501
+ table.add_column("Period", no_wrap=True)
2502
+ table.add_column("Source", no_wrap=True)
2503
+ table.add_column("Note", overflow="fold")
2504
+ for row in rows:
2505
+ table.add_row(row.name, row.region, row.value, row.period, row.source, row.note)
2506
+ if not rows:
2507
+ table.add_row("-", "-", "-", "-", "Fallback", "No macro rows matched the query.")
2508
+ table.caption = "Fallback rows are connector-ready placeholders. Use provider keys later for exact values."
2509
+ return table
2510
+
2511
+
2512
+ def _format_macro_indicator(indicator: str, region: str, rows: list[MacroIndicator]) -> Table:
2513
+ table = Table(title=f"Macro Indicator: {indicator.replace('_', ' ').title()} | {region.upper()}", expand=True)
2514
+ table.add_column("Period", style="cyan", no_wrap=True)
2515
+ table.add_column("Indicator", no_wrap=True)
2516
+ table.add_column("Region", no_wrap=True)
2517
+ table.add_column("Value", justify="right")
2518
+ table.add_column("Source", no_wrap=True)
2519
+ table.add_column("Note", overflow="fold")
2520
+ for row in rows:
2521
+ table.add_row(row.period, row.name, row.region, row.value, row.source, row.note)
2522
+ if not rows:
2523
+ table.add_row("-", indicator, region.upper(), "-", "Alpha Vantage", "No data returned.")
2524
+ table.caption = "Hidden macro alias. Verify releases with official sources."
2525
+ return table
2526
+
2527
+
2528
+ def _format_trading_overview(realtime: RealtimeConnectorCatalog, brokers: BrokerCatalog) -> Table:
2529
+ table = Table(title="Trading Layer v0.4.0 | Safe Execution Workspace", expand=True)
2530
+ table.add_column("Area", style="cyan", no_wrap=True)
2531
+ table.add_column("Status", no_wrap=True)
2532
+ table.add_column("Detail", overflow="fold")
2533
+ table.add_row("Real-Time Trading", "scaffold", f"{len(realtime.all())} realtime connector profile(s). Use /trading realtime.")
2534
+ table.add_row("Broker Integrations", "catalog", f"{len(brokers.all())} broker integration profile(s). Use /trading brokers.")
2535
+ table.add_row("Paper Trading", "active", "Local paper orders only. Use /trading paper buy AAPL 1 market 100.")
2536
+ table.add_row("Algo Trading", "paper-only", "Strategy execution is intentionally local/paper in v0.4.0.")
2537
+ table.add_row("Live Orders", "disabled", "No live broker orders are sent by FinCLI v0.4.0.")
2538
+ table.caption = "Trading features are simulation/catalog first. Configure broker adapters only after explicit live-trading safety work."
2539
+ return table
2540
+
2541
+
2542
+ def _format_realtime_connectors(connectors: tuple[RealtimeConnector, ...]) -> Table:
2543
+ table = Table(title="Real-Time Connector Catalog", expand=True)
2544
+ table.add_column("Connector", style="cyan", no_wrap=True)
2545
+ table.add_column("Transport", no_wrap=True)
2546
+ table.add_column("Assets", overflow="fold")
2547
+ table.add_column("Status", no_wrap=True)
2548
+ table.add_column("Note", overflow="fold")
2549
+ for connector in connectors:
2550
+ table.add_row(
2551
+ connector.name,
2552
+ connector.transport,
2553
+ ", ".join(connector.asset_classes),
2554
+ connector.status,
2555
+ connector.note,
2556
+ )
2557
+ return table
2558
+
2559
+
2560
+ def _format_brokers(brokers: tuple[BrokerIntegration, ...]) -> Table:
2561
+ table = Table(title="Broker Integration Catalog", expand=True)
2562
+ table.add_column("Broker", style="cyan", no_wrap=True)
2563
+ table.add_column("Region", no_wrap=True)
2564
+ table.add_column("Assets", overflow="fold")
2565
+ table.add_column("Mode", no_wrap=True)
2566
+ table.add_column("Note", overflow="fold")
2567
+ for broker in brokers:
2568
+ table.add_row(
2569
+ broker.name,
2570
+ broker.region,
2571
+ ", ".join(broker.asset_classes),
2572
+ broker.mode,
2573
+ broker.note,
2574
+ )
2575
+ table.caption = "Catalog entries are not live execution adapters yet unless explicitly marked and configured."
2576
+ return table
2577
+
2578
+
2579
+ def _format_paper_order(order: dict[str, object]) -> Table:
2580
+ table = Table(title="Paper Trading Order", expand=True)
2581
+ table.add_column("Field", style="cyan", no_wrap=True)
2582
+ table.add_column("Value")
2583
+ for key in ("side", "symbol", "quantity", "order_type", "price", "notional", "status", "strategy"):
2584
+ table.add_row(key, str(order.get(key, "-")))
2585
+ table.caption = "Paper trading only. No broker/live order was sent."
2586
+ return table
2587
+
2588
+
2589
+ def _format_paper_orders(orders: list[dict[str, object]]) -> Table:
2590
+ table = Table(title="Paper Trading Orders", expand=True)
2591
+ table.add_column("ID", justify="right")
2592
+ table.add_column("Side", no_wrap=True)
2593
+ table.add_column("Symbol", style="cyan", no_wrap=True)
2594
+ table.add_column("Qty", justify="right")
2595
+ table.add_column("Type", no_wrap=True)
2596
+ table.add_column("Price", justify="right")
2597
+ table.add_column("Notional", justify="right")
2598
+ table.add_column("Status", no_wrap=True)
2599
+ table.add_column("Created", no_wrap=True)
2600
+ for order in orders:
2601
+ table.add_row(
2602
+ str(order.get("id", "-")),
2603
+ str(order.get("side", "-")),
2604
+ str(order.get("symbol", "-")),
2605
+ _format_optional_number(order.get("quantity")),
2606
+ str(order.get("order_type", "-")),
2607
+ _format_optional_number(order.get("price")),
2608
+ _format_optional_number(order.get("notional")),
2609
+ str(order.get("status", "-")),
2610
+ str(order.get("created_at", "-")),
2611
+ )
2612
+ if not orders:
2613
+ table.add_row("-", "-", "-", "-", "-", "-", "-", "empty", "-")
2614
+ table.caption = "Paper trading orders are stored locally in SQLite."
2615
+ return table
2616
+
2617
+
2618
+ def _macro_error_row(indicator: str, region: str, exc: FinCLIError) -> MacroIndicator:
2619
+ label = indicator.replace("_", " ").title()
2620
+ help_text = f" {exc.help_text}" if getattr(exc, "help_text", None) else ""
2621
+ return MacroIndicator(
2622
+ name=label,
2623
+ region=region.upper(),
2624
+ value="unavailable",
2625
+ period=date.today().isoformat(),
2626
+ source="Alpha Vantage",
2627
+ note=f"{exc}{help_text}",
2628
+ )
2629
+
2630
+
2631
+ def _format_insider_transactions(symbol: str, rows: list[dict[str, object]]) -> Table:
2632
+ table = Table(title=f"Finnhub Insider Transactions: {symbol}", expand=True)
2633
+ table.add_column("Date", style="cyan", no_wrap=True)
2634
+ table.add_column("Name", overflow="fold")
2635
+ table.add_column("Code", no_wrap=True)
2636
+ table.add_column("Change", justify="right")
2637
+ table.add_column("Shares", justify="right")
2638
+ table.add_column("Price", justify="right")
2639
+ for row in rows:
2640
+ table.add_row(
2641
+ str(row.get("date") or "-"),
2642
+ str(row.get("name") or "-"),
2643
+ str(row.get("transaction_code") or "-"),
2644
+ _format_optional_number(row.get("change")),
2645
+ _format_optional_number(row.get("shares")),
2646
+ _format_optional_number(row.get("transaction_price")),
2647
+ )
2648
+ if not rows:
2649
+ table.add_row("-", "No insider transactions returned.", "-", "-", "-", "-")
2650
+ table.caption = "Finnhub endpoint availability depends on API key, plan, and symbol coverage."
2651
+ return table
2652
+
2653
+
2654
+ def _format_ipo_calendar(rows: list[dict[str, object]], start: date, end: date) -> Table:
2655
+ table = Table(title=f"Finnhub IPO Calendar | {start.isoformat()} to {end.isoformat()}", expand=True)
2656
+ table.add_column("Date", style="cyan", no_wrap=True)
2657
+ table.add_column("Symbol", no_wrap=True)
2658
+ table.add_column("Name", overflow="fold")
2659
+ table.add_column("Exchange", no_wrap=True)
2660
+ table.add_column("Price", justify="right")
2661
+ table.add_column("Shares", justify="right")
2662
+ table.add_column("Status", no_wrap=True)
2663
+ for row in rows:
2664
+ table.add_row(
2665
+ str(row.get("date") or "-"),
2666
+ str(row.get("symbol") or "-"),
2667
+ str(row.get("name") or "-"),
2668
+ str(row.get("exchange") or "-"),
2669
+ str(row.get("price") or "-"),
2670
+ _format_optional_number(row.get("shares")),
2671
+ str(row.get("status") or "-"),
2672
+ )
2673
+ if not rows:
2674
+ table.add_row("-", "-", "No IPOs returned for the selected window.", "-", "-", "-", "-")
2675
+ table.caption = "Finnhub endpoint availability depends on API key, plan, and date coverage."
2676
+ return table
2677
+
2678
+
2679
+ def _format_optional_number(value: object) -> str:
2680
+ if value is None:
2681
+ return "-"
2682
+ try:
2683
+ number = float(value)
2684
+ except (TypeError, ValueError):
2685
+ return str(value)
2686
+ if number.is_integer():
2687
+ return f"{number:,.0f}"
2688
+ return f"{number:,.2f}"
2689
+
2690
+
2691
+ def _format_user_profile(profile: UserProfile | None) -> Table:
2692
+ table = Table(title="User Gameplay Profile", expand=True)
2693
+ table.add_column("Field", style="cyan", no_wrap=True)
2694
+ table.add_column("Value", overflow="fold")
2695
+ if profile is None:
2696
+ table.add_row("Status", "Not configured")
2697
+ table.add_row("Setup", '/profile set "Nama" <equity> <currency> <leverage> <years>')
2698
+ table.add_row("Use", "Profile is used by /analyze for SL/TP and risk-context wording.")
2699
+ return table
2700
+ table.add_row("Name", profile.name)
2701
+ table.add_row("Equity", f"{profile.equity:g} {profile.currency}")
2702
+ table.add_row("Leverage", profile.leverage)
2703
+ table.add_row("Investment Years", f"{profile.years_in_investment:g}")
2704
+ table.add_row("Gameplay", profile.gameplay)
2705
+ table.add_row("Analyze Usage", "Used by /analyze to constrain Signal, SL, TP1, TP2, TP3, and Reason.")
2706
+ return table
2707
+
2708
+
2709
+ def _format_agents(agents: list[Agent], label: str) -> Table:
2710
+ table = Table(title=f"FinCLI Agents: {label}", expand=True)
2711
+ table.add_column("Slug", style="cyan", no_wrap=True)
2712
+ table.add_column("Name", no_wrap=True)
2713
+ table.add_column("Category", no_wrap=True)
2714
+ table.add_column("Framework", overflow="fold")
2715
+ table.add_column("Role", overflow="fold")
2716
+ for agent in agents:
2717
+ table.add_row(agent.slug, agent.name, agent.category, agent.framework, agent.role)
2718
+ if not agents:
2719
+ table.add_row("-", "-", "-", "-", "No agents matched.")
2720
+ return table
2721
+
2722
+
2723
+ def _format_agent(agent: Agent) -> Panel:
2724
+ return Panel(
2725
+ "\n".join(
2726
+ [
2727
+ f"Name : {agent.name}",
2728
+ f"Slug : {agent.slug}",
2729
+ f"Category : {agent.category}",
2730
+ f"Framework : {agent.framework}",
2731
+ f"Role : {agent.role}",
2732
+ "",
2733
+ "Usage : use as a thinking lens for /research and future multi-agent analysis.",
2734
+ ]
2735
+ ),
2736
+ title="FinCLI Agent",
2737
+ border_style="cyan",
2738
+ )
2739
+
2740
+
2741
+ def _format_connectors(connectors: list[Connector], label: str) -> Table:
2742
+ table = Table(title=f"Connector Catalog: {label}", expand=True)
2743
+ table.add_column("Name", style="cyan", no_wrap=True)
2744
+ table.add_column("Category", no_wrap=True)
2745
+ table.add_column("Access", no_wrap=True)
2746
+ table.add_column("Coverage", overflow="fold")
2747
+ for connector in connectors:
2748
+ table.add_row(connector.name, connector.category, connector.access, connector.coverage)
2749
+ if not connectors:
2750
+ table.add_row("-", "-", "-", "No connectors matched.")
2751
+ table.caption = "Catalog entries are roadmap-ready; active adapters depend on implementation and entitlement."
2752
+ return table
2753
+
2754
+
2755
+ def _format_news_connectors(connectors: list[NewsConnectorSpec], label: str) -> Table:
2756
+ table = Table(title=f"News Connector Catalog: {label}", expand=True)
2757
+ table.add_column("Slug", style="cyan", no_wrap=True)
2758
+ table.add_column("Name", overflow="fold")
2759
+ table.add_column("Access", no_wrap=True)
2760
+ table.add_column("Category", no_wrap=True)
2761
+ table.add_column("API Key", no_wrap=True)
2762
+ table.add_column("Status", overflow="fold")
2763
+ for connector in connectors:
2764
+ status = "active rss" if connector.access == "public-rss" else "api-key ready"
2765
+ if connector.slug == "custom_news":
2766
+ status = "custom endpoint"
2767
+ table.add_row(
2768
+ connector.slug,
2769
+ connector.name,
2770
+ connector.access,
2771
+ connector.category,
2772
+ connector.env_key or "-",
2773
+ status,
2774
+ )
2775
+ if not connectors:
2776
+ table.add_row("-", "No news connectors matched.", "-", "-", "-", "-")
2777
+ table.caption = (
2778
+ "Use /news_model use <slug> for primary, /news_model priority a,b,c for fallback order, "
2779
+ "and /news_model key <slug> <api_key> for API-key providers."
2780
+ )
2781
+ return table
2782
+
2783
+
2784
+ def _format_plugins(plugins: list[PluginManifest], status_only: bool = False) -> Table:
2785
+ table = Table(title="FinCLI Plugins" if not status_only else "FinCLI Plugin Status", expand=True)
2786
+ table.add_column("Name", style="cyan", no_wrap=True)
2787
+ table.add_column("Version", no_wrap=True)
2788
+ table.add_column("Status", no_wrap=True)
2789
+ table.add_column("Capabilities", overflow="fold")
2790
+ table.add_column("Commands", overflow="fold")
2791
+ if not status_only:
2792
+ table.add_column("Description", overflow="fold")
2793
+ for plugin in plugins:
2794
+ row = [
2795
+ plugin.name,
2796
+ plugin.version,
2797
+ plugin.status,
2798
+ ", ".join(plugin.capabilities) or "-",
2799
+ ", ".join(plugin.commands) or "-",
2800
+ ]
2801
+ if not status_only:
2802
+ row.append(plugin.description or "-")
2803
+ table.add_row(*row)
2804
+ if not plugins:
2805
+ empty = ["-", "-", "no plugins", "-", "-"]
2806
+ if not status_only:
2807
+ empty.append("Create ~/.fincli/plugins/<name>/plugin.json to register a local plugin.")
2808
+ table.add_row(*empty)
2809
+ table.caption = "Plugins are manifest-only in v0.4.0; FinCLI does not execute plugin code yet."
2810
+ return table
2811
+
2812
+
2813
+ def _format_transactions(rows: list[dict[str, object]]) -> Table:
2374
2814
  table = Table(title="Transaction Ledger", expand=True)
2375
2815
  table.add_column("ID", justify="right")
2376
2816
  table.add_column("Action")
@@ -2408,7 +2848,7 @@ def _format_journal_stats(stats: JournalStats) -> Table:
2408
2848
  return table
2409
2849
 
2410
2850
 
2411
- def _format_news(symbol: str, items: list[NewsItem]) -> str:
2851
+ def _format_news(symbol: str, items: list[NewsItem]) -> str:
2412
2852
  if not items:
2413
2853
  return f"News: {symbol}\nBelum ada news dari provider aktif."
2414
2854
  lines = [f"News: {symbol}"]
@@ -2417,67 +2857,96 @@ def _format_news(symbol: str, items: list[NewsItem]) -> str:
2417
2857
  url = f"\n URL: {item.url}" if item.url else ""
2418
2858
  summary = f"\n Summary: {item.summary}" if item.summary else ""
2419
2859
  lines.append(f"{index}. {item.title}\n Source: {item.source} | Published: {published}{summary}{url}")
2420
- return "\n".join(lines)
2421
-
2422
-
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}"
2440
- )
2441
- return table
2442
-
2443
-
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")
2465
-
2466
-
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
2473
-
2474
- published = published.replace(tzinfo=timezone.utc)
2475
- from datetime import datetime, timezone
2476
-
2477
- return max((datetime.now(timezone.utc) - published).days, 0)
2478
-
2479
-
2480
- def _format_web_results(query: str, results: list[WebSearchResult]) -> Table:
2860
+ return "\n".join(lines)
2861
+
2862
+
2863
+ def _format_news_desk(desk: NewsDesk) -> Table:
2864
+ table = Table(title=f"News Desk: {desk.symbol}", expand=True)
2865
+ table.add_column("Time", style="dim", no_wrap=True)
2866
+ table.add_column("Source", style="cyan", no_wrap=True)
2867
+ table.add_column("Headline", style="white", overflow="fold")
2868
+ table.add_column("Summary", overflow="fold")
2869
+ table.add_column("Analysis", overflow="fold")
2870
+ for item in desk.items:
2871
+ published = item.published_at.isoformat(timespec="minutes") if item.published_at else "unknown"
2872
+ table.add_row(published, item.source, item.title, item.summary or "-", _news_item_analysis(item))
2873
+ if not desk.items:
2874
+ table.add_row("-", "-", "No news from active providers.", desk.note, "-")
2875
+ lookback = f" | Lookback: {desk.lookback_days}d" if desk.lookback_days else ""
2876
+ quality = _news_data_quality(desk)
2877
+ errors = f" | Errors: {len(desk.errors)}" if desk.errors else ""
2878
+ table.caption = (
2879
+ f"Providers: {', '.join(desk.provider_chain)}{lookback} | "
2880
+ f"Reliability: {desk.reliability_status}{errors} | Data Quality: {quality.compact()} | {desk.note}"
2881
+ )
2882
+ return table
2883
+
2884
+
2885
+ def _news_item_analysis(item: NewsItem) -> str:
2886
+ text = f"{item.title} {item.summary}".lower()
2887
+ bullish_words = ("beat", "beats", "rise", "rises", "rally", "rallies", "higher", "growth", "upgrade", "bullish", "record")
2888
+ bearish_words = ("miss", "falls", "falling", "lower", "sink", "sinks", "down", "cut", "downgrade", "bearish", "weak")
2889
+ caution_words = ("risk", "uncertain", "probe", "lawsuit", "volatility", "warning", "recall", "delay")
2890
+ bullish = sum(1 for word in bullish_words if word in text)
2891
+ bearish = sum(1 for word in bearish_words if word in text)
2892
+ caution = sum(1 for word in caution_words if word in text)
2893
+ if caution and caution >= max(bullish, bearish):
2894
+ bias = "caution"
2895
+ elif bullish > bearish:
2896
+ bias = "bullish"
2897
+ elif bearish > bullish:
2898
+ bias = "bearish"
2899
+ else:
2900
+ bias = "neutral"
2901
+ if item.published_at is None:
2902
+ freshness = "date unknown"
2903
+ else:
2904
+ freshness = "fresh" if _news_age_days(item) <= 3 else "older context"
2905
+ return semantic_text(f"{bias} | {freshness} | verify source before trading")
2906
+
2907
+
2908
+ def _news_age_days(item: NewsItem) -> int:
2909
+ if item.published_at is None:
2910
+ return 999
2911
+ published = item.published_at
2912
+ if published.tzinfo is None:
2913
+ from datetime import timezone
2914
+
2915
+ published = published.replace(tzinfo=timezone.utc)
2916
+ from datetime import datetime, timezone
2917
+
2918
+ return max((datetime.now(timezone.utc) - published).days, 0)
2919
+
2920
+
2921
+ def _news_data_quality(desk: NewsDesk) -> DataQualityReport:
2922
+ item_count = len(desk.items)
2923
+ score = 20
2924
+ if item_count >= 8:
2925
+ score = 85
2926
+ elif item_count >= 3:
2927
+ score = 70
2928
+ elif item_count >= 1:
2929
+ score = 55
2930
+ if desk.errors:
2931
+ score = max(20, score - min(30, len(desk.errors) * 10))
2932
+ missing = () if item_count else ("news",)
2933
+ tier = "strong" if score >= 85 else "usable" if score >= 65 else "partial" if score >= 40 else "weak"
2934
+ return DataQualityReport(
2935
+ score=score,
2936
+ quote="not_applicable",
2937
+ ohlcv="not_applicable",
2938
+ news=f"{item_count} item(s)",
2939
+ fundamentals="not_applicable",
2940
+ provider=", ".join(desk.provider_chain) or "unknown",
2941
+ tier=tier,
2942
+ freshness=f"{desk.lookback_days or 'latest'}d",
2943
+ reliability_status=desk.reliability_status,
2944
+ missing_fields=missing,
2945
+ label=f"{tier} | {desk.reliability_status}",
2946
+ )
2947
+
2948
+
2949
+ def _format_web_results(query: str, results: list[WebSearchResult]) -> Table:
2481
2950
  table = Table(title=f"Web Research: {query}", expand=True)
2482
2951
  table.add_column("#", justify="right", width=3)
2483
2952
  table.add_column("Title", style="cyan", overflow="fold")
@@ -2568,6 +3037,65 @@ def _split_command(raw: str) -> list[str]:
2568
3037
  return parts
2569
3038
 
2570
3039
 
3040
+ def _doctor_live_symbol(args: list[str]) -> str:
3041
+ lowered = [arg.lower() for arg in args]
3042
+ if "--live" not in lowered:
3043
+ return "AAPL"
3044
+ index = lowered.index("--live")
3045
+ if len(args) > index + 1 and not args[index + 1].startswith("--"):
3046
+ return args[index + 1].upper()
3047
+ return "AAPL"
3048
+
3049
+
3050
+ def _router_roots() -> set[str]:
3051
+ """Return slash command roots directly handled by CommandRouter."""
3052
+
3053
+ return {
3054
+ "/agent",
3055
+ "/ai",
3056
+ "/ai_model",
3057
+ "/alert",
3058
+ "/analyze",
3059
+ "/backtest",
3060
+ "/cache",
3061
+ "/calendar",
3062
+ "/clear",
3063
+ "/config",
3064
+ "/connector",
3065
+ "/dashboard",
3066
+ "/doctor",
3067
+ "/exit",
3068
+ "/export",
3069
+ "/funda",
3070
+ "/help",
3071
+ "/history",
3072
+ "/journal",
3073
+ "/macro",
3074
+ "/market",
3075
+ "/mtf",
3076
+ "/news",
3077
+ "/news_model",
3078
+ "/plugin",
3079
+ "/portfolio",
3080
+ "/privacy",
3081
+ "/profile",
3082
+ "/provider",
3083
+ "/quote",
3084
+ "/report",
3085
+ "/research",
3086
+ "/scan",
3087
+ "/secrets",
3088
+ "/setup",
3089
+ "/structure",
3090
+ "/symbol",
3091
+ "/technical",
3092
+ "/tx",
3093
+ "/watchlist",
3094
+ "/web",
3095
+ "/yahoo",
3096
+ }
3097
+
3098
+
2571
3099
  def _strip_wrapping_quotes(value: str) -> str:
2572
3100
  if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
2573
3101
  return value[1:-1]