feat(reports): add report exporter with YAML frontmatter and JSON metadata (Issue #21)

This commit is contained in:
Andrew Kaszubski 2025-12-26 10:11:42 +11:00
parent bb0ea33100
commit 436f6cc092
4 changed files with 1204 additions and 0 deletions

View File

@ -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)

View File

@ -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"])

View File

@ -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",
]

View File

@ -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)