emmanuelakbi commited on
Commit
07ff2cb
·
1 Parent(s): 1453598

Deploy FinAgent: multi-agent trading signals powered by Qwen on AMD MI300X

Browse files

- Gradio 4.44 frontend with live agent activity feed
- CrewAI orchestrator (5 specialized agents: Market Scanner,
Fundamental Analyst, Technical Analyst, Risk Manager, Chief Strategist)
- 10 keyless tool functions (yfinance, ddgs, pandas-ta)
- Backed by vLLM + Qwen3-8B running on AMD Instinct MI300X
- Built for the AMD Developer Hackathon (May 2026)

.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keep the Space clean — prevent stray caches/local state from ever
2
+ # getting pushed.
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+ .hypothesis/
7
+ .pytest_cache/
8
+ .DS_Store
9
+ *.log
10
+ .env
11
+ .env.*
README.md CHANGED
@@ -1,15 +1,74 @@
1
  ---
2
- title: Finagent
3
- emoji: 👀
4
- colorFrom: gray
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
  short_description: Multi-agent trading signals powered by Qwen on AMD MI300X
 
 
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FinAgent
3
+ emoji: 🤖
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 4.44.1
 
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
  short_description: Multi-agent trading signals powered by Qwen on AMD MI300X
12
+ tags:
13
+ - crewai
14
+ - qwen
15
+ - amd-mi300x
16
+ - trading
17
+ - agents
18
  ---
19
 
20
+ # 🤖 FinAgent AI-Powered Trading Signal Generator
21
+
22
+ **Built for the AMD Developer Hackathon** · Track: AI Agents & Agentic Workflows
23
+
24
+ FinAgent is a CrewAI-driven multi-agent system that analyzes any ticker symbol you throw at it — stocks, crypto, ETFs — through five specialized AI agents and returns a structured BUY / SELL / HOLD trading signal with confidence, entry price, stop loss, and target.
25
+
26
+ All the reasoning runs on **Qwen3-8B served by vLLM on an AMD Instinct MI300X** via the AMD Developer Cloud, accessed through a standard OpenAI-compatible API.
27
+
28
+ ## The five agents
29
+
30
+ | Agent | Role | Tools |
31
+ | ------------------- | --------------------------------------------------- | ----------------------------------------------- |
32
+ | Market Scanner | Detect news, price changes, volume anomalies | `search_news`, `get_price_change`, `get_volume` |
33
+ | Fundamental Analyst | Evaluate financials, earnings, peer comparison | `get_financials`, `get_earnings`, `get_peers` |
34
+ | Technical Analyst | Read price history, indicators, entry / exit points | `get_price_history`, `calculate_indicators` |
35
+ | Risk Manager | Size the position, place ATR-based stop loss | `calculate_position_size`, `set_stop_loss` |
36
+ | Chief Strategist | Synthesize all four into a final call | — (pure reasoning) |
37
+
38
+ The first three run in parallel. Risk Manager waits for Technical Analyst's entry price. Chief Strategist waits for everyone.
39
+
40
+ ## How it works
41
+
42
+ 1. You paste a comma-separated watchlist (up to 10 tickers).
43
+ 2. The Gradio UI streams agent activity live as they work.
44
+ 3. Each ticker produces a signal card: action (BUY/SELL/HOLD), confidence, entry / stop / target prices, and a per-agent reasoning summary.
45
+ 4. Failed tickers render an error card; one bad ticker doesn't stop the batch.
46
+
47
+ ## Try it
48
+
49
+ Type something like:
50
+
51
+ ```
52
+ AAPL, NVDA, BTC-USD, TSLA
53
+ ```
54
+
55
+ pick your risk profile, portfolio value, and trading style, and hit **🔍 Analyze**.
56
+
57
+ ## Architecture
58
+
59
+ ```
60
+ ┌──────────────────────────────┐ HTTPS ┌─────────────────────────────┐
61
+ │ Hugging Face Space │◄────────►│ AMD Developer Cloud │
62
+ │ • Gradio 4.44 UI │ │ • AMD Instinct MI300X │
63
+ │ • CrewAI 1.14 orchestrator │ │ • vLLM 0.6.3 + ROCm 6.2 │
64
+ │ • yfinance / ddgs tools │ │ • Qwen/Qwen3-8B │
65
+ └──────────────────────────────┘ └─────────────────────────────┘
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT — see [GitHub repository](https://github.com/your-username/FinAgent) for the full source, tests, and development plan (4 specs built with Kiro spec-driven development).
71
+
72
+ ---
73
+
74
+ ⚠️ **Disclaimer:** Trading signals produced here are for informational purposes only and do not constitute financial advice. Always do your own research before placing a trade.
app.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FinAgent Gradio frontend — application entry point.
2
+
3
+ This module constructs the Gradio Blocks application used as the FinAgent
4
+ Hugging Face Space. The outer shell (title, theme, custom CSS) is defined
5
+ in :func:`create_app`; UI widgets, session state, and event wiring are
6
+ added by later tasks (6.2, 6.3, 6.4).
7
+
8
+ The vLLM inference endpoint URL is read from the ``VLLM_ENDPOINT_URL``
9
+ environment variable at module import time so it can be validated early
10
+ by the Analyze event handler.
11
+ """
12
+
13
+ import os
14
+ import time
15
+ from datetime import datetime
16
+
17
+ import gradio as gr
18
+
19
+ from rendering import (
20
+ build_css,
21
+ render_activity_entry,
22
+ render_activity_feed,
23
+ render_error_card,
24
+ render_signal_card,
25
+ render_summary,
26
+ )
27
+ from validation import validate_portfolio_value, validate_tickers
28
+
29
+ # Inference endpoint URL for the vLLM server powering the agent pipeline.
30
+ # Read at import time; the event handler is responsible for surfacing a
31
+ # user-facing error when this value is missing.
32
+ VLLM_ENDPOINT_URL = os.environ.get("VLLM_ENDPOINT_URL")
33
+
34
+ # Hard cap on total pipeline execution time per Requirement 4.4. The
35
+ # pipeline loop checks the elapsed wall-clock time before dispatching
36
+ # each ticker and yields a timeout warning once this budget is exceeded.
37
+ TIMEOUT_SECONDS = 180
38
+
39
+
40
+ def _format_progress_message(
41
+ ticker: str,
42
+ i: int,
43
+ total: int,
44
+ elapsed_seconds: int,
45
+ ) -> str:
46
+ """Format the in-flight progress message shown above the activity feed.
47
+
48
+ Pulled out of :func:`run_analysis` as a pure, side-effect-free helper
49
+ so the progress string's contract (Requirements 4.2, 4.3) can be
50
+ exercised directly by property tests without constructing a full
51
+ Gradio event loop.
52
+
53
+ Args:
54
+ ticker: The ticker symbol currently being analyzed.
55
+ i: 1-indexed position of ``ticker`` in the watchlist.
56
+ total: Total number of tickers in the watchlist (``i <= total``).
57
+ elapsed_seconds: Whole seconds elapsed since the analysis began;
58
+ rendered verbatim in the ``(elapsed: Ns)`` suffix.
59
+
60
+ Returns:
61
+ A Markdown-formatted string of the form::
62
+
63
+ **Analyzing ticker {i} of {total}** — {ticker} (elapsed: {N}s)
64
+
65
+ which contains the ticker, ``i``, and ``total`` as substrings
66
+ (Property 6 in the design document).
67
+ """
68
+ return (
69
+ f"**Analyzing ticker {i} of {total}** — {ticker} "
70
+ f"(elapsed: {elapsed_seconds}s)"
71
+ )
72
+
73
+
74
+ def run_analysis(
75
+ ticker_input,
76
+ risk_tolerance,
77
+ portfolio_value,
78
+ trading_style,
79
+ activity_log,
80
+ signals_state,
81
+ ):
82
+ """Analyze button event handler — streams UI updates as a generator.
83
+
84
+ This is the task 7.1 slice of the full handler: it covers environment
85
+ and input validation plus initial state setup. The pipeline execution
86
+ loop (task 7.2), error handling / completion yields (task 7.3), and
87
+ the ``_render_signals_dashboard`` helper (task 7.4) are added by
88
+ subsequent tasks.
89
+
90
+ Each ``yield`` produces the tuple wired to ``analyze_btn.click``'s
91
+ outputs in :func:`create_app`, in this order::
92
+
93
+ (analyze_btn, error_display, progress_text,
94
+ activity_feed, signals_dashboard,
95
+ activity_log, signals_state)
96
+
97
+ Yields:
98
+ Tuples of :class:`gradio.update` calls and session-state values
99
+ that incrementally update the UI during analysis.
100
+ """
101
+ # --- Environment validation ---
102
+ # The vLLM endpoint URL is required to run the pipeline. Surface a
103
+ # clear configuration error and re-enable the button so the user can
104
+ # retry once the variable is set.
105
+ if not VLLM_ENDPOINT_URL:
106
+ yield (
107
+ gr.update(interactive=True),
108
+ gr.update(
109
+ value=(
110
+ "❌ Configuration error: `VLLM_ENDPOINT_URL` "
111
+ "environment variable is not set."
112
+ ),
113
+ visible=True,
114
+ ),
115
+ gr.update(visible=False),
116
+ gr.update(),
117
+ gr.update(),
118
+ activity_log,
119
+ signals_state,
120
+ )
121
+ return
122
+
123
+ # --- Ticker validation ---
124
+ validation = validate_tickers(ticker_input)
125
+ if not validation.valid:
126
+ yield (
127
+ gr.update(interactive=True),
128
+ gr.update(value=f"❌ {validation.error_message}", visible=True),
129
+ gr.update(visible=False),
130
+ gr.update(),
131
+ gr.update(),
132
+ activity_log,
133
+ signals_state,
134
+ )
135
+ return
136
+
137
+ # --- Portfolio value validation ---
138
+ portfolio_error = validate_portfolio_value(portfolio_value)
139
+ if portfolio_error:
140
+ yield (
141
+ gr.update(interactive=True),
142
+ gr.update(value=f"❌ {portfolio_error}", visible=True),
143
+ gr.update(visible=False),
144
+ gr.update(),
145
+ gr.update(),
146
+ activity_log,
147
+ signals_state,
148
+ )
149
+ return
150
+
151
+ # --- Initialize per-run state ---
152
+ # Reset activity log and signals for a fresh run; capture the start
153
+ # timestamp so the pipeline loop (task 7.2) can enforce the timeout
154
+ # and report elapsed time in progress updates.
155
+ activity_log = []
156
+ signals_state = []
157
+ analysis_start = time.time()
158
+ tickers = validation.tickers
159
+ total = len(tickers)
160
+
161
+ # Seed the activity feed with a start entry so the user sees
162
+ # immediate feedback the moment the button is clicked.
163
+ start_entry = render_activity_entry(
164
+ datetime.now(),
165
+ "System",
166
+ f"Analysis started for {total} ticker(s)",
167
+ False,
168
+ )
169
+ activity_log.append(start_entry)
170
+
171
+ # Initial yield: disable button to prevent re-submission, hide any
172
+ # stale error banner, show the progress indicator, seed the activity
173
+ # feed, and clear the signals dashboard from any previous run.
174
+ yield (
175
+ gr.update(interactive=False),
176
+ gr.update(visible=False),
177
+ gr.update(value=f"**Analyzing ticker 1 of {total}**", visible=True),
178
+ render_activity_feed(activity_log),
179
+ "",
180
+ activity_log,
181
+ signals_state,
182
+ )
183
+
184
+ # --- Pipeline execution loop (task 7.2) ---
185
+ # The entire pipeline setup + per-ticker loop runs inside a single
186
+ # ``try`` block (task 7.3) so any unexpected exception — import
187
+ # failure, runner construction error, or an unhandled crash inside
188
+ # ``_run_single`` — is surfaced to the user as a visible error and
189
+ # the Analyze button is re-enabled instead of leaving the UI in a
190
+ # locked, spinning state (Requirements 8.1, 8.3).
191
+ try:
192
+ # Import the orchestration package lazily so ``app.py`` remains
193
+ # importable in environments where ``crew`` isn't installed
194
+ # (e.g., isolated unit-test runs of the validation/rendering
195
+ # modules).
196
+ from crew import (
197
+ LLMConfig,
198
+ OrchestratorConfig,
199
+ WatchlistRunner,
200
+ )
201
+ from crew.callbacks import ActivityEvent, ActivityFeedCallback, EventType
202
+
203
+ # Configure the orchestrator to point at the vLLM inference endpoint.
204
+ config = OrchestratorConfig(
205
+ llm=LLMConfig(base_url=VLLM_ENDPOINT_URL),
206
+ )
207
+
208
+ # Buffer events emitted by the runner/crew during ``_run_single``.
209
+ # The callback handler runs synchronously on the same thread, so
210
+ # a plain list plus a closure is sufficient — we drain it into
211
+ # the activity feed after each ticker completes.
212
+ pending_events: list[ActivityEvent] = []
213
+
214
+ def event_handler(event: ActivityEvent) -> None:
215
+ pending_events.append(event)
216
+
217
+ callback = ActivityFeedCallback(handler=event_handler)
218
+ runner = WatchlistRunner(config=config, tools={}, callback=callback)
219
+
220
+ for i, ticker in enumerate(tickers, 1):
221
+ # Enforce the overall pipeline timeout (Requirement 4.4). We
222
+ # check before dispatching each ticker so a slow ticker
223
+ # can't push the total past the budget unnoticed; when
224
+ # exceeded we append a timeout entry, re-enable the Analyze
225
+ # button, and return early.
226
+ elapsed = time.time() - analysis_start
227
+ if elapsed > TIMEOUT_SECONDS:
228
+ timeout_entry = render_activity_entry(
229
+ datetime.now(),
230
+ "System",
231
+ f"⚠️ Analysis timed out after {TIMEOUT_SECONDS}s",
232
+ False,
233
+ )
234
+ activity_log.append(timeout_entry)
235
+ yield (
236
+ gr.update(interactive=True),
237
+ gr.update(
238
+ value="⚠️ Analysis timed out. Try fewer tickers.",
239
+ visible=True,
240
+ ),
241
+ gr.update(visible=False),
242
+ render_activity_feed(activity_log),
243
+ _render_signals_dashboard(signals_state),
244
+ activity_log,
245
+ signals_state,
246
+ )
247
+ return
248
+
249
+ # Progress message surfaces the current ticker, its position
250
+ # in the batch, and elapsed seconds (Requirements 4.2, 4.3,
251
+ # 3.6). The format is centralized in
252
+ # :func:`_format_progress_message` so the contract can be
253
+ # property-tested directly.
254
+ progress_msg = _format_progress_message(
255
+ ticker=ticker,
256
+ i=i,
257
+ total=total,
258
+ elapsed_seconds=int(elapsed),
259
+ )
260
+
261
+ # Run the crew pipeline for this ticker. ``_run_single``
262
+ # handles its own exception isolation and always returns a
263
+ # CrewResult (success or failure), so per-ticker errors
264
+ # won't break the outer loop.
265
+ result = runner._run_single(ticker)
266
+
267
+ # Translate buffered callback events into activity feed HTML
268
+ # entries. Task-start events show a spinner to indicate work
269
+ # in progress; all other event types render as static lines.
270
+ for event in pending_events:
271
+ entry = render_activity_entry(
272
+ event.timestamp,
273
+ event.agent_name,
274
+ event.message,
275
+ is_spinner=(event.event_type == EventType.TASK_START),
276
+ )
277
+ activity_log.append(entry)
278
+ pending_events.clear()
279
+
280
+ # Collect the per-ticker outcome. Successful runs contribute
281
+ # a TradingSignal; failures contribute an error dict that
282
+ # ``_render_signals_dashboard`` renders via
283
+ # ``render_error_card`` (Requirement 5.5).
284
+ if result.success and result.signal is not None:
285
+ signals_state.append(result.signal)
286
+ else:
287
+ signals_state.append(
288
+ {
289
+ "ticker": ticker,
290
+ "error": result.error or "Unknown error",
291
+ }
292
+ )
293
+
294
+ # Stream the intermediate state to the UI: keep the button
295
+ # disabled (still running), hide the error banner, refresh
296
+ # the progress line, activity feed, and signals dashboard.
297
+ yield (
298
+ gr.update(interactive=False),
299
+ gr.update(visible=False),
300
+ gr.update(value=progress_msg, visible=True),
301
+ render_activity_feed(activity_log),
302
+ _render_signals_dashboard(signals_state),
303
+ activity_log,
304
+ signals_state,
305
+ )
306
+
307
+ except Exception as e:
308
+ # --- Unhandled pipeline error (task 7.3) ---
309
+ # Log the failure to the activity feed so the user can see what
310
+ # went wrong in-context (Requirement 8.2), surface a visible
311
+ # error banner with the exception message, hide the progress
312
+ # indicator, and re-enable the Analyze button so the user can
313
+ # retry (Requirements 8.1, 8.3). Any signals already collected
314
+ # from earlier tickers are preserved in ``signals_state`` and
315
+ # re-rendered in the final yield.
316
+ error_entry = render_activity_entry(
317
+ datetime.now(),
318
+ "System",
319
+ f"❌ Error: {str(e)}",
320
+ False,
321
+ )
322
+ activity_log.append(error_entry)
323
+ yield (
324
+ gr.update(interactive=True),
325
+ gr.update(value=f"❌ An error occurred: {str(e)}", visible=True),
326
+ gr.update(visible=False),
327
+ render_activity_feed(activity_log),
328
+ _render_signals_dashboard(signals_state),
329
+ activity_log,
330
+ signals_state,
331
+ )
332
+ return
333
+
334
+ # --- Successful completion (task 7.3) ---
335
+ # The pipeline finished without raising. Append a completion entry
336
+ # reporting total elapsed time (Requirement 8.4), hide the progress
337
+ # indicator, clear any lingering error banner, and re-enable the
338
+ # Analyze button for the next run.
339
+ elapsed_total = time.time() - analysis_start
340
+ complete_entry = render_activity_entry(
341
+ datetime.now(),
342
+ "System",
343
+ f"✅ Analysis complete ({int(elapsed_total)}s)",
344
+ False,
345
+ )
346
+ activity_log.append(complete_entry)
347
+
348
+ yield (
349
+ gr.update(interactive=True),
350
+ gr.update(visible=False),
351
+ gr.update(visible=False),
352
+ render_activity_feed(activity_log),
353
+ _render_signals_dashboard(signals_state),
354
+ activity_log,
355
+ signals_state,
356
+ )
357
+
358
+
359
+ def _render_signals_dashboard(signals: list) -> str:
360
+ """Render the full signals dashboard HTML (summary bar + cards).
361
+
362
+ Iterates the collected results and produces a dashboard composed of
363
+ an aggregate :func:`render_summary` bar followed by one HTML card per
364
+ result: a :func:`render_signal_card` for :class:`TradingSignal`-like
365
+ objects and a :func:`render_error_card` for error dicts of the form
366
+ ``{"ticker": ..., "error": ...}``.
367
+
368
+ The action label is read defensively via ``getattr(item.action,
369
+ "value", item.action)`` so this helper works both with the orchestrator's
370
+ ``Action`` enum (``Action.BUY.value == "BUY"``) and with plain string
371
+ actions, without importing from the ``crew`` package.
372
+
373
+ Args:
374
+ signals: Ordered list of :class:`TradingSignal`-like objects and/or
375
+ error dicts accumulated during an analysis run.
376
+
377
+ Returns:
378
+ Concatenated HTML string (summary bar followed by card HTML joined
379
+ by newlines), or an empty string when ``signals`` is empty.
380
+ """
381
+ if not signals:
382
+ return ""
383
+
384
+ cards: list[str] = []
385
+ buy_count = 0
386
+ sell_count = 0
387
+ hold_count = 0
388
+
389
+ for item in signals:
390
+ # Error dicts are produced when a ticker fails analysis — render
391
+ # them as dedicated error cards and skip action counting.
392
+ if isinstance(item, dict) and "error" in item:
393
+ cards.append(render_error_card(item["ticker"], item["error"]))
394
+ continue
395
+
396
+ cards.append(render_signal_card(item))
397
+
398
+ # Categorize the action for the summary bar. ``item.action`` may
399
+ # be an enum (``.value`` gives the label) or already a string —
400
+ # ``getattr`` with a default covers both without importing the
401
+ # orchestrator's ``Action`` enum.
402
+ action_label = str(getattr(item.action, "value", item.action)).upper()
403
+ if action_label == "BUY":
404
+ buy_count += 1
405
+ elif action_label == "SELL":
406
+ sell_count += 1
407
+ elif action_label == "HOLD":
408
+ hold_count += 1
409
+
410
+ total = len(signals)
411
+ summary = render_summary(total, buy_count, sell_count, hold_count)
412
+
413
+ return summary + "\n".join(cards)
414
+
415
+
416
+ def create_app() -> gr.Blocks:
417
+ """Build and return the Gradio Blocks application.
418
+
419
+ Creates the outer shell: page title, dark Base theme with an emerald
420
+ primary hue and slate neutral hue, and the custom dark financial
421
+ terminal CSS from :func:`rendering.build_css`.
422
+
423
+ Returns:
424
+ The configured :class:`gradio.Blocks` instance, ready to have UI
425
+ widgets and event handlers attached by subsequent tasks.
426
+ """
427
+ custom_css = build_css()
428
+
429
+ with gr.Blocks(
430
+ title="FinAgent - AI Trading Signals",
431
+ theme=gr.themes.Base(
432
+ primary_hue="emerald",
433
+ neutral_hue="slate",
434
+ ),
435
+ css=custom_css,
436
+ ) as app:
437
+ # --- Header ---
438
+ gr.Markdown(
439
+ "# 🤖 FinAgent\n"
440
+ "### AI-Powered Trading Signal Generator"
441
+ )
442
+
443
+ # --- Session State (per-user, isolated by gr.State) ---
444
+ activity_log = gr.State([]) # list[str]: HTML strings for activity feed entries
445
+ signals_state = gr.State([]) # list[TradingSignal | dict]: collected signals / errors
446
+ start_time = gr.State(None) # Optional[float]: time.time() when analysis started
447
+
448
+ # --- Main Layout: Input panel (left) + Activity/Signals (right) ---
449
+ with gr.Row():
450
+ # Left Column: Input Panel
451
+ with gr.Column(scale=1):
452
+ ticker_input = gr.Textbox(
453
+ label="Watchlist",
454
+ placeholder="AAPL, NVDA, TSLA, BTC-USD",
455
+ info="Comma-separated tickers (max 10)",
456
+ )
457
+ risk_tolerance = gr.Dropdown(
458
+ choices=["Conservative", "Moderate", "Aggressive"],
459
+ value="Moderate",
460
+ label="Risk Tolerance",
461
+ )
462
+ portfolio_value = gr.Number(
463
+ value=10000,
464
+ minimum=0,
465
+ label="Portfolio Value ($)",
466
+ )
467
+ trading_style = gr.Dropdown(
468
+ choices=["Day Trading", "Swing Trading", "Position Trading"],
469
+ value="Swing Trading",
470
+ label="Trading Style",
471
+ )
472
+ analyze_btn = gr.Button(
473
+ "🔍 Analyze",
474
+ variant="primary",
475
+ interactive=True,
476
+ )
477
+ error_display = gr.Markdown(visible=False)
478
+
479
+ # Right Column: Activity Feed + Signals Dashboard
480
+ with gr.Column(scale=2):
481
+ progress_text = gr.Markdown(visible=False)
482
+ activity_feed = gr.HTML(
483
+ label="Agent Activity",
484
+ value="<div class='activity-feed'></div>",
485
+ )
486
+ signals_dashboard = gr.HTML(
487
+ label="Trading Signals",
488
+ value="",
489
+ )
490
+
491
+ # --- Event Wiring ---
492
+ # The Analyze button streams results from the ``run_analysis``
493
+ # generator. Inputs carry the user's configuration plus the
494
+ # per-session activity log and collected signals so the handler
495
+ # can extend them incrementally. Outputs cover every widget the
496
+ # handler mutates during the run (button interactivity, error
497
+ # banner, progress text, activity feed, signals dashboard) plus
498
+ # the two session-state values it updates.
499
+ analyze_btn.click(
500
+ fn=run_analysis,
501
+ inputs=[
502
+ ticker_input,
503
+ risk_tolerance,
504
+ portfolio_value,
505
+ trading_style,
506
+ activity_log,
507
+ signals_state,
508
+ ],
509
+ outputs=[
510
+ analyze_btn,
511
+ error_display,
512
+ progress_text,
513
+ activity_feed,
514
+ signals_dashboard,
515
+ activity_log,
516
+ signals_state,
517
+ ],
518
+ )
519
+
520
+ # --- Footer ---
521
+ gr.Markdown(
522
+ "⚠️ **Disclaimer:** Trading signals are for informational purposes only "
523
+ "and do not constitute financial advice. Always do your own research."
524
+ )
525
+
526
+ # Expose widgets and session state on the returned Blocks so subsequent
527
+ # tasks (6.3 event wiring) can reference them without re-entering the
528
+ # context. These attributes are an internal contract between tasks in
529
+ # this module only.
530
+ app.ticker_input = ticker_input
531
+ app.risk_tolerance = risk_tolerance
532
+ app.portfolio_value = portfolio_value
533
+ app.trading_style = trading_style
534
+ app.analyze_btn = analyze_btn
535
+ app.error_display = error_display
536
+ app.progress_text = progress_text
537
+ app.activity_feed = activity_feed
538
+ app.signals_dashboard = signals_dashboard
539
+ app.activity_log = activity_log
540
+ app.signals_state = signals_state
541
+ app.start_time = start_time
542
+
543
+ return app
544
+
545
+
546
+ if __name__ == "__main__":
547
+ # Launch the Gradio app on all interfaces so the Hugging Face Space
548
+ # container can route external traffic to it (per Requirement 7.5).
549
+ create_app().launch(server_name="0.0.0.0")
crew/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CrewAI multi-agent orchestration layer for FinAgent.
2
+
3
+ This package coordinates five specialized AI agents to analyze financial
4
+ tickers and produce structured trading signals (BUY/SELL/HOLD).
5
+ """
6
+
7
+ from crew.callbacks import ActivityEvent, ActivityFeedCallback, EventType
8
+ from crew.config import CrewConfig, LLMConfig, OrchestratorConfig
9
+ from crew.crew import CrewResult, FinAgentCrew
10
+ from crew.runner import WatchlistResult, WatchlistRunner
11
+ from crew.signals import Action, TradingSignal
12
+
13
+ __all__ = [
14
+ "Action",
15
+ "ActivityEvent",
16
+ "ActivityFeedCallback",
17
+ "CrewConfig",
18
+ "CrewResult",
19
+ "EventType",
20
+ "FinAgentCrew",
21
+ "LLMConfig",
22
+ "OrchestratorConfig",
23
+ "TradingSignal",
24
+ "WatchlistResult",
25
+ "WatchlistRunner",
26
+ ]
crew/agents.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent factory functions for creating configured CrewAI Agent instances."""
2
+
3
+ from crewai import Agent
4
+ from langchain_openai import ChatOpenAI
5
+
6
+ from crew.config import LLMConfig
7
+
8
+
9
+ def create_llm(config: LLMConfig) -> ChatOpenAI:
10
+ """Create a shared LLM instance pointing to the vLLM endpoint."""
11
+ return ChatOpenAI(
12
+ base_url=config.base_url,
13
+ model=config.model_name,
14
+ temperature=config.temperature,
15
+ max_tokens=config.max_tokens,
16
+ timeout=config.request_timeout,
17
+ api_key="not-needed",
18
+ )
19
+
20
+
21
+ def create_market_scanner(llm: ChatOpenAI, tools: list) -> Agent:
22
+ """Create the Market Scanner agent.
23
+
24
+ Args:
25
+ llm: Shared LLM instance
26
+ tools: [search_news, get_price_change, get_volume]
27
+ """
28
+ return Agent(
29
+ role="Market Scanner",
30
+ goal="Detect significant market events, price movements, and volume anomalies for the given ticker",
31
+ backstory=(
32
+ "You are an experienced market surveillance specialist who monitors "
33
+ "news feeds, price action, and trading volumes 24/7. You have a keen "
34
+ "eye for detecting material events that could impact stock prices."
35
+ ),
36
+ llm=llm,
37
+ tools=tools,
38
+ max_iter=5,
39
+ verbose=True,
40
+ )
41
+
42
+
43
+ def create_fundamental_analyst(llm: ChatOpenAI, tools: list) -> Agent:
44
+ """Create the Fundamental Analyst agent.
45
+
46
+ Args:
47
+ llm: Shared LLM instance
48
+ tools: [get_financials, get_earnings, get_peers]
49
+ """
50
+ return Agent(
51
+ role="Fundamental Analyst",
52
+ goal="Determine the intrinsic value of the company by analyzing financial metrics, earnings trends, and peer comparisons",
53
+ backstory=(
54
+ "You are a seasoned equity research analyst with 15 years of experience "
55
+ "in fundamental valuation. You specialize in dissecting financial statements, "
56
+ "identifying earnings quality, and comparing companies against their peers."
57
+ ),
58
+ llm=llm,
59
+ tools=tools,
60
+ max_iter=5,
61
+ verbose=True,
62
+ )
63
+
64
+
65
+ def create_technical_analyst(llm: ChatOpenAI, tools: list) -> Agent:
66
+ """Create the Technical Analyst agent.
67
+
68
+ Args:
69
+ llm: Shared LLM instance
70
+ tools: [get_price_history, calculate_indicators]
71
+ """
72
+ return Agent(
73
+ role="Technical Analyst",
74
+ goal="Identify optimal entry and exit points using price patterns and technical indicators",
75
+ backstory=(
76
+ "You are a quantitative technical analyst who combines classical chart "
77
+ "patterns with modern indicator analysis. You focus on RSI, MACD, "
78
+ "Bollinger Bands, and moving average crossovers to time entries precisely."
79
+ ),
80
+ llm=llm,
81
+ tools=tools,
82
+ max_iter=5,
83
+ verbose=True,
84
+ )
85
+
86
+
87
+ def create_risk_manager(llm: ChatOpenAI, tools: list) -> Agent:
88
+ """Create the Risk Manager agent.
89
+
90
+ Args:
91
+ llm: Shared LLM instance
92
+ tools: [calculate_position_size, set_stop_loss]
93
+ """
94
+ return Agent(
95
+ role="Risk Manager",
96
+ goal="Protect capital through optimal position sizing and stop-loss placement based on volatility",
97
+ backstory=(
98
+ "You are a portfolio risk specialist who never lets a single trade "
99
+ "risk more than the defined threshold. You use ATR-based stop-losses "
100
+ "and position sizing formulas to ensure consistent risk management."
101
+ ),
102
+ llm=llm,
103
+ tools=tools,
104
+ max_iter=5,
105
+ verbose=True,
106
+ )
107
+
108
+
109
+ def create_chief_strategist(llm: ChatOpenAI) -> Agent:
110
+ """Create the Chief Strategist agent (no tools, pure reasoning)."""
111
+ return Agent(
112
+ role="Chief Strategist",
113
+ goal="Synthesize all agent analyses into a single, actionable trading signal with confidence level",
114
+ backstory=(
115
+ "You are the head of trading strategy with decades of experience "
116
+ "integrating fundamental, technical, and risk perspectives into "
117
+ "decisive trading calls. You weigh conflicting signals and produce "
118
+ "clear BUY/SELL/HOLD recommendations with calibrated confidence."
119
+ ),
120
+ llm=llm,
121
+ tools=[],
122
+ max_iter=5,
123
+ verbose=True,
124
+ )
crew/callbacks.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ActivityFeedCallback implementation for real-time UI updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING, Callable, Optional
9
+
10
+ if TYPE_CHECKING:
11
+ from crew.signals import TradingSignal
12
+
13
+
14
+ class EventType(str, Enum):
15
+ """Types of activity feed events."""
16
+
17
+ TICKER_START = "ticker_start"
18
+ TICKER_COMPLETE = "ticker_complete"
19
+ TASK_START = "task_start"
20
+ TASK_COMPLETE = "task_complete"
21
+ TASK_FAILED = "task_failed"
22
+ AGENT_OUTPUT = "agent_output"
23
+ CREW_ERROR = "crew_error"
24
+
25
+
26
+ @dataclass
27
+ class ActivityEvent:
28
+ """Structured payload for activity feed callbacks."""
29
+
30
+ event_type: EventType
31
+ agent_name: str
32
+ ticker: str
33
+ message: str
34
+ timestamp: datetime
35
+
36
+
37
+ class ActivityFeedCallback:
38
+ """Manages activity feed event dispatch to the Gradio UI."""
39
+
40
+ def __init__(self, handler: Callable[[ActivityEvent], None]) -> None:
41
+ """Initialize with a handler function that receives ActivityEvent payloads.
42
+
43
+ Args:
44
+ handler: Function that receives ActivityEvent payloads.
45
+ Typically connected to a Gradio state update.
46
+ """
47
+ self._handler = handler
48
+
49
+ def on_ticker_start(self, ticker: str) -> None:
50
+ """Emit event when a ticker analysis begins."""
51
+ event = ActivityEvent(
52
+ event_type=EventType.TICKER_START,
53
+ agent_name="system",
54
+ ticker=ticker,
55
+ message=f"Starting analysis for {ticker}",
56
+ timestamp=datetime.now(timezone.utc),
57
+ )
58
+ self._emit(event)
59
+
60
+ def on_ticker_complete(
61
+ self, ticker: str, signal: Optional[TradingSignal] = None
62
+ ) -> None:
63
+ """Emit event when a ticker analysis completes."""
64
+ if signal is not None:
65
+ message = f"Analysis complete for {ticker}: {signal.action.value} (Confidence: {signal.confidence}%)"
66
+ else:
67
+ message = f"Analysis complete for {ticker}"
68
+ event = ActivityEvent(
69
+ event_type=EventType.TICKER_COMPLETE,
70
+ agent_name="system",
71
+ ticker=ticker,
72
+ message=message,
73
+ timestamp=datetime.now(timezone.utc),
74
+ )
75
+ self._emit(event)
76
+
77
+ def on_task_start(self, agent_name: str, ticker: str) -> None:
78
+ """Emit event when an agent task begins execution."""
79
+ event = ActivityEvent(
80
+ event_type=EventType.TASK_START,
81
+ agent_name=agent_name,
82
+ ticker=ticker,
83
+ message=f"{agent_name} started task for {ticker}",
84
+ timestamp=datetime.now(timezone.utc),
85
+ )
86
+ self._emit(event)
87
+
88
+ def on_task_complete(
89
+ self, agent_name: str, ticker: str, output_summary: str
90
+ ) -> None:
91
+ """Emit event when an agent task completes successfully."""
92
+ event = ActivityEvent(
93
+ event_type=EventType.TASK_COMPLETE,
94
+ agent_name=agent_name,
95
+ ticker=ticker,
96
+ message=output_summary,
97
+ timestamp=datetime.now(timezone.utc),
98
+ )
99
+ self._emit(event)
100
+
101
+ def on_task_failed(self, agent_name: str, ticker: str, error: str) -> None:
102
+ """Emit event when an agent task fails."""
103
+ event = ActivityEvent(
104
+ event_type=EventType.TASK_FAILED,
105
+ agent_name=agent_name,
106
+ ticker=ticker,
107
+ message=f"{agent_name} failed for {ticker}: {error}",
108
+ timestamp=datetime.now(timezone.utc),
109
+ )
110
+ self._emit(event)
111
+
112
+ def on_agent_output(self, agent_name: str, ticker: str, output: str) -> None:
113
+ """Emit event for intermediate agent output."""
114
+ event = ActivityEvent(
115
+ event_type=EventType.AGENT_OUTPUT,
116
+ agent_name=agent_name,
117
+ ticker=ticker,
118
+ message=output,
119
+ timestamp=datetime.now(timezone.utc),
120
+ )
121
+ self._emit(event)
122
+
123
+ def _emit(self, event: ActivityEvent) -> None:
124
+ """Dispatch event to the registered handler."""
125
+ self._handler(event)
crew/config.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM configuration, timeouts, and constants for the orchestration layer."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class LLMConfig:
8
+ """Configuration for the vLLM endpoint connection."""
9
+
10
+ base_url: str = "http://localhost:8000/v1"
11
+ model_name: str = "Qwen/Qwen3-8B"
12
+ temperature: float = 0.7
13
+ max_tokens: int = 1024
14
+ request_timeout: int = 120 # seconds
15
+
16
+
17
+ @dataclass
18
+ class CrewConfig:
19
+ """Configuration for crew execution parameters."""
20
+
21
+ max_iterations: int = 5
22
+ task_timeout: int = 120 # seconds per task
23
+ verbose: bool = True
24
+
25
+
26
+ @dataclass
27
+ class OrchestratorConfig:
28
+ """Top-level configuration combining all settings."""
29
+
30
+ llm: LLMConfig = field(default_factory=LLMConfig)
31
+ crew: CrewConfig = field(default_factory=CrewConfig)
crew/crew.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FinAgentCrew class — main orchestrator for the multi-agent analysis pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from crewai import Crew, Process
9
+
10
+ from crew.config import OrchestratorConfig
11
+ from crew.agents import (
12
+ create_llm,
13
+ create_market_scanner,
14
+ create_fundamental_analyst,
15
+ create_technical_analyst,
16
+ create_risk_manager,
17
+ create_chief_strategist,
18
+ )
19
+ from crew.tasks import (
20
+ create_market_scan_task,
21
+ create_fundamental_task,
22
+ create_technical_task,
23
+ create_risk_task,
24
+ create_strategy_task,
25
+ )
26
+ from crew.signals import TradingSignal, TradingSignalParser
27
+ from crew.callbacks import ActivityFeedCallback
28
+
29
+
30
+ @dataclass
31
+ class CrewResult:
32
+ """Result of a single ticker crew execution."""
33
+
34
+ ticker: str
35
+ signal: Optional[TradingSignal]
36
+ raw_output: str
37
+ success: bool
38
+ error: Optional[str] = None
39
+
40
+
41
+ class FinAgentCrew:
42
+ """Orchestrates the multi-agent analysis pipeline for a single ticker."""
43
+
44
+ def __init__(
45
+ self,
46
+ config: OrchestratorConfig,
47
+ tools: dict[str, list],
48
+ callback: Optional[ActivityFeedCallback] = None,
49
+ ):
50
+ """Initialize the crew orchestrator.
51
+
52
+ Args:
53
+ config: Full orchestrator configuration
54
+ tools: Dict mapping agent names to their tool lists:
55
+ {
56
+ "market_scanner": [search_news, get_price_change, get_volume],
57
+ "fundamental_analyst": [get_financials, get_earnings, get_peers],
58
+ "technical_analyst": [get_price_history, calculate_indicators],
59
+ "risk_manager": [calculate_position_size, set_stop_loss],
60
+ }
61
+ callback: Optional activity feed callback for real-time UI updates
62
+ """
63
+ self._config = config
64
+ self._tools = tools
65
+ self._callback = callback
66
+ self._parser = TradingSignalParser()
67
+
68
+ def run(self, ticker: str) -> CrewResult:
69
+ """Execute the full analysis pipeline for a single ticker.
70
+
71
+ Builds the crew, kicks off execution, parses the output into a
72
+ TradingSignal, and returns a CrewResult. Emits callback events
73
+ at task start, completion, and failure points.
74
+
75
+ Args:
76
+ ticker: Stock ticker symbol to analyze (e.g., "AAPL")
77
+
78
+ Returns:
79
+ CrewResult with parsed TradingSignal on success,
80
+ or error details on failure.
81
+ """
82
+ try:
83
+ if self._callback:
84
+ self._callback.on_task_start("Crew", ticker)
85
+
86
+ crew = self._build_crew(ticker)
87
+ result = crew.kickoff()
88
+ raw_output = str(result)
89
+
90
+ signal = self._parse_output(raw_output, ticker)
91
+
92
+ if signal is not None:
93
+ if self._callback:
94
+ self._callback.on_task_complete(
95
+ "Chief Strategist",
96
+ ticker,
97
+ f"{signal.action.value} ({signal.confidence}%)",
98
+ )
99
+ return CrewResult(
100
+ ticker=ticker,
101
+ signal=signal,
102
+ raw_output=raw_output,
103
+ success=True,
104
+ )
105
+ else:
106
+ if self._callback:
107
+ self._callback.on_task_failed(
108
+ "Chief Strategist",
109
+ ticker,
110
+ "Failed to parse trading signal",
111
+ )
112
+ return CrewResult(
113
+ ticker=ticker,
114
+ signal=None,
115
+ raw_output=raw_output,
116
+ success=False,
117
+ error="Failed to parse trading signal from crew output",
118
+ )
119
+ except Exception as e:
120
+ error_msg = str(e)
121
+ if self._callback:
122
+ self._callback.on_task_failed("Crew", ticker, error_msg)
123
+ return CrewResult(
124
+ ticker=ticker,
125
+ signal=None,
126
+ raw_output="",
127
+ success=False,
128
+ error=error_msg,
129
+ )
130
+
131
+ def _build_crew(self, ticker: str) -> Crew:
132
+ """Assemble the Crew with agents, tasks, and process configuration.
133
+
134
+ Creates all five agents with their respective tools, builds tasks
135
+ with proper dependency chains, and returns a configured Crew instance.
136
+
137
+ Args:
138
+ ticker: Stock ticker symbol for task descriptions
139
+
140
+ Returns:
141
+ Configured Crew instance ready for kickoff
142
+ """
143
+ llm = create_llm(self._config.llm)
144
+
145
+ # Create agents with their assigned tools
146
+ market_scanner = create_market_scanner(
147
+ llm, self._tools.get("market_scanner", [])
148
+ )
149
+ fundamental_analyst = create_fundamental_analyst(
150
+ llm, self._tools.get("fundamental_analyst", [])
151
+ )
152
+ technical_analyst = create_technical_analyst(
153
+ llm, self._tools.get("technical_analyst", [])
154
+ )
155
+ risk_manager = create_risk_manager(
156
+ llm, self._tools.get("risk_manager", [])
157
+ )
158
+ chief_strategist = create_chief_strategist(llm)
159
+
160
+ # Create tasks with dependency chain
161
+ market_task = create_market_scan_task(market_scanner, ticker)
162
+ fundamental_task = create_fundamental_task(fundamental_analyst, ticker)
163
+ technical_task = create_technical_task(technical_analyst, ticker)
164
+ risk_task = create_risk_task(risk_manager, ticker, [technical_task])
165
+ strategy_task = create_strategy_task(
166
+ chief_strategist,
167
+ ticker,
168
+ [market_task, fundamental_task, technical_task, risk_task],
169
+ )
170
+
171
+ return Crew(
172
+ agents=[
173
+ market_scanner,
174
+ fundamental_analyst,
175
+ technical_analyst,
176
+ risk_manager,
177
+ chief_strategist,
178
+ ],
179
+ tasks=[
180
+ market_task,
181
+ fundamental_task,
182
+ technical_task,
183
+ risk_task,
184
+ strategy_task,
185
+ ],
186
+ process=Process.sequential,
187
+ verbose=self._config.crew.verbose,
188
+ )
189
+
190
+ def _parse_output(
191
+ self, raw_output: str, ticker: str
192
+ ) -> Optional[TradingSignal]:
193
+ """Parse crew output into a TradingSignal.
194
+
195
+ Delegates to TradingSignalParser which attempts primary structured
196
+ format first, then falls back to heuristic extraction.
197
+
198
+ Args:
199
+ raw_output: Raw text output from the crew execution
200
+ ticker: Expected ticker symbol for validation
201
+
202
+ Returns:
203
+ TradingSignal if parsing succeeds, None if output is unparseable
204
+ """
205
+ return self._parser.parse(raw_output, ticker)
crew/runner.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WatchlistRunner — multi-ticker sequential execution with fault isolation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+
8
+ from crew.config import OrchestratorConfig
9
+ from crew.crew import CrewResult, FinAgentCrew
10
+ from crew.callbacks import ActivityFeedCallback
11
+
12
+
13
+ @dataclass
14
+ class WatchlistResult:
15
+ """Aggregated result of running the analysis pipeline across multiple tickers."""
16
+
17
+ signals: list[CrewResult] = field(default_factory=list)
18
+ total_tickers: int = 0
19
+ successful: int = 0
20
+ failed: int = 0
21
+
22
+
23
+ class WatchlistRunner:
24
+ """Runs the FinAgentCrew pipeline for each ticker in a watchlist sequentially."""
25
+
26
+ def __init__(
27
+ self,
28
+ config: OrchestratorConfig,
29
+ tools: dict[str, list],
30
+ callback: Optional[ActivityFeedCallback] = None,
31
+ ):
32
+ self._config = config
33
+ self._tools = tools
34
+ self._callback = callback
35
+
36
+ def run(self, watchlist: str) -> WatchlistResult:
37
+ """Parse the watchlist and run the analysis pipeline for each ticker.
38
+
39
+ Args:
40
+ watchlist: Comma-separated string of ticker symbols.
41
+
42
+ Returns:
43
+ WatchlistResult with aggregated signals and success/failure counts.
44
+ """
45
+ tickers = self._parse_watchlist(watchlist)
46
+ results: list[CrewResult] = []
47
+ successful = 0
48
+ failed = 0
49
+
50
+ for ticker in tickers:
51
+ if self._callback:
52
+ self._callback.on_ticker_start(ticker)
53
+
54
+ result = self._run_single(ticker)
55
+ results.append(result)
56
+
57
+ if result.success:
58
+ successful += 1
59
+ else:
60
+ failed += 1
61
+
62
+ if self._callback:
63
+ self._callback.on_ticker_complete(ticker, result.signal)
64
+
65
+ return WatchlistResult(
66
+ signals=results,
67
+ total_tickers=len(tickers),
68
+ successful=successful,
69
+ failed=failed,
70
+ )
71
+
72
+ def _parse_watchlist(self, watchlist: str) -> list[str]:
73
+ """Split watchlist string on commas, strip whitespace, uppercase, remove empties.
74
+
75
+ Args:
76
+ watchlist: Raw comma-separated ticker string.
77
+
78
+ Returns:
79
+ List of cleaned, uppercased ticker symbols.
80
+ """
81
+ parts = watchlist.split(",")
82
+ tickers = []
83
+ for part in parts:
84
+ stripped = part.strip().upper()
85
+ if stripped:
86
+ tickers.append(stripped)
87
+ return tickers
88
+
89
+ def _run_single(self, ticker: str) -> CrewResult:
90
+ """Run the full analysis pipeline for a single ticker with error isolation.
91
+
92
+ Args:
93
+ ticker: Uppercased ticker symbol.
94
+
95
+ Returns:
96
+ CrewResult on success or a failure CrewResult if an exception occurs.
97
+ """
98
+ try:
99
+ crew = FinAgentCrew(
100
+ config=self._config,
101
+ tools=self._tools,
102
+ callback=self._callback,
103
+ )
104
+ return crew.run(ticker)
105
+ except Exception as e:
106
+ return CrewResult(
107
+ ticker=ticker,
108
+ signal=None,
109
+ raw_output="",
110
+ success=False,
111
+ error=str(e),
112
+ )
crew/signals.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TradingSignal dataclass and parser for structured output handling."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Optional
7
+
8
+
9
+ class Action(str, Enum):
10
+ """Trading signal actions."""
11
+
12
+ BUY = "BUY"
13
+ SELL = "SELL"
14
+ HOLD = "HOLD"
15
+
16
+
17
+ @dataclass
18
+ class TradingSignal:
19
+ """Structured trading signal output."""
20
+
21
+ ticker: str
22
+ action: Action
23
+ confidence: int # 0-100
24
+ entry_price: Optional[float] = None
25
+ stop_loss: Optional[float] = None
26
+ target_price: Optional[float] = None
27
+ reasoning: Optional[dict[str, str]] = None # {agent_name: summary}
28
+
29
+ @staticmethod
30
+ def validate_confidence(value: int) -> int:
31
+ """Clamp confidence to 0-100 range."""
32
+ return max(0, min(100, value))
33
+
34
+
35
+ class TradingSignalParser:
36
+ """Parses raw LLM output into structured TradingSignal objects."""
37
+
38
+ # Primary format: "AAPL — BUY (Confidence: 75%)"
39
+ PRIMARY_PATTERN = re.compile(
40
+ r"([A-Z\-\.]+)\s*[—–-]\s*(BUY|SELL|HOLD)\s*\(Confidence:\s*(\d{1,3})%\)",
41
+ re.IGNORECASE,
42
+ )
43
+
44
+ # Fallback patterns for less structured output
45
+ ACTION_PATTERN = re.compile(r"\b(BUY|SELL|HOLD)\b", re.IGNORECASE)
46
+ CONFIDENCE_PATTERN = re.compile(r"(\d{1,3})\s*%")
47
+ PRICE_PATTERN = re.compile(r"\$\s*([\d,]+\.?\d*)")
48
+
49
+ def parse(self, raw_output: str, ticker: str) -> Optional[TradingSignal]:
50
+ """Parse raw output into a TradingSignal.
51
+
52
+ Attempts primary pattern first, falls back to heuristic extraction.
53
+
54
+ Args:
55
+ raw_output: Raw text from Chief Strategist agent
56
+ ticker: Expected ticker symbol
57
+
58
+ Returns:
59
+ TradingSignal if parsing succeeds, None if output is unparseable
60
+ """
61
+ signal = self._parse_primary(raw_output, ticker)
62
+ if signal is not None:
63
+ return signal
64
+ return self._parse_fallback(raw_output, ticker)
65
+
66
+ def _parse_primary(self, raw_output: str, ticker: str) -> Optional[TradingSignal]:
67
+ """Attempt to parse using the primary structured format."""
68
+ match = self.PRIMARY_PATTERN.search(raw_output)
69
+ if not match:
70
+ return None
71
+
72
+ parsed_ticker = match.group(1).upper()
73
+ action_str = match.group(2).upper()
74
+ confidence_raw = int(match.group(3))
75
+
76
+ action = Action(action_str)
77
+ confidence = TradingSignal.validate_confidence(confidence_raw)
78
+
79
+ prices = self._extract_prices(raw_output)
80
+ reasoning = self._extract_reasoning(raw_output)
81
+
82
+ return TradingSignal(
83
+ ticker=parsed_ticker,
84
+ action=action,
85
+ confidence=confidence,
86
+ entry_price=prices.get("entry"),
87
+ stop_loss=prices.get("stop_loss"),
88
+ target_price=prices.get("target"),
89
+ reasoning=reasoning,
90
+ )
91
+
92
+ def _parse_fallback(self, raw_output: str, ticker: str) -> Optional[TradingSignal]:
93
+ """Attempt heuristic extraction from unstructured output."""
94
+ action_match = self.ACTION_PATTERN.search(raw_output)
95
+ if not action_match:
96
+ return None
97
+
98
+ action = Action(action_match.group(1).upper())
99
+
100
+ confidence_match = self.CONFIDENCE_PATTERN.search(raw_output)
101
+ if confidence_match:
102
+ confidence = TradingSignal.validate_confidence(int(confidence_match.group(1)))
103
+ else:
104
+ confidence = 50 # Default confidence when not specified
105
+
106
+ prices = self._extract_prices(raw_output)
107
+
108
+ return TradingSignal(
109
+ ticker=ticker.upper(),
110
+ action=action,
111
+ confidence=confidence,
112
+ entry_price=prices.get("entry"),
113
+ stop_loss=prices.get("stop_loss"),
114
+ target_price=prices.get("target"),
115
+ )
116
+
117
+ def _extract_prices(self, raw_output: str) -> dict[str, Optional[float]]:
118
+ """Extract entry, stop-loss, and target prices from text.
119
+
120
+ Finds all $XX.XX patterns and assigns:
121
+ - First as entry price
122
+ - Second as stop_loss
123
+ - Third as target price
124
+ """
125
+ matches = self.PRICE_PATTERN.findall(raw_output)
126
+ prices: dict[str, Optional[float]] = {
127
+ "entry": None,
128
+ "stop_loss": None,
129
+ "target": None,
130
+ }
131
+
132
+ # Parse matched price strings, removing commas
133
+ parsed = []
134
+ for m in matches:
135
+ cleaned = m.replace(",", "")
136
+ try:
137
+ parsed.append(float(cleaned))
138
+ except ValueError:
139
+ continue
140
+
141
+ if len(parsed) >= 1:
142
+ prices["entry"] = parsed[0]
143
+ if len(parsed) >= 2:
144
+ prices["stop_loss"] = parsed[1]
145
+ if len(parsed) >= 3:
146
+ prices["target"] = parsed[2]
147
+
148
+ return prices
149
+
150
+ def _extract_reasoning(self, raw_output: str) -> Optional[dict[str, str]]:
151
+ """Extract per-agent reasoning summaries from text.
152
+
153
+ Looks for lines like:
154
+ - Market: ...
155
+ - Fundamental: ...
156
+ - Technical: ...
157
+ - Risk: ...
158
+ """
159
+ reasoning_pattern = re.compile(
160
+ r"^-\s*(Market|Fundamental|Technical|Risk)\s*:\s*(.+)$",
161
+ re.MULTILINE | re.IGNORECASE,
162
+ )
163
+ matches = reasoning_pattern.findall(raw_output)
164
+
165
+ if not matches:
166
+ return None
167
+
168
+ reasoning = {}
169
+ for key, value in matches:
170
+ reasoning[key.strip()] = value.strip()
171
+
172
+ return reasoning if reasoning else None
crew/tasks.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task factory functions with dependencies for CrewAI Task instances."""
2
+
3
+ from crewai import Task, Agent
4
+
5
+
6
+ def create_market_scan_task(agent: Agent, ticker: str) -> Task:
7
+ """Create the market scanning task."""
8
+ return Task(
9
+ description=(
10
+ f"Analyze the current market conditions for {ticker}. "
11
+ f"Search for recent news, check price changes, and identify volume anomalies. "
12
+ f"Summarize any significant market events that could affect the stock."
13
+ ),
14
+ expected_output=(
15
+ f"A summary of market conditions for {ticker} including: "
16
+ f"key news events, price change magnitude and direction, "
17
+ f"and whether volume is normal or unusual."
18
+ ),
19
+ agent=agent,
20
+ )
21
+
22
+
23
+ def create_fundamental_task(agent: Agent, ticker: str) -> Task:
24
+ """Create the fundamental analysis task."""
25
+ return Task(
26
+ description=(
27
+ f"Perform a fundamental analysis of {ticker}. "
28
+ f"Retrieve financial metrics, recent earnings data, and peer comparisons. "
29
+ f"Assess whether the stock is overvalued, undervalued, or fairly valued."
30
+ ),
31
+ expected_output=(
32
+ f"A valuation assessment for {ticker} including: "
33
+ f"key financial metrics (P/E, margins, growth), "
34
+ f"earnings trend and surprises, peer comparison, "
35
+ f"and an overall fundamental outlook (bullish/bearish/neutral)."
36
+ ),
37
+ agent=agent,
38
+ )
39
+
40
+
41
+ def create_technical_task(agent: Agent, ticker: str) -> Task:
42
+ """Create the technical analysis task."""
43
+ return Task(
44
+ description=(
45
+ f"Perform a technical analysis of {ticker}. "
46
+ f"Retrieve price history and calculate technical indicators. "
47
+ f"Identify the current trend, support/resistance levels, "
48
+ f"and recommend entry and target prices."
49
+ ),
50
+ expected_output=(
51
+ f"A technical analysis for {ticker} including: "
52
+ f"current trend direction, RSI/MACD/Bollinger signals, "
53
+ f"recommended entry price, and target price."
54
+ ),
55
+ agent=agent,
56
+ )
57
+
58
+
59
+ def create_risk_task(agent: Agent, ticker: str, context: list) -> Task:
60
+ """Create the risk assessment task.
61
+
62
+ Args:
63
+ agent: Risk Manager agent
64
+ ticker: Stock symbol
65
+ context: [technical_task] — depends on Technical Analyst output
66
+ """
67
+ return Task(
68
+ description=(
69
+ f"Calculate position sizing and stop-loss levels for {ticker}. "
70
+ f"Use the entry price from the Technical Analyst's recommendation "
71
+ f"to determine optimal position size and ATR-based stop-loss."
72
+ ),
73
+ expected_output=(
74
+ f"Risk parameters for {ticker} including: "
75
+ f"recommended position size, stop-loss price, "
76
+ f"take-profit target, and risk-reward ratio."
77
+ ),
78
+ agent=agent,
79
+ context=context,
80
+ )
81
+
82
+
83
+ def create_strategy_task(agent: Agent, ticker: str, context: list) -> Task:
84
+ """Create the strategy synthesis task.
85
+
86
+ Args:
87
+ agent: Chief Strategist agent
88
+ ticker: Stock symbol
89
+ context: [market_task, fundamental_task, technical_task, risk_task]
90
+ """
91
+ return Task(
92
+ description=(
93
+ f"Synthesize all analysis for {ticker} into a final trading signal. "
94
+ f"Consider the market conditions, fundamental valuation, technical signals, "
95
+ f"and risk parameters. Produce a clear BUY, SELL, or HOLD recommendation "
96
+ f"with a confidence percentage.\n\n"
97
+ f"Your output MUST follow this exact format:\n"
98
+ f"{ticker} — ACTION (Confidence: XX%)\n"
99
+ f"Entry: $XX.XX\n"
100
+ f"Stop Loss: $XX.XX\n"
101
+ f"Target: $XX.XX\n"
102
+ f"Reasoning:\n"
103
+ f"- Market: [summary]\n"
104
+ f"- Fundamental: [summary]\n"
105
+ f"- Technical: [summary]\n"
106
+ f"- Risk: [summary]"
107
+ ),
108
+ expected_output=(
109
+ f"A trading signal in the format: "
110
+ f"'{ticker} — BUY/SELL/HOLD (Confidence: XX%)' "
111
+ f"followed by entry, stop-loss, target prices and reasoning summaries."
112
+ ),
113
+ agent=agent,
114
+ context=context,
115
+ )
rendering.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTML/CSS rendering for the FinAgent Gradio frontend.
2
+
3
+ Provides pure rendering functions that generate HTML strings for:
4
+ - Custom dark terminal theme CSS
5
+ - Trading signal cards (BUY/SELL/HOLD)
6
+ - Error cards for failed ticker analysis
7
+ - Aggregate summary bar
8
+ - Activity feed entries and container
9
+
10
+ Full implementation is provided in task 4.x; this module currently
11
+ contains function stubs.
12
+ """
13
+
14
+ from datetime import datetime
15
+ from typing import Any, List, Optional
16
+
17
+
18
+ def build_css() -> str:
19
+ """Generate custom CSS for the dark financial terminal theme.
20
+
21
+ Returns:
22
+ CSS string to be injected into the Gradio Blocks ``css`` parameter.
23
+ """
24
+ return """
25
+ .gradio-container {
26
+ font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace !important;
27
+ background-color: #0d1117 !important;
28
+ }
29
+ .activity-feed {
30
+ background: #161b22;
31
+ border: 1px solid #30363d;
32
+ border-radius: 6px;
33
+ padding: 12px;
34
+ max-height: 400px;
35
+ overflow-y: auto;
36
+ font-family: 'JetBrains Mono', monospace;
37
+ font-size: 13px;
38
+ }
39
+ .activity-entry {
40
+ padding: 4px 0;
41
+ border-bottom: 1px solid #21262d;
42
+ color: #c9d1d9;
43
+ }
44
+ .activity-timestamp {
45
+ color: #8b949e;
46
+ margin-right: 8px;
47
+ }
48
+ .activity-agent {
49
+ color: #58a6ff;
50
+ font-weight: bold;
51
+ }
52
+ .activity-spinner {
53
+ color: #f0883e;
54
+ }
55
+ .signal-card {
56
+ background: #161b22;
57
+ border: 1px solid #30363d;
58
+ border-radius: 8px;
59
+ padding: 16px;
60
+ margin: 8px 0;
61
+ border-left: 4px solid;
62
+ }
63
+ .signal-buy { border-left-color: #3fb950; }
64
+ .signal-sell { border-left-color: #f85149; }
65
+ .signal-hold { border-left-color: #d29922; }
66
+ .signal-error { border-left-color: #f85149; background: #1c0c0c; }
67
+ .signal-ticker {
68
+ font-size: 18px;
69
+ font-weight: bold;
70
+ color: #f0f6fc;
71
+ }
72
+ .signal-action-buy { color: #3fb950; }
73
+ .signal-action-sell { color: #f85149; }
74
+ .signal-action-hold { color: #d29922; }
75
+ .signal-confidence {
76
+ font-size: 14px;
77
+ color: #8b949e;
78
+ }
79
+ .signal-prices {
80
+ display: grid;
81
+ grid-template-columns: repeat(3, 1fr);
82
+ gap: 8px;
83
+ margin-top: 8px;
84
+ }
85
+ .signal-price-item {
86
+ text-align: center;
87
+ padding: 8px;
88
+ background: #0d1117;
89
+ border-radius: 4px;
90
+ }
91
+ .signal-price-label {
92
+ font-size: 11px;
93
+ color: #8b949e;
94
+ text-transform: uppercase;
95
+ }
96
+ .signal-price-value {
97
+ font-size: 16px;
98
+ color: #f0f6fc;
99
+ font-weight: bold;
100
+ }
101
+ .summary-bar {
102
+ display: flex;
103
+ gap: 16px;
104
+ padding: 12px;
105
+ background: #161b22;
106
+ border-radius: 6px;
107
+ margin-top: 12px;
108
+ border: 1px solid #30363d;
109
+ }
110
+ .summary-item {
111
+ text-align: center;
112
+ flex: 1;
113
+ }
114
+ """
115
+
116
+
117
+ def _format_price(value: Optional[float]) -> str:
118
+ """Format an optional price as ``$X.XX`` or ``N/A`` when missing."""
119
+ if value is None:
120
+ return "N/A"
121
+ return f"${value:.2f}"
122
+
123
+
124
+ def _action_text(action: Any) -> str:
125
+ """Return the upper-case action label from an enum-like or string value."""
126
+ # Support both enum-like objects (with ``.value``) and plain strings.
127
+ raw = getattr(action, "value", action)
128
+ return str(raw).upper()
129
+
130
+
131
+ def render_signal_card(signal: Any) -> str:
132
+ """Render a TradingSignal as an HTML card.
133
+
134
+ Args:
135
+ signal: TradingSignal dataclass instance containing ticker, action,
136
+ confidence, entry/stop-loss/target prices, and reasoning.
137
+
138
+ Returns:
139
+ HTML string for the signal card with action-specific color coding.
140
+ """
141
+ action_text = _action_text(signal.action)
142
+ action_lower = action_text.lower()
143
+ action_class = f"signal-{action_lower}"
144
+ action_color_class = f"signal-action-{action_lower}"
145
+
146
+ entry_price = getattr(signal, "entry_price", None)
147
+ stop_loss = getattr(signal, "stop_loss", None)
148
+ target_price = getattr(signal, "target_price", None)
149
+
150
+ prices_html = f"""
151
+ <div class="signal-prices">
152
+ <div class="signal-price-item">
153
+ <div class="signal-price-label">Entry</div>
154
+ <div class="signal-price-value">{_format_price(entry_price)}</div>
155
+ </div>
156
+ <div class="signal-price-item">
157
+ <div class="signal-price-label">Stop Loss</div>
158
+ <div class="signal-price-value">{_format_price(stop_loss)}</div>
159
+ </div>
160
+ <div class="signal-price-item">
161
+ <div class="signal-price-label">Target</div>
162
+ <div class="signal-price-value">{_format_price(target_price)}</div>
163
+ </div>
164
+ </div>
165
+ """
166
+
167
+ reasoning = getattr(signal, "reasoning", None) or {}
168
+ reasoning_html = ""
169
+ if reasoning:
170
+ reasoning_items = "".join(
171
+ f"<li><strong>{k}:</strong> {v}</li>"
172
+ for k, v in reasoning.items()
173
+ )
174
+ reasoning_html = (
175
+ f"<ul style='color:#c9d1d9;margin-top:8px;'>{reasoning_items}</ul>"
176
+ )
177
+
178
+ return f"""
179
+ <div class="signal-card {action_class}">
180
+ <div style="display:flex;justify-content:space-between;align-items:center;">
181
+ <span class="signal-ticker">{signal.ticker}</span>
182
+ <span class="{action_color_class}" style="font-size:20px;font-weight:bold;">
183
+ {action_text}
184
+ </span>
185
+ </div>
186
+ <div class="signal-confidence">Confidence: {signal.confidence}%</div>
187
+ {prices_html}
188
+ {reasoning_html}
189
+ </div>
190
+ """
191
+
192
+
193
+ def render_error_card(ticker: str, error_message: str) -> str:
194
+ """Render an error card for a failed ticker analysis.
195
+
196
+ Args:
197
+ ticker: The ticker symbol that failed analysis.
198
+ error_message: Human-readable error description.
199
+
200
+ Returns:
201
+ HTML string for the error card with the ``signal-error`` class.
202
+ """
203
+ return f"""
204
+ <div class="signal-card signal-error">
205
+ <div class="signal-ticker">{ticker}</div>
206
+ <div style="color:#f85149;margin-top:4px;">⚠️ Analysis failed: {error_message}</div>
207
+ </div>
208
+ """
209
+
210
+
211
+ def render_summary(
212
+ total: int,
213
+ buy_count: int,
214
+ sell_count: int,
215
+ hold_count: int,
216
+ ) -> str:
217
+ """Render the aggregate summary bar.
218
+
219
+ Args:
220
+ total: Total number of tickers analyzed.
221
+ buy_count: Number of BUY signals.
222
+ sell_count: Number of SELL signals.
223
+ hold_count: Number of HOLD signals.
224
+
225
+ Returns:
226
+ HTML string for the summary bar with color-coded counts
227
+ (green for BUY, red for SELL, yellow for HOLD).
228
+ """
229
+ return f"""
230
+ <div class="summary-bar">
231
+ <div class="summary-item">
232
+ <div style="font-size:24px;color:#f0f6fc;">{total}</div>
233
+ <div style="font-size:11px;color:#8b949e;">ANALYZED</div>
234
+ </div>
235
+ <div class="summary-item">
236
+ <div style="font-size:24px;color:#3fb950;">{buy_count}</div>
237
+ <div style="font-size:11px;color:#8b949e;">BUY</div>
238
+ </div>
239
+ <div class="summary-item">
240
+ <div style="font-size:24px;color:#f85149;">{sell_count}</div>
241
+ <div style="font-size:11px;color:#8b949e;">SELL</div>
242
+ </div>
243
+ <div class="summary-item">
244
+ <div style="font-size:24px;color:#d29922;">{hold_count}</div>
245
+ <div style="font-size:11px;color:#8b949e;">HOLD</div>
246
+ </div>
247
+ </div>
248
+ """
249
+
250
+
251
+ def render_activity_entry(
252
+ timestamp: datetime,
253
+ agent_name: str,
254
+ message: str,
255
+ is_spinner: bool = False,
256
+ ) -> str:
257
+ """Render a single activity feed entry as HTML.
258
+
259
+ Args:
260
+ timestamp: Entry timestamp (formatted as HH:MM:SS).
261
+ agent_name: Name of the agent producing the entry.
262
+ message: Entry message content.
263
+ is_spinner: When True, include a spinner indicator.
264
+
265
+ Returns:
266
+ HTML string for the activity entry.
267
+ """
268
+ time_str = timestamp.strftime("%H:%M:%S")
269
+ spinner = '<span class="activity-spinner"> ⟳</span>' if is_spinner else ""
270
+ return f"""
271
+ <div class="activity-entry">
272
+ <span class="activity-timestamp">[{time_str}]</span>
273
+ <span class="activity-agent">{agent_name}</span>{spinner}
274
+ <span>{message}</span>
275
+ </div>
276
+ """
277
+
278
+
279
+ def render_activity_feed(entries: List[str]) -> str:
280
+ """Wrap activity entries in the feed container with auto-scroll.
281
+
282
+ Args:
283
+ entries: List of pre-rendered HTML entry strings.
284
+
285
+ Returns:
286
+ HTML string for the activity feed container, including an
287
+ auto-scroll script that pins the view to the latest entry.
288
+ """
289
+ entries_html = "\n".join(entries)
290
+ return f"""
291
+ <div class="activity-feed" id="activity-feed">
292
+ {entries_html}
293
+ </div>
294
+ <script>
295
+ var feed = document.getElementById('activity-feed');
296
+ if (feed) feed.scrollTop = feed.scrollHeight;
297
+ </script>
298
+ """
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space — runtime dependencies for FinAgent.
2
+ #
3
+ # This file is consumed by the Space builder and installs into a CPU-only
4
+ # environment (the GPU inference runs elsewhere on AMD Developer Cloud).
5
+ # Pins here should stay tight to avoid drifting into incompatible combinations.
6
+
7
+ # Gradio frontend framework
8
+ gradio==4.44.1
9
+
10
+ # Agent orchestration
11
+ crewai==1.14.4
12
+ langchain-openai==0.3.18
13
+
14
+ # Market / tool data providers
15
+ yfinance==1.3.0
16
+ ddgs>=9.0,<10
17
+ pandas-ta-remake==1.0.4
18
+
19
+ # pandas-ta-remake 1.0.4 still imports pkg_resources; Setuptools >=81 removed it.
20
+ setuptools<81
21
+
22
+ # Tokenizers 0.20.x lacks DecodeStream on Py3.13; crewai needs >=0.21.
23
+ tokenizers>=0.21,<1
tools/__init__.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent Tools Module for FinAgent.
3
+
4
+ This module provides 10 CrewAI tool functions across four domains:
5
+ - Market Scanner: search_news, get_price_change, get_volume
6
+ - Fundamental Analyst: get_financials, get_earnings, get_peers
7
+ - Technical Analyst: get_price_history, calculate_indicators
8
+ - Risk Manager: calculate_position_size, set_stop_loss
9
+
10
+ All tools return formatted strings and handle errors gracefully.
11
+ """
12
+
13
+ # Market Scanner Tools
14
+ from tools.market_scanner import search_news, get_price_change, get_volume
15
+
16
+ # Fundamental Analyst Tools
17
+ from tools.fundamental_analyst import get_financials, get_earnings, get_peers
18
+
19
+ # Technical Analyst Tools
20
+ from tools.technical_analyst import get_price_history, calculate_indicators
21
+
22
+ # Risk Manager Tools
23
+ from tools.risk_manager import calculate_position_size, set_stop_loss
24
+
25
+ __all__ = [
26
+ # Market Scanner
27
+ "search_news",
28
+ "get_price_change",
29
+ "get_volume",
30
+ # Fundamental Analyst
31
+ "get_financials",
32
+ "get_earnings",
33
+ "get_peers",
34
+ # Technical Analyst
35
+ "get_price_history",
36
+ "calculate_indicators",
37
+ # Risk Manager
38
+ "calculate_position_size",
39
+ "set_stop_loss",
40
+ ]
tools/cache.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TTL Cache module for Agent Tools.
3
+
4
+ Provides an in-memory cache with time-to-live eviction to avoid
5
+ redundant API calls and rate limiting.
6
+ """
7
+
8
+ import json
9
+ import threading
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import Optional
13
+
14
+
15
+ @dataclass
16
+ class CacheEntry:
17
+ """A single cache entry with its stored value and creation timestamp."""
18
+
19
+ value: str
20
+ timestamp: float
21
+
22
+
23
+ class TTLCache:
24
+ """In-memory cache with time-to-live eviction.
25
+
26
+ Stores string results keyed by function name + parameters.
27
+ Thread-safe via threading.Lock on all read/write operations.
28
+ """
29
+
30
+ def __init__(self, default_ttl: int = 300, max_age: int = 900):
31
+ """Initialize the cache.
32
+
33
+ Args:
34
+ default_ttl: Time-to-live in seconds (default 300 = 5 minutes).
35
+ Entries older than this are considered expired on get().
36
+ max_age: Maximum entry age before forced eviction (default 900 = 15 minutes).
37
+ Entries older than this are removed during eviction sweeps.
38
+ """
39
+ self.default_ttl = default_ttl
40
+ self.max_age = max_age
41
+ self._store: dict[str, CacheEntry] = {}
42
+ self._lock = threading.Lock()
43
+
44
+ def make_key(self, func_name: str, **kwargs) -> str:
45
+ """Generate a deterministic cache key from function name and parameters.
46
+
47
+ Uses sorted JSON serialization to ensure the same parameters always
48
+ produce the same key regardless of argument order.
49
+
50
+ Args:
51
+ func_name: The name of the tool function.
52
+ **kwargs: The parameters passed to the function.
53
+
54
+ Returns:
55
+ A string key in the format "func_name:{sorted_params_json}".
56
+ """
57
+ sorted_params_json = json.dumps(kwargs, sort_keys=True)
58
+ return f"{func_name}:{sorted_params_json}"
59
+
60
+ def get(self, key: str) -> Optional[str]:
61
+ """Return cached value if it exists and is within TTL, else None.
62
+
63
+ Always triggers _evict_stale() to clean up old entries.
64
+
65
+ Args:
66
+ key: The cache key to look up.
67
+
68
+ Returns:
69
+ The cached string value if valid, or None if expired/missing.
70
+ """
71
+ with self._lock:
72
+ self._evict_stale()
73
+ entry = self._store.get(key)
74
+ if entry is None:
75
+ return None
76
+ if time.time() - entry.timestamp > self.default_ttl:
77
+ return None
78
+ return entry.value
79
+
80
+ def set(self, key: str, value: str) -> None:
81
+ """Store a value in the cache with the current timestamp.
82
+
83
+ Args:
84
+ key: The cache key.
85
+ value: The string value to cache.
86
+ """
87
+ with self._lock:
88
+ self._store[key] = CacheEntry(value=value, timestamp=time.time())
89
+
90
+ def _evict_stale(self) -> None:
91
+ """Remove entries older than max_age (15 minutes by default).
92
+
93
+ This method is called internally and assumes the lock is already held.
94
+ """
95
+ current_time = time.time()
96
+ stale_keys = [
97
+ key
98
+ for key, entry in self._store.items()
99
+ if current_time - entry.timestamp > self.max_age
100
+ ]
101
+ for key in stale_keys:
102
+ del self._store[key]
103
+
104
+ def clear(self) -> None:
105
+ """Remove all entries from the cache. Useful for testing."""
106
+ with self._lock:
107
+ self._store.clear()
tools/fundamental_analyst.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fundamental Analyst Tools for FinAgent.
3
+
4
+ Provides tools for fundamental analysis agents:
5
+ - get_financials: Retrieve key financial metrics
6
+ - get_earnings: Retrieve earnings history with surprise calculations
7
+ - get_peers: Retrieve sector/industry peers
8
+ """
9
+
10
+ import math
11
+
12
+ import yfinance
13
+ from crewai.tools import tool
14
+
15
+ from tools.cache import TTLCache
16
+ from tools.utils import validate_ticker, format_currency, format_percent, safe_get
17
+
18
+ cache = TTLCache()
19
+
20
+
21
+ @tool("Get Financials")
22
+ def get_financials(ticker: str) -> str:
23
+ """Retrieve key financial metrics for a company including market cap, P/E ratio, revenue growth, profit margin, and debt-to-equity ratio."""
24
+ try:
25
+ # 1. Input validation
26
+ valid, result = validate_ticker(ticker)
27
+ if not valid:
28
+ return result
29
+
30
+ normalized_ticker = result
31
+
32
+ # 2. Cache check
33
+ cache_key = cache.make_key("get_financials", ticker=normalized_ticker)
34
+ cached = cache.get(cache_key)
35
+ if cached:
36
+ return cached
37
+
38
+ # 3. External API call
39
+ info = yfinance.Ticker(normalized_ticker).info
40
+
41
+ if not info or info.get("regularMarketPrice") is None and info.get("currentPrice") is None:
42
+ return f"Error: Ticker '{normalized_ticker}' not found. Please verify the symbol."
43
+
44
+ # 4. Format response — missing fields show "N/A", not an error
45
+ market_cap = format_currency(info.get("marketCap"))
46
+ pe_ratio = safe_get(info, "trailingPE")
47
+ revenue_growth = format_percent(info.get("revenueGrowth"))
48
+ profit_margin = format_percent(info.get("profitMargins"))
49
+ debt_equity = safe_get(info, "debtToEquity")
50
+
51
+ response = (
52
+ f"Financial Metrics for {normalized_ticker}:\n"
53
+ f"Market Cap: {market_cap}\n"
54
+ f"P/E Ratio: {pe_ratio}\n"
55
+ f"Revenue Growth: {revenue_growth}\n"
56
+ f"Profit Margin: {profit_margin}\n"
57
+ f"Debt/Equity: {debt_equity}"
58
+ )
59
+
60
+ # 5. Cache and return
61
+ cache.set(cache_key, response)
62
+ return response
63
+
64
+ except Exception as e:
65
+ return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
66
+
67
+
68
+ @tool("Get Earnings")
69
+ def get_earnings(ticker: str) -> str:
70
+ """Retrieve earnings history with surprise calculations for a given ticker.
71
+
72
+ Args:
73
+ ticker: Stock symbol (e.g., AAPL) or crypto pair (e.g., BTC-USD).
74
+
75
+ Returns:
76
+ A formatted string with the last 4 quarters of earnings data including
77
+ reported EPS, estimated EPS, and surprise percentage, or an error message.
78
+ """
79
+ try:
80
+ # 1. Input validation
81
+ valid, result = validate_ticker(ticker)
82
+ if not valid:
83
+ return result
84
+
85
+ normalized_ticker = result
86
+
87
+ # 2. Detect crypto tickers (contain "-" like BTC-USD, ETH-USD)
88
+ if "-" in normalized_ticker:
89
+ return "Earnings data is not available for this instrument type."
90
+
91
+ # 3. Cache check
92
+ cache_key = cache.make_key("get_earnings", ticker=normalized_ticker)
93
+ cached = cache.get(cache_key)
94
+ if cached:
95
+ return cached
96
+
97
+ # 4. External API call
98
+ stock = yfinance.Ticker(normalized_ticker)
99
+ earnings_dates = stock.earnings_dates
100
+
101
+ # 5. Check if earnings data is available
102
+ if earnings_dates is None or earnings_dates.empty:
103
+ return "Earnings data is not available for this instrument type."
104
+
105
+ # 6. Filter to rows that have reported EPS (past earnings only)
106
+ relevant_cols = ["EPS Estimate", "Reported EPS"]
107
+ if not all(col in earnings_dates.columns for col in relevant_cols):
108
+ return "Earnings data is not available for this instrument type."
109
+
110
+ earnings_data = earnings_dates.dropna(subset=["Reported EPS"])
111
+
112
+ if earnings_data.empty:
113
+ return "Earnings data is not available for this instrument type."
114
+
115
+ # Take the last 4 quarters (most recent first)
116
+ earnings_data = earnings_data.head(4)
117
+
118
+ # 7. Format response
119
+ lines = [f"Earnings History for {normalized_ticker} (Last 4 Quarters):"]
120
+
121
+ for date_idx, row in earnings_data.iterrows():
122
+ reported_eps = row["Reported EPS"]
123
+ estimated_eps = row.get("EPS Estimate")
124
+
125
+ # Determine quarter label from the date index
126
+ quarter_date = date_idx
127
+ quarter_num = (quarter_date.month - 1) // 3 + 1
128
+ quarter_label = f"Q{quarter_num} {quarter_date.year}"
129
+
130
+ # Calculate surprise percentage
131
+ if estimated_eps is not None and estimated_eps != 0:
132
+ if not math.isnan(estimated_eps):
133
+ surprise = round(((reported_eps - estimated_eps) / abs(estimated_eps)) * 100, 2)
134
+ surprise_str = f"+{surprise:.2f}%" if surprise >= 0 else f"{surprise:.2f}%"
135
+ lines.append(
136
+ f"{quarter_label}: EPS ${reported_eps:.2f} "
137
+ f"(Est: ${estimated_eps:.2f}) | Surprise: {surprise_str}"
138
+ )
139
+ else:
140
+ lines.append(
141
+ f"{quarter_label}: EPS ${reported_eps:.2f} "
142
+ f"(Est: N/A) | Surprise: N/A"
143
+ )
144
+ else:
145
+ lines.append(
146
+ f"{quarter_label}: EPS ${reported_eps:.2f} "
147
+ f"(Est: N/A) | Surprise: N/A"
148
+ )
149
+
150
+ response = "\n".join(lines)
151
+
152
+ # 8. Cache and return
153
+ cache.set(cache_key, response)
154
+ return response
155
+
156
+ except Exception as e:
157
+ return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
158
+
159
+
160
+ # Sector-to-peers mapping for common sectors
161
+ SECTOR_PEERS = {
162
+ "Technology": [
163
+ ("MSFT", "Microsoft Corporation"),
164
+ ("AAPL", "Apple Inc."),
165
+ ("GOOGL", "Alphabet Inc."),
166
+ ("NVDA", "NVIDIA Corporation"),
167
+ ("META", "Meta Platforms Inc."),
168
+ ("AMZN", "Amazon.com Inc."),
169
+ ("CRM", "Salesforce Inc."),
170
+ ("ADBE", "Adobe Inc."),
171
+ ("ORCL", "Oracle Corporation"),
172
+ ("INTC", "Intel Corporation"),
173
+ ],
174
+ "Healthcare": [
175
+ ("JNJ", "Johnson & Johnson"),
176
+ ("UNH", "UnitedHealth Group Inc."),
177
+ ("PFE", "Pfizer Inc."),
178
+ ("ABBV", "AbbVie Inc."),
179
+ ("MRK", "Merck & Co. Inc."),
180
+ ("LLY", "Eli Lilly and Company"),
181
+ ("TMO", "Thermo Fisher Scientific Inc."),
182
+ ("ABT", "Abbott Laboratories"),
183
+ ],
184
+ "Financial Services": [
185
+ ("JPM", "JPMorgan Chase & Co."),
186
+ ("BAC", "Bank of America Corporation"),
187
+ ("GS", "Goldman Sachs Group Inc."),
188
+ ("MS", "Morgan Stanley"),
189
+ ("WFC", "Wells Fargo & Company"),
190
+ ("C", "Citigroup Inc."),
191
+ ("BLK", "BlackRock Inc."),
192
+ ("SCHW", "Charles Schwab Corporation"),
193
+ ],
194
+ "Consumer Cyclical": [
195
+ ("AMZN", "Amazon.com Inc."),
196
+ ("TSLA", "Tesla Inc."),
197
+ ("HD", "The Home Depot Inc."),
198
+ ("NKE", "Nike Inc."),
199
+ ("MCD", "McDonald's Corporation"),
200
+ ("SBUX", "Starbucks Corporation"),
201
+ ("TGT", "Target Corporation"),
202
+ ("LOW", "Lowe's Companies Inc."),
203
+ ],
204
+ "Consumer Defensive": [
205
+ ("PG", "Procter & Gamble Company"),
206
+ ("KO", "The Coca-Cola Company"),
207
+ ("PEP", "PepsiCo Inc."),
208
+ ("WMT", "Walmart Inc."),
209
+ ("COST", "Costco Wholesale Corporation"),
210
+ ("CL", "Colgate-Palmolive Company"),
211
+ ("MDLZ", "Mondelez International Inc."),
212
+ ],
213
+ "Communication Services": [
214
+ ("GOOGL", "Alphabet Inc."),
215
+ ("META", "Meta Platforms Inc."),
216
+ ("DIS", "The Walt Disney Company"),
217
+ ("NFLX", "Netflix Inc."),
218
+ ("CMCSA", "Comcast Corporation"),
219
+ ("T", "AT&T Inc."),
220
+ ("VZ", "Verizon Communications Inc."),
221
+ ],
222
+ "Industrials": [
223
+ ("CAT", "Caterpillar Inc."),
224
+ ("HON", "Honeywell International Inc."),
225
+ ("UPS", "United Parcel Service Inc."),
226
+ ("BA", "The Boeing Company"),
227
+ ("GE", "General Electric Company"),
228
+ ("RTX", "RTX Corporation"),
229
+ ("DE", "Deere & Company"),
230
+ ("LMT", "Lockheed Martin Corporation"),
231
+ ],
232
+ "Energy": [
233
+ ("XOM", "Exxon Mobil Corporation"),
234
+ ("CVX", "Chevron Corporation"),
235
+ ("COP", "ConocoPhillips"),
236
+ ("SLB", "Schlumberger Limited"),
237
+ ("EOG", "EOG Resources Inc."),
238
+ ("OXY", "Occidental Petroleum Corporation"),
239
+ ("MPC", "Marathon Petroleum Corporation"),
240
+ ],
241
+ "Real Estate": [
242
+ ("AMT", "American Tower Corporation"),
243
+ ("PLD", "Prologis Inc."),
244
+ ("CCI", "Crown Castle Inc."),
245
+ ("EQIX", "Equinix Inc."),
246
+ ("SPG", "Simon Property Group Inc."),
247
+ ("O", "Realty Income Corporation"),
248
+ ],
249
+ "Utilities": [
250
+ ("NEE", "NextEra Energy Inc."),
251
+ ("DUK", "Duke Energy Corporation"),
252
+ ("SO", "The Southern Company"),
253
+ ("D", "Dominion Energy Inc."),
254
+ ("AEP", "American Electric Power Company Inc."),
255
+ ("SRE", "Sempra"),
256
+ ],
257
+ "Basic Materials": [
258
+ ("LIN", "Linde plc"),
259
+ ("APD", "Air Products and Chemicals Inc."),
260
+ ("SHW", "The Sherwin-Williams Company"),
261
+ ("FCX", "Freeport-McMoRan Inc."),
262
+ ("NEM", "Newmont Corporation"),
263
+ ("DOW", "Dow Inc."),
264
+ ],
265
+ }
266
+
267
+
268
+ @tool("Get Peers")
269
+ def get_peers(ticker: str) -> str:
270
+ """Retrieve sector/industry classification and peer companies for a given ticker.
271
+ Returns sector, industry, and up to 5 peer companies in the same sector.
272
+ Use this to contextualize a company's performance relative to competitors."""
273
+ try:
274
+ # 1. Input validation
275
+ valid, result = validate_ticker(ticker)
276
+ if not valid:
277
+ return result
278
+
279
+ normalized_ticker = result
280
+
281
+ # 2. Cache check
282
+ cache_key = cache.make_key("get_peers", ticker=normalized_ticker)
283
+ cached = cache.get(cache_key)
284
+ if cached:
285
+ return cached
286
+
287
+ # 3. Detect crypto/ETFs early by ticker pattern (e.g., BTC-USD, ETH-USD)
288
+ if "-" in normalized_ticker:
289
+ return "Peer comparison is not available for this instrument type."
290
+
291
+ # 4. External API call
292
+ yf_ticker = yfinance.Ticker(normalized_ticker)
293
+ info = yf_ticker.info
294
+
295
+ # 5. Check if sector is available (missing for crypto/ETFs)
296
+ sector = info.get("sector")
297
+ industry = info.get("industry")
298
+
299
+ if not sector:
300
+ return "Peer comparison is not available for this instrument type."
301
+
302
+ # 6. Identify peers from sector mapping, excluding the ticker itself
303
+ sector_companies = SECTOR_PEERS.get(sector, [])
304
+ peers = [
305
+ (sym, name)
306
+ for sym, name in sector_companies
307
+ if sym != normalized_ticker
308
+ ][:5]
309
+
310
+ # 7. Format response
311
+ lines = [f"Peer Analysis for {normalized_ticker}:"]
312
+ lines.append(f"Sector: {sector}")
313
+ lines.append(f"Industry: {industry if industry else 'N/A'}")
314
+
315
+ if peers:
316
+ lines.append("Peers:")
317
+ for sym, name in peers:
318
+ lines.append(f"- {sym} ({name})")
319
+ else:
320
+ lines.append("Peers: No peer data available for this sector.")
321
+
322
+ response = "\n".join(lines)
323
+
324
+ # 8. Cache and return
325
+ cache.set(cache_key, response)
326
+ return response
327
+
328
+ except Exception as e:
329
+ return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
tools/market_scanner.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Market Scanner Tools for FinAgent.
3
+
4
+ Provides tools for market scanning agents:
5
+ - search_news: Search recent news for a ticker
6
+ - get_price_change: Get current price vs previous close
7
+ - get_volume: Get volume analysis with unusual activity detection
8
+ """
9
+
10
+ import yfinance
11
+ from crewai.tools import tool
12
+ from ddgs import DDGS
13
+
14
+ from tools.cache import TTLCache
15
+ from tools.utils import validate_ticker
16
+
17
+ cache = TTLCache()
18
+
19
+
20
+ @tool("Search News")
21
+ def search_news(ticker: str) -> str:
22
+ """Search recent news articles for a given ticker symbol.
23
+
24
+ Args:
25
+ ticker: Stock symbol (e.g., AAPL) or crypto pair (e.g., BTC-USD).
26
+
27
+ Returns:
28
+ A formatted string with up to 5 recent news articles including
29
+ title and snippet, or an error message if something goes wrong.
30
+ """
31
+ try:
32
+ # 1. Input validation
33
+ valid, result = validate_ticker(ticker)
34
+ if not valid:
35
+ return result
36
+
37
+ normalized_ticker = result
38
+
39
+ # 2. Cache check
40
+ cache_key = cache.make_key("search_news", ticker=normalized_ticker)
41
+ cached = cache.get(cache_key)
42
+ if cached:
43
+ return cached
44
+
45
+ # 3. External API call
46
+ ddgs = DDGS()
47
+ articles = ddgs.news(query=normalized_ticker, max_results=5, timelimit="w")
48
+
49
+ # 4. Format response
50
+ if not articles:
51
+ response = f"No recent news found for {normalized_ticker} in the last 7 days."
52
+ else:
53
+ lines = [f"Recent News for {normalized_ticker} (last 7 days):"]
54
+ for i, article in enumerate(articles[:5], start=1):
55
+ title = article.get("title", "No title")
56
+ snippet = article.get("body", "No description available")
57
+ lines.append(f"{i}. {title} - {snippet}")
58
+ response = "\n".join(lines)
59
+
60
+ # 5. Cache and return
61
+ cache.set(cache_key, response)
62
+ return response
63
+
64
+ except Exception as e:
65
+ return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
66
+
67
+
68
+ @tool("Get Price Change")
69
+ def get_price_change(ticker: str) -> str:
70
+ """Get current price vs previous close and calculate the change for a ticker.
71
+
72
+ Retrieves the current price and previous closing price, then calculates
73
+ the absolute and percentage change.
74
+
75
+ Args:
76
+ ticker: Stock symbol (e.g., "AAPL") or crypto pair (e.g., "BTC-USD").
77
+
78
+ Returns:
79
+ Formatted string with price change data or error message.
80
+ """
81
+ try:
82
+ # 1. Input validation
83
+ valid, result = validate_ticker(ticker)
84
+ if not valid:
85
+ return result
86
+
87
+ normalized_ticker = result
88
+
89
+ # 2. Cache check
90
+ cache_key = cache.make_key("get_price_change", ticker=normalized_ticker)
91
+ cached = cache.get(cache_key)
92
+ if cached:
93
+ return cached
94
+
95
+ # 3. External API call
96
+ stock = yfinance.Ticker(normalized_ticker)
97
+ info = stock.info
98
+
99
+ current_price = info.get("currentPrice")
100
+ previous_close = info.get("previousClose")
101
+
102
+ # Crypto tickers (e.g. BTC-USD) expose regularMarketPrice rather than
103
+ # currentPrice. Fall back to it when currentPrice is missing.
104
+ if current_price is None:
105
+ current_price = info.get("regularMarketPrice")
106
+
107
+ # Fall back to history when either value is still unavailable.
108
+ # Some tickers (notably crypto) only return a single row for a 2-day
109
+ # lookback because trading is continuous, so also try a longer window.
110
+ if current_price is None or previous_close is None:
111
+ hist = stock.history(period="2d")
112
+ if hist is None or hist.empty:
113
+ return f"Error: Ticker '{normalized_ticker}' not found. Please verify the symbol."
114
+ if len(hist) < 2:
115
+ # Retry with a wider window to recover a previous close.
116
+ hist = stock.history(period="5d")
117
+ if hist is None or len(hist) < 2:
118
+ return (
119
+ f"Error: Ticker '{normalized_ticker}' not found. "
120
+ f"Please verify the symbol."
121
+ )
122
+ previous_close = float(hist["Close"].iloc[-2])
123
+ current_price = float(hist["Close"].iloc[-1])
124
+
125
+ # 4. Data validation
126
+ if previous_close is None or previous_close == 0:
127
+ return f"Error: Required data unavailable for {normalized_ticker}: previousClose"
128
+
129
+ if current_price is None:
130
+ return f"Error: Required data unavailable for {normalized_ticker}: currentPrice"
131
+
132
+ current_price = float(current_price)
133
+ previous_close = float(previous_close)
134
+
135
+ # 5. Calculate change
136
+ absolute_change = current_price - previous_close
137
+ percent_change = round(((current_price - previous_close) / previous_close) * 100, 2)
138
+
139
+ # 6. Format response
140
+ sign = "+" if absolute_change >= 0 else "-"
141
+ abs_change = abs(absolute_change)
142
+
143
+ response = (
144
+ f"Price Change for {normalized_ticker}:\n"
145
+ f"Current Price: ${current_price:.2f}\n"
146
+ f"Previous Close: ${previous_close:.2f}\n"
147
+ f"Change: {sign}${abs_change:.2f} ({'+' if percent_change >= 0 else ''}{percent_change}%)"
148
+ )
149
+
150
+ # 7. Cache and return
151
+ cache.set(cache_key, response)
152
+ return response
153
+
154
+ except Exception as e:
155
+ return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
156
+
157
+
158
+ @tool("Get Volume Analysis")
159
+ def get_volume(ticker: str) -> str:
160
+ """Get volume analysis with unusual activity detection for a ticker.
161
+
162
+ Retrieves current volume and 20-day average volume, calculates the
163
+ volume ratio, and flags unusual activity when ratio exceeds 2.0.
164
+
165
+ Args:
166
+ ticker: Stock symbol (e.g., "AAPL") or crypto pair (e.g., "BTC-USD").
167
+
168
+ Returns:
169
+ Formatted string with volume analysis or error message.
170
+ """
171
+ try:
172
+ # 1. Input validation
173
+ valid, result = validate_ticker(ticker)
174
+ if not valid:
175
+ return result
176
+
177
+ normalized_ticker = result
178
+
179
+ # 2. Cache check
180
+ cache_key = cache.make_key("get_volume", ticker=normalized_ticker)
181
+ cached = cache.get(cache_key)
182
+ if cached:
183
+ return cached
184
+
185
+ # 3. External API call
186
+ stock = yfinance.Ticker(normalized_ticker)
187
+ hist = stock.history(period="25d")
188
+
189
+ if hist.empty or len(hist) < 2:
190
+ return f"Error: Ticker '{normalized_ticker}' not found. Please verify the symbol."
191
+
192
+ if "Volume" not in hist.columns:
193
+ return f"Error: Required data unavailable for {normalized_ticker}: Volume"
194
+
195
+ # 4. Data validation and computation
196
+ current_volume = int(hist["Volume"].iloc[-1])
197
+ # Average of prior 20 days (excluding the most recent day)
198
+ prior_volumes = hist["Volume"].iloc[:-1]
199
+ # Take up to 20 days for the average
200
+ prior_20 = prior_volumes.tail(20)
201
+
202
+ if len(prior_20) == 0 or prior_20.mean() == 0:
203
+ return f"Error: Required data unavailable for {normalized_ticker}: insufficient volume history"
204
+
205
+ avg_volume_float = prior_20.mean()
206
+ avg_volume = int(avg_volume_float)
207
+ volume_ratio = round(current_volume / avg_volume_float, 2)
208
+
209
+ # 5. Format response
210
+ response = (
211
+ f"Volume Analysis for {normalized_ticker}:\n"
212
+ f"Current Volume: {current_volume:,}\n"
213
+ f"20-Day Avg Volume: {avg_volume:,}\n"
214
+ f"Volume Ratio: {volume_ratio}x"
215
+ )
216
+
217
+ # Include UNUSUAL VOLUME flag when ratio > 2.0
218
+ if volume_ratio > 2.0:
219
+ response += "\n⚠️ UNUSUAL VOLUME"
220
+
221
+ # 6. Cache and return
222
+ cache.set(cache_key, response)
223
+ return response
224
+
225
+ except Exception as e:
226
+ return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
tools/risk_manager.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Risk Manager Tools for FinAgent.
3
+
4
+ Provides tools for risk management agents:
5
+ - calculate_position_size: Calculate position size based on risk parameters
6
+ - set_stop_loss: Calculate ATR-based stop-loss and take-profit levels
7
+ """
8
+
9
+ import math
10
+
11
+ import yfinance
12
+
13
+ # pandas-ta-remake is the maintained fork published under a different module
14
+ # name (pandas_ta_remake). Try it first, then fall back to the upstream
15
+ # pandas_ta name if it is what the environment provides.
16
+ try:
17
+ import pandas_ta_remake as ta # type: ignore
18
+ except ImportError: # pragma: no cover - exercised only when remake is absent
19
+ import pandas_ta as ta # type: ignore
20
+
21
+ from crewai.tools import tool
22
+
23
+ from tools.cache import TTLCache
24
+ from tools.utils import validate_ticker
25
+
26
+ cache = TTLCache()
27
+
28
+
29
+ @tool("Calculate Position Size")
30
+ def calculate_position_size(
31
+ portfolio_value: float,
32
+ risk_percent: float,
33
+ entry_price: float,
34
+ stop_loss: float,
35
+ ) -> str:
36
+ """Calculate position size based on portfolio risk parameters.
37
+
38
+ Args:
39
+ portfolio_value: Total portfolio value in dollars.
40
+ risk_percent: Percentage of portfolio to risk (0-100).
41
+ entry_price: Planned entry price per share.
42
+ stop_loss: Stop-loss price per share.
43
+
44
+ Returns:
45
+ Formatted string with position size details or error message.
46
+ """
47
+ try:
48
+ # 1. Input validation
49
+ if portfolio_value <= 0:
50
+ return "Error: portfolio_value must be positive."
51
+
52
+ if entry_price <= 0:
53
+ return "Error: entry_price must be positive."
54
+
55
+ if risk_percent <= 0 or risk_percent > 100:
56
+ return "Error: risk_percent must be between 0 and 100."
57
+
58
+ if entry_price == stop_loss:
59
+ return "Error: entry_price and stop_loss cannot be equal."
60
+
61
+ # 2. Calculate position size (no cache - pure computation)
62
+ risk_amount = portfolio_value * risk_percent / 100
63
+ risk_per_share = abs(entry_price - stop_loss)
64
+ shares = math.floor(risk_amount / risk_per_share)
65
+ total_position_value = shares * entry_price
66
+
67
+ # 3. Format response
68
+ response = (
69
+ f"Position Size Calculation:\n"
70
+ f"Portfolio Value: ${portfolio_value:,.2f}\n"
71
+ f"Risk Amount: ${risk_amount:,.2f} ({risk_percent}%)\n"
72
+ f"Entry Price: ${entry_price:,.2f}\n"
73
+ f"Stop Loss: ${stop_loss:,.2f}\n"
74
+ f"Risk Per Share: ${risk_per_share:,.2f}\n"
75
+ f"Position Size: {shares} shares\n"
76
+ f"Total Position Value: ${total_position_value:,.2f}"
77
+ )
78
+
79
+ return response
80
+
81
+ except Exception as e:
82
+ return f"Error: An unexpected error occurred: {str(e)}"
83
+
84
+
85
+ @tool("Set Stop Loss")
86
+ def set_stop_loss(ticker: str, entry_price: float, atr_multiplier: float) -> str:
87
+ """Calculate ATR-based stop-loss and take-profit levels.
88
+
89
+ Uses the 14-period Average True Range (ATR) to determine volatility-based
90
+ stop-loss and take-profit levels for a given entry price.
91
+
92
+ Args:
93
+ ticker: Stock symbol (e.g., AAPL) or crypto pair (e.g., BTC-USD).
94
+ entry_price: Entry price for the position.
95
+ atr_multiplier: Multiplier for ATR (e.g., 1.5, 2.0).
96
+
97
+ Returns:
98
+ Formatted string with stop-loss, take-profit, and risk-reward ratio.
99
+ """
100
+ try:
101
+ # 1. Input validation
102
+ if atr_multiplier <= 0:
103
+ return "Error: ATR multiplier must be positive."
104
+
105
+ if entry_price <= 0:
106
+ return "Error: Entry price must be positive."
107
+
108
+ valid, result = validate_ticker(ticker)
109
+ if not valid:
110
+ return result
111
+
112
+ normalized_ticker = result
113
+
114
+ # 2. Cache check
115
+ cache_key = cache.make_key(
116
+ "set_stop_loss",
117
+ ticker=normalized_ticker,
118
+ entry_price=entry_price,
119
+ atr_multiplier=atr_multiplier,
120
+ )
121
+ cached = cache.get(cache_key)
122
+ if cached:
123
+ return cached
124
+
125
+ # 3. External API call - get price history for ATR calculation
126
+ stock = yfinance.Ticker(normalized_ticker)
127
+ df = stock.history(period="30d")
128
+
129
+ if df.empty or len(df) < 14:
130
+ return f"Error: Insufficient data to calculate ATR for {normalized_ticker}."
131
+
132
+ # 4. Calculate ATR using pandas_ta
133
+ atr_series = ta.atr(df["High"], df["Low"], df["Close"], length=14)
134
+
135
+ if atr_series is None or atr_series.dropna().empty:
136
+ return f"Error: Insufficient data to calculate ATR for {normalized_ticker}."
137
+
138
+ atr_value = atr_series.dropna().iloc[-1]
139
+
140
+ if atr_value != atr_value: # Check for NaN
141
+ return f"Error: Insufficient data to calculate ATR for {normalized_ticker}."
142
+
143
+ # 5. Calculate stop-loss and take-profit
144
+ stop_loss_price = round(entry_price - (atr_value * atr_multiplier), 2)
145
+ take_profit_price = round(entry_price + (atr_value * atr_multiplier * 2), 2)
146
+
147
+ # 6. Format response
148
+ response = (
149
+ f"Stop-Loss & Take-Profit for {normalized_ticker}:\n"
150
+ f"Entry Price: ${entry_price:.2f}\n"
151
+ f"ATR (14-period): ${atr_value:.2f}\n"
152
+ f"ATR Multiplier: {atr_multiplier}x\n"
153
+ f"Stop Loss: ${stop_loss_price:.2f} (Entry - ATR \u00d7 {atr_multiplier})\n"
154
+ f"Take Profit: ${take_profit_price:.2f} (Entry + ATR \u00d7 {atr_multiplier * 2})\n"
155
+ f"Risk/Reward Ratio: 1:2"
156
+ )
157
+
158
+ # 7. Cache and return
159
+ cache.set(cache_key, response)
160
+ return response
161
+
162
+ except Exception as e:
163
+ return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
tools/technical_analyst.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Technical Analyst Tools for FinAgent.
3
+
4
+ Provides tools for technical analysis agents:
5
+ - get_price_history: Retrieve price history with calculated indicators
6
+ - calculate_indicators: Compute buy/sell signals from technical indicators
7
+ """
8
+
9
+ # pandas-ta-remake is the maintained fork published under a different module
10
+ # name (pandas_ta_remake). Try it first, then fall back to the upstream
11
+ # pandas_ta name if it is what the environment provides.
12
+ try:
13
+ import pandas_ta_remake as ta # type: ignore
14
+ except ImportError: # pragma: no cover - exercised only when remake is absent
15
+ import pandas_ta as ta # type: ignore
16
+
17
+ import yfinance
18
+ from crewai.tools import tool
19
+
20
+ from tools.cache import TTLCache
21
+ from tools.utils import validate_ticker
22
+
23
+ cache = TTLCache()
24
+
25
+
26
+ @tool("Get Price History")
27
+ def get_price_history(ticker: str) -> str:
28
+ """Retrieve 60 days of price history with technical indicators including RSI, MACD, SMA, and Bollinger Bands.
29
+
30
+ Args:
31
+ ticker: Stock symbol (e.g., AAPL) or crypto pair (e.g., BTC-USD).
32
+
33
+ Returns:
34
+ A formatted table with the last 5 days of price data and indicators,
35
+ or an error message if data is unavailable.
36
+ """
37
+ try:
38
+ # 1. Input validation
39
+ valid, result = validate_ticker(ticker)
40
+ if not valid:
41
+ return result
42
+
43
+ normalized_ticker = result
44
+
45
+ # 2. Cache check
46
+ cache_key = cache.make_key("get_price_history", ticker=normalized_ticker)
47
+ cached = cache.get(cache_key)
48
+ if cached:
49
+ return cached
50
+
51
+ # 3. External API call — fetch 90 calendar days to ensure ~60 trading days
52
+ stock = yfinance.Ticker(normalized_ticker)
53
+ hist = stock.history(period="90d")
54
+
55
+ if hist is None or hist.empty:
56
+ return f"Error: Ticker '{normalized_ticker}' not found. Please verify the symbol."
57
+
58
+ close = hist["Close"]
59
+ num_days = len(close)
60
+
61
+ # 4. Calculate indicators — mark as N/A if insufficient data
62
+ insufficient_data = num_days < 50
63
+
64
+ if insufficient_data:
65
+ # Not enough data for SMA50; calculate what we can
66
+ rsi = ta.rsi(close, length=14) if num_days >= 14 else None
67
+ macd_df = ta.macd(close, fast=12, slow=26, signal=9) if num_days >= 26 else None
68
+ sma20 = ta.sma(close, length=20) if num_days >= 20 else None
69
+ sma50 = None
70
+ bbands_df = ta.bbands(close, length=20, std=2) if num_days >= 20 else None
71
+ else:
72
+ rsi = ta.rsi(close, length=14)
73
+ macd_df = ta.macd(close, fast=12, slow=26, signal=9)
74
+ sma20 = ta.sma(close, length=20)
75
+ sma50 = ta.sma(close, length=50)
76
+ bbands_df = ta.bbands(close, length=20, std=2)
77
+
78
+ # 5. Format response — last 5 days of computed data
79
+ header = (
80
+ f"Price History & Indicators for {normalized_ticker} (Last 5 Days):\n"
81
+ f"Date | Close | RSI | MACD | Signal | SMA20 | SMA50 | BB_Upper | BB_Lower"
82
+ )
83
+
84
+ rows = []
85
+ last_5_indices = hist.index[-5:]
86
+
87
+ for idx in last_5_indices:
88
+ date_str = idx.strftime("%Y-%m-%d")
89
+ close_val = f"{close[idx]:.2f}"
90
+
91
+ # RSI
92
+ if rsi is not None and idx in rsi.index and not _is_na(rsi[idx]):
93
+ rsi_val = f"{rsi[idx]:.1f}"
94
+ else:
95
+ rsi_val = "N/A"
96
+
97
+ # MACD and Signal
98
+ if macd_df is not None and idx in macd_df.index:
99
+ macd_val_raw = macd_df["MACD_12_26_9"][idx]
100
+ signal_val_raw = macd_df["MACDs_12_26_9"][idx]
101
+ macd_val = f"{macd_val_raw:.2f}" if not _is_na(macd_val_raw) else "N/A"
102
+ signal_val = f"{signal_val_raw:.2f}" if not _is_na(signal_val_raw) else "N/A"
103
+ else:
104
+ macd_val = "N/A"
105
+ signal_val = "N/A"
106
+
107
+ # SMA20
108
+ if sma20 is not None and idx in sma20.index and not _is_na(sma20[idx]):
109
+ sma20_val = f"{sma20[idx]:.2f}"
110
+ else:
111
+ sma20_val = "N/A"
112
+
113
+ # SMA50
114
+ if sma50 is not None and idx in sma50.index and not _is_na(sma50[idx]):
115
+ sma50_val = f"{sma50[idx]:.2f}"
116
+ else:
117
+ sma50_val = "N/A"
118
+
119
+ # Bollinger Bands
120
+ if bbands_df is not None and idx in bbands_df.index:
121
+ bbu_raw = bbands_df["BBU_20_2.0"][idx]
122
+ bbl_raw = bbands_df["BBL_20_2.0"][idx]
123
+ bbu_val = f"{bbu_raw:.2f}" if not _is_na(bbu_raw) else "N/A"
124
+ bbl_val = f"{bbl_raw:.2f}" if not _is_na(bbl_raw) else "N/A"
125
+ else:
126
+ bbu_val = "N/A"
127
+ bbl_val = "N/A"
128
+
129
+ row = (
130
+ f"{date_str} | {close_val:>7} | {rsi_val:>5} | {macd_val:>5} | "
131
+ f"{signal_val:>6} | {sma20_val:>7} | {sma50_val:>7} | {bbu_val:>8} | {bbl_val:>8}"
132
+ )
133
+ rows.append(row)
134
+
135
+ response = header + "\n" + "\n".join(rows)
136
+
137
+ # 6. Cache and return
138
+ cache.set(cache_key, response)
139
+ return response
140
+
141
+ except Exception as e:
142
+ return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
143
+
144
+
145
+ def _is_na(value) -> bool:
146
+ """Check if a value is NaN or None."""
147
+ if value is None:
148
+ return True
149
+ try:
150
+ import math
151
+ return math.isnan(value)
152
+ except (TypeError, ValueError):
153
+ return False
154
+
155
+
156
+ @tool("Calculate Indicators")
157
+ def calculate_indicators(ticker: str) -> str:
158
+ """Compute current buy/sell signals from RSI, MACD, and Bollinger Bands for a given ticker.
159
+
160
+ Args:
161
+ ticker: Stock symbol (e.g., AAPL) or crypto pair (e.g., BTC-USD).
162
+
163
+ Returns:
164
+ A formatted string with each indicator's current value and signal classification
165
+ (BUY, SELL, or NEUTRAL), or an error message if data is unavailable.
166
+ """
167
+ try:
168
+ # 1. Input validation
169
+ valid, result = validate_ticker(ticker)
170
+ if not valid:
171
+ return result
172
+
173
+ normalized_ticker = result
174
+
175
+ # 2. Cache check
176
+ cache_key = cache.make_key("calculate_indicators", ticker=normalized_ticker)
177
+ cached = cache.get(cache_key)
178
+ if cached:
179
+ return cached
180
+
181
+ # 3. External API call — fetch 90 calendar days to ensure ~60 trading days
182
+ stock = yfinance.Ticker(normalized_ticker)
183
+ hist = stock.history(period="90d")
184
+
185
+ if hist is None or hist.empty:
186
+ return f"Error: Ticker '{normalized_ticker}' not found. Please verify the symbol."
187
+
188
+ close = hist["Close"]
189
+ num_days = len(close)
190
+
191
+ if num_days < 26:
192
+ return f"Error: Insufficient data for {normalized_ticker}. Need at least 26 trading days."
193
+
194
+ # 4. Calculate indicators
195
+ rsi_series = ta.rsi(close, length=14)
196
+ macd_df = ta.macd(close, fast=12, slow=26, signal=9)
197
+ bbands_df = ta.bbands(close, length=20, std=2)
198
+
199
+ # Get current values
200
+ current_rsi = rsi_series.iloc[-1] if rsi_series is not None else None
201
+ current_close = close.iloc[-1]
202
+
203
+ # MACD values
204
+ if macd_df is not None:
205
+ current_macd = macd_df["MACD_12_26_9"].iloc[-1]
206
+ current_signal = macd_df["MACDs_12_26_9"].iloc[-1]
207
+ prev_macd = macd_df["MACD_12_26_9"].iloc[-2]
208
+ prev_signal = macd_df["MACDs_12_26_9"].iloc[-2]
209
+ else:
210
+ current_macd = None
211
+ current_signal = None
212
+ prev_macd = None
213
+ prev_signal = None
214
+
215
+ # Bollinger Band values
216
+ if bbands_df is not None:
217
+ current_upper = bbands_df["BBU_20_2.0"].iloc[-1]
218
+ current_lower = bbands_df["BBL_20_2.0"].iloc[-1]
219
+ else:
220
+ current_upper = None
221
+ current_lower = None
222
+
223
+ # 5. Classify signals
224
+ # RSI classification
225
+ if current_rsi is not None and not _is_na(current_rsi):
226
+ if current_rsi < 30:
227
+ rsi_signal = "BUY"
228
+ rsi_desc = "Oversold"
229
+ elif current_rsi > 70:
230
+ rsi_signal = "SELL"
231
+ rsi_desc = "Overbought"
232
+ else:
233
+ rsi_signal = "NEUTRAL"
234
+ rsi_desc = "Neutral"
235
+ rsi_line = f"RSI (14): {current_rsi:.1f} → {rsi_signal} ({rsi_desc})"
236
+ else:
237
+ rsi_line = "RSI (14): N/A → NEUTRAL (Insufficient Data)"
238
+
239
+ # MACD classification
240
+ if (current_macd is not None and current_signal is not None and
241
+ prev_macd is not None and prev_signal is not None and
242
+ not _is_na(current_macd) and not _is_na(current_signal) and
243
+ not _is_na(prev_macd) and not _is_na(prev_signal)):
244
+ is_bullish = current_macd > current_signal and prev_macd <= prev_signal
245
+ is_bearish = current_macd < current_signal and prev_macd >= prev_signal
246
+
247
+ if is_bullish:
248
+ macd_signal = "BUY"
249
+ macd_desc = "Bullish Crossover"
250
+ elif is_bearish:
251
+ macd_signal = "SELL"
252
+ macd_desc = "Bearish Crossover"
253
+ else:
254
+ macd_signal = "NEUTRAL"
255
+ macd_desc = "Neutral"
256
+ macd_line = f"MACD: {current_macd:.2f} / Signal: {current_signal:.2f} → {macd_signal} ({macd_desc})"
257
+ else:
258
+ macd_line = "MACD: N/A / Signal: N/A → NEUTRAL (Insufficient Data)"
259
+
260
+ # Bollinger classification
261
+ if (current_upper is not None and current_lower is not None and
262
+ not _is_na(current_upper) and not _is_na(current_lower)):
263
+ if current_close < current_lower:
264
+ bb_signal = "BUY"
265
+ bb_desc = "Below Lower Band"
266
+ elif current_close > current_upper:
267
+ bb_signal = "SELL"
268
+ bb_desc = "Above Upper Band"
269
+ else:
270
+ bb_signal = "NEUTRAL"
271
+ bb_desc = "Neutral"
272
+ bb_line = f"Bollinger: Price ${current_close:.2f} / Upper ${current_upper:.2f} / Lower ${current_lower:.2f} → {bb_signal} ({bb_desc})"
273
+ else:
274
+ bb_line = "Bollinger: N/A → NEUTRAL (Insufficient Data)"
275
+
276
+ # 6. Format response
277
+ response = (
278
+ f"Technical Signals for {normalized_ticker}:\n"
279
+ f"{rsi_line}\n"
280
+ f"{macd_line}\n"
281
+ f"{bb_line}"
282
+ )
283
+
284
+ # 7. Cache and return
285
+ cache.set(cache_key, response)
286
+ return response
287
+
288
+ except Exception as e:
289
+ return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
tools/utils.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utility functions for Agent Tools.
3
+
4
+ Provides shared helpers: ticker validation, currency formatting,
5
+ safe dictionary access, and percentage formatting.
6
+ """
7
+
8
+
9
+ def validate_ticker(ticker: str) -> tuple[bool, str]:
10
+ """Validate and normalize a ticker string.
11
+
12
+ Strips whitespace from input. If empty or whitespace-only after strip,
13
+ returns an error tuple. Otherwise returns the uppercase normalized ticker.
14
+
15
+ Args:
16
+ ticker: Raw ticker string from user/agent input.
17
+
18
+ Returns:
19
+ (True, normalized_ticker) if valid
20
+ (False, error_message) if invalid
21
+ """
22
+ stripped = ticker.strip()
23
+ if not stripped:
24
+ return (False, "Error: Invalid ticker provided. Ticker must be a non-empty string.")
25
+ return (True, stripped.upper())
26
+
27
+
28
+ def format_currency(value: float, precision: int = 2) -> str:
29
+ """Format monetary values with appropriate units (B/M/K).
30
+
31
+ Args:
32
+ value: The monetary value to format. If None, returns "N/A".
33
+ precision: Number of decimal places (default 2).
34
+
35
+ Returns:
36
+ Formatted string like "$1.50B", "$250.00M", "$45.00K", or "$123.45".
37
+
38
+ Examples:
39
+ >>> format_currency(1_500_000_000)
40
+ '$1.50B'
41
+ >>> format_currency(250_000_000)
42
+ '$250.00M'
43
+ >>> format_currency(45_000)
44
+ '$45.00K'
45
+ >>> format_currency(123.456)
46
+ '$123.46'
47
+ >>> format_currency(None)
48
+ 'N/A'
49
+ """
50
+ if value is None:
51
+ return "N/A"
52
+
53
+ abs_value = abs(value)
54
+ sign = "-" if value < 0 else ""
55
+
56
+ if abs_value >= 1_000_000_000:
57
+ return f"{sign}${abs_value / 1_000_000_000:.{precision}f}B"
58
+ elif abs_value >= 1_000_000:
59
+ return f"{sign}${abs_value / 1_000_000:.{precision}f}M"
60
+ elif abs_value >= 1_000:
61
+ return f"{sign}${abs_value / 1_000:.{precision}f}K"
62
+ else:
63
+ return f"{sign}${abs_value:.{precision}f}"
64
+
65
+
66
+ def safe_get(info: dict, key: str, default: str = "N/A") -> str:
67
+ """Safely extract a value from a dict, returning default if missing or None.
68
+
69
+ Args:
70
+ info: Dictionary to look up.
71
+ key: Key to retrieve.
72
+ default: Value to return if key is missing or value is None.
73
+
74
+ Returns:
75
+ String representation of the value, or default.
76
+ """
77
+ value = info.get(key)
78
+ if value is None:
79
+ return default
80
+ return str(value)
81
+
82
+
83
+ def format_percent(value: float, precision: int = 2) -> str:
84
+ """Format a decimal or percentage value as a string with % sign.
85
+
86
+ If value is None, returns "N/A". If value appears to be a decimal
87
+ (absolute value less than 1), it is multiplied by 100 first to
88
+ convert to a percentage.
89
+
90
+ Args:
91
+ value: The value to format. Decimals (e.g., 0.082) are converted
92
+ to percentages (8.20%). Values >= 1 are treated as already
93
+ being percentages.
94
+ precision: Number of decimal places (default 2).
95
+
96
+ Returns:
97
+ Formatted string like "8.20%" or "N/A".
98
+
99
+ Examples:
100
+ >>> format_percent(0.082)
101
+ '8.20%'
102
+ >>> format_percent(25.5)
103
+ '25.50%'
104
+ >>> format_percent(None)
105
+ 'N/A'
106
+ """
107
+ if value is None:
108
+ return "N/A"
109
+
110
+ # If value is a decimal (abs < 1), multiply by 100 to get percentage
111
+ if abs(value) < 1:
112
+ value = value * 100
113
+
114
+ return f"{value:.{precision}f}%"
validation.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Input validation module for the FinAgent Gradio frontend.
2
+
3
+ Provides pure validation functions (no Gradio dependencies) for ticker
4
+ symbols and portfolio value inputs. These functions are used by the
5
+ event handlers in ``app.py`` and are directly testable in isolation.
6
+ """
7
+
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional
11
+
12
+ # Valid ticker characters: letters, digits, hyphens, periods.
13
+ TICKER_PATTERN = re.compile(r"^[A-Za-z0-9\-\.]+$")
14
+
15
+ # Maximum number of tickers allowed per analysis run.
16
+ MAX_TICKERS = 10
17
+
18
+
19
+ @dataclass
20
+ class ValidationResult:
21
+ """Result of input validation.
22
+
23
+ Attributes:
24
+ valid: Whether the input passed all validation checks.
25
+ tickers: Normalized (trimmed, uppercased) ticker list. Empty if invalid.
26
+ error_message: Human-readable error message. ``None`` when valid.
27
+ """
28
+
29
+ valid: bool
30
+ tickers: list[str] = field(default_factory=list)
31
+ error_message: Optional[str] = None
32
+
33
+
34
+ def validate_tickers(raw_input: str) -> ValidationResult:
35
+ """Validate and normalize a comma-separated ticker input string.
36
+
37
+ Rules enforced:
38
+ - Input must not be empty or whitespace-only
39
+ - Each ticker is trimmed and converted to uppercase
40
+ - Empty segments after splitting on commas are discarded
41
+ - Only letters, digits, hyphens, and periods are allowed
42
+ - Maximum of ``MAX_TICKERS`` tickers per submission
43
+
44
+ Args:
45
+ raw_input: Raw user input string (e.g., ``"aapl, nvda, tsla"``).
46
+
47
+ Returns:
48
+ A ``ValidationResult`` with normalized tickers on success, or an
49
+ error message on failure.
50
+ """
51
+ # Reject empty or whitespace-only input early.
52
+ if not raw_input or not raw_input.strip():
53
+ return ValidationResult(
54
+ valid=False,
55
+ tickers=[],
56
+ error_message="Please enter at least one ticker symbol.",
57
+ )
58
+
59
+ # Split, trim, uppercase, and drop empty segments (e.g., trailing commas).
60
+ raw_tickers = [segment.strip().upper() for segment in raw_input.split(",")]
61
+ tickers = [t for t in raw_tickers if t]
62
+
63
+ if not tickers:
64
+ return ValidationResult(
65
+ valid=False,
66
+ tickers=[],
67
+ error_message="Please enter at least one ticker symbol.",
68
+ )
69
+
70
+ # Character validation — collect every ticker that contains disallowed chars.
71
+ invalid_tickers: list[str] = []
72
+ for ticker in tickers:
73
+ if not TICKER_PATTERN.match(ticker):
74
+ invalid_chars = sorted(set(re.findall(r"[^A-Za-z0-9\-\.]", ticker)))
75
+ invalid_tickers.append(f"{ticker} (invalid: {''.join(invalid_chars)})")
76
+
77
+ if invalid_tickers:
78
+ return ValidationResult(
79
+ valid=False,
80
+ tickers=[],
81
+ error_message=f"Invalid characters in: {', '.join(invalid_tickers)}",
82
+ )
83
+
84
+ # Enforce maximum ticker count.
85
+ if len(tickers) > MAX_TICKERS:
86
+ return ValidationResult(
87
+ valid=False,
88
+ tickers=[],
89
+ error_message=(
90
+ f"Maximum {MAX_TICKERS} tickers per analysis. "
91
+ f"You entered {len(tickers)}."
92
+ ),
93
+ )
94
+
95
+ return ValidationResult(valid=True, tickers=tickers, error_message=None)
96
+
97
+
98
+ def validate_portfolio_value(value: float) -> Optional[str]:
99
+ """Validate a portfolio value.
100
+
101
+ A portfolio value is considered valid when it is non-negative (``>= 0``).
102
+ Negative values are rejected with a human-readable error message.
103
+
104
+ Args:
105
+ value: The portfolio value to validate.
106
+
107
+ Returns:
108
+ An error message string if the value is invalid, or ``None`` if valid.
109
+ """
110
+ if value < 0:
111
+ return "Portfolio value must be non-negative."
112
+ return None