Migrate all agent roles from Legacy to EvoAgent architecture: - fundamentals_analyst, technical_analyst, sentiment_analyst, valuation_analyst - risk_manager, portfolio_manager Key changes: - EvoAgent now supports Portfolio Manager compatibility methods (_make_decision, get_decisions, get_portfolio_state, load_portfolio_state, update_portfolio) - Add UnifiedAgentFactory for centralized agent creation - ToolGuard with batch approval API and WebSocket broadcast - Legacy agents marked deprecated (AnalystAgent, RiskAgent, PMAgent) - Remove backend/agents/compat.py migration shim - Add run_id alongside workspace_id for semantic clarity - Complete integration test coverage (13 tests) - All smoke tests passing for 6 agent roles Constraint: Must maintain backward compatibility with existing run configs Constraint: Memory support must work with EvoAgent (no fallback to Legacy) Rejected: Separate PM implementation for EvoAgent | unified approach cleaner Confidence: high Scope-risk: broad Directive: EVO_AGENT_IDS env var still respected but defaults to all roles Not-tested: Kubernetes sandbox mode for skill execution
1155 lines
36 KiB
Python
1155 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.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 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(
|
|
_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,
|
|
}
|