diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f5e048..66b80e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Export reports to file with metadata (Issue #21) + - YAML frontmatter formatting for report metadata [file:tradingagents/utils/report_exporter.py:63-111](tradingagents/utils/report_exporter.py) + - Report creation with combined YAML frontmatter and markdown content [file:tradingagents/utils/report_exporter.py:112-136](tradingagents/utils/report_exporter.py) + - Safe filename generation with date prefixes and sanitization [file:tradingagents/utils/report_exporter.py:137-185](tradingagents/utils/report_exporter.py) + - JSON metadata serialization with datetime handling and directory creation [file:tradingagents/utils/report_exporter.py:186-220](tradingagents/utils/report_exporter.py) + - Comprehensive report generation combining multiple sections with table of contents [file:tradingagents/utils/report_exporter.py:221-325](tradingagents/utils/report_exporter.py) + - Support for organizing report sections by team (Analyst, Research, Trading, Portfolio) + - Datetime-to-ISO-string conversion for YAML/JSON serialization + - Helper functions for basic YAML formatting when PyYAML is unavailable + - Comprehensive test suite for all report export functions [file:tests/test_report_exporter.py](tests/test_report_exporter.py) + - Public API exports in utils/__init__.py for easy access - Rate limit error handling for LLM APIs (Issue #39) - Unified exception hierarchy for handling rate limit errors across providers (OpenAI, Anthropic, OpenRouter) [file:tradingagents/utils/exceptions.py](tradingagents/utils/exceptions.py) - Dual-output logging configuration supporting both terminal and file outputs [file:tradingagents/utils/logging_config.py](tradingagents/utils/logging_config.py) diff --git a/tests/test_report_exporter.py b/tests/test_report_exporter.py new file mode 100644 index 00000000..f4596115 --- /dev/null +++ b/tests/test_report_exporter.py @@ -0,0 +1,807 @@ +""" +Tests for tradingagents/utils/report_exporter.py - Report export utilities with metadata. + +This test file follows TDD principles - tests are written BEFORE implementation. +All tests should FAIL initially (RED phase) until the implementation is complete. + +Test Coverage: +1. YAML frontmatter formatting and validation +2. Report creation with frontmatter +3. Filename generation following YYYY-MM-DD_SectionName.md pattern +4. JSON metadata serialization with datetime handling +5. Comprehensive report generation +6. Integration with save_report_section_decorator +""" + +import json +import pytest +import tempfile +import yaml +from datetime import datetime +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +# Import the module to test (will fail initially - TDD RED phase) +# from tradingagents.utils.report_exporter import ( +# format_metadata_frontmatter, +# create_report_with_frontmatter, +# generate_section_filename, +# save_json_metadata, +# generate_comprehensive_report, +# ) + + +@pytest.fixture +def temp_output_dir(): + """Create a temporary directory for output files.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def sample_metadata(): + """Create sample metadata for testing.""" + return { + "ticker": "AAPL", + "analysis_date": "2024-12-26", + "date_range": "2024-11-26 to 2024-12-26", + "analysts": ["market", "sentiment", "news", "fundamentals"], + "data_vendor": "alpaca", + "llm_provider": "openrouter", + "shallow_thinker": "anthropic/claude-3.5-sonnet", + "deep_thinker": "anthropic/claude-opus-4.5", + "generated_at": datetime(2024, 12, 26, 14, 30, 0), + } + + +@pytest.fixture +def sample_report_sections(): + """Create sample report sections for testing.""" + return { + "market_report": "# Market Analysis\n\nAPPL shows strong momentum...", + "sentiment_report": "# Social Sentiment\n\nPositive sentiment across social media...", + "news_report": "# News Analysis\n\nRecent product launch received well...", + "fundamentals_report": "# Fundamentals Analysis\n\nStrong financials with P/E of 25...", + "investment_plan": "# Investment Plan\n\nRecommend BUY with target price $180...", + "trader_investment_plan": "# Trading Plan\n\nEntry at $175, stop loss at $170...", + "final_trade_decision": "# Final Decision\n\nExecute BUY order for 100 shares...", + } + + +@pytest.fixture +def partial_report_sections(): + """Create partial report sections (some analysts haven't completed).""" + return { + "market_report": "# Market Analysis\n\nAPPL shows strong momentum...", + "sentiment_report": None, + "news_report": "# News Analysis\n\nRecent product launch received well...", + "fundamentals_report": None, + "investment_plan": None, + "trader_investment_plan": None, + "final_trade_decision": None, + } + + +class TestFormatMetadataFrontmatter: + """Test format_metadata_frontmatter() function.""" + + def test_generates_valid_yaml_frontmatter(self, sample_metadata): + """Test that frontmatter is valid YAML wrapped in --- delimiters.""" + from tradingagents.utils.report_exporter import format_metadata_frontmatter + + result = format_metadata_frontmatter(sample_metadata) + + # Should start and end with --- delimiters + assert result.startswith("---\n") + assert result.endswith("---\n") + + # Extract YAML content between delimiters + yaml_content = result.split("---\n")[1] + + # Should parse as valid YAML + parsed = yaml.safe_load(yaml_content) + assert isinstance(parsed, dict) + + def test_includes_all_metadata_fields(self, sample_metadata): + """Test that all metadata fields are included in frontmatter.""" + from tradingagents.utils.report_exporter import format_metadata_frontmatter + + result = format_metadata_frontmatter(sample_metadata) + yaml_content = result.split("---\n")[1] + parsed = yaml.safe_load(yaml_content) + + assert parsed["ticker"] == "AAPL" + assert parsed["analysis_date"] == "2024-12-26" + assert parsed["date_range"] == "2024-11-26 to 2024-12-26" + assert parsed["analysts"] == ["market", "sentiment", "news", "fundamentals"] + assert parsed["data_vendor"] == "alpaca" + assert parsed["llm_provider"] == "openrouter" + assert parsed["shallow_thinker"] == "anthropic/claude-3.5-sonnet" + assert parsed["deep_thinker"] == "anthropic/claude-opus-4.5" + + def test_handles_datetime_serialization(self, sample_metadata): + """Test that datetime objects are properly serialized to ISO format.""" + from tradingagents.utils.report_exporter import format_metadata_frontmatter + + result = format_metadata_frontmatter(sample_metadata) + yaml_content = result.split("---\n")[1] + parsed = yaml.safe_load(yaml_content) + + # generated_at should be serialized as ISO string + assert "generated_at" in parsed + assert isinstance(parsed["generated_at"], str) + assert parsed["generated_at"] == "2024-12-26T14:30:00" + + def test_handles_empty_metadata(self): + """Test that empty metadata dict produces valid YAML.""" + from tradingagents.utils.report_exporter import format_metadata_frontmatter + + result = format_metadata_frontmatter({}) + + assert result.startswith("---\n") + assert result.endswith("---\n") + yaml_content = result.split("---\n")[1] + parsed = yaml.safe_load(yaml_content) + assert parsed == {} or parsed is None + + def test_handles_none_values(self): + """Test that None values in metadata are handled gracefully.""" + from tradingagents.utils.report_exporter import format_metadata_frontmatter + + metadata = { + "ticker": "AAPL", + "analysis_date": None, + "analysts": None, + } + + result = format_metadata_frontmatter(metadata) + yaml_content = result.split("---\n")[1] + parsed = yaml.safe_load(yaml_content) + + assert parsed["ticker"] == "AAPL" + assert parsed["analysis_date"] is None + assert parsed["analysts"] is None + + def test_handles_special_characters_in_strings(self): + """Test that special characters in strings are properly escaped.""" + from tradingagents.utils.report_exporter import format_metadata_frontmatter + + metadata = { + "ticker": "AAPL", + "notes": "Quote: \"strong buy\" & wait for: $180", + } + + result = format_metadata_frontmatter(metadata) + yaml_content = result.split("---\n")[1] + parsed = yaml.safe_load(yaml_content) + + # Special characters should be preserved + assert parsed["notes"] == "Quote: \"strong buy\" & wait for: $180" + + +class TestCreateReportWithFrontmatter: + """Test create_report_with_frontmatter() function.""" + + def test_combines_frontmatter_and_content(self, sample_metadata): + """Test that frontmatter and content are properly combined.""" + from tradingagents.utils.report_exporter import create_report_with_frontmatter + + content = "# Market Analysis\n\nAPPL shows strong momentum..." + result = create_report_with_frontmatter(content, sample_metadata) + + # Should start with frontmatter + assert result.startswith("---\n") + + # Should contain content after frontmatter + assert "# Market Analysis" in result + assert "APPL shows strong momentum..." in result + + # Frontmatter should be followed by blank line and content + parts = result.split("---\n", 2) + assert len(parts) == 3 # ['', yaml_content, content] + + def test_frontmatter_before_content(self, sample_metadata): + """Test that frontmatter appears before content with proper spacing.""" + from tradingagents.utils.report_exporter import create_report_with_frontmatter + + content = "# Market Analysis\n\nContent here" + result = create_report_with_frontmatter(content, sample_metadata) + + # Find where frontmatter ends + frontmatter_end = result.find("---\n", 4) # Skip first --- + content_start = result.find("# Market Analysis") + + assert frontmatter_end < content_start + assert frontmatter_end > 0 + assert content_start > 0 + + def test_handles_empty_content(self, sample_metadata): + """Test that empty content string is handled gracefully.""" + from tradingagents.utils.report_exporter import create_report_with_frontmatter + + result = create_report_with_frontmatter("", sample_metadata) + + # Should still have valid frontmatter + assert result.startswith("---\n") + assert "---\n" in result[4:] # Second --- exists + + def test_handles_multiline_content(self, sample_metadata): + """Test that multiline content is preserved correctly.""" + from tradingagents.utils.report_exporter import create_report_with_frontmatter + + content = """# Market Analysis + +## Price Action +AAPL shows strong momentum. + +## Volume +High volume confirms the trend. + +## Conclusion +Bullish outlook.""" + + result = create_report_with_frontmatter(content, sample_metadata) + + # All content lines should be preserved + assert "# Market Analysis" in result + assert "## Price Action" in result + assert "## Volume" in result + assert "## Conclusion" in result + + def test_preserves_content_formatting(self, sample_metadata): + """Test that content formatting (code blocks, lists, etc) is preserved.""" + from tradingagents.utils.report_exporter import create_report_with_frontmatter + + content = """# Analysis + +```python +print("test") +``` + +- Item 1 +- Item 2 + +**Bold text**""" + + result = create_report_with_frontmatter(content, sample_metadata) + + assert "```python" in result + assert 'print("test")' in result + assert "- Item 1" in result + assert "**Bold text**" in result + + +class TestGenerateSectionFilename: + """Test generate_section_filename() function.""" + + def test_follows_date_section_pattern(self): + """Test that filename follows YYYY-MM-DD_SectionName.md pattern.""" + from tradingagents.utils.report_exporter import generate_section_filename + + result = generate_section_filename("market_report", "2024-12-26") + + assert result == "2024-12-26_market_report.md" + + def test_converts_section_name_to_snake_case(self): + """Test that section names are converted to snake_case.""" + from tradingagents.utils.report_exporter import generate_section_filename + + result = generate_section_filename("Market Report", "2024-12-26") + + # Should convert spaces to underscores and lowercase + assert result == "2024-12-26_market_report.md" + + def test_handles_various_date_formats(self): + """Test that various date string formats work.""" + from tradingagents.utils.report_exporter import generate_section_filename + + # ISO format + result1 = generate_section_filename("market_report", "2024-12-26") + assert result1 == "2024-12-26_market_report.md" + + # Different separator (should still work or normalize) + result2 = generate_section_filename("market_report", "2024/12/26") + assert "2024" in result2 + assert "12" in result2 + assert "26" in result2 + + def test_handles_special_characters_in_section_name(self): + """Test that special characters in section name are handled.""" + from tradingagents.utils.report_exporter import generate_section_filename + + result = generate_section_filename("market-report/analysis", "2024-12-26") + + # Special characters should be replaced or removed + assert result.endswith(".md") + assert "2024-12-26" in result + + def test_always_adds_md_extension(self): + """Test that .md extension is always added.""" + from tradingagents.utils.report_exporter import generate_section_filename + + result1 = generate_section_filename("market_report", "2024-12-26") + result2 = generate_section_filename("final_decision", "2024-12-26") + + assert result1.endswith(".md") + assert result2.endswith(".md") + + def test_comprehensive_report_filename(self): + """Test that comprehensive report gets special naming.""" + from tradingagents.utils.report_exporter import generate_section_filename + + result = generate_section_filename("comprehensive_report", "2024-12-26") + + assert result == "2024-12-26_comprehensive_report.md" + + +class TestSaveJsonMetadata: + """Test save_json_metadata() function.""" + + def test_creates_json_file(self, temp_output_dir, sample_metadata): + """Test that JSON file is created at specified path.""" + from tradingagents.utils.report_exporter import save_json_metadata + + filepath = temp_output_dir / "metadata.json" + save_json_metadata(sample_metadata, filepath) + + assert filepath.exists() + assert filepath.is_file() + + def test_saves_valid_json(self, temp_output_dir, sample_metadata): + """Test that saved file contains valid JSON.""" + from tradingagents.utils.report_exporter import save_json_metadata + + filepath = temp_output_dir / "metadata.json" + save_json_metadata(sample_metadata, filepath) + + with open(filepath, "r") as f: + data = json.load(f) + + assert isinstance(data, dict) + + def test_includes_all_metadata_fields(self, temp_output_dir, sample_metadata): + """Test that all metadata fields are saved to JSON.""" + from tradingagents.utils.report_exporter import save_json_metadata + + filepath = temp_output_dir / "metadata.json" + save_json_metadata(sample_metadata, filepath) + + with open(filepath, "r") as f: + data = json.load(f) + + assert data["ticker"] == "AAPL" + assert data["analysis_date"] == "2024-12-26" + assert data["analysts"] == ["market", "sentiment", "news", "fundamentals"] + assert data["llm_provider"] == "openrouter" + + def test_handles_datetime_serialization(self, temp_output_dir, sample_metadata): + """Test that datetime objects are serialized to ISO format strings.""" + from tradingagents.utils.report_exporter import save_json_metadata + + filepath = temp_output_dir / "metadata.json" + save_json_metadata(sample_metadata, filepath) + + with open(filepath, "r") as f: + data = json.load(f) + + # generated_at should be ISO string + assert "generated_at" in data + assert isinstance(data["generated_at"], str) + assert data["generated_at"] == "2024-12-26T14:30:00" + + def test_handles_nested_dictionaries(self, temp_output_dir): + """Test that nested dictionaries are properly serialized.""" + from tradingagents.utils.report_exporter import save_json_metadata + + metadata = { + "ticker": "AAPL", + "config": { + "llm_provider": "openrouter", + "models": { + "shallow": "claude-3.5-sonnet", + "deep": "claude-opus-4.5", + } + } + } + + filepath = temp_output_dir / "metadata.json" + save_json_metadata(metadata, filepath) + + with open(filepath, "r") as f: + data = json.load(f) + + assert data["config"]["llm_provider"] == "openrouter" + assert data["config"]["models"]["shallow"] == "claude-3.5-sonnet" + + def test_overwrites_existing_file(self, temp_output_dir): + """Test that existing file is overwritten with new data.""" + from tradingagents.utils.report_exporter import save_json_metadata + + filepath = temp_output_dir / "metadata.json" + + # Save first version + save_json_metadata({"ticker": "AAPL"}, filepath) + + # Save second version + save_json_metadata({"ticker": "GOOGL", "date": "2024-12-26"}, filepath) + + with open(filepath, "r") as f: + data = json.load(f) + + # Should have new data + assert data["ticker"] == "GOOGL" + assert data["date"] == "2024-12-26" + + def test_creates_parent_directories(self, temp_output_dir): + """Test that parent directories are created if they don't exist.""" + from tradingagents.utils.report_exporter import save_json_metadata + + filepath = temp_output_dir / "subdir" / "nested" / "metadata.json" + save_json_metadata({"ticker": "AAPL"}, filepath) + + assert filepath.exists() + + def test_handles_path_as_string(self, temp_output_dir): + """Test that function accepts both Path and string for filepath.""" + from tradingagents.utils.report_exporter import save_json_metadata + + filepath_str = str(temp_output_dir / "metadata.json") + save_json_metadata({"ticker": "AAPL"}, filepath_str) + + assert Path(filepath_str).exists() + + def test_pretty_prints_json(self, temp_output_dir): + """Test that JSON is formatted with indentation for readability.""" + from tradingagents.utils.report_exporter import save_json_metadata + + metadata = { + "ticker": "AAPL", + "analysts": ["market", "sentiment"], + } + + filepath = temp_output_dir / "metadata.json" + save_json_metadata(metadata, filepath) + + with open(filepath, "r") as f: + content = f.read() + + # Should have indentation + assert " " in content or "\t" in content + + +class TestGenerateComprehensiveReport: + """Test generate_comprehensive_report() function.""" + + def test_includes_all_sections(self, sample_report_sections, sample_metadata): + """Test that comprehensive report includes all completed sections.""" + from tradingagents.utils.report_exporter import generate_comprehensive_report + + result = generate_comprehensive_report(sample_report_sections, sample_metadata) + + # Should include frontmatter + assert result.startswith("---\n") + + # Should include all sections + assert "Market Analysis" in result + assert "Social Sentiment" in result + assert "News Analysis" in result + assert "Fundamentals Analysis" in result + assert "Investment Plan" in result + assert "Trading Plan" in result + assert "Final Decision" in result + + def test_skips_none_sections(self, partial_report_sections, sample_metadata): + """Test that None sections are skipped in comprehensive report.""" + from tradingagents.utils.report_exporter import generate_comprehensive_report + + result = generate_comprehensive_report(partial_report_sections, sample_metadata) + + # Should include completed sections + assert "Market Analysis" in result + assert "News Analysis" in result + + # Should not have placeholders for None sections + # (Exact format depends on implementation) + + def test_organizes_sections_by_team(self, sample_report_sections, sample_metadata): + """Test that sections are organized by team (Analyst, Research, Trading, Portfolio).""" + from tradingagents.utils.report_exporter import generate_comprehensive_report + + result = generate_comprehensive_report(sample_report_sections, sample_metadata) + + # Should have team headers + assert "Analyst Team" in result or "Market Analysis" in result + assert "Investment Plan" in result or "Research Team" in result + + def test_includes_metadata_in_frontmatter(self, sample_report_sections, sample_metadata): + """Test that metadata is included in frontmatter.""" + from tradingagents.utils.report_exporter import generate_comprehensive_report + + result = generate_comprehensive_report(sample_report_sections, sample_metadata) + + # Extract frontmatter + parts = result.split("---\n", 2) + yaml_content = parts[1] + parsed = yaml.safe_load(yaml_content) + + assert parsed["ticker"] == "AAPL" + assert parsed["llm_provider"] == "openrouter" + + def test_handles_empty_report_sections(self, sample_metadata): + """Test that empty report sections dict is handled gracefully.""" + from tradingagents.utils.report_exporter import generate_comprehensive_report + + result = generate_comprehensive_report({}, sample_metadata) + + # Should still have frontmatter + assert result.startswith("---\n") + + def test_preserves_markdown_formatting(self, sample_report_sections, sample_metadata): + """Test that markdown formatting in sections is preserved.""" + from tradingagents.utils.report_exporter import generate_comprehensive_report + + # Add markdown elements to sections + sample_report_sections["market_report"] = """# Market Analysis + +## Price Action +- Strong uptrend +- **Key level**: $175 + +``` +Support: $170 +Resistance: $180 +```""" + + result = generate_comprehensive_report(sample_report_sections, sample_metadata) + + assert "## Price Action" in result + assert "- Strong uptrend" in result + assert "**Key level**" in result + assert "```" in result + + def test_sections_appear_in_logical_order(self, sample_report_sections, sample_metadata): + """Test that sections appear in logical order (analysts -> research -> trading -> portfolio).""" + from tradingagents.utils.report_exporter import generate_comprehensive_report + + result = generate_comprehensive_report(sample_report_sections, sample_metadata) + + # Find positions of each section + market_pos = result.find("Market Analysis") + sentiment_pos = result.find("Social Sentiment") + investment_pos = result.find("Investment Plan") + trading_pos = result.find("Trading Plan") + final_pos = result.find("Final Decision") + + # Analyst sections should come before investment plan + assert market_pos < investment_pos + assert sentiment_pos < investment_pos + + # Investment plan before trading plan + assert investment_pos < trading_pos + + # Trading plan before final decision + assert trading_pos < final_pos + + +class TestSaveReportSectionDecoratorIntegration: + """Integration tests for enhanced save_report_section_decorator.""" + + def test_creates_section_file_with_frontmatter(self, temp_output_dir, sample_metadata): + """Test that decorator creates section files with YAML frontmatter.""" + # This will test the enhanced decorator in cli/main.py + # Mock the MessageBuffer and test the decorator + + from unittest.mock import Mock + # Import will be available after implementation + # from tradingagents.utils.report_exporter import create_report_with_frontmatter + + # For now, test the expected behavior + section_name = "market_report" + content = "# Market Analysis\n\nAPPL shows strength" + expected_filename = f"2024-12-26_{section_name}.md" + + # File should be created with frontmatter + # This test validates the integration point + + def test_saves_comprehensive_report(self, temp_output_dir, sample_report_sections): + """Test that comprehensive report is saved after all sections complete.""" + # Test that when all sections are complete, comprehensive report is generated + pass + + def test_saves_json_metadata_alongside_reports(self, temp_output_dir, sample_metadata): + """Test that JSON metadata file is saved alongside markdown reports.""" + # Test that metadata.json is created with all parameters + expected_file = temp_output_dir / "metadata.json" + + # File should exist and contain metadata + # This test validates the integration point + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_handles_unicode_in_content(self, sample_metadata): + """Test that unicode characters in content are handled correctly.""" + from tradingagents.utils.report_exporter import create_report_with_frontmatter + + content = "# Analysis\n\nPrice: €100, ¥1000, £50, 📈 trending up" + result = create_report_with_frontmatter(content, sample_metadata) + + assert "€100" in result + assert "¥1000" in result + assert "£50" in result + assert "📈" in result + + def test_handles_very_long_content(self, sample_metadata): + """Test that very long content is handled correctly.""" + from tradingagents.utils.report_exporter import create_report_with_frontmatter + + # Generate large content + content = "# Analysis\n\n" + ("Long paragraph. " * 10000) + result = create_report_with_frontmatter(content, sample_metadata) + + assert result.startswith("---\n") + assert "Long paragraph." in result + + def test_handles_empty_string_section_name(self): + """Test that empty section name is handled gracefully.""" + from tradingagents.utils.report_exporter import generate_section_filename + + # Should handle gracefully or raise descriptive error + try: + result = generate_section_filename("", "2024-12-26") + # If it doesn't raise, should produce valid filename + assert result.endswith(".md") + except ValueError as e: + # Or should raise descriptive error + assert "section" in str(e).lower() or "name" in str(e).lower() + + def test_handles_invalid_date_format(self): + """Test that invalid date format is handled gracefully.""" + from tradingagents.utils.report_exporter import generate_section_filename + + # Should handle gracefully or raise descriptive error + try: + result = generate_section_filename("market_report", "invalid-date") + assert result.endswith(".md") + except ValueError as e: + assert "date" in str(e).lower() + + def test_handles_path_with_spaces(self, temp_output_dir): + """Test that file paths with spaces are handled correctly.""" + from tradingagents.utils.report_exporter import save_json_metadata + + subdir = temp_output_dir / "path with spaces" + subdir.mkdir() + filepath = subdir / "metadata.json" + + save_json_metadata({"ticker": "AAPL"}, filepath) + + assert filepath.exists() + + def test_handles_concurrent_writes(self, temp_output_dir): + """Test that concurrent writes to same file are handled safely.""" + from tradingagents.utils.report_exporter import save_json_metadata + + filepath = temp_output_dir / "metadata.json" + + # Multiple writes in sequence + for i in range(5): + save_json_metadata({"iteration": i}, filepath) + + # Last write should win + with open(filepath, "r") as f: + data = json.load(f) + assert data["iteration"] == 4 + + def test_metadata_with_list_of_dicts(self, temp_output_dir): + """Test that metadata containing list of dictionaries is serialized correctly.""" + from tradingagents.utils.report_exporter import save_json_metadata + + metadata = { + "ticker": "AAPL", + "analysts_config": [ + {"name": "market", "enabled": True}, + {"name": "sentiment", "enabled": False}, + ] + } + + filepath = temp_output_dir / "metadata.json" + save_json_metadata(metadata, filepath) + + with open(filepath, "r") as f: + data = json.load(f) + + assert len(data["analysts_config"]) == 2 + assert data["analysts_config"][0]["name"] == "market" + assert data["analysts_config"][1]["enabled"] is False + + +class TestYAMLCompatibility: + """Test YAML frontmatter compatibility with various parsers.""" + + def test_frontmatter_parseable_by_jekyll(self, sample_metadata): + """Test that frontmatter is compatible with Jekyll static site generator.""" + from tradingagents.utils.report_exporter import format_metadata_frontmatter + + result = format_metadata_frontmatter(sample_metadata) + + # Jekyll expects exactly three dashes + assert result.startswith("---\n") + assert result.count("---\n") == 2 + + def test_frontmatter_parseable_by_hugo(self, sample_metadata): + """Test that frontmatter is compatible with Hugo static site generator.""" + from tradingagents.utils.report_exporter import format_metadata_frontmatter + + result = format_metadata_frontmatter(sample_metadata) + yaml_content = result.split("---\n")[1] + + # Hugo requires valid YAML + parsed = yaml.safe_load(yaml_content) + assert isinstance(parsed, dict) + + def test_frontmatter_no_tab_characters(self, sample_metadata): + """Test that frontmatter uses spaces not tabs (YAML requirement).""" + from tradingagents.utils.report_exporter import format_metadata_frontmatter + + result = format_metadata_frontmatter(sample_metadata) + + # YAML should use spaces for indentation + assert "\t" not in result + + +class TestFilenamePatterns: + """Test filename pattern generation and validation.""" + + def test_all_section_filenames_unique(self, sample_report_sections): + """Test that all section filenames are unique.""" + from tradingagents.utils.report_exporter import generate_section_filename + + date = "2024-12-26" + filenames = set() + + for section_name in sample_report_sections.keys(): + filename = generate_section_filename(section_name, date) + assert filename not in filenames + filenames.add(filename) + + # Should have 7 unique filenames + assert len(filenames) == 7 + + def test_comprehensive_report_filename_distinct(self): + """Test that comprehensive report filename is distinct from sections.""" + from tradingagents.utils.report_exporter import generate_section_filename + + date = "2024-12-26" + + section_files = [ + generate_section_filename("market_report", date), + generate_section_filename("sentiment_report", date), + ] + + comprehensive_file = generate_section_filename("comprehensive_report", date) + + assert comprehensive_file not in section_files + + def test_filename_sorting_chronological(self): + """Test that filenames sort chronologically by date.""" + from tradingagents.utils.report_exporter import generate_section_filename + + files = [ + generate_section_filename("market_report", "2024-12-26"), + generate_section_filename("market_report", "2024-12-25"), + generate_section_filename("market_report", "2024-12-27"), + ] + + sorted_files = sorted(files) + + # Should sort by date + assert sorted_files[0].startswith("2024-12-25") + assert sorted_files[1].startswith("2024-12-26") + assert sorted_files[2].startswith("2024-12-27") + + +if __name__ == "__main__": + # Run tests with minimal verbosity to prevent subprocess pipe deadlock (Issue #90) + pytest.main([__file__, "--tb=line", "-q"]) diff --git a/tradingagents/utils/__init__.py b/tradingagents/utils/__init__.py index 5d089982..09d761fe 100644 --- a/tradingagents/utils/__init__.py +++ b/tradingagents/utils/__init__.py @@ -17,6 +17,14 @@ from tradingagents.utils.logging_config import ( sanitize_log_message, ) +from tradingagents.utils.report_exporter import ( + format_metadata_frontmatter, + create_report_with_frontmatter, + generate_section_filename, + save_json_metadata, + generate_comprehensive_report, +) + __all__ = [ "LLMRateLimitError", "OpenAIRateLimitError", @@ -25,4 +33,9 @@ __all__ = [ "from_provider_error", "setup_dual_logger", "sanitize_log_message", + "format_metadata_frontmatter", + "create_report_with_frontmatter", + "generate_section_filename", + "save_json_metadata", + "generate_comprehensive_report", ] diff --git a/tradingagents/utils/report_exporter.py b/tradingagents/utils/report_exporter.py new file mode 100644 index 00000000..d9123f60 --- /dev/null +++ b/tradingagents/utils/report_exporter.py @@ -0,0 +1,373 @@ +""" +Report export utilities with metadata. + +This module provides functions for exporting trading analysis reports with YAML frontmatter +metadata and JSON sidecar files. It supports individual section reports and comprehensive +multi-section reports. + +Features: +- YAML frontmatter formatting for markdown files +- Report creation with metadata +- Safe filename generation with date prefixes +- JSON metadata serialization with datetime handling +- Comprehensive report generation with table of contents + +Usage: + from tradingagents.utils.report_exporter import ( + create_report_with_frontmatter, + generate_section_filename, + save_json_metadata, + generate_comprehensive_report + ) + + # Create single section report + metadata = { + "ticker": "AAPL", + "analysis_date": "2024-12-26", + "generated_at": datetime.now() + } + + content = "# Market Analysis\\n\\nStrong momentum..." + report = create_report_with_frontmatter(content, metadata) + + # Generate filename + filename = generate_section_filename("market_report", "2024-12-26") + + # Save JSON metadata + save_json_metadata(metadata, Path("output") / "metadata.json") + + # Create comprehensive report from multiple sections + sections = { + "market_report": "# Market Analysis\\n...", + "sentiment_report": "# Sentiment\\n..." + } + comprehensive = generate_comprehensive_report(sections, metadata) +""" + +import json +import re +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional, Union + +try: + import yaml +except ImportError: + yaml = None + +from tradingagents.utils.logging_config import setup_dual_logger + +logger = setup_dual_logger(__name__) + + +def format_metadata_frontmatter(metadata: dict) -> str: + """ + Format metadata dict as YAML frontmatter wrapped in --- delimiters. + + Converts metadata dictionary into YAML format suitable for markdown frontmatter. + Handles datetime objects by converting them to ISO format strings. Sorts keys + for consistency. + + Args: + metadata: Dictionary containing metadata fields + + Returns: + String containing YAML frontmatter wrapped in --- delimiters + + Example: + >>> metadata = {"ticker": "AAPL", "date": "2024-12-26"} + >>> frontmatter = format_metadata_frontmatter(metadata) + >>> print(frontmatter) + --- + ticker: AAPL + date: 2024-12-26 + --- + """ + if yaml is None: + logger.warning("PyYAML not installed - using basic YAML formatting") + # Fallback to basic YAML formatting if pyyaml not available + yaml_lines = [] + for key in sorted(metadata.keys()): + value = metadata[key] + if isinstance(value, datetime): + value = value.isoformat() + yaml_lines.append(f"{key}: {_format_yaml_value(value)}") + yaml_content = "\n".join(yaml_lines) + else: + # Convert datetime objects to ISO format strings + serializable_metadata = _convert_datetimes_to_iso(metadata) + + # Generate YAML with sorted keys + yaml_content = yaml.safe_dump( + serializable_metadata, + default_flow_style=False, + sort_keys=True, + allow_unicode=True + ).rstrip() + + # Wrap in frontmatter delimiters + return f"---\n{yaml_content}\n---\n" + + +def create_report_with_frontmatter(content: str, metadata: dict) -> str: + """ + Combine YAML frontmatter with markdown content. + + Creates a complete markdown report by prepending YAML frontmatter to the content. + The frontmatter is separated from content by a blank line for readability. + + Args: + content: Markdown content for the report + metadata: Dictionary containing metadata fields + + Returns: + String containing complete report with frontmatter and content + + Example: + >>> content = "# Market Analysis\\n\\nStrong momentum" + >>> metadata = {"ticker": "AAPL"} + >>> report = create_report_with_frontmatter(content, metadata) + """ + frontmatter = format_metadata_frontmatter(metadata) + + # Combine frontmatter and content with blank line separator + return f"{frontmatter}\n{content}" + + +def generate_section_filename(section_name: str, date: str) -> str: + """ + Generate safe filename from section name and date. + + Creates a filename following the pattern: YYYY-MM-DD_section_name.md + Sanitizes special characters, converts to lowercase, and replaces spaces + with underscores. + + Args: + section_name: Name of the report section (e.g., "market_report") + date: Date string in YYYY-MM-DD format (or similar formats) + + Returns: + String containing safe filename with .md extension + + Raises: + ValueError: If section_name is empty + + Example: + >>> filename = generate_section_filename("Market Report", "2024-12-26") + >>> print(filename) + 2024-12-26_market_report.md + """ + if not section_name or not section_name.strip(): + raise ValueError("Section name cannot be empty") + + # Normalize date format - replace / with - if present + normalized_date = date.replace("/", "-") + + # Sanitize section name: + # 1. Convert to lowercase + # 2. Replace spaces with underscores + # 3. Remove or replace special characters + sanitized_name = section_name.lower().strip() + sanitized_name = sanitized_name.replace(" ", "_") + + # Remove or replace special characters (keep alphanumeric, underscore, hyphen) + sanitized_name = re.sub(r'[^a-z0-9_-]', '_', sanitized_name) + + # Remove consecutive underscores + sanitized_name = re.sub(r'_+', '_', sanitized_name) + + # Remove leading/trailing underscores + sanitized_name = sanitized_name.strip('_') + + # Construct filename + return f"{normalized_date}_{sanitized_name}.md" + + +def save_json_metadata(metadata: dict, filepath: Union[Path, str]) -> None: + """ + Save metadata as JSON sidecar file. + + Serializes metadata dictionary to JSON with indentation for readability. + Handles datetime objects by converting to ISO format strings. Creates + parent directories if they don't exist. + + Args: + metadata: Dictionary containing metadata fields + filepath: Path where JSON file should be saved (Path or string) + + Returns: + None. Creates a JSON file at the specified filepath with formatted metadata. + + Example: + >>> metadata = {"ticker": "AAPL", "date": "2024-12-26"} + >>> save_json_metadata(metadata, Path("output/metadata.json")) + """ + # Convert to Path if string + filepath = Path(filepath) + + # Create parent directories if needed + filepath.parent.mkdir(parents=True, exist_ok=True) + + # Convert datetime objects to ISO format strings + serializable_metadata = _convert_datetimes_to_iso(metadata) + + # Write JSON with indentation for readability + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(serializable_metadata, f, indent=2, ensure_ascii=False) + + logger.debug(f"Saved JSON metadata to {filepath}") + + +def generate_comprehensive_report(report_sections: dict, metadata: dict) -> str: + """ + Combine all report sections into single comprehensive report. + + Creates a comprehensive markdown report by combining all completed sections + in logical order (Analyst Team -> Research Team -> Trading Team -> Portfolio Team). + Skips sections with None values. Includes YAML frontmatter with full metadata + and a table of contents. + + Args: + report_sections: Dictionary mapping section names to content (str or None) + metadata: Dictionary containing metadata fields + + Returns: + String containing comprehensive report with all sections + + Example: + >>> sections = { + ... "market_report": "# Market Analysis\\n...", + ... "sentiment_report": "# Sentiment\\n...", + ... "investment_plan": "# Investment Plan\\n..." + ... } + >>> metadata = {"ticker": "AAPL", "date": "2024-12-26"} + >>> report = generate_comprehensive_report(sections, metadata) + """ + # Start with frontmatter + frontmatter = format_metadata_frontmatter(metadata) + + # Define section order by team + section_order = [ + # Analyst Team + ("market_report", "Market Analysis"), + ("sentiment_report", "Social Sentiment"), + ("news_report", "News Analysis"), + ("fundamentals_report", "Fundamentals Analysis"), + # Research Team + ("investment_plan", "Investment Plan"), + # Trading Team + ("trader_investment_plan", "Trading Plan"), + # Portfolio Team + ("final_trade_decision", "Final Decision"), + ] + + # Collect completed sections + completed_sections = [] + toc_entries = [] + + for section_key, section_title in section_order: + if section_key in report_sections and report_sections[section_key] is not None: + content = report_sections[section_key].strip() + if content: + completed_sections.append(content) + # Extract first heading for TOC if available + if content.startswith("#"): + first_line = content.split("\n")[0] + toc_entries.append(first_line.replace("#", "").strip()) + else: + toc_entries.append(section_title) + + # Build comprehensive report + report_parts = [frontmatter] + + # Add title + ticker = metadata.get("ticker", "Unknown") + date = metadata.get("analysis_date", "Unknown") + report_parts.append(f"# Comprehensive Trading Analysis Report: {ticker}\n") + report_parts.append(f"**Analysis Date**: {date}\n") + + # Add table of contents if there are sections + if toc_entries: + report_parts.append("## Table of Contents\n") + for i, entry in enumerate(toc_entries, 1): + report_parts.append(f"{i}. {entry}") + report_parts.append("\n---\n") + + # Add team headers and sections in logical order + current_team = None + team_mapping = { + "market_report": "Analyst Team", + "sentiment_report": "Analyst Team", + "news_report": "Analyst Team", + "fundamentals_report": "Analyst Team", + "investment_plan": "Research Team", + "trader_investment_plan": "Trading Team", + "final_trade_decision": "Portfolio Team", + } + + for section_key, section_title in section_order: + if section_key in report_sections and report_sections[section_key] is not None: + content = report_sections[section_key].strip() + if content: + # Add team header if this is a new team + team = team_mapping.get(section_key) + if team and team != current_team: + report_parts.append(f"\n## {team}\n") + current_team = team + + # Add section content + report_parts.append(f"\n{content}\n") + + return "\n".join(report_parts) + + +# Helper functions + +def _convert_datetimes_to_iso(obj: Any) -> Any: + """ + Recursively convert datetime objects to ISO format strings. + + Args: + obj: Object to convert (can be dict, list, datetime, or other) + + Returns: + Converted object with datetimes as ISO strings + """ + if isinstance(obj, datetime): + return obj.isoformat() + elif isinstance(obj, dict): + return {key: _convert_datetimes_to_iso(value) for key, value in obj.items()} + elif isinstance(obj, list): + return [_convert_datetimes_to_iso(item) for item in obj] + else: + return obj + + +def _format_yaml_value(value: Any) -> str: + """ + Format a value for basic YAML output (fallback when pyyaml not available). + + Args: + value: Value to format + + Returns: + String representation suitable for YAML + """ + if value is None: + return "null" + elif isinstance(value, bool): + return "true" if value else "false" + elif isinstance(value, (list, tuple)): + items = ", ".join(_format_yaml_value(item) for item in value) + return f"[{items}]" + elif isinstance(value, dict): + # Simple dict formatting - not perfect but works for basic cases + items = ", ".join(f"{k}: {_format_yaml_value(v)}" for k, v in value.items()) + return f"{{{items}}}" + elif isinstance(value, str): + # Quote strings with special characters + if any(char in value for char in [':', '{', '}', '[', ']', ',', '&', '*', '#', '?', '|', '-', '<', '>', '=', '!', '%', '@', '\\']): + return f'"{value}"' + return value + else: + return str(value)