feat: competitive intelligence & historical pattern matching layer

This commit is contained in:
Celes Renata
2026-04-14 19:42:48 +00:00
parent b478022ba3
commit f7a11d14ea
203 changed files with 20155 additions and 97 deletions
+377
View File
@@ -0,0 +1,377 @@
"""Unit tests for macro API endpoints and dashboard components.
Tests macro event list/detail endpoints, macro toggle endpoint,
and trend projection endpoint return correct data structures.
Requirements: 8.1, 8.2, 11.5, 12.10
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from httpx import ASGITransport, AsyncClient
from services.api.app import _parse_jsonb, _row_to_dict, app
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
NOW = datetime(2026, 5, 15, 14, 0, 0, tzinfo=timezone.utc)
class FakeRecord(dict):
"""Mimics asyncpg.Record for testing."""
def items(self):
return super().items()
def _make_event_row(event_id: str | None = None) -> FakeRecord:
eid = event_id or str(uuid4())
return FakeRecord({
"id": eid,
"event_types": ["trade_barrier", "cost_increase"],
"severity": "high",
"affected_regions": ["US", "CN"],
"affected_sectors": ["Technology"],
"affected_commodities": ["semiconductors"],
"summary": "US tariffs on Chinese semiconductors",
"key_facts": json.dumps(["25% tariff", "Effective in 30 days"]),
"estimated_duration": "medium_term",
"confidence": 0.85,
"source_document_id": str(uuid4()),
"created_at": NOW,
# Detail fields
"model_provider": "ollama",
"model_name": "test-model",
"prompt_version": "event-v1",
"schema_version": "1.0.0",
})
def _make_impact_row(event_id: str) -> FakeRecord:
return FakeRecord({
"id": str(uuid4()),
"event_id": event_id,
"company_id": str(uuid4()),
"ticker": "AAPL",
"macro_impact_score": 0.45,
"impact_direction": "negative",
"contributing_factors": json.dumps(["geographic_overlap:0.650"]),
"confidence": 0.8,
"computed_at": NOW,
"legal_name": "Apple Inc.",
"sector": "Technology",
# For ticker endpoint
"event_summary": "US tariffs on Chinese semiconductors",
"event_severity": "high",
"event_types": ["trade_barrier"],
"affected_regions": ["US", "CN"],
})
def _make_projection_row(trend_id: str) -> FakeRecord:
return FakeRecord({
"id": str(uuid4()),
"trend_window_id": trend_id,
"projected_direction": "bearish",
"projected_strength": 0.6,
"projected_confidence": 0.5,
"projection_horizon": "7d",
"driving_factors": json.dumps(["Macro signals project bearish impact"]),
"macro_contribution_pct": 0.3,
"diverges_from_current": True,
"computed_at": NOW,
})
# ---------------------------------------------------------------------------
# Route structure tests
# ---------------------------------------------------------------------------
class TestMacroRouteStructure:
"""Verify all macro-related routes are registered."""
def test_macro_event_list_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/macro/events" in paths
def test_macro_event_detail_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/macro/events/{event_id}" in paths
def test_macro_impacts_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/macro/impacts/{ticker}" in paths
def test_macro_status_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/admin/macro/status" in paths
def test_macro_toggle_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/admin/macro/toggle" in paths
def test_trend_projection_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/trends/{trend_id}/projection" in paths
# ---------------------------------------------------------------------------
# Macro event endpoints (Requirements: 8.1, 8.2)
# ---------------------------------------------------------------------------
class TestMacroEventEndpoints:
"""Test macro event list and detail endpoints."""
@pytest.mark.asyncio
async def test_list_macro_events_returns_events(self):
"""GET /api/macro/events should return a list of events."""
event_row = _make_event_row()
mock_pool = AsyncMock()
mock_pool.fetch = AsyncMock(return_value=[event_row])
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/macro/events")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["severity"] == "high"
assert data[0]["summary"] == "US tariffs on Chinese semiconductors"
assert isinstance(data[0]["key_facts"], list)
@pytest.mark.asyncio
async def test_list_macro_events_with_severity_filter(self):
"""GET /api/macro/events?severity=high should filter by severity."""
event_row = _make_event_row()
mock_pool = AsyncMock()
mock_pool.fetch = AsyncMock(return_value=[event_row])
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/macro/events?severity=high")
assert resp.status_code == 200
# Verify the query was called (filter applied)
mock_pool.fetch.assert_called_once()
call_args = mock_pool.fetch.call_args
assert "high" in call_args.args
@pytest.mark.asyncio
async def test_get_macro_event_detail(self):
"""GET /api/macro/events/{id} should return event with affected companies."""
event_id = str(uuid4())
event_row = _make_event_row(event_id)
impact_row = _make_impact_row(event_id)
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(return_value=event_row)
mock_pool.fetch = AsyncMock(return_value=[impact_row])
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/macro/events/{event_id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == event_id
assert data["severity"] == "high"
assert "affected_companies" in data
assert len(data["affected_companies"]) == 1
assert data["affected_companies"][0]["ticker"] == "AAPL"
@pytest.mark.asyncio
async def test_get_macro_event_not_found(self):
"""GET /api/macro/events/{id} should return 404 for missing event."""
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(return_value=None)
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/macro/events/{uuid4()}")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Macro toggle endpoint (Requirement: 11.5)
# ---------------------------------------------------------------------------
class TestMacroToggleEndpoint:
"""Test macro toggle endpoint persists state and records audit event."""
@pytest.mark.asyncio
async def test_get_macro_status_returns_default(self):
"""GET /api/admin/macro/status should return default enabled state."""
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(return_value=None)
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/admin/macro/status")
assert resp.status_code == 200
data = resp.json()
assert data["macro_enabled"] is True
assert data["source"] == "default"
@pytest.mark.asyncio
async def test_get_macro_status_from_config(self):
"""GET /api/admin/macro/status should read from risk_configs."""
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(return_value=FakeRecord({
"macro_enabled": "false",
}))
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/admin/macro/status")
assert resp.status_code == 200
data = resp.json()
assert data["macro_enabled"] is False
assert data["source"] == "risk_configs"
@pytest.mark.asyncio
async def test_toggle_macro_layer(self):
"""PUT /api/admin/macro/toggle should persist state and record audit."""
config_id = str(uuid4())
mock_pool = AsyncMock()
# First call: fetch current state
mock_pool.fetchrow = AsyncMock(return_value=FakeRecord({
"id": config_id,
"macro_enabled": "true",
}))
mock_pool.execute = AsyncMock()
with patch("services.api.app.pool", mock_pool), \
patch("services.api.app.record_audit_event", new_callable=AsyncMock) as mock_audit:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.put(
"/api/admin/macro/toggle",
json={"enabled": False, "operator": "test_user"},
)
assert resp.status_code == 200
data = resp.json()
assert data["macro_enabled"] is False
assert data["previous_enabled"] is True
assert data["toggled_by"] == "test_user"
# Verify audit event was recorded
mock_audit.assert_called_once()
audit_call = mock_audit.call_args
assert audit_call.kwargs.get("event_type") or audit_call.args[1] == "macro.layer_toggled"
# ---------------------------------------------------------------------------
# Trend projection endpoint (Requirement: 12.10)
# ---------------------------------------------------------------------------
class TestTrendProjectionEndpoint:
"""Test trend projection endpoint returns projection data."""
@pytest.mark.asyncio
async def test_get_trend_projection(self):
"""GET /api/trends/{id}/projection should return projection data."""
trend_id = str(uuid4())
proj_row = _make_projection_row(trend_id)
mock_pool = AsyncMock()
# First call: verify trend exists
mock_pool.fetchrow = AsyncMock(side_effect=[
FakeRecord({"id": trend_id}), # trend exists
proj_row, # projection data
])
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/trends/{trend_id}/projection")
assert resp.status_code == 200
data = resp.json()
assert data["projected_direction"] == "bearish"
assert data["projected_strength"] == 0.6
assert data["projected_confidence"] == 0.5
assert data["diverges_from_current"] is True
assert isinstance(data["driving_factors"], list)
@pytest.mark.asyncio
async def test_get_trend_projection_not_found(self):
"""GET /api/trends/{id}/projection should return null projection for missing."""
trend_id = str(uuid4())
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(side_effect=[
FakeRecord({"id": trend_id}), # trend exists
None, # no projection
])
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/trends/{trend_id}/projection")
assert resp.status_code == 200
data = resp.json()
assert data["projection"] is None
@pytest.mark.asyncio
async def test_get_trend_projection_trend_not_found(self):
"""GET /api/trends/{id}/projection should 404 for missing trend."""
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(return_value=None)
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/trends/{uuid4()}/projection")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Macro impacts for ticker endpoint (Requirement: 8.2)
# ---------------------------------------------------------------------------
class TestMacroImpactsEndpoint:
"""Test macro impacts for a specific company."""
@pytest.mark.asyncio
async def test_get_macro_impacts_for_ticker(self):
"""GET /api/macro/impacts/{ticker} should return impact records."""
impact_row = _make_impact_row(str(uuid4()))
mock_pool = AsyncMock()
mock_pool.fetch = AsyncMock(return_value=[impact_row])
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/macro/impacts/AAPL")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["ticker"] == "AAPL"
assert data[0]["macro_impact_score"] == 0.45
assert data[0]["impact_direction"] == "negative"