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