"""Report generator — orchestrates collection, building, validation, summarization, and storage. Provides three public functions: - generate_report: full pipeline from data collection to assembled ReportData - store_report: upsert into trading_reports table - process_report_job: Redis queue job handler with retry and dedup Requirements: 5.1, 5.2, 5.3, 6.3, 6.4, 6.5 Design: Report Generator """ from __future__ import annotations import asyncio import logging from datetime import date, datetime, timezone import asyncpg from services.reporting.collector import collect_report_data from services.reporting.models import ReportData, ReportType from services.reporting.sections import ( build_model_quality_section, build_pnl_section, build_position_performance_section, build_recommendation_accuracy_section, build_risk_metrics_section, ) from services.reporting.summarizer import ( generate_executive_summary, summarize_section, ) from services.reporting.validator import ( compute_validation_status, validate_model_quality, validate_recommendation_accuracy, ) from services.shared.agent_config import AgentConfigResolver logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Retry configuration for process_report_job # --------------------------------------------------------------------------- _MAX_RETRIES = 3 _BACKOFF_SECONDS = (30, 60, 120) # In-memory set tracking in-progress jobs to reject duplicates. # Key format: "{report_type}:{period_start}:{period_end}" _in_progress_jobs: set[str] = set() # --------------------------------------------------------------------------- # generate_report # --------------------------------------------------------------------------- async def generate_report( pool: asyncpg.Pool, report_type: ReportType, period_start: date, period_end: date, ) -> ReportData: """Orchestrate full report generation. 1. Collect data via collector 2. Build all 5 sections via section builders 3. Validate recommendation_accuracy and model_quality via validator 4. Create AgentConfigResolver and summarize each section 5. Generate executive summary 6. Assemble final ReportData """ # 1. Collect data data = await collect_report_data(pool, period_start, period_end) # 2. Build sections pnl = build_pnl_section(data) rec_accuracy = build_recommendation_accuracy_section(data) position_perf = build_position_performance_section(data) risk_metrics = build_risk_metrics_section(data) model_quality = build_model_quality_section(data) # 3. Validate rec_warnings = validate_recommendation_accuracy( rec_accuracy, data.prediction_outcomes, ) rec_accuracy.validation_warnings = rec_warnings mq_warnings = validate_model_quality( model_quality, data.model_metric_snapshots, ) model_quality.validation_warnings = mq_warnings # 4. Summarize each section resolver = AgentConfigResolver(pool) pnl.summary = await summarize_section( pool, resolver, "pnl", pnl.model_dump(), ) rec_accuracy.summary = await summarize_section( pool, resolver, "recommendation_accuracy", rec_accuracy.model_dump(), ) position_perf.summary = await summarize_section( pool, resolver, "position_performance", position_perf.model_dump(), ) risk_metrics.summary = await summarize_section( pool, resolver, "risk_metrics", risk_metrics.model_dump(), ) model_quality.summary = await summarize_section( pool, resolver, "model_quality", model_quality.model_dump(), ) # 5. Generate executive summary section_summaries = { "pnl": pnl.summary, "recommendation_accuracy": rec_accuracy.summary, "position_performance": position_perf.summary, "risk_metrics": risk_metrics.summary, "model_quality": model_quality.summary, } executive_summary = await generate_executive_summary( pool, resolver, section_summaries, ) # 6. Assemble ReportData report = ReportData( pnl=pnl, recommendation_accuracy=rec_accuracy, position_performance=position_perf, risk_metrics=risk_metrics, model_quality=model_quality, executive_summary=executive_summary, generated_at=datetime.now(timezone.utc), period_start=period_start, period_end=period_end, report_type=ReportType(report_type), ) # Set validation status based on all warnings report.validation_status = compute_validation_status(report) return report # --------------------------------------------------------------------------- # store_report # --------------------------------------------------------------------------- _UPSERT_SQL = """\ INSERT INTO trading_reports (report_type, period_start, period_end, report_data, validation_status, generated_at) VALUES ($1, $2, $3, $4::jsonb, $5, $6) ON CONFLICT (report_type, period_start, period_end) DO UPDATE SET report_data = EXCLUDED.report_data, validation_status = EXCLUDED.validation_status, generated_at = EXCLUDED.generated_at RETURNING id """ async def store_report( pool: asyncpg.Pool, report: ReportData, ) -> str: """Store report in trading_reports table via upsert. Uses INSERT ... ON CONFLICT (report_type, period_start, period_end) DO UPDATE to handle regeneration of existing reports. Returns the report UUID as a string. """ row = await pool.fetchrow( _UPSERT_SQL, report.report_type.value, report.period_start, report.period_end, report.model_dump_json(), report.validation_status.value, report.generated_at, ) report_id = str(row["id"]) # type: ignore[index] logger.info( "Stored report %s (type=%s, period=%s to %s)", report_id, report.report_type.value, report.period_start, report.period_end, ) return report_id # --------------------------------------------------------------------------- # process_report_job # --------------------------------------------------------------------------- def _job_key(report_type: str, period_start: str, period_end: str) -> str: """Build a dedup key for an in-progress job.""" return f"{report_type}:{period_start}:{period_end}" async def process_report_job( pool: asyncpg.Pool, job: dict, ) -> None: """Process a report generation job from the Redis queue. Deserializes job payload, calls generate_report + store_report. Handles retries with exponential backoff (30s, 60s, 120s up to 3 attempts). Rejects duplicate jobs for the same report_type + period. Expected job payload:: { "report_type": "daily" | "weekly", "period_start": "YYYY-MM-DD", "period_end": "YYYY-MM-DD" } """ report_type_str = job.get("report_type", "") period_start_str = job.get("period_start", "") period_end_str = job.get("period_end", "") # Validate payload try: report_type = ReportType(report_type_str) period_start = date.fromisoformat(period_start_str) period_end = date.fromisoformat(period_end_str) except (ValueError, TypeError) as exc: logger.error("Invalid report job payload: %s — %s", job, exc) return # Reject duplicate in-progress jobs key = _job_key(report_type_str, period_start_str, period_end_str) if key in _in_progress_jobs: logger.warning( "Duplicate report job rejected (already in progress): %s", key, ) return _in_progress_jobs.add(key) try: last_error: Exception | None = None for attempt in range(_MAX_RETRIES): try: report = await generate_report( pool, report_type, period_start, period_end, ) await store_report(pool, report) logger.info( "Report job completed: %s (attempt %d)", key, attempt + 1, ) return except Exception as exc: last_error = exc if attempt < _MAX_RETRIES - 1: backoff = _BACKOFF_SECONDS[attempt] logger.warning( "Report job %s failed (attempt %d/%d): %s — retrying in %ds", key, attempt + 1, _MAX_RETRIES, exc, backoff, ) await asyncio.sleep(backoff) # All retries exhausted logger.error( "Report job %s failed after %d attempts: %s", key, _MAX_RETRIES, last_error, ) finally: _in_progress_jobs.discard(key)