feat: trading feedback engine — periodic performance reports with AI summarization
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
This commit is contained in:
Celes Renata
2026-05-01 22:13:09 +00:00
parent 376fcb4bb4
commit bc077bfcc8
28 changed files with 6771 additions and 1 deletions
+279
View File
@@ -0,0 +1,279 @@
"""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)