373 lines
12 KiB
Python
373 lines
12 KiB
Python
"""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
|