TradingAgents/tradingagents/portfolio/portfolio_graph.py

412 lines
15 KiB
Python

"""Portfolio analysis graph coordinator."""
import concurrent.futures
from typing import Dict, List, Any, Tuple
import yfinance as yf
from datetime import datetime
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.portfolio.models import Portfolio, Position, PortfolioAnalysisResult
from tradingagents.portfolio.metrics import calculate_portfolio_metrics
from tradingagents.default_config import DEFAULT_CONFIG
class PortfolioAnalysisGraph:
"""Coordinates portfolio-level analysis across multiple stocks."""
def __init__(
self,
selected_analysts=["market", "social", "news", "fundamentals"],
debug=False,
config: Dict[str, Any] = None,
):
"""
Initialize portfolio analysis graph.
Args:
selected_analysts: List of analyst types to use
debug: Whether to enable debug mode
config: Configuration dictionary
"""
self.debug = debug
self.config = config or DEFAULT_CONFIG
self.selected_analysts = selected_analysts
# We'll create individual trading graphs per stock as needed
self.trading_graphs: Dict[str, TradingAgentsGraph] = {}
def _get_current_prices(self, tickers: List[str], analysis_date: str) -> Dict[str, float]:
"""
Fetch current prices for tickers.
Args:
tickers: List of ticker symbols
analysis_date: Analysis date (YYYY-MM-DD)
Returns:
Dictionary mapping tickers to prices
"""
prices = {}
for ticker in tickers:
try:
stock = yf.Ticker(ticker)
# Get the historical data up to analysis date
hist = stock.history(period="5d", end=analysis_date)
if not hist.empty:
prices[ticker] = float(hist['Close'].iloc[-1])
else:
print(f"Warning: No price data for {ticker}")
prices[ticker] = None
except Exception as e:
print(f"Error fetching price for {ticker}: {e}")
prices[ticker] = None
return prices
def _analyze_single_stock(
self,
ticker: str,
analysis_date: str
) -> Tuple[str, Dict[str, Any]]:
"""
Analyze a single stock using the trading agents framework.
Args:
ticker: Stock ticker symbol
analysis_date: Analysis date
Returns:
Tuple of (ticker, analysis_result)
"""
try:
print(f"\nAnalyzing {ticker}...")
# Create a custom config for this stock to avoid memory collection conflicts
stock_config = self.config.copy()
# Add a unique memory prefix to avoid ChromaDB collection name conflicts
import time
unique_id = f"{ticker}_{int(time.time() * 1000000)}"
stock_config["memory_prefix"] = unique_id
# Use ticker-specific data cache directory to avoid conflicts
import os
stock_data_cache = os.path.join(
stock_config["data_cache_dir"],
f"portfolio_{ticker}"
)
stock_config["data_cache_dir"] = stock_data_cache
os.makedirs(stock_data_cache, exist_ok=True)
# Create a trading graph for this stock
ta = TradingAgentsGraph(
selected_analysts=self.selected_analysts,
debug=self.debug,
config=stock_config
)
# Run the analysis
final_state, decision = ta.propagate(ticker, analysis_date)
return (ticker, {
'final_state': final_state,
'decision': decision,
'success': True
})
except Exception as e:
print(f"Error analyzing {ticker}: {e}")
return (ticker, {
'error': str(e),
'success': False
})
def analyze_portfolio(
self,
portfolio: Portfolio,
max_workers: int = 3
) -> PortfolioAnalysisResult:
"""
Analyze the entire portfolio.
Args:
portfolio: Portfolio object with positions
max_workers: Maximum number of parallel workers for stock analysis
Returns:
PortfolioAnalysisResult with complete analysis
"""
print(f"\n{'='*60}")
print(f"Starting Portfolio Analysis: {portfolio.name}")
print(f"Analysis Date: {portfolio.analysis_date}")
print(f"Positions: {', '.join(portfolio.tickers)}")
print(f"{'='*60}\n")
# Step 1: Fetch current prices and update portfolio
print("Fetching current prices...")
current_prices = self._get_current_prices(
portfolio.tickers,
portfolio.analysis_date
)
for ticker, price in current_prices.items():
if price is not None:
portfolio.positions[ticker].current_price = price
# Display portfolio summary
print(f"\nPortfolio Summary:")
print(f" Total Cost Basis: ${portfolio.total_cost_basis:,.2f}")
if portfolio.total_market_value:
print(f" Total Market Value: ${portfolio.total_market_value:,.2f}")
print(f" Unrealized P/L: ${portfolio.total_unrealized_gain_loss:,.2f} "
f"({portfolio.total_unrealized_gain_loss_pct:+.2f}%)")
print()
# Step 2: Calculate portfolio metrics
print("Calculating portfolio metrics...")
portfolio_metrics = calculate_portfolio_metrics(portfolio)
# Step 3: Analyze individual stocks in parallel
print(f"\nAnalyzing {len(portfolio.tickers)} stocks in parallel...")
individual_analyses = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all analysis tasks
future_to_ticker = {
executor.submit(
self._analyze_single_stock,
ticker,
portfolio.analysis_date
): ticker
for ticker in portfolio.tickers
}
# Collect results as they complete
for future in concurrent.futures.as_completed(future_to_ticker):
ticker, result = future.result()
individual_analyses[ticker] = result
if result.get('success'):
print(f"✓ Completed analysis for {ticker}")
else:
print(f"✗ Failed analysis for {ticker}: {result.get('error')}")
# Step 4: Generate portfolio-level recommendations
print("\nGenerating portfolio-level insights...")
portfolio_recommendation = self._generate_portfolio_recommendation(
portfolio,
portfolio_metrics,
individual_analyses
)
# Step 5: Generate rebalancing suggestions
rebalancing_suggestions = self._generate_rebalancing_suggestions(
portfolio,
portfolio_metrics,
individual_analyses
)
# Step 6: Generate risk assessment
risk_assessment = self._generate_risk_assessment(
portfolio,
portfolio_metrics,
individual_analyses
)
print(f"\n{'='*60}")
print("Portfolio Analysis Complete!")
print(f"{'='*60}\n")
return PortfolioAnalysisResult(
portfolio=portfolio,
individual_analyses=individual_analyses,
portfolio_metrics=portfolio_metrics,
portfolio_recommendation=portfolio_recommendation,
rebalancing_suggestions=rebalancing_suggestions,
risk_assessment=risk_assessment
)
def _generate_portfolio_recommendation(
self,
portfolio: Portfolio,
metrics: Dict,
analyses: Dict[str, Dict]
) -> str:
"""Generate overall portfolio recommendation."""
lines = []
lines.append("# Portfolio Overview")
lines.append("")
# Summarize individual recommendations
buy_count = 0
sell_count = 0
hold_count = 0
for ticker, analysis in analyses.items():
if not analysis.get('success'):
continue
decision = analysis.get('decision', '').upper()
weight = portfolio.get_position_weights()[ticker]
if 'BUY' in decision:
buy_count += 1
elif 'SELL' in decision:
sell_count += 1
else:
hold_count += 1
lines.append(f"**Individual Stock Recommendations:**")
lines.append(f"- Buy signals: {buy_count}")
lines.append(f"- Hold signals: {hold_count}")
lines.append(f"- Sell signals: {sell_count}")
lines.append("")
# Portfolio metrics summary
if 'diversification_score' in metrics:
div_score = metrics['diversification_score']
lines.append(f"**Diversification Score:** {div_score:.2f}/1.00")
if div_score < 0.5:
lines.append("⚠️ Portfolio shows high correlation - consider diversifying")
lines.append("")
if 'portfolio_beta' in metrics:
beta = metrics['portfolio_beta']
lines.append(f"**Portfolio Beta:** {beta:.2f}")
if beta > 1.2:
lines.append(" - Portfolio is more volatile than market")
elif beta < 0.8:
lines.append(" - Portfolio is less volatile than market")
lines.append("")
if 'sharpe_ratio' in metrics:
sharpe = metrics['sharpe_ratio']
lines.append(f"**Sharpe Ratio:** {sharpe:.2f}")
if sharpe > 1:
lines.append(" - Good risk-adjusted returns")
elif sharpe < 0:
lines.append(" - Negative risk-adjusted returns")
lines.append("")
# Sector concentration
if 'sector_weights' in metrics:
lines.append("**Sector Allocation:**")
for sector, weight in sorted(
metrics['sector_weights'].items(),
key=lambda x: x[1],
reverse=True
):
lines.append(f"- {sector}: {weight:.1f}%")
lines.append("")
# Check for over-concentration
max_sector_weight = max(metrics['sector_weights'].values())
if max_sector_weight > 50:
lines.append(f"⚠️ High concentration in {max(metrics['sector_weights'], key=metrics['sector_weights'].get)} "
f"({max_sector_weight:.1f}%) - consider diversifying")
lines.append("")
return "\n".join(lines)
def _generate_rebalancing_suggestions(
self,
portfolio: Portfolio,
metrics: Dict,
analyses: Dict[str, Dict]
) -> List[Dict]:
"""Generate rebalancing suggestions."""
suggestions = []
weights = portfolio.get_position_weights()
# Check for over-concentration
for ticker, weight in weights.items():
if weight > 30:
suggestions.append({
'type': 'REDUCE',
'ticker': ticker,
'current_weight': weight,
'reason': f'Position represents {weight:.1f}% of portfolio - consider reducing for better diversification'
})
if weight < 5 and len(portfolio.tickers) > 5:
suggestions.append({
'type': 'EVALUATE',
'ticker': ticker,
'current_weight': weight,
'reason': f'Small position ({weight:.1f}%) - consider consolidating or increasing'
})
# Check individual stock recommendations
for ticker, analysis in analyses.items():
if not analysis.get('success'):
continue
decision = analysis.get('decision', '').upper()
if 'SELL' in decision:
suggestions.append({
'type': 'CONSIDER_SELL',
'ticker': ticker,
'current_weight': weights[ticker],
'reason': 'Individual analysis suggests SELL'
})
return suggestions
def _generate_risk_assessment(
self,
portfolio: Portfolio,
metrics: Dict,
analyses: Dict[str, Dict]
) -> str:
"""Generate risk assessment for portfolio."""
lines = []
lines.append("# Portfolio Risk Assessment")
lines.append("")
# Volatility
if 'portfolio_volatility' in metrics:
vol = metrics['portfolio_volatility'] * 100
lines.append(f"**Portfolio Volatility:** {vol:.1f}% (annualized)")
if vol > 25:
lines.append(" - High volatility portfolio")
elif vol < 15:
lines.append(" - Low volatility portfolio")
lines.append("")
# Correlation risk
if 'diversification_score' in metrics:
div_score = metrics['diversification_score']
if div_score < 0.5:
lines.append("**Correlation Risk:** HIGH")
lines.append(" - Positions are highly correlated")
lines.append(" - Portfolio may not benefit from diversification during market stress")
else:
lines.append("**Correlation Risk:** LOW")
lines.append(" - Good diversification across positions")
lines.append("")
# Concentration risk
weights = portfolio.get_position_weights()
max_weight = max(weights.values())
if max_weight > 30:
max_ticker = max(weights, key=weights.get)
lines.append("**Concentration Risk:** HIGH")
lines.append(f" - {max_ticker} represents {max_weight:.1f}% of portfolio")
lines.append(" - Consider reducing position size")
else:
lines.append("**Concentration Risk:** LOW")
lines.append(" - Well-balanced position sizing")
lines.append("")
# Sector concentration risk
if 'sector_weights' in metrics:
max_sector_weight = max(metrics['sector_weights'].values())
if max_sector_weight > 50:
max_sector = max(metrics['sector_weights'], key=metrics['sector_weights'].get)
lines.append("**Sector Concentration Risk:** HIGH")
lines.append(f" - {max_sector} sector represents {max_sector_weight:.1f}%")
lines.append(" - Consider adding exposure to other sectors")
else:
lines.append("**Sector Concentration Risk:** MODERATE")
lines.append("")
return "\n".join(lines)