@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.
- package/README.md +644 -0
- package/fincli/__init__.py +3 -0
- package/fincli/app/__init__.py +1 -0
- package/fincli/app/analysis/__init__.py +1 -0
- package/fincli/app/analysis/ai_prompts.py +33 -0
- package/fincli/app/analysis/analyzer.py +119 -0
- package/fincli/app/analysis/assistant_context.py +161 -0
- package/fincli/app/analysis/indicators.py +143 -0
- package/fincli/app/analysis/market_structure.py +106 -0
- package/fincli/app/analysis/technical_debate.py +251 -0
- package/fincli/app/analysis/technical_signal.py +203 -0
- package/fincli/app/cli/__init__.py +1 -0
- package/fincli/app/cli/autocomplete.py +17 -0
- package/fincli/app/cli/commands.py +82 -0
- package/fincli/app/cli/router.py +1257 -0
- package/fincli/app/main.py +16 -0
- package/fincli/app/modules/__init__.py +1 -0
- package/fincli/app/modules/economic_calendar.py +139 -0
- package/fincli/app/modules/exporter.py +51 -0
- package/fincli/app/modules/journal.py +65 -0
- package/fincli/app/modules/journal_analytics.py +70 -0
- package/fincli/app/modules/portfolio.py +34 -0
- package/fincli/app/modules/scanner.py +105 -0
- package/fincli/app/modules/transactions.py +84 -0
- package/fincli/app/modules/watchlist.py +25 -0
- package/fincli/app/providers/__init__.py +1 -0
- package/fincli/app/providers/ai/__init__.py +1 -0
- package/fincli/app/providers/ai/anthropic_provider.py +11 -0
- package/fincli/app/providers/ai/base.py +29 -0
- package/fincli/app/providers/ai/gemini_provider.py +11 -0
- package/fincli/app/providers/ai/groq_provider.py +11 -0
- package/fincli/app/providers/ai/http_provider.py +145 -0
- package/fincli/app/providers/ai/huggingface_provider.py +11 -0
- package/fincli/app/providers/ai/manager.py +60 -0
- package/fincli/app/providers/ai/openai_provider.py +11 -0
- package/fincli/app/providers/ai/openrouter_provider.py +11 -0
- package/fincli/app/providers/ai/together_provider.py +11 -0
- package/fincli/app/providers/market/__init__.py +1 -0
- package/fincli/app/providers/market/base.py +77 -0
- package/fincli/app/providers/market/custom_provider.py +169 -0
- package/fincli/app/providers/market/finnhub_provider.py +187 -0
- package/fincli/app/providers/market/manager.py +123 -0
- package/fincli/app/providers/market/news_provider.py +28 -0
- package/fincli/app/providers/market/symbols.py +182 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -0
- package/fincli/app/providers/market/yfinance_provider.py +447 -0
- package/fincli/app/services/__init__.py +1 -0
- package/fincli/app/services/market_data.py +203 -0
- package/fincli/app/services/market_overview.py +111 -0
- package/fincli/app/storage/__init__.py +1 -0
- package/fincli/app/storage/cache.py +38 -0
- package/fincli/app/storage/config.py +114 -0
- package/fincli/app/storage/database.py +101 -0
- package/fincli/app/storage/market_cache.py +92 -0
- package/fincli/app/tui/__init__.py +1 -0
- package/fincli/app/tui/components.py +55 -0
- package/fincli/app/tui/layout.py +261 -0
- package/fincli/app/tui/market_provider_selector.py +267 -0
- package/fincli/app/tui/model_selector.py +412 -0
- package/fincli/app/tui/theme.py +157 -0
- package/fincli/app/utils/__init__.py +1 -0
- package/fincli/app/utils/errors.py +33 -0
- package/fincli/app/utils/formatting.py +17 -0
- package/fincli/app/utils/logger.py +19 -0
- package/npm/bin/fincli.js +35 -0
- package/npm/postinstall.js +72 -0
- package/package.json +23 -0
- package/pyproject.toml +31 -0
- 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
|
+
)
|