| """ |
| 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 |
| """ |
|
|
| |
| |
| |
| try: |
| import pandas_ta_remake as ta |
| except ImportError: |
| import pandas_ta as ta |
|
|
| 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: |
| |
| valid, result = validate_ticker(ticker) |
| if not valid: |
| return result |
|
|
| normalized_ticker = result |
|
|
| |
| cache_key = cache.make_key("get_price_history", ticker=normalized_ticker) |
| cached = cache.get(cache_key) |
| if cached: |
| return cached |
|
|
| |
| 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) |
|
|
| |
| insufficient_data = num_days < 50 |
|
|
| if insufficient_data: |
| |
| 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) |
|
|
| |
| 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}" |
|
|
| |
| 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" |
|
|
| |
| 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" |
|
|
| |
| 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" |
|
|
| |
| 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" |
|
|
| |
| 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) |
|
|
| |
| 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: |
| |
| valid, result = validate_ticker(ticker) |
| if not valid: |
| return result |
|
|
| normalized_ticker = result |
|
|
| |
| cache_key = cache.make_key("calculate_indicators", ticker=normalized_ticker) |
| cached = cache.get(cache_key) |
| if cached: |
| return cached |
|
|
| |
| 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." |
|
|
| |
| 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) |
|
|
| |
| current_rsi = rsi_series.iloc[-1] if rsi_series is not None else None |
| current_close = close.iloc[-1] |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| 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)" |
|
|
| |
| 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)" |
|
|
| |
| 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)" |
|
|
| |
| response = ( |
| f"Technical Signals for {normalized_ticker}:\n" |
| f"{rsi_line}\n" |
| f"{macd_line}\n" |
| f"{bb_line}" |
| ) |
|
|
| |
| cache.set(cache_key, response) |
| return response |
|
|
| except Exception as e: |
| return f"Error: An unexpected error occurred while processing {ticker}: {str(e)}" |
|
|