bc077bfcc8
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
- Migration 038: trading_reports table + report-summarizer agent seed
- 6 reporting modules: models, collector, sections, validator, summarizer, generator
- API endpoints: GET /api/reports (paginated, filterable), GET /api/reports/{id}
- Frontend hooks: useReports, useReport with TanStack Query
- Scheduler: daily (after 16:30 ET) and weekly (Saturday) report triggers
- Redis queue consumer for async report generation with retry/dedup
- 5 property-based tests (chunking, serialization, validation, accuracy, deltas)
- 109 unit/integration tests across all modules
- 6 frontend hook tests with MSW mocks
280 lines
8.8 KiB
Python
280 lines
8.8 KiB
Python
"""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)
|