feat: 微服务架构拆分和前后端优化
后端: - 拆分出 agent_service, runtime_service, trading_service, news_service - Gateway 模块化拆分 (gateway_*.py) - 添加 domains/ 领域层 - 新增 control_client, runtime_client - 更新 start-dev.sh 支持 split 服务模式 前端: - 完善 API 服务层 (newsApi, tradingApi) - 更新 vite.config.js - Explain 组件优化 测试: - 添加多个服务 app 测试 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
backend/tests/test_agent_service_app.py
Normal file
27
backend/tests/test_agent_service_app.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the extracted agent service surface."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.apps.agent_service import create_app
|
||||
|
||||
|
||||
def test_agent_service_routes_include_control_plane_endpoints(tmp_path):
|
||||
app = create_app(project_root=tmp_path)
|
||||
|
||||
paths = {route.path for route in app.routes}
|
||||
|
||||
assert "/health" in paths
|
||||
assert "/api/status" in paths
|
||||
assert "/api/workspaces" in paths
|
||||
assert "/api/guard/pending" in paths
|
||||
|
||||
|
||||
def test_agent_service_excludes_runtime_routes(tmp_path):
|
||||
app = create_app(project_root=tmp_path)
|
||||
paths = {route.path for route in app.routes}
|
||||
|
||||
assert "/api/runtime/start" not in paths
|
||||
assert "/api/runtime/gateway/port" not in paths
|
||||
139
backend/tests/test_data_tools_service_routing.py
Normal file
139
backend/tests/test_data_tools_service_routing.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for data_tools preferring split services when configured."""
|
||||
|
||||
from backend.tools import data_tools
|
||||
from shared.schema import CompanyNews, FinancialMetrics, InsiderTrade, LineItem, Price
|
||||
|
||||
|
||||
def test_data_tools_prefers_trading_service(monkeypatch):
|
||||
monkeypatch.setenv("TRADING_SERVICE_URL", "http://localhost:8001")
|
||||
monkeypatch.setenv("SERVICE_NAME", "agent_service")
|
||||
monkeypatch.setattr(data_tools._cache, "get_prices", lambda key: None)
|
||||
monkeypatch.setattr(data_tools._cache, "get_financial_metrics", lambda key: None)
|
||||
monkeypatch.setattr(data_tools._cache, "get_insider_trades", lambda key: None)
|
||||
monkeypatch.setattr(data_tools._cache, "get_company_news", lambda key: None)
|
||||
|
||||
def fake_service_get_json(base_url, path, *, params):
|
||||
if path == "/api/prices":
|
||||
return {
|
||||
"ticker": "AAPL",
|
||||
"prices": [
|
||||
Price(
|
||||
open=1,
|
||||
close=2,
|
||||
high=3,
|
||||
low=1,
|
||||
volume=10,
|
||||
time="2026-03-16",
|
||||
).model_dump()
|
||||
],
|
||||
}
|
||||
if path == "/api/financials":
|
||||
return {
|
||||
"financial_metrics": [
|
||||
FinancialMetrics(
|
||||
ticker="AAPL",
|
||||
report_period="2026-03-16",
|
||||
period="ttm",
|
||||
currency="USD",
|
||||
market_cap=123.0,
|
||||
enterprise_value=None,
|
||||
price_to_earnings_ratio=None,
|
||||
price_to_book_ratio=None,
|
||||
price_to_sales_ratio=None,
|
||||
enterprise_value_to_ebitda_ratio=None,
|
||||
enterprise_value_to_revenue_ratio=None,
|
||||
free_cash_flow_yield=None,
|
||||
peg_ratio=None,
|
||||
gross_margin=None,
|
||||
operating_margin=None,
|
||||
net_margin=None,
|
||||
return_on_equity=None,
|
||||
return_on_assets=None,
|
||||
return_on_invested_capital=None,
|
||||
asset_turnover=None,
|
||||
inventory_turnover=None,
|
||||
receivables_turnover=None,
|
||||
days_sales_outstanding=None,
|
||||
operating_cycle=None,
|
||||
working_capital_turnover=None,
|
||||
current_ratio=None,
|
||||
quick_ratio=None,
|
||||
cash_ratio=None,
|
||||
operating_cash_flow_ratio=None,
|
||||
debt_to_equity=None,
|
||||
debt_to_assets=None,
|
||||
interest_coverage=None,
|
||||
revenue_growth=None,
|
||||
earnings_growth=None,
|
||||
book_value_growth=None,
|
||||
earnings_per_share_growth=None,
|
||||
free_cash_flow_growth=None,
|
||||
operating_income_growth=None,
|
||||
ebitda_growth=None,
|
||||
payout_ratio=None,
|
||||
earnings_per_share=None,
|
||||
book_value_per_share=None,
|
||||
free_cash_flow_per_share=None,
|
||||
).model_dump()
|
||||
]
|
||||
}
|
||||
if path == "/api/insider-trades":
|
||||
return {
|
||||
"insider_trades": [
|
||||
InsiderTrade(ticker="AAPL", filing_date="2026-03-16").model_dump()
|
||||
]
|
||||
}
|
||||
if path == "/api/news":
|
||||
return {
|
||||
"news": [
|
||||
CompanyNews(
|
||||
ticker="AAPL",
|
||||
title="Title",
|
||||
source="polygon",
|
||||
url="https://example.com",
|
||||
).model_dump()
|
||||
]
|
||||
}
|
||||
if path == "/api/market-cap":
|
||||
return {"ticker": "AAPL", "end_date": "2026-03-16", "market_cap": 2.5e12}
|
||||
if path == "/api/line-items":
|
||||
return {
|
||||
"search_results": [
|
||||
LineItem(
|
||||
ticker="AAPL",
|
||||
report_period="2026-03-16",
|
||||
period="ttm",
|
||||
currency="USD",
|
||||
free_cash_flow=321.0,
|
||||
).model_dump()
|
||||
]
|
||||
}
|
||||
raise AssertionError(path)
|
||||
|
||||
monkeypatch.setattr(data_tools, "_service_get_json", fake_service_get_json)
|
||||
|
||||
prices = data_tools.get_prices("AAPL", "2026-03-01", "2026-03-16")
|
||||
metrics = data_tools.get_financial_metrics("AAPL", "2026-03-16")
|
||||
trades = data_tools.get_insider_trades("AAPL", "2026-03-16")
|
||||
news = data_tools.get_company_news("AAPL", "2026-03-16")
|
||||
market_cap = data_tools.get_market_cap("AAPL", "2026-03-16")
|
||||
line_items = data_tools.search_line_items(
|
||||
"AAPL",
|
||||
["free_cash_flow"],
|
||||
"2026-03-16",
|
||||
)
|
||||
|
||||
assert prices[0].close == 2
|
||||
assert metrics[0].ticker == "AAPL"
|
||||
assert trades[0].ticker == "AAPL"
|
||||
assert news[0].ticker == "AAPL"
|
||||
assert market_cap == 2.5e12
|
||||
assert line_items[0].free_cash_flow == 321.0
|
||||
|
||||
|
||||
def test_data_tools_skips_self_recursion_for_trading_service(monkeypatch):
|
||||
monkeypatch.setenv("TRADING_SERVICE_URL", "http://localhost:8001")
|
||||
monkeypatch.setenv("SERVICE_NAME", "trading_service")
|
||||
|
||||
assert data_tools._trading_service_url() is None
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
|
||||
from backend.services.gateway import Gateway
|
||||
import backend.services.gateway as gateway_module
|
||||
from shared.schema import InsiderTrade, InsiderTradeResponse, Price, PriceResponse
|
||||
|
||||
|
||||
class DummyWebSocket:
|
||||
@@ -35,6 +36,10 @@ class FakeMarketStore:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get_ticker_watermarks(self, symbol):
|
||||
self.calls.append(("get_ticker_watermarks", symbol))
|
||||
return {"symbol": symbol, "last_news_fetch": "2026-12-31"}
|
||||
|
||||
def get_news_timeline_enriched(self, symbol, *, start_date=None, end_date=None):
|
||||
self.calls.append(("get_news_timeline_enriched", symbol, start_date, end_date))
|
||||
return [{"date": end_date, "count": 2, "source_count": 1, "top_title": "Top", "positive_count": 1}]
|
||||
@@ -123,6 +128,75 @@ def make_gateway(market_store=None):
|
||||
)
|
||||
|
||||
|
||||
class FakeNewsClient:
|
||||
def __init__(self, base_url):
|
||||
self.base_url = base_url
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
return None
|
||||
|
||||
async def get_categories(self, ticker, start_date=None, end_date=None, limit=200):
|
||||
return {"ticker": ticker, "categories": {"remote": {"count": 2}}}
|
||||
|
||||
async def get_enriched_news(self, ticker, start_date=None, end_date=None, limit=None):
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"news": [
|
||||
{
|
||||
"id": "remote-news-1",
|
||||
"ticker": ticker,
|
||||
"title": "Remote Title",
|
||||
"date": end_date,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def get_story(self, ticker, as_of_date):
|
||||
return {"symbol": ticker, "as_of_date": as_of_date, "story": "remote story", "source": "news_service"}
|
||||
|
||||
|
||||
class FakeTradingClient:
|
||||
def __init__(self, base_url):
|
||||
self.base_url = base_url
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
return None
|
||||
|
||||
async def get_insider_trades(self, ticker, end_date=None, start_date=None, limit=None):
|
||||
return InsiderTradeResponse(
|
||||
insider_trades=[
|
||||
InsiderTrade(
|
||||
ticker=ticker,
|
||||
name="Remote Insider",
|
||||
filing_date=end_date or "2026-03-16",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def get_prices(self, ticker, start_date=None, end_date=None):
|
||||
prices = [
|
||||
Price(
|
||||
open=float(100 + idx),
|
||||
close=float(101 + idx),
|
||||
high=float(102 + idx),
|
||||
low=float(99 + idx),
|
||||
volume=1000 + idx,
|
||||
time=f"2026-01-{idx + 1:02d}",
|
||||
)
|
||||
for idx in range(30)
|
||||
]
|
||||
return PriceResponse(ticker=ticker, prices=prices)
|
||||
|
||||
async def get_market_cap(self, ticker, end_date):
|
||||
return {"ticker": ticker, "end_date": end_date, "market_cap": 2.5e12}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_get_stock_news_timeline_uses_market_store_symbol_argument():
|
||||
market_store = FakeMarketStore()
|
||||
@@ -135,6 +209,7 @@ async def test_handle_get_stock_news_timeline_uses_market_store_symbol_argument(
|
||||
)
|
||||
|
||||
assert market_store.calls == [
|
||||
("get_ticker_watermarks", "AAPL"),
|
||||
("get_news_timeline_enriched", "AAPL", "2026-02-14", "2026-03-16")
|
||||
]
|
||||
assert websocket.messages[-1]["type"] == "stock_news_timeline_loaded"
|
||||
@@ -153,6 +228,7 @@ async def test_handle_get_stock_news_categories_uses_market_store_symbol_argumen
|
||||
)
|
||||
|
||||
assert market_store.calls == [
|
||||
("get_ticker_watermarks", "AAPL"),
|
||||
("get_news_items_enriched", "AAPL", "2026-02-14", "2026-03-16", None, 200),
|
||||
("get_news_categories_enriched", "AAPL", "2026-02-14", "2026-03-16", 200)
|
||||
]
|
||||
@@ -175,7 +251,7 @@ async def test_handle_get_stock_range_explain_uses_market_store_rows(monkeypatch
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
gateway_module.news_domain,
|
||||
"build_range_explanation",
|
||||
fake_build_range_explanation,
|
||||
)
|
||||
@@ -186,6 +262,7 @@ async def test_handle_get_stock_range_explain_uses_market_store_rows(monkeypatch
|
||||
)
|
||||
|
||||
assert market_store.calls == [
|
||||
("get_ticker_watermarks", "AAPL"),
|
||||
("get_news_items_enriched", "AAPL", "2026-03-10", "2026-03-16", None, 100)
|
||||
]
|
||||
assert websocket.messages[-1] == {
|
||||
@@ -207,7 +284,7 @@ async def test_handle_get_stock_range_explain_uses_article_ids_path(monkeypatch)
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
gateway_module.news_domain,
|
||||
"build_range_explanation",
|
||||
lambda **kwargs: {"news_count": len(kwargs["news_rows"])},
|
||||
)
|
||||
@@ -222,7 +299,10 @@ async def test_handle_get_stock_range_explain_uses_article_ids_path(monkeypatch)
|
||||
},
|
||||
)
|
||||
|
||||
assert market_store.calls == [("get_news_by_ids_enriched", "AAPL", ["news-99"])]
|
||||
assert market_store.calls == [
|
||||
("get_ticker_watermarks", "AAPL"),
|
||||
("get_news_by_ids_enriched", "AAPL", ["news-99"])
|
||||
]
|
||||
assert websocket.messages[-1]["result"]["news_count"] == 1
|
||||
|
||||
|
||||
@@ -238,6 +318,7 @@ async def test_handle_get_stock_news_for_date_uses_trade_date_lookup():
|
||||
)
|
||||
|
||||
assert market_store.calls == [
|
||||
("get_ticker_watermarks", "AAPL"),
|
||||
("get_news_items_enriched", "AAPL", None, None, "2026-03-16", 10)
|
||||
]
|
||||
assert websocket.messages[-1]["type"] == "stock_news_for_date_loaded"
|
||||
@@ -251,7 +332,7 @@ async def test_handle_get_stock_story_returns_story_payload(monkeypatch):
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
gateway_module.news_domain,
|
||||
"enrich_news_for_symbol",
|
||||
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 3},
|
||||
)
|
||||
@@ -266,6 +347,132 @@ async def test_handle_get_stock_story_returns_story_payload(monkeypatch):
|
||||
assert "AAPL Story" in websocket.messages[-1]["story"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_get_stock_news_categories_uses_news_service_client_when_configured(monkeypatch):
|
||||
market_store = FakeMarketStore()
|
||||
gateway = make_gateway(market_store)
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setenv("NEWS_SERVICE_URL", "http://news-service.local")
|
||||
monkeypatch.setattr(gateway_module, "NewsServiceClient", FakeNewsClient)
|
||||
|
||||
await gateway._handle_get_stock_news_categories(
|
||||
websocket,
|
||||
{"ticker": "AAPL", "lookback_days": 30},
|
||||
)
|
||||
|
||||
assert market_store.calls == []
|
||||
assert websocket.messages[-1]["type"] == "stock_news_categories_loaded"
|
||||
assert websocket.messages[-1]["categories"]["remote"]["count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_get_stock_story_uses_news_service_client_when_configured(monkeypatch):
|
||||
market_store = FakeMarketStore()
|
||||
gateway = make_gateway(market_store)
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setenv("NEWS_SERVICE_URL", "http://news-service.local")
|
||||
monkeypatch.setattr(gateway_module, "NewsServiceClient", FakeNewsClient)
|
||||
|
||||
await gateway._handle_get_stock_story(
|
||||
websocket,
|
||||
{"ticker": "AAPL", "as_of_date": "2026-03-16"},
|
||||
)
|
||||
|
||||
assert market_store.calls == []
|
||||
assert websocket.messages[-1]["type"] == "stock_story_loaded"
|
||||
assert websocket.messages[-1]["story"] == "remote story"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_get_stock_news_uses_news_service_client_when_configured(monkeypatch):
|
||||
market_store = FakeMarketStore()
|
||||
gateway = make_gateway(market_store)
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setenv("NEWS_SERVICE_URL", "http://news-service.local")
|
||||
monkeypatch.setattr(gateway_module, "NewsServiceClient", FakeNewsClient)
|
||||
|
||||
await gateway._handle_get_stock_news(
|
||||
websocket,
|
||||
{"ticker": "AAPL", "lookback_days": 30, "limit": 5},
|
||||
)
|
||||
|
||||
assert market_store.calls == []
|
||||
assert websocket.messages[-1]["type"] == "stock_news_loaded"
|
||||
assert websocket.messages[-1]["source"] == "news_service"
|
||||
assert websocket.messages[-1]["news"][0]["title"] == "Remote Title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_get_stock_insider_trades_uses_trading_service_client_when_configured(monkeypatch):
|
||||
market_store = FakeMarketStore()
|
||||
gateway = make_gateway(market_store)
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setenv("TRADING_SERVICE_URL", "http://trading-service.local")
|
||||
monkeypatch.setattr(gateway_module, "TradingServiceClient", FakeTradingClient)
|
||||
|
||||
await gateway._handle_get_stock_insider_trades(
|
||||
websocket,
|
||||
{"ticker": "AAPL", "end_date": "2026-03-16", "limit": 10},
|
||||
)
|
||||
|
||||
assert websocket.messages[-1]["type"] == "stock_insider_trades_loaded"
|
||||
assert websocket.messages[-1]["trades"][0]["name"] == "Remote Insider"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_get_stock_history_uses_trading_service_client_when_configured(monkeypatch):
|
||||
market_store = FakeMarketStore()
|
||||
gateway = make_gateway(market_store)
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setenv("TRADING_SERVICE_URL", "http://trading-service.local")
|
||||
monkeypatch.setattr(gateway_module, "TradingServiceClient", FakeTradingClient)
|
||||
|
||||
await gateway._handle_get_stock_history(
|
||||
websocket,
|
||||
{"ticker": "AAPL", "lookback_days": 30},
|
||||
)
|
||||
|
||||
assert market_store.calls == []
|
||||
assert websocket.messages[-1]["type"] == "stock_history_loaded"
|
||||
assert websocket.messages[-1]["source"] == "trading_service"
|
||||
assert len(websocket.messages[-1]["prices"]) == 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_get_stock_technical_indicators_uses_trading_service_client_when_configured(monkeypatch):
|
||||
gateway = make_gateway(FakeMarketStore())
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setenv("TRADING_SERVICE_URL", "http://trading-service.local")
|
||||
monkeypatch.setattr(gateway_module, "TradingServiceClient", FakeTradingClient)
|
||||
|
||||
await gateway._handle_get_stock_technical_indicators(
|
||||
websocket,
|
||||
{"ticker": "AAPL"},
|
||||
)
|
||||
|
||||
assert websocket.messages[-1]["type"] == "stock_technical_indicators_loaded"
|
||||
assert websocket.messages[-1]["ticker"] == "AAPL"
|
||||
assert websocket.messages[-1]["indicators"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_market_caps_uses_trading_service_client_when_configured(monkeypatch):
|
||||
gateway = make_gateway(FakeMarketStore())
|
||||
|
||||
monkeypatch.setenv("TRADING_SERVICE_URL", "http://trading-service.local")
|
||||
monkeypatch.setattr(gateway_module, "TradingServiceClient", FakeTradingClient)
|
||||
|
||||
market_caps = await gateway._get_market_caps(["AAPL", "MSFT"], "2026-03-16")
|
||||
|
||||
assert market_caps == {"AAPL": 2.5e12, "MSFT": 2.5e12}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_get_stock_similar_days_returns_items(monkeypatch):
|
||||
market_store = FakeMarketStore()
|
||||
@@ -273,7 +480,7 @@ async def test_handle_get_stock_similar_days_returns_items(monkeypatch):
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
gateway_module.news_domain,
|
||||
"enrich_news_for_symbol",
|
||||
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 3},
|
||||
)
|
||||
@@ -295,7 +502,12 @@ async def test_handle_run_stock_enrich_rebuilds_caches(monkeypatch):
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
gateway_module.gateway_stock_handlers,
|
||||
"enrich_news_for_symbol",
|
||||
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 2, "queued_count": 2},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_module.news_domain,
|
||||
"enrich_news_for_symbol",
|
||||
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 2, "queued_count": 2},
|
||||
)
|
||||
@@ -325,7 +537,7 @@ async def test_handle_run_stock_enrich_rejects_local_to_llm_without_llm(monkeypa
|
||||
gateway = make_gateway(FakeMarketStore())
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setattr(gateway_module, "llm_enrichment_enabled", lambda: False)
|
||||
monkeypatch.setattr(gateway_module.gateway_stock_handlers, "llm_enrichment_enabled", lambda: False)
|
||||
|
||||
await gateway._handle_run_stock_enrich(
|
||||
websocket,
|
||||
@@ -361,7 +573,7 @@ def test_schedule_watchlist_market_store_refresh_creates_task(monkeypatch):
|
||||
|
||||
gateway._schedule_watchlist_market_store_refresh(["AAPL", "MSFT"])
|
||||
|
||||
assert captured["coro_name"] == "_refresh_market_store_for_watchlist"
|
||||
assert captured["coro_name"] == "refresh_market_store_for_watchlist"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -369,7 +581,7 @@ async def test_refresh_market_store_for_watchlist_emits_system_messages(monkeypa
|
||||
gateway = make_gateway()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
gateway_module.gateway_cycle_support,
|
||||
"ingest_symbols",
|
||||
lambda symbols, mode="incremental": [
|
||||
{"symbol": symbol, "prices": 3, "news": 4, "aligned": 4}
|
||||
@@ -445,12 +657,12 @@ async def test_handle_get_agent_profile_returns_model_and_tool_groups(monkeypatc
|
||||
websocket = DummyWebSocket()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
gateway_module.gateway_admin_handlers,
|
||||
"load_agent_profiles",
|
||||
lambda: {"risk_manager": {"skills": ["risk_review"], "active_tool_groups": ["risk_ops", "legacy_group"]}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
gateway_module.gateway_admin_handlers,
|
||||
"get_agent_model_info",
|
||||
lambda agent_id: ("gpt-4o-mini", "OPENAI"),
|
||||
)
|
||||
@@ -461,7 +673,7 @@ async def test_handle_get_agent_profile_returns_model_and_tool_groups(monkeypatc
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_module,
|
||||
gateway_module.gateway_admin_handlers,
|
||||
"get_bootstrap_config_for_run",
|
||||
lambda project_root, config_name: _Bootstrap(),
|
||||
)
|
||||
|
||||
211
backend/tests/test_gateway_support_modules.py
Normal file
211
backend/tests/test_gateway_support_modules.py
Normal file
@@ -0,0 +1,211 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Direct tests for Gateway support modules."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services import gateway_cycle_support, gateway_runtime_support
|
||||
|
||||
|
||||
class _DummyDashboard:
|
||||
def __init__(self):
|
||||
self.updated = []
|
||||
self.tickers = []
|
||||
self.initial_cash = None
|
||||
self.enable_memory = False
|
||||
self.days_total = 0
|
||||
|
||||
def update(self, **kwargs):
|
||||
self.updated.append(kwargs)
|
||||
|
||||
def stop(self):
|
||||
return None
|
||||
|
||||
def print_final_summary(self):
|
||||
return None
|
||||
|
||||
|
||||
class _DummyScheduler:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def reconfigure(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
|
||||
|
||||
class _DummyStateSync:
|
||||
def __init__(self):
|
||||
self.updated = []
|
||||
self.saved = False
|
||||
self.system_messages = []
|
||||
self.backtest_dates = []
|
||||
self.state = {}
|
||||
|
||||
def update_state(self, key, value):
|
||||
self.updated.append((key, value))
|
||||
self.state[key] = value
|
||||
|
||||
def save_state(self):
|
||||
self.saved = True
|
||||
|
||||
async def on_system_message(self, message):
|
||||
self.system_messages.append(message)
|
||||
|
||||
def set_backtest_dates(self, dates):
|
||||
self.backtest_dates = list(dates)
|
||||
|
||||
|
||||
class _DummyStorage:
|
||||
def __init__(self):
|
||||
self.initial_cash = 100000.0
|
||||
self.is_live_session_active = False
|
||||
self.server_state_updates = []
|
||||
|
||||
def can_apply_initial_cash(self):
|
||||
return True
|
||||
|
||||
def apply_initial_cash(self, value):
|
||||
self.initial_cash = value
|
||||
return True
|
||||
|
||||
def update_server_state_from_dashboard(self, state):
|
||||
self.server_state_updates.append(state)
|
||||
|
||||
def load_file(self, name):
|
||||
if name == "summary":
|
||||
return {"totalAssetValue": self.initial_cash}
|
||||
return []
|
||||
|
||||
|
||||
class _DummyPM:
|
||||
def __init__(self):
|
||||
self.portfolio = {"margin_requirement": 0.0}
|
||||
|
||||
def apply_runtime_portfolio_config(self, margin_requirement=None, initial_cash=None):
|
||||
if margin_requirement is not None:
|
||||
self.portfolio["margin_requirement"] = margin_requirement
|
||||
return {"margin_requirement": True}
|
||||
|
||||
def can_apply_initial_cash(self):
|
||||
return True
|
||||
|
||||
|
||||
class _DummyMarketService:
|
||||
def __init__(self):
|
||||
self.updated = None
|
||||
self.stopped = False
|
||||
|
||||
def update_tickers(self, tickers):
|
||||
self.updated = list(tickers)
|
||||
return {"active": list(tickers), "added": list(tickers), "removed": []}
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
|
||||
def make_gateway_stub():
|
||||
pipeline = SimpleNamespace(max_comm_cycles=0, pm=_DummyPM())
|
||||
gateway = SimpleNamespace(
|
||||
market_service=_DummyMarketService(),
|
||||
pipeline=pipeline,
|
||||
scheduler=_DummyScheduler(),
|
||||
config={
|
||||
"tickers": ["AAPL"],
|
||||
"schedule_mode": "daily",
|
||||
"interval_minutes": 60,
|
||||
"trigger_time": "09:30",
|
||||
"enable_memory": False,
|
||||
},
|
||||
storage=_DummyStorage(),
|
||||
state_sync=_DummyStateSync(),
|
||||
_dashboard=_DummyDashboard(),
|
||||
_watchlist_ingest_task=None,
|
||||
_market_status_task=None,
|
||||
_backtest_task=None,
|
||||
_backtest_start_date=None,
|
||||
_backtest_end_date=None,
|
||||
_manual_cycle_task=None,
|
||||
)
|
||||
return gateway
|
||||
|
||||
|
||||
def test_normalize_watchlist_filters_invalid_and_dedupes():
|
||||
assert gateway_runtime_support.normalize_watchlist(["aapl", " AAPL ", "", "msft"]) == ["AAPL", "MSFT"]
|
||||
assert gateway_runtime_support.normalize_watchlist("aapl,msft") == ["AAPL", "MSFT"]
|
||||
|
||||
|
||||
def test_normalize_agent_workspace_filename_obeys_allowlist():
|
||||
allowlist = {"SOUL.md", "PROFILE.md"}
|
||||
assert gateway_runtime_support.normalize_agent_workspace_filename("SOUL.md", allowlist=allowlist) == "SOUL.md"
|
||||
assert gateway_runtime_support.normalize_agent_workspace_filename("README.md", allowlist=allowlist) is None
|
||||
|
||||
|
||||
def test_apply_runtime_config_updates_gateway_state():
|
||||
gateway = make_gateway_stub()
|
||||
|
||||
result = gateway_runtime_support.apply_runtime_config(
|
||||
gateway,
|
||||
{
|
||||
"tickers": ["MSFT", "NVDA"],
|
||||
"schedule_mode": "intraday",
|
||||
"interval_minutes": 30,
|
||||
"trigger_time": "10:30",
|
||||
"initial_cash": 150000.0,
|
||||
"margin_requirement": 0.5,
|
||||
"max_comm_cycles": 4,
|
||||
"enable_memory": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert gateway.config["tickers"] == ["MSFT", "NVDA"]
|
||||
assert gateway.config["schedule_mode"] == "intraday"
|
||||
assert gateway.storage.initial_cash == 150000.0
|
||||
assert result["runtime_config_applied"]["max_comm_cycles"] == 4
|
||||
assert gateway.scheduler.calls[-1] == {
|
||||
"mode": "intraday",
|
||||
"trigger_time": "10:30",
|
||||
"interval_minutes": 30,
|
||||
}
|
||||
|
||||
|
||||
def test_schedule_watchlist_market_store_refresh_creates_task(monkeypatch):
|
||||
gateway = make_gateway_stub()
|
||||
captured = {}
|
||||
|
||||
class DummyTask:
|
||||
def done(self):
|
||||
return False
|
||||
|
||||
def cancel(self):
|
||||
captured["cancelled"] = True
|
||||
|
||||
def fake_create_task(coro):
|
||||
captured["name"] = coro.cr_code.co_name
|
||||
coro.close()
|
||||
return DummyTask()
|
||||
|
||||
monkeypatch.setattr(gateway_cycle_support.asyncio, "create_task", fake_create_task)
|
||||
|
||||
gateway_cycle_support.schedule_watchlist_market_store_refresh(gateway, ["AAPL", "MSFT"])
|
||||
|
||||
assert captured["name"] == "refresh_market_store_for_watchlist"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_market_store_for_watchlist_emits_system_messages(monkeypatch):
|
||||
gateway = make_gateway_stub()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cycle_support,
|
||||
"ingest_symbols",
|
||||
lambda symbols, mode="incremental": [
|
||||
{"symbol": symbol, "prices": 3, "news": 4}
|
||||
for symbol in symbols
|
||||
],
|
||||
)
|
||||
|
||||
await gateway_cycle_support.refresh_market_store_for_watchlist(gateway, ["AAPL", "MSFT"])
|
||||
|
||||
assert gateway.state_sync.system_messages[0] == "正在同步自选股市场数据: AAPL, MSFT"
|
||||
assert "自选股市场数据已同步:" in gateway.state_sync.system_messages[1]
|
||||
171
backend/tests/test_news_domain.py
Normal file
171
backend/tests/test_news_domain.py
Normal file
@@ -0,0 +1,171 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for the news domain helpers."""
|
||||
|
||||
from backend.domains import news as news_domain
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get_ticker_watermarks(self, symbol):
|
||||
self.calls.append(("get_ticker_watermarks", symbol))
|
||||
return {"symbol": symbol, "last_news_fetch": "2026-03-10"}
|
||||
|
||||
def get_news_items_enriched(self, ticker, start_date=None, end_date=None, trade_date=None, limit=100):
|
||||
self.calls.append(("get_news_items_enriched", ticker, start_date, end_date, trade_date, limit))
|
||||
target = trade_date or end_date
|
||||
return [{"id": "n1", "ticker": ticker, "date": target, "trade_date": target}]
|
||||
|
||||
def get_news_timeline_enriched(self, ticker, start_date=None, end_date=None):
|
||||
self.calls.append(("get_news_timeline_enriched", ticker, start_date, end_date))
|
||||
return [{"date": end_date, "count": 1}]
|
||||
|
||||
def get_news_categories_enriched(self, ticker, start_date=None, end_date=None, limit=200):
|
||||
self.calls.append(("get_news_categories_enriched", ticker, start_date, end_date, limit))
|
||||
return {"macro": {"count": 1}}
|
||||
|
||||
def get_news_by_ids_enriched(self, ticker, article_ids):
|
||||
self.calls.append(("get_news_by_ids_enriched", ticker, list(article_ids)))
|
||||
return [{"id": article_ids[0], "ticker": ticker, "date": "2026-03-16"}]
|
||||
|
||||
|
||||
def test_news_rows_need_enrichment_detects_missing_fields():
|
||||
assert news_domain.news_rows_need_enrichment([]) is True
|
||||
assert news_domain.news_rows_need_enrichment([{"sentiment": "", "relevance": "", "key_discussion": ""}]) is True
|
||||
assert news_domain.news_rows_need_enrichment([{"sentiment": "positive"}]) is False
|
||||
|
||||
|
||||
def test_ensure_news_fresh_triggers_incremental_refresh_when_watermark_is_stale(monkeypatch):
|
||||
store = _FakeStore()
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"update_ticker_incremental",
|
||||
lambda symbol, end_date=None, store=None: calls.append((symbol, end_date)),
|
||||
)
|
||||
|
||||
payload = news_domain.ensure_news_fresh(store, ticker="AAPL", target_date="2026-03-16")
|
||||
|
||||
assert calls == [("AAPL", "2026-03-16")]
|
||||
assert payload["target_date"] == "2026-03-16"
|
||||
assert payload["refreshed"] is True
|
||||
|
||||
|
||||
def test_ensure_news_fresh_skips_refresh_when_watermark_is_current(monkeypatch):
|
||||
store = _FakeStore()
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
store,
|
||||
"get_ticker_watermarks",
|
||||
lambda symbol: {"symbol": symbol, "last_news_fetch": "2026-03-16"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"update_ticker_incremental",
|
||||
lambda symbol, end_date=None, store=None: calls.append((symbol, end_date)),
|
||||
)
|
||||
|
||||
payload = news_domain.ensure_news_fresh(store, ticker="AAPL", target_date="2026-03-16")
|
||||
|
||||
assert calls == []
|
||||
assert payload["refreshed"] is False
|
||||
|
||||
|
||||
def test_get_enriched_news_returns_rows_without_enrichment_when_present(monkeypatch):
|
||||
store = _FakeStore()
|
||||
monkeypatch.setattr(news_domain, "news_rows_need_enrichment", lambda rows: False)
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"ensure_news_fresh",
|
||||
lambda store, ticker, target_date=None: {
|
||||
"ticker": ticker,
|
||||
"target_date": target_date,
|
||||
"last_news_fetch": target_date,
|
||||
"refreshed": False,
|
||||
},
|
||||
)
|
||||
|
||||
payload = news_domain.get_enriched_news(
|
||||
store,
|
||||
ticker="AAPL",
|
||||
start_date="2026-03-01",
|
||||
end_date="2026-03-16",
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert payload["ticker"] == "AAPL"
|
||||
assert payload["news"][0]["ticker"] == "AAPL"
|
||||
assert payload["freshness"]["target_date"] is None or payload["freshness"]["target_date"] == "2026-03-16"
|
||||
assert store.calls == [
|
||||
("get_news_items_enriched", "AAPL", "2026-03-01", "2026-03-16", None, 20)
|
||||
]
|
||||
|
||||
|
||||
def test_get_story_and_similar_days_delegate(monkeypatch):
|
||||
store = _FakeStore()
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"ensure_news_fresh",
|
||||
lambda store, ticker, target_date=None: {
|
||||
"ticker": ticker,
|
||||
"target_date": target_date,
|
||||
"last_news_fetch": target_date,
|
||||
"refreshed": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(news_domain, "enrich_news_for_symbol", lambda *args, **kwargs: {"analyzed": 1})
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"get_or_create_stock_story",
|
||||
lambda store, symbol, as_of_date: {"symbol": symbol, "as_of_date": as_of_date, "story": "story"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"find_similar_days",
|
||||
lambda store, symbol, target_date, top_k: {"symbol": symbol, "target_date": target_date, "items": [{"score": 0.9}]},
|
||||
)
|
||||
|
||||
story = news_domain.get_story_payload(store, ticker="AAPL", as_of_date="2026-03-16")
|
||||
similar = news_domain.get_similar_days_payload(store, ticker="AAPL", date="2026-03-16", n_similar=8)
|
||||
|
||||
assert story["story"] == "story"
|
||||
assert "freshness" in story
|
||||
assert similar["items"][0]["score"] == 0.9
|
||||
assert "freshness" in similar
|
||||
|
||||
|
||||
def test_get_range_explain_payload_uses_article_ids(monkeypatch):
|
||||
store = _FakeStore()
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"ensure_news_fresh",
|
||||
lambda store, ticker, target_date=None: {
|
||||
"ticker": ticker,
|
||||
"target_date": target_date,
|
||||
"last_news_fetch": target_date,
|
||||
"refreshed": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(news_domain, "news_rows_need_enrichment", lambda rows: False)
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"build_range_explanation",
|
||||
lambda ticker, start_date, end_date, news_rows: {"ticker": ticker, "count": len(news_rows)},
|
||||
)
|
||||
|
||||
payload = news_domain.get_range_explain_payload(
|
||||
store,
|
||||
ticker="AAPL",
|
||||
start_date="2026-03-10",
|
||||
end_date="2026-03-16",
|
||||
article_ids=["news-9"],
|
||||
limit=50,
|
||||
)
|
||||
|
||||
assert payload["ticker"] == "AAPL"
|
||||
assert payload["result"] == {"ticker": "AAPL", "count": 1}
|
||||
assert "freshness" in payload
|
||||
assert store.calls == [("get_news_by_ids_enriched", "AAPL", ["news-9"])]
|
||||
180
backend/tests/test_news_service_app.py
Normal file
180
backend/tests/test_news_service_app.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# -*- 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
|
||||
@@ -9,6 +9,7 @@ def test_router_includes_local_csv_fallback(monkeypatch):
|
||||
monkeypatch.delenv("FINNHUB_API_KEY", raising=False)
|
||||
monkeypatch.delenv("FINANCIAL_DATASETS_API_KEY", raising=False)
|
||||
monkeypatch.delenv("FIN_DATA_SOURCE", raising=False)
|
||||
monkeypatch.delenv("ENABLED_DATA_SOURCES", raising=False)
|
||||
reset_config()
|
||||
|
||||
router = DataProviderRouter()
|
||||
|
||||
194
backend/tests/test_runtime_service_app.py
Normal file
194
backend/tests/test_runtime_service_app.py
Normal file
@@ -0,0 +1,194 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the extracted runtime service app surface."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api import runtime as runtime_module
|
||||
from backend.apps.runtime_service import create_app
|
||||
|
||||
|
||||
def test_runtime_service_routes_are_exposed():
|
||||
app = create_app()
|
||||
paths = {route.path for route in app.routes}
|
||||
|
||||
assert "/health" in paths
|
||||
assert "/api/status" in paths
|
||||
assert "/api/runtime/start" in paths
|
||||
assert "/api/runtime/stop" in paths
|
||||
assert "/api/runtime/current" in paths
|
||||
assert "/api/runtime/gateway/port" in paths
|
||||
|
||||
|
||||
def test_runtime_service_health_and_status(monkeypatch):
|
||||
runtime_state = runtime_module.get_runtime_state()
|
||||
runtime_state.gateway_process = None
|
||||
runtime_state.gateway_port = 9876
|
||||
runtime_state.runtime_manager = object()
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
health_response = client.get("/health")
|
||||
status_response = client.get("/api/status")
|
||||
|
||||
assert health_response.status_code == 200
|
||||
assert health_response.json() == {
|
||||
"status": "healthy",
|
||||
"service": "runtime-service",
|
||||
"gateway_running": False,
|
||||
"gateway_port": 9876,
|
||||
}
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json() == {
|
||||
"status": "operational",
|
||||
"service": "runtime-service",
|
||||
"runtime": {
|
||||
"gateway_running": False,
|
||||
"gateway_port": 9876,
|
||||
"has_runtime_manager": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_runtime_service_gateway_port_endpoint_uses_runtime_router(monkeypatch):
|
||||
runtime_module.get_runtime_state().gateway_port = 9345
|
||||
monkeypatch.setattr(runtime_module, "_is_gateway_running", lambda: True)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/api/runtime/gateway/port",
|
||||
headers={"host": "runtime.example:8003", "x-forwarded-proto": "https"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"port": 9345,
|
||||
"is_running": True,
|
||||
"ws_url": "wss://runtime.example:9345",
|
||||
}
|
||||
|
||||
|
||||
def test_runtime_service_get_runtime_config(monkeypatch, tmp_path):
|
||||
run_dir = tmp_path / "runs" / "demo"
|
||||
state_dir = run_dir / "state"
|
||||
state_dir.mkdir(parents=True)
|
||||
(run_dir / "BOOTSTRAP.md").write_text(
|
||||
"---\n"
|
||||
"tickers:\n"
|
||||
" - AAPL\n"
|
||||
"schedule_mode: intraday\n"
|
||||
"interval_minutes: 30\n"
|
||||
"trigger_time: '10:00'\n"
|
||||
"max_comm_cycles: 3\n"
|
||||
"enable_memory: true\n"
|
||||
"---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(state_dir / "runtime_state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"context": {
|
||||
"config_name": "demo",
|
||||
"run_dir": str(run_dir),
|
||||
"bootstrap_values": {
|
||||
"tickers": ["AAPL"],
|
||||
"schedule_mode": "intraday",
|
||||
"interval_minutes": 30,
|
||||
"trigger_time": "10:00",
|
||||
"max_comm_cycles": 3,
|
||||
"enable_memory": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||
monkeypatch.setattr(runtime_module, "_is_gateway_running", lambda: True)
|
||||
runtime_module.get_runtime_state().gateway_port = 8765
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get("/api/runtime/config")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["run_id"] == "demo"
|
||||
assert payload["bootstrap"]["schedule_mode"] == "intraday"
|
||||
assert payload["resolved"]["interval_minutes"] == 30
|
||||
assert payload["resolved"]["enable_memory"] is True
|
||||
|
||||
|
||||
def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, tmp_path):
|
||||
run_dir = tmp_path / "runs" / "demo"
|
||||
state_dir = run_dir / "state"
|
||||
state_dir.mkdir(parents=True)
|
||||
(run_dir / "BOOTSTRAP.md").write_text(
|
||||
"---\n"
|
||||
"tickers:\n"
|
||||
" - AAPL\n"
|
||||
"schedule_mode: daily\n"
|
||||
"interval_minutes: 60\n"
|
||||
"trigger_time: '09:30'\n"
|
||||
"max_comm_cycles: 2\n"
|
||||
"---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(state_dir / "runtime_state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"context": {
|
||||
"config_name": "demo",
|
||||
"run_dir": str(run_dir),
|
||||
"bootstrap_values": {
|
||||
"tickers": ["AAPL"],
|
||||
"schedule_mode": "daily",
|
||||
"interval_minutes": 60,
|
||||
"trigger_time": "09:30",
|
||||
"max_comm_cycles": 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
class _DummyContext:
|
||||
def __init__(self):
|
||||
self.bootstrap_values = {
|
||||
"tickers": ["AAPL"],
|
||||
"schedule_mode": "daily",
|
||||
"interval_minutes": 60,
|
||||
"trigger_time": "09:30",
|
||||
"max_comm_cycles": 2,
|
||||
}
|
||||
|
||||
class _DummyManager:
|
||||
def __init__(self):
|
||||
self.config_name = "demo"
|
||||
self.bootstrap = dict(_DummyContext().bootstrap_values)
|
||||
self.context = _DummyContext()
|
||||
|
||||
def _persist_snapshot(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||
monkeypatch.setattr(runtime_module, "_is_gateway_running", lambda: True)
|
||||
runtime_module.get_runtime_state().runtime_manager = _DummyManager()
|
||||
runtime_module.get_runtime_state().gateway_port = 8765
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.put(
|
||||
"/api/runtime/config",
|
||||
json={
|
||||
"schedule_mode": "intraday",
|
||||
"interval_minutes": 15,
|
||||
"trigger_time": "10:15",
|
||||
"max_comm_cycles": 4,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["bootstrap"]["schedule_mode"] == "intraday"
|
||||
assert payload["resolved"]["interval_minutes"] == 15
|
||||
assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8")
|
||||
107
backend/tests/test_service_clients.py
Normal file
107
backend/tests/test_service_clients.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for split-aware shared service clients."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.client.control_client import ControlPlaneClient
|
||||
from shared.client.runtime_client import RuntimeServiceClient
|
||||
|
||||
|
||||
class _DummyResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _DummyAsyncClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def get(self, path, params=None):
|
||||
self.calls.append(("get", path, params))
|
||||
return _DummyResponse({"path": path, "params": params})
|
||||
|
||||
async def post(self, path, json=None):
|
||||
self.calls.append(("post", path, json))
|
||||
return _DummyResponse({"path": path, "json": json})
|
||||
|
||||
async def put(self, path, json=None):
|
||||
self.calls.append(("put", path, json))
|
||||
return _DummyResponse({"path": path, "json": json})
|
||||
|
||||
async def aclose(self):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_plane_client_hits_current_workspace_and_guard_routes():
|
||||
client = ControlPlaneClient()
|
||||
client._client = _DummyAsyncClient()
|
||||
|
||||
await client.list_workspaces()
|
||||
await client.get_workspace("demo")
|
||||
await client.list_agents("demo")
|
||||
await client.get_agent("demo", "risk_manager")
|
||||
await client.fetch_pending_approvals()
|
||||
await client.approve_pending_approval("ap-1")
|
||||
await client.deny_pending_approval("ap-2", reason="nope")
|
||||
|
||||
assert client._client.calls == [
|
||||
("get", "/workspaces", None),
|
||||
("get", "/workspaces/demo", None),
|
||||
("get", "/workspaces/demo/agents", None),
|
||||
("get", "/workspaces/demo/agents/risk_manager", None),
|
||||
("get", "/guard/pending", None),
|
||||
(
|
||||
"post",
|
||||
"/guard/approve",
|
||||
{
|
||||
"approval_id": "ap-1",
|
||||
"one_time": True,
|
||||
"expires_in_minutes": 30,
|
||||
},
|
||||
),
|
||||
(
|
||||
"post",
|
||||
"/guard/deny",
|
||||
{
|
||||
"approval_id": "ap-2",
|
||||
"reason": "nope",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_service_client_hits_current_runtime_routes():
|
||||
client = RuntimeServiceClient()
|
||||
client._client = _DummyAsyncClient()
|
||||
|
||||
await client.fetch_context()
|
||||
await client.fetch_agents()
|
||||
await client.fetch_events()
|
||||
await client.fetch_gateway_port()
|
||||
await client.start_runtime({"tickers": ["AAPL"]})
|
||||
await client.stop_runtime(force=True)
|
||||
await client.restart_runtime({"tickers": ["MSFT"]})
|
||||
await client.fetch_current_runtime()
|
||||
await client.get_runtime_config()
|
||||
await client.update_runtime_config({"schedule_mode": "intraday"})
|
||||
|
||||
assert client._client.calls == [
|
||||
("get", "/context", None),
|
||||
("get", "/agents", None),
|
||||
("get", "/events", None),
|
||||
("get", "/gateway/port", None),
|
||||
("post", "/start", {"tickers": ["AAPL"]}),
|
||||
("post", "/stop?force=true", None),
|
||||
("post", "/restart", {"tickers": ["MSFT"]}),
|
||||
("get", "/current", None),
|
||||
("get", "/config", None),
|
||||
("put", "/config", {"schedule_mode": "intraday"}),
|
||||
]
|
||||
32
backend/tests/test_shared_schema_bridge.py
Normal file
32
backend/tests/test_shared_schema_bridge.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Regression coverage for the shared schema bridge."""
|
||||
|
||||
from backend.data import schema as legacy_schema
|
||||
from shared import schema as shared_schema
|
||||
|
||||
|
||||
def test_backend_data_schema_reexports_shared_contracts():
|
||||
assert legacy_schema.Price is shared_schema.Price
|
||||
assert legacy_schema.PriceResponse is shared_schema.PriceResponse
|
||||
assert legacy_schema.FinancialMetrics is shared_schema.FinancialMetrics
|
||||
assert legacy_schema.FinancialMetricsResponse is (
|
||||
shared_schema.FinancialMetricsResponse
|
||||
)
|
||||
assert legacy_schema.LineItem is shared_schema.LineItem
|
||||
assert legacy_schema.LineItemResponse is shared_schema.LineItemResponse
|
||||
assert legacy_schema.InsiderTrade is shared_schema.InsiderTrade
|
||||
assert legacy_schema.InsiderTradeResponse is (
|
||||
shared_schema.InsiderTradeResponse
|
||||
)
|
||||
assert legacy_schema.CompanyNews is shared_schema.CompanyNews
|
||||
assert legacy_schema.CompanyNewsResponse is shared_schema.CompanyNewsResponse
|
||||
assert legacy_schema.CompanyFacts is shared_schema.CompanyFacts
|
||||
assert legacy_schema.CompanyFactsResponse is (
|
||||
shared_schema.CompanyFactsResponse
|
||||
)
|
||||
assert legacy_schema.Position is shared_schema.Position
|
||||
assert legacy_schema.Portfolio is shared_schema.Portfolio
|
||||
assert legacy_schema.AnalystSignal is shared_schema.AnalystSignal
|
||||
assert legacy_schema.TickerAnalysis is shared_schema.TickerAnalysis
|
||||
assert legacy_schema.AgentStateData is shared_schema.AgentStateData
|
||||
assert legacy_schema.AgentStateMetadata is shared_schema.AgentStateMetadata
|
||||
47
backend/tests/test_trading_domain.py
Normal file
47
backend/tests/test_trading_domain.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for the trading domain helpers."""
|
||||
|
||||
from backend.domains import trading as trading_domain
|
||||
|
||||
|
||||
def test_trading_domain_payload_wrappers(monkeypatch):
|
||||
monkeypatch.setattr(trading_domain, "get_prices", lambda ticker, start_date, end_date: [{"close": 1}])
|
||||
monkeypatch.setattr(trading_domain, "get_financial_metrics", lambda ticker, end_date, period, limit: [{"ticker": ticker}])
|
||||
monkeypatch.setattr(trading_domain, "get_company_news", lambda ticker, end_date, start_date=None, limit=1000: [{"ticker": ticker}])
|
||||
monkeypatch.setattr(trading_domain, "get_insider_trades", lambda ticker, end_date, start_date=None, limit=1000: [{"ticker": ticker}])
|
||||
monkeypatch.setattr(trading_domain, "get_market_cap", lambda ticker, end_date: 2.5e12)
|
||||
|
||||
assert trading_domain.get_prices_payload(ticker="AAPL", start_date="2026-03-01", end_date="2026-03-16") == {
|
||||
"ticker": "AAPL",
|
||||
"prices": [{"close": 1}],
|
||||
}
|
||||
assert trading_domain.get_financials_payload(ticker="AAPL", end_date="2026-03-16") == {
|
||||
"financial_metrics": [{"ticker": "AAPL"}],
|
||||
}
|
||||
assert trading_domain.get_news_payload(ticker="AAPL", end_date="2026-03-16") == {
|
||||
"news": [{"ticker": "AAPL"}],
|
||||
}
|
||||
assert trading_domain.get_insider_trades_payload(ticker="AAPL", end_date="2026-03-16") == {
|
||||
"insider_trades": [{"ticker": "AAPL"}],
|
||||
}
|
||||
assert trading_domain.get_market_cap_payload(ticker="AAPL", end_date="2026-03-16") == {
|
||||
"ticker": "AAPL",
|
||||
"end_date": "2026-03-16",
|
||||
"market_cap": 2.5e12,
|
||||
}
|
||||
|
||||
|
||||
def test_get_market_status_payload_uses_market_service(monkeypatch):
|
||||
class _FakeMarketService:
|
||||
def __init__(self, tickers):
|
||||
self.tickers = tickers
|
||||
|
||||
def get_market_status(self):
|
||||
return {"status": "open", "status_text": "Open"}
|
||||
|
||||
monkeypatch.setattr(trading_domain, "MarketService", _FakeMarketService)
|
||||
|
||||
assert trading_domain.get_market_status_payload() == {
|
||||
"status": "open",
|
||||
"status_text": "Open",
|
||||
}
|
||||
231
backend/tests/test_trading_service_app.py
Normal file
231
backend/tests/test_trading_service_app.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the extracted trading service app surface."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.apps.trading_service import create_app
|
||||
from shared.schema import CompanyNews, FinancialMetrics, InsiderTrade, LineItem, Price
|
||||
|
||||
|
||||
def test_trading_service_routes_are_exposed():
|
||||
app = create_app()
|
||||
|
||||
paths = {route.path for route in app.routes}
|
||||
|
||||
assert "/health" in paths
|
||||
assert "/api/prices" in paths
|
||||
assert "/api/financials" in paths
|
||||
assert "/api/news" in paths
|
||||
assert "/api/insider-trades" in paths
|
||||
assert "/api/market/status" in paths
|
||||
assert "/api/market-cap" in paths
|
||||
assert "/api/line-items" in paths
|
||||
|
||||
|
||||
def test_trading_service_prices_endpoint(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_prices_payload",
|
||||
lambda ticker, start_date, end_date: {
|
||||
"ticker": ticker,
|
||||
"prices": [
|
||||
Price(
|
||||
open=1.0,
|
||||
close=2.0,
|
||||
high=2.5,
|
||||
low=0.5,
|
||||
volume=100,
|
||||
time="2026-03-20",
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/api/prices",
|
||||
params={
|
||||
"ticker": "AAPL",
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": "2026-03-20",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ticker"] == "AAPL"
|
||||
assert response.json()["prices"][0]["close"] == 2.0
|
||||
|
||||
|
||||
def test_trading_service_financials_endpoint(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_financials_payload",
|
||||
lambda ticker, end_date, period, limit: {
|
||||
"financial_metrics": [
|
||||
FinancialMetrics(
|
||||
ticker=ticker,
|
||||
report_period=end_date,
|
||||
period=period,
|
||||
currency="USD",
|
||||
market_cap=123.0,
|
||||
enterprise_value=None,
|
||||
price_to_earnings_ratio=None,
|
||||
price_to_book_ratio=None,
|
||||
price_to_sales_ratio=None,
|
||||
enterprise_value_to_ebitda_ratio=None,
|
||||
enterprise_value_to_revenue_ratio=None,
|
||||
free_cash_flow_yield=None,
|
||||
peg_ratio=None,
|
||||
gross_margin=None,
|
||||
operating_margin=None,
|
||||
net_margin=None,
|
||||
return_on_equity=None,
|
||||
return_on_assets=None,
|
||||
return_on_invested_capital=None,
|
||||
asset_turnover=None,
|
||||
inventory_turnover=None,
|
||||
receivables_turnover=None,
|
||||
days_sales_outstanding=None,
|
||||
operating_cycle=None,
|
||||
working_capital_turnover=None,
|
||||
current_ratio=None,
|
||||
quick_ratio=None,
|
||||
cash_ratio=None,
|
||||
operating_cash_flow_ratio=None,
|
||||
debt_to_equity=None,
|
||||
debt_to_assets=None,
|
||||
interest_coverage=None,
|
||||
revenue_growth=None,
|
||||
earnings_growth=None,
|
||||
book_value_growth=None,
|
||||
earnings_per_share_growth=None,
|
||||
free_cash_flow_growth=None,
|
||||
operating_income_growth=None,
|
||||
ebitda_growth=None,
|
||||
payout_ratio=None,
|
||||
earnings_per_share=None,
|
||||
book_value_per_share=None,
|
||||
free_cash_flow_per_share=None,
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/api/financials",
|
||||
params={"ticker": "AAPL", "end_date": "2026-03-20"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["financial_metrics"][0]["ticker"] == "AAPL"
|
||||
|
||||
|
||||
def test_trading_service_news_and_insider_endpoints(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_news_payload",
|
||||
lambda ticker, end_date, start_date=None, limit=1000: {
|
||||
"news": [
|
||||
CompanyNews(
|
||||
ticker=ticker,
|
||||
title="News title",
|
||||
source="polygon",
|
||||
url="https://example.com/news",
|
||||
date=end_date,
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_insider_trades_payload",
|
||||
lambda ticker, end_date, start_date=None, limit=1000: {
|
||||
"insider_trades": [
|
||||
InsiderTrade(ticker=ticker, filing_date=end_date)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
news_response = client.get(
|
||||
"/api/news",
|
||||
params={"ticker": "AAPL", "end_date": "2026-03-20"},
|
||||
)
|
||||
insider_response = client.get(
|
||||
"/api/insider-trades",
|
||||
params={"ticker": "AAPL", "end_date": "2026-03-20"},
|
||||
)
|
||||
|
||||
assert news_response.status_code == 200
|
||||
assert news_response.json()["news"][0]["title"] == "News title"
|
||||
assert insider_response.status_code == 200
|
||||
assert insider_response.json()["insider_trades"][0]["ticker"] == "AAPL"
|
||||
|
||||
|
||||
def test_trading_service_market_status_endpoint(monkeypatch):
|
||||
class _FakeMarketService:
|
||||
def get_market_status(self):
|
||||
return {"status": "open", "status_text": "Open"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_market_status_payload",
|
||||
lambda: _FakeMarketService().get_market_status(),
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get("/api/market/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "open", "status_text": "Open"}
|
||||
|
||||
|
||||
def test_trading_service_market_cap_endpoint(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_market_cap_payload",
|
||||
lambda ticker, end_date: {
|
||||
"ticker": ticker,
|
||||
"end_date": end_date,
|
||||
"market_cap": 3.5e12,
|
||||
},
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/api/market-cap",
|
||||
params={"ticker": "AAPL", "end_date": "2026-03-20"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"ticker": "AAPL",
|
||||
"end_date": "2026-03-20",
|
||||
"market_cap": 3.5e12,
|
||||
}
|
||||
|
||||
|
||||
def test_trading_service_line_items_endpoint(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_line_items_payload",
|
||||
lambda ticker, line_items, end_date, period, limit: {
|
||||
"search_results": [
|
||||
LineItem(
|
||||
ticker=ticker,
|
||||
report_period=end_date,
|
||||
period=period,
|
||||
currency="USD",
|
||||
free_cash_flow=123.0,
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/api/line-items",
|
||||
params=[
|
||||
("ticker", "AAPL"),
|
||||
("line_items", "free_cash_flow"),
|
||||
("end_date", "2026-03-20"),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["search_results"][0]["ticker"] == "AAPL"
|
||||
assert response.json()["search_results"][0]["free_cash_flow"] == 123.0
|
||||
Reference in New Issue
Block a user