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,273 @@
|
||||
"""Unit tests for the report data collector.
|
||||
|
||||
Tests the CollectedData dataclass defaults, _row_dict UUID conversion,
|
||||
and collect_report_data with mocked asyncpg pool.
|
||||
|
||||
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from services.reporting.collector import CollectedData, _row_dict, collect_report_data
|
||||
|
||||
# ===================================================================
|
||||
# _row_dict tests
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestRowDict:
|
||||
"""Tests for _row_dict UUID→str conversion."""
|
||||
|
||||
def test_uuid_fields_converted_to_str(self):
|
||||
"""UUID values in the record are converted to strings."""
|
||||
test_uuid = uuid.uuid4()
|
||||
row = MagicMock()
|
||||
row.__iter__ = MagicMock(return_value=iter([("id", test_uuid), ("name", "test")]))
|
||||
row.keys = MagicMock(return_value=["id", "name"])
|
||||
row.values = MagicMock(return_value=[test_uuid, "test"])
|
||||
row.items = MagicMock(return_value=[("id", test_uuid), ("name", "test")])
|
||||
# dict(row) needs to work — use a real dict-like mock
|
||||
mock_dict = {"id": test_uuid, "name": "test"}
|
||||
row.__iter__ = MagicMock(return_value=iter(mock_dict))
|
||||
row.__getitem__ = lambda self, key: mock_dict[key]
|
||||
|
||||
# Simpler approach: just pass a dict-like object
|
||||
class FakeRecord(dict):
|
||||
pass
|
||||
|
||||
record = FakeRecord(id=test_uuid, name="test", count=42)
|
||||
result = _row_dict(record)
|
||||
|
||||
assert result["id"] == str(test_uuid)
|
||||
assert result["name"] == "test"
|
||||
assert result["count"] == 42
|
||||
|
||||
def test_no_uuid_fields_unchanged(self):
|
||||
"""Non-UUID values pass through unchanged."""
|
||||
|
||||
class FakeRecord(dict):
|
||||
pass
|
||||
|
||||
record = FakeRecord(ticker="AAPL", price=185.50, active=True)
|
||||
result = _row_dict(record)
|
||||
|
||||
assert result["ticker"] == "AAPL"
|
||||
assert result["price"] == 185.50
|
||||
assert result["active"] is True
|
||||
|
||||
def test_multiple_uuid_fields(self):
|
||||
"""Multiple UUID fields are all converted."""
|
||||
|
||||
class FakeRecord(dict):
|
||||
pass
|
||||
|
||||
id1 = uuid.uuid4()
|
||||
id2 = uuid.uuid4()
|
||||
record = FakeRecord(id=id1, recommendation_id=id2, ticker="MSFT")
|
||||
result = _row_dict(record)
|
||||
|
||||
assert result["id"] == str(id1)
|
||||
assert result["recommendation_id"] == str(id2)
|
||||
assert result["ticker"] == "MSFT"
|
||||
|
||||
def test_empty_record(self):
|
||||
"""Empty record returns empty dict."""
|
||||
|
||||
class FakeRecord(dict):
|
||||
pass
|
||||
|
||||
record = FakeRecord()
|
||||
result = _row_dict(record)
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# CollectedData defaults
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCollectedDataDefaults:
|
||||
"""Tests for CollectedData dataclass default values."""
|
||||
|
||||
def test_default_empty_lists(self):
|
||||
"""All list fields default to empty lists."""
|
||||
data = CollectedData()
|
||||
assert data.trading_decisions == []
|
||||
assert data.orders == []
|
||||
assert data.open_positions == []
|
||||
assert data.closed_positions == []
|
||||
assert data.recommendations == []
|
||||
assert data.prediction_outcomes == []
|
||||
assert data.model_metric_snapshots == []
|
||||
assert data.circuit_breaker_events == []
|
||||
|
||||
def test_default_none_snapshots(self):
|
||||
"""Snapshot fields default to None."""
|
||||
data = CollectedData()
|
||||
assert data.portfolio_snapshot is None
|
||||
assert data.previous_portfolio_snapshot is None
|
||||
|
||||
def test_default_zero_balance(self):
|
||||
"""Reserve pool balance defaults to 0.0."""
|
||||
data = CollectedData()
|
||||
assert data.reserve_pool_balance == 0.0
|
||||
|
||||
def test_independent_list_instances(self):
|
||||
"""Each CollectedData instance has independent list instances."""
|
||||
data1 = CollectedData()
|
||||
data2 = CollectedData()
|
||||
data1.trading_decisions.append({"id": "test"})
|
||||
assert data2.trading_decisions == []
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# collect_report_data with mocked pool
|
||||
# ===================================================================
|
||||
|
||||
|
||||
def _make_mock_pool():
|
||||
"""Create a mock asyncpg pool with async context manager support."""
|
||||
pool = MagicMock()
|
||||
conn = AsyncMock()
|
||||
|
||||
# pool.acquire() returns a sync object that supports async context manager
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=conn)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
pool.acquire.return_value = ctx
|
||||
|
||||
return pool, conn
|
||||
|
||||
|
||||
class TestCollectReportData:
|
||||
"""Tests for collect_report_data with mocked asyncpg."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_activity_returns_empty_lists(self):
|
||||
"""When no data exists, all lists are empty and snapshots are None."""
|
||||
pool, conn = _make_mock_pool()
|
||||
|
||||
# All queries return empty results
|
||||
conn.fetch.return_value = []
|
||||
conn.fetchrow.return_value = None
|
||||
|
||||
result = await collect_report_data(
|
||||
pool, date(2025, 1, 15), date(2025, 1, 15)
|
||||
)
|
||||
|
||||
assert isinstance(result, CollectedData)
|
||||
assert result.trading_decisions == []
|
||||
assert result.orders == []
|
||||
assert result.open_positions == []
|
||||
assert result.closed_positions == []
|
||||
assert result.portfolio_snapshot is None
|
||||
assert result.previous_portfolio_snapshot is None
|
||||
assert result.recommendations == []
|
||||
assert result.prediction_outcomes == []
|
||||
assert result.model_metric_snapshots == []
|
||||
assert result.circuit_breaker_events == []
|
||||
assert result.reserve_pool_balance == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queries_use_correct_date_range(self):
|
||||
"""Verify that queries are called with the correct period dates."""
|
||||
pool, conn = _make_mock_pool()
|
||||
conn.fetch.return_value = []
|
||||
conn.fetchrow.return_value = None
|
||||
|
||||
start = date(2025, 1, 13)
|
||||
end = date(2025, 1, 17)
|
||||
|
||||
await collect_report_data(pool, start, end)
|
||||
|
||||
# Verify fetch was called (trading_decisions, orders, open_positions,
|
||||
# closed_positions, recommendations, prediction_outcomes,
|
||||
# model_metric_snapshots, circuit_breaker_events)
|
||||
assert conn.fetch.call_count == 8
|
||||
|
||||
# Verify fetchrow was called (portfolio_snapshot, previous_snapshot,
|
||||
# reserve_pool_balance)
|
||||
assert conn.fetchrow.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserve_pool_balance_from_ledger(self):
|
||||
"""Reserve pool balance is read from the latest ledger entry."""
|
||||
pool, conn = _make_mock_pool()
|
||||
conn.fetch.return_value = []
|
||||
|
||||
# Mock fetchrow to return different values for different queries
|
||||
balance_row = {"balance_after": 450.75}
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_fetchrow(query, *args):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if "reserve_pool_ledger" in query:
|
||||
return balance_row
|
||||
return None
|
||||
|
||||
conn.fetchrow.side_effect = mock_fetchrow
|
||||
|
||||
result = await collect_report_data(
|
||||
pool, date(2025, 1, 15), date(2025, 1, 15)
|
||||
)
|
||||
|
||||
assert result.reserve_pool_balance == 450.75
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_portfolio_snapshots_populated(self):
|
||||
"""Portfolio snapshot and previous snapshot are populated when data exists."""
|
||||
pool, conn = _make_mock_pool()
|
||||
conn.fetch.return_value = []
|
||||
|
||||
current_snapshot = {
|
||||
"id": uuid.uuid4(),
|
||||
"snapshot_date": date(2025, 1, 15),
|
||||
"portfolio_value": 10500.0,
|
||||
"active_pool": 8000.0,
|
||||
"reserve_pool": 2500.0,
|
||||
"cumulative_return": 0.05,
|
||||
}
|
||||
previous_snapshot = {
|
||||
"id": uuid.uuid4(),
|
||||
"snapshot_date": date(2025, 1, 14),
|
||||
"portfolio_value": 10000.0,
|
||||
"active_pool": 7500.0,
|
||||
"reserve_pool": 2500.0,
|
||||
"cumulative_return": 0.0,
|
||||
}
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_fetchrow(query, *args):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if "reserve_pool_ledger" in query:
|
||||
return None
|
||||
if "snapshot_date >=" in query:
|
||||
# current snapshot query (snapshot_date >= $1 AND snapshot_date <= $2)
|
||||
return current_snapshot
|
||||
if "snapshot_date <" in query:
|
||||
# previous snapshot query (snapshot_date < $1)
|
||||
return previous_snapshot
|
||||
return None
|
||||
|
||||
conn.fetchrow.side_effect = mock_fetchrow
|
||||
|
||||
result = await collect_report_data(
|
||||
pool, date(2025, 1, 15), date(2025, 1, 15)
|
||||
)
|
||||
|
||||
assert result.portfolio_snapshot is not None
|
||||
assert result.portfolio_snapshot["portfolio_value"] == 10500.0
|
||||
# UUID fields should be converted to str
|
||||
assert isinstance(result.portfolio_snapshot["id"], str)
|
||||
|
||||
assert result.previous_portfolio_snapshot is not None
|
||||
assert result.previous_portfolio_snapshot["portfolio_value"] == 10000.0
|
||||
Reference in New Issue
Block a user