from typing import Annotated, List, Optional, Union from datetime import datetime from dateutil.relativedelta import relativedelta import yfinance as yf import pandas as pd import os import requests from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from .stockstats_utils import StockstatsUtils def get_YFin_data_online( symbol: Annotated[str, "ticker symbol of the company"], start_date: Annotated[str, "Start date in yyyy-mm-dd format"], end_date: Annotated[str, "End date in yyyy-mm-dd format"], ): datetime.strptime(start_date, "%Y-%m-%d") datetime.strptime(end_date, "%Y-%m-%d") # Create ticker object ticker = yf.Ticker(symbol.upper()) # Fetch historical data for the specified date range data = ticker.history(start=start_date, end=end_date) # Check if data is empty if data.empty: return ( f"No data found for symbol '{symbol}' between {start_date} and {end_date}" ) # Remove timezone info from index for cleaner output if data.index.tz is not None: data.index = data.index.tz_localize(None) # Round numerical values to 2 decimal places for cleaner display numeric_columns = ["Open", "High", "Low", "Close", "Adj Close"] for col in numeric_columns: if col in data.columns: data[col] = data[col].round(2) # Convert DataFrame to CSV string csv_string = data.to_csv() # Add header information header = f"# Stock data for {symbol.upper()} from {start_date} to {end_date}\n" header += f"# Total records: {len(data)}\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" return header + csv_string def get_stock_stats_indicators_window( symbol: Annotated[str, "ticker symbol of the company"], indicator: Annotated[str, "technical indicator to get the analysis and report of"], curr_date: Annotated[ str, "The current trading date you are trading on, YYYY-mm-dd" ], look_back_days: Annotated[int, "how many days to look back"], ) -> str: best_ind_params = { # Moving Averages "close_50_sma": ( "50 SMA: A medium-term trend indicator. " "Usage: Identify trend direction and serve as dynamic support/resistance. " "Tips: It lags price; combine with faster indicators for timely signals." ), "close_200_sma": ( "200 SMA: A long-term trend benchmark. " "Usage: Confirm overall market trend and identify golden/death cross setups. " "Tips: It reacts slowly; best for strategic trend confirmation rather than frequent trading entries." ), "close_10_ema": ( "10 EMA: A responsive short-term average. " "Usage: Capture quick shifts in momentum and potential entry points. " "Tips: Prone to noise in choppy markets; use alongside longer averages for filtering false signals." ), # MACD Related "macd": ( "MACD: Computes momentum via differences of EMAs. " "Usage: Look for crossovers and divergence as signals of trend changes. " "Tips: Confirm with other indicators in low-volatility or sideways markets." ), "macds": ( "MACD Signal: An EMA smoothing of the MACD line. " "Usage: Use crossovers with the MACD line to trigger trades. " "Tips: Should be part of a broader strategy to avoid false positives." ), "macdh": ( "MACD Histogram: Shows the gap between the MACD line and its signal. " "Usage: Visualize momentum strength and spot divergence early. " "Tips: Can be volatile; complement with additional filters in fast-moving markets." ), # Momentum Indicators "rsi": ( "RSI: Measures momentum to flag overbought/oversold conditions. " "Usage: Apply 70/30 thresholds and watch for divergence to signal reversals. " "Tips: In strong trends, RSI may remain extreme; always cross-check with trend analysis." ), # Volatility Indicators "boll": ( "Bollinger Middle: A 20 SMA serving as the basis for Bollinger Bands. " "Usage: Acts as a dynamic benchmark for price movement. " "Tips: Combine with the upper and lower bands to effectively spot breakouts or reversals." ), "boll_ub": ( "Bollinger Upper Band: Typically 2 standard deviations above the middle line. " "Usage: Signals potential overbought conditions and breakout zones. " "Tips: Confirm signals with other tools; prices may ride the band in strong trends." ), "boll_lb": ( "Bollinger Lower Band: Typically 2 standard deviations below the middle line. " "Usage: Indicates potential oversold conditions. " "Tips: Use additional analysis to avoid false reversal signals." ), "atr": ( "ATR: Averages true range to measure volatility. " "Usage: Set stop-loss levels and adjust position sizes based on current market volatility. " "Tips: It's a reactive measure, so use it as part of a broader risk management strategy." ), # Volume-Based Indicators "vwma": ( "VWMA: A moving average weighted by volume. " "Usage: Confirm trends by integrating price action with volume data. " "Tips: Watch for skewed results from volume spikes; use in combination with other volume analyses." ), "mfi": ( "MFI: The Money Flow Index is a momentum indicator that uses both price and volume to measure buying and selling pressure. " "Usage: Identify overbought (>80) or oversold (<20) conditions and confirm the strength of trends or reversals. " "Tips: Use alongside RSI or MACD to confirm signals; divergence between price and MFI can indicate potential reversals." ), } if indicator not in best_ind_params: raise ValueError( f"Indicator {indicator} is not supported. Please choose from: {list(best_ind_params.keys())}" ) end_date = curr_date curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") before = curr_date_dt - relativedelta(days=look_back_days) # Optimized: Get stock data once and calculate indicators for all dates try: indicator_data = _get_stock_stats_bulk(symbol, indicator, curr_date) # Generate the date range we need current_dt = curr_date_dt date_values = [] while current_dt >= before: date_str = current_dt.strftime('%Y-%m-%d') # Look up the indicator value for this date if date_str in indicator_data: indicator_value = indicator_data[date_str] else: indicator_value = "N/A: Not a trading day (weekend or holiday)" date_values.append((date_str, indicator_value)) current_dt = current_dt - relativedelta(days=1) # Build the result string ind_string = "" for date_str, value in date_values: ind_string += f"{date_str}: {value}\n" except Exception as e: print(f"Error getting bulk stockstats data: {e}") # Fallback to original implementation if bulk method fails ind_string = "" curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") while curr_date_dt >= before: indicator_value = get_stockstats_indicator( symbol, indicator, curr_date_dt.strftime("%Y-%m-%d") ) ind_string += f"{curr_date_dt.strftime('%Y-%m-%d')}: {indicator_value}\n" curr_date_dt = curr_date_dt - relativedelta(days=1) result_str = ( f"## {indicator} values from {before.strftime('%Y-%m-%d')} to {end_date}:\n\n" + ind_string + "\n\n" + best_ind_params.get(indicator, "No description available.") ) return result_str def _get_stock_stats_bulk( symbol: Annotated[str, "ticker symbol of the company"], indicator: Annotated[str, "technical indicator to calculate"], curr_date: Annotated[str, "current date for reference"] ) -> dict: """ Optimized bulk calculation of stock stats indicators. Fetches data once and calculates indicator for all available dates. Returns dict mapping date strings to indicator values. """ from .config import get_config import pandas as pd from stockstats import wrap import os config = get_config() online = config["data_vendors"]["technical_indicators"] != "local" if not online: # Local data path try: data = pd.read_csv( os.path.join( config.get("data_cache_dir", "data"), f"{symbol}-YFin-data-2015-01-01-2025-03-25.csv", ) ) df = wrap(data) except FileNotFoundError: raise Exception("Stockstats fail: Yahoo Finance data not fetched yet!") else: # Online data fetching with caching today_date = pd.Timestamp.today() curr_date_dt = pd.to_datetime(curr_date) end_date = today_date start_date = today_date - pd.DateOffset(years=2) start_date_str = start_date.strftime("%Y-%m-%d") end_date_str = end_date.strftime("%Y-%m-%d") os.makedirs(config["data_cache_dir"], exist_ok=True) data_file = os.path.join( config["data_cache_dir"], f"{symbol}-YFin-data-{start_date_str}-{end_date_str}.csv", ) if os.path.exists(data_file): data = pd.read_csv(data_file) data["Date"] = pd.to_datetime(data["Date"]) else: data = yf.download( symbol, start=start_date_str, end=end_date_str, multi_level_index=False, progress=False, auto_adjust=True, ) data = data.reset_index() data.to_csv(data_file, index=False) df = wrap(data) df["Date"] = df["Date"].dt.strftime("%Y-%m-%d") # Calculate the indicator for all rows at once df[indicator] # This triggers stockstats to calculate the indicator # Create a dictionary mapping date strings to indicator values result_dict = {} for _, row in df.iterrows(): date_str = row["Date"] indicator_value = row[indicator] # Handle NaN/None values if pd.isna(indicator_value): result_dict[date_str] = "N/A" else: result_dict[date_str] = str(indicator_value) return result_dict def get_stockstats_indicator( symbol: Annotated[str, "ticker symbol of the company"], indicator: Annotated[str, "technical indicator to get the analysis and report of"], curr_date: Annotated[ str, "The current trading date you are trading on, YYYY-mm-dd" ], ) -> str: curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") curr_date = curr_date_dt.strftime("%Y-%m-%d") try: indicator_value = StockstatsUtils.get_stock_stats( symbol, indicator, curr_date, ) except Exception as e: print( f"Error getting stockstats indicator data for indicator {indicator} on {curr_date}: {e}" ) return "" return str(indicator_value) def get_technical_analysis( symbol: Annotated[str, "ticker symbol of the company"], curr_date: Annotated[str, "The current trading date, YYYY-mm-dd"], ) -> str: """ Get a concise technical analysis summary with key indicators, signals, and trend interpretation. Returns analysis-ready output instead of verbose day-by-day data. """ from .config import get_config from stockstats import wrap # Default indicators to analyze indicators = ["rsi", "stoch", "macd", "adx", "close_20_ema", "close_50_sma", "close_200_sma", "boll", "atr", "obv", "vwap", "fib"] # Fetch price data (last 60 days for indicator calculation) curr_date_dt = pd.to_datetime(curr_date) start_date = curr_date_dt - pd.DateOffset(days=200) # Need enough history for 200 SMA try: data = yf.download( symbol, start=start_date.strftime("%Y-%m-%d"), end=curr_date_dt.strftime("%Y-%m-%d"), multi_level_index=False, progress=False, auto_adjust=True, ) if data.empty: return f"No data found for {symbol}" data = data.reset_index() df = wrap(data) # Get latest values latest = df.iloc[-1] prev = df.iloc[-2] if len(df) > 1 else latest prev_5 = df.iloc[-5] if len(df) > 5 else latest current_price = float(latest['close']) # Build analysis analysis = [] analysis.append(f"# Technical Analysis for {symbol.upper()}") analysis.append(f"**Date:** {curr_date}") analysis.append(f"**Current Price:** ${current_price:.2f}") analysis.append("") # Price action summary daily_change = ((current_price - float(prev['close'])) / float(prev['close'])) * 100 weekly_change = ((current_price - float(prev_5['close'])) / float(prev_5['close'])) * 100 analysis.append(f"## Price Action") analysis.append(f"- **Daily Change:** {daily_change:+.2f}%") analysis.append(f"- **5-Day Change:** {weekly_change:+.2f}%") analysis.append("") # RSI Analysis if 'rsi' in indicators: try: df['rsi'] # Trigger calculation rsi = float(df.iloc[-1]['rsi']) rsi_prev = float(df.iloc[-5]['rsi']) if len(df) > 5 else rsi if rsi > 70: rsi_signal = "OVERBOUGHT ⚠️" elif rsi < 30: rsi_signal = "OVERSOLD ⚡" elif rsi > 50: rsi_signal = "Bullish" else: rsi_signal = "Bearish" rsi_trend = "↑" if rsi > rsi_prev else "↓" analysis.append(f"## RSI (14)") analysis.append(f"- **Value:** {rsi:.1f} {rsi_trend}") analysis.append(f"- **Signal:** {rsi_signal}") analysis.append("") except Exception as e: pass # MACD Analysis if 'macd' in indicators: try: df['macd'] df['macds'] df['macdh'] macd = float(df.iloc[-1]['macd']) signal = float(df.iloc[-1]['macds']) histogram = float(df.iloc[-1]['macdh']) hist_prev = float(df.iloc[-2]['macdh']) if len(df) > 1 else histogram if macd > signal and histogram > 0: macd_signal = "BULLISH CROSSOVER ⚡" if histogram > hist_prev else "Bullish" elif macd < signal and histogram < 0: macd_signal = "BEARISH CROSSOVER ⚠️" if histogram < hist_prev else "Bearish" else: macd_signal = "Neutral" momentum = "Strengthening ↑" if abs(histogram) > abs(hist_prev) else "Weakening ↓" analysis.append(f"## MACD") analysis.append(f"- **MACD Line:** {macd:.3f}") analysis.append(f"- **Signal Line:** {signal:.3f}") analysis.append(f"- **Histogram:** {histogram:.3f} ({momentum})") analysis.append(f"- **Signal:** {macd_signal}") analysis.append("") except Exception as e: pass # Moving Averages if 'close_50_sma' in indicators or 'close_200_sma' in indicators: try: df['close_50_sma'] df['close_200_sma'] sma_50 = float(df.iloc[-1]['close_50_sma']) sma_200 = float(df.iloc[-1]['close_200_sma']) # Trend determination if current_price > sma_50 > sma_200: trend = "STRONG UPTREND ⚡" elif current_price > sma_50: trend = "Uptrend" elif current_price < sma_50 < sma_200: trend = "STRONG DOWNTREND ⚠️" elif current_price < sma_50: trend = "Downtrend" else: trend = "Sideways" # Golden/Death cross detection sma_50_prev = float(df.iloc[-5]['close_50_sma']) if len(df) > 5 else sma_50 sma_200_prev = float(df.iloc[-5]['close_200_sma']) if len(df) > 5 else sma_200 cross = "" if sma_50 > sma_200 and sma_50_prev < sma_200_prev: cross = " (GOLDEN CROSS ⚡)" elif sma_50 < sma_200 and sma_50_prev > sma_200_prev: cross = " (DEATH CROSS ⚠️)" analysis.append(f"## Moving Averages") analysis.append(f"- **50 SMA:** ${sma_50:.2f} ({'+' if current_price > sma_50 else ''}{((current_price - sma_50) / sma_50 * 100):.1f}% from price)") analysis.append(f"- **200 SMA:** ${sma_200:.2f} ({'+' if current_price > sma_200 else ''}{((current_price - sma_200) / sma_200 * 100):.1f}% from price)") analysis.append(f"- **Trend:** {trend}{cross}") analysis.append("") except Exception as e: pass # Bollinger Bands if 'boll' in indicators: try: df['boll'] df['boll_ub'] df['boll_lb'] middle = float(df.iloc[-1]['boll']) upper = float(df.iloc[-1]['boll_ub']) lower = float(df.iloc[-1]['boll_lb']) # Position within bands (0 = lower, 1 = upper) band_position = (current_price - lower) / (upper - lower) if upper != lower else 0.5 if band_position > 0.95: bb_signal = "AT UPPER BAND - Potential reversal ⚠️" elif band_position < 0.05: bb_signal = "AT LOWER BAND - Potential bounce ⚡" elif band_position > 0.8: bb_signal = "Near upper band" elif band_position < 0.2: bb_signal = "Near lower band" else: bb_signal = "Within bands" bandwidth = ((upper - lower) / middle) * 100 analysis.append(f"## Bollinger Bands (20,2)") analysis.append(f"- **Upper:** ${upper:.2f}") analysis.append(f"- **Middle:** ${middle:.2f}") analysis.append(f"- **Lower:** ${lower:.2f}") analysis.append(f"- **Band Position:** {band_position:.0%}") analysis.append(f"- **Bandwidth:** {bandwidth:.1f}% (volatility indicator)") analysis.append(f"- **Signal:** {bb_signal}") analysis.append("") except Exception as e: pass # ATR (Volatility) if 'atr' in indicators: try: df['atr'] atr = float(df.iloc[-1]['atr']) atr_pct = (atr / current_price) * 100 if atr_pct > 5: vol_level = "HIGH VOLATILITY ⚠️" elif atr_pct > 2: vol_level = "Moderate volatility" else: vol_level = "Low volatility" analysis.append(f"## ATR (Volatility)") analysis.append(f"- **ATR:** ${atr:.2f} ({atr_pct:.1f}% of price)") analysis.append(f"- **Level:** {vol_level}") analysis.append(f"- **Suggested Stop-Loss:** ${current_price - (1.5 * atr):.2f} (1.5x ATR)") analysis.append("") except Exception as e: pass # Stochastic Oscillator if 'stoch' in indicators: try: df['kdjk'] # Stochastic %K df['kdjd'] # Stochastic %D stoch_k = float(df.iloc[-1]['kdjk']) stoch_d = float(df.iloc[-1]['kdjd']) stoch_k_prev = float(df.iloc[-2]['kdjk']) if len(df) > 1 else stoch_k if stoch_k > 80 and stoch_d > 80: stoch_signal = "OVERBOUGHT ⚠️" elif stoch_k < 20 and stoch_d < 20: stoch_signal = "OVERSOLD ⚡" elif stoch_k > stoch_d and stoch_k_prev < stoch_d: stoch_signal = "Bullish crossover ⚡" elif stoch_k < stoch_d and stoch_k_prev > stoch_d: stoch_signal = "Bearish crossover ⚠️" elif stoch_k > 50: stoch_signal = "Bullish" else: stoch_signal = "Bearish" analysis.append(f"## Stochastic (14,3,3)") analysis.append(f"- **%K:** {stoch_k:.1f}") analysis.append(f"- **%D:** {stoch_d:.1f}") analysis.append(f"- **Signal:** {stoch_signal}") analysis.append("") except Exception as e: pass # ADX (Trend Strength) if 'adx' in indicators: try: df['adx'] df['dx'] adx = float(df.iloc[-1]['adx']) adx_prev = float(df.iloc[-5]['adx']) if len(df) > 5 else adx if adx > 50: trend_strength = "VERY STRONG TREND ⚡" elif adx > 25: trend_strength = "Strong trend" elif adx > 20: trend_strength = "Trending" else: trend_strength = "WEAK/NO TREND (range-bound) ⚠️" adx_direction = "Strengthening ↑" if adx > adx_prev else "Weakening ↓" analysis.append(f"## ADX (Trend Strength)") analysis.append(f"- **ADX:** {adx:.1f} ({adx_direction})") analysis.append(f"- **Interpretation:** {trend_strength}") analysis.append("") except Exception as e: pass # 20 EMA (Short-term trend) if 'close_20_ema' in indicators: try: df['close_20_ema'] ema_20 = float(df.iloc[-1]['close_20_ema']) pct_from_ema = ((current_price - ema_20) / ema_20) * 100 if current_price > ema_20: ema_signal = "Price ABOVE 20 EMA (short-term bullish)" else: ema_signal = "Price BELOW 20 EMA (short-term bearish)" analysis.append(f"## 20 EMA") analysis.append(f"- **Value:** ${ema_20:.2f} ({pct_from_ema:+.1f}% from price)") analysis.append(f"- **Signal:** {ema_signal}") analysis.append("") except Exception as e: pass # OBV (On-Balance Volume) if 'obv' in indicators: try: # Calculate OBV manually since stockstats may not have it obv = 0 obv_values = [0] for i in range(1, len(df)): if float(df.iloc[i]['close']) > float(df.iloc[i-1]['close']): obv += float(df.iloc[i]['volume']) elif float(df.iloc[i]['close']) < float(df.iloc[i-1]['close']): obv -= float(df.iloc[i]['volume']) obv_values.append(obv) current_obv = obv_values[-1] obv_5_ago = obv_values[-5] if len(obv_values) > 5 else obv_values[0] if current_obv > obv_5_ago and current_price > float(df.iloc[-5]['close']): obv_signal = "Confirmed uptrend (price & volume rising)" elif current_obv < obv_5_ago and current_price < float(df.iloc[-5]['close']): obv_signal = "Confirmed downtrend (price & volume falling)" elif current_obv > obv_5_ago and current_price < float(df.iloc[-5]['close']): obv_signal = "BULLISH DIVERGENCE ⚡ (accumulation)" elif current_obv < obv_5_ago and current_price > float(df.iloc[-5]['close']): obv_signal = "BEARISH DIVERGENCE ⚠️ (distribution)" else: obv_signal = "Neutral" obv_formatted = f"{current_obv/1e6:.1f}M" if abs(current_obv) > 1e6 else f"{current_obv/1e3:.1f}K" analysis.append(f"## OBV (On-Balance Volume)") analysis.append(f"- **Value:** {obv_formatted}") analysis.append(f"- **5-Day Trend:** {'Rising ↑' if current_obv > obv_5_ago else 'Falling ↓'}") analysis.append(f"- **Signal:** {obv_signal}") analysis.append("") except Exception as e: pass # VWAP (Volume Weighted Average Price) if 'vwap' in indicators: try: # Calculate VWAP for today (simplified - using recent data) typical_price = (float(df.iloc[-1]['high']) + float(df.iloc[-1]['low']) + float(df.iloc[-1]['close'])) / 3 # Calculate cumulative VWAP (last 20 periods approximation) recent_df = df.tail(20) tp_vol = ((recent_df['high'] + recent_df['low'] + recent_df['close']) / 3) * recent_df['volume'] vwap = float(tp_vol.sum() / recent_df['volume'].sum()) pct_from_vwap = ((current_price - vwap) / vwap) * 100 if current_price > vwap: vwap_signal = "Price ABOVE VWAP (institutional buying)" else: vwap_signal = "Price BELOW VWAP (institutional selling)" analysis.append(f"## VWAP (20-period)") analysis.append(f"- **VWAP:** ${vwap:.2f}") analysis.append(f"- **Current vs VWAP:** {pct_from_vwap:+.1f}%") analysis.append(f"- **Signal:** {vwap_signal}") analysis.append("") except Exception as e: pass # Fibonacci Retracement Levels if 'fib' in indicators: try: # Get high and low from last 50 periods recent_high = float(df.tail(50)['high'].max()) recent_low = float(df.tail(50)['low'].min()) diff = recent_high - recent_low fib_levels = { "0.0% (High)": recent_high, "23.6%": recent_high - (diff * 0.236), "38.2%": recent_high - (diff * 0.382), "50.0%": recent_high - (diff * 0.5), "61.8%": recent_high - (diff * 0.618), "78.6%": recent_high - (diff * 0.786), "100% (Low)": recent_low, } # Find nearest support and resistance support = None resistance = None for level_name, level_price in fib_levels.items(): if level_price < current_price and (support is None or level_price > support[1]): support = (level_name, level_price) if level_price > current_price and (resistance is None or level_price < resistance[1]): resistance = (level_name, level_price) analysis.append(f"## Fibonacci Levels (50-period)") analysis.append(f"- **Recent High:** ${recent_high:.2f}") analysis.append(f"- **Recent Low:** ${recent_low:.2f}") if resistance: analysis.append(f"- **Next Resistance:** ${resistance[1]:.2f} ({resistance[0]})") if support: analysis.append(f"- **Next Support:** ${support[1]:.2f} ({support[0]})") analysis.append("") except Exception as e: pass # Overall Summary analysis.append("## Summary") signals = [] # Collect all signals for summary try: rsi = float(df.iloc[-1]['rsi']) if rsi > 70: signals.append("RSI overbought") elif rsi < 30: signals.append("RSI oversold") except: pass try: if current_price > float(df.iloc[-1]['close_50_sma']): signals.append("Above 50 SMA") else: signals.append("Below 50 SMA") except: pass if signals: analysis.append(f"- **Key Signals:** {', '.join(signals)}") return "\n".join(analysis) except Exception as e: return f"Error analyzing {symbol}: {str(e)}" def get_balance_sheet( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", curr_date: Annotated[str, "current date (not used for yfinance)"] = None ): """Get balance sheet data from yfinance.""" try: ticker_obj = yf.Ticker(ticker.upper()) if freq.lower() == "quarterly": data = ticker_obj.quarterly_balance_sheet else: data = ticker_obj.balance_sheet if data.empty: return f"No balance sheet data found for symbol '{ticker}'" # Convert to CSV string for consistency with other functions csv_string = data.to_csv() # Add header information header = f"# Balance Sheet data for {ticker.upper()} ({freq})\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" return header + csv_string except Exception as e: return f"Error retrieving balance sheet for {ticker}: {str(e)}" def get_cashflow( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", curr_date: Annotated[str, "current date (not used for yfinance)"] = None ): """Get cash flow data from yfinance.""" try: ticker_obj = yf.Ticker(ticker.upper()) if freq.lower() == "quarterly": data = ticker_obj.quarterly_cashflow else: data = ticker_obj.cashflow if data.empty: return f"No cash flow data found for symbol '{ticker}'" # Convert to CSV string for consistency with other functions csv_string = data.to_csv() # Add header information header = f"# Cash Flow data for {ticker.upper()} ({freq})\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" return header + csv_string except Exception as e: return f"Error retrieving cash flow for {ticker}: {str(e)}" def get_income_statement( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", curr_date: Annotated[str, "current date (not used for yfinance)"] = None ): """Get income statement data from yfinance.""" try: ticker_obj = yf.Ticker(ticker.upper()) if freq.lower() == "quarterly": data = ticker_obj.quarterly_income_stmt else: data = ticker_obj.income_stmt if data.empty: return f"No income statement data found for symbol '{ticker}'" # Convert to CSV string for consistency with other functions csv_string = data.to_csv() # Add header information header = f"# Income Statement data for {ticker.upper()} ({freq})\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" return header + csv_string except Exception as e: return f"Error retrieving income statement for {ticker}: {str(e)}" def get_insider_transactions( ticker: Annotated[str, "ticker symbol of the company"], curr_date: Annotated[str, "current date (not used for yfinance)"] = None ): """Get insider transactions data from yfinance with parsed transaction types.""" try: ticker_obj = yf.Ticker(ticker.upper()) data = ticker_obj.insider_transactions if data is None or data.empty: return f"No insider transactions data found for symbol '{ticker}'" # Parse the Text column to populate Transaction type def classify_transaction(text): if pd.isna(text) or text == '': return 'Unknown' text_lower = str(text).lower() if 'sale' in text_lower: return 'Sale' elif 'purchase' in text_lower or 'buy' in text_lower: return 'Purchase' elif 'gift' in text_lower: return 'Gift' elif 'exercise' in text_lower or 'option' in text_lower: return 'Option Exercise' elif 'award' in text_lower or 'grant' in text_lower: return 'Award/Grant' elif 'conversion' in text_lower: return 'Conversion' else: return 'Other' # Apply classification data['Transaction'] = data['Text'].apply(classify_transaction) # Calculate summary statistics transaction_counts = data['Transaction'].value_counts().to_dict() total_sales_value = data[data['Transaction'] == 'Sale']['Value'].sum() total_purchases_value = data[data['Transaction'] == 'Purchase']['Value'].sum() # Determine insider sentiment sales_count = transaction_counts.get('Sale', 0) purchases_count = transaction_counts.get('Purchase', 0) if purchases_count > sales_count: sentiment = "BULLISH ⚡ (more buying than selling)" elif sales_count > purchases_count * 2: sentiment = "BEARISH ⚠️ (significant insider selling)" elif sales_count > purchases_count: sentiment = "Slightly bearish (more selling than buying)" else: sentiment = "Neutral" # Build summary header header = f"# Insider Transactions for {ticker.upper()}\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" header += "## Summary\n" header += f"- **Insider Sentiment:** {sentiment}\n" for tx_type, count in sorted(transaction_counts.items(), key=lambda x: -x[1]): header += f"- **{tx_type}:** {count} transactions\n" if total_sales_value > 0: header += f"- **Total Sales Value:** ${total_sales_value:,.0f}\n" if total_purchases_value > 0: header += f"- **Total Purchases Value:** ${total_purchases_value:,.0f}\n" header += "\n## Transaction Details\n\n" # Select key columns for output output_cols = ['Start Date', 'Insider', 'Position', 'Transaction', 'Shares', 'Value', 'Ownership'] available_cols = [c for c in output_cols if c in data.columns] csv_string = data[available_cols].to_csv(index=False) return header + csv_string except Exception as e: return f"Error retrieving insider transactions for {ticker}: {str(e)}" def validate_ticker(symbol: str) -> bool: """ Validate if a ticker symbol exists and has trading data. """ try: ticker = yf.Ticker(symbol.upper()) # Use fast_info for lighter validation (no historical download needed) # fast_info attributes are lazy-loaded _ = ticker.fast_info.get("lastPrice") return True except Exception: # Fallback to older method if fast_info fails or is missing try: return not ticker.history(period="1d", progress=False).empty except: return False def get_fundamentals( ticker: Annotated[str, "ticker symbol of the company"], curr_date: Annotated[str, "current date (for reference)"] = None ) -> str: """ Get comprehensive fundamental data for a ticker using yfinance. Returns data in a format similar to Alpha Vantage's OVERVIEW endpoint. This is a FREE alternative to Alpha Vantage with no rate limits. """ import json try: ticker_obj = yf.Ticker(ticker.upper()) info = ticker_obj.info if not info or info.get('regularMarketPrice') is None: return f"No fundamental data found for symbol '{ticker}'" # Build a structured response similar to Alpha Vantage fundamentals = { # Company Info "Symbol": ticker.upper(), "AssetType": info.get("quoteType", "N/A"), "Name": info.get("longName", info.get("shortName", "N/A")), "Description": info.get("longBusinessSummary", "N/A"), "Exchange": info.get("exchange", "N/A"), "Currency": info.get("currency", "USD"), "Country": info.get("country", "N/A"), "Sector": info.get("sector", "N/A"), "Industry": info.get("industry", "N/A"), "Address": f"{info.get('address1', '')} {info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}".strip(), "OfficialSite": info.get("website", "N/A"), "FiscalYearEnd": info.get("fiscalYearEnd", "N/A"), # Valuation "MarketCapitalization": str(info.get("marketCap", "N/A")), "EBITDA": str(info.get("ebitda", "N/A")), "PERatio": str(info.get("trailingPE", "N/A")), "ForwardPE": str(info.get("forwardPE", "N/A")), "PEGRatio": str(info.get("pegRatio", "N/A")), "BookValue": str(info.get("bookValue", "N/A")), "PriceToBookRatio": str(info.get("priceToBook", "N/A")), "PriceToSalesRatioTTM": str(info.get("priceToSalesTrailing12Months", "N/A")), "EVToRevenue": str(info.get("enterpriseToRevenue", "N/A")), "EVToEBITDA": str(info.get("enterpriseToEbitda", "N/A")), # Earnings & Revenue "EPS": str(info.get("trailingEps", "N/A")), "ForwardEPS": str(info.get("forwardEps", "N/A")), "RevenueTTM": str(info.get("totalRevenue", "N/A")), "RevenuePerShareTTM": str(info.get("revenuePerShare", "N/A")), "GrossProfitTTM": str(info.get("grossProfits", "N/A")), "QuarterlyRevenueGrowthYOY": str(info.get("revenueGrowth", "N/A")), "QuarterlyEarningsGrowthYOY": str(info.get("earningsGrowth", "N/A")), # Margins & Returns "ProfitMargin": str(info.get("profitMargins", "N/A")), "OperatingMarginTTM": str(info.get("operatingMargins", "N/A")), "GrossMargins": str(info.get("grossMargins", "N/A")), "ReturnOnAssetsTTM": str(info.get("returnOnAssets", "N/A")), "ReturnOnEquityTTM": str(info.get("returnOnEquity", "N/A")), # Dividend "DividendPerShare": str(info.get("dividendRate", "N/A")), "DividendYield": str(info.get("dividendYield", "N/A")), "ExDividendDate": str(info.get("exDividendDate", "N/A")), "PayoutRatio": str(info.get("payoutRatio", "N/A")), # Balance Sheet "TotalCash": str(info.get("totalCash", "N/A")), "TotalDebt": str(info.get("totalDebt", "N/A")), "CurrentRatio": str(info.get("currentRatio", "N/A")), "QuickRatio": str(info.get("quickRatio", "N/A")), "DebtToEquity": str(info.get("debtToEquity", "N/A")), "FreeCashFlow": str(info.get("freeCashflow", "N/A")), "OperatingCashFlow": str(info.get("operatingCashflow", "N/A")), # Trading Info "Beta": str(info.get("beta", "N/A")), "52WeekHigh": str(info.get("fiftyTwoWeekHigh", "N/A")), "52WeekLow": str(info.get("fiftyTwoWeekLow", "N/A")), "50DayMovingAverage": str(info.get("fiftyDayAverage", "N/A")), "200DayMovingAverage": str(info.get("twoHundredDayAverage", "N/A")), "SharesOutstanding": str(info.get("sharesOutstanding", "N/A")), "SharesFloat": str(info.get("floatShares", "N/A")), "SharesShort": str(info.get("sharesShort", "N/A")), "ShortRatio": str(info.get("shortRatio", "N/A")), "ShortPercentOfFloat": str(info.get("shortPercentOfFloat", "N/A")), # Ownership "PercentInsiders": str(info.get("heldPercentInsiders", "N/A")), "PercentInstitutions": str(info.get("heldPercentInstitutions", "N/A")), # Analyst "AnalystTargetPrice": str(info.get("targetMeanPrice", "N/A")), "AnalystTargetHigh": str(info.get("targetHighPrice", "N/A")), "AnalystTargetLow": str(info.get("targetLowPrice", "N/A")), "NumberOfAnalysts": str(info.get("numberOfAnalystOpinions", "N/A")), "RecommendationKey": info.get("recommendationKey", "N/A"), "RecommendationMean": str(info.get("recommendationMean", "N/A")), } # Return as formatted JSON string return json.dumps(fundamentals, indent=4) except Exception as e: return f"Error retrieving fundamentals for {ticker}: {str(e)}" def get_options_activity( ticker: Annotated[str, "ticker symbol of the company"], num_expirations: Annotated[int, "number of nearest expiration dates to analyze"] = 3, curr_date: Annotated[str, "current date (for reference)"] = None ) -> str: """ Get options activity for a specific ticker using yfinance. Analyzes volume, open interest, and put/call ratios. This is a FREE alternative to Tradier with no API key required. """ try: ticker_obj = yf.Ticker(ticker.upper()) # Get available expiration dates expirations = ticker_obj.options if not expirations: return f"No options data available for {ticker}" # Analyze the nearest N expiration dates expirations_to_analyze = expirations[:min(num_expirations, len(expirations))] report = f"## Options Activity for {ticker.upper()}\n\n" report += f"**Available Expirations:** {len(expirations)} dates\n" report += f"**Analyzing:** {', '.join(expirations_to_analyze)}\n\n" total_call_volume = 0 total_put_volume = 0 total_call_oi = 0 total_put_oi = 0 unusual_activity = [] for exp_date in expirations_to_analyze: try: opt = ticker_obj.option_chain(exp_date) calls = opt.calls puts = opt.puts if calls.empty and puts.empty: continue # Calculate totals for this expiration call_vol = calls['volume'].sum() if 'volume' in calls.columns else 0 put_vol = puts['volume'].sum() if 'volume' in puts.columns else 0 call_oi = calls['openInterest'].sum() if 'openInterest' in calls.columns else 0 put_oi = puts['openInterest'].sum() if 'openInterest' in puts.columns else 0 # Handle NaN values call_vol = 0 if pd.isna(call_vol) else int(call_vol) put_vol = 0 if pd.isna(put_vol) else int(put_vol) call_oi = 0 if pd.isna(call_oi) else int(call_oi) put_oi = 0 if pd.isna(put_oi) else int(put_oi) total_call_volume += call_vol total_put_volume += put_vol total_call_oi += call_oi total_put_oi += put_oi # Find unusual activity (high volume relative to OI) for _, row in calls.iterrows(): vol = row.get('volume', 0) oi = row.get('openInterest', 0) if pd.notna(vol) and pd.notna(oi) and oi > 0 and vol > oi * 0.5 and vol > 100: unusual_activity.append({ 'type': 'CALL', 'expiration': exp_date, 'strike': row['strike'], 'volume': int(vol), 'openInterest': int(oi), 'vol_oi_ratio': round(vol / oi, 2) if oi > 0 else 0, 'impliedVolatility': round(row.get('impliedVolatility', 0) * 100, 1) }) for _, row in puts.iterrows(): vol = row.get('volume', 0) oi = row.get('openInterest', 0) if pd.notna(vol) and pd.notna(oi) and oi > 0 and vol > oi * 0.5 and vol > 100: unusual_activity.append({ 'type': 'PUT', 'expiration': exp_date, 'strike': row['strike'], 'volume': int(vol), 'openInterest': int(oi), 'vol_oi_ratio': round(vol / oi, 2) if oi > 0 else 0, 'impliedVolatility': round(row.get('impliedVolatility', 0) * 100, 1) }) except Exception as e: report += f"*Error fetching {exp_date}: {str(e)}*\n" continue # Calculate put/call ratios pc_volume_ratio = round(total_put_volume / total_call_volume, 3) if total_call_volume > 0 else 0 pc_oi_ratio = round(total_put_oi / total_call_oi, 3) if total_call_oi > 0 else 0 # Summary report += "### Summary\n" report += "| Metric | Calls | Puts | Put/Call Ratio |\n" report += "|--------|-------|------|----------------|\n" report += f"| Volume | {total_call_volume:,} | {total_put_volume:,} | {pc_volume_ratio} |\n" report += f"| Open Interest | {total_call_oi:,} | {total_put_oi:,} | {pc_oi_ratio} |\n\n" # Sentiment interpretation report += "### Sentiment Analysis\n" if pc_volume_ratio < 0.7: report += "- **Volume P/C Ratio:** Bullish (more call volume)\n" elif pc_volume_ratio > 1.3: report += "- **Volume P/C Ratio:** Bearish (more put volume)\n" else: report += "- **Volume P/C Ratio:** Neutral\n" if pc_oi_ratio < 0.7: report += "- **OI P/C Ratio:** Bullish positioning\n" elif pc_oi_ratio > 1.3: report += "- **OI P/C Ratio:** Bearish positioning\n" else: report += "- **OI P/C Ratio:** Neutral positioning\n" # Unusual activity if unusual_activity: # Sort by volume/OI ratio unusual_activity.sort(key=lambda x: x['vol_oi_ratio'], reverse=True) top_unusual = unusual_activity[:10] report += "\n### Unusual Activity (High Volume vs Open Interest)\n" report += "| Type | Expiry | Strike | Volume | OI | Vol/OI | IV |\n" report += "|------|--------|--------|--------|----|---------|----|---|\n" for item in top_unusual: report += f"| {item['type']} | {item['expiration']} | ${item['strike']} | {item['volume']:,} | {item['openInterest']:,} | {item['vol_oi_ratio']}x | {item['impliedVolatility']}% |\n" else: report += "\n*No unusual options activity detected.*\n" return report except Exception as e: return f"Error retrieving options activity for {ticker}: {str(e)}" def _get_ticker_universe( tickers: Optional[Union[str, List[str]]] = None, max_tickers: Optional[int] = None ) -> List[str]: """ Get a list of ticker symbols. Args: tickers: List of ticker symbols, or None to load from config file max_tickers: Maximum number of tickers to return (None = all) Returns: List of ticker symbols """ # If custom list provided, use it if isinstance(tickers, list): ticker_list = [t.upper().strip() for t in tickers if t and isinstance(t, str)] return ticker_list[:max_tickers] if max_tickers else ticker_list # Load from config file from tradingagents.default_config import DEFAULT_CONFIG ticker_file = DEFAULT_CONFIG.get("tickers_file") if not ticker_file: print("Warning: tickers_file not configured, using fallback list") return _get_default_tickers()[:max_tickers] if max_tickers else _get_default_tickers() # Load tickers from file try: ticker_path = Path(ticker_file) if ticker_path.exists(): with open(ticker_path, 'r') as f: ticker_list = [line.strip().upper() for line in f if line.strip()] # Remove duplicates while preserving order seen = set() ticker_list = [t for t in ticker_list if t and t not in seen and not seen.add(t)] return ticker_list[:max_tickers] if max_tickers else ticker_list else: print(f"Warning: Ticker file not found at {ticker_file}, using fallback list") return _get_default_tickers()[:max_tickers] if max_tickers else _get_default_tickers() except Exception as e: print(f"Warning: Could not load ticker list from file: {e}, using fallback") return _get_default_tickers()[:max_tickers] if max_tickers else _get_default_tickers() def _get_default_tickers() -> List[str]: """Fallback list of major US stocks if ticker file is not found.""" return [ "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "BRK-B", "V", "UNH", "XOM", "JNJ", "JPM", "WMT", "MA", "PG", "LLY", "AVGO", "HD", "MRK", "COST", "ABBV", "PEP", "ADBE", "TMO", "CSCO", "NFLX", "ACN", "DHR", "ABT", "VZ", "WFC", "CRM", "PM", "LIN", "DIS", "BMY", "NKE", "TXN", "RTX", "QCOM", "UPS", "HON", "AMGN", "DE", "INTU", "AMAT", "LOW", "SBUX", "C", "BKNG", "ADP", "GE", "TJX", "AXP", "SPGI", "MDT", "GILD", "ISRG", "BLK", "SYK", "ZTS", "CI", "CME", "ICE", "EQIX", "REGN", "APH", "KLAC", "CDNS", "SNPS", "MCHP", "FTNT", "ANSS", "CTSH", "WDAY", "ON", "NXPI", "MPWR", "CRWD", "AMD", "INTC", "MU", "LRCX", "PANW", "NOW", "DDOG", "ZS", "NET", "TEAM" ] def get_pre_earnings_accumulation_signal( ticker: Annotated[str, "ticker symbol to analyze"], lookback_days: Annotated[int, "days to analyze volume"] = 10, ) -> dict: """ Detect if a stock is being accumulated BEFORE earnings (LEADING INDICATOR). SIGNAL: Volume increases while price stays flat = Smart money accumulating This happens BEFORE the price run, giving you an early entry. Returns a dict with signal strength and metrics. Args: ticker: Stock symbol to check lookback_days: Recent days to analyze Returns: Dict with 'signal' (bool), 'volume_ratio' (float), 'price_change_pct' (float), 'current_price' (float) """ try: stock = yf.Ticker(ticker.upper()) # Get 1 month of data to calculate baseline hist = stock.history(period="1mo") if len(hist) < 20: return {'signal': False, 'reason': 'Insufficient data'} # Baseline volume (excluding recent period) baseline_volume = hist['Volume'][:-lookback_days].mean() # Recent volume recent_volume = hist['Volume'][-lookback_days:].mean() # Volume ratio volume_ratio = recent_volume / baseline_volume if baseline_volume > 0 else 0 # Price movement in recent period price_start = hist['Close'].iloc[-lookback_days] price_end = hist['Close'].iloc[-1] price_change_pct = ((price_end - price_start) / price_start) * 100 # SIGNAL CRITERIA: # - Volume up at least 50% (1.5x) # - Price relatively flat (< 5% move) accumulation_signal = volume_ratio >= 1.5 and abs(price_change_pct) < 5.0 return { 'signal': accumulation_signal, 'volume_ratio': round(volume_ratio, 2), 'price_change_pct': round(price_change_pct, 2), 'current_price': round(price_end, 2), 'baseline_volume': int(baseline_volume), 'recent_volume': int(recent_volume), } except Exception as e: return {'signal': False, 'reason': str(e)} def check_if_price_reacted( ticker: Annotated[str, "ticker symbol to analyze"], lookback_days: Annotated[int, "days to check for price reaction"] = 3, reaction_threshold: Annotated[float, "% change to consider as 'reacted'"] = 5.0, ) -> dict: """ Check if a stock's price has already reacted to news/catalyst. Use this to determine if a catalyst (analyst upgrade, news, etc.) is LEADING or LAGGING: - If price hasn't moved much = LEADING indicator (you're early) - If price already moved significantly = LAGGING indicator (you're late) Args: ticker: Stock symbol to check lookback_days: Days to check for reaction (default 3) reaction_threshold: Price change % to consider as "reacted" (default 5%) Returns: Dict with 'reacted' (bool), 'price_change_pct' (float), 'status' (str: 'leading' or 'lagging') """ try: stock = yf.Ticker(ticker.upper()) # Get recent history hist = stock.history(period="1mo") if len(hist) < lookback_days: return {'reacted': None, 'reason': 'Insufficient data', 'status': 'unknown'} # Check price movement in lookback period price_start = hist['Close'].iloc[-lookback_days] price_end = hist['Close'].iloc[-1] price_change_pct = ((price_end - price_start) / price_start) * 100 # Determine if already reacted reacted = abs(price_change_pct) >= reaction_threshold return { 'reacted': reacted, 'price_change_pct': round(price_change_pct, 2), 'status': 'lagging' if reacted else 'leading', 'current_price': round(price_end, 2), } except Exception as e: return {'reacted': None, 'reason': str(e), 'status': 'unknown'}