diff --git a/PORTFOLIO_ANALYSIS.md b/PORTFOLIO_ANALYSIS.md
new file mode 100644
index 00000000..1c63d63f
--- /dev/null
+++ b/PORTFOLIO_ANALYSIS.md
@@ -0,0 +1,263 @@
+# Portfolio Analysis Feature
+
+## Overview
+
+The Portfolio Analysis feature extends TradingAgents to analyze entire portfolios of stocks, providing comprehensive insights on diversification, risk, correlation, and rebalancing recommendations.
+
+## Features
+
+### Core Capabilities
+- **Multi-stock parallel analysis**: Analyze multiple stocks concurrently for faster results
+- **Portfolio-level metrics**: Calculate correlation, beta, volatility, Sharpe ratio, and diversification scores
+- **Risk assessment**: Identify concentration risks, sector exposure, and correlation risks
+- **Rebalancing suggestions**: AI-powered recommendations for portfolio optimization
+- **Comprehensive PDF reports**: Visual charts including allocation pie charts, correlation heatmaps, sector breakdown, and performance graphs
+
+### Analysis Components
+
+#### Individual Stock Analysis
+Each position in your portfolio is analyzed using the full TradingAgents framework:
+- Market Analyst
+- Sentiment Analyst (optional)
+- News Analyst (optional)
+- Fundamentals Analyst (optional)
+- Research Team debate
+- Trading recommendations
+- Risk management review
+
+#### Portfolio-Level Analysis
+- **Correlation Matrix**: Understand how your positions move together
+- **Sector Diversification**: See your exposure across different sectors
+- **Position Weights**: Identify over/under-weighted positions
+- **Performance Metrics**: Beta, volatility, Sharpe ratio, max drawdown
+- **Risk Concentration**: Warnings for concentrated positions or sectors
+
+## Usage
+
+### Command Line Interface
+
+#### Option 1: Interactive CLI
+```bash
+python -m cli.main analyze-portfolio
+```
+
+The CLI will prompt you to enter:
+1. Portfolio name
+2. Analysis date
+3. Your positions (ticker, shares, average cost)
+4. Analyst selection
+5. Research depth
+6. LLM settings
+
+#### Option 2: Programmatic Usage
+```python
+from tradingagents.portfolio.models import Portfolio, Position
+from tradingagents.portfolio.portfolio_graph import PortfolioAnalysisGraph
+from tradingagents.default_config import DEFAULT_CONFIG
+
+# Define your positions
+positions = {
+ "AAPL": Position(ticker="AAPL", shares=100, avg_cost=150.00),
+ "MSFT": Position(ticker="MSFT", shares=50, avg_cost=300.00),
+ "NVDA": Position(ticker="NVDA", shares=75, avg_cost=450.00),
+}
+
+# Create portfolio
+portfolio = Portfolio(
+ positions=positions,
+ analysis_date="2024-12-01",
+ name="My Portfolio"
+)
+
+# Configure analysis
+config = DEFAULT_CONFIG.copy()
+config["max_debate_rounds"] = 1
+config["quick_think_llm"] = "gpt-4o-mini"
+config["deep_think_llm"] = "gpt-4o-mini"
+
+# Initialize and run analysis
+portfolio_graph = PortfolioAnalysisGraph(
+ selected_analysts=["market", "fundamentals"],
+ debug=True,
+ config=config
+)
+
+result = portfolio_graph.analyze_portfolio(portfolio)
+
+# Access results
+print(result.portfolio_recommendation)
+print(result.risk_assessment)
+print(result.rebalancing_suggestions)
+```
+
+### Test the Feature
+
+Run the included test script:
+```bash
+python test_portfolio_analysis.py
+```
+
+## Output
+
+### Console Output
+- Real-time progress updates for each stock analysis
+- Portfolio summary (value, P/L, allocations)
+- Portfolio metrics (beta, volatility, Sharpe ratio)
+- Risk assessment
+- Rebalancing suggestions
+
+### Saved Files
+
+Results are saved in `results/portfolio/{analysis_date}/`:
+- `portfolio_analysis.json`: Complete analysis results in JSON format
+- `portfolio_analysis_{date}.pdf`: Comprehensive PDF report with charts
+
+### PDF Report Sections
+
+1. **Cover Page**: Portfolio summary, total value, P/L
+2. **Portfolio Visualizations**:
+ - Allocation pie chart
+ - Position performance bar chart
+ - Sector allocation bar chart
+ - Correlation heatmap
+3. **Portfolio Overview**: Summary and recommendations
+4. **Risk Assessment**: Detailed risk analysis
+5. **Rebalancing Suggestions**: Specific recommendations
+6. **Individual Stock Analyses**: Detailed breakdown for each position
+
+## Configuration
+
+### Performance Optimization
+
+Adjust `max_workers` for parallel processing:
+```python
+result = portfolio_graph.analyze_portfolio(portfolio, max_workers=3)
+```
+- Higher values = faster but more API calls
+- Recommended: 2-4 workers
+
+### Cost Optimization
+
+Use cheaper LLMs for testing:
+```python
+config["quick_think_llm"] = "gpt-4o-mini"
+config["deep_think_llm"] = "gpt-4o-mini"
+config["max_debate_rounds"] = 1
+```
+
+Select fewer analysts:
+```python
+selected_analysts=["market", "fundamentals"] # Instead of all 4
+```
+
+## Requirements
+
+### Python Packages
+```bash
+pip install reportlab matplotlib seaborn
+```
+
+All requirements are in `requirements.txt`.
+
+### API Keys
+- OpenAI API key (or Anthropic/Google)
+- Alpha Vantage API key (for fundamental/news data)
+
+## Metrics Explained
+
+### Portfolio Beta
+- Measures portfolio volatility relative to the market (SPY)
+- Beta > 1: More volatile than market
+- Beta < 1: Less volatile than market
+
+### Sharpe Ratio
+- Risk-adjusted return metric
+- Higher is better (>1 is good)
+- Negative means returns below risk-free rate
+
+### Diversification Score
+- 0 to 1 scale (1 = best diversification)
+- Based on correlation between positions
+- <0.5 = high correlation, poor diversification
+
+### Max Drawdown
+- Largest peak-to-trough decline
+- Measures downside risk
+- Lower is better
+
+## Backward Compatibility
+
+The portfolio analysis feature is **completely separate** from single-stock analysis:
+- Single stock: `python -m cli.main analyze`
+- Portfolio: `python -m cli.main analyze-portfolio`
+
+All existing functionality remains unchanged.
+
+## Limitations
+
+- Requires historical price data (uses yfinance)
+- Analysis date cannot be in the future
+- Parallel analysis increases API costs
+- Correlation analysis requires at least 60 days of overlapping price data
+- Sector data may not be available for all tickers
+
+## Examples
+
+### Example Portfolio Input
+```
+Portfolio name: Tech Holdings
+Analysis date: 2024-12-01
+
+Position #1
+ Ticker: AAPL
+ Shares: 100
+ Average cost: $150.00
+
+Position #2
+ Ticker: MSFT
+ Shares: 50
+ Average cost: $300.00
+
+Position #3
+ Ticker: GOOGL
+ Shares: 25
+ Average cost: $120.00
+```
+
+### Example Output
+```
+Portfolio Summary:
+ Total Cost Basis: $36,000.00
+ Total Market Value: $42,500.00
+ Unrealized P/L: $6,500.00 (+18.06%)
+
+Portfolio Metrics:
+ Beta: 1.15
+ Volatility: 22.3% (annualized)
+ Sharpe Ratio: 1.42
+ Diversification Score: 0.68/1.00
+
+Sector Allocation:
+ - Technology: 85.0%
+ - Communication: 15.0%
+
+⚠️ High concentration in Technology sector (85.0%) - consider diversifying
+```
+
+## Support
+
+For issues or questions:
+1. Check the main README.md
+2. Review PORTFOLIO_ANALYSIS.md (this file)
+3. Run test_portfolio_analysis.py to verify setup
+4. Open an issue on GitHub
+
+## Future Enhancements
+
+Potential future features:
+- Portfolio optimization suggestions
+- Historical portfolio performance tracking
+- Tax-loss harvesting recommendations
+- Monte Carlo simulation for risk projections
+- Factor analysis (value, growth, momentum)
+- ESG scoring
diff --git a/cli/main.py b/cli/main.py
index 571575a0..720f98db 100644
--- a/cli/main.py
+++ b/cli/main.py
@@ -1120,8 +1120,16 @@ def run_analysis():
@app.command()
def analyze():
+ """Analyze a single stock with the trading agents framework."""
run_analysis()
+@app.command()
+def analyze_portfolio():
+ """Analyze a portfolio of multiple stocks."""
+ from cli.portfolio_cli import run_portfolio_analysis
+ run_portfolio_analysis()
+
+
if __name__ == "__main__":
app()
diff --git a/cli/portfolio_cli.py b/cli/portfolio_cli.py
new file mode 100644
index 00000000..92887ede
--- /dev/null
+++ b/cli/portfolio_cli.py
@@ -0,0 +1,285 @@
+"""Portfolio analysis CLI interface."""
+import typer
+import datetime
+from pathlib import Path
+from rich.console import Console
+from rich.panel import Panel
+from rich.table import Table
+from rich import box
+from rich.prompt import Prompt, Confirm
+
+from tradingagents.portfolio.models import Portfolio, Position
+from tradingagents.portfolio.portfolio_graph import PortfolioAnalysisGraph
+from tradingagents.default_config import DEFAULT_CONFIG
+from cli.utils import select_analysts, select_research_depth, select_llm_provider
+from cli.utils import select_shallow_thinking_agent, select_deep_thinking_agent
+
+console = Console()
+
+
+def get_portfolio_input() -> Portfolio:
+ """
+ Get portfolio holdings from user input.
+
+ Returns:
+ Portfolio object with user's positions
+ """
+ console.print(Panel(
+ "[bold]Enter Your Portfolio Holdings[/bold]\n"
+ "[dim]You will be prompted to enter each position.\n"
+ "Enter ticker symbol, number of shares, and average cost per share.[/dim]",
+ border_style="cyan"
+ ))
+
+ # Get portfolio name
+ portfolio_name = Prompt.ask(
+ "\nPortfolio name",
+ default="My Portfolio"
+ )
+
+ # Get analysis date
+ default_date = datetime.datetime.now().strftime("%Y-%m-%d")
+ while True:
+ analysis_date = Prompt.ask(
+ "Analysis date (YYYY-MM-DD)",
+ default=default_date
+ )
+ try:
+ datetime.datetime.strptime(analysis_date, "%Y-%m-%d")
+ break
+ except ValueError:
+ console.print("[red]Invalid date format. Please use YYYY-MM-DD[/red]")
+
+ # Collect positions
+ positions = {}
+ position_num = 1
+
+ console.print("\n[bold cyan]Enter positions (press Ctrl+C or enter empty ticker to finish):[/bold cyan]\n")
+
+ while True:
+ try:
+ console.print(f"[yellow]Position #{position_num}[/yellow]")
+
+ # Get ticker
+ ticker = Prompt.ask(" Ticker symbol").strip().upper()
+ if not ticker:
+ if len(positions) == 0:
+ console.print("[red]Please enter at least one position[/red]")
+ continue
+ break
+
+ # Check if ticker already exists
+ if ticker in positions:
+ console.print(f"[yellow] {ticker} already in portfolio. Skipping.[/yellow]")
+ continue
+
+ # Get shares
+ while True:
+ try:
+ shares_str = Prompt.ask(" Number of shares")
+ shares = float(shares_str)
+ if shares <= 0:
+ console.print("[red] Shares must be positive[/red]")
+ continue
+ break
+ except ValueError:
+ console.print("[red] Invalid number[/red]")
+
+ # Get average cost
+ while True:
+ try:
+ cost_str = Prompt.ask(" Average cost per share ($)")
+ avg_cost = float(cost_str)
+ if avg_cost <= 0:
+ console.print("[red] Cost must be positive[/red]")
+ continue
+ break
+ except ValueError:
+ console.print("[red] Invalid number[/red]")
+
+ # Add position
+ positions[ticker] = Position(
+ ticker=ticker,
+ shares=shares,
+ avg_cost=avg_cost
+ )
+
+ console.print(f"[green] ✓ Added {ticker}: {shares} shares @ ${avg_cost:.2f}[/green]\n")
+ position_num += 1
+
+ except KeyboardInterrupt:
+ if len(positions) == 0:
+ console.print("\n[red]No positions entered. Exiting.[/red]")
+ raise typer.Exit()
+ break
+
+ # Create portfolio
+ portfolio = Portfolio(
+ positions=positions,
+ analysis_date=analysis_date,
+ name=portfolio_name
+ )
+
+ # Display summary
+ console.print("\n[bold green]Portfolio Summary:[/bold green]")
+
+ table = Table(show_header=True, header_style="bold magenta", box=box.ROUNDED)
+ table.add_column("Ticker", style="cyan", justify="center")
+ table.add_column("Shares", justify="right")
+ table.add_column("Avg Cost", justify="right")
+ table.add_column("Cost Basis", justify="right")
+
+ for ticker, position in portfolio.positions.items():
+ table.add_row(
+ ticker,
+ f"{position.shares:.2f}",
+ f"${position.avg_cost:.2f}",
+ f"${position.cost_basis:,.2f}"
+ )
+
+ table.add_row(
+ "[bold]TOTAL[/bold]",
+ "",
+ "",
+ f"[bold]${portfolio.total_cost_basis:,.2f}[/bold]"
+ )
+
+ console.print(table)
+ console.print()
+
+ # Confirm
+ if not Confirm.ask("Proceed with this portfolio?", default=True):
+ raise typer.Exit()
+
+ return portfolio
+
+
+def run_portfolio_analysis():
+ """Run the complete portfolio analysis workflow."""
+
+ # Display welcome
+ console.print(Panel(
+ "[bold green]TradingAgents Portfolio Analyzer[/bold green]\n"
+ "[dim]Analyze your entire portfolio with AI-powered multi-agent framework[/dim]",
+ border_style="green"
+ ))
+ console.print()
+
+ # Step 1: Get portfolio holdings
+ portfolio = get_portfolio_input()
+
+ # Step 2: Select analysts
+ console.print(Panel(
+ "[bold]Select Analysts[/bold]\n"
+ "[dim]Choose which analyst agents to include in the analysis[/dim]",
+ border_style="blue"
+ ))
+ selected_analysts = select_analysts()
+
+ # Step 3: Select research depth
+ console.print(Panel(
+ "[bold]Research Depth[/bold]\n"
+ "[dim]Higher depth = more thorough analysis but slower[/dim]",
+ border_style="blue"
+ ))
+ research_depth = select_research_depth()
+
+ # Step 4: Select LLM provider
+ console.print(Panel(
+ "[bold]LLM Provider[/bold]\n"
+ "[dim]Select which LLM service to use[/dim]",
+ border_style="blue"
+ ))
+ llm_provider, backend_url = select_llm_provider()
+
+ # Step 5: Select thinking agents
+ console.print(Panel(
+ "[bold]Thinking Agents[/bold]\n"
+ "[dim]Select reasoning models[/dim]",
+ border_style="blue"
+ ))
+ shallow_thinker = select_shallow_thinking_agent(llm_provider)
+ deep_thinker = select_deep_thinking_agent(llm_provider)
+
+ # Create config
+ config = DEFAULT_CONFIG.copy()
+ config["max_debate_rounds"] = research_depth
+ config["max_risk_discuss_rounds"] = research_depth
+ config["quick_think_llm"] = shallow_thinker
+ config["deep_think_llm"] = deep_thinker
+ config["backend_url"] = backend_url
+ config["llm_provider"] = llm_provider.lower()
+
+ # Create results directory
+ results_dir = Path(config["results_dir"]) / "portfolio" / portfolio.analysis_date
+ results_dir.mkdir(parents=True, exist_ok=True)
+
+ # Initialize portfolio graph
+ console.print("\n[bold cyan]Initializing portfolio analysis...[/bold cyan]\n")
+
+ portfolio_graph = PortfolioAnalysisGraph(
+ selected_analysts=[analyst.value for analyst in selected_analysts],
+ debug=True,
+ config=config
+ )
+
+ # Run analysis
+ try:
+ result = portfolio_graph.analyze_portfolio(portfolio)
+
+ # Display results
+ console.print("\n[bold green]Analysis Complete![/bold green]\n")
+
+ # Save result
+ import json
+ result_file = results_dir / "portfolio_analysis.json"
+ with open(result_file, 'w') as f:
+ json.dump(result.to_dict(), f, indent=2, default=str)
+
+ console.print(f"[green]Results saved to: {result_file}[/green]\n")
+
+ # Display portfolio recommendation
+ if result.portfolio_recommendation:
+ console.print(Panel(
+ result.portfolio_recommendation,
+ title="Portfolio Overview",
+ border_style="green"
+ ))
+
+ # Display risk assessment
+ if result.risk_assessment:
+ console.print(Panel(
+ result.risk_assessment,
+ title="Risk Assessment",
+ border_style="yellow"
+ ))
+
+ # Display rebalancing suggestions
+ if result.rebalancing_suggestions:
+ console.print("\n[bold]Rebalancing Suggestions:[/bold]")
+ for suggestion in result.rebalancing_suggestions:
+ console.print(f" • [{suggestion['type']}] {suggestion['ticker']}: {suggestion['reason']}")
+
+ console.print(f"\n[bold cyan]Full analysis results saved to:[/bold cyan] {results_dir}")
+
+ # Generate PDF report
+ try:
+ from cli.portfolio_pdf_generator import generate_portfolio_pdf_report
+
+ console.print("\n[bold cyan]Generating portfolio PDF report...[/bold cyan]")
+ pdf_path = generate_portfolio_pdf_report(result, results_dir)
+ console.print(f"[bold green]Portfolio PDF saved to:[/bold green] {pdf_path}")
+ except ImportError as e:
+ console.print(f"[yellow]Warning: {e}[/yellow]")
+ console.print("[yellow]PDF generation skipped. Install required packages:[/yellow]")
+ console.print("[dim]pip install reportlab matplotlib seaborn[/dim]")
+ except Exception as e:
+ console.print(f"[red]Error generating portfolio PDF: {e}[/red]")
+
+ return result
+
+ except Exception as e:
+ console.print(f"\n[red]Error during analysis: {e}[/red]")
+ import traceback
+ traceback.print_exc()
+ raise typer.Exit(1)
diff --git a/cli/portfolio_pdf_generator.py b/cli/portfolio_pdf_generator.py
new file mode 100644
index 00000000..776bbc18
--- /dev/null
+++ b/cli/portfolio_pdf_generator.py
@@ -0,0 +1,372 @@
+"""PDF report generator for portfolio analysis results."""
+import datetime
+import io
+from pathlib import Path
+from typing import Dict, Any
+
+from tradingagents.portfolio.models import PortfolioAnalysisResult
+
+
+def generate_portfolio_charts(result: PortfolioAnalysisResult) -> Dict[str, io.BytesIO]:
+ """
+ Generate charts for portfolio analysis.
+
+ Args:
+ result: Portfolio analysis result
+
+ Returns:
+ Dictionary of chart names to BytesIO buffers
+ """
+ charts = {}
+
+ try:
+ import matplotlib.pyplot as plt
+ import matplotlib
+ matplotlib.use('Agg') # Use non-interactive backend
+ import numpy as np
+ except ImportError:
+ print("Warning: matplotlib required for chart generation")
+ return charts
+
+ portfolio = result.portfolio
+ metrics = result.portfolio_metrics
+
+ # Chart 1: Portfolio Allocation Pie Chart
+ try:
+ fig, ax = plt.subplots(figsize=(8, 8))
+ weights = portfolio.get_position_weights()
+
+ colors = plt.cm.Set3(np.linspace(0, 1, len(weights)))
+ wedges, texts, autotexts = ax.pie(
+ weights.values(),
+ labels=weights.keys(),
+ autopct='%1.1f%%',
+ colors=colors,
+ startangle=90
+ )
+
+ # Make percentage text bold
+ for autotext in autotexts:
+ autotext.set_color('white')
+ autotext.set_fontweight('bold')
+ autotext.set_fontsize(10)
+
+ ax.set_title('Portfolio Allocation', fontsize=16, fontweight='bold', pad=20)
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=150, bbox_inches='tight')
+ buf.seek(0)
+ plt.close(fig)
+ charts['allocation'] = buf
+ except Exception as e:
+ print(f"Error generating allocation chart: {e}")
+
+ # Chart 2: Correlation Heatmap
+ if 'correlation_matrix' in metrics:
+ try:
+ import seaborn as sns
+
+ corr_matrix = metrics['correlation_matrix']
+ tickers = list(corr_matrix.keys())
+
+ # Convert to numpy array
+ corr_array = np.array([[corr_matrix[t1][t2] for t2 in tickers] for t1 in tickers])
+
+ fig, ax = plt.subplots(figsize=(10, 8))
+ sns.heatmap(
+ corr_array,
+ annot=True,
+ fmt='.2f',
+ cmap='coolwarm',
+ center=0,
+ vmin=-1,
+ vmax=1,
+ square=True,
+ linewidths=1,
+ cbar_kws={"shrink": 0.8},
+ ax=ax,
+ xticklabels=tickers,
+ yticklabels=tickers
+ )
+
+ ax.set_title('Position Correlation Matrix', fontsize=16, fontweight='bold', pad=20)
+ plt.tight_layout()
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=150, bbox_inches='tight')
+ buf.seek(0)
+ plt.close(fig)
+ charts['correlation'] = buf
+ except ImportError:
+ print("Warning: seaborn required for correlation heatmap")
+ except Exception as e:
+ print(f"Error generating correlation chart: {e}")
+
+ # Chart 3: Sector Allocation
+ if 'sector_weights' in metrics:
+ try:
+ sector_weights = metrics['sector_weights']
+
+ fig, ax = plt.subplots(figsize=(10, 6))
+ sectors = list(sector_weights.keys())
+ weights_list = list(sector_weights.values())
+
+ colors = plt.cm.Paired(np.linspace(0, 1, len(sectors)))
+ bars = ax.barh(sectors, weights_list, color=colors)
+
+ ax.set_xlabel('Portfolio Weight (%)', fontsize=12)
+ ax.set_title('Sector Allocation', fontsize=16, fontweight='bold', pad=20)
+ ax.grid(True, alpha=0.3, axis='x')
+
+ # Add value labels on bars
+ for bar, weight in zip(bars, weights_list):
+ width = bar.get_width()
+ ax.text(
+ width,
+ bar.get_y() + bar.get_height() / 2,
+ f' {weight:.1f}%',
+ ha='left',
+ va='center',
+ fontweight='bold'
+ )
+
+ plt.tight_layout()
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=150, bbox_inches='tight')
+ buf.seek(0)
+ plt.close(fig)
+ charts['sectors'] = buf
+ except Exception as e:
+ print(f"Error generating sector chart: {e}")
+
+ # Chart 4: Position Performance
+ try:
+ fig, ax = plt.subplots(figsize=(10, 6))
+
+ tickers = []
+ performance = []
+ colors_list = []
+
+ for ticker, position in portfolio.positions.items():
+ if position.unrealized_gain_loss_pct is not None:
+ tickers.append(ticker)
+ perf = position.unrealized_gain_loss_pct
+ performance.append(perf)
+ colors_list.append('green' if perf >= 0 else 'red')
+
+ bars = ax.barh(tickers, performance, color=colors_list, alpha=0.7)
+
+ ax.set_xlabel('Unrealized Gain/Loss (%)', fontsize=12)
+ ax.set_title('Position Performance', fontsize=16, fontweight='bold', pad=20)
+ ax.axvline(x=0, color='black', linestyle='-', linewidth=0.8)
+ ax.grid(True, alpha=0.3, axis='x')
+
+ # Add value labels
+ for bar, perf in zip(bars, performance):
+ width = bar.get_width()
+ ax.text(
+ width,
+ bar.get_y() + bar.get_height() / 2,
+ f' {perf:+.1f}%',
+ ha='left' if width >= 0 else 'right',
+ va='center',
+ fontweight='bold'
+ )
+
+ plt.tight_layout()
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=150, bbox_inches='tight')
+ buf.seek(0)
+ plt.close(fig)
+ charts['performance'] = buf
+ except Exception as e:
+ print(f"Error generating performance chart: {e}")
+
+ return charts
+
+
+def generate_portfolio_pdf_report(
+ result: PortfolioAnalysisResult,
+ output_path: Path
+) -> Path:
+ """
+ Generate a comprehensive PDF report for portfolio analysis.
+
+ Args:
+ result: Portfolio analysis result
+ output_path: Directory where the PDF should be saved
+
+ Returns:
+ Path to the generated PDF file
+ """
+ try:
+ from reportlab.lib.pagesizes import letter
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+ from reportlab.lib.units import inch
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
+ from reportlab.platypus import Table, TableStyle, Image
+ from reportlab.lib import colors
+ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
+ except ImportError:
+ raise ImportError(
+ "reportlab is required for PDF generation. "
+ "Install it with: pip install reportlab"
+ )
+
+ portfolio = result.portfolio
+
+ # Create the PDF document
+ pdf_file = output_path / f"portfolio_analysis_{portfolio.analysis_date}.pdf"
+ doc = SimpleDocTemplate(
+ str(pdf_file),
+ pagesize=letter,
+ rightMargin=72,
+ leftMargin=72,
+ topMargin=72,
+ bottomMargin=18,
+ )
+
+ elements = []
+ styles = getSampleStyleSheet()
+
+ # Custom styles
+ styles.add(ParagraphStyle(
+ name='CustomTitle',
+ parent=styles['Heading1'],
+ fontSize=24,
+ textColor=colors.HexColor('#1a1a1a'),
+ spaceAfter=30,
+ alignment=TA_CENTER,
+ ))
+ styles.add(ParagraphStyle(
+ name='CustomHeading1',
+ parent=styles['Heading1'],
+ fontSize=18,
+ textColor=colors.HexColor('#2c3e50'),
+ spaceAfter=12,
+ spaceBefore=12,
+ ))
+ styles.add(ParagraphStyle(
+ name='CustomBody',
+ parent=styles['BodyText'],
+ fontSize=10,
+ alignment=TA_JUSTIFY,
+ spaceAfter=12,
+ ))
+
+ # Title
+ title = Paragraph(
+ f"Portfolio Analysis Report
{portfolio.name}",
+ styles['CustomTitle']
+ )
+ elements.append(title)
+ elements.append(Spacer(1, 12))
+
+ # Metadata
+ metadata = [
+ ['Analysis Date:', portfolio.analysis_date],
+ ['Report Generated:', datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')],
+ ['Number of Positions:', str(len(portfolio.positions))],
+ ['Total Cost Basis:', f"${portfolio.total_cost_basis:,.2f}"],
+ ]
+
+ if portfolio.total_market_value:
+ metadata.append(['Total Market Value:', f"${portfolio.total_market_value:,.2f}"])
+ metadata.append([
+ 'Total P/L:',
+ f"${portfolio.total_unrealized_gain_loss:,.2f} "
+ f"({portfolio.total_unrealized_gain_loss_pct:+.2f}%)"
+ ])
+
+ t = Table(metadata, colWidths=[2.5*inch, 3.5*inch])
+ t.setStyle(TableStyle([
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
+ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
+ ('FONTSIZE', (0, 0), (-1, -1), 10),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
+ ('TOPPADDING', (0, 0), (-1, -1), 6),
+ ]))
+ elements.append(t)
+ elements.append(Spacer(1, 30))
+
+ # Generate charts
+ print("Generating portfolio charts...")
+ charts = generate_portfolio_charts(result)
+
+ # Add charts
+ if charts:
+ elements.append(PageBreak())
+ elements.append(Paragraph('Portfolio Visualizations', styles['CustomHeading1']))
+ elements.append(Spacer(1, 12))
+
+ if 'allocation' in charts:
+ img = Image(charts['allocation'], width=5*inch, height=5*inch)
+ elements.append(img)
+ elements.append(Spacer(1, 20))
+
+ if 'performance' in charts:
+ img = Image(charts['performance'], width=6*inch, height=3.6*inch)
+ elements.append(img)
+ elements.append(Spacer(1, 20))
+
+ if 'sectors' in charts:
+ elements.append(PageBreak())
+ img = Image(charts['sectors'], width=6*inch, height=3.6*inch)
+ elements.append(img)
+ elements.append(Spacer(1, 20))
+
+ if 'correlation' in charts:
+ img = Image(charts['correlation'], width=6*inch, height=4.8*inch)
+ elements.append(img)
+ elements.append(Spacer(1, 20))
+
+ # Portfolio Overview
+ elements.append(PageBreak())
+ if result.portfolio_recommendation:
+ elements.append(Paragraph('Portfolio Overview', styles['CustomHeading1']))
+ elements.append(Spacer(1, 12))
+ for line in result.portfolio_recommendation.split('\n'):
+ if line.strip():
+ elements.append(Paragraph(line.replace('#', '').replace('**', '').replace('**', ''), styles['CustomBody']))
+
+ # Risk Assessment
+ elements.append(PageBreak())
+ if result.risk_assessment:
+ elements.append(Paragraph('Risk Assessment', styles['CustomHeading1']))
+ elements.append(Spacer(1, 12))
+ for line in result.risk_assessment.split('\n'):
+ if line.strip():
+ elements.append(Paragraph(line.replace('#', '').replace('**', '').replace('**', ''), styles['CustomBody']))
+
+ # Rebalancing Suggestions
+ if result.rebalancing_suggestions:
+ elements.append(PageBreak())
+ elements.append(Paragraph('Rebalancing Suggestions', styles['CustomHeading1']))
+ elements.append(Spacer(1, 12))
+ for suggestion in result.rebalancing_suggestions:
+ text = f"• [{suggestion['type']}] {suggestion['ticker']}: {suggestion['reason']}"
+ elements.append(Paragraph(text, styles['CustomBody']))
+
+ # Individual Stock Analyses
+ elements.append(PageBreak())
+ elements.append(Paragraph('Individual Stock Analyses', styles['CustomHeading1']))
+ elements.append(Spacer(1, 20))
+
+ for ticker, analysis in result.individual_analyses.items():
+ if not analysis.get('success'):
+ elements.append(Paragraph(f"{ticker}: Analysis Failed", styles['Heading2']))
+ elements.append(Paragraph(f"Error: {analysis.get('error', 'Unknown error')}", styles['CustomBody']))
+ elements.append(Spacer(1, 20))
+ continue
+
+ elements.append(Paragraph(ticker, styles['Heading2']))
+ decision = analysis.get('decision', 'No decision')
+ elements.append(Paragraph(f"Decision: {decision}", styles['CustomBody']))
+ elements.append(Spacer(1, 20))
+
+ # Build PDF
+ doc.build(elements)
+
+ return pdf_file
diff --git a/requirements.txt b/requirements.txt
index a8d7c544..4be034b5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -26,3 +26,4 @@ langchain_anthropic
langchain-google-genai
reportlab
matplotlib
+seaborn
diff --git a/test_portfolio_analysis.py b/test_portfolio_analysis.py
new file mode 100644
index 00000000..56cd0d3b
--- /dev/null
+++ b/test_portfolio_analysis.py
@@ -0,0 +1,78 @@
+"""Test portfolio analysis functionality."""
+from pathlib import Path
+from tradingagents.portfolio.models import Portfolio, Position
+from tradingagents.portfolio.portfolio_graph import PortfolioAnalysisGraph
+from tradingagents.default_config import DEFAULT_CONFIG
+
+# Create a sample portfolio
+positions = {
+ "AAPL": Position(ticker="AAPL", shares=100, avg_cost=150.00),
+ "MSFT": Position(ticker="MSFT", shares=50, avg_cost=300.00),
+ "GOOGL": Position(ticker="GOOGL", shares=25, avg_cost=120.00),
+}
+
+portfolio = Portfolio(
+ positions=positions,
+ analysis_date="2024-12-01",
+ name="Test Portfolio"
+)
+
+# Create config
+config = DEFAULT_CONFIG.copy()
+config["max_debate_rounds"] = 1 # Use minimal rounds for testing
+config["max_risk_discuss_rounds"] = 1
+config["quick_think_llm"] = "gpt-4o-mini"
+config["deep_think_llm"] = "gpt-4o-mini"
+
+# Initialize portfolio graph
+print("Initializing portfolio analysis...")
+portfolio_graph = PortfolioAnalysisGraph(
+ selected_analysts=["market", "fundamentals"], # Use fewer analysts for faster testing
+ debug=True,
+ config=config
+)
+
+# Run analysis
+try:
+ print("\nStarting portfolio analysis...")
+ result = portfolio_graph.analyze_portfolio(portfolio, max_workers=2)
+
+ print("\n" + "="*60)
+ print("PORTFOLIO ANALYSIS COMPLETE")
+ print("="*60)
+
+ # Print summary
+ print(f"\nPortfolio: {result.portfolio.name}")
+ print(f"Total Value: ${result.portfolio.total_market_value:,.2f}")
+ print(f"Total P/L: ${result.portfolio.total_unrealized_gain_loss:,.2f} "
+ f"({result.portfolio.total_unrealized_gain_loss_pct:+.2f}%)")
+
+ print("\n" + result.portfolio_recommendation)
+ print("\n" + result.risk_assessment)
+
+ if result.rebalancing_suggestions:
+ print("\nRebalancing Suggestions:")
+ for suggestion in result.rebalancing_suggestions:
+ print(f" • [{suggestion['type']}] {suggestion['ticker']}: {suggestion['reason']}")
+
+ # Test PDF generation
+ try:
+ from cli.portfolio_pdf_generator import generate_portfolio_pdf_report
+
+ output_path = Path("./test_output")
+ output_path.mkdir(exist_ok=True)
+
+ print("\nGenerating PDF report...")
+ pdf_path = generate_portfolio_pdf_report(result, output_path)
+ print(f"PDF generated: {pdf_path}")
+ print(f"File size: {pdf_path.stat().st_size} bytes")
+
+ except Exception as e:
+ print(f"\nPDF generation error: {e}")
+
+ print("\n✓ Test completed successfully!")
+
+except Exception as e:
+ print(f"\nError: {e}")
+ import traceback
+ traceback.print_exc()
diff --git a/tradingagents/portfolio/__init__.py b/tradingagents/portfolio/__init__.py
new file mode 100644
index 00000000..ffdaae07
--- /dev/null
+++ b/tradingagents/portfolio/__init__.py
@@ -0,0 +1,4 @@
+"""Portfolio analysis module for TradingAgents."""
+from tradingagents.portfolio.models import Portfolio, Position, PortfolioAnalysisResult
+
+__all__ = ["Portfolio", "Position", "PortfolioAnalysisResult"]
diff --git a/tradingagents/portfolio/metrics.py b/tradingagents/portfolio/metrics.py
new file mode 100644
index 00000000..174ae37a
--- /dev/null
+++ b/tradingagents/portfolio/metrics.py
@@ -0,0 +1,242 @@
+"""Portfolio metrics calculation utilities."""
+import numpy as np
+import pandas as pd
+import yfinance as yf
+from typing import Dict, List, Tuple, Optional
+from datetime import datetime, timedelta
+from tradingagents.portfolio.models import Portfolio
+
+
+def fetch_historical_prices(
+ tickers: List[str],
+ end_date: str,
+ days: int = 252
+) -> pd.DataFrame:
+ """
+ Fetch historical prices for multiple tickers.
+
+ Args:
+ tickers: List of ticker symbols
+ end_date: End date for historical data (YYYY-MM-DD)
+ days: Number of days of historical data to fetch
+
+ Returns:
+ DataFrame with adjusted close prices for each ticker
+ """
+ end = datetime.strptime(end_date, '%Y-%m-%d')
+ start = end - timedelta(days=days)
+
+ data = yf.download(
+ tickers,
+ start=start.strftime('%Y-%m-%d'),
+ end=end.strftime('%Y-%m-%d'),
+ progress=False
+ )
+
+ # Handle single ticker vs multiple tickers
+ if len(tickers) == 1:
+ prices = data['Adj Close'].to_frame()
+ prices.columns = tickers
+ else:
+ prices = data['Adj Close']
+
+ return prices
+
+
+def calculate_returns(prices: pd.DataFrame) -> pd.DataFrame:
+ """Calculate daily returns from price data."""
+ return prices.pct_change().dropna()
+
+
+def calculate_correlation_matrix(returns: pd.DataFrame) -> pd.DataFrame:
+ """Calculate correlation matrix for portfolio holdings."""
+ return returns.corr()
+
+
+def calculate_portfolio_beta(
+ portfolio_returns: pd.Series,
+ market_returns: pd.Series
+) -> float:
+ """
+ Calculate portfolio beta relative to market.
+
+ Args:
+ portfolio_returns: Daily returns of the portfolio
+ market_returns: Daily returns of the market (e.g., SPY)
+
+ Returns:
+ Portfolio beta
+ """
+ # Align the series
+ aligned = pd.concat([portfolio_returns, market_returns], axis=1, join='inner')
+ aligned.columns = ['portfolio', 'market']
+
+ covariance = aligned['portfolio'].cov(aligned['market'])
+ market_variance = aligned['market'].var()
+
+ beta = covariance / market_variance
+ return float(beta)
+
+
+def calculate_sharpe_ratio(
+ returns: pd.Series,
+ risk_free_rate: float = 0.04
+) -> float:
+ """
+ Calculate annualized Sharpe ratio.
+
+ Args:
+ returns: Daily returns
+ risk_free_rate: Annual risk-free rate (default 4%)
+
+ Returns:
+ Annualized Sharpe ratio
+ """
+ # Annualize returns and volatility
+ annual_return = returns.mean() * 252
+ annual_vol = returns.std() * np.sqrt(252)
+
+ if annual_vol == 0:
+ return 0.0
+
+ sharpe = (annual_return - risk_free_rate) / annual_vol
+ return float(sharpe)
+
+
+def calculate_portfolio_volatility(returns: pd.Series) -> float:
+ """
+ Calculate annualized portfolio volatility.
+
+ Args:
+ returns: Daily returns
+
+ Returns:
+ Annualized volatility (standard deviation)
+ """
+ return float(returns.std() * np.sqrt(252))
+
+
+def get_sector_allocation(tickers: List[str]) -> Dict[str, Dict[str, float]]:
+ """
+ Get sector allocation for portfolio tickers.
+
+ Args:
+ tickers: List of ticker symbols
+
+ Returns:
+ Dictionary mapping tickers to sector and industry
+ """
+ sector_data = {}
+
+ for ticker in tickers:
+ try:
+ stock = yf.Ticker(ticker)
+ info = stock.info
+ sector_data[ticker] = {
+ 'sector': info.get('sector', 'Unknown'),
+ 'industry': info.get('industry', 'Unknown'),
+ }
+ except Exception as e:
+ print(f"Warning: Could not fetch sector data for {ticker}: {e}")
+ sector_data[ticker] = {
+ 'sector': 'Unknown',
+ 'industry': 'Unknown',
+ }
+
+ return sector_data
+
+
+def calculate_diversification_score(correlation_matrix: pd.DataFrame) -> float:
+ """
+ Calculate portfolio diversification score.
+
+ A score closer to 1 indicates better diversification (low correlation).
+ A score closer to 0 indicates poor diversification (high correlation).
+
+ Args:
+ correlation_matrix: Correlation matrix of portfolio returns
+
+ Returns:
+ Diversification score between 0 and 1
+ """
+ # Get average correlation excluding diagonal
+ n = len(correlation_matrix)
+ if n <= 1:
+ return 1.0
+
+ # Sum all correlations and subtract diagonal (which is all 1s)
+ total_corr = correlation_matrix.sum().sum() - n
+ # Average correlation between different assets
+ avg_corr = total_corr / (n * (n - 1))
+
+ # Convert to diversification score (inverse of average correlation)
+ # High correlation = low diversification
+ diversification_score = 1 - avg_corr
+
+ return float(max(0, min(1, diversification_score)))
+
+
+def calculate_portfolio_metrics(portfolio: Portfolio) -> Dict:
+ """
+ Calculate comprehensive portfolio metrics.
+
+ Args:
+ portfolio: Portfolio object with positions
+
+ Returns:
+ Dictionary of portfolio metrics
+ """
+ tickers = portfolio.tickers
+
+ if len(tickers) == 0:
+ return {}
+
+ try:
+ # Fetch historical data
+ prices = fetch_historical_prices(tickers, portfolio.analysis_date)
+ returns = calculate_returns(prices)
+
+ # Calculate portfolio returns (weighted by position value)
+ weights = portfolio.get_position_weights()
+ weight_array = np.array([weights[ticker] / 100 for ticker in tickers])
+ portfolio_returns = (returns * weight_array).sum(axis=1)
+
+ # Correlation matrix
+ correlation_matrix = calculate_correlation_matrix(returns)
+
+ # Fetch market data (SPY as proxy)
+ market_prices = fetch_historical_prices(['SPY'], portfolio.analysis_date)
+ market_returns = calculate_returns(market_prices)['SPY']
+
+ # Calculate metrics
+ metrics = {
+ 'correlation_matrix': correlation_matrix.to_dict(),
+ 'portfolio_beta': calculate_portfolio_beta(portfolio_returns, market_returns),
+ 'portfolio_volatility': calculate_portfolio_volatility(portfolio_returns),
+ 'sharpe_ratio': calculate_sharpe_ratio(portfolio_returns),
+ 'diversification_score': calculate_diversification_score(correlation_matrix),
+ 'annualized_return': float(portfolio_returns.mean() * 252),
+ 'max_drawdown': float((portfolio_returns.cumsum().expanding().max() -
+ portfolio_returns.cumsum()).max()),
+ }
+
+ # Add sector allocation
+ sector_data = get_sector_allocation(tickers)
+ metrics['sector_allocation'] = sector_data
+
+ # Calculate sector concentration
+ sectors = {}
+ for ticker, data in sector_data.items():
+ sector = data['sector']
+ weight = weights[ticker]
+ sectors[sector] = sectors.get(sector, 0) + weight
+ metrics['sector_weights'] = sectors
+
+ return metrics
+
+ except Exception as e:
+ print(f"Error calculating portfolio metrics: {e}")
+ return {
+ 'error': str(e),
+ 'sector_allocation': get_sector_allocation(tickers)
+ }
diff --git a/tradingagents/portfolio/models.py b/tradingagents/portfolio/models.py
new file mode 100644
index 00000000..bef09b9c
--- /dev/null
+++ b/tradingagents/portfolio/models.py
@@ -0,0 +1,141 @@
+"""Portfolio data models and structures."""
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+from datetime import datetime
+
+
+@dataclass
+class Position:
+ """Represents a single position in the portfolio."""
+ ticker: str
+ shares: float
+ avg_cost: float
+ current_price: Optional[float] = None
+
+ @property
+ def cost_basis(self) -> float:
+ """Total cost basis of the position."""
+ return self.shares * self.avg_cost
+
+ @property
+ def market_value(self) -> Optional[float]:
+ """Current market value of the position."""
+ if self.current_price is None:
+ return None
+ return self.shares * self.current_price
+
+ @property
+ def unrealized_gain_loss(self) -> Optional[float]:
+ """Unrealized gain/loss for this position."""
+ if self.market_value is None:
+ return None
+ return self.market_value - self.cost_basis
+
+ @property
+ def unrealized_gain_loss_pct(self) -> Optional[float]:
+ """Unrealized gain/loss percentage."""
+ if self.market_value is None:
+ return None
+ return ((self.market_value - self.cost_basis) / self.cost_basis) * 100
+
+
+@dataclass
+class Portfolio:
+ """Represents a complete portfolio with multiple positions."""
+ positions: Dict[str, Position]
+ analysis_date: str
+ name: str = "My Portfolio"
+
+ @property
+ def tickers(self) -> List[str]:
+ """List of all tickers in the portfolio."""
+ return list(self.positions.keys())
+
+ @property
+ def total_cost_basis(self) -> float:
+ """Total cost basis of the portfolio."""
+ return sum(pos.cost_basis for pos in self.positions.values())
+
+ @property
+ def total_market_value(self) -> Optional[float]:
+ """Total market value of the portfolio."""
+ values = [pos.market_value for pos in self.positions.values()]
+ if None in values:
+ return None
+ return sum(values)
+
+ @property
+ def total_unrealized_gain_loss(self) -> Optional[float]:
+ """Total unrealized gain/loss for the portfolio."""
+ if self.total_market_value is None:
+ return None
+ return self.total_market_value - self.total_cost_basis
+
+ @property
+ def total_unrealized_gain_loss_pct(self) -> Optional[float]:
+ """Total unrealized gain/loss percentage."""
+ if self.total_market_value is None:
+ return None
+ return ((self.total_market_value - self.total_cost_basis) /
+ self.total_cost_basis) * 100
+
+ def get_position_weights(self) -> Dict[str, float]:
+ """Get the weight of each position as percentage of portfolio."""
+ if self.total_market_value is None:
+ # Fall back to cost basis if no market values
+ total = self.total_cost_basis
+ return {
+ ticker: (pos.cost_basis / total) * 100
+ for ticker, pos in self.positions.items()
+ }
+
+ return {
+ ticker: (pos.market_value / self.total_market_value) * 100
+ for ticker, pos in self.positions.items()
+ }
+
+ def to_dict(self) -> Dict:
+ """Convert portfolio to dictionary representation."""
+ return {
+ "name": self.name,
+ "analysis_date": self.analysis_date,
+ "total_cost_basis": self.total_cost_basis,
+ "total_market_value": self.total_market_value,
+ "total_unrealized_gain_loss": self.total_unrealized_gain_loss,
+ "total_unrealized_gain_loss_pct": self.total_unrealized_gain_loss_pct,
+ "positions": {
+ ticker: {
+ "shares": pos.shares,
+ "avg_cost": pos.avg_cost,
+ "current_price": pos.current_price,
+ "cost_basis": pos.cost_basis,
+ "market_value": pos.market_value,
+ "unrealized_gain_loss": pos.unrealized_gain_loss,
+ "unrealized_gain_loss_pct": pos.unrealized_gain_loss_pct,
+ }
+ for ticker, pos in self.positions.items()
+ },
+ "position_weights": self.get_position_weights(),
+ }
+
+
+@dataclass
+class PortfolioAnalysisResult:
+ """Results from portfolio analysis."""
+ portfolio: Portfolio
+ individual_analyses: Dict[str, Dict] # ticker -> analysis result
+ portfolio_metrics: Dict = field(default_factory=dict)
+ portfolio_recommendation: Optional[str] = None
+ rebalancing_suggestions: List[Dict] = field(default_factory=list)
+ risk_assessment: Optional[str] = None
+
+ def to_dict(self) -> Dict:
+ """Convert analysis result to dictionary."""
+ return {
+ "portfolio": self.portfolio.to_dict(),
+ "individual_analyses": self.individual_analyses,
+ "portfolio_metrics": self.portfolio_metrics,
+ "portfolio_recommendation": self.portfolio_recommendation,
+ "rebalancing_suggestions": self.rebalancing_suggestions,
+ "risk_assessment": self.risk_assessment,
+ }
diff --git a/tradingagents/portfolio/portfolio_graph.py b/tradingagents/portfolio/portfolio_graph.py
new file mode 100644
index 00000000..c1299b0b
--- /dev/null
+++ b/tradingagents/portfolio/portfolio_graph.py
@@ -0,0 +1,394 @@
+"""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 trading graph for this stock
+ ta = TradingAgentsGraph(
+ selected_analysts=self.selected_analysts,
+ debug=self.debug,
+ config=self.config.copy()
+ )
+
+ # 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)