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,256 @@
|
||||
"""API integration tests for trading report endpoints.
|
||||
|
||||
Tests GET /api/reports (list with pagination/filtering) and
|
||||
GET /api/reports/{report_id} (detail with full report_data).
|
||||
|
||||
Uses httpx.AsyncClient with the FastAPI app and mocks the module-level
|
||||
``pool`` variable in services.api.app.
|
||||
|
||||
Requirements validated: 5.4, 5.5, 5.6
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from services.api.app import app
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FakeRecord(dict):
|
||||
"""Dict subclass that behaves like an asyncpg Record for bracket access."""
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
try:
|
||||
return self[name]
|
||||
except KeyError:
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
def _make_list_record(**overrides) -> FakeRecord:
|
||||
"""Build a FakeRecord matching the list-endpoint SELECT columns."""
|
||||
defaults = {
|
||||
"id": uuid.uuid4(),
|
||||
"report_type": "daily",
|
||||
"period_start": date(2025, 1, 15),
|
||||
"period_end": date(2025, 1, 15),
|
||||
"validation_status": "passed",
|
||||
"generated_at": datetime(2025, 1, 15, 21, 30, tzinfo=timezone.utc),
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return FakeRecord(**defaults)
|
||||
|
||||
|
||||
def _make_detail_record(**overrides) -> FakeRecord:
|
||||
"""Build a FakeRecord matching the detail-endpoint SELECT columns."""
|
||||
defaults = {
|
||||
"id": uuid.uuid4(),
|
||||
"report_type": "daily",
|
||||
"period_start": date(2025, 1, 15),
|
||||
"period_end": date(2025, 1, 15),
|
||||
"report_data": {
|
||||
"pnl": {"realized_pnl": 125.50, "unrealized_pnl": -30.20},
|
||||
"executive_summary": "Test summary",
|
||||
},
|
||||
"validation_status": "passed",
|
||||
"generated_at": datetime(2025, 1, 15, 21, 30, tzinfo=timezone.utc),
|
||||
"created_at": datetime(2025, 1, 15, 21, 30, 5, tzinfo=timezone.utc),
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return FakeRecord(**defaults)
|
||||
|
||||
|
||||
_POOL_PATCH = "services.api.app.pool"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 1. GET /api/reports — list endpoint
|
||||
# Requirements validated: 5.4, 5.6
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestListReports:
|
||||
"""Tests for GET /api/reports."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_pagination(self) -> None:
|
||||
"""List reports with no params returns rows using default limit/offset."""
|
||||
r1 = _make_list_record()
|
||||
r2 = _make_list_record(
|
||||
report_type="weekly",
|
||||
period_start=date(2025, 1, 13),
|
||||
period_end=date(2025, 1, 17),
|
||||
)
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetch = AsyncMock(return_value=[r1, r2])
|
||||
|
||||
with patch(_POOL_PATCH, mock_pool):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/reports")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
# UUID fields are serialized as strings
|
||||
assert data[0]["id"] == str(r1["id"])
|
||||
assert data[0]["report_type"] == "daily"
|
||||
assert data[0]["period_start"] == "2025-01-15"
|
||||
assert data[0]["period_end"] == "2025-01-15"
|
||||
assert data[0]["validation_status"] == "passed"
|
||||
assert "generated_at" in data[0]
|
||||
|
||||
# pool.fetch called with default limit=20, offset=0
|
||||
call_args = mock_pool.fetch.call_args
|
||||
sql = call_args[0][0]
|
||||
assert "LIMIT" in sql
|
||||
assert "OFFSET" in sql
|
||||
# Last two positional args are limit and offset
|
||||
assert call_args[0][-2] == 20
|
||||
assert call_args[0][-1] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_report_type(self) -> None:
|
||||
"""Filtering by report_type=weekly passes the value to the query."""
|
||||
r1 = _make_list_record(report_type="weekly")
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetch = AsyncMock(return_value=[r1])
|
||||
|
||||
with patch(_POOL_PATCH, mock_pool):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/reports", params={"report_type": "weekly"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["report_type"] == "weekly"
|
||||
|
||||
# Verify the SQL includes a report_type condition
|
||||
call_args = mock_pool.fetch.call_args
|
||||
sql = call_args[0][0]
|
||||
assert "report_type" in sql
|
||||
# "weekly" should be among the positional params
|
||||
assert "weekly" in call_args[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_date_range(self) -> None:
|
||||
"""Filtering by start_date and end_date passes dates to the query."""
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetch = AsyncMock(return_value=[])
|
||||
|
||||
with patch(_POOL_PATCH, mock_pool):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get(
|
||||
"/api/reports",
|
||||
params={"start_date": "2025-01-01", "end_date": "2025-01-31"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
call_args = mock_pool.fetch.call_args
|
||||
sql = call_args[0][0]
|
||||
assert "period_start" in sql
|
||||
assert "period_end" in sql
|
||||
# Date strings should be among the positional params
|
||||
assert "2025-01-01" in call_args[0]
|
||||
assert "2025-01-31" in call_args[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_report_type_returns_400(self) -> None:
|
||||
"""An invalid report_type value returns HTTP 400."""
|
||||
mock_pool = AsyncMock()
|
||||
|
||||
with patch(_POOL_PATCH, mock_pool):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get(
|
||||
"/api/reports", params={"report_type": "monthly"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "daily" in resp.json()["detail"].lower() or "weekly" in resp.json()["detail"].lower()
|
||||
# pool.fetch should NOT have been called
|
||||
mock_pool.fetch.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_date_format_returns_400(self) -> None:
|
||||
"""A malformed start_date returns HTTP 400."""
|
||||
mock_pool = AsyncMock()
|
||||
|
||||
with patch(_POOL_PATCH, mock_pool):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get(
|
||||
"/api/reports", params={"start_date": "not-a-date"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "YYYY-MM-DD" in resp.json()["detail"]
|
||||
mock_pool.fetch.assert_not_awaited()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 2. GET /api/reports/{report_id} — detail endpoint
|
||||
# Requirements validated: 5.4, 5.5
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetReport:
|
||||
"""Tests for GET /api/reports/{report_id}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_id_returns_full_report(self) -> None:
|
||||
"""A valid report_id returns the full report including report_data."""
|
||||
record = _make_detail_record()
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(return_value=record)
|
||||
|
||||
report_id = str(record["id"])
|
||||
|
||||
with patch(_POOL_PATCH, mock_pool):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get(f"/api/reports/{report_id}")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == report_id
|
||||
assert data["report_type"] == "daily"
|
||||
assert data["period_start"] == "2025-01-15"
|
||||
assert data["period_end"] == "2025-01-15"
|
||||
assert data["validation_status"] == "passed"
|
||||
assert "generated_at" in data
|
||||
assert "created_at" in data
|
||||
# report_data is included as a dict
|
||||
assert isinstance(data["report_data"], dict)
|
||||
assert data["report_data"]["pnl"]["realized_pnl"] == 125.50
|
||||
assert data["report_data"]["executive_summary"] == "Test summary"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_id_returns_404(self) -> None:
|
||||
"""A non-existent report_id returns HTTP 404."""
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(return_value=None)
|
||||
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
with patch(_POOL_PATCH, mock_pool):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get(f"/api/reports/{fake_id}")
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
Reference in New Issue
Block a user