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