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
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:
@@ -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)
|
||||
Reference in New Issue
Block a user