feat: Add evaluation hooks, skill adaptation and team pipeline config

- Add EvaluationHook for post-execution agent evaluation
- Add SkillAdaptationHook for dynamic skill adaptation
- Add team/ directory with team coordination logic
- Add TEAM_PIPELINE.yaml for smoke_fullstack pipeline config
- Update RuntimeView, TraderView and RuntimeSettingsPanel UI
- Add runtimeApi and websocket services
- Add runtime_state.json to smoke_fullstack state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:52:12 +08:00
parent f4a2b7f3af
commit 4b5ac86b83
87 changed files with 5042 additions and 744 deletions

View File

@@ -37,6 +37,9 @@ from backend.services.storage import StorageService
from backend.data.provider_router import get_provider_router
from backend.tools.data_tools import get_prices
from backend.tools.data_tools import get_company_news
from backend.tools.data_tools import get_insider_trades
from backend.tools.data_tools import prices_to_df
from backend.tools.technical_signals import StockTechnicalAnalyzer
from backend.core.scheduler import Scheduler
logger = logging.getLogger(__name__)
@@ -99,9 +102,15 @@ class Gateway:
self._provider_router = get_provider_router()
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._project_root = Path(__file__).resolve().parents[2]
self._technical_analyzer = StockTechnicalAnalyzer()
async def start(self, host: str = "0.0.0.0", port: int = 8766):
"""Start gateway server"""
"""Start gateway server with proper initialization order.
Phase 1: Start WebSocket server first so frontend can connect immediately
Phase 2: Start market data service (pushes data to connected clients)
Phase 3: Start scheduler last (triggers trading cycles)
"""
logger.info(f"Starting gateway on {host}:{port}")
self._loop = asyncio.get_running_loop()
self._provider_router.add_listener(self._on_provider_usage_changed)
@@ -124,7 +133,7 @@ class Gateway:
self.state_sync.load_state()
self.market_service.set_price_recorder(self.storage.record_price_point)
self.state_sync.update_state("status", "running")
self.state_sync.update_state("status", "initializing")
self.state_sync.update_state("server_mode", self.mode)
self.state_sync.update_state("is_backtest", self.is_backtest)
self.state_sync.update_state(
@@ -171,30 +180,72 @@ class Gateway:
f"{summary.get('totalAssetValue', 0):,.2f}",
)
await self.market_service.start(broadcast_func=self.broadcast)
# ======================================================================
# PHASE 1: Start WebSocket server first
# This allows frontend to connect immediately and receive status updates
# ======================================================================
logger.info("[Phase 1/4] Starting WebSocket server...")
self.state_sync.update_state("status", "websocket_ready")
if self.scheduler:
await self.scheduler.start(self.on_strategy_trigger)
elif self.scheduler_callback:
await self.scheduler_callback(callback=self.on_strategy_trigger)
# Start market status monitoring (only for live mode)
if not self.is_backtest:
self._market_status_task = asyncio.create_task(
self._market_status_monitor(),
)
async with websockets.serve(
# Create server but don't block yet - we'll serve inside the context manager
server = await websockets.serve(
self.handle_client,
host,
port,
ping_interval=30,
ping_timeout=60,
):
logger.info(
f"Gateway started: ws://{host}:{port}, mode={self.mode}",
)
logger.info(f"WebSocket server ready: ws://{host}:{port}")
# Give a brief moment for any existing clients to reconnect
await asyncio.sleep(0.1)
# ======================================================================
# PHASE 2: Start market data service
# Now frontend is connected, start pushing price updates
# ======================================================================
logger.info("[Phase 2/4] Starting market data service...")
self.state_sync.update_state("status", "market_service_starting")
await self.market_service.start(broadcast_func=self.broadcast)
self.state_sync.update_state("status", "market_service_ready")
logger.info("Market data service ready - price updates active")
# ======================================================================
# PHASE 3: Start market status monitoring
# Monitors market open/close and broadcasts status
# ======================================================================
logger.info("[Phase 3/4] Starting market status monitoring...")
if not self.is_backtest:
self._market_status_task = asyncio.create_task(
self._market_status_monitor(),
)
await asyncio.Future()
# ======================================================================
# PHASE 4: Start scheduler last
# Only start trading after everything else is ready
# ======================================================================
logger.info("[Phase 4/4] Starting scheduler...")
self.state_sync.update_state("status", "scheduler_starting")
if self.scheduler:
# Wire up heartbeat callback if heartbeat is configured
heartbeat_interval = self.config.get("heartbeat_interval", 0)
if heartbeat_interval and heartbeat_interval > 0:
self.scheduler.set_heartbeat_callback(self.on_heartbeat_trigger)
logger.info(
f"[Heartbeat] Registered heartbeat callback (interval={heartbeat_interval}s)",
)
await self.scheduler.start(self.on_strategy_trigger)
elif self.scheduler_callback:
await self.scheduler_callback(callback=self.on_strategy_trigger)
self.state_sync.update_state("status", "running")
logger.info(
f"Gateway fully operational: ws://{host}:{port}, mode={self.mode}",
)
# Keep server running
await asyncio.Future()
def _on_provider_usage_changed(self, snapshot: Dict[str, Any]):
"""Handle provider routing updates from the shared router."""
@@ -275,8 +326,8 @@ class Gateway:
ensure_ascii=False,
),
)
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to send error response to client: {e}")
async def _handle_client_messages(
self,
@@ -343,10 +394,14 @@ class Gateway:
await self._handle_get_stock_news_categories(websocket, data)
elif msg_type == "get_stock_range_explain":
await self._handle_get_stock_range_explain(websocket, data)
elif msg_type == "get_stock_insider_trades":
await self._handle_get_stock_insider_trades(websocket, data)
elif msg_type == "get_stock_story":
await self._handle_get_stock_story(websocket, data)
elif msg_type == "get_stock_similar_days":
await self._handle_get_stock_similar_days(websocket, data)
elif msg_type == "get_stock_technical_indicators":
await self._handle_get_stock_technical_indicators(websocket, data)
elif msg_type == "run_stock_enrich":
await self._handle_run_stock_enrich(websocket, data)
@@ -862,6 +917,94 @@ class Gateway:
),
)
async def _handle_get_stock_insider_trades(
self,
websocket: ServerConnection,
data: Dict[str, Any],
):
ticker = normalize_symbol(data.get("ticker", ""))
if not ticker:
await websocket.send(
json.dumps(
{
"type": "stock_insider_trades_loaded",
"ticker": "",
"trades": [],
"error": "invalid ticker",
},
ensure_ascii=False,
),
)
return
end_date = str(
data.get("end_date")
or self.state_sync.state.get("current_date")
or datetime.now().strftime("%Y-%m-%d")
).strip()[:10]
start_date = str(data.get("start_date") or "").strip()[:10]
limit = int(data.get("limit", 50))
trades = await asyncio.to_thread(
get_insider_trades,
ticker=ticker,
end_date=end_date,
start_date=start_date if start_date else None,
limit=limit,
)
# Sort by transaction date descending
sorted_trades = sorted(
trades,
key=lambda t: t.transaction_date or "",
reverse=True,
)
# Format for frontend
formatted_trades = [
{
"ticker": t.ticker,
"name": t.name,
"title": t.title,
"is_board_director": t.is_board_director,
"transaction_date": t.transaction_date,
"transaction_shares": t.transaction_shares,
"transaction_price_per_share": t.transaction_price_per_share,
"transaction_value": t.transaction_value,
"shares_owned_before_transaction": t.shares_owned_before_transaction,
"shares_owned_after_transaction": t.shares_owned_after_transaction,
"security_title": t.security_title,
"filing_date": t.filing_date,
# Calculated fields
"holding_change": (
(t.shares_owned_after_transaction or 0)
- (t.shares_owned_before_transaction or 0)
if t.shares_owned_after_transaction and t.shares_owned_before_transaction
else None
),
"is_buy": (
(t.transaction_shares or 0) > 0
if t.transaction_shares is not None
else None
),
}
for t in sorted_trades
]
await websocket.send(
json.dumps(
{
"type": "stock_insider_trades_loaded",
"ticker": ticker,
"start_date": start_date or None,
"end_date": end_date,
"trades": formatted_trades,
},
ensure_ascii=False,
default=str,
),
)
async def _handle_get_stock_story(
self,
websocket: ServerConnection,
@@ -969,6 +1112,136 @@ class Gateway:
),
)
async def _handle_get_stock_technical_indicators(
self,
websocket: ServerConnection,
data: Dict[str, Any],
):
ticker = normalize_symbol(data.get("ticker", ""))
if not ticker:
await websocket.send(
json.dumps(
{
"type": "stock_technical_indicators_loaded",
"ticker": ticker,
"indicators": None,
"error": "ticker is required",
},
ensure_ascii=False,
),
)
return
try:
# Get price data for the ticker
from datetime import datetime, timedelta
end_date = datetime.now()
start_date = end_date - timedelta(days=250) # ~1 year for MA200
prices = get_prices(
ticker=ticker,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if not prices or len(prices) < 20:
await websocket.send(
json.dumps(
{
"type": "stock_technical_indicators_loaded",
"ticker": ticker,
"indicators": None,
"error": "Insufficient price data",
},
ensure_ascii=False,
),
)
return
# Analyze technical indicators
df = prices_to_df(prices)
signal = self._technical_analyzer.analyze(ticker, df)
# Calculate additional volatility metrics
import pandas as pd
df_sorted = df.sort_values("time").reset_index(drop=True)
df_sorted["returns"] = df_sorted["close"].pct_change()
vol_10 = float(df_sorted["returns"].tail(10).std() * (252**0.5) * 100) if len(df_sorted) >= 10 else None
vol_20 = float(df_sorted["returns"].tail(20).std() * (252**0.5) * 100) if len(df_sorted) >= 20 else None
vol_60 = float(df_sorted["returns"].tail(60).std() * (252**0.5) * 100) if len(df_sorted) >= 60 else None
# Calculate MA distance from current price
ma_distance = {}
for ma_key in ["ma5", "ma10", "ma20", "ma50", "ma200"]:
ma_value = getattr(signal, ma_key, None)
if ma_value and ma_value > 0:
ma_distance[ma_key] = ((signal.current_price - ma_value) / ma_value) * 100
else:
ma_distance[ma_key] = None
indicators = {
"ticker": ticker,
"current_price": signal.current_price,
"ma": {
"ma5": signal.ma5,
"ma10": signal.ma10,
"ma20": signal.ma20,
"ma50": signal.ma50,
"ma200": signal.ma200,
"distance": ma_distance,
},
"rsi": {
"rsi14": signal.rsi14,
"status": "oversold" if signal.rsi14 < 30 else "overbought" if signal.rsi14 > 70 else "neutral",
},
"macd": {
"macd": signal.macd,
"signal": signal.macd_signal,
"histogram": signal.macd - signal.macd_signal,
},
"bollinger": {
"upper": signal.bollinger_upper,
"mid": signal.bollinger_mid,
"lower": signal.bollinger_lower,
},
"volatility": {
"vol_10d": vol_10,
"vol_20d": vol_20,
"vol_60d": vol_60,
"annualized": signal.annualized_volatility_pct,
"risk_level": signal.risk_level,
},
"trend": signal.trend,
"mean_reversion": signal.mean_reversion_signal,
}
await websocket.send(
json.dumps(
{
"type": "stock_technical_indicators_loaded",
"ticker": ticker,
"indicators": indicators,
},
ensure_ascii=False,
default=str,
),
)
except Exception as e:
logger.exception(f"Error getting technical indicators for {ticker}")
await websocket.send(
json.dumps(
{
"type": "stock_technical_indicators_loaded",
"ticker": ticker,
"indicators": None,
"error": str(e),
},
ensure_ascii=False,
),
)
async def _handle_run_stock_enrich(
self,
websocket: ServerConnection,
@@ -2288,6 +2561,58 @@ class Gateway:
else:
await self._run_live_cycle(date, tickers)
async def on_heartbeat_trigger(self, date: str):
"""Run lightweight heartbeat check for all analysts.
Each analyst reads its HEARTBEAT.md and performs a self-check
without running the full trading pipeline.
"""
logger.info(f"[Heartbeat] Running heartbeat check for {date}")
tickers = self.config.get("tickers", [])
analysts = self.pipeline._all_analysts()
for analyst in analysts:
try:
ws_id = getattr(analyst, "workspace_id", None)
if ws_id:
from backend.agents.workspace_manager import get_workspace_dir
ws_dir = get_workspace_dir(ws_id)
if ws_dir:
from pathlib import Path
hb_path = Path(ws_dir) / "HEARTBEAT.md"
if hb_path.exists():
content = hb_path.read_text(encoding="utf-8").strip()
if content:
hb_task = (
f"# 定期主动检查\n\n{content}\n\n"
"请执行上述检查并报告结果。"
)
logger.info(
f"[Heartbeat] Running heartbeat for {analyst.name}",
)
# Build a minimal user message and let the analyst reply
from agentscope.message import Msg
msg = Msg(
role="user",
content=hb_task,
name="system",
)
result = await analyst.reply([msg])
logger.info(
f"[Heartbeat] {analyst.name} heartbeat complete",
)
continue
logger.debug(
f"[Heartbeat] No HEARTBEAT.md for {analyst.name}, skipping",
)
except Exception as e:
logger.error(
f"[Heartbeat] {analyst.name} failed: {e}",
exc_info=True,
)
async def _run_backtest_cycle(self, date: str, tickers: List[str]):
"""Run backtest cycle with pre-loaded prices"""
self.market_service.set_backtest_date(date)
@@ -2428,7 +2753,8 @@ class Gateway:
market_caps[ticker] = market_cap
else:
market_caps[ticker] = 1e9
except Exception:
except Exception as e:
logger.warning(f"Failed to get market cap for {ticker}, using default 1e9: {e}")
market_caps[ticker] = 1e9
return market_caps