@drico2008/fincli 0.1.9 → 0.3.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/LICENSE +21 -0
- package/README.md +124 -625
- package/fincli/__init__.py +3 -3
- package/fincli/app/agents/__init__.py +5 -0
- package/fincli/app/agents/registry.py +76 -0
- package/fincli/app/analysis/ai_prompts.py +26 -14
- package/fincli/app/analysis/analyzer.py +107 -96
- package/fincli/app/analysis/assistant_context.py +187 -186
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +108 -81
- package/fincli/app/cli/router.py +2327 -1237
- package/fincli/app/connectors/__init__.py +5 -0
- package/fincli/app/connectors/catalog.py +148 -0
- package/fincli/app/connectors/news_connectors.py +412 -0
- package/fincli/app/modules/alerts.py +80 -0
- package/fincli/app/modules/economic_calendar.py +374 -1
- package/fincli/app/modules/portfolio_risk.py +305 -0
- package/fincli/app/modules/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/transactions.py +84 -84
- package/fincli/app/modules/user_profile.py +84 -0
- package/fincli/app/plugins/loader.py +72 -0
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/market/alphavantage_provider.py +194 -0
- package/fincli/app/providers/market/base.py +98 -77
- package/fincli/app/providers/market/custom_provider.py +186 -169
- package/fincli/app/providers/market/manager.py +84 -1
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/providers/reliability.py +86 -0
- package/fincli/app/research/__init__.py +8 -0
- package/fincli/app/research/engine.py +137 -0
- package/fincli/app/research/exporter.py +91 -0
- package/fincli/app/research/formatter.py +27 -0
- package/fincli/app/research/models.py +24 -0
- package/fincli/app/research/prompt_builder.py +54 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +274 -169
- package/fincli/app/services/market_overview.py +42 -1
- package/fincli/app/services/news_aggregator.py +95 -0
- package/fincli/app/services/web_research.py +267 -267
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +209 -99
- package/fincli/app/storage/provider_metrics.py +61 -0
- package/fincli/app/storage/secrets.py +26 -2
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +269 -258
- package/fincli/app/tui/market_provider_selector.py +3 -1
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +123 -60
- package/package.json +22 -20
- package/pyproject.toml +35 -35
package/fincli/app/tui/layout.py
CHANGED
|
@@ -1,262 +1,273 @@
|
|
|
1
|
-
"""Textual layout for FinCLI."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from collections.abc import Iterable
|
|
6
|
-
from threading import Lock
|
|
7
|
-
|
|
8
|
-
from textual.app import App, ComposeResult, SystemCommand
|
|
9
|
-
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
10
|
-
from textual.css.query import NoMatches
|
|
11
|
-
from textual.screen import Screen
|
|
12
|
-
from textual.worker import Worker, WorkerState
|
|
13
|
-
from textual.widgets import Header, Input, RichLog, Static
|
|
14
|
-
|
|
15
|
-
from fincli import __version__
|
|
16
|
-
from fincli.app.cli.autocomplete import SlashAutocomplete
|
|
17
|
-
from fincli.app.cli.commands import CommandRegistry
|
|
18
|
-
from fincli.app.cli.router import CommandResult, CommandRouter
|
|
19
|
-
from fincli.app.providers.ai.manager import AIProviderManager
|
|
1
|
+
"""Textual layout for FinCLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from threading import Lock
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
9
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
10
|
+
from textual.css.query import NoMatches
|
|
11
|
+
from textual.screen import Screen
|
|
12
|
+
from textual.worker import Worker, WorkerState
|
|
13
|
+
from textual.widgets import Header, Input, RichLog, Static
|
|
14
|
+
|
|
15
|
+
from fincli import __version__
|
|
16
|
+
from fincli.app.cli.autocomplete import SlashAutocomplete
|
|
17
|
+
from fincli.app.cli.commands import CommandRegistry
|
|
18
|
+
from fincli.app.cli.router import CommandResult, CommandRouter
|
|
19
|
+
from fincli.app.providers.ai.manager import AIProviderManager
|
|
20
20
|
from fincli.app.tui.components import CommandPalette, format_thinking_message, format_user_message
|
|
21
|
-
from fincli.app.tui.
|
|
22
|
-
from fincli.app.tui.
|
|
23
|
-
from fincli.app.tui.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
("ctrl+
|
|
35
|
-
("
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
self.
|
|
42
|
-
self.
|
|
43
|
-
self.
|
|
44
|
-
self.
|
|
45
|
-
self.
|
|
46
|
-
self.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
21
|
+
from fincli.app.tui.components import write_output_entry
|
|
22
|
+
from fincli.app.tui.market_provider_selector import MarketProviderSelectorScreen
|
|
23
|
+
from fincli.app.tui.model_selector import AIModelSelectorScreen
|
|
24
|
+
from fincli.app.tui.theme import APP_CSS
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FinCLIApp(App[None]):
|
|
28
|
+
"""Modern terminal dashboard for FinCLI v0.1."""
|
|
29
|
+
|
|
30
|
+
CSS = APP_CSS
|
|
31
|
+
TITLE = f"FinCLI v{__version__}"
|
|
32
|
+
SUB_TITLE = "Financial terminal MVP"
|
|
33
|
+
BINDINGS = [
|
|
34
|
+
("ctrl+c", "quit", "Quit"),
|
|
35
|
+
("ctrl+l", "clear_output", "Clear"),
|
|
36
|
+
("f1", "help", "Help"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.registry = CommandRegistry()
|
|
42
|
+
self.autocomplete = SlashAutocomplete(self.registry)
|
|
43
|
+
self.router = CommandRouter(registry=self.registry)
|
|
44
|
+
self._route_lock = Lock()
|
|
45
|
+
self._worker_index = 0
|
|
46
|
+
self._latest_worker_sequence = 0
|
|
47
|
+
self._worker_meta: dict[str, dict[str, str | bool]] = {}
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
yield Header(show_clock=True)
|
|
51
|
+
with Vertical(id="workspace"):
|
|
52
|
+
yield Static(
|
|
53
|
+
f"FINCLI v{__version__} | PROFESSIONAL RESEARCH TERMINAL | LIVE WORKSPACE",
|
|
54
|
+
id="top_strip",
|
|
55
|
+
)
|
|
56
|
+
yield Static(
|
|
57
|
+
"MARKET DESK /research /news /macro ANALYSIS /technical /analyze /mtf RISK /profile /portfolio /journal",
|
|
58
|
+
id="market_ribbon",
|
|
59
|
+
)
|
|
60
|
+
yield Static("OUTPUT STREAM | green=bullish/positive | red=bearish/negative | yellow=caution/wait", id="output_header")
|
|
61
|
+
with Vertical(id="output_frame"):
|
|
52
62
|
yield RichLog(id="output", wrap=True, markup=True, highlight=True)
|
|
53
|
-
yield Static("ready | /
|
|
63
|
+
yield Static("ready | /research AAPL --quick | /analyze XAUUSD | /provider status", id="status_bar")
|
|
54
64
|
with Vertical(id="command_area"):
|
|
65
|
+
yield Static("Type a question for AI chat or use slash commands. Start with / for autocomplete.", id="command_hint")
|
|
55
66
|
with Horizontal(id="command_line"):
|
|
56
|
-
yield Static("> ", id="command_prompt")
|
|
57
|
-
yield Input(placeholder="/", id="command_input")
|
|
58
|
-
with VerticalScroll(id="command_palette_scroll"):
|
|
59
|
-
yield CommandPalette(id="command_palette")
|
|
60
|
-
|
|
61
|
-
def on_mount(self) -> None:
|
|
62
|
-
palette = self.query_one(CommandPalette)
|
|
63
|
-
palette.clear_palette()
|
|
64
|
-
self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
|
|
65
|
-
output = self.query_one("#output", RichLog)
|
|
66
|
-
output
|
|
67
|
-
self._submit_route("/dashboard", display_raw="/dashboard", clear_output_before_result=True)
|
|
68
|
-
self.query_one("#command_input", Input).focus()
|
|
69
|
-
|
|
70
|
-
def on_input_changed(self, event: Input.Changed) -> None:
|
|
71
|
-
palette = self.query_one(CommandPalette)
|
|
72
|
-
palette_scroll = self.query_one("#command_palette_scroll", VerticalScroll)
|
|
73
|
-
value = event.value.strip()
|
|
74
|
-
if not value.startswith("/"):
|
|
75
|
-
palette.clear_palette()
|
|
76
|
-
palette_scroll.styles.display = "none"
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
suggestions = self.autocomplete.suggestions_for(value)
|
|
80
|
-
palette_scroll.styles.display = "block"
|
|
81
|
-
palette_scroll.scroll_home(animate=False)
|
|
82
|
-
palette.render_commands(suggestions, value)
|
|
83
|
-
|
|
84
|
-
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
85
|
-
event.input.value = ""
|
|
86
|
-
palette = self.query_one(CommandPalette)
|
|
87
|
-
palette.clear_palette()
|
|
88
|
-
self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
|
|
89
|
-
raw = event.value.strip()
|
|
90
|
-
output = self.query_one("#output", RichLog)
|
|
91
|
-
status = self.query_one("#status_bar", Static)
|
|
92
|
-
if raw.lower() == "/ai_model":
|
|
93
|
-
self.push_screen(AIModelSelectorScreen(self.router.config, self._set_ai_model_from_selector))
|
|
94
|
-
status.update("selecting ai model | esc to close")
|
|
95
|
-
return
|
|
96
|
-
if raw.lower() == "/news_model":
|
|
97
|
-
self.push_screen(MarketProviderSelectorScreen(self.router.config, self._set_market_provider_from_selector))
|
|
98
|
-
status.update("selecting market/news provider | esc to close")
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
if raw and (not raw.startswith("/") or raw.lower().startswith("/ai ")):
|
|
102
|
-
prompt = raw[4:].strip() if raw.lower().startswith("/ai ") else raw
|
|
103
|
-
self._handle_ai_chat(prompt)
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
if raw.lower() == "/clear":
|
|
107
|
-
self._invalidate_pending_workers()
|
|
108
|
-
output.clear()
|
|
109
|
-
status.update("cleared | /help untuk command")
|
|
110
|
-
return
|
|
111
|
-
if raw.lower() == "/exit":
|
|
112
|
-
self.exit()
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
self._submit_route(raw, display_raw=raw)
|
|
116
|
-
|
|
117
|
-
def action_clear_output(self) -> None:
|
|
118
|
-
self._invalidate_pending_workers()
|
|
119
|
-
self.query_one("#output", RichLog).clear()
|
|
120
|
-
self.query_one("#status_bar", Static).update("cleared | /help untuk command")
|
|
121
|
-
|
|
122
|
-
def action_help(self) -> None:
|
|
123
|
-
output = self.query_one("#output", RichLog)
|
|
124
|
-
output
|
|
125
|
-
|
|
126
|
-
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
127
|
-
"""Return a curated command palette for FinCLI."""
|
|
128
|
-
yield SystemCommand(
|
|
129
|
-
"Keys",
|
|
130
|
-
"Show or hide FinCLI keyboard shortcuts.",
|
|
131
|
-
self._toggle_help_panel,
|
|
132
|
-
)
|
|
133
|
-
yield SystemCommand(
|
|
134
|
-
"Maximize Panel",
|
|
135
|
-
"Maximize the active FinCLI panel.",
|
|
136
|
-
screen.action_maximize,
|
|
137
|
-
)
|
|
138
|
-
yield SystemCommand(
|
|
139
|
-
"Save Screenshot",
|
|
140
|
-
"Save the current FinCLI screen as an SVG file.",
|
|
141
|
-
lambda: self.set_timer(0.1, self.deliver_screenshot),
|
|
142
|
-
)
|
|
143
|
-
yield SystemCommand(
|
|
144
|
-
"Change Theme",
|
|
145
|
-
"Open Textual theme selector for the terminal UI.",
|
|
146
|
-
self.action_change_theme,
|
|
147
|
-
)
|
|
148
|
-
yield SystemCommand(
|
|
149
|
-
"Clear Output",
|
|
150
|
-
"Clear the main output log.",
|
|
151
|
-
self.action_clear_output,
|
|
152
|
-
)
|
|
153
|
-
yield SystemCommand(
|
|
154
|
-
"Quit FinCLI",
|
|
155
|
-
"Exit FinCLI and return to the terminal.",
|
|
156
|
-
self.action_quit,
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
def _toggle_help_panel(self) -> None:
|
|
160
|
-
if self.screen.query("HelpPanel"):
|
|
161
|
-
self.action_hide_help_panel()
|
|
162
|
-
else:
|
|
163
|
-
self.action_show_help_panel()
|
|
164
|
-
|
|
165
|
-
def _set_ai_model_from_selector(self, provider: str, model: str) -> None:
|
|
166
|
-
self.router.ai_provider = AIProviderManager().create(provider)
|
|
167
|
-
output = self.query_one("#output", RichLog)
|
|
168
|
-
status = self.query_one("#status_bar", Static)
|
|
169
|
-
output
|
|
170
|
-
status.update(f"ready | ai model: {provider}/{model}")
|
|
171
|
-
|
|
172
|
-
def _set_market_provider_from_selector(self, providers: tuple[str, ...]) -> None:
|
|
173
|
-
self.router._refresh_market_service()
|
|
174
|
-
self.router.cache.clear()
|
|
175
|
-
output = self.query_one("#output", RichLog)
|
|
176
|
-
status = self.query_one("#status_bar", Static)
|
|
177
|
-
output
|
|
178
|
-
status.update(f"ready | market provider: {providers[0] if providers else 'yfinance'}")
|
|
179
|
-
|
|
180
|
-
def _handle_ai_chat(self, prompt: str) -> None:
|
|
181
|
-
output = self.query_one("#output", RichLog)
|
|
182
|
-
status = self.query_one("#status_bar", Static)
|
|
183
|
-
output
|
|
184
|
-
output
|
|
185
|
-
self._submit_route(f"/ai {prompt}", display_raw="/ai", chat=True)
|
|
186
|
-
|
|
187
|
-
def _submit_route(
|
|
188
|
-
self,
|
|
189
|
-
raw: str,
|
|
190
|
-
*,
|
|
191
|
-
display_raw: str,
|
|
192
|
-
chat: bool = False,
|
|
193
|
-
clear_output_before_result: bool = False,
|
|
194
|
-
) -> None:
|
|
195
|
-
"""Run a router command without blocking Textual's UI thread."""
|
|
196
|
-
self._worker_index += 1
|
|
197
|
-
worker_name = f"route-{self._worker_index}"
|
|
198
|
-
self._latest_worker_sequence = self._worker_index
|
|
199
|
-
self._worker_meta[worker_name] = {
|
|
200
|
-
"raw": raw,
|
|
201
|
-
"display_raw": display_raw,
|
|
202
|
-
"chat": chat,
|
|
203
|
-
"clear_output_before_result": clear_output_before_result,
|
|
204
|
-
"sequence": str(self._worker_index),
|
|
205
|
-
}
|
|
206
|
-
self.query_one("#status_bar", Static).update(f"running | {display_raw}")
|
|
207
|
-
self.run_worker(
|
|
208
|
-
lambda: self._route_in_worker(raw),
|
|
209
|
-
name=worker_name,
|
|
210
|
-
group="router",
|
|
211
|
-
description=display_raw,
|
|
212
|
-
thread=True,
|
|
213
|
-
)
|
|
214
|
-
|
|
215
|
-
def _invalidate_pending_workers(self) -> None:
|
|
216
|
-
self._latest_worker_sequence = max(self._latest_worker_sequence, self._worker_index) + 1
|
|
217
|
-
|
|
218
|
-
def _route_in_worker(self, raw: str) -> CommandResult:
|
|
219
|
-
with self._route_lock:
|
|
220
|
-
return self.router.route(raw)
|
|
221
|
-
|
|
222
|
-
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
223
|
-
worker = event.worker
|
|
224
|
-
meta = self._worker_meta.get(worker.name or "")
|
|
225
|
-
if meta is None:
|
|
226
|
-
return
|
|
227
|
-
if event.state not in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
|
|
228
|
-
return
|
|
229
|
-
|
|
230
|
-
self._worker_meta.pop(worker.name or "", None)
|
|
231
|
-
sequence = int(str(meta.get("sequence", "0")))
|
|
232
|
-
if sequence < self._latest_worker_sequence:
|
|
233
|
-
return
|
|
234
|
-
try:
|
|
235
|
-
output = self.query_one("#output", RichLog)
|
|
236
|
-
status = self.query_one("#status_bar", Static)
|
|
237
|
-
except NoMatches:
|
|
238
|
-
return
|
|
239
|
-
display_raw = str(meta["display_raw"])
|
|
240
|
-
|
|
241
|
-
if event.state == WorkerState.CANCELLED:
|
|
242
|
-
status.update(f"cancelled | {display_raw}")
|
|
243
|
-
return
|
|
244
|
-
if event.state == WorkerState.ERROR:
|
|
245
|
-
output
|
|
246
|
-
status.update(f"error | {display_raw}")
|
|
247
|
-
return
|
|
248
|
-
|
|
249
|
-
result = worker.result
|
|
250
|
-
if bool(meta.get("clear_output_before_result")):
|
|
251
|
-
output.clear()
|
|
252
|
-
if result.clear:
|
|
253
|
-
output.clear()
|
|
254
|
-
elif result.renderable:
|
|
255
|
-
output
|
|
256
|
-
|
|
257
|
-
if bool(meta.get("chat")):
|
|
258
|
-
status.update(f"{result.status} | ai chat")
|
|
259
|
-
else:
|
|
260
|
-
status.update(f"{result.status} | last: {display_raw or 'empty'}")
|
|
261
|
-
if result.should_exit:
|
|
262
|
-
self.exit()
|
|
67
|
+
yield Static("F> ", id="command_prompt")
|
|
68
|
+
yield Input(placeholder="Ask FinCLI or type /help", id="command_input")
|
|
69
|
+
with VerticalScroll(id="command_palette_scroll"):
|
|
70
|
+
yield CommandPalette(id="command_palette")
|
|
71
|
+
|
|
72
|
+
def on_mount(self) -> None:
|
|
73
|
+
palette = self.query_one(CommandPalette)
|
|
74
|
+
palette.clear_palette()
|
|
75
|
+
self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
|
|
76
|
+
output = self.query_one("#output", RichLog)
|
|
77
|
+
write_output_entry(output, "Loading dashboard...")
|
|
78
|
+
self._submit_route("/dashboard", display_raw="/dashboard", clear_output_before_result=True)
|
|
79
|
+
self.query_one("#command_input", Input).focus()
|
|
80
|
+
|
|
81
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
82
|
+
palette = self.query_one(CommandPalette)
|
|
83
|
+
palette_scroll = self.query_one("#command_palette_scroll", VerticalScroll)
|
|
84
|
+
value = event.value.strip()
|
|
85
|
+
if not value.startswith("/"):
|
|
86
|
+
palette.clear_palette()
|
|
87
|
+
palette_scroll.styles.display = "none"
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
suggestions = self.autocomplete.suggestions_for(value)
|
|
91
|
+
palette_scroll.styles.display = "block"
|
|
92
|
+
palette_scroll.scroll_home(animate=False)
|
|
93
|
+
palette.render_commands(suggestions, value)
|
|
94
|
+
|
|
95
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
96
|
+
event.input.value = ""
|
|
97
|
+
palette = self.query_one(CommandPalette)
|
|
98
|
+
palette.clear_palette()
|
|
99
|
+
self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
|
|
100
|
+
raw = event.value.strip()
|
|
101
|
+
output = self.query_one("#output", RichLog)
|
|
102
|
+
status = self.query_one("#status_bar", Static)
|
|
103
|
+
if raw.lower() == "/ai_model":
|
|
104
|
+
self.push_screen(AIModelSelectorScreen(self.router.config, self._set_ai_model_from_selector))
|
|
105
|
+
status.update("selecting ai model | esc to close")
|
|
106
|
+
return
|
|
107
|
+
if raw.lower() == "/news_model":
|
|
108
|
+
self.push_screen(MarketProviderSelectorScreen(self.router.config, self._set_market_provider_from_selector))
|
|
109
|
+
status.update("selecting market/news provider | esc to close")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if raw and (not raw.startswith("/") or raw.lower().startswith("/ai ")):
|
|
113
|
+
prompt = raw[4:].strip() if raw.lower().startswith("/ai ") else raw
|
|
114
|
+
self._handle_ai_chat(prompt)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if raw.lower() == "/clear":
|
|
118
|
+
self._invalidate_pending_workers()
|
|
119
|
+
output.clear()
|
|
120
|
+
status.update("cleared | /help untuk command")
|
|
121
|
+
return
|
|
122
|
+
if raw.lower() == "/exit":
|
|
123
|
+
self.exit()
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
self._submit_route(raw, display_raw=raw)
|
|
127
|
+
|
|
128
|
+
def action_clear_output(self) -> None:
|
|
129
|
+
self._invalidate_pending_workers()
|
|
130
|
+
self.query_one("#output", RichLog).clear()
|
|
131
|
+
self.query_one("#status_bar", Static).update("cleared | /help untuk command")
|
|
132
|
+
|
|
133
|
+
def action_help(self) -> None:
|
|
134
|
+
output = self.query_one("#output", RichLog)
|
|
135
|
+
write_output_entry(output, self.router.route("/help").renderable)
|
|
136
|
+
|
|
137
|
+
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
138
|
+
"""Return a curated command palette for FinCLI."""
|
|
139
|
+
yield SystemCommand(
|
|
140
|
+
"Keys",
|
|
141
|
+
"Show or hide FinCLI keyboard shortcuts.",
|
|
142
|
+
self._toggle_help_panel,
|
|
143
|
+
)
|
|
144
|
+
yield SystemCommand(
|
|
145
|
+
"Maximize Panel",
|
|
146
|
+
"Maximize the active FinCLI panel.",
|
|
147
|
+
screen.action_maximize,
|
|
148
|
+
)
|
|
149
|
+
yield SystemCommand(
|
|
150
|
+
"Save Screenshot",
|
|
151
|
+
"Save the current FinCLI screen as an SVG file.",
|
|
152
|
+
lambda: self.set_timer(0.1, self.deliver_screenshot),
|
|
153
|
+
)
|
|
154
|
+
yield SystemCommand(
|
|
155
|
+
"Change Theme",
|
|
156
|
+
"Open Textual theme selector for the terminal UI.",
|
|
157
|
+
self.action_change_theme,
|
|
158
|
+
)
|
|
159
|
+
yield SystemCommand(
|
|
160
|
+
"Clear Output",
|
|
161
|
+
"Clear the main output log.",
|
|
162
|
+
self.action_clear_output,
|
|
163
|
+
)
|
|
164
|
+
yield SystemCommand(
|
|
165
|
+
"Quit FinCLI",
|
|
166
|
+
"Exit FinCLI and return to the terminal.",
|
|
167
|
+
self.action_quit,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _toggle_help_panel(self) -> None:
|
|
171
|
+
if self.screen.query("HelpPanel"):
|
|
172
|
+
self.action_hide_help_panel()
|
|
173
|
+
else:
|
|
174
|
+
self.action_show_help_panel()
|
|
175
|
+
|
|
176
|
+
def _set_ai_model_from_selector(self, provider: str, model: str) -> None:
|
|
177
|
+
self.router.ai_provider = AIProviderManager().create(provider)
|
|
178
|
+
output = self.query_one("#output", RichLog)
|
|
179
|
+
status = self.query_one("#status_bar", Static)
|
|
180
|
+
write_output_entry(output, f"AI model aktif: {provider} / {model}")
|
|
181
|
+
status.update(f"ready | ai model: {provider}/{model}")
|
|
182
|
+
|
|
183
|
+
def _set_market_provider_from_selector(self, providers: tuple[str, ...]) -> None:
|
|
184
|
+
self.router._refresh_market_service()
|
|
185
|
+
self.router.cache.clear()
|
|
186
|
+
output = self.query_one("#output", RichLog)
|
|
187
|
+
status = self.query_one("#status_bar", Static)
|
|
188
|
+
write_output_entry(output, f"Provider market/news priority aktif: {', '.join(providers)}")
|
|
189
|
+
status.update(f"ready | market provider: {providers[0] if providers else 'yfinance'}")
|
|
190
|
+
|
|
191
|
+
def _handle_ai_chat(self, prompt: str) -> None:
|
|
192
|
+
output = self.query_one("#output", RichLog)
|
|
193
|
+
status = self.query_one("#status_bar", Static)
|
|
194
|
+
write_output_entry(output, format_user_message(prompt))
|
|
195
|
+
write_output_entry(output, format_thinking_message("routing prompt to active AI provider..."))
|
|
196
|
+
self._submit_route(f"/ai {prompt}", display_raw="/ai", chat=True)
|
|
197
|
+
|
|
198
|
+
def _submit_route(
|
|
199
|
+
self,
|
|
200
|
+
raw: str,
|
|
201
|
+
*,
|
|
202
|
+
display_raw: str,
|
|
203
|
+
chat: bool = False,
|
|
204
|
+
clear_output_before_result: bool = False,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Run a router command without blocking Textual's UI thread."""
|
|
207
|
+
self._worker_index += 1
|
|
208
|
+
worker_name = f"route-{self._worker_index}"
|
|
209
|
+
self._latest_worker_sequence = self._worker_index
|
|
210
|
+
self._worker_meta[worker_name] = {
|
|
211
|
+
"raw": raw,
|
|
212
|
+
"display_raw": display_raw,
|
|
213
|
+
"chat": chat,
|
|
214
|
+
"clear_output_before_result": clear_output_before_result,
|
|
215
|
+
"sequence": str(self._worker_index),
|
|
216
|
+
}
|
|
217
|
+
self.query_one("#status_bar", Static).update(f"running | {display_raw}")
|
|
218
|
+
self.run_worker(
|
|
219
|
+
lambda: self._route_in_worker(raw),
|
|
220
|
+
name=worker_name,
|
|
221
|
+
group="router",
|
|
222
|
+
description=display_raw,
|
|
223
|
+
thread=True,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _invalidate_pending_workers(self) -> None:
|
|
227
|
+
self._latest_worker_sequence = max(self._latest_worker_sequence, self._worker_index) + 1
|
|
228
|
+
|
|
229
|
+
def _route_in_worker(self, raw: str) -> CommandResult:
|
|
230
|
+
with self._route_lock:
|
|
231
|
+
return self.router.route(raw)
|
|
232
|
+
|
|
233
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
234
|
+
worker = event.worker
|
|
235
|
+
meta = self._worker_meta.get(worker.name or "")
|
|
236
|
+
if meta is None:
|
|
237
|
+
return
|
|
238
|
+
if event.state not in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
self._worker_meta.pop(worker.name or "", None)
|
|
242
|
+
sequence = int(str(meta.get("sequence", "0")))
|
|
243
|
+
if sequence < self._latest_worker_sequence:
|
|
244
|
+
return
|
|
245
|
+
try:
|
|
246
|
+
output = self.query_one("#output", RichLog)
|
|
247
|
+
status = self.query_one("#status_bar", Static)
|
|
248
|
+
except NoMatches:
|
|
249
|
+
return
|
|
250
|
+
display_raw = str(meta["display_raw"])
|
|
251
|
+
|
|
252
|
+
if event.state == WorkerState.CANCELLED:
|
|
253
|
+
status.update(f"cancelled | {display_raw}")
|
|
254
|
+
return
|
|
255
|
+
if event.state == WorkerState.ERROR:
|
|
256
|
+
write_output_entry(output, f"Error menjalankan {display_raw}: {worker.error}")
|
|
257
|
+
status.update(f"error | {display_raw}")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
result = worker.result
|
|
261
|
+
if bool(meta.get("clear_output_before_result")):
|
|
262
|
+
output.clear()
|
|
263
|
+
if result.clear:
|
|
264
|
+
output.clear()
|
|
265
|
+
elif result.renderable:
|
|
266
|
+
write_output_entry(output, result.renderable)
|
|
267
|
+
|
|
268
|
+
if bool(meta.get("chat")):
|
|
269
|
+
status.update(f"{result.status} | ai chat")
|
|
270
|
+
else:
|
|
271
|
+
status.update(f"{result.status} | last: {display_raw or 'empty'}")
|
|
272
|
+
if result.should_exit:
|
|
273
|
+
self.exit()
|
|
@@ -39,14 +39,16 @@ PROVIDER_LABELS = {
|
|
|
39
39
|
"custom": "Custom API",
|
|
40
40
|
"finnhub": "Finnhub",
|
|
41
41
|
"twelvedata": "Twelve Data",
|
|
42
|
+
"alphavantage": "Alpha Vantage",
|
|
42
43
|
}
|
|
43
44
|
PROVIDER_ENV_KEYS = {
|
|
44
45
|
"yfinance": (),
|
|
45
46
|
"custom": ("MARKET_DATA_API_KEY", "MARKET_DATA_BASE_URL"),
|
|
46
47
|
"finnhub": ("FINNHUB_API_KEY",),
|
|
47
48
|
"twelvedata": ("TWELVE_DATA_API_KEY",),
|
|
49
|
+
"alphavantage": ("ALPHA_VANTAGE_API_KEY",),
|
|
48
50
|
}
|
|
49
|
-
DEFAULT_FALLBACK_ORDER = ("twelvedata", "finnhub", "custom", "yfinance")
|
|
51
|
+
DEFAULT_FALLBACK_ORDER = ("twelvedata", "finnhub", "alphavantage", "custom", "yfinance")
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
def market_provider_choices() -> tuple[MarketProviderChoice, ...]:
|