Files
stonks-oracle/tests/test_report_collector.py
Celes Renata 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
feat: trading feedback engine — periodic performance reports with AI summarization
- 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
2026-05-01 22:13:09 +00:00

274 lines
9.1 KiB
Python

"""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