TradingAgents/tradingagents/utils/report_exporter.py

374 lines
12 KiB
Python

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