Files
evotraders/backend/tools/analysis_tools.py
cillin 12de93aa30 feat: initial commit - EvoTraders project
量化交易多智能体系统,包含:
- 分析师、投资组合经理、风险经理等智能体
- 股票分析、投资组合管理、风险控制工具
- React 前端界面
- FastAPI 后端服务

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-13 04:34:06 +08:00

1290 lines
41 KiB
Python

# -*- 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 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.tools.data_tools import (
get_company_news,
get_financial_metrics,
get_insider_trades,
get_market_cap,
get_prices,
prices_to_df,
search_line_items,
)
logger = logging.getLogger(__name__)
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.
"""
if tickers is None:
return []
if isinstance(tickers, str):
try:
parsed = json.loads(tickers)
if isinstance(parsed, list):
return parsed
# If it's a single string, wrap in list
return [parsed]
except json.JSONDecodeError:
# If not valid JSON, treat as comma-separated string
return [t.strip() for t in tickers.split(",") if t.strip()]
if isinstance(tickers, list):
return tickers
return []
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 tool functions."""
@wraps(func)
def 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 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."""
return current_date or datetime.today().strftime("%Y-%m-%d")
# ==================== Fundamental Analysis Tools ====================
@safe
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"]
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" Asset Turnover: {_fmt(m.asset_turnover)}")
lines.append(f" Inventory Turnover: {_fmt(m.inventory_turnover)}")
lines.append(f" Receivables Turnover: {_fmt(m.receivables_turnover)}")
lines.append(
f" Working Capital Turnover: {_fmt(m.working_capital_turnover)}",
)
lines.append("")
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
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"]
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" P/E Ratio: {_fmt(m.price_to_earnings_ratio)}")
lines.append(f" P/B Ratio: {_fmt(m.price_to_book_ratio)}")
lines.append(f" P/S Ratio: {_fmt(m.price_to_sales_ratio)}")
lines.append("")
return _to_text_response("\n".join(lines))
@safe
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)
lines = [
f"=== Comprehensive Financial Metrics ({current_date}, {period}) ===\n",
]
for ticker in tickers:
metrics = get_financial_metrics(
ticker=ticker,
end_date=current_date,
period=period,
)
if not metrics:
lines.append(f"{ticker}: No data available\n")
continue
m = metrics[0]
lines.append(f"{ticker}:")
lines.append(f" Market Cap: ${_fmt(m.market_cap, ',.0f')}")
lines.append(
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)}",
)
lines.append(
f" ROE: {_fmt(m.return_on_equity, '.1%')} | Net Margin: {_fmt(m.net_margin, '.1%')}",
)
lines.append(
f" Revenue Growth: {_fmt(m.revenue_growth, '.1%')} | Earnings Growth: {_fmt(m.earnings_growth, '.1%')}",
)
lines.append(
f" Current Ratio: {_fmt(m.current_ratio)} | D/E: {_fmt(m.debt_to_equity)}",
)
lines.append(
f" EPS: ${_fmt(m.earnings_per_share)} | FCF/Share: ${_fmt(m.free_cash_flow_per_share)}",
)
lines.append("")
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
df = prices_to_df(prices)
n = len(df)
# Calculate moving averages
sma_20_win = min(20, n // 2)
sma_50_win = min(50, n - 5) if n > 25 else min(25, n - 5)
sma_200_win = min(200, n - 10) if n > 200 else None
df["SMA_20"] = df["close"].rolling(window=sma_20_win).mean()
df["SMA_50"] = df["close"].rolling(window=sma_50_win).mean()
if sma_200_win:
df["SMA_200"] = df["close"].rolling(window=sma_200_win).mean()
df["EMA_12"] = df["close"].ewm(span=min(12, n // 3)).mean()
df["EMA_26"] = df["close"].ewm(span=min(26, n // 2)).mean()
df["MACD"] = df["EMA_12"] - df["EMA_26"]
df["MACD_signal"] = df["MACD"].ewm(span=9).mean()
current_price = _safe_float(df["close"].iloc[-1])
sma_20 = _safe_float(df["SMA_20"].iloc[-1])
sma_50 = _safe_float(df["SMA_50"].iloc[-1])
sma_200 = (
_safe_float(df["SMA_200"].iloc[-1])
if "SMA_200" in df.columns
else None
)
macd = _safe_float(df["MACD"].iloc[-1])
macd_signal = _safe_float(df["MACD_signal"].iloc[-1])
# Determine trend
if sma_200:
trend = "BULLISH" if current_price > sma_200 else "BEARISH"
distance_200ma = ((current_price - sma_200) / sma_200) * 100
else:
trend = "UNKNOWN"
distance_200ma = None
macd_signal_str = "BUY" if macd > macd_signal else "SELL"
lines.append(f"{ticker}: ${current_price:.2f}")
lines.append(
f" SMA20: ${sma_20:.2f} | SMA50: ${sma_50:.2f} | SMA200: {f'${sma_200:.2f}' if sma_200 else 'N/A'}",
)
lines.append(
f" MACD: {macd:.3f} | Signal: {macd_signal:.3f} -> {macd_signal_str}",
)
lines.append(
f" Long-term Trend: {trend}"
+ (
f" ({distance_200ma:+.1f}% from 200MA)"
if distance_200ma
else ""
),
)
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
df = prices_to_df(prices)
n = len(df)
# Bollinger Bands
window = min(20, n - 2)
df["SMA"] = df["close"].rolling(window=window).mean()
df["STD"] = df["close"].rolling(window=window).std()
df["Upper_Band"] = df["SMA"] + (2 * df["STD"])
df["Lower_Band"] = df["SMA"] - (2 * df["STD"])
# RSI
delta = df["close"].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
rs = gain / loss
df["RSI"] = 100 - (100 / (1 + rs))
current_price = _safe_float(df["close"].iloc[-1])
sma = _safe_float(df["SMA"].iloc[-1])
upper = _safe_float(df["Upper_Band"].iloc[-1])
lower = _safe_float(df["Lower_Band"].iloc[-1])
rsi = _safe_float(df["RSI"].iloc[-1])
deviation = (current_price - sma) / sma * 100
# Signal interpretation
if rsi > 70:
rsi_signal = "OVERBOUGHT"
elif rsi < 30:
rsi_signal = "OVERSOLD"
else:
rsi_signal = "NEUTRAL"
if current_price > upper:
bb_signal = "ABOVE UPPER BAND (potential sell)"
elif current_price < lower:
bb_signal = "BELOW LOWER BAND (potential buy)"
else:
bb_signal = "WITHIN BANDS"
lines.append(f"{ticker}: ${current_price:.2f}")
lines.append(
f" Bollinger: Lower ${lower:.2f} | SMA ${sma:.2f} | Upper ${upper:.2f}",
)
lines.append(f" Position: {bb_signal}")
lines.append(f" RSI: {rsi:.1f} -> {rsi_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
df = prices_to_df(prices)
n = len(df)
df["returns"] = df["close"].pct_change()
# Adaptive periods
short_p = min(5, n // 3)
med_p = min(10, n // 2)
long_p = min(20, n - 2)
current_price = _safe_float(df["close"].iloc[-1])
mom_5 = (
_safe_float(
(df["close"].iloc[-1] / df["close"].iloc[-short_p - 1] - 1)
* 100,
)
if n > short_p
else 0
)
mom_10 = (
_safe_float(
(df["close"].iloc[-1] / df["close"].iloc[-med_p - 1] - 1)
* 100,
)
if n > med_p
else 0
)
mom_20 = (
_safe_float(
(df["close"].iloc[-1] / df["close"].iloc[-long_p - 1] - 1)
* 100,
)
if n > long_p
else 0
)
volatility = _safe_float(
df["returns"].tail(20).std() * np.sqrt(252) * 100,
)
# Overall momentum signal
avg_mom = (mom_5 + mom_10 + mom_20) / 3
if avg_mom > 2:
signal = "STRONG BULLISH"
elif avg_mom > 0:
signal = "BULLISH"
elif avg_mom > -2:
signal = "BEARISH"
else:
signal = "STRONG BEARISH"
lines.append(f"{ticker}: ${current_price:.2f}")
lines.append(
f" 5-day: {mom_5:+.1f}% | 10-day: {mom_10:+.1f}% | 20-day: {mom_20:+.1f}%",
)
lines.append(f" Volatility (annualized): {volatility:.1f}%")
lines.append(f" Overall: {signal}")
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)
n = len(df)
df["returns"] = df["close"].pct_change()
# Adaptive windows
short_w = min(10, n // 2)
med_w = min(20, n - 2)
long_w = min(60, n - 1) if n > 30 else med_w
current_price = _safe_float(df["close"].iloc[-1])
vol_10 = _safe_float(
df["returns"].tail(short_w).std() * np.sqrt(252) * 100,
)
vol_20 = _safe_float(
df["returns"].tail(med_w).std() * np.sqrt(252) * 100,
)
vol_60 = _safe_float(
df["returns"].tail(long_w).std() * np.sqrt(252) * 100,
)
# Risk assessment
if vol_20 > 50:
risk = "HIGH RISK"
elif vol_20 > 25:
risk = "MODERATE RISK"
else:
risk = "LOW RISK"
lines.append(f"{ticker}: ${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: {risk}")
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)
lines = [f"=== DCF Valuation Analysis ({current_date}) ===\n"]
for ticker in tickers:
metrics = get_financial_metrics(
ticker=ticker,
end_date=current_date,
limit=8,
)
if not metrics:
lines.append(f"{ticker}: No financial metrics\n")
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
):
lines.append(f"{ticker}: Invalid free cash flow data\n")
continue
market_cap = get_market_cap(ticker, current_date)
if not market_cap:
lines.append(f"{ticker}: Market cap unavailable\n")
continue
m = metrics[0]
current_fcf = line_items[0].free_cash_flow
growth_rate = m.earnings_growth or 0.05
discount_rate = 0.10
terminal_growth = 0.03
num_years = 5
# DCF calculation
pv_fcf = sum(
current_fcf
* (1 + growth_rate) ** year
/ (1 + discount_rate) ** year
for year in range(1, num_years + 1)
)
terminal_fcf = (
current_fcf
* (1 + growth_rate) ** num_years
* (1 + terminal_growth)
)
terminal_value = terminal_fcf / (discount_rate - terminal_growth)
pv_terminal = terminal_value / (1 + discount_rate) ** num_years
enterprise_value = pv_fcf + pv_terminal
value_gap = (enterprise_value - market_cap) / market_cap * 100
# Assessment
if value_gap > 20:
assessment = "SIGNIFICANTLY UNDERVALUED"
elif value_gap > 0:
assessment = "POTENTIALLY UNDERVALUED"
elif value_gap > -20:
assessment = "POTENTIALLY OVERVALUED"
else:
assessment = "SIGNIFICANTLY OVERVALUED"
lines.append(f"{ticker}:")
lines.append(f" Current FCF: ${current_fcf:,.0f}")
lines.append(f" DCF Enterprise Value: ${enterprise_value:,.0f}")
lines.append(f" Market Cap: ${market_cap:,.0f}")
lines.append(f" Value Gap: {value_gap:+.1f}% -> {assessment}")
lines.append("")
return _to_text_response("\n".join(lines))
@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)
lines = [f"=== Owner Earnings Valuation ({current_date}) ===\n"]
for ticker in tickers:
metrics = get_financial_metrics(
ticker=ticker,
end_date=current_date,
limit=8,
)
if not metrics:
lines.append(f"{ticker}: No financial metrics\n")
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:
lines.append(f"{ticker}: Insufficient financial data\n")
continue
market_cap = get_market_cap(ticker, current_date)
if not market_cap:
lines.append(f"{ticker}: Market cap unavailable\n")
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:
lines.append(
f"{ticker}: Negative owner earnings (${owner_earnings:,.0f})\n",
)
continue
# Valuation
growth_rate = m.earnings_growth or 0.05
required_return = 0.15
margin_of_safety = 0.25
num_years = 5
pv_earnings = sum(
owner_earnings
* (1 + growth_rate) ** year
/ (1 + required_return) ** year
for year in range(1, num_years + 1)
)
terminal_growth = min(growth_rate, 0.03)
terminal_earnings = (
owner_earnings
* (1 + growth_rate) ** num_years
* (1 + terminal_growth)
)
terminal_value = terminal_earnings / (
required_return - terminal_growth
)
pv_terminal = terminal_value / (1 + required_return) ** num_years
intrinsic_value = (pv_earnings + pv_terminal) * (1 - margin_of_safety)
value_gap = (intrinsic_value - market_cap) / market_cap * 100
# Assessment
if value_gap > 20:
assessment = "SIGNIFICANTLY UNDERVALUED"
elif value_gap > 0:
assessment = "POTENTIALLY UNDERVALUED"
elif value_gap > -20:
assessment = "POTENTIALLY OVERVALUED"
else:
assessment = "SIGNIFICANTLY OVERVALUED"
lines.append(f"{ticker}:")
lines.append(f" Owner Earnings: ${owner_earnings:,.0f}")
lines.append(
f" Intrinsic Value (w/ 25% MoS): ${intrinsic_value:,.0f}",
)
lines.append(f" Market Cap: ${market_cap:,.0f}")
lines.append(f" Value Gap: {value_gap:+.1f}% -> {assessment}")
lines.append("")
return _to_text_response("\n".join(lines))
@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)
lines = [f"=== EV/EBITDA Valuation ({current_date}) ===\n"]
for ticker in tickers:
metrics = get_financial_metrics(
ticker=ticker,
end_date=current_date,
limit=8,
)
if not metrics:
lines.append(f"{ticker}: No financial metrics\n")
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
):
lines.append(f"{ticker}: Missing EV/EBITDA data\n")
continue
market_cap = get_market_cap(ticker, current_date)
if not market_cap:
lines.append(f"{ticker}: Market cap unavailable\n")
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:
lines.append(f"{ticker}: Insufficient historical data\n")
continue
median_multiple = median(valid_multiples)
current_multiple = m.enterprise_value_to_ebitda_ratio
implied_ev = median_multiple * current_ebitda
net_debt = m.enterprise_value - market_cap
implied_equity = max(implied_ev - net_debt, 0)
value_gap = (
(implied_equity - market_cap) / market_cap * 100
if market_cap > 0
else 0
)
multiple_discount = (
(median_multiple - current_multiple) / median_multiple * 100
)
# Assessment
if multiple_discount > 10:
assessment = "TRADING BELOW HISTORICAL MULTIPLE"
elif multiple_discount > -10:
assessment = "NEAR HISTORICAL AVERAGE"
else:
assessment = "TRADING ABOVE HISTORICAL MULTIPLE"
lines.append(f"{ticker}:")
lines.append(f" Current EV/EBITDA: {current_multiple:.1f}x")
lines.append(f" Historical Median: {median_multiple:.1f}x")
lines.append(f" Multiple vs History: {multiple_discount:+.1f}%")
lines.append(f" Implied Equity Value: ${implied_equity:,.0f}")
lines.append(f" Value Gap: {value_gap:+.1f}% -> {assessment}")
lines.append("")
return _to_text_response("\n".join(lines))
@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)
lines = [f"=== Residual Income Valuation ({current_date}) ===\n"]
for ticker in tickers:
metrics = get_financial_metrics(
ticker=ticker,
end_date=current_date,
limit=8,
)
if not metrics:
lines.append(f"{ticker}: No financial metrics\n")
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:
lines.append(f"{ticker}: No net income data\n")
continue
market_cap = get_market_cap(ticker, current_date)
if not market_cap:
lines.append(f"{ticker}: Market cap unavailable\n")
continue
m = metrics[0]
if not m.price_to_book_ratio or m.price_to_book_ratio <= 0:
lines.append(f"{ticker}: Invalid P/B ratio\n")
continue
net_income = line_items[0].net_income
pb_ratio = m.price_to_book_ratio
book_value = market_cap / pb_ratio
# Model parameters
cost_of_equity = 0.10
bv_growth = m.book_value_growth or 0.03
terminal_growth = 0.03
num_years = 5
margin_of_safety = 0.20
initial_ri = net_income - cost_of_equity * book_value
if initial_ri <= 0:
lines.append(f"{ticker}: Negative residual income\n")
continue
# PV calculation
pv_ri = sum(
initial_ri * (1 + bv_growth) ** year / (1 + cost_of_equity) ** year
for year in range(1, num_years + 1)
)
terminal_ri = initial_ri * (1 + bv_growth) ** (num_years + 1)
terminal_value = terminal_ri / (cost_of_equity - terminal_growth)
pv_terminal = terminal_value / (1 + cost_of_equity) ** num_years
intrinsic_value = (book_value + pv_ri + pv_terminal) * (
1 - margin_of_safety
)
value_gap = (intrinsic_value - market_cap) / market_cap * 100
lines.append(f"{ticker}:")
lines.append(f" Book Value: ${book_value:,.0f}")
lines.append(f" Residual Income: ${initial_ri:,.0f}")
lines.append(
f" Intrinsic Value (w/ 20% MoS): ${intrinsic_value:,.0f}",
)
lines.append(f" Value Gap: {value_gap:+.1f}%")
lines.append("")
return _to_text_response("\n".join(lines))
# 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,
}