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 +11 -0
- README.md +66 -7
- app.py +549 -0
- crew/__init__.py +26 -0
- crew/agents.py +124 -0
- crew/callbacks.py +125 -0
- crew/config.py +31 -0
- crew/crew.py +205 -0
- crew/runner.py +112 -0
- crew/signals.py +172 -0
- crew/tasks.py +115 -0
- rendering.py +298 -0
- requirements.txt +23 -0
- tools/__init__.py +40 -0
- tools/cache.py +107 -0
- tools/fundamental_analyst.py +329 -0
- tools/market_scanner.py +226 -0
- tools/risk_manager.py +163 -0
- tools/technical_analyst.py +289 -0
- tools/utils.py +114 -0
- validation.py +112 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|