@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -684
  3. package/fincli/__init__.py +3 -3
  4. package/fincli/app/agents/__init__.py +5 -0
  5. package/fincli/app/agents/registry.py +76 -0
  6. package/fincli/app/analysis/ai_prompts.py +23 -16
  7. package/fincli/app/analysis/analyzer.py +107 -100
  8. package/fincli/app/analysis/assistant_context.py +187 -160
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/indicators.py +1 -1
  12. package/fincli/app/analysis/market_structure.py +1 -1
  13. package/fincli/app/analysis/multi_timeframe.py +180 -0
  14. package/fincli/app/analysis/trading_methods.py +144 -0
  15. package/fincli/app/cli/commands.py +105 -77
  16. package/fincli/app/cli/router.py +2143 -1121
  17. package/fincli/app/connectors/__init__.py +5 -0
  18. package/fincli/app/connectors/catalog.py +148 -0
  19. package/fincli/app/connectors/news_connectors.py +412 -0
  20. package/fincli/app/modules/alerts.py +80 -0
  21. package/fincli/app/modules/economic_calendar.py +374 -1
  22. package/fincli/app/modules/reports.py +151 -0
  23. package/fincli/app/modules/scanner.py +111 -93
  24. package/fincli/app/modules/session_history.py +113 -0
  25. package/fincli/app/modules/transactions.py +84 -84
  26. package/fincli/app/modules/user_profile.py +84 -0
  27. package/fincli/app/plugins/loader.py +72 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +8 -7
  29. package/fincli/app/providers/ai/gemini_provider.py +8 -7
  30. package/fincli/app/providers/ai/groq_provider.py +8 -7
  31. package/fincli/app/providers/ai/huggingface_provider.py +8 -7
  32. package/fincli/app/providers/ai/manager.py +60 -60
  33. package/fincli/app/providers/ai/openai_provider.py +8 -7
  34. package/fincli/app/providers/ai/openrouter_provider.py +8 -7
  35. package/fincli/app/providers/ai/together_provider.py +8 -7
  36. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  37. package/fincli/app/providers/market/base.py +98 -77
  38. package/fincli/app/providers/market/custom_provider.py +186 -169
  39. package/fincli/app/providers/market/manager.py +85 -2
  40. package/fincli/app/providers/market/news_provider.py +4 -4
  41. package/fincli/app/providers/market/symbols.py +143 -0
  42. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  43. package/fincli/app/providers/market/yfinance_provider.py +1 -1
  44. package/fincli/app/research/__init__.py +7 -0
  45. package/fincli/app/research/engine.py +75 -0
  46. package/fincli/app/research/formatter.py +22 -0
  47. package/fincli/app/research/models.py +18 -0
  48. package/fincli/app/research/prompt_builder.py +47 -0
  49. package/fincli/app/services/macro_data.py +50 -0
  50. package/fincli/app/services/market_data.py +203 -203
  51. package/fincli/app/services/news_aggregator.py +90 -0
  52. package/fincli/app/services/web_research.py +267 -0
  53. package/fincli/app/storage/cache.py +2 -2
  54. package/fincli/app/storage/config.py +122 -88
  55. package/fincli/app/storage/database.py +201 -85
  56. package/fincli/app/storage/secrets.py +12 -3
  57. package/fincli/app/tui/components.py +68 -50
  58. package/fincli/app/tui/layout.py +270 -258
  59. package/fincli/app/tui/market_provider_selector.py +6 -1
  60. package/fincli/app/tui/model_selector.py +11 -3
  61. package/fincli/app/tui/theme.py +134 -74
  62. package/fincli/app/utils/formatting.py +125 -12
  63. package/npm/bin/fincli.js +9 -2
  64. package/package.json +23 -23
  65. package/pyproject.toml +35 -35
@@ -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.screen import Screen
11
- from textual.worker import Worker, WorkerState
12
- from textual.widgets import Header, Input, RichLog, Static
13
-
14
- from fincli import __version__
15
- from fincli.app.cli.autocomplete import SlashAutocomplete
16
- from fincli.app.cli.commands import CommandRegistry
17
- from fincli.app.cli.router import CommandResult, CommandRouter
18
- from fincli.app.providers.ai.manager import AIProviderManager
19
- from fincli.app.tui.components import CommandPalette, format_ai_message, format_thinking_message, format_user_message
20
- from fincli.app.tui.market_provider_selector import MarketProviderSelectorScreen
21
- from fincli.app.tui.model_selector import AIModelSelectorScreen
22
- from fincli.app.tui.theme import APP_CSS
23
-
24
-
25
- class FinCLIApp(App[None]):
26
- """Modern terminal dashboard for FinCLI v0.1."""
27
-
28
- CSS = APP_CSS
29
- TITLE = f"FinCLI v{__version__}"
30
- SUB_TITLE = "Financial terminal MVP"
31
- BINDINGS = [
32
- ("ctrl+c", "quit", "Quit"),
33
- ("ctrl+l", "clear_output", "Clear"),
34
- ("f1", "help", "Help"),
35
- ]
36
-
37
- def __init__(self) -> None:
38
- super().__init__()
39
- self.registry = CommandRegistry()
40
- self.autocomplete = SlashAutocomplete(self.registry)
41
- self.router = CommandRouter(registry=self.registry)
42
- self._route_lock = Lock()
43
- self._worker_index = 0
44
- self._latest_worker_sequence = 0
45
- self._worker_meta: dict[str, dict[str, str | bool]] = {}
46
-
47
- def compose(self) -> ComposeResult:
48
- yield Header(show_clock=True)
49
- with Vertical(id="workspace"):
50
- with Vertical(id="main"):
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 | /dashboard | /market AAPL | /provider status", id="status_bar")
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.write("Loading dashboard...")
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.write(self.router.route("/help").renderable)
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.write(f"AI model aktif: {provider} / {model}")
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.write(f"Provider market/news priority aktif: {', '.join(providers)}")
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.write(format_user_message(prompt))
183
- output.write(format_thinking_message("routing prompt to active AI provider..."))
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
- output = self.query_one("#output", RichLog)
234
- status = self.query_one("#status_bar", Static)
235
- display_raw = str(meta["display_raw"])
236
-
237
- if event.state == WorkerState.CANCELLED:
238
- status.update(f"cancelled | {display_raw}")
239
- return
240
- if event.state == WorkerState.ERROR:
241
- output.write(f"Error menjalankan {display_raw}: {worker.error}")
242
- status.update(f"error | {display_raw}")
243
- return
244
-
245
- result = worker.result
246
- if bool(meta.get("clear_output_before_result")):
247
- output.clear()
248
- if result.clear:
249
- output.clear()
250
- elif result.renderable:
251
- if bool(meta.get("chat")) and result.status == "ready":
252
- output.write(format_ai_message(str(result.renderable)))
253
- else:
254
- output.write(result.renderable)
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