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:
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user