finagent / tools /technical_analyst.py
emmanuelakbi's picture
Deploy FinAgent: multi-agent trading signals powered by Qwen on AMD MI300X
07ff2cb
"""
Technical Analyst Tools for FinAgent.
Provides tools for technical analysis agents:
- get_price_history: Retrieve price history with calculated indicators
- calculate_indicators: Compute buy/sell signals from technical indicators
"""
# pandas-ta-remake is the maintained fork published under a different module
# name (pandas_ta_remake). Try it first, then fall back to the upstream
# pandas_ta name if it is what the environment provides.
try:
import pandas_ta_remake as ta # type: ignore
except ImportError: # pragma: no cover - exercised only when remake is absent
import pandas_ta as ta # type: ignore
import yfinance
from crewai.tools import tool
from tools.cache import TTLCache
from tools.utils import validate_ticker
cache = TTLCache()
@tool("Get Price History")
def get_price_history(ticker: str) -> str:
"""Retrieve 60 days of price history with technical indicators including RSI, MACD, SMA, and Bollinger Bands.
Args:
ticker: Stock symbol (e.g., AAPL) or crypto pair (e.g., BTC-USD).
Returns:
A formatted table with the last 5 days of price data and indicators,
or an error message if data is unavailable.
"""
try:
# 1. Input validation
valid, result = validate_ticker(ticker)
if not valid:
return result
normalized_ticker = result
# 2. Cache check
cache_key = cache.make_key("get_price_history", ticker=normalized_ticker)
cached = cache.get(cache_key)
if cached:
return cached
# 3. External API call β€” fetch 90 calendar days to ensure ~60 trading days
stock = yfinance.Ticker(normalized_ticker)
hist = stock.history(period="90d")
if hist is None or hist.empty:
return f"Error: Ticker '{normalized_ticker}' not found. Please verify the symbol."
close = hist["Close"]
num_days = len(close)
# 4. Calculate indicators β€” mark as N/A if insufficient data
insufficient_data = num_days < 50
if insufficient_data:
# Not enough data for SMA50; calculate what we can
rsi = ta.rsi(close, length=14) if num_days >= 14 else None
macd_df = ta.macd(close, fast=12, slow=26, signal=9) if num_days >= 26 else None
sma20 = ta.sma(close, length=20) if num_days >= 20 else None
sma50 = None
bbands_df = ta.bbands(close, length=20, std=2) if num_days >= 20 else None
else:
rsi = ta.rsi(close, length=14)
macd_df = ta.macd(close, fast=12, slow=26, signal=9)
sma20 = ta.sma(close, length=20)
sma50 = ta.sma(close, length=50)
bbands_df = ta.bbands(close, length=20, std=2)
# 5. Format response β€” last 5 days of computed data
header = (
f"Price History & Indicators for {normalized_ticker} (Last 5 Days):\n"
f"Date | Close | RSI | MACD | Signal | SMA20 | SMA50 | BB_Upper | BB_Lower"
)
rows = []
last_5_indices = hist.index[-5:]
for idx in last_5_indices:
date_str = idx.strftime("%Y-%m-%d")
close_val = f"{close[idx]:.2f}"
# RSI
if rsi is not None and idx in rsi.index and not _is_na(rsi[idx]):
rsi_val = f"{rsi[idx]:.1f}"
else:
rsi_val = "N/A"
# MACD and Signal
if macd_df is not None and idx in macd_df.index:
macd_val_raw = macd_df["MACD_12_26_9"][idx]
signal_val_raw = macd_df["MACDs_12_26_9"][idx]
macd_val = f"{macd_val_raw:.2f}" if not _is_na(macd_val_raw) else "N/A"
signal_val = f"{signal_val_raw:.2f}" if not _is_na(signal_val_raw) else "N/A"
else:
macd_val = "N/A"
signal_val = "N/A"
# SMA20
if sma20 is not None and idx in sma20.index and not _is_na(sma20[idx]):
sma20_val = f"{sma20[idx]:.2f}"
else:
sma20_val = "N/A"
# SMA50
if sma50 is not None and idx in sma50.index and not _is_na(sma50[idx]):
sma50_val = f"{sma50[idx]:.2f}"
else:
sma50_val = "N/A"
# Bollinger Bands
if bbands_df is not None and idx in bbands_df.index:
bbu_raw = bbands_df["BBU_20_2.0"][idx]
bbl_raw = bbands_df["BBL_20_2.0"][idx]
bbu_val = f"{bbu_raw:.2f}" if not _is_na(bbu_raw) else "N/A"
bbl_val = f"{bbl_raw:.2f}" if not _is_na(bbl_raw) else "N/A"
else:
bbu_val = "N/A"
bbl_val = "N/A"
row = (
f"{date_str} | {close_val:>7} | {rsi_val:>5} | {macd_val:>5} | "
f"{signal_val:>6} | {sma20_val:>7} | {sma50_val:>7} | {bbu_val:>8} | {bbl_val:>8}"
)
rows.append(row)
response = header + "\n" + "\n".join(rows)
# 6. Cache and return
cache.set(cache_key, response)
return response
except Exception as e:
return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"
def _is_na(value) -> bool:
"""Check if a value is NaN or None."""
if value is None:
return True
try:
import math
return math.isnan(value)
except (TypeError, ValueError):
return False
@tool("Calculate Indicators")
def calculate_indicators(ticker: str) -> str:
"""Compute current buy/sell signals from RSI, MACD, and Bollinger Bands for a given ticker.
Args:
ticker: Stock symbol (e.g., AAPL) or crypto pair (e.g., BTC-USD).
Returns:
A formatted string with each indicator's current value and signal classification
(BUY, SELL, or NEUTRAL), or an error message if data is unavailable.
"""
try:
# 1. Input validation
valid, result = validate_ticker(ticker)
if not valid:
return result
normalized_ticker = result
# 2. Cache check
cache_key = cache.make_key("calculate_indicators", ticker=normalized_ticker)
cached = cache.get(cache_key)
if cached:
return cached
# 3. External API call β€” fetch 90 calendar days to ensure ~60 trading days
stock = yfinance.Ticker(normalized_ticker)
hist = stock.history(period="90d")
if hist is None or hist.empty:
return f"Error: Ticker '{normalized_ticker}' not found. Please verify the symbol."
close = hist["Close"]
num_days = len(close)
if num_days < 26:
return f"Error: Insufficient data for {normalized_ticker}. Need at least 26 trading days."
# 4. Calculate indicators
rsi_series = ta.rsi(close, length=14)
macd_df = ta.macd(close, fast=12, slow=26, signal=9)
bbands_df = ta.bbands(close, length=20, std=2)
# Get current values
current_rsi = rsi_series.iloc[-1] if rsi_series is not None else None
current_close = close.iloc[-1]
# MACD values
if macd_df is not None:
current_macd = macd_df["MACD_12_26_9"].iloc[-1]
current_signal = macd_df["MACDs_12_26_9"].iloc[-1]
prev_macd = macd_df["MACD_12_26_9"].iloc[-2]
prev_signal = macd_df["MACDs_12_26_9"].iloc[-2]
else:
current_macd = None
current_signal = None
prev_macd = None
prev_signal = None
# Bollinger Band values
if bbands_df is not None:
current_upper = bbands_df["BBU_20_2.0"].iloc[-1]
current_lower = bbands_df["BBL_20_2.0"].iloc[-1]
else:
current_upper = None
current_lower = None
# 5. Classify signals
# RSI classification
if current_rsi is not None and not _is_na(current_rsi):
if current_rsi < 30:
rsi_signal = "BUY"
rsi_desc = "Oversold"
elif current_rsi > 70:
rsi_signal = "SELL"
rsi_desc = "Overbought"
else:
rsi_signal = "NEUTRAL"
rsi_desc = "Neutral"
rsi_line = f"RSI (14): {current_rsi:.1f} β†’ {rsi_signal} ({rsi_desc})"
else:
rsi_line = "RSI (14): N/A β†’ NEUTRAL (Insufficient Data)"
# MACD classification
if (current_macd is not None and current_signal is not None and
prev_macd is not None and prev_signal is not None and
not _is_na(current_macd) and not _is_na(current_signal) and
not _is_na(prev_macd) and not _is_na(prev_signal)):
is_bullish = current_macd > current_signal and prev_macd <= prev_signal
is_bearish = current_macd < current_signal and prev_macd >= prev_signal
if is_bullish:
macd_signal = "BUY"
macd_desc = "Bullish Crossover"
elif is_bearish:
macd_signal = "SELL"
macd_desc = "Bearish Crossover"
else:
macd_signal = "NEUTRAL"
macd_desc = "Neutral"
macd_line = f"MACD: {current_macd:.2f} / Signal: {current_signal:.2f} β†’ {macd_signal} ({macd_desc})"
else:
macd_line = "MACD: N/A / Signal: N/A β†’ NEUTRAL (Insufficient Data)"
# Bollinger classification
if (current_upper is not None and current_lower is not None and
not _is_na(current_upper) and not _is_na(current_lower)):
if current_close < current_lower:
bb_signal = "BUY"
bb_desc = "Below Lower Band"
elif current_close > current_upper:
bb_signal = "SELL"
bb_desc = "Above Upper Band"
else:
bb_signal = "NEUTRAL"
bb_desc = "Neutral"
bb_line = f"Bollinger: Price ${current_close:.2f} / Upper ${current_upper:.2f} / Lower ${current_lower:.2f} β†’ {bb_signal} ({bb_desc})"
else:
bb_line = "Bollinger: N/A β†’ NEUTRAL (Insufficient Data)"
# 6. Format response
response = (
f"Technical Signals for {normalized_ticker}:\n"
f"{rsi_line}\n"
f"{macd_line}\n"
f"{bb_line}"
)
# 7. Cache and return
cache.set(cache_key, response)
return response
except Exception as e:
return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}"