374 lines
12 KiB
Python
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)
|