feat: Add comprehensive portfolio analysis system

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 <noreply@anthropic.com>
This commit is contained in:
0x7d0 2025-10-10 11:58:31 +02:00
parent 1f6256d346
commit 07d0bcccb9
10 changed files with 1788 additions and 0 deletions

263
PORTFOLIO_ANALYSIS.md Normal file
View File

@ -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

View File

@ -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()

285
cli/portfolio_cli.py Normal file
View File

@ -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)

View File

@ -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<br/>{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('**', '<b>').replace('**', '</b>'), 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('**', '<b>').replace('**', '</b>'), 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"<b>Decision:</b> {decision}", styles['CustomBody']))
elements.append(Spacer(1, 20))
# Build PDF
doc.build(elements)
return pdf_file

View File

@ -26,3 +26,4 @@ langchain_anthropic
langchain-google-genai
reportlab
matplotlib
seaborn

View File

@ -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()

View File

@ -0,0 +1,4 @@
"""Portfolio analysis module for TradingAgents."""
from tradingagents.portfolio.models import Portfolio, Position, PortfolioAnalysisResult
__all__ = ["Portfolio", "Position", "PortfolioAnalysisResult"]

View File

@ -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)
}

View File

@ -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,
}

View File

@ -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)