Files
evotraders/backend/tools/analysis_tools.py
2026-03-30 17:46:44 +08:00

1139 lines
36 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 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.skills.builtin.valuation_review.scripts.dcf_report import (
build_dcf_report,
)
from backend.skills.builtin.valuation_review.scripts.multiple_valuation_report import (
build_ev_ebitda_report,
build_residual_income_report,
)
from backend.skills.builtin.valuation_review.scripts.owner_earnings_report import (
build_owner_earnings_report,
)
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.technical_signals import StockTechnicalAnalyzer
logger = logging.getLogger(__name__)
_technical_analyzer = StockTechnicalAnalyzer()
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 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."""
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
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)
tickers = _parse_tickers(tickers)
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
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(build_dcf_report(rows, 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(build_owner_earnings_report(rows, 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(build_ev_ebitda_report(rows, 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(build_residual_income_report(rows, 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,
}