feat(reports): add report exporter with YAML frontmatter and JSON metadata (Issue #21)
This commit is contained in:
parent
bb0ea33100
commit
436f6cc092
11
CHANGELOG.md
11
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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue