@drico2008/fincli 0.1.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 (69) hide show
  1. package/README.md +644 -0
  2. package/fincli/__init__.py +3 -0
  3. package/fincli/app/__init__.py +1 -0
  4. package/fincli/app/analysis/__init__.py +1 -0
  5. package/fincli/app/analysis/ai_prompts.py +33 -0
  6. package/fincli/app/analysis/analyzer.py +119 -0
  7. package/fincli/app/analysis/assistant_context.py +161 -0
  8. package/fincli/app/analysis/indicators.py +143 -0
  9. package/fincli/app/analysis/market_structure.py +106 -0
  10. package/fincli/app/analysis/technical_debate.py +251 -0
  11. package/fincli/app/analysis/technical_signal.py +203 -0
  12. package/fincli/app/cli/__init__.py +1 -0
  13. package/fincli/app/cli/autocomplete.py +17 -0
  14. package/fincli/app/cli/commands.py +82 -0
  15. package/fincli/app/cli/router.py +1257 -0
  16. package/fincli/app/main.py +16 -0
  17. package/fincli/app/modules/__init__.py +1 -0
  18. package/fincli/app/modules/economic_calendar.py +139 -0
  19. package/fincli/app/modules/exporter.py +51 -0
  20. package/fincli/app/modules/journal.py +65 -0
  21. package/fincli/app/modules/journal_analytics.py +70 -0
  22. package/fincli/app/modules/portfolio.py +34 -0
  23. package/fincli/app/modules/scanner.py +105 -0
  24. package/fincli/app/modules/transactions.py +84 -0
  25. package/fincli/app/modules/watchlist.py +25 -0
  26. package/fincli/app/providers/__init__.py +1 -0
  27. package/fincli/app/providers/ai/__init__.py +1 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +11 -0
  29. package/fincli/app/providers/ai/base.py +29 -0
  30. package/fincli/app/providers/ai/gemini_provider.py +11 -0
  31. package/fincli/app/providers/ai/groq_provider.py +11 -0
  32. package/fincli/app/providers/ai/http_provider.py +145 -0
  33. package/fincli/app/providers/ai/huggingface_provider.py +11 -0
  34. package/fincli/app/providers/ai/manager.py +60 -0
  35. package/fincli/app/providers/ai/openai_provider.py +11 -0
  36. package/fincli/app/providers/ai/openrouter_provider.py +11 -0
  37. package/fincli/app/providers/ai/together_provider.py +11 -0
  38. package/fincli/app/providers/market/__init__.py +1 -0
  39. package/fincli/app/providers/market/base.py +77 -0
  40. package/fincli/app/providers/market/custom_provider.py +169 -0
  41. package/fincli/app/providers/market/finnhub_provider.py +187 -0
  42. package/fincli/app/providers/market/manager.py +123 -0
  43. package/fincli/app/providers/market/news_provider.py +28 -0
  44. package/fincli/app/providers/market/symbols.py +182 -0
  45. package/fincli/app/providers/market/twelvedata_provider.py +167 -0
  46. package/fincli/app/providers/market/yfinance_provider.py +447 -0
  47. package/fincli/app/services/__init__.py +1 -0
  48. package/fincli/app/services/market_data.py +203 -0
  49. package/fincli/app/services/market_overview.py +111 -0
  50. package/fincli/app/storage/__init__.py +1 -0
  51. package/fincli/app/storage/cache.py +38 -0
  52. package/fincli/app/storage/config.py +114 -0
  53. package/fincli/app/storage/database.py +101 -0
  54. package/fincli/app/storage/market_cache.py +92 -0
  55. package/fincli/app/tui/__init__.py +1 -0
  56. package/fincli/app/tui/components.py +55 -0
  57. package/fincli/app/tui/layout.py +261 -0
  58. package/fincli/app/tui/market_provider_selector.py +267 -0
  59. package/fincli/app/tui/model_selector.py +412 -0
  60. package/fincli/app/tui/theme.py +157 -0
  61. package/fincli/app/utils/__init__.py +1 -0
  62. package/fincli/app/utils/errors.py +33 -0
  63. package/fincli/app/utils/formatting.py +17 -0
  64. package/fincli/app/utils/logger.py +19 -0
  65. package/npm/bin/fincli.js +35 -0
  66. package/npm/postinstall.js +72 -0
  67. package/package.json +23 -0
  68. package/pyproject.toml +31 -0
  69. package/requirements.txt +9 -0
@@ -0,0 +1,1257 @@
1
+ """Command parsing and routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass
8
+ from datetime import date
9
+ import os
10
+ import shlex
11
+ from typing import Any
12
+
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+
16
+ from fincli.app.cli.commands import CommandRegistry
17
+ from fincli.app.analysis.analyzer import build_market_analysis_prompt, build_technical_ai_summary
18
+ from fincli.app.analysis.assistant_context import (
19
+ build_fincli_assistant_prompt,
20
+ coding_refusal,
21
+ extract_market_symbols,
22
+ is_coding_request,
23
+ )
24
+ from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
25
+ from fincli.app.analysis.market_structure import MarketStructureSummary, analyze_market_structure
26
+ from fincli.app.analysis.technical_debate import TechnicalDebate, format_debate, run_technical_debate
27
+ from fincli.app.analysis.technical_signal import TechnicalSignal, format_signal
28
+ from fincli.app.modules.economic_calendar import (
29
+ EconomicCalendarService,
30
+ EconomicEvent,
31
+ default_calendar_window,
32
+ fallback_events,
33
+ filter_events,
34
+ )
35
+ from fincli.app.modules.exporter import export_rows
36
+ from fincli.app.modules.journal_analytics import JournalStats, build_journal_review_prompt, calculate_journal_stats
37
+ from fincli.app.modules.journal import JournalService
38
+ from fincli.app.modules.portfolio import PortfolioService
39
+ from fincli.app.modules.scanner import ScanResult, scan_symbols
40
+ from fincli.app.modules.transactions import TransactionService
41
+ from fincli.app.modules.watchlist import WatchlistService
42
+ from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
43
+ from fincli.app.providers.ai.manager import AIProviderManager
44
+ from fincli.app.providers.market.base import BaseMarketProvider, FundamentalSnapshot, NewsItem, Quote
45
+ from fincli.app.providers.market.manager import MarketProviderManager
46
+ from fincli.app.providers.market.yfinance_provider import YahooTable, YFinanceProvider
47
+ from fincli.app.services.market_data import MarketDataService
48
+ from fincli.app.services.market_overview import MarketOverview, build_market_overview
49
+ from fincli.app.storage.cache import TTLCache
50
+ from fincli.app.storage.config import ConfigManager
51
+ from fincli.app.storage.database import FinCLIDatabase
52
+ from fincli.app.storage.market_cache import MarketCache
53
+ from fincli.app.utils.errors import CommandError, FinCLIError
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class CommandResult:
58
+ renderable: Any
59
+ status: str = "ready"
60
+ clear: bool = False
61
+ should_exit: bool = False
62
+
63
+
64
+ class CommandRouter:
65
+ """Route slash commands to services."""
66
+
67
+ def __init__(
68
+ self,
69
+ config: ConfigManager | None = None,
70
+ db: FinCLIDatabase | None = None,
71
+ registry: CommandRegistry | None = None,
72
+ market_provider: BaseMarketProvider | None = None,
73
+ ai_provider: BaseAIProvider | None = None,
74
+ ) -> None:
75
+ self.config = config or ConfigManager()
76
+ self.db = db or FinCLIDatabase()
77
+ self.registry = registry or CommandRegistry()
78
+ self.cache: TTLCache[object] = TTLCache(self.config.settings.cache_ttl_seconds)
79
+ self.market_cache = MarketCache(self.db)
80
+ self.market_manager = MarketProviderManager()
81
+ self.market_service = self._build_market_service(market_provider)
82
+ self.market_provider = self.market_service.primary_provider
83
+ self.ai_provider = ai_provider or AIProviderManager().create(self.config.settings.ai_provider)
84
+ self.watchlist = WatchlistService(self.db)
85
+ self.portfolio = PortfolioService(self.db)
86
+ self.transactions = TransactionService(self.db, self.portfolio)
87
+ self.journal = JournalService(self.db)
88
+
89
+ def route(self, raw: str) -> CommandResult:
90
+ raw = raw.strip()
91
+ if not raw:
92
+ return CommandResult(Panel("Ketik /help untuk melihat command.", title="FinCLI"))
93
+ if not raw.startswith("/"):
94
+ return CommandResult(Panel("Command harus diawali slash. Contoh: /help", title="Invalid Input"))
95
+
96
+ try:
97
+ if raw.lower().startswith("/export "):
98
+ export_parts = raw.split(maxsplit=3)
99
+ if len(export_parts) == 4:
100
+ return self._export(export_parts[1:])
101
+
102
+ parts = shlex.split(raw)
103
+ if not parts:
104
+ raise CommandError("Command kosong.")
105
+
106
+ root = parts[0].lower()
107
+ args = parts[1:]
108
+
109
+ if root == "/help":
110
+ return CommandResult(self._help_table())
111
+ if root == "/dashboard":
112
+ return CommandResult(self._dashboard())
113
+ if root == "/clear":
114
+ return CommandResult("", clear=True)
115
+ if root == "/exit":
116
+ return CommandResult("Keluar dari FinCLI.", should_exit=True)
117
+ if root == "/config":
118
+ return CommandResult(self._config_panel())
119
+ if root == "/ai_model":
120
+ return self._ai_model(args)
121
+ if root == "/news_model":
122
+ return self._news_model(args)
123
+ if root == "/provider":
124
+ return self._provider(args)
125
+ if root == "/cache":
126
+ return self._cache(args)
127
+ if root == "/watchlist":
128
+ return self._watchlist(args)
129
+ if root == "/portfolio":
130
+ return self._portfolio(args)
131
+ if root == "/tx":
132
+ return self._tx(args)
133
+ if root == "/journal":
134
+ return self._journal(args)
135
+ if root in {"/price", "/quote"}:
136
+ return self._price(args)
137
+ if root == "/market":
138
+ return self._market(args)
139
+ if root == "/technical":
140
+ return self._technical(args)
141
+ if root == "/structure":
142
+ return self._structure(args)
143
+ if root == "/news":
144
+ return self._news(args)
145
+ if root == "/funda":
146
+ return self._fundamentals(args)
147
+ if root == "/yahoo":
148
+ return self._yahoo(args)
149
+ if root == "/ai":
150
+ return self._ai(args)
151
+ if root == "/analyze":
152
+ return self._analyze(args)
153
+ if root == "/scan":
154
+ return self._scan(args)
155
+ if root == "/calendar":
156
+ return self._calendar(args)
157
+ if root == "/export":
158
+ return self._export(args)
159
+
160
+ raise CommandError(f"Command tidak dikenal: {root}", "Gunakan /help untuk melihat daftar command.")
161
+ except FinCLIError as exc:
162
+ message = str(exc)
163
+ if exc.help_text:
164
+ message = f"{message}\n\n{exc.help_text}"
165
+ return CommandResult(Panel(message, title="Error", border_style="red"), status="error")
166
+ except ValueError as exc:
167
+ return CommandResult(
168
+ Panel(f"Format command tidak valid: {exc}\nGunakan quote untuk teks panjang.", title="Error"),
169
+ status="error",
170
+ )
171
+
172
+ def _help_table(self) -> Table:
173
+ table = Table(title="FinCLI v0.1 Commands", expand=True)
174
+ table.add_column("Command", style="cyan", no_wrap=True)
175
+ table.add_column("Group", style="magenta")
176
+ table.add_column("Fungsi", style="white")
177
+ table.add_column("Contoh", style="green")
178
+ for command in self.registry.all():
179
+ table.add_row(command.name, command.group, command.description, command.example)
180
+ return table
181
+
182
+ def _dashboard(self) -> Table:
183
+ return _format_dashboard(
184
+ provider_chain=[provider.name for provider in self.market_service.providers],
185
+ watchlist_rows=self.watchlist.list(),
186
+ portfolio_rows=self.portfolio.list(),
187
+ journal_stats=calculate_journal_stats(self.journal.list(limit=10_000)),
188
+ realized_pnl=self.transactions.realized_pnl_total(),
189
+ quote_getter=self._safe_quote,
190
+ portfolio_value_getter=self._portfolio_market_values,
191
+ )
192
+
193
+ def _config_panel(self) -> Panel:
194
+ safe = self.config.settings.safe_dict()
195
+ lines = [
196
+ f"AI provider : {safe['ai_provider']}",
197
+ f"AI model : {safe['ai_model']}",
198
+ f"Market provider : {safe['market_provider']}",
199
+ f"News provider : {safe['news_provider']}",
200
+ f"Timezone : {safe['timezone']}",
201
+ f"Default currency : {safe['default_currency']}",
202
+ f"Cache TTL : {safe['cache_ttl_seconds']}s",
203
+ f"Theme : {safe['theme']}",
204
+ "",
205
+ "API key status:",
206
+ ]
207
+ lines.extend(f"- {key}: {value}" for key, value in safe["api_keys"].items())
208
+ return Panel("\n".join(lines), title="Active Config", border_style="cyan")
209
+
210
+ def _ai_model(self, args: list[str]) -> CommandResult:
211
+ if len(args) == 0:
212
+ current = self.config.settings
213
+ return CommandResult(Panel(f"{current.ai_provider} / {current.ai_model}", title="Active AI Model"))
214
+ if len(args) < 2:
215
+ raise CommandError("Format: /ai_model <provider> <model>")
216
+ self.config.set_ai_model(args[0], args[1])
217
+ self.ai_provider = AIProviderManager().create(args[0])
218
+ return CommandResult(Panel(f"AI model aktif: {args[0]} / {args[1]}", title="AI Model Updated"))
219
+
220
+ def _news_model(self, args: list[str]) -> CommandResult:
221
+ if len(args) == 0:
222
+ current = self.config.settings
223
+ chain = ", ".join(current.market_provider_priority or [current.market_provider])
224
+ return CommandResult(
225
+ Panel(
226
+ (
227
+ f"Market: {current.market_provider}\n"
228
+ f"News: {current.news_provider}\n"
229
+ f"Fallback priority: {chain}\n\n"
230
+ "Di TUI, gunakan /news_model untuk membuka provider selector."
231
+ ),
232
+ title="Active Data Provider",
233
+ )
234
+ )
235
+ self.config.set_market_provider(args[0])
236
+ self.config.set_news_provider(args[0])
237
+ self.config.set_market_provider_priority([args[0], *self._priority_tail(args[0])])
238
+ self._refresh_market_service()
239
+ self.cache.clear()
240
+ return CommandResult(Panel(f"Provider market/news aktif: {args[0]}", title="Provider Updated"))
241
+
242
+ def _provider(self, args: list[str]) -> CommandResult:
243
+ if args and args[0].lower() == "list":
244
+ return CommandResult(_format_provider_list())
245
+ if args and args[0].lower() == "key" and len(args) >= 2 and args[1].lower() == "status":
246
+ return CommandResult(_format_provider_key_status(self.market_manager))
247
+ if args and args[0].lower() == "use":
248
+ if len(args) < 2:
249
+ raise CommandError("Format: /provider use <provider>")
250
+ provider = args[1].lower()
251
+ self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
252
+ self._refresh_market_service()
253
+ self.cache.clear()
254
+ return CommandResult(Panel(f"Provider market aktif: {provider}", title="Provider Updated"))
255
+ if args and args[0].lower() == "priority":
256
+ if len(args) < 2:
257
+ raise CommandError("Format: /provider priority finnhub,yfinance")
258
+ providers = [provider.strip() for provider in args[1].split(",") if provider.strip()]
259
+ self.config.set_market_provider_priority(providers)
260
+ self._refresh_market_service()
261
+ self.cache.clear()
262
+ return CommandResult(Panel(f"Provider priority: {', '.join(providers)}", title="Provider Priority"))
263
+ if args and args[0].lower() == "status":
264
+ settings = self.config.settings
265
+ provider_status = self._provider_health_text()
266
+ text = (
267
+ f"Market provider: {settings.market_provider} (active: {self.market_provider.name})\n"
268
+ f"News provider : {settings.news_provider} (active: {self.market_provider.name} fallback)\n"
269
+ f"Provider chain : {', '.join(provider.name for provider in self.market_service.providers)}\n"
270
+ f"AI provider : {settings.ai_provider} (active: {self.ai_provider.name})\n"
271
+ f"{provider_status}"
272
+ )
273
+ return CommandResult(Panel(text, title="Provider Status", border_style="yellow"))
274
+ if args and args[0].lower() == "test":
275
+ if len(args) < 2:
276
+ raise CommandError("Format: /provider test [provider] <symbol>")
277
+ if len(args) >= 3:
278
+ provider = self.market_manager.create(args[1])
279
+ quote = self.market_service.run(provider.quote(args[2]))
280
+ else:
281
+ quote = self._get_quote(args[1])
282
+ return CommandResult(_format_quote(quote))
283
+ raise CommandError(
284
+ "Format: /provider status, /provider list, /provider key status, /provider use <provider>, "
285
+ "/provider priority finnhub,yfinance, atau /provider test [provider] <symbol>"
286
+ )
287
+
288
+ def _cache(self, args: list[str]) -> CommandResult:
289
+ if args and args[0].lower() == "stats":
290
+ stats = self.market_cache.stats()
291
+ lines = [
292
+ f"Runtime cache TTL : {self.config.settings.cache_ttl_seconds}s",
293
+ f"Persistent entries: {stats['total']}",
294
+ f"- quote : {stats['quote']}",
295
+ f"- history : {stats['history']}",
296
+ f"- news : {stats['news']}",
297
+ f"- fundamentals : {stats['fundamentals']}",
298
+ ]
299
+ return CommandResult(Panel("\n".join(lines), title="Cache Stats", border_style="cyan"))
300
+ if args and args[0].lower() == "clear":
301
+ self.cache.clear()
302
+ cleared = self.market_cache.clear()
303
+ return CommandResult(Panel(f"Runtime cache dan persistent cache dibersihkan ({cleared} entry).", title="Cache"))
304
+ raise CommandError("Format: /cache clear atau /cache stats")
305
+
306
+ def _watchlist(self, args: list[str]) -> CommandResult:
307
+ if not args:
308
+ rows = self.watchlist.list()
309
+ table = Table(title="Watchlist", expand=True)
310
+ table.add_column("Symbol", style="cyan")
311
+ table.add_column("Price", justify="right")
312
+ table.add_column("Currency")
313
+ table.add_column("Status")
314
+ table.add_column("Group")
315
+ table.add_column("Created")
316
+ for row in rows:
317
+ quote = self._safe_quote(str(row["symbol"]))
318
+ table.add_row(
319
+ str(row["symbol"]),
320
+ _fmt(quote.price) if quote else "N/A",
321
+ quote.currency if quote else "-",
322
+ quote.status if quote else "unavailable",
323
+ str(row["group_name"]),
324
+ str(row["created_at"]),
325
+ )
326
+ if not rows:
327
+ table.add_row("-", "-", "-", "-", "Belum ada data. Gunakan /watchlist add AAPL", "-")
328
+ return CommandResult(table)
329
+
330
+ action = args[0].lower()
331
+ if action == "add" and len(args) >= 2:
332
+ self.watchlist.add(args[1], args[2] if len(args) >= 3 else "default")
333
+ return CommandResult(Panel(f"{args[1].upper()} ditambahkan ke watchlist.", title="Watchlist"))
334
+ if action == "remove" and len(args) >= 2:
335
+ self.watchlist.remove(args[1])
336
+ return CommandResult(Panel(f"{args[1].upper()} dihapus dari watchlist.", title="Watchlist"))
337
+ raise CommandError("Format: /watchlist, /watchlist add <symbol>, /watchlist remove <symbol>")
338
+
339
+ def _portfolio(self, args: list[str]) -> CommandResult:
340
+ if not args:
341
+ rows = self.portfolio.list()
342
+ table = Table(title="Portfolio", expand=True)
343
+ table.add_column("Symbol", style="cyan")
344
+ table.add_column("Qty", justify="right")
345
+ table.add_column("Avg Price", justify="right")
346
+ table.add_column("Current", justify="right")
347
+ table.add_column("PnL", justify="right")
348
+ table.add_column("PnL %", justify="right")
349
+ table.add_column("Currency")
350
+ table.add_column("Updated")
351
+ for row in rows:
352
+ current_price, pnl, pnl_percent = self._portfolio_market_values(row)
353
+ table.add_row(
354
+ str(row["symbol"]),
355
+ f"{float(row['quantity']):,.8g}",
356
+ f"{float(row['average_price']):,.4f}",
357
+ _fmt(current_price),
358
+ _fmt(pnl),
359
+ _fmt(pnl_percent),
360
+ str(row["currency"]),
361
+ str(row["updated_at"]),
362
+ )
363
+ if not rows:
364
+ table.add_row(
365
+ "-",
366
+ "-",
367
+ "-",
368
+ "-",
369
+ "-",
370
+ "-",
371
+ "-",
372
+ "Belum ada posisi. Gunakan /portfolio add BTC-USD 0.05 65000",
373
+ )
374
+ return CommandResult(table)
375
+
376
+ action = args[0].lower()
377
+ if action == "performance":
378
+ return CommandResult(self._portfolio_performance_table())
379
+ if action == "add" and len(args) >= 4:
380
+ try:
381
+ quantity = float(args[2])
382
+ average_price = float(args[3])
383
+ except ValueError as exc:
384
+ raise CommandError("Quantity dan average price harus angka.") from exc
385
+ self.portfolio.add(args[1], quantity, average_price, args[4] if len(args) >= 5 else "USD")
386
+ return CommandResult(Panel(f"Posisi {args[1].upper()} disimpan.", title="Portfolio"))
387
+ if action == "remove" and len(args) >= 2:
388
+ self.portfolio.remove(args[1])
389
+ return CommandResult(Panel(f"Posisi {args[1].upper()} dihapus.", title="Portfolio"))
390
+ raise CommandError(
391
+ "Format: /portfolio, /portfolio performance, /portfolio add <symbol> <qty> <avg_price>, "
392
+ "/portfolio remove <symbol>"
393
+ )
394
+
395
+ def _tx(self, args: list[str]) -> CommandResult:
396
+ if not args or args[0].lower() == "list":
397
+ return CommandResult(_format_transactions(self.transactions.list()))
398
+
399
+ if args[0].lower() == "add":
400
+ if len(args) < 5:
401
+ raise CommandError("Format: /tx add <buy|sell> <symbol> <qty> <price> [currency]")
402
+ try:
403
+ quantity = float(args[3])
404
+ price = float(args[4])
405
+ except ValueError as exc:
406
+ raise CommandError("Quantity dan price harus angka.") from exc
407
+ tx = self.transactions.add(
408
+ action=args[1],
409
+ symbol=args[2],
410
+ quantity=quantity,
411
+ price=price,
412
+ currency=args[5] if len(args) >= 6 else "USD",
413
+ )
414
+ return CommandResult(
415
+ Panel(
416
+ (
417
+ f"Transaction saved: {tx['action']} {tx['symbol']} "
418
+ f"{_fmt(float(tx['quantity']))} @ {_fmt(float(tx['price']))} "
419
+ f"| Realized PnL {_fmt(float(tx['realized_pnl']))}"
420
+ ),
421
+ title="Transaction",
422
+ border_style="green",
423
+ )
424
+ )
425
+
426
+ raise CommandError("Format: /tx add <buy|sell> <symbol> <qty> <price> [currency] atau /tx list")
427
+
428
+ def _journal(self, args: list[str]) -> CommandResult:
429
+ if not args:
430
+ rows = self.journal.list()
431
+ return CommandResult(self._journal_table(rows, "Journal"))
432
+
433
+ if args[0].lower() == "stats":
434
+ rows = self.journal.list(limit=10_000)
435
+ stats = calculate_journal_stats(rows)
436
+ return CommandResult(_format_journal_stats(stats))
437
+
438
+ if args[0].lower() == "review":
439
+ rows = self.journal.list(limit=10_000)
440
+ stats = calculate_journal_stats(rows)
441
+ prompt = build_journal_review_prompt(rows, stats)
442
+ response = self._run_async(self.ai_provider.complete(AIRequest(prompt=prompt, model=self.config.settings.ai_model)))
443
+ if not isinstance(response, AIResponse):
444
+ raise CommandError("AI provider mengembalikan data tidak valid.")
445
+ return CommandResult(f"Journal Review\n{_format_ai_response(response)}\n\nDisclaimer: bukan nasihat keuangan.")
446
+
447
+ if args[0].lower() == "add":
448
+ if len(args) < 3:
449
+ raise CommandError('Format: /journal add <instrument> <bias> "entry reason"')
450
+ self.journal.add(args[1], bias=args[2], entry_reason=args[3] if len(args) >= 4 else "")
451
+ return CommandResult(Panel(f"Journal untuk {args[1].upper()} ditambahkan.", title="Journal"))
452
+
453
+ rows = self.journal.list(args[0])
454
+ return CommandResult(self._journal_table(rows, f"Journal {args[0].upper()}"))
455
+
456
+ def _journal_table(self, rows: list[dict[str, object]], title: str) -> Table:
457
+ table = Table(title=title, expand=True)
458
+ table.add_column("ID", justify="right")
459
+ table.add_column("Instrument", style="cyan")
460
+ table.add_column("Bias")
461
+ table.add_column("Entry Reason")
462
+ table.add_column("Created")
463
+ for row in rows:
464
+ table.add_row(
465
+ str(row["id"]),
466
+ str(row["instrument"]),
467
+ str(row["bias"]),
468
+ str(row["entry_reason"]),
469
+ str(row["created_at"]),
470
+ )
471
+ if not rows:
472
+ table.add_row("-", "-", "-", 'Belum ada journal. Gunakan /journal add BTC-USD bullish "Alasan entry"', "-")
473
+ return table
474
+
475
+ def _price(self, args: list[str]) -> CommandResult:
476
+ if not args:
477
+ raise CommandError("Format: /price <symbol>")
478
+ symbol = args[0].upper()
479
+ cache_key = f"quote:{symbol}"
480
+ cached = self.cache.get(cache_key)
481
+ quote = cached if isinstance(cached, Quote) else self._run_async(self.market_service.quote(symbol))
482
+ if not isinstance(quote, Quote):
483
+ raise CommandError("Provider quote mengembalikan data tidak valid.")
484
+ self.cache.set(cache_key, quote)
485
+ return CommandResult(_format_quote(quote))
486
+
487
+ def _technical(self, args: list[str]) -> CommandResult:
488
+ if not args:
489
+ raise CommandError("Format: /technical <symbol> [interval]")
490
+ symbol = args[0].upper()
491
+ interval = args[1] if len(args) >= 2 else "1d"
492
+ candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=interval))
493
+ if not candles:
494
+ raise CommandError(f"Data teknikal kosong untuk {symbol}.")
495
+ summary = summarize_technical_indicators(candles)
496
+ structure = analyze_market_structure(candles)
497
+ debate = run_technical_debate(summary, structure, candles)
498
+ signal = debate.judge_signal
499
+ ai_summary = build_technical_ai_summary(symbol, interval, candles)
500
+ return CommandResult(_format_technical(symbol, interval, summary, signal, ai_summary, debate))
501
+
502
+ def _market(self, args: list[str]) -> CommandResult:
503
+ if not args:
504
+ raise CommandError("Format: /market <symbol> [interval]")
505
+ symbol = args[0].upper()
506
+ interval = args[1] if len(args) >= 2 else "1d"
507
+ overview = self._run_async(build_market_overview(symbol, self.market_service, interval))
508
+ return CommandResult(_format_market_overview(overview))
509
+
510
+ def _structure(self, args: list[str]) -> CommandResult:
511
+ if not args:
512
+ raise CommandError("Format: /structure <symbol> [interval]")
513
+ symbol = args[0].upper()
514
+ interval = args[1] if len(args) >= 2 else "1d"
515
+ candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=interval))
516
+ if not candles:
517
+ raise CommandError(f"Data struktur market kosong untuk {symbol}.")
518
+ structure = analyze_market_structure(candles)
519
+ return CommandResult(_format_structure(symbol, interval, structure))
520
+
521
+ def _news(self, args: list[str]) -> CommandResult:
522
+ if not args:
523
+ raise CommandError("Format: /news <symbol>")
524
+ symbol = args[0].upper()
525
+ items = self._run_async(self.market_service.news(symbol, limit=5))
526
+ return CommandResult(_format_news(symbol, items))
527
+
528
+ def _fundamentals(self, args: list[str]) -> CommandResult:
529
+ if not args:
530
+ raise CommandError("Format: /funda <symbol>")
531
+ symbol = args[0].upper()
532
+ snapshot = self._run_async(self.market_service.fundamentals(symbol))
533
+ return CommandResult(_format_fundamentals(snapshot))
534
+
535
+ def _yahoo(self, args: list[str]) -> CommandResult:
536
+ if not args:
537
+ raise CommandError(
538
+ "Format: /yahoo <symbol> [history|statistics|profile|financials|balance|cashflow|analysis|holders|news] [period] [interval]"
539
+ )
540
+ symbol = args[0].upper()
541
+ section = args[1].lower() if len(args) >= 2 else "statistics"
542
+ period = args[2] if len(args) >= 3 else "6mo"
543
+ interval = args[3] if len(args) >= 4 else "1d"
544
+ provider = YFinanceProvider()
545
+ table = self._run_async(provider.yahoo_table(symbol, section, period=period, interval=interval))
546
+ if not isinstance(table, YahooTable):
547
+ raise CommandError("YFinance provider mengembalikan data tabel tidak valid.")
548
+ return CommandResult(_format_yahoo_table(table))
549
+
550
+ def _ai(self, args: list[str]) -> CommandResult:
551
+ if not args:
552
+ raise CommandError("Format: /ai <pertanyaan>")
553
+ prompt = " ".join(args)
554
+ if is_coding_request(prompt):
555
+ response = AIResponse(provider="fincli", model="local-policy", content=coding_refusal())
556
+ return CommandResult(_format_ai_response(response))
557
+
558
+ market_context = self._freechat_market_context(prompt)
559
+ assistant_prompt = build_fincli_assistant_prompt(prompt, market_context)
560
+ request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
561
+ response = self._run_async(self.ai_provider.complete(request))
562
+ if not isinstance(response, AIResponse):
563
+ raise CommandError("AI provider mengembalikan data tidak valid.")
564
+ return CommandResult(_format_ai_response(response))
565
+
566
+ def _analyze(self, args: list[str]) -> CommandResult:
567
+ if not args:
568
+ raise CommandError("Format: /analyze <symbol> [timeframe]")
569
+ symbol = args[0].upper()
570
+ timeframe = args[1] if len(args) >= 2 else "1d"
571
+ candles = self._run_async(self.market_service.history(symbol, period="6mo", interval=timeframe))
572
+ if not candles:
573
+ raise CommandError(f"Data market kosong untuk {symbol}.")
574
+ technical = summarize_technical_indicators(candles)
575
+ structure = analyze_market_structure(candles)
576
+ news_context = self._analysis_context(symbol)
577
+ prompt = build_market_analysis_prompt(symbol, timeframe, candles, technical, structure, news_context)
578
+ request = AIRequest(prompt=prompt, model=self.config.settings.ai_model)
579
+ response = self._run_async(self.ai_provider.complete(request))
580
+ if not isinstance(response, AIResponse):
581
+ raise CommandError("AI provider mengembalikan data tidak valid.")
582
+ return CommandResult(f"AI Market Analysis: {symbol}\n{_format_ai_response(response)}\n\nDisclaimer: bukan nasihat keuangan.")
583
+
584
+ def _scan(self, args: list[str]) -> CommandResult:
585
+ if not args or args[0].lower() != "watchlist":
586
+ raise CommandError("Format: /scan watchlist [filter] [interval]")
587
+ rows = self.watchlist.list()
588
+ symbols = [str(row["symbol"]) for row in rows]
589
+ if not symbols:
590
+ return CommandResult(Panel("Watchlist kosong. Gunakan /watchlist add AAPL.", title="Scan"))
591
+ filter_expression = args[1] if len(args) >= 2 else ""
592
+ interval = args[2] if len(args) >= 3 else "1d"
593
+ results = self._run_async(scan_symbols(symbols, self.market_service, filter_expression, interval=interval))
594
+ return CommandResult(_format_scan_results(results, filter_expression or "all", interval))
595
+
596
+ def _calendar(self, args: list[str]) -> CommandResult:
597
+ start, end, country, impact = _parse_calendar_args(args)
598
+ service = EconomicCalendarService(api_key=os.getenv("FINNHUB_API_KEY"))
599
+ source = "finnhub"
600
+ note = "Aktual dari provider Finnhub."
601
+ try:
602
+ events = self._run_async(service.events(start, end))
603
+ except FinCLIError as exc:
604
+ events = fallback_events(start, end)
605
+ source = "fallback"
606
+ note = f"{exc} Menggunakan fallback kategori event; isi FINNHUB_API_KEY untuk data aktual."
607
+ events = filter_events(events, country=country, impact=impact)
608
+ return CommandResult(_format_calendar(events, start, end, source, note))
609
+
610
+ def _run_async(self, awaitable: Any) -> Any:
611
+ try:
612
+ asyncio.get_running_loop()
613
+ except RuntimeError:
614
+ return asyncio.run(awaitable)
615
+
616
+ with ThreadPoolExecutor(max_workers=1) as executor:
617
+ future = executor.submit(asyncio.run, awaitable)
618
+ return future.result()
619
+
620
+ def _portfolio_market_values(self, row: dict[str, object]) -> tuple[float | None, float | None, float | None]:
621
+ try:
622
+ symbol = str(row["symbol"])
623
+ quantity = float(row["quantity"])
624
+ average_price = float(row["average_price"])
625
+ quote = self._get_quote(symbol)
626
+ current_price = quote.price
627
+ if current_price is None:
628
+ return None, None, None
629
+ pnl = (current_price - average_price) * quantity
630
+ invested = average_price * quantity
631
+ pnl_percent = (pnl / invested * 100) if invested else None
632
+ return current_price, pnl, pnl_percent
633
+ except FinCLIError:
634
+ return None, None, None
635
+ except (TypeError, ValueError, KeyError):
636
+ return None, None, None
637
+
638
+ def _portfolio_performance_table(self) -> Table:
639
+ positions = self.portfolio.list()
640
+ realized = self.transactions.realized_pnl_total()
641
+ cost_basis = 0.0
642
+ market_value = 0.0
643
+ unrealized = 0.0
644
+ for row in positions:
645
+ quantity = float(row["quantity"])
646
+ average_price = float(row["average_price"])
647
+ current_price, pnl, _ = self._portfolio_market_values(row)
648
+ cost_basis += quantity * average_price
649
+ if current_price is not None:
650
+ market_value += quantity * current_price
651
+ if pnl is not None:
652
+ unrealized += pnl
653
+
654
+ table = Table(title="Portfolio Performance", expand=True)
655
+ table.add_column("Metric", style="cyan")
656
+ table.add_column("Value", justify="right")
657
+ table.add_row("Cost Basis", _fmt(cost_basis))
658
+ table.add_row("Market Value", _fmt(market_value))
659
+ table.add_row("Unrealized PnL", _fmt(unrealized))
660
+ table.add_row("Realized PnL", _fmt(realized))
661
+ table.add_row("Total PnL", _fmt(realized + unrealized))
662
+ return table
663
+
664
+ def _get_quote(self, symbol: str) -> Quote:
665
+ normalized = symbol.upper()
666
+ cache_key = f"quote:{normalized}"
667
+ cached = self.cache.get(cache_key)
668
+ if isinstance(cached, Quote):
669
+ return cached
670
+ quote = self._run_async(self.market_service.quote(normalized))
671
+ if not isinstance(quote, Quote):
672
+ raise CommandError("Provider quote mengembalikan data tidak valid.")
673
+ self.cache.set(cache_key, quote)
674
+ return quote
675
+
676
+ def _provider_health_text(self) -> str:
677
+ try:
678
+ status = self._run_async(self.market_service.status())
679
+ return (
680
+ f"Provider health: {status.status}\n"
681
+ f"Provider realtime: {status.realtime}\n"
682
+ f"Provider message: {status.message}"
683
+ )
684
+ except (FinCLIError, AttributeError) as exc:
685
+ return f"Provider health: unavailable ({exc})"
686
+
687
+ def _safe_quote(self, symbol: str) -> Quote | None:
688
+ try:
689
+ return self._get_quote(symbol)
690
+ except FinCLIError:
691
+ return None
692
+
693
+ def _analysis_context(self, symbol: str) -> str:
694
+ sections: list[str] = []
695
+ try:
696
+ news_items = self._run_async(self.market_service.news(symbol, limit=3))
697
+ sections.append(_format_news_context(news_items))
698
+ except (FinCLIError, AttributeError) as exc:
699
+ sections.append(f"News unavailable: {exc}")
700
+ try:
701
+ fundamentals = self._run_async(self.market_service.fundamentals(symbol))
702
+ sections.append(_format_fundamental_context(fundamentals))
703
+ except (FinCLIError, AttributeError) as exc:
704
+ sections.append(f"Fundamentals unavailable: {exc}")
705
+ return "\n\n".join(sections)
706
+
707
+ def _freechat_market_context(self, prompt: str) -> str:
708
+ symbols = extract_market_symbols(prompt)
709
+ if not symbols:
710
+ return ""
711
+
712
+ sections = [
713
+ "FinCLI provider chain: "
714
+ + ", ".join(provider.name for provider in self.market_service.providers)
715
+ + ". Realtime status depends on the active provider and API key."
716
+ ]
717
+ for symbol in symbols:
718
+ sections.append(self._symbol_freechat_context(symbol))
719
+ return "\n\n".join(sections)
720
+
721
+ def _symbol_freechat_context(self, symbol: str) -> str:
722
+ lines = [f"Symbol: {symbol}"]
723
+ try:
724
+ quote = self._get_quote(symbol)
725
+ lines.append(
726
+ f"Quote: price={_fmt(quote.price)} {quote.currency}; provider={quote.provider}; "
727
+ f"status={quote.status}; timestamp={quote.timestamp.isoformat(timespec='seconds')}"
728
+ )
729
+ except (FinCLIError, AttributeError, ValueError) as exc:
730
+ lines.append(f"Quote: unavailable ({exc})")
731
+
732
+ try:
733
+ candles = self._run_async(self.market_service.history(symbol, period="6mo", interval="1d"))
734
+ if candles:
735
+ technical = summarize_technical_indicators(candles)
736
+ structure = analyze_market_structure(candles)
737
+ debate = run_technical_debate(technical, structure, candles)
738
+ signal = debate.judge_signal
739
+ lines.extend(
740
+ [
741
+ f"OHLCV: {len(candles)} daily candles available.",
742
+ (
743
+ "Technical: "
744
+ f"close={_fmt(technical.latest_close)}; trend={technical.trend_bias}; "
745
+ f"RSI={_fmt(technical.rsi)}; MACD={_fmt(technical.macd)}/{_fmt(technical.macd_signal)}; "
746
+ f"support={_fmt(technical.support)}; resistance={_fmt(technical.resistance)}; "
747
+ f"ATR={_fmt(technical.atr)}"
748
+ ),
749
+ (
750
+ "Structure: "
751
+ f"trend={structure.trend}; pattern={structure.latest_pattern}; "
752
+ f"BOS={structure.break_of_structure}; CHoCH={structure.change_of_character}; "
753
+ f"liquidity={structure.liquidity_area or 'N/A'}; risk_zone={structure.risk_zone or 'N/A'}"
754
+ ),
755
+ (
756
+ "Debate Signal: "
757
+ f"{signal.label}; confidence={signal.confidence}; score={signal.score}; "
758
+ f"judge_reasoning={'; '.join(debate.judge_reasoning[:2])}"
759
+ ),
760
+ ]
761
+ )
762
+ else:
763
+ lines.append("OHLCV: unavailable (provider returned no candles).")
764
+ except (FinCLIError, AttributeError, ValueError) as exc:
765
+ lines.append(f"OHLCV/Technical: unavailable ({exc})")
766
+
767
+ try:
768
+ fundamentals = self._run_async(self.market_service.fundamentals(symbol))
769
+ lines.append(
770
+ "Fundamentals: "
771
+ f"provider={fundamentals.provider}; market_cap={_fmt(fundamentals.market_cap)}; "
772
+ f"pe={_fmt(fundamentals.pe_ratio)}; eps={_fmt(fundamentals.eps)}; "
773
+ f"revenue={_fmt(fundamentals.revenue)}; sector={fundamentals.sector or 'N/A'}; "
774
+ f"industry={fundamentals.industry or 'N/A'}"
775
+ )
776
+ except (FinCLIError, AttributeError, ValueError) as exc:
777
+ lines.append(f"Fundamentals: unavailable ({exc})")
778
+
779
+ try:
780
+ news_items = self._run_async(self.market_service.news(symbol, limit=3))
781
+ if news_items:
782
+ lines.append("News:")
783
+ for item in news_items:
784
+ published = item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown time"
785
+ summary = f" - {item.summary}" if item.summary else ""
786
+ lines.append(f"- {item.title} ({item.source}, {published}){summary}")
787
+ else:
788
+ lines.append("News: no recent items from active provider.")
789
+ except (FinCLIError, AttributeError, ValueError) as exc:
790
+ lines.append(f"News: unavailable ({exc})")
791
+
792
+ return "\n".join(lines)
793
+
794
+ def _export(self, args: list[str]) -> CommandResult:
795
+ if len(args) < 3 or args[0].lower() not in {"journal", "portfolio"}:
796
+ raise CommandError("Format: /export <journal|portfolio> <csv|json> <path>")
797
+ dataset = args[0].lower()
798
+ export_format = args[1].lower()
799
+ target = args[2]
800
+ rows = self.journal.list(limit=10_000) if dataset == "journal" else self.portfolio.list()
801
+ written = export_rows(rows, export_format, target)
802
+ return CommandResult(Panel(f"Export {dataset} selesai: {written}", title="Export", border_style="green"))
803
+
804
+ def _build_market_service(self, injected_provider: BaseMarketProvider | None = None) -> MarketDataService:
805
+ if injected_provider is not None:
806
+ return MarketDataService(
807
+ [injected_provider],
808
+ cache=self.market_cache,
809
+ cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
810
+ )
811
+ priority = self.config.settings.market_provider_priority or [self.config.settings.market_provider]
812
+ return MarketDataService(
813
+ self.market_manager.create_many(priority),
814
+ cache=self.market_cache,
815
+ cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
816
+ )
817
+
818
+ def _refresh_market_service(self) -> None:
819
+ self.market_service = self._build_market_service()
820
+ self.market_provider = self.market_service.primary_provider
821
+
822
+ def _priority_tail(self, active_provider: str) -> list[str]:
823
+ active = active_provider.lower()
824
+ existing = self.config.settings.market_provider_priority or ["yfinance"]
825
+ tail = [provider for provider in existing if provider != active]
826
+ if active != "yfinance" and "yfinance" not in tail:
827
+ tail.append("yfinance")
828
+ return tail
829
+
830
+
831
+ def _format_quote(quote: Quote) -> str:
832
+ price = "N/A" if quote.price is None else f"{quote.price:,.4f}"
833
+ return (
834
+ f"Quote: {quote.symbol}\n"
835
+ f"Price: {price} {quote.currency}\n"
836
+ f"Provider: {quote.provider}\n"
837
+ f"Status: {quote.status}\n"
838
+ f"Timestamp: {quote.timestamp.isoformat(timespec='seconds')}\n"
839
+ "Catatan: yfinance fallback biasanya delayed, bukan realtime."
840
+ )
841
+
842
+
843
+ def _format_dashboard(
844
+ provider_chain: list[str],
845
+ watchlist_rows: list[dict[str, object]],
846
+ portfolio_rows: list[dict[str, object]],
847
+ journal_stats: JournalStats,
848
+ realized_pnl: float,
849
+ quote_getter: Any,
850
+ portfolio_value_getter: Any,
851
+ ) -> Table:
852
+ table = Table(title="FinCLI Dashboard", expand=True)
853
+ table.add_column("Area", style="cyan", no_wrap=True)
854
+ table.add_column("Summary", style="white")
855
+ table.add_column("Next Action", style="dim")
856
+
857
+ table.add_row(
858
+ "Provider Chain",
859
+ ", ".join(provider_chain) if provider_chain else "N/A",
860
+ "/provider status | /provider priority finnhub,yfinance",
861
+ )
862
+
863
+ watchlist_symbols = [str(row["symbol"]) for row in watchlist_rows]
864
+ quote_bits: list[str] = []
865
+ for symbol in watchlist_symbols[:4]:
866
+ quote = quote_getter(symbol)
867
+ quote_bits.append(f"{symbol} {_fmt(quote.price) if quote else 'N/A'}")
868
+ table.add_row(
869
+ "Watchlist",
870
+ f"{len(watchlist_rows)} symbol(s)" + (f" | {', '.join(quote_bits)}" if quote_bits else ""),
871
+ "/watchlist add AAPL | /scan watchlist trend=bullish",
872
+ )
873
+
874
+ market_value = 0.0
875
+ unrealized = 0.0
876
+ for row in portfolio_rows:
877
+ current_price, pnl, _ = portfolio_value_getter(row)
878
+ if current_price is not None:
879
+ market_value += float(row["quantity"]) * current_price
880
+ if pnl is not None:
881
+ unrealized += pnl
882
+ portfolio_summary = (
883
+ f"{len(portfolio_rows)} position(s) | Market Value {_fmt(market_value)} | "
884
+ f"Unrealized PnL {_fmt(unrealized)} | Realized PnL {_fmt(realized_pnl)}"
885
+ if portfolio_rows
886
+ else "No local portfolio positions"
887
+ )
888
+ table.add_row("Portfolio", portfolio_summary, "/tx add buy AAPL 10 185 | /portfolio performance")
889
+
890
+ table.add_row(
891
+ "Journal",
892
+ (
893
+ f"{journal_stats.total_entries} entries | Win Rate {_fmt(journal_stats.win_rate)} | "
894
+ f"Top {journal_stats.top_instrument}"
895
+ ),
896
+ "/journal stats | /journal review",
897
+ )
898
+
899
+ table.add_row(
900
+ "Market",
901
+ "Use /market for compact quote + technical + structure + news + fundamentals.",
902
+ "/market AAPL 1d | /analyze AAPL 1d",
903
+ )
904
+ return table
905
+
906
+
907
+ def _format_market_overview(overview: MarketOverview) -> Table:
908
+ table = Table(title=f"Market Overview: {overview.symbol} | {overview.timeframe}", expand=True)
909
+ table.add_column("Section", style="cyan", no_wrap=True)
910
+ table.add_column("Value", style="white")
911
+ table.add_column("Context", style="dim")
912
+
913
+ quality = overview.data_quality
914
+ table.add_row(
915
+ "Data Quality",
916
+ f"{quality.score}/100",
917
+ f"quote={quality.quote}; ohlcv={quality.ohlcv}; news={quality.news}; fundamentals={quality.fundamentals}; provider={quality.provider}",
918
+ )
919
+ table.add_row(
920
+ "Quote",
921
+ f"{_fmt(overview.quote.price)} {overview.quote.currency}",
922
+ f"{overview.quote.provider} | {overview.quote.status} | {overview.quote.timestamp.isoformat(timespec='seconds')}",
923
+ )
924
+ table.add_row(
925
+ "Technical",
926
+ f"RSI {_fmt(overview.technical.rsi)} | Trend {overview.technical.trend_bias}",
927
+ f"MACD {_fmt(overview.technical.macd)} / Signal {_fmt(overview.technical.macd_signal)} | ATR {_fmt(overview.technical.atr)}",
928
+ )
929
+ table.add_row(
930
+ "Key Levels",
931
+ f"Support {_fmt(overview.technical.support)} | Resistance {_fmt(overview.technical.resistance)}",
932
+ f"Bollinger {_fmt(overview.technical.bollinger_lower)} - {_fmt(overview.technical.bollinger_upper)}",
933
+ )
934
+ table.add_row(
935
+ "Market Structure",
936
+ f"{overview.structure.trend} | {overview.structure.latest_pattern}",
937
+ f"BOS={overview.structure.break_of_structure}; CHoCH={overview.structure.change_of_character}; Liquidity={overview.structure.liquidity_area}",
938
+ )
939
+
940
+ if overview.fundamentals is not None:
941
+ table.add_row(
942
+ "Fundamentals",
943
+ f"P/E {_fmt(overview.fundamentals.pe_ratio)} | EPS {_fmt(overview.fundamentals.eps)}",
944
+ f"Sector={overview.fundamentals.sector or 'N/A'}; Industry={overview.fundamentals.industry or 'N/A'}; Market Cap={_fmt(overview.fundamentals.market_cap)}",
945
+ )
946
+ else:
947
+ table.add_row("Fundamentals", "N/A", "Provider did not return fundamentals.")
948
+
949
+ if overview.news:
950
+ latest_news = overview.news[0]
951
+ table.add_row(
952
+ "Latest News",
953
+ latest_news.title,
954
+ f"{latest_news.source} | {latest_news.published_at.isoformat(timespec='seconds') if latest_news.published_at else 'unknown time'}",
955
+ )
956
+ else:
957
+ table.add_row("Latest News", "N/A", "Provider did not return recent news.")
958
+
959
+ table.add_row("Disclaimer", "Informational only", "Bukan nasihat keuangan.")
960
+ return table
961
+
962
+
963
+ def _format_technical(
964
+ symbol: str,
965
+ interval: str,
966
+ summary: TechnicalSummary,
967
+ signal: TechnicalSignal | None = None,
968
+ ai_summary: str = "",
969
+ debate: TechnicalDebate | None = None,
970
+ ) -> str:
971
+ signal_text = format_signal(signal) if signal is not None else "Signal: CAUTION\nSignal Reasoning:\n- Signal unavailable."
972
+ debate_text = format_debate(debate) if debate is not None else "Technical Debate:\n- Debate unavailable."
973
+ return (
974
+ f"Technical Analysis: {symbol}\n"
975
+ f"Timeframe: {interval}\n"
976
+ f"Latest Close: {_fmt(summary.latest_close)}\n"
977
+ f"Trend Bias: {summary.trend_bias}\n"
978
+ f"SMA 5: {_fmt(summary.sma_fast)}\n"
979
+ f"SMA 20: {_fmt(summary.sma_slow)}\n"
980
+ f"EMA 12: {_fmt(summary.ema_fast)}\n"
981
+ f"RSI 14: {_fmt(summary.rsi)}\n"
982
+ f"MACD: {_fmt(summary.macd)} | Signal: {_fmt(summary.macd_signal)}\n"
983
+ f"Bollinger: upper {_fmt(summary.bollinger_upper)} | lower {_fmt(summary.bollinger_lower)}\n"
984
+ f"ATR 14: {_fmt(summary.atr)}\n"
985
+ f"Support: {_fmt(summary.support)} | Resistance: {_fmt(summary.resistance)}\n"
986
+ f"Volume Latest: {_fmt(summary.volume_latest)}\n"
987
+ f"\n{signal_text}\n"
988
+ f"\n{debate_text}\n"
989
+ f"\n{ai_summary}\n"
990
+ "Disclaimer: analisis ini bersifat informasional, bukan nasihat keuangan."
991
+ )
992
+
993
+
994
+ def _format_structure(symbol: str, interval: str, structure: MarketStructureSummary) -> str:
995
+ return (
996
+ f"Market Structure: {symbol}\n"
997
+ f"Timeframe: {interval}\n"
998
+ f"Trend: {structure.trend}\n"
999
+ f"Latest Pattern: {structure.latest_pattern}\n"
1000
+ f"Break of Structure: {structure.break_of_structure}\n"
1001
+ f"Change of Character: {structure.change_of_character}\n"
1002
+ f"Support: {_fmt(structure.support)}\n"
1003
+ f"Resistance: {_fmt(structure.resistance)}\n"
1004
+ f"Liquidity Area: {structure.liquidity_area or 'N/A'}\n"
1005
+ f"Risk Zone: {structure.risk_zone or 'N/A'}\n"
1006
+ "Disclaimer: struktur pasar ini bersifat skenario, bukan nasihat keuangan."
1007
+ )
1008
+
1009
+
1010
+ def _format_scan_results(results: list[ScanResult], filter_expression: str, interval: str) -> Table:
1011
+ table = Table(title=f"Scan Watchlist | {filter_expression} | {interval}", expand=True)
1012
+ table.add_column("Symbol", style="cyan")
1013
+ table.add_column("Close", justify="right")
1014
+ table.add_column("RSI", justify="right")
1015
+ table.add_column("Trend")
1016
+ table.add_column("Support", justify="right")
1017
+ table.add_column("Resistance", justify="right")
1018
+ table.add_column("Reason")
1019
+ for result in results:
1020
+ table.add_row(
1021
+ result.symbol,
1022
+ _fmt(result.latest_close),
1023
+ _fmt(result.rsi),
1024
+ result.trend_bias,
1025
+ _fmt(result.support),
1026
+ _fmt(result.resistance),
1027
+ result.reason,
1028
+ )
1029
+ if not results:
1030
+ table.add_row("-", "-", "-", "-", "-", "-", "Tidak ada symbol yang match.")
1031
+ return table
1032
+
1033
+
1034
+ def _parse_calendar_args(args: list[str]) -> tuple[date, date, str | None, str | None]:
1035
+ country: str | None = None
1036
+ impact: str | None = None
1037
+ positional: list[str] = []
1038
+
1039
+ for arg in args:
1040
+ normalized = arg.lower()
1041
+ if normalized.startswith("country="):
1042
+ country = arg.split("=", 1)[1].upper()
1043
+ elif normalized.startswith("impact="):
1044
+ impact = arg.split("=", 1)[1].lower()
1045
+ elif normalized in {"high", "medium", "low"}:
1046
+ impact = normalized
1047
+ elif len(arg) in {2, 3} and arg.isalpha():
1048
+ country = arg.upper()
1049
+ else:
1050
+ positional.append(arg)
1051
+
1052
+ if not positional:
1053
+ start, end = default_calendar_window("week")
1054
+ elif positional[0].lower() in {"today", "week"}:
1055
+ start, end = default_calendar_window(positional[0].lower())
1056
+ elif len(positional) >= 2:
1057
+ start = _parse_date_arg(positional[0])
1058
+ end = _parse_date_arg(positional[1])
1059
+ else:
1060
+ raise CommandError("Format: /calendar [today|week|<from YYYY-MM-DD> <to YYYY-MM-DD>] [country=US] [impact=high]")
1061
+
1062
+ if end < start:
1063
+ raise CommandError("Tanggal akhir calendar tidak boleh lebih kecil dari tanggal awal.")
1064
+ return start, end, country, impact
1065
+
1066
+
1067
+ def _parse_date_arg(value: str) -> date:
1068
+ try:
1069
+ return date.fromisoformat(value)
1070
+ except ValueError as exc:
1071
+ raise CommandError("Tanggal calendar harus format YYYY-MM-DD.") from exc
1072
+
1073
+
1074
+ def _format_calendar(events: list[EconomicEvent], start: date, end: date, source: str, note: str) -> Table:
1075
+ table = Table(title=f"Economic Calendar | {start.isoformat()} to {end.isoformat()} | {source}", expand=True)
1076
+ table.add_column("Time", style="cyan", no_wrap=True)
1077
+ table.add_column("Country")
1078
+ table.add_column("Impact")
1079
+ table.add_column("Event", style="white")
1080
+ table.add_column("Actual", justify="right")
1081
+ table.add_column("Estimate", justify="right")
1082
+ table.add_column("Previous", justify="right")
1083
+
1084
+ for event in events:
1085
+ event_time = event.time.isoformat(timespec="minutes") if event.time else "TBA"
1086
+ table.add_row(
1087
+ event_time,
1088
+ event.country,
1089
+ event.impact,
1090
+ event.event,
1091
+ event.actual or "-",
1092
+ event.estimate or "-",
1093
+ event.previous or "-",
1094
+ )
1095
+
1096
+ if not events:
1097
+ table.add_row("-", "-", "-", "Tidak ada event yang cocok dengan filter.", "-", "-", "-")
1098
+ table.add_row("Note", source, "-", note, "-", "-", "-")
1099
+ return table
1100
+
1101
+
1102
+ def _format_provider_list() -> Table:
1103
+ table = Table(title="Market Providers", expand=True)
1104
+ table.add_column("Name", style="cyan")
1105
+ table.add_column("Realtime")
1106
+ table.add_column("Status")
1107
+ table.add_column("Notes")
1108
+ for provider in MarketProviderManager().list_providers():
1109
+ table.add_row(provider.name, str(provider.realtime), provider.status, provider.notes)
1110
+ return table
1111
+
1112
+
1113
+ def _format_provider_key_status(manager: MarketProviderManager) -> Table:
1114
+ table = Table(title="Market Provider API Key Status", expand=True)
1115
+ table.add_column("Provider", style="cyan")
1116
+ table.add_column("Key")
1117
+ table.add_column("Status")
1118
+ table.add_column("Source")
1119
+ for row in manager.key_status():
1120
+ table.add_row(row["provider"], row["key"], row["status"], row["source"])
1121
+ return table
1122
+
1123
+
1124
+ def _format_transactions(rows: list[dict[str, object]]) -> Table:
1125
+ table = Table(title="Transaction Ledger", expand=True)
1126
+ table.add_column("ID", justify="right")
1127
+ table.add_column("Action")
1128
+ table.add_column("Symbol", style="cyan")
1129
+ table.add_column("Qty", justify="right")
1130
+ table.add_column("Price", justify="right")
1131
+ table.add_column("Realized PnL", justify="right")
1132
+ table.add_column("Created")
1133
+ for row in rows:
1134
+ table.add_row(
1135
+ str(row["id"]),
1136
+ str(row["action"]),
1137
+ str(row["symbol"]),
1138
+ _fmt(float(row["quantity"])),
1139
+ _fmt(float(row["price"])),
1140
+ _fmt(float(row["realized_pnl"])),
1141
+ str(row["created_at"]),
1142
+ )
1143
+ if not rows:
1144
+ table.add_row("-", "-", "-", "-", "-", "-", "Belum ada transaksi. Gunakan /tx add buy AAPL 10 100")
1145
+ return table
1146
+
1147
+
1148
+ def _format_journal_stats(stats: JournalStats) -> Table:
1149
+ table = Table(title="Journal Stats", expand=True)
1150
+ table.add_column("Metric", style="cyan")
1151
+ table.add_column("Value", justify="right")
1152
+ table.add_row("Total Entries", str(stats.total_entries))
1153
+ table.add_row("Wins", str(stats.wins))
1154
+ table.add_row("Losses", str(stats.losses))
1155
+ table.add_row("Win Rate", _fmt(stats.win_rate))
1156
+ table.add_row("Top Instrument", stats.top_instrument)
1157
+ table.add_row("Top Emotion", stats.top_emotion)
1158
+ table.add_row("Top Tags", ", ".join(stats.top_tags) if stats.top_tags else "N/A")
1159
+ return table
1160
+
1161
+
1162
+ def _format_news(symbol: str, items: list[NewsItem]) -> str:
1163
+ if not items:
1164
+ return f"News: {symbol}\nBelum ada news dari provider aktif."
1165
+ lines = [f"News: {symbol}"]
1166
+ for index, item in enumerate(items, start=1):
1167
+ published = item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown time"
1168
+ url = f"\n URL: {item.url}" if item.url else ""
1169
+ summary = f"\n Summary: {item.summary}" if item.summary else ""
1170
+ lines.append(f"{index}. {item.title}\n Source: {item.source} | Published: {published}{summary}{url}")
1171
+ return "\n".join(lines)
1172
+
1173
+
1174
+ def _format_fundamentals(snapshot: FundamentalSnapshot) -> str:
1175
+ return (
1176
+ f"Fundamental Snapshot: {snapshot.symbol}\n"
1177
+ f"Provider: {snapshot.provider}\n"
1178
+ f"Currency: {snapshot.currency}\n"
1179
+ f"Market Cap: {_fmt(snapshot.market_cap)}\n"
1180
+ f"P/E Ratio: {_fmt(snapshot.pe_ratio)}\n"
1181
+ f"EPS: {_fmt(snapshot.eps)}\n"
1182
+ f"Revenue: {_fmt(snapshot.revenue)}\n"
1183
+ f"Beta: {_fmt(snapshot.beta)}\n"
1184
+ f"Sector: {snapshot.sector or 'N/A'}\n"
1185
+ f"Industry: {snapshot.industry or 'N/A'}"
1186
+ )
1187
+
1188
+
1189
+ def _format_yahoo_table(dataset: YahooTable) -> Table:
1190
+ table = Table(title=f"Yahoo Finance {dataset.section}: {dataset.symbol}", expand=True)
1191
+ for index, column in enumerate(dataset.columns):
1192
+ table.add_column(str(column), style="cyan" if index == 0 else "white", overflow="fold")
1193
+
1194
+ for row in dataset.rows:
1195
+ normalized = [str(value) for value in row[: len(dataset.columns)]]
1196
+ normalized += [""] * max(0, len(dataset.columns) - len(normalized))
1197
+ table.add_row(*normalized)
1198
+
1199
+ if not dataset.rows:
1200
+ table.add_row(*(["No data returned by yfinance/Yahoo."] + [""] * (len(dataset.columns) - 1)))
1201
+
1202
+ note = dataset.note or "Data source: yfinance/Yahoo Finance. Realtime/delayed status depends on exchange coverage."
1203
+ table.caption = f"{note}\nSource: {dataset.source_url}"
1204
+ return table
1205
+
1206
+
1207
+ def _format_news_context(items: list[NewsItem]) -> str:
1208
+ if not items:
1209
+ return "News: no recent news from active provider."
1210
+ lines = ["News:"]
1211
+ for item in items:
1212
+ published = item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown time"
1213
+ summary = f" - {item.summary}" if item.summary else ""
1214
+ lines.append(f"- {item.title} ({item.source}, {published}){summary}")
1215
+ return "\n".join(lines)
1216
+
1217
+
1218
+ def _format_fundamental_context(snapshot: FundamentalSnapshot) -> str:
1219
+ return (
1220
+ "Fundamentals:\n"
1221
+ f"- Symbol: {snapshot.symbol}\n"
1222
+ f"- Currency: {snapshot.currency}\n"
1223
+ f"- Market Cap: {_fmt(snapshot.market_cap)}\n"
1224
+ f"- P/E Ratio: {_fmt(snapshot.pe_ratio)}\n"
1225
+ f"- EPS: {_fmt(snapshot.eps)}\n"
1226
+ f"- Revenue: {_fmt(snapshot.revenue)}\n"
1227
+ f"- Beta: {_fmt(snapshot.beta)}\n"
1228
+ f"- Sector: {snapshot.sector or 'N/A'}\n"
1229
+ f"- Industry: {snapshot.industry or 'N/A'}"
1230
+ )
1231
+
1232
+
1233
+ def _format_ai_response(response: AIResponse) -> str:
1234
+ return (
1235
+ f"Provider: {response.provider}\n"
1236
+ f"Model: {response.model}\n"
1237
+ f"Response:\n{response.content}"
1238
+ )
1239
+
1240
+
1241
+ def _fmt(value: float | None) -> str:
1242
+ if value is None:
1243
+ return "N/A"
1244
+ return f"{value:,.4f}"
1245
+
1246
+
1247
+ class UnavailableAIProvider:
1248
+ """Default AI provider used until a concrete API client is configured."""
1249
+
1250
+ def __init__(self, provider_name: str) -> None:
1251
+ self.name = provider_name
1252
+
1253
+ async def complete(self, request: AIRequest) -> AIResponse:
1254
+ raise CommandError(
1255
+ f"AI provider {self.name} belum siap dipakai.",
1256
+ "Set API key di .env dan gunakan provider client Phase 2 lanjutan, atau injeksi provider untuk testing.",
1257
+ )