diff --git a/cli/chart_generator.py b/cli/chart_generator.py
new file mode 100644
index 00000000..655396ba
--- /dev/null
+++ b/cli/chart_generator.py
@@ -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
diff --git a/cli/main.py b/cli/main.py
index 2e06d50c..571575a0 100644
--- a/cli/main.py
+++ b/cli/main.py
@@ -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():
diff --git a/cli/pdf_generator.py b/cli/pdf_generator.py
new file mode 100644
index 00000000..1f4c8197
--- /dev/null
+++ b/cli/pdf_generator.py
@@ -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
{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('&', '&')
+ text = text.replace('<', '<')
+ text = text.replace('>', '>')
+
+ # Now we can safely add our formatting tags
+ # Convert markdown bold (**text** or __text__) to HTML bold
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
+ text = re.sub(r'__(.+?)__', r'\1', text)
+
+ # Convert markdown italic (*text* or _text_) to HTML italic
+ # Be careful not to match ** or __
+ text = re.sub(r'(?\1', text)
+ text = re.sub(r'(?\1', 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('<', '<').replace('>', '>'), 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
diff --git a/requirements.txt b/requirements.txt
index a6154cd2..a8d7c544 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -24,3 +24,5 @@ rich
questionary
langchain_anthropic
langchain-google-genai
+reportlab
+matplotlib