From b950e3a018fad32bf3d8bdf3c72e27b32fbead26 Mon Sep 17 00:00:00 2001 From: MUmarJ Date: Mon, 19 Jan 2026 22:22:37 -0500 Subject: [PATCH] feat: add date filtering to PDF report compiler Adds --date arg to filter reports by date and auto-generate filename with date and symbols --- cli/compile_reports.py | 501 +++++++++++++++++++++++++++++++++++++++++ cli/main.py | 44 +++- 2 files changed, 536 insertions(+), 9 deletions(-) create mode 100644 cli/compile_reports.py diff --git a/cli/compile_reports.py b/cli/compile_reports.py new file mode 100644 index 00000000..1026823b --- /dev/null +++ b/cli/compile_reports.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +""" +Compile all trading agent reports into a single consolidated PDF. + +Creates a PDF with: +1. Summary table showing all symbols, their decisions, and analysis dates +2. Detailed reports for each symbol (in order specified by REPORT_ORDER) + +Usage: + python cli/compile_reports.py # Compile all results into single PDF + python cli/compile_reports.py --output report.pdf # Custom output filename + python cli/compile_reports.py --date 2026-01-18 # Filter to specific date (auto-names output) +""" + +import argparse +import re +import sys +from datetime import datetime +from pathlib import Path + +import markdown2 +from playwright.sync_api import sync_playwright + +# Report order (top to bottom for each symbol's section) +REPORT_ORDER = [ + ("final_trade_decision.md", "Final Trade Decision"), + ("trader_investment_plan.md", "Trader Investment Plan"), + ("investment_plan.md", "Investment Plan"), + ("fundamentals_report.md", "Fundamentals Analysis"), + ("news_report.md", "News Analysis"), + ("sentiment_report.md", "Sentiment Analysis"), + ("market_report.md", "Market Analysis"), +] + +# Clean GitHub-style markdown CSS +CSS_STYLES = """ +@page { + size: A4; + margin: 0.75in; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.6; + color: #24292f; + max-width: 100%; + margin: 0; + padding: 0; +} + +h1 { + font-size: 2em; + font-weight: 600; + border-bottom: 1px solid #d0d7de; + padding-bottom: 0.3em; + margin-top: 24px; + margin-bottom: 16px; +} + +h2 { + font-size: 1.5em; + font-weight: 600; + border-bottom: 1px solid #d0d7de; + padding-bottom: 0.3em; + margin-top: 24px; + margin-bottom: 16px; +} + +h3 { + font-size: 1.25em; + font-weight: 600; + margin-top: 24px; + margin-bottom: 16px; +} + +h4 { + font-size: 1em; + font-weight: 600; + margin-top: 24px; + margin-bottom: 16px; +} + +p { + margin-top: 0; + margin-bottom: 16px; +} + +ul, ol { + padding-left: 2em; + margin-top: 0; + margin-bottom: 16px; +} + +li { + margin-bottom: 4px; +} + +li + li { + margin-top: 4px; +} + +table { + border-collapse: collapse; + width: 100%; + margin-top: 0; + margin-bottom: 16px; +} + +th, td { + padding: 6px 13px; + border: 1px solid #d0d7de; +} + +th { + background-color: #f6f8fa; + font-weight: 600; +} + +tr:nth-child(2n) { + background-color: #f6f8fa; +} + +hr { + border: 0; + border-top: 1px solid #d0d7de; + margin: 24px 0; +} + +code { + background-color: rgba(175, 184, 193, 0.2); + padding: 0.2em 0.4em; + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + font-size: 85%; +} + +pre { + background-color: #f6f8fa; + padding: 16px; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 16px; + font-size: 85%; + line-height: 1.45; +} + +pre code { + padding: 0; + background: none; + font-size: 100%; +} + +blockquote { + border-left: 0.25em solid #d0d7de; + padding: 0 1em; + margin: 0 0 16px 0; + color: #57606a; +} + +strong { + font-weight: 600; +} + +/* Decision color styling */ +.decision-buy { color: #1a7f37; font-weight: 700; } +.decision-sell { color: #cf222e; font-weight: 700; } +.decision-hold { color: #9a6700; font-weight: 700; } + +/* Symbol section - page break before each new symbol */ +.symbol-section { + page-break-before: always; +} + +.symbol-section:first-of-type { + page-break-before: avoid; +} + +/* Report title styling */ +.report-title { + color: #0969da; + font-size: 1.3em; + font-weight: 600; + margin-top: 32px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 2px solid #0969da; +} + +.report-title:first-of-type { + margin-top: 16px; +} +""" + + +def extract_decision(content: str) -> str: + """Extract BUY/SELL/HOLD decision from final trade decision content.""" + content_lower = content.lower() + + patterns = [ + r"recommendation[:\s]*\*{0,2}(buy|sell|hold)\*{0,2}", + r"\*{0,2}(buy|sell|hold)\*{0,2}[:\s]*recommendation", + r"final.*?decision[:\s]*\*{0,2}(buy|sell|hold)\*{0,2}", + r"recommend.*?(buy|sell|hold)", + r"action[:\s]*\*{0,2}(buy|sell|hold)\*{0,2}", + ] + + for pattern in patterns: + match = re.search(pattern, content_lower) + if match: + return match.group(1).upper() + + buy_count = len(re.findall(r"\bbuy\b", content_lower)) + sell_count = len(re.findall(r"\bsell\b", content_lower)) + hold_count = len(re.findall(r"\bhold\b", content_lower)) + + max_count = max(buy_count, sell_count, hold_count) + if max_count > 0: + if sell_count == max_count: + return "SELL" + if buy_count == max_count: + return "BUY" + return "HOLD" + + return "N/A" + + +def markdown_to_html(md_content: str) -> str: + """Convert markdown to HTML with extras.""" + return markdown2.markdown( + md_content, + extras=[ + "tables", + "fenced-code-blocks", + "strike", + "task_list", + "cuddled-lists", + ], + ) + + +def find_all_reports(results_dir: Path, date_filter: str | None = None) -> list[dict]: + """Find all report directories and extract their data. + + Args: + results_dir: Path to the results directory + date_filter: Optional date string (YYYY-MM-DD) to filter reports + """ + all_reports = [] + + if not results_dir.exists(): + return all_reports + + for symbol_dir in sorted(results_dir.iterdir()): + if not symbol_dir.is_dir(): + continue + + symbol = symbol_dir.name + if symbol.startswith(".") or " " in symbol: + continue + + for date_dir in sorted(symbol_dir.iterdir(), reverse=True): + if not date_dir.is_dir(): + continue + + date = date_dir.name + + # Skip if date doesn't match filter + if date_filter and date != date_filter: + continue + + reports_dir = date_dir / "reports" + + if not reports_dir.exists(): + continue + + report_files = [] + decision = "N/A" + + for filename, title in REPORT_ORDER: + file_path = reports_dir / filename + if file_path.exists(): + content = file_path.read_text(encoding="utf-8") + html_content = markdown_to_html(content) + report_files.append((filename, title, html_content)) + + if filename == "final_trade_decision.md": + decision = extract_decision(content) + + if report_files: + all_reports.append({ + "symbol": symbol, + "date": date, + "decision": decision, + "reports_dir": reports_dir, + "reports": report_files, + }) + + return all_reports + + +def build_html_document(all_reports: list[dict]) -> str: + """Build complete HTML document with summary table and all reports.""" + + # Build summary table rows + summary_rows = [] + for report_data in all_reports: + decision = report_data["decision"] + decision_class = f"decision-{decision.lower()}" if decision in ["BUY", "SELL", "HOLD"] else "" + summary_rows.append(f''' + {report_data["symbol"]} + {report_data["date"]} + {decision} + {len(report_data["reports"])} reports + ''') + + summary_table = "\n".join(summary_rows) + + # Build symbol sections + symbol_sections = [] + for report_data in all_reports: + symbol = report_data["symbol"] + date = report_data["date"] + decision = report_data["decision"] + decision_class = f"decision-{decision.lower()}" if decision in ["BUY", "SELL", "HOLD"] else "" + + # Build report content - simple flowing structure + reports_html_parts = [] + for _, title, html_content in report_data["reports"]: + reports_html_parts.append(f'''
{title}
+{html_content}''') + + reports_html = "\n".join(reports_html_parts) + + symbol_sections.append(f'''
+

{symbol} Trading Analysis Report

+

Date: {date}  |  Recommendation: {decision}

+
+{reports_html} +
''') + + all_symbols_html = "\n".join(symbol_sections) + + generated_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + html = f''' + + + + + Trading Analysis Report + + + +

Trading Analysis Report

+

Generated: {generated_date}

+ +

Summary of Recommendations

+ + + + + + + + + + + {summary_table} + +
SymbolAnalysis DateDecisionReports
+ +
+ +{all_symbols_html} + +
+

Report generated by TradingAgents

+ +''' + return html + + +def compile_to_pdf(html_content: str, output_path: Path) -> bool: + """Generate PDF from HTML using Playwright.""" + try: + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.set_content(html_content, wait_until="networkidle") + + page.pdf( + path=str(output_path), + format="A4", + margin={ + "top": "0.5in", + "bottom": "0.5in", + "left": "0.5in", + "right": "0.5in", + }, + print_background=True, + ) + browser.close() + return True + except Exception as e: + print(f"Error generating PDF: {e}") + return False + + +def main(): + parser = argparse.ArgumentParser( + description="Compile all trading agent reports into a single consolidated PDF", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python cli/compile_reports.py + python cli/compile_reports.py --output my_report.pdf + python cli/compile_reports.py --date 2026-01-18 + python cli/compile_reports.py --date 2026-01-18 --output custom.pdf + """, + ) + parser.add_argument( + "--output", "-o", + default="./results/trading_analysis_report.pdf", + help="Output PDF filename (default: ./results/trading_analysis_report.pdf)", + ) + parser.add_argument( + "--results-dir", "-r", + default="./results", + help="Results directory (default: ./results)", + ) + parser.add_argument( + "--date", "-d", + help="Filter reports to a specific date (format: YYYY-MM-DD)", + ) + + args = parser.parse_args() + + # Validate date format if provided + if args.date: + import re as re_module + if not re_module.match(r"^\d{4}-\d{2}-\d{2}$", args.date): + print(f"Error: Invalid date format '{args.date}'. Expected YYYY-MM-DD") + sys.exit(1) + + results_dir = Path(args.results_dir) + default_output = "./results/trading_analysis_report.pdf" + + if not results_dir.exists(): + print(f"Error: Results directory not found: {results_dir}") + sys.exit(1) + + if args.date: + print(f"Scanning {results_dir} for reports on {args.date}...") + else: + print(f"Scanning {results_dir} for reports...") + + all_reports = find_all_reports(results_dir, date_filter=args.date) + + if not all_reports: + if args.date: + print(f"No reports found for date {args.date}") + else: + print("No reports found") + sys.exit(1) + + print(f"Found {len(all_reports)} symbol analysis report(s):\n") + + for report_data in all_reports: + decision_indicator = { + "BUY": "[BUY]", + "SELL": "[SELL]", + "HOLD": "[HOLD]", + }.get(report_data["decision"], "[N/A]") + + print(f" {report_data['symbol']:6} | {report_data['date']} | {decision_indicator:6} | {len(report_data['reports'])} reports") + + # Determine output path + if args.date and args.output == default_output: + # Generate dynamic filename from date + symbols (up to 5) + symbols = [r["symbol"] for r in all_reports[:5]] + symbols_str = "_".join(symbols) + output_path = Path(f"./results/trading_report_{args.date}_{symbols_str}.pdf") + else: + output_path = Path(args.output) + + print("\nGenerating PDF...") + + html_document = build_html_document(all_reports) + + if compile_to_pdf(html_document, output_path): + print(f"\n+ PDF created: {output_path}") + else: + print("\n- Failed to create PDF") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/cli/main.py b/cli/main.py index 9a13b596..22b48537 100644 --- a/cli/main.py +++ b/cli/main.py @@ -429,10 +429,12 @@ def get_user_selections(): box_content += f"\n[dim]Default: {default}[/dim]" return Panel(box_content, border_style="blue", padding=(1, 2)) - # Step 1: Ticker symbol + # Step 1: Ticker symbol(s) console.print( create_question_box( - "Step 1: Ticker Symbol", "Enter the ticker symbol to analyze", "SPY" + "Step 1: Ticker Symbol(s)", + "Enter ticker symbol(s) to analyze (comma-separated for multiple)", + "SPY", ) ) selected_ticker = get_ticker() @@ -504,8 +506,11 @@ def get_user_selections(): def get_ticker(): - """Get ticker symbol from user input.""" - return typer.prompt("", default="SPY") + """Get ticker symbol(s) from user input. Supports comma-separated symbols.""" + raw_input = typer.prompt("", default="SPY") + # Split by comma, strip whitespace, convert to uppercase + symbols = [s.strip().upper() for s in raw_input.split(",") if s.strip()] + return symbols if len(symbols) > 1 else symbols[0] def get_analysis_date(): @@ -743,6 +748,7 @@ def extract_content_string(content): return str(content) def run_analysis(): + """Run analysis for one or more ticker symbols.""" # First get all user selections selections = get_user_selections() @@ -755,13 +761,33 @@ def run_analysis(): config["backend_url"] = selections["backend_url"] config["llm_provider"] = selections["llm_provider"].lower() - # Initialize the graph + # Normalize ticker(s) to list + tickers = selections["ticker"] if isinstance(selections["ticker"], list) else [selections["ticker"]] + + # Initialize the graph once and reuse for all symbols graph = TradingAgentsGraph( [analyst.value for analyst in selections["analysts"]], config=config, debug=True ) + for i, ticker in enumerate(tickers, 1): + if len(tickers) > 1: + console.print(f"\n[bold cyan]{'═' * 50}[/bold cyan]") + console.print(f"[bold cyan] Analyzing {ticker} ({i}/{len(tickers)})[/bold cyan]") + console.print(f"[bold cyan]{'═' * 50}[/bold cyan]\n") + + run_single_analysis(ticker, selections, config, graph) + + if i < len(tickers): + console.print(f"\n[dim]Moving to next symbol...[/dim]\n") + + if len(tickers) > 1: + console.print(f"\n[bold green]Completed analysis for all {len(tickers)} symbols: {', '.join(tickers)}[/bold green]") + + +def run_single_analysis(ticker: str, selections: dict, config: dict, graph: TradingAgentsGraph): + """Run analysis for a single ticker symbol.""" # Create result directory - results_dir = Path(config["results_dir"]) / selections["ticker"] / selections["analysis_date"] + results_dir = Path(config["results_dir"]) / ticker / selections["analysis_date"] results_dir.mkdir(parents=True, exist_ok=True) report_dir = results_dir / "reports" report_dir.mkdir(parents=True, exist_ok=True) @@ -815,7 +841,7 @@ def run_analysis(): update_display(layout) # Add initial messages - message_buffer.add_message("System", f"Selected ticker: {selections['ticker']}") + message_buffer.add_message("System", f"Selected ticker: {ticker}") message_buffer.add_message( "System", f"Analysis date: {selections['analysis_date']}" ) @@ -842,13 +868,13 @@ def run_analysis(): # Create spinner text spinner_text = ( - f"Analyzing {selections['ticker']} on {selections['analysis_date']}..." + f"Analyzing {ticker} on {selections['analysis_date']}..." ) update_display(layout, spinner_text) # Initialize state and get graph args init_agent_state = graph.propagator.create_initial_state( - selections["ticker"], selections["analysis_date"] + ticker, selections["analysis_date"] ) args = graph.propagator.get_graph_args()