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