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

View File

@@ -48,6 +48,14 @@ CREATE TABLE IF NOT EXISTS signals (
signal TEXT,
confidence REAL,
reasoning_json TEXT,
reasons_json TEXT,
risks_json TEXT,
invalidation TEXT,
next_action TEXT,
intrinsic_value REAL,
fair_value_range_json TEXT,
value_gap_pct REAL,
valuation_methods_json TEXT,
real_return REAL,
is_correct TEXT,
trade_date TEXT,
@@ -270,8 +278,10 @@ class RuntimeDb:
"""
INSERT OR REPLACE INTO signals
(id, ticker, agent_id, agent_name, role, signal, confidence, reasoning_json,
reasons_json, risks_json, invalidation, next_action, intrinsic_value,
fair_value_range_json, value_gap_pct, valuation_methods_json,
real_return, is_correct, trade_date, created_at, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
signal_id,
@@ -282,6 +292,14 @@ class RuntimeDb:
payload.get("signal"),
payload.get("confidence"),
_json_dumps(payload.get("reasoning")),
_json_dumps(payload.get("reasons")),
_json_dumps(payload.get("risks")),
payload.get("invalidation"),
payload.get("next_action"),
payload.get("intrinsic_value"),
_json_dumps(payload.get("fair_value_range")),
payload.get("value_gap_pct"),
_json_dumps(payload.get("valuation_methods")),
payload.get("real_return"),
None if payload.get("is_correct") is None else str(payload.get("is_correct")),
payload.get("date"),
@@ -313,8 +331,10 @@ class RuntimeDb:
"""
INSERT INTO signals
(id, ticker, agent_id, agent_name, role, signal, confidence, reasoning_json,
reasons_json, risks_json, invalidation, next_action, intrinsic_value,
fair_value_range_json, value_gap_pct, valuation_methods_json,
real_return, is_correct, trade_date, created_at, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
signal_id,
@@ -325,6 +345,14 @@ class RuntimeDb:
payload.get("signal"),
payload.get("confidence"),
_json_dumps(payload.get("reasoning")),
_json_dumps(payload.get("reasons")),
_json_dumps(payload.get("risks")),
payload.get("invalidation"),
payload.get("next_action"),
payload.get("intrinsic_value"),
_json_dumps(payload.get("fair_value_range")),
payload.get("value_gap_pct"),
_json_dumps(payload.get("valuation_methods")),
payload.get("real_return"),
None if payload.get("is_correct") is None else str(payload.get("is_correct")),
payload.get("date"),
@@ -461,6 +489,18 @@ class RuntimeDb:
else "该信号暂未完成后验评估"
),
"tone": "positive" if str(row["signal"] or "").lower() in {"bullish", "buy", "long"} else "negative" if str(row["signal"] or "").lower() in {"bearish", "sell", "short"} else "neutral",
# Extended signal fields
"signal": row["signal"],
"confidence": row["confidence"],
"reasoning": json.loads(row["reasoning_json"]) if row["reasoning_json"] else None,
"reasons": json.loads(row["reasons_json"]) if row["reasons_json"] else None,
"risks": json.loads(row["risks_json"]) if row["risks_json"] else None,
"invalidation": row["invalidation"],
"next_action": row["next_action"],
"intrinsic_value": row["intrinsic_value"],
"fair_value_range": json.loads(row["fair_value_range_json"]) if row["fair_value_range_json"] else None,
"value_gap_pct": row["value_gap_pct"],
"valuation_methods": json.loads(row["valuation_methods_json"]) if row["valuation_methods_json"] else None,
}
for row in signal_rows
]