Migrate all agent roles from Legacy to EvoAgent architecture: - fundamentals_analyst, technical_analyst, sentiment_analyst, valuation_analyst - risk_manager, portfolio_manager Key changes: - EvoAgent now supports Portfolio Manager compatibility methods (_make_decision, get_decisions, get_portfolio_state, load_portfolio_state, update_portfolio) - Add UnifiedAgentFactory for centralized agent creation - ToolGuard with batch approval API and WebSocket broadcast - Legacy agents marked deprecated (AnalystAgent, RiskAgent, PMAgent) - Remove backend/agents/compat.py migration shim - Add run_id alongside workspace_id for semantic clarity - Complete integration test coverage (13 tests) - All smoke tests passing for 6 agent roles Constraint: Must maintain backward compatibility with existing run configs Constraint: Memory support must work with EvoAgent (no fallback to Legacy) Rejected: Separate PM implementation for EvoAgent | unified approach cleaner Confidence: high Scope-risk: broad Directive: EVO_AGENT_IDS env var still respected but defaults to all roles Not-tested: Kubernetes sandbox mode for skill execution
262 lines
9.0 KiB
Python
262 lines
9.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for the extracted news service app surface."""
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from backend.apps.news_service import create_app
|
|
|
|
|
|
class _FakeStore:
|
|
def get_ticker_watermarks(self, symbol):
|
|
return {"symbol": symbol, "last_news_fetch": "2026-12-31"}
|
|
|
|
def get_news_timeline_enriched(self, symbol, start_date=None, end_date=None):
|
|
return [{"date": end_date, "count": 1}]
|
|
|
|
def get_news_items(self, symbol, start_date=None, end_date=None, limit=100):
|
|
return [{"id": "news-raw-1", "ticker": symbol, "title": "Raw Title", "date": end_date}]
|
|
|
|
def get_news_items_enriched(self, symbol, start_date=None, end_date=None, trade_date=None, limit=100):
|
|
return [{"id": "news-1", "ticker": symbol, "title": "Title", "date": trade_date or end_date}]
|
|
|
|
def upsert_news_analysis(self, symbol, rows):
|
|
return len(rows)
|
|
|
|
def get_analyzed_news_ids(self, symbol, start_date=None, end_date=None):
|
|
return set()
|
|
|
|
def get_news_categories_enriched(self, symbol, start_date=None, end_date=None, limit=200):
|
|
return {"market": {"label": "market", "count": 1, "article_ids": ["news-1"]}}
|
|
|
|
def get_news_by_ids_enriched(self, symbol, article_ids):
|
|
return [{"id": article_ids[0], "ticker": symbol, "title": "Picked"}]
|
|
|
|
|
|
def test_news_service_routes_are_exposed():
|
|
app = create_app()
|
|
paths = {route.path for route in app.routes}
|
|
|
|
assert "/health" in paths
|
|
assert "/api/enriched-news" in paths
|
|
assert "/api/news-for-date" in paths
|
|
assert "/api/news-timeline" in paths
|
|
assert "/api/categories" in paths
|
|
assert "/api/similar-days" in paths
|
|
assert "/api/stories/{ticker}" in paths
|
|
assert "/api/range-explain" in paths
|
|
|
|
|
|
def test_news_service_enriched_news_and_categories(monkeypatch):
|
|
app = create_app()
|
|
app.dependency_overrides.clear()
|
|
from backend.apps import news_service as news_service_module
|
|
|
|
app.dependency_overrides[news_service_module.get_market_store] = lambda: _FakeStore()
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.enrich_news_for_symbol",
|
|
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 1},
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
news_response = client.get(
|
|
"/api/enriched-news",
|
|
params={"ticker": "AAPL", "end_date": "2026-03-23"},
|
|
)
|
|
categories_response = client.get(
|
|
"/api/categories",
|
|
params={"ticker": "AAPL", "end_date": "2026-03-23"},
|
|
)
|
|
|
|
assert news_response.status_code == 200
|
|
assert news_response.json()["news"][0]["ticker"] == "AAPL"
|
|
assert categories_response.status_code == 200
|
|
assert categories_response.json()["categories"]["market"]["count"] == 1
|
|
|
|
|
|
def test_news_service_news_for_date_and_timeline(monkeypatch):
|
|
app = create_app()
|
|
from backend.apps import news_service as news_service_module
|
|
|
|
app.dependency_overrides[news_service_module.get_market_store] = lambda: _FakeStore()
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.enrich_news_for_symbol",
|
|
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 1},
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
date_response = client.get(
|
|
"/api/news-for-date",
|
|
params={"ticker": "AAPL", "date": "2026-03-23"},
|
|
)
|
|
timeline_response = client.get(
|
|
"/api/news-timeline",
|
|
params={
|
|
"ticker": "AAPL",
|
|
"start_date": "2026-03-01",
|
|
"end_date": "2026-03-23",
|
|
},
|
|
)
|
|
|
|
assert date_response.status_code == 200
|
|
assert date_response.json()["date"] == "2026-03-23"
|
|
assert timeline_response.status_code == 200
|
|
assert timeline_response.json()["timeline"][0]["count"] == 1
|
|
|
|
|
|
def test_news_service_similar_days_and_story(monkeypatch):
|
|
app = create_app()
|
|
from backend.apps import news_service as news_service_module
|
|
|
|
app.dependency_overrides[news_service_module.get_market_store] = lambda: _FakeStore()
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.enrich_news_for_symbol",
|
|
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 1},
|
|
)
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.find_similar_days",
|
|
lambda store, symbol, target_date, top_k: {
|
|
"symbol": symbol,
|
|
"target_date": target_date,
|
|
"items": [{"date": "2026-03-20", "score": 0.9}],
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.get_or_create_stock_story",
|
|
lambda store, symbol, as_of_date: {
|
|
"symbol": symbol,
|
|
"as_of_date": as_of_date,
|
|
"story": "story body",
|
|
"source": "local",
|
|
},
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
similar_response = client.get(
|
|
"/api/similar-days",
|
|
params={"ticker": "AAPL", "date": "2026-03-23", "n_similar": 3},
|
|
)
|
|
story_response = client.get(
|
|
"/api/stories/AAPL",
|
|
params={"as_of_date": "2026-03-23"},
|
|
)
|
|
|
|
assert similar_response.status_code == 200
|
|
assert similar_response.json()["items"][0]["score"] == 0.9
|
|
assert story_response.status_code == 200
|
|
assert story_response.json()["story"] == "story body"
|
|
|
|
|
|
def test_news_service_range_explain(monkeypatch):
|
|
app = create_app()
|
|
from backend.apps import news_service as news_service_module
|
|
|
|
app.dependency_overrides[news_service_module.get_market_store] = lambda: _FakeStore()
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.enrich_news_for_symbol",
|
|
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 1},
|
|
)
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.build_range_explanation",
|
|
lambda ticker, start_date, end_date, news_rows: {
|
|
"symbol": ticker,
|
|
"news_count": len(news_rows),
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
},
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get(
|
|
"/api/range-explain",
|
|
params={
|
|
"ticker": "AAPL",
|
|
"start_date": "2026-03-01",
|
|
"end_date": "2026-03-23",
|
|
"article_ids": ["news-7"],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["result"]["news_count"] == 1
|
|
|
|
|
|
def test_news_service_contract_stability():
|
|
"""Verify news service API maintains contract stability."""
|
|
app = create_app()
|
|
routes = {route.path: route for route in app.routes if hasattr(route, "methods")}
|
|
|
|
# Health endpoint
|
|
assert "/health" in routes
|
|
|
|
# News/explain endpoints
|
|
assert "/api/enriched-news" in routes
|
|
assert "/api/news-for-date" in routes
|
|
assert "/api/news-timeline" in routes
|
|
assert "/api/categories" in routes
|
|
assert "/api/similar-days" in routes
|
|
assert "/api/stories/{ticker}" in routes
|
|
assert "/api/range-explain" in routes
|
|
|
|
# Verify all are GET endpoints (read-only service)
|
|
for path in ["/api/enriched-news", "/api/news-for-date", "/api/news-timeline",
|
|
"/api/categories", "/api/similar-days", "/api/stories/{ticker}",
|
|
"/api/range-explain"]:
|
|
assert "GET" in routes[path].methods
|
|
|
|
|
|
def test_news_service_enriched_news_contract(monkeypatch):
|
|
"""Test enriched news endpoint maintains response contract."""
|
|
app = create_app()
|
|
app.dependency_overrides.clear()
|
|
from backend.apps import news_service as news_service_module
|
|
|
|
app.dependency_overrides[news_service_module.get_market_store] = lambda: _FakeStore()
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.enrich_news_for_symbol",
|
|
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 1, "news": [{"id": "1", "title": "Test"}]},
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get(
|
|
"/api/enriched-news",
|
|
params={"ticker": "AAPL", "end_date": "2026-03-23"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert "news" in payload
|
|
|
|
|
|
def test_news_service_stories_contract(monkeypatch):
|
|
"""Test stories endpoint maintains response contract."""
|
|
app = create_app()
|
|
from backend.apps import news_service as news_service_module
|
|
|
|
app.dependency_overrides[news_service_module.get_market_store] = lambda: _FakeStore()
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.enrich_news_for_symbol",
|
|
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 1},
|
|
)
|
|
monkeypatch.setattr(
|
|
"backend.domains.news.get_or_create_stock_story",
|
|
lambda store, symbol, as_of_date: {
|
|
"symbol": symbol,
|
|
"as_of_date": as_of_date,
|
|
"story": "story body",
|
|
"source": "local",
|
|
"headline": "Test Headline",
|
|
},
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get(
|
|
"/api/stories/AAPL",
|
|
params={"as_of_date": "2026-03-23"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert "symbol" in payload
|
|
assert "as_of_date" in payload
|
|
assert "story" in payload
|