feat: Add PDF report generation with charts for trading analysis

- Add comprehensive PDF report generation with all analysis stages
- Include market data visualizations: price chart, volume chart, and technical indicators (RSI/MACD)
- Fix text parsing issues with proper HTML/markdown escaping
- Add reportlab and matplotlib dependencies for PDF and chart generation
- PDF reports saved automatically after analysis completion in results directory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
0x7d0 2025-10-09 18:46:37 +02:00
parent 1122505cd3
commit 1f6256d346
4 changed files with 537 additions and 0 deletions

248
cli/chart_generator.py Normal file
View File

@ -0,0 +1,248 @@
"""Chart generation utilities for PDF reports."""
import io
from pathlib import Path
from typing import Optional, Dict, Any
import datetime
def generate_stock_price_chart(ticker: str, analysis_date: str) -> Optional[io.BytesIO]:
"""
Generate a stock price chart with moving averages.
Args:
ticker: Stock ticker symbol
analysis_date: Analysis date string (YYYY-MM-DD)
Returns:
BytesIO buffer containing the chart image, or None if generation fails
"""
try:
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import yfinance as yf
from datetime import datetime, timedelta
except ImportError:
print("Warning: matplotlib required for chart generation")
return None
try:
# Parse the analysis date
end_date = datetime.strptime(analysis_date, '%Y-%m-%d')
# Get 90 days of data for context
start_date = end_date - timedelta(days=90)
# Fetch stock data
stock = yf.Ticker(ticker)
df = stock.history(start=start_date.strftime('%Y-%m-%d'),
end=end_date.strftime('%Y-%m-%d'))
if df.empty:
print(f"Warning: No data available for {ticker}")
return None
# Calculate moving averages
df['SMA_20'] = df['Close'].rolling(window=20).mean()
df['SMA_50'] = df['Close'].rolling(window=50).mean()
# Create the figure
fig, ax = plt.subplots(figsize=(10, 6))
# Plot price and moving averages
ax.plot(df.index, df['Close'], label='Close Price', linewidth=2, color='#2E86AB')
ax.plot(df.index, df['SMA_20'], label='20-day SMA', linewidth=1.5,
linestyle='--', color='#A23B72', alpha=0.8)
ax.plot(df.index, df['SMA_50'], label='50-day SMA', linewidth=1.5,
linestyle='--', color='#F18F01', alpha=0.8)
# Formatting
ax.set_title(f'{ticker} Stock Price - Last 90 Days', fontsize=14, fontweight='bold')
ax.set_xlabel('Date', fontsize=12)
ax.set_ylabel('Price ($)', fontsize=12)
ax.legend(loc='best', fontsize=10)
ax.grid(True, alpha=0.3)
# Format x-axis dates
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
ax.xaxis.set_major_locator(mdates.MonthLocator())
plt.xticks(rotation=45)
# Tight layout to prevent label cutoff
plt.tight_layout()
# Save to BytesIO buffer
buf = io.BytesIO()
plt.savefig(buf, format='png', dpi=150, bbox_inches='tight')
buf.seek(0)
plt.close(fig)
return buf
except Exception as e:
print(f"Error generating stock price chart: {e}")
return None
def generate_volume_chart(ticker: str, analysis_date: str) -> Optional[io.BytesIO]:
"""
Generate a trading volume chart.
Args:
ticker: Stock ticker symbol
analysis_date: Analysis date string (YYYY-MM-DD)
Returns:
BytesIO buffer containing the chart image, or None if generation fails
"""
try:
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import yfinance as yf
from datetime import datetime, timedelta
except ImportError:
return None
try:
# Parse the analysis date
end_date = datetime.strptime(analysis_date, '%Y-%m-%d')
start_date = end_date - timedelta(days=90)
# Fetch stock data
stock = yf.Ticker(ticker)
df = stock.history(start=start_date.strftime('%Y-%m-%d'),
end=end_date.strftime('%Y-%m-%d'))
if df.empty:
return None
# Create the figure
fig, ax = plt.subplots(figsize=(10, 4))
# Plot volume bars
colors = ['green' if close >= open_ else 'red'
for close, open_ in zip(df['Close'], df['Open'])]
ax.bar(df.index, df['Volume'], color=colors, alpha=0.6)
# Add average volume line
avg_volume = df['Volume'].mean()
ax.axhline(y=avg_volume, color='blue', linestyle='--',
linewidth=1.5, label=f'Avg Volume: {avg_volume:,.0f}', alpha=0.7)
# Formatting
ax.set_title(f'{ticker} Trading Volume - Last 90 Days', fontsize=14, fontweight='bold')
ax.set_xlabel('Date', fontsize=12)
ax.set_ylabel('Volume', fontsize=12)
ax.legend(loc='best', fontsize=10)
ax.grid(True, alpha=0.3, axis='y')
# Format y-axis to show volume in millions
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x/1e6:.1f}M'))
# Format x-axis dates
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
ax.xaxis.set_major_locator(mdates.MonthLocator())
plt.xticks(rotation=45)
plt.tight_layout()
# Save to BytesIO buffer
buf = io.BytesIO()
plt.savefig(buf, format='png', dpi=150, bbox_inches='tight')
buf.seek(0)
plt.close(fig)
return buf
except Exception as e:
print(f"Error generating volume chart: {e}")
return None
def generate_technical_indicators_chart(ticker: str, analysis_date: str) -> Optional[io.BytesIO]:
"""
Generate a chart showing RSI and MACD technical indicators.
Args:
ticker: Stock ticker symbol
analysis_date: Analysis date string (YYYY-MM-DD)
Returns:
BytesIO buffer containing the chart image, or None if generation fails
"""
try:
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import yfinance as yf
import pandas as pd
from datetime import datetime, timedelta
except ImportError:
return None
try:
# Parse the analysis date
end_date = datetime.strptime(analysis_date, '%Y-%m-%d')
start_date = end_date - timedelta(days=90)
# Fetch stock data
stock = yf.Ticker(ticker)
df = stock.history(start=start_date.strftime('%Y-%m-%d'),
end=end_date.strftime('%Y-%m-%d'))
if df.empty:
return None
# Calculate RSI
delta = df['Close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
rs = gain / loss
df['RSI'] = 100 - (100 / (1 + rs))
# Calculate MACD
exp1 = df['Close'].ewm(span=12, adjust=False).mean()
exp2 = df['Close'].ewm(span=26, adjust=False).mean()
df['MACD'] = exp1 - exp2
df['Signal'] = df['MACD'].ewm(span=9, adjust=False).mean()
# Create subplots
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), sharex=True)
# RSI plot
ax1.plot(df.index, df['RSI'], label='RSI', linewidth=2, color='#2E86AB')
ax1.axhline(y=70, color='red', linestyle='--', linewidth=1, alpha=0.7, label='Overbought (70)')
ax1.axhline(y=30, color='green', linestyle='--', linewidth=1, alpha=0.7, label='Oversold (30)')
ax1.fill_between(df.index, 30, 70, alpha=0.1, color='gray')
ax1.set_ylabel('RSI', fontsize=12)
ax1.set_title(f'{ticker} Technical Indicators', fontsize=14, fontweight='bold')
ax1.legend(loc='best', fontsize=10)
ax1.grid(True, alpha=0.3)
ax1.set_ylim(0, 100)
# MACD plot
ax2.plot(df.index, df['MACD'], label='MACD', linewidth=2, color='#2E86AB')
ax2.plot(df.index, df['Signal'], label='Signal', linewidth=2, color='#F18F01')
ax2.bar(df.index, df['MACD'] - df['Signal'], label='Histogram',
color='gray', alpha=0.3)
ax2.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax2.set_xlabel('Date', fontsize=12)
ax2.set_ylabel('MACD', fontsize=12)
ax2.legend(loc='best', fontsize=10)
ax2.grid(True, alpha=0.3)
# Format x-axis dates
ax2.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
ax2.xaxis.set_major_locator(mdates.MonthLocator())
plt.xticks(rotation=45)
plt.tight_layout()
# Save to BytesIO buffer
buf = io.BytesIO()
plt.savefig(buf, format='png', dpi=150, bbox_inches='tight')
buf.seek(0)
plt.close(fig)
return buf
except Exception as e:
print(f"Error generating technical indicators chart: {e}")
return None

View File

@ -28,6 +28,7 @@ from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
from cli.models import AnalystType
from cli.utils import *
from cli.pdf_generator import generate_pdf_report
console = Console()
@ -1099,6 +1100,23 @@ def run_analysis():
update_display(layout)
# Generate PDF report after the Live display context ends
try:
console.print("\n[bold cyan]Generating PDF report...[/bold cyan]")
pdf_path = generate_pdf_report(
final_state,
selections["ticker"],
selections["analysis_date"],
results_dir
)
console.print(f"[bold green]PDF report saved to:[/bold green] {pdf_path}")
except ImportError as e:
console.print(f"[yellow]Warning: {e}[/yellow]")
console.print("[yellow]PDF generation skipped. Install reportlab to enable PDF reports:[/yellow]")
console.print("[dim]pip install reportlab[/dim]")
except Exception as e:
console.print(f"[red]Error generating PDF report: {e}[/red]")
@app.command()
def analyze():

269
cli/pdf_generator.py Normal file
View File

@ -0,0 +1,269 @@
"""PDF report generator for TradingAgents analysis results."""
import datetime
import re
from pathlib import Path
from typing import Dict, Any
import io
from cli.chart_generator import (
generate_stock_price_chart,
generate_volume_chart,
generate_technical_indicators_chart
)
def generate_pdf_report(
final_state: Dict[str, Any],
ticker: str,
analysis_date: str,
output_path: Path,
) -> Path:
"""
Generate a comprehensive PDF report from the analysis results.
Args:
final_state: The final state dictionary containing all analysis results
ticker: The stock ticker symbol
analysis_date: The analysis date
output_path: Path where the PDF should be saved
Returns:
Path to the generated PDF file
"""
try:
from reportlab.lib.pagesizes import letter, A4
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_LEFT, TA_JUSTIFY
except ImportError:
raise ImportError(
"reportlab is required for PDF generation. "
"Install it with: pip install reportlab"
)
# Create the PDF document
pdf_file = output_path / f"{ticker}_{analysis_date}_analysis_report.pdf"
doc = SimpleDocTemplate(
str(pdf_file),
pagesize=letter,
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=18,
)
# Container for the 'Flowable' objects
elements = []
# Define styles
styles = getSampleStyleSheet()
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='CustomHeading2',
parent=styles['Heading2'],
fontSize=14,
textColor=colors.HexColor('#34495e'),
spaceAfter=10,
spaceBefore=10,
))
styles.add(ParagraphStyle(
name='CustomBody',
parent=styles['BodyText'],
fontSize=10,
alignment=TA_JUSTIFY,
spaceAfter=12,
))
# Title Page
title = Paragraph(
f"TradingAgents Analysis Report<br/>{ticker}",
styles['CustomTitle']
)
elements.append(title)
elements.append(Spacer(1, 12))
# Metadata table
metadata = [
['Analysis Date:', analysis_date],
['Report Generated:', datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')],
['Ticker Symbol:', ticker],
]
t = Table(metadata, colWidths=[2*inch, 4*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 and add charts
elements.append(PageBreak())
elements.append(Paragraph('Market Data Visualizations', styles['CustomHeading1']))
elements.append(Spacer(1, 12))
# Stock price chart
print("Generating stock price chart...")
price_chart = generate_stock_price_chart(ticker, analysis_date)
if price_chart:
img = Image(price_chart, width=6*inch, height=3.6*inch)
elements.append(img)
elements.append(Spacer(1, 20))
# Volume chart
print("Generating volume chart...")
volume_chart = generate_volume_chart(ticker, analysis_date)
if volume_chart:
img = Image(volume_chart, width=6*inch, height=2.4*inch)
elements.append(img)
elements.append(Spacer(1, 20))
# Technical indicators chart
print("Generating technical indicators chart...")
tech_chart = generate_technical_indicators_chart(ticker, analysis_date)
if tech_chart:
img = Image(tech_chart, width=6*inch, height=4.8*inch)
elements.append(img)
elements.append(Spacer(1, 20))
# Helper function to clean and escape text for PDF
def clean_text(text: str) -> str:
"""Clean text and properly escape for reportlab."""
# Remove markdown headers
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# Escape special XML/HTML characters first
text = text.replace('&', '&amp;')
text = text.replace('<', '&lt;')
text = text.replace('>', '&gt;')
# Now we can safely add our formatting tags
# Convert markdown bold (**text** or __text__) to HTML bold
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
# Convert markdown italic (*text* or _text_) to HTML italic
# Be careful not to match ** or __
text = re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'<i>\1</i>', text)
text = re.sub(r'(?<!_)_(?!_)(.+?)(?<!_)_(?!_)', r'<i>\1</i>', text)
# Remove any remaining problematic characters
text = text.replace('\r', '')
return text
# Helper function to add section
def add_section(title: str, content: str):
if content:
elements.append(Paragraph(title, styles['CustomHeading1']))
elements.append(Spacer(1, 12))
# Clean the content
content = clean_text(content)
# Split content into paragraphs
paragraphs = content.split('\n\n')
for para in paragraphs:
para = para.strip()
if para:
# Replace single newlines with spaces, but preserve structure
para = para.replace('\n', ' ')
# Clean up multiple spaces
para = re.sub(r'\s+', ' ', para)
try:
elements.append(Paragraph(para, styles['CustomBody']))
except Exception as e:
# If there's still an error, add as plain text
print(f"Warning: Could not parse paragraph, adding as plain text: {e}")
elements.append(Paragraph(para.replace('<', '&lt;').replace('>', '&gt;'), styles['CustomBody']))
elements.append(Spacer(1, 20))
# I. Analyst Team Reports
elements.append(PageBreak())
elements.append(Paragraph('I. Analyst Team Reports', styles['CustomHeading1']))
elements.append(Spacer(1, 20))
if final_state.get('market_report'):
add_section('Market Analysis', final_state['market_report'])
if final_state.get('sentiment_report'):
add_section('Social Sentiment Analysis', final_state['sentiment_report'])
if final_state.get('news_report'):
add_section('News Analysis', final_state['news_report'])
if final_state.get('fundamentals_report'):
add_section('Fundamentals Analysis', final_state['fundamentals_report'])
# II. Research Team Reports
if final_state.get('investment_debate_state'):
elements.append(PageBreak())
elements.append(Paragraph('II. Research Team Decision', styles['CustomHeading1']))
elements.append(Spacer(1, 20))
debate_state = final_state['investment_debate_state']
if debate_state.get('bull_history'):
add_section('Bull Researcher Analysis', debate_state['bull_history'])
if debate_state.get('bear_history'):
add_section('Bear Researcher Analysis', debate_state['bear_history'])
if debate_state.get('judge_decision'):
add_section('Research Manager Decision', debate_state['judge_decision'])
# III. Trading Team Reports
if final_state.get('trader_investment_plan'):
elements.append(PageBreak())
elements.append(Paragraph('III. Trading Team Plan', styles['CustomHeading1']))
elements.append(Spacer(1, 20))
add_section('Trader Plan', final_state['trader_investment_plan'])
# IV. Risk Management Team Reports
if final_state.get('risk_debate_state'):
elements.append(PageBreak())
elements.append(Paragraph('IV. Risk Management Team Decision', styles['CustomHeading1']))
elements.append(Spacer(1, 20))
risk_state = final_state['risk_debate_state']
if risk_state.get('risky_history'):
add_section('Aggressive Analyst Analysis', risk_state['risky_history'])
if risk_state.get('safe_history'):
add_section('Conservative Analyst Analysis', risk_state['safe_history'])
if risk_state.get('neutral_history'):
add_section('Neutral Analyst Analysis', risk_state['neutral_history'])
# V. Portfolio Manager Decision
if risk_state.get('judge_decision'):
elements.append(PageBreak())
elements.append(Paragraph('V. Portfolio Manager Decision', styles['CustomHeading1']))
elements.append(Spacer(1, 20))
add_section('Final Decision', risk_state['judge_decision'])
# Build PDF
doc.build(elements)
return pdf_file

View File

@ -24,3 +24,5 @@ rich
questionary
langchain_anthropic
langchain-google-genai
reportlab
matplotlib