@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -625
  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 +26 -14
  7. package/fincli/app/analysis/analyzer.py +107 -96
  8. package/fincli/app/analysis/assistant_context.py +187 -186
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/multi_timeframe.py +180 -0
  12. package/fincli/app/analysis/trading_methods.py +144 -0
  13. package/fincli/app/cli/commands.py +108 -81
  14. package/fincli/app/cli/router.py +2327 -1237
  15. package/fincli/app/connectors/__init__.py +5 -0
  16. package/fincli/app/connectors/catalog.py +148 -0
  17. package/fincli/app/connectors/news_connectors.py +412 -0
  18. package/fincli/app/modules/alerts.py +80 -0
  19. package/fincli/app/modules/economic_calendar.py +374 -1
  20. package/fincli/app/modules/portfolio_risk.py +305 -0
  21. package/fincli/app/modules/reports.py +151 -0
  22. package/fincli/app/modules/scanner.py +111 -93
  23. package/fincli/app/modules/transactions.py +84 -84
  24. package/fincli/app/modules/user_profile.py +84 -0
  25. package/fincli/app/plugins/loader.py +72 -0
  26. package/fincli/app/providers/ai/manager.py +60 -60
  27. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  28. package/fincli/app/providers/market/base.py +98 -77
  29. package/fincli/app/providers/market/custom_provider.py +186 -169
  30. package/fincli/app/providers/market/manager.py +84 -1
  31. package/fincli/app/providers/market/symbols.py +143 -0
  32. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  33. package/fincli/app/providers/reliability.py +86 -0
  34. package/fincli/app/research/__init__.py +8 -0
  35. package/fincli/app/research/engine.py +137 -0
  36. package/fincli/app/research/exporter.py +91 -0
  37. package/fincli/app/research/formatter.py +27 -0
  38. package/fincli/app/research/models.py +24 -0
  39. package/fincli/app/research/prompt_builder.py +54 -0
  40. package/fincli/app/services/macro_data.py +50 -0
  41. package/fincli/app/services/market_data.py +274 -169
  42. package/fincli/app/services/market_overview.py +42 -1
  43. package/fincli/app/services/news_aggregator.py +95 -0
  44. package/fincli/app/services/web_research.py +267 -267
  45. package/fincli/app/storage/config.py +122 -88
  46. package/fincli/app/storage/database.py +209 -99
  47. package/fincli/app/storage/provider_metrics.py +61 -0
  48. package/fincli/app/storage/secrets.py +26 -2
  49. package/fincli/app/tui/components.py +68 -50
  50. package/fincli/app/tui/layout.py +269 -258
  51. package/fincli/app/tui/market_provider_selector.py +3 -1
  52. package/fincli/app/tui/theme.py +134 -74
  53. package/fincli/app/utils/formatting.py +123 -60
  54. package/package.json +22 -20
  55. package/pyproject.toml +35 -35
@@ -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.market_provider_selector import MarketProviderSelectorScreen
22
- from fincli.app.tui.model_selector import AIModelSelectorScreen
23
- from fincli.app.tui.theme import APP_CSS
24
-
25
-
26
- class FinCLIApp(App[None]):
27
- """Modern terminal dashboard for FinCLI v0.1."""
28
-
29
- CSS = APP_CSS
30
- TITLE = f"FinCLI v{__version__}"
31
- SUB_TITLE = "Financial terminal MVP"
32
- BINDINGS = [
33
- ("ctrl+c", "quit", "Quit"),
34
- ("ctrl+l", "clear_output", "Clear"),
35
- ("f1", "help", "Help"),
36
- ]
37
-
38
- def __init__(self) -> None:
39
- super().__init__()
40
- self.registry = CommandRegistry()
41
- self.autocomplete = SlashAutocomplete(self.registry)
42
- self.router = CommandRouter(registry=self.registry)
43
- self._route_lock = Lock()
44
- self._worker_index = 0
45
- self._latest_worker_sequence = 0
46
- self._worker_meta: dict[str, dict[str, str | bool]] = {}
47
-
48
- def compose(self) -> ComposeResult:
49
- yield Header(show_clock=True)
50
- with Vertical(id="workspace"):
51
- with Vertical(id="main"):
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 | /dashboard | /market AAPL | /provider status", id="status_bar")
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.write("Loading dashboard...")
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.write(self.router.route("/help").renderable)
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.write(f"AI model aktif: {provider} / {model}")
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.write(f"Provider market/news priority aktif: {', '.join(providers)}")
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.write(format_user_message(prompt))
184
- output.write(format_thinking_message("routing prompt to active AI provider..."))
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.write(f"Error menjalankan {display_raw}: {worker.error}")
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.write(result.renderable)
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, ...]: