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