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:
parent
1f6256d346
commit
07d0bcccb9
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -26,3 +26,4 @@ langchain_anthropic
|
|||
langchain-google-genai
|
||||
reportlab
|
||||
matplotlib
|
||||
seaborn
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""Portfolio analysis module for TradingAgents."""
|
||||
from tradingagents.portfolio.models import Portfolio, Position, PortfolioAnalysisResult
|
||||
|
||||
__all__ = ["Portfolio", "Position", "PortfolioAnalysisResult"]
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue