From 07d0bcccb9b98835801508d1cb7902e6094ea6a8 Mon Sep 17 00:00:00 2001 From: 0x7d0 Date: Fri, 10 Oct 2025 11:58:31 +0200 Subject: [PATCH] feat: Add comprehensive portfolio analysis system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add multi-stock portfolio analysis capabilities: - Parallel analysis of multiple positions using existing trading agents - Portfolio-level metrics: correlation, beta, volatility, Sharpe ratio, diversification - Risk assessment: concentration risk, sector exposure, correlation analysis - Rebalancing recommendations based on AI analysis - Comprehensive PDF reports with visualizations: * Allocation pie chart * Correlation heatmap * Sector distribution * Position performance charts - Interactive CLI for portfolio input - Programmatic API for custom integration - Full backward compatibility with single-stock analysis New modules: - tradingagents/portfolio/: Core portfolio models, metrics, and graph - cli/portfolio_cli.py: Interactive portfolio analysis interface - cli/portfolio_pdf_generator.py: Portfolio-specific PDF generation - PORTFOLIO_ANALYSIS.md: Comprehensive documentation New command: python -m cli.main analyze-portfolio Dependencies added: seaborn for correlation heatmaps šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- PORTFOLIO_ANALYSIS.md | 263 ++++++++++++++ cli/main.py | 8 + cli/portfolio_cli.py | 285 +++++++++++++++ cli/portfolio_pdf_generator.py | 372 +++++++++++++++++++ requirements.txt | 1 + test_portfolio_analysis.py | 78 ++++ tradingagents/portfolio/__init__.py | 4 + tradingagents/portfolio/metrics.py | 242 +++++++++++++ tradingagents/portfolio/models.py | 141 ++++++++ tradingagents/portfolio/portfolio_graph.py | 394 +++++++++++++++++++++ 10 files changed, 1788 insertions(+) create mode 100644 PORTFOLIO_ANALYSIS.md create mode 100644 cli/portfolio_cli.py create mode 100644 cli/portfolio_pdf_generator.py create mode 100644 test_portfolio_analysis.py create mode 100644 tradingagents/portfolio/__init__.py create mode 100644 tradingagents/portfolio/metrics.py create mode 100644 tradingagents/portfolio/models.py create mode 100644 tradingagents/portfolio/portfolio_graph.py 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)