@drico2008/fincli 0.1.3 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +909 -684
- package/fincli/__init__.py +3 -3
- package/fincli/app/agents/__init__.py +5 -0
- package/fincli/app/agents/registry.py +76 -0
- package/fincli/app/analysis/ai_prompts.py +23 -16
- package/fincli/app/analysis/analyzer.py +107 -100
- package/fincli/app/analysis/assistant_context.py +187 -160
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/indicators.py +1 -1
- package/fincli/app/analysis/market_structure.py +1 -1
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +105 -77
- package/fincli/app/cli/router.py +2143 -1121
- package/fincli/app/connectors/__init__.py +5 -0
- package/fincli/app/connectors/catalog.py +148 -0
- package/fincli/app/connectors/news_connectors.py +412 -0
- package/fincli/app/modules/alerts.py +80 -0
- package/fincli/app/modules/economic_calendar.py +374 -1
- package/fincli/app/modules/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/session_history.py +113 -0
- 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/anthropic_provider.py +8 -7
- package/fincli/app/providers/ai/gemini_provider.py +8 -7
- package/fincli/app/providers/ai/groq_provider.py +8 -7
- package/fincli/app/providers/ai/huggingface_provider.py +8 -7
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/ai/openai_provider.py +8 -7
- package/fincli/app/providers/ai/openrouter_provider.py +8 -7
- package/fincli/app/providers/ai/together_provider.py +8 -7
- 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 +85 -2
- package/fincli/app/providers/market/news_provider.py +4 -4
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/providers/market/yfinance_provider.py +1 -1
- package/fincli/app/research/__init__.py +7 -0
- package/fincli/app/research/engine.py +75 -0
- package/fincli/app/research/formatter.py +22 -0
- package/fincli/app/research/models.py +18 -0
- package/fincli/app/research/prompt_builder.py +47 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +203 -203
- package/fincli/app/services/news_aggregator.py +90 -0
- package/fincli/app/services/web_research.py +267 -0
- package/fincli/app/storage/cache.py +2 -2
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +201 -85
- package/fincli/app/storage/secrets.py +12 -3
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +270 -258
- package/fincli/app/tui/market_provider_selector.py +6 -1
- package/fincli/app/tui/model_selector.py +11 -3
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +125 -12
- package/npm/bin/fincli.js +9 -2
- package/package.json +23 -23
- package/pyproject.toml +35 -35
package/fincli/app/tui/layout.py
CHANGED
|
@@ -1,261 +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.
|
|
11
|
-
from textual.
|
|
12
|
-
from textual.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
from fincli
|
|
16
|
-
from fincli.app.cli.
|
|
17
|
-
from fincli.app.cli.
|
|
18
|
-
from fincli.app.
|
|
19
|
-
from fincli.app.
|
|
20
|
-
from fincli.app.tui.
|
|
21
|
-
from fincli.app.tui.
|
|
22
|
-
from fincli.app.tui.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
("
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
self.
|
|
42
|
-
self.
|
|
43
|
-
self.
|
|
44
|
-
self.
|
|
45
|
-
self.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
from fincli.app.tui.components import CommandPalette, format_thinking_message, format_user_message
|
|
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"):
|
|
51
62
|
yield RichLog(id="output", wrap=True, markup=True, highlight=True)
|
|
52
|
-
yield Static("ready | /
|
|
63
|
+
yield Static("ready | /research AAPL --quick | /analyze XAUUSD | /provider status", id="status_bar")
|
|
53
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")
|
|
54
66
|
with Horizontal(id="command_line"):
|
|
55
|
-
yield Static("> ", id="command_prompt")
|
|
56
|
-
yield Input(placeholder="/", id="command_input")
|
|
57
|
-
with VerticalScroll(id="command_palette_scroll"):
|
|
58
|
-
yield CommandPalette(id="command_palette")
|
|
59
|
-
|
|
60
|
-
def on_mount(self) -> None:
|
|
61
|
-
palette = self.query_one(CommandPalette)
|
|
62
|
-
palette.clear_palette()
|
|
63
|
-
self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
|
|
64
|
-
output = self.query_one("#output", RichLog)
|
|
65
|
-
output
|
|
66
|
-
self._submit_route("/dashboard", display_raw="/dashboard", clear_output_before_result=True)
|
|
67
|
-
self.query_one("#command_input", Input).focus()
|
|
68
|
-
|
|
69
|
-
def on_input_changed(self, event: Input.Changed) -> None:
|
|
70
|
-
palette = self.query_one(CommandPalette)
|
|
71
|
-
palette_scroll = self.query_one("#command_palette_scroll", VerticalScroll)
|
|
72
|
-
value = event.value.strip()
|
|
73
|
-
if not value.startswith("/"):
|
|
74
|
-
palette.clear_palette()
|
|
75
|
-
palette_scroll.styles.display = "none"
|
|
76
|
-
return
|
|
77
|
-
|
|
78
|
-
suggestions = self.autocomplete.suggestions_for(value)
|
|
79
|
-
palette_scroll.styles.display = "block"
|
|
80
|
-
palette_scroll.scroll_home(animate=False)
|
|
81
|
-
palette.render_commands(suggestions, value)
|
|
82
|
-
|
|
83
|
-
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
84
|
-
event.input.value = ""
|
|
85
|
-
palette = self.query_one(CommandPalette)
|
|
86
|
-
palette.clear_palette()
|
|
87
|
-
self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
|
|
88
|
-
raw = event.value.strip()
|
|
89
|
-
output = self.query_one("#output", RichLog)
|
|
90
|
-
status = self.query_one("#status_bar", Static)
|
|
91
|
-
if raw.lower() == "/ai_model":
|
|
92
|
-
self.push_screen(AIModelSelectorScreen(self.router.config, self._set_ai_model_from_selector))
|
|
93
|
-
status.update("selecting ai model | esc to close")
|
|
94
|
-
return
|
|
95
|
-
if raw.lower() == "/news_model":
|
|
96
|
-
self.push_screen(MarketProviderSelectorScreen(self.router.config, self._set_market_provider_from_selector))
|
|
97
|
-
status.update("selecting market/news provider | esc to close")
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
if raw and (not raw.startswith("/") or raw.lower().startswith("/ai ")):
|
|
101
|
-
prompt = raw[4:].strip() if raw.lower().startswith("/ai ") else raw
|
|
102
|
-
self._handle_ai_chat(prompt)
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
if raw.lower() == "/clear":
|
|
106
|
-
self._invalidate_pending_workers()
|
|
107
|
-
output.clear()
|
|
108
|
-
status.update("cleared | /help untuk command")
|
|
109
|
-
return
|
|
110
|
-
if raw.lower() == "/exit":
|
|
111
|
-
self.exit()
|
|
112
|
-
return
|
|
113
|
-
|
|
114
|
-
self._submit_route(raw, display_raw=raw)
|
|
115
|
-
|
|
116
|
-
def action_clear_output(self) -> None:
|
|
117
|
-
self._invalidate_pending_workers()
|
|
118
|
-
self.query_one("#output", RichLog).clear()
|
|
119
|
-
self.query_one("#status_bar", Static).update("cleared | /help untuk command")
|
|
120
|
-
|
|
121
|
-
def action_help(self) -> None:
|
|
122
|
-
output = self.query_one("#output", RichLog)
|
|
123
|
-
output
|
|
124
|
-
|
|
125
|
-
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
126
|
-
"""Return a curated command palette for FinCLI."""
|
|
127
|
-
yield SystemCommand(
|
|
128
|
-
"Keys",
|
|
129
|
-
"Show or hide FinCLI keyboard shortcuts.",
|
|
130
|
-
self._toggle_help_panel,
|
|
131
|
-
)
|
|
132
|
-
yield SystemCommand(
|
|
133
|
-
"Maximize Panel",
|
|
134
|
-
"Maximize the active FinCLI panel.",
|
|
135
|
-
screen.action_maximize,
|
|
136
|
-
)
|
|
137
|
-
yield SystemCommand(
|
|
138
|
-
"Save Screenshot",
|
|
139
|
-
"Save the current FinCLI screen as an SVG file.",
|
|
140
|
-
lambda: self.set_timer(0.1, self.deliver_screenshot),
|
|
141
|
-
)
|
|
142
|
-
yield SystemCommand(
|
|
143
|
-
"Change Theme",
|
|
144
|
-
"Open Textual theme selector for the terminal UI.",
|
|
145
|
-
self.action_change_theme,
|
|
146
|
-
)
|
|
147
|
-
yield SystemCommand(
|
|
148
|
-
"Clear Output",
|
|
149
|
-
"Clear the main output log.",
|
|
150
|
-
self.action_clear_output,
|
|
151
|
-
)
|
|
152
|
-
yield SystemCommand(
|
|
153
|
-
"Quit FinCLI",
|
|
154
|
-
"Exit FinCLI and return to the terminal.",
|
|
155
|
-
self.action_quit,
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
def _toggle_help_panel(self) -> None:
|
|
159
|
-
if self.screen.query("HelpPanel"):
|
|
160
|
-
self.action_hide_help_panel()
|
|
161
|
-
else:
|
|
162
|
-
self.action_show_help_panel()
|
|
163
|
-
|
|
164
|
-
def _set_ai_model_from_selector(self, provider: str, model: str) -> None:
|
|
165
|
-
self.router.ai_provider = AIProviderManager().create(provider)
|
|
166
|
-
output = self.query_one("#output", RichLog)
|
|
167
|
-
status = self.query_one("#status_bar", Static)
|
|
168
|
-
output
|
|
169
|
-
status.update(f"ready | ai model: {provider}/{model}")
|
|
170
|
-
|
|
171
|
-
def _set_market_provider_from_selector(self, providers: tuple[str, ...]) -> None:
|
|
172
|
-
self.router._refresh_market_service()
|
|
173
|
-
self.router.cache.clear()
|
|
174
|
-
output = self.query_one("#output", RichLog)
|
|
175
|
-
status = self.query_one("#status_bar", Static)
|
|
176
|
-
output
|
|
177
|
-
status.update(f"ready | market provider: {providers[0] if providers else 'yfinance'}")
|
|
178
|
-
|
|
179
|
-
def _handle_ai_chat(self, prompt: str) -> None:
|
|
180
|
-
output = self.query_one("#output", RichLog)
|
|
181
|
-
status = self.query_one("#status_bar", Static)
|
|
182
|
-
output
|
|
183
|
-
output
|
|
184
|
-
self._submit_route(f"/ai {prompt}", display_raw="/ai", chat=True)
|
|
185
|
-
|
|
186
|
-
def _submit_route(
|
|
187
|
-
self,
|
|
188
|
-
raw: str,
|
|
189
|
-
*,
|
|
190
|
-
display_raw: str,
|
|
191
|
-
chat: bool = False,
|
|
192
|
-
clear_output_before_result: bool = False,
|
|
193
|
-
) -> None:
|
|
194
|
-
"""Run a router command without blocking Textual's UI thread."""
|
|
195
|
-
self._worker_index += 1
|
|
196
|
-
worker_name = f"route-{self._worker_index}"
|
|
197
|
-
self._latest_worker_sequence = self._worker_index
|
|
198
|
-
self._worker_meta[worker_name] = {
|
|
199
|
-
"raw": raw,
|
|
200
|
-
"display_raw": display_raw,
|
|
201
|
-
"chat": chat,
|
|
202
|
-
"clear_output_before_result": clear_output_before_result,
|
|
203
|
-
"sequence": str(self._worker_index),
|
|
204
|
-
}
|
|
205
|
-
self.query_one("#status_bar", Static).update(f"running | {display_raw}")
|
|
206
|
-
self.run_worker(
|
|
207
|
-
lambda: self._route_in_worker(raw),
|
|
208
|
-
name=worker_name,
|
|
209
|
-
group="router",
|
|
210
|
-
description=display_raw,
|
|
211
|
-
thread=True,
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
def _invalidate_pending_workers(self) -> None:
|
|
215
|
-
self._latest_worker_sequence = max(self._latest_worker_sequence, self._worker_index) + 1
|
|
216
|
-
|
|
217
|
-
def _route_in_worker(self, raw: str) -> CommandResult:
|
|
218
|
-
with self._route_lock:
|
|
219
|
-
return self.router.route(raw)
|
|
220
|
-
|
|
221
|
-
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
222
|
-
worker = event.worker
|
|
223
|
-
meta = self._worker_meta.get(worker.name or "")
|
|
224
|
-
if meta is None:
|
|
225
|
-
return
|
|
226
|
-
if event.state not in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
|
|
227
|
-
return
|
|
228
|
-
|
|
229
|
-
self._worker_meta.pop(worker.name or "", None)
|
|
230
|
-
sequence = int(str(meta.get("sequence", "0")))
|
|
231
|
-
if sequence < self._latest_worker_sequence:
|
|
232
|
-
return
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if event.state == WorkerState.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if bool(meta.get("chat")):
|
|
257
|
-
status.update(f"{result.status} | ai chat")
|
|
258
|
-
else:
|
|
259
|
-
status.update(f"{result.status} | last: {display_raw or 'empty'}")
|
|
260
|
-
if result.should_exit:
|
|
261
|
-
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, ...]:
|
|
@@ -291,6 +293,9 @@ class MarketProviderSelectorScreen(ModalScreen[tuple[str, ...] | None]):
|
|
|
291
293
|
if self.pending_env_keys:
|
|
292
294
|
self._set_mode("key")
|
|
293
295
|
else:
|
|
296
|
+
providers = recommended_provider_priority(self.selected_provider)
|
|
297
|
+
self.config.set_market_provider_priority(list(providers))
|
|
298
|
+
self.on_selected(providers)
|
|
294
299
|
self._set_mode("priority")
|
|
295
300
|
|
|
296
301
|
|
|
@@ -425,6 +425,9 @@ class AIModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
|
|
425
425
|
if choice is None or not value.strip():
|
|
426
426
|
return
|
|
427
427
|
save_secret(choice.env_key, value)
|
|
428
|
+
model = self.config.settings.ai_model if self.config.settings.ai_provider == choice.provider else _default_model(choice.provider)
|
|
429
|
+
self.config.set_ai_model(choice.provider, model)
|
|
430
|
+
self.on_selected(choice.provider, model)
|
|
428
431
|
self._set_mode("model")
|
|
429
432
|
|
|
430
433
|
|
|
@@ -438,9 +441,14 @@ def _has_key(provider: str) -> bool:
|
|
|
438
441
|
return bool(choice and os.getenv(choice.env_key))
|
|
439
442
|
|
|
440
443
|
|
|
441
|
-
def _masked_key(provider: str) -> str:
|
|
444
|
+
def _masked_key(provider: str) -> str:
|
|
442
445
|
choice = _provider_choice(provider)
|
|
443
446
|
if choice is None:
|
|
444
447
|
return "not configured"
|
|
445
|
-
masked = mask_secret(os.getenv(choice.env_key))
|
|
446
|
-
return "not configured" if masked == "not set" else f"configured {masked}"
|
|
448
|
+
masked = mask_secret(os.getenv(choice.env_key))
|
|
449
|
+
return "not configured" if masked == "not set" else f"configured {masked}"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _default_model(provider: str) -> str:
|
|
453
|
+
info = AIProviderManager().get(provider)
|
|
454
|
+
return info.default_model if info else provider
|