# -*- coding: utf-8 -*- """ Analysis tools for fundamental, technical, sentiment, and valuation analysis. All tools accept tickers as List[str] with default from analysis context. Returns human-readable text format for easy LLM consumption. """ # flake8: noqa: E501 # pylint: disable=C0301,W0613 import ast import json import logging import traceback from datetime import datetime, timedelta from functools import wraps from statistics import median from typing import List, Optional, Union import numpy as np import pandas as pd from agentscope.message import TextBlock from agentscope.tool import ToolResponse from backend.data.provider_utils import normalize_symbol from backend.tools.data_tools import ( get_company_news, get_financial_metrics, get_insider_trades, get_market_cap, get_prices, prices_to_df, search_line_items, ) from backend.tools.sandboxed_executor import get_sandbox from backend.tools.technical_signals import StockTechnicalAnalyzer logger = logging.getLogger(__name__) _technical_analyzer = StockTechnicalAnalyzer() _sandbox = get_sandbox() def _to_text_response(text: str) -> ToolResponse: """Convert text string to ToolResponse.""" return ToolResponse(content=[TextBlock(type="text", text=text)]) def _parse_tickers(tickers: Union[str, List[str], None]) -> List[str]: """ Parse tickers parameter which may be a JSON string or a list. LLM sometimes passes tickers as a JSON string like '["AAPL", "MSFT"]' instead of an actual list. This function handles both cases. Args: tickers: List of stock tickers as a list or JSON string. Returns: List of stock tickers. """ def _sanitize(values: List[object]) -> List[str]: cleaned: List[str] = [] for value in values: if value is None: continue symbol = normalize_symbol(str(value).strip().strip("\"'")) if symbol and symbol not in cleaned: cleaned.append(symbol) return cleaned if tickers is None: return [] if isinstance(tickers, str): try: parsed = json.loads(tickers) if isinstance(parsed, list): return _sanitize(parsed) return _sanitize([parsed]) except json.JSONDecodeError: try: parsed = ast.literal_eval(tickers) if isinstance(parsed, list): return _sanitize(parsed) return _sanitize([parsed]) except (SyntaxError, ValueError): pass return _sanitize(tickers.split(",")) if isinstance(tickers, list): return _sanitize(tickers) return _sanitize([tickers]) def _safe_float(value, default=0.0) -> float: """Safely convert to float.""" try: if pd.isna(value) or np.isnan(value): return default return float(value) except (ValueError, TypeError, OverflowError): return default def safe(func): """Decorator to catch exceptions in both sync and async tool functions.""" if asyncio.iscoroutinefunction(func): @wraps(func) async def async_wrapper(*args, **kwargs): try: return await func(*args, **kwargs) except Exception as e: error_msg = f"Error in {func.__name__}: {str(e)}" logger.error(f"{error_msg}\n{traceback.format_exc()}") return _to_text_response(f"[ERROR] {error_msg}") return async_wrapper else: @wraps(func) def sync_wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as e: error_msg = f"Error in {func.__name__}: {str(e)}" logger.error(f"{error_msg}\n{traceback.format_exc()}") return _to_text_response(f"[ERROR] {error_msg}") return sync_wrapper def _fmt(val, fmt=".2f", suffix="") -> str: """Format value with handling for None.""" if val is None: return "N/A" try: return f"{val:{fmt}}{suffix}" except (ValueError, TypeError): return str(val) def _resolved_date(current_date: Optional[str]) -> str: """Ensure we always return a concrete date string.""" today = datetime.today().date() if not current_date: return today.strftime("%Y-%m-%d") requested_date = datetime.strptime(current_date, "%Y-%m-%d").date() return min(requested_date, today).strftime("%Y-%m-%d") # ==================== Fundamental Analysis Tools ==================== @safe async def analyze_efficiency_ratios( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Analyze asset utilization efficiency ratios for stocks. Evaluates how efficiently companies use assets to generate revenue. Higher ratios generally indicate better operational efficiency. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. Returns: Text summary of efficiency metrics for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Efficiency Ratios Analysis ({current_date}) ===\n"] async def _fetch_one(ticker): try: metrics = await asyncio.to_thread(get_financial_metrics, ticker=ticker, end_date=current_date) if not metrics: return f"{ticker}: No data available\n" m = metrics[0] ticker_lines = [ f"{ticker}:", f" Asset Turnover: {_fmt(m.asset_turnover)}", f" Inventory Turnover: {_fmt(m.inventory_turnover)}", f" Receivables Turnover: {_fmt(m.receivables_turnover)}", f" Working Capital Turnover: {_fmt(m.working_capital_turnover)}\n", ] return "\n".join(ticker_lines) except Exception as e: return f"{ticker}: Error - {str(e)}\n" results = await asyncio.gather(*[_fetch_one(t) for t in tickers]) lines.extend(results) return _to_text_response("\n".join(lines)) @safe def analyze_profitability( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Analyze profitability metrics for stocks. Assesses how effectively companies generate profit from operations and equity. Higher margins indicate stronger profitability and better cost management. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. Returns: Text summary of profitability metrics for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Profitability Analysis ({current_date}) ===\n"] for ticker in tickers: metrics = get_financial_metrics(ticker=ticker, end_date=current_date) if not metrics: lines.append(f"{ticker}: No data available\n") continue m = metrics[0] roe = _safe_float(m.return_on_equity) net_margin = _safe_float(m.net_margin) op_margin = _safe_float(m.operating_margin) lines.append(f"{ticker}:") lines.append(f" Return on Equity (ROE): {_fmt(roe/100, '.1%')}") lines.append(f" Net Margin: {_fmt(net_margin/100, '.1%')}") lines.append(f" Operating Margin: {_fmt(op_margin/100, '.1%')}") lines.append("") return _to_text_response("\n".join(lines)) @safe def analyze_growth( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Analyze growth metrics for stocks. Evaluates company growth trajectory across key financial dimensions. Higher growth rates may indicate strong business momentum. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. Returns: Text summary of growth metrics for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Growth Analysis ({current_date}) ===\n"] for ticker in tickers: metrics = get_financial_metrics(ticker=ticker, end_date=current_date) if not metrics: lines.append(f"{ticker}: No data available\n") continue m = metrics[0] lines.append(f"{ticker}:") lines.append(f" Revenue Growth: {_fmt(m.revenue_growth, '.1%')}") lines.append(f" Earnings Growth: {_fmt(m.earnings_growth, '.1%')}") lines.append( f" Book Value Growth: {_fmt(m.book_value_growth, '.1%')}", ) lines.append("") return _to_text_response("\n".join(lines)) @safe def analyze_financial_health( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Analyze financial health metrics for stocks. Assesses financial stability and ability to meet obligations. Strong financial health suggests lower bankruptcy risk. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. Returns: Text summary of financial health metrics for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Financial Health Analysis ({current_date}) ===\n"] for ticker in tickers: metrics = get_financial_metrics(ticker=ticker, end_date=current_date) if not metrics: lines.append(f"{ticker}: No data available\n") continue m = metrics[0] lines.append(f"{ticker}:") lines.append( f" Current Ratio: {_fmt(m.current_ratio)} (>1 is healthy)", ) lines.append(f" Debt to Equity: {_fmt(m.debt_to_equity)}") lines.append( f" Free Cash Flow/Share: ${_fmt(m.free_cash_flow_per_share)}", ) lines.append(f" EPS: ${_fmt(m.earnings_per_share)}") lines.append("") return _to_text_response("\n".join(lines)) @safe async def analyze_valuation_ratios( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Analyze valuation ratios for stocks. Evaluates whether stocks are overvalued or undervalued using common multiples. Lower ratios may indicate undervaluation but compare with industry peers. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. Returns: Text summary of valuation ratios for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Valuation Ratios Analysis ({current_date}) ===\n"] async def _fetch_one(ticker): try: metrics = await asyncio.to_thread(get_financial_metrics, ticker=ticker, end_date=current_date) if not metrics: return f"{ticker}: No data available\n" m = metrics[0] ticker_lines = [ f"{ticker}:", f" P/E Ratio: {_fmt(m.price_to_earnings_ratio)}", f" P/B Ratio: {_fmt(m.price_to_book_ratio)}", f" P/S Ratio: {_fmt(m.price_to_sales_ratio)}\n", ] return "\n".join(ticker_lines) except Exception as e: return f"{ticker}: Error - {str(e)}\n" results = await asyncio.gather(*[_fetch_one(t) for t in tickers]) lines.extend(results) return _to_text_response("\n".join(lines)) @safe async def get_financial_metrics_tool( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, period: str = "ttm", ) -> ToolResponse: """ Get comprehensive financial metrics for stocks. Retrieves complete set of financial metrics for fundamental analysis. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. period: Time period - 'ttm', 'quarterly', or 'annual'. Default 'ttm'. Returns: Text summary of all available financial metrics for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [ f"=== Comprehensive Financial Metrics ({current_date}, {period}) ===\n", ] async def _fetch_one(ticker): try: # Offload synchronous data fetching to thread to keep loop snappy metrics = await asyncio.to_thread( get_financial_metrics, ticker=ticker, end_date=current_date, period=period, ) if not metrics: return f"{ticker}: No data available\n" m = metrics[0] ticker_lines = [ f"{ticker}:", f" Market Cap: ${_fmt(m.market_cap, ',.0f')}", f" P/E: {_fmt(m.price_to_earnings_ratio)} | P/B: {_fmt(m.price_to_book_ratio)} | P/S: {_fmt(m.price_to_sales_ratio)}", f" ROE: {_fmt(m.return_on_equity, '.1%')} | Net Margin: {_fmt(m.net_margin, '.1%')}", f" Revenue Growth: {_fmt(m.revenue_growth, '.1%')} | Earnings Growth: {_fmt(m.earnings_growth, '.1%')}", f" Current Ratio: {_fmt(m.current_ratio)} | D/E: {_fmt(m.debt_to_equity)}", f" EPS: ${_fmt(m.earnings_per_share)} | FCF/Share: ${_fmt(m.free_cash_flow_per_share)}\n", ] return "\n".join(ticker_lines) except Exception as e: return f"{ticker}: Error fetching data - {str(e)}\n" # Parallelize data retrieval for all tickers results = await asyncio.gather(*[_fetch_one(t) for t in tickers]) lines.extend(results) return _to_text_response("\n".join(lines)) # ==================== Technical Analysis Tools ==================== @safe def analyze_trend_following( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Trend following analysis using moving averages and MACD. Identifies market trends using SMA (20/50/200) and MACD indicators. Helps determine if stocks are in uptrend, downtrend, or consolidation. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. Returns: Text summary of trend analysis for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Trend Following Analysis ({current_date}) ===\n"] end_dt = datetime.strptime(current_date, "%Y-%m-%d") extended_start = (end_dt - timedelta(days=250)).strftime("%Y-%m-%d") for ticker in tickers: prices = get_prices( ticker=ticker, start_date=extended_start, end_date=current_date, ) if not prices or len(prices) < 10: lines.append(f"{ticker}: Insufficient price data\n") continue signal = _technical_analyzer.analyze(ticker, prices_to_df(prices)) distance_200ma = ( ((signal.current_price - signal.ma200) / signal.ma200) * 100 if signal.ma200 else None ) macd_signal_str = ( "BUY" if signal.macd > signal.macd_signal else "SELL" ) lines.append(f"{ticker}: ${signal.current_price:.2f}") lines.append( f" MA20: ${signal.ma20:.2f} | MA50: ${signal.ma50:.2f} | MA200: {f'${signal.ma200:.2f}' if signal.ma200 else 'N/A'}", ) lines.append( f" MACD: {signal.macd:.3f} | Signal: {signal.macd_signal:.3f} -> {macd_signal_str}", ) lines.append( f" Long-term Trend: {signal.trend}" + ( f" ({distance_200ma:+.1f}% from 200MA)" if distance_200ma else "" ), ) if signal.notes: lines.append(f" Notes: {'; '.join(signal.notes)}") lines.append("") return _to_text_response("\n".join(lines)) @safe def analyze_mean_reversion( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Mean reversion analysis using Bollinger Bands and RSI. Identifies overbought/oversold conditions. RSI >70 = overbought, <30 = oversold. Price near bands may signal reversal. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. Returns: Text summary of mean reversion signals for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Mean Reversion Analysis ({current_date}) ===\n"] end_dt = datetime.strptime(current_date, "%Y-%m-%d") extended_start = (end_dt - timedelta(days=60)).strftime("%Y-%m-%d") for ticker in tickers: prices = get_prices( ticker=ticker, start_date=extended_start, end_date=current_date, ) if not prices or len(prices) < 5: lines.append(f"{ticker}: Insufficient price data\n") continue signal = _technical_analyzer.analyze(ticker, prices_to_df(prices)) deviation = ( ((signal.current_price - signal.bollinger_mid) / signal.bollinger_mid) * 100 if signal.bollinger_mid > 0 else 0 ) if signal.current_price > signal.bollinger_upper > 0: bb_signal = "ABOVE UPPER BAND (potential sell)" elif 0 < signal.current_price < signal.bollinger_lower: bb_signal = "BELOW LOWER BAND (potential buy)" else: bb_signal = "WITHIN BANDS" lines.append(f"{ticker}: ${signal.current_price:.2f}") lines.append( f" Bollinger: Lower ${signal.bollinger_lower:.2f} | Mid ${signal.bollinger_mid:.2f} | Upper ${signal.bollinger_upper:.2f}", ) lines.append(f" Position: {bb_signal}") lines.append( f" RSI: {signal.rsi14:.1f} -> {signal.mean_reversion_signal}", ) lines.append(f" Price Deviation from SMA: {deviation:+.1f}%") lines.append("") return _to_text_response("\n".join(lines)) @safe def analyze_momentum( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Momentum analysis for different time periods. Measures price momentum over 5, 10, and 20 day periods. Positive momentum indicates upward price pressure. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. Returns: Text summary of momentum indicators for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Momentum Analysis ({current_date}) ===\n"] end_dt = datetime.strptime(current_date, "%Y-%m-%d") extended_start = (end_dt - timedelta(days=45)).strftime("%Y-%m-%d") for ticker in tickers: prices = get_prices( ticker=ticker, start_date=extended_start, end_date=current_date, ) if not prices or len(prices) < 5: lines.append(f"{ticker}: Insufficient price data\n") continue signal = _technical_analyzer.analyze(ticker, prices_to_df(prices)) avg_mom = ( signal.momentum_5d_pct + signal.momentum_10d_pct + signal.momentum_20d_pct ) / 3 if avg_mom > 2: signal_text = "STRONG BULLISH" elif avg_mom > 0: signal_text = "BULLISH" elif avg_mom > -2: signal_text = "BEARISH" else: signal_text = "STRONG BEARISH" lines.append(f"{ticker}: ${signal.current_price:.2f}") lines.append( f" 5-day: {signal.momentum_5d_pct:+.1f}% | 10-day: {signal.momentum_10d_pct:+.1f}% | 20-day: {signal.momentum_20d_pct:+.1f}%", ) lines.append( f" Volatility (annualized): {signal.annualized_volatility_pct:.1f}%", ) lines.append(f" Overall: {signal_text}") lines.append("") return _to_text_response("\n".join(lines)) @safe def analyze_volatility( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Volatility analysis for different time windows. Measures price volatility over 10, 20, and 60 day periods. Higher volatility indicates higher risk but potentially higher returns. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date (YYYY-MM-DD). If None, uses date from context. Returns: Text summary of volatility metrics for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Volatility Analysis ({current_date}) ===\n"] end_dt = datetime.strptime(current_date, "%Y-%m-%d") extended_start = (end_dt - timedelta(days=90)).strftime("%Y-%m-%d") for ticker in tickers: prices = get_prices( ticker=ticker, start_date=extended_start, end_date=current_date, ) if not prices or len(prices) < 5: lines.append(f"{ticker}: Insufficient price data\n") continue df = prices_to_df(prices) signal = _technical_analyzer.analyze(ticker, df) returns = df["close"].pct_change() short_w = min(10, max(1, len(df) - 1)) med_w = min(20, max(1, len(df) - 1)) long_w = min(60, max(1, len(df) - 1)) vol_10 = _safe_float( returns.tail(short_w).std() * np.sqrt(252) * 100, ) vol_20 = _safe_float( returns.tail(med_w).std() * np.sqrt(252) * 100, ) vol_60 = _safe_float( returns.tail(long_w).std() * np.sqrt(252) * 100, ) lines.append(f"{ticker}: ${signal.current_price:.2f}") lines.append( f" 10-day Vol: {vol_10:.1f}% | 20-day Vol: {vol_20:.1f}% | 60-day Vol: {vol_60:.1f}%", ) lines.append(f" Risk Level: {signal.risk_level}") lines.append("") return _to_text_response("\n".join(lines)) # ==================== Sentiment Analysis Tools ==================== @safe def analyze_insider_trading( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, start_date: Optional[str] = None, ) -> ToolResponse: """ Analyze insider trading activity. Tracks buying/selling by company insiders (executives, directors). Insider buying can signal confidence; selling may indicate concerns. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date. If None, uses date from context. start_date: Optional start date for lookback period. Returns: Text summary of insider trading activity for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== Insider Trading Analysis ({current_date}) ===\n"] for ticker in tickers: trades = get_insider_trades( ticker=ticker, end_date=current_date, start_date=start_date, limit=1000, ) if not trades: lines.append(f"{ticker}: No insider trading data\n") continue shares = pd.Series([t.transaction_shares for t in trades]).dropna() if len(shares) == 0: lines.append(f"{ticker}: {len(trades)} trades but no share data\n") continue buy_count = int((shares > 0).sum()) sell_count = int((shares < 0).sum()) buy_vol = float(shares[shares > 0].sum()) sell_vol = float(abs(shares[shares < 0].sum())) # Sentiment interpretation if buy_count > sell_count * 2: sentiment = "STRONG INSIDER BUYING" elif buy_count > sell_count: sentiment = "NET INSIDER BUYING" elif sell_count > buy_count * 2: sentiment = "STRONG INSIDER SELLING" elif sell_count > buy_count: sentiment = "NET INSIDER SELLING" else: sentiment = "MIXED INSIDER ACTIVITY" lines.append(f"{ticker}:") lines.append(f" Buys: {buy_count} trades ({buy_vol:,.0f} shares)") lines.append(f" Sells: {sell_count} trades ({sell_vol:,.0f} shares)") lines.append(f" Signal: {sentiment}") lines.append("") return _to_text_response("\n".join(lines)) @safe def analyze_news_sentiment( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, start_date: Optional[str] = None, ) -> ToolResponse: """ Analyze recent news for stocks. Retrieves and summarizes recent news articles. Use this to understand recent events and market sentiment. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date. If None, uses date from context. start_date: Optional start date for lookback period. Returns: Text summary of recent news for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) lines = [f"=== News Analysis ({current_date}) ===\n"] for ticker in tickers: news = get_company_news( ticker=ticker, end_date=current_date, start_date=start_date, limit=10, ) if not news: lines.append(f"{ticker}: No recent news\n") continue lines.append(f"{ticker} - {len(news)} recent articles:") for i, n in enumerate(news[:5], 1): date_str = n.date[:10] if n.date else "N/A" lines.append(f" {i}. [{date_str}] {n.title[:80]}...") lines.append(f" Source: {n.source}") if len(news) > 5: lines.append(f" ... and {len(news) - 5} more articles") lines.append("") return _to_text_response("\n".join(lines)) # ==================== Valuation Analysis Tools ==================== @safe def dcf_valuation_analysis( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Discounted Cash Flow (DCF) valuation analysis. Estimates intrinsic value by projecting future free cash flows. Positive value_gap indicates potential undervaluation. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date. If None, uses date from context. Returns: Text summary of DCF valuation for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) rows = [] for ticker in tickers: metrics = get_financial_metrics( ticker=ticker, end_date=current_date, limit=8, ) if not metrics: rows.append({"ticker": ticker, "error": "No financial metrics"}) continue line_items = search_line_items( ticker=ticker, line_items=["free_cash_flow"], end_date=current_date, period="ttm", limit=2, ) if ( not line_items or not line_items[0].free_cash_flow or line_items[0].free_cash_flow <= 0 ): rows.append({"ticker": ticker, "error": "Invalid free cash flow data"}) continue market_cap = get_market_cap(ticker, current_date) if not market_cap: rows.append({"ticker": ticker, "error": "Market cap unavailable"}) continue m = metrics[0] rows.append( { "ticker": ticker, "current_fcf": line_items[0].free_cash_flow, "growth_rate": m.earnings_growth or 0.05, "market_cap": market_cap, "discount_rate": 0.10, "terminal_growth": 0.03, "num_years": 5, }, ) return _to_text_response( _sandbox.execute_skill( skill_name="builtin/valuation_review", function_name="build_dcf_report", function_args={"rows": rows, "current_date": current_date}, ) ) @safe def owner_earnings_valuation_analysis( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Buffett-style owner earnings valuation analysis. Owner earnings = Net Income + D&A - CapEx - Working Capital Changes. Represents true cash owners could extract from the business. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date. If None, uses date from context. Returns: Text summary of owner earnings valuation for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) rows = [] for ticker in tickers: metrics = get_financial_metrics( ticker=ticker, end_date=current_date, limit=8, ) if not metrics: rows.append({"ticker": ticker, "error": "No financial metrics"}) continue line_items = search_line_items( ticker=ticker, line_items=[ "net_income", "depreciation_and_amortization", "capital_expenditure", "working_capital", ], end_date=current_date, period="ttm", limit=2, ) if len(line_items) < 2: rows.append({"ticker": ticker, "error": "Insufficient financial data"}) continue market_cap = get_market_cap(ticker, current_date) if not market_cap: rows.append({"ticker": ticker, "error": "Market cap unavailable"}) continue m = metrics[0] current, previous = line_items[0], line_items[1] net_income = current.net_income or 0 depreciation = current.depreciation_and_amortization or 0 capex = current.capital_expenditure or 0 wc_change = (current.working_capital or 0) - ( previous.working_capital or 0 ) owner_earnings = net_income + depreciation - capex - wc_change if owner_earnings <= 0: rows.append( { "ticker": ticker, "error": f"Negative owner earnings (${owner_earnings:,.0f})", }, ) continue rows.append( { "ticker": ticker, "owner_earnings": owner_earnings, "growth_rate": m.earnings_growth or 0.05, "market_cap": market_cap, "required_return": 0.15, "margin_of_safety": 0.25, "num_years": 5, }, ) return _to_text_response( _sandbox.execute_skill( skill_name="builtin/valuation_review", function_name="build_owner_earnings_report", function_args={"rows": rows, "current_date": current_date}, ) ) @safe def ev_ebitda_valuation_analysis( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ EV/EBITDA multiple valuation analysis. Compares current EV/EBITDA to historical median. Lower multiples relative to history may indicate undervaluation. Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date. If None, uses date from context. Returns: Text summary of EV/EBITDA valuation for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) rows = [] for ticker in tickers: metrics = get_financial_metrics( ticker=ticker, end_date=current_date, limit=8, ) if not metrics: rows.append({"ticker": ticker, "error": "No financial metrics"}) continue m = metrics[0] if ( not m.enterprise_value or not m.enterprise_value_to_ebitda_ratio or m.enterprise_value_to_ebitda_ratio <= 0 ): rows.append({"ticker": ticker, "error": "Missing EV/EBITDA data"}) continue market_cap = get_market_cap(ticker, current_date) if not market_cap: rows.append({"ticker": ticker, "error": "Market cap unavailable"}) continue current_ebitda = ( m.enterprise_value / m.enterprise_value_to_ebitda_ratio ) valid_multiples = [ x.enterprise_value_to_ebitda_ratio for x in metrics if x.enterprise_value_to_ebitda_ratio and x.enterprise_value_to_ebitda_ratio > 0 ] if len(valid_multiples) < 3: rows.append({"ticker": ticker, "error": "Insufficient historical data"}) continue rows.append( { "ticker": ticker, "current_multiple": m.enterprise_value_to_ebitda_ratio, "median_multiple": median(valid_multiples), "current_ebitda": current_ebitda, "market_cap": market_cap, "net_debt": m.enterprise_value - market_cap, }, ) return _to_text_response( _sandbox.execute_skill( skill_name="builtin/valuation_review", function_name="build_ev_ebitda_report", function_args={"rows": rows, "current_date": current_date}, ) ) @safe def residual_income_valuation_analysis( tickers: Optional[List[str]] = None, current_date: Optional[str] = None, ) -> ToolResponse: """ Residual Income Model (RIM) valuation analysis. Values company based on book value plus PV of future residual income. Residual income = Net Income - (Cost of Equity x Book Value). Args: tickers: List of stock tickers. If None, uses all tickers from context. current_date: Analysis date. If None, uses date from context. Returns: Text summary of residual income valuation for all tickers. """ current_date = _resolved_date(current_date) tickers = _parse_tickers(tickers) rows = [] for ticker in tickers: metrics = get_financial_metrics( ticker=ticker, end_date=current_date, limit=8, ) if not metrics: rows.append({"ticker": ticker, "error": "No financial metrics"}) continue line_items = search_line_items( ticker=ticker, line_items=["net_income"], end_date=current_date, period="ttm", limit=1, ) if not line_items or not line_items[0].net_income: rows.append({"ticker": ticker, "error": "No net income data"}) continue market_cap = get_market_cap(ticker, current_date) if not market_cap: rows.append({"ticker": ticker, "error": "Market cap unavailable"}) continue m = metrics[0] if not m.price_to_book_ratio or m.price_to_book_ratio <= 0: rows.append({"ticker": ticker, "error": "Invalid P/B ratio"}) continue net_income = line_items[0].net_income pb_ratio = m.price_to_book_ratio book_value = market_cap / pb_ratio cost_of_equity = 0.10 initial_ri = net_income - cost_of_equity * book_value if initial_ri <= 0: rows.append({"ticker": ticker, "error": "Negative residual income"}) continue rows.append( { "ticker": ticker, "book_value": book_value, "initial_ri": initial_ri, "market_cap": market_cap, "cost_of_equity": cost_of_equity, "bv_growth": m.book_value_growth or 0.03, "terminal_growth": 0.03, "num_years": 5, "margin_of_safety": 0.20, }, ) return _to_text_response( _sandbox.execute_skill( skill_name="builtin/valuation_review", function_name="build_residual_income_report", function_args={"rows": rows, "current_date": current_date}, ) ) # Tool Registry for dynamic toolkit creation TOOL_REGISTRY = { "analyze_efficiency_ratios": analyze_efficiency_ratios, "analyze_profitability": analyze_profitability, "analyze_growth": analyze_growth, "analyze_financial_health": analyze_financial_health, "analyze_valuation_ratios": analyze_valuation_ratios, "get_financial_metrics_tool": get_financial_metrics_tool, "analyze_trend_following": analyze_trend_following, "analyze_mean_reversion": analyze_mean_reversion, "analyze_momentum": analyze_momentum, "analyze_volatility": analyze_volatility, "analyze_insider_trading": analyze_insider_trading, "analyze_news_sentiment": analyze_news_sentiment, "dcf_valuation_analysis": dcf_valuation_analysis, "owner_earnings_valuation_analysis": owner_earnings_valuation_analysis, "ev_ebitda_valuation_analysis": ev_ebitda_valuation_analysis, "residual_income_valuation_analysis": residual_income_valuation_analysis, }