4 Commits

Author SHA1 Message Date
4295293a21 Align branding, prompts, and deployment tooling 2026-03-28 22:16:56 +08:00
4aa69650e8 feat: update openclaw workspace integration 2026-03-27 22:27:16 +08:00
5c08c1865c chore: sync current workspace changes 2026-03-27 11:27:26 +08:00
6ecc224427 feat: OpenClaw WebSocket integration with workspace file preview
- Migrate OpenClaw from HTTP (port 8004) to WebSocket (port 18789)
- Add workspace file list and content preview handlers
- Add OpenClawStatus component with agent/skills view
- Add OpenClawView panel in trader interface
- Add Zustand store for OpenClaw state management
- Fix gateway logging noise (yfinance, websockets)
- Fix RunWorkspaceManager.get_agent_asset_dir attribute error
- Handle missing workspace files gracefully in preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 11:08:15 +08:00
134 changed files with 10270 additions and 2790 deletions

View File

@@ -2,7 +2,7 @@
# Keep `.env` untracked and never paste real secrets into tracked files.
# ================== General Configuration | 通用配置 ==================
TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN
TICKERS=AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN
# Financial Data API
# At least `FINANCIAL_DATASETS_API_KEY` is required when using `FIN_DATA_SOURCE=financial_datasets`.

1
.gitignore vendored
View File

@@ -51,7 +51,6 @@ node_modules
outputs/
/production/
/smoke_test/
/smoke_live_mock/
# Local tooling state
.omc/

View File

@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"lastScanned": 1774313111650,
"lastScanned": 1774515151036,
"projectRoot": "/Users/cillin/workspeace/evotraders",
"techStack": {
"languages": [
@@ -54,7 +54,7 @@
"path": "backend",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1774313111639,
"lastAccessed": 1774515151025,
"keyFiles": [
"__init__.py",
"cli.py",
@@ -66,14 +66,14 @@
"path": "backtest",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313111640,
"lastAccessed": 1774515151026,
"keyFiles": []
},
"data": {
"path": "data",
"purpose": "Data files",
"fileCount": 3,
"lastAccessed": 1774313111640,
"lastAccessed": 1774515151027,
"keyFiles": [
"market_research.db",
"market_research.db-shm",
@@ -84,14 +84,14 @@
"path": "deploy",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313111640,
"lastAccessed": 1774515151027,
"keyFiles": []
},
"docs": {
"path": "docs",
"purpose": "Documentation",
"fileCount": 1,
"lastAccessed": 1774313111641,
"lastAccessed": 1774515151027,
"keyFiles": [
"compat-removal-plan.md"
]
@@ -100,7 +100,7 @@
"path": "evotraders.egg-info",
"purpose": null,
"fileCount": 6,
"lastAccessed": 1774313111641,
"lastAccessed": 1774515151028,
"keyFiles": [
"PKG-INFO",
"SOURCES.txt",
@@ -113,7 +113,7 @@
"path": "frontend",
"purpose": null,
"fileCount": 13,
"lastAccessed": 1774313111641,
"lastAccessed": 1774515151028,
"keyFiles": [
"README.md",
"components.json",
@@ -126,41 +126,28 @@
"path": "live",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313111642,
"lastAccessed": 1774515151028,
"keyFiles": []
},
"logs": {
"path": "logs",
"purpose": null,
"fileCount": 6,
"lastAccessed": 1774313111642,
"keyFiles": [
"2026-03-16_00-48-03.log",
"2026-03-18_23-17-29.log",
"2026-03-18_23-17-30.log",
"2026-03-19_00-18-04.log",
"2026-03-19_00-34-21.log"
]
},
"reference": {
"path": "reference",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313111643,
"lastAccessed": 1774515151028,
"keyFiles": []
},
"runs": {
"path": "runs",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313111643,
"lastAccessed": 1774515151029,
"keyFiles": []
},
"scripts": {
"path": "scripts",
"purpose": "Build/utility scripts",
"fileCount": 1,
"lastAccessed": 1774313111644,
"lastAccessed": 1774515151030,
"keyFiles": [
"run_prod.sh"
]
@@ -169,7 +156,7 @@
"path": "services",
"purpose": "Business logic services",
"fileCount": 1,
"lastAccessed": 1774313111644,
"lastAccessed": 1774515151030,
"keyFiles": [
"README.md"
]
@@ -178,21 +165,14 @@
"path": "shared",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313111644,
"keyFiles": []
},
"workspaces": {
"path": "workspaces",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313111645,
"lastAccessed": 1774515151030,
"keyFiles": []
},
"backend/api": {
"path": "backend/api",
"purpose": "API routes",
"fileCount": 5,
"lastAccessed": 1774313111645,
"lastAccessed": 1774515151030,
"keyFiles": [
"__init__.py",
"agents.py",
@@ -203,7 +183,7 @@
"path": "backend/config",
"purpose": "Configuration files",
"fileCount": 6,
"lastAccessed": 1774313111646,
"lastAccessed": 1774515151030,
"keyFiles": [
"__init__.py",
"agent_profiles.yaml",
@@ -213,8 +193,8 @@
"backend/data": {
"path": "backend/data",
"purpose": "Data files",
"fileCount": 13,
"lastAccessed": 1774313111647,
"fileCount": 12,
"lastAccessed": 1774515151031,
"keyFiles": [
"__init__.py",
"cache.py",
@@ -225,7 +205,7 @@
"path": "docs/assets",
"purpose": "Static assets",
"fileCount": 5,
"lastAccessed": 1774313111647,
"lastAccessed": 1774515151031,
"keyFiles": [
"dashboard.jpg",
"evotraders_demo.gif",
@@ -236,7 +216,7 @@
"path": "frontend/dist",
"purpose": "Distribution/build output",
"fileCount": 2,
"lastAccessed": 1774313111647,
"lastAccessed": 1774515151031,
"keyFiles": [
"index.html",
"trading_logo.png"
@@ -246,261 +226,309 @@
"path": "frontend/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1774313111650,
"lastAccessed": 1774515151036,
"keyFiles": []
}
},
"hotPaths": [
{
"path": "CLAUDE.md",
"accessCount": 15,
"lastAccessed": 1774342728155,
"type": "directory"
},
{
"path": "frontend/src/App.jsx",
"accessCount": 10,
"lastAccessed": 1774339397617,
"path": "frontend/src/hooks/useWebSocketConnection.js",
"accessCount": 100,
"lastAccessed": 1774550862686,
"type": "file"
},
{
"path": "frontend/src/hooks/useWebsocketSessionSync.js",
"accessCount": 4,
"lastAccessed": 1774313470024,
"type": "file"
},
{
"path": "",
"accessCount": 4,
"lastAccessed": 1774339108220,
"type": "directory"
},
{
"path": "backend/services/gateway.py",
"accessCount": 3,
"lastAccessed": 1774339389171,
"accessCount": 98,
"lastAccessed": 1774550272354,
"type": "file"
},
{
"path": "backend/services/gateway_openclaw_handlers.py",
"accessCount": 91,
"lastAccessed": 1774550256325,
"type": "file"
},
{
"path": "backend/api/openclaw.py",
"accessCount": 48,
"lastAccessed": 1774545375555,
"type": "file"
},
{
"path": "frontend/src/hooks/useOpenClawPanel.js",
"accessCount": 42,
"lastAccessed": 1774550688926,
"type": "file"
},
{
"path": "shared/client/openclaw_client.py",
"accessCount": 39,
"lastAccessed": 1774545484770,
"type": "file"
},
{
"path": "frontend/src",
"accessCount": 35,
"lastAccessed": 1774550715529,
"type": "directory"
},
{
"path": "reference/openclaw/src",
"accessCount": 33,
"lastAccessed": 1774550840611,
"type": "directory"
},
{
"path": "backend/services/openclaw_cli.py",
"accessCount": 31,
"lastAccessed": 1774545484887,
"type": "file"
},
{
"path": "frontend/src/components/TraderView.jsx",
"accessCount": 23,
"lastAccessed": 1774543366574,
"type": "file"
},
{
"path": "shared/models/openclaw.py",
"accessCount": 22,
"lastAccessed": 1774545419541,
"type": "file"
},
{
"path": "frontend/src/store/openclawStore.js",
"accessCount": 20,
"lastAccessed": 1774550319533,
"type": "file"
},
{
"path": "frontend/src/App.jsx",
"accessCount": 18,
"lastAccessed": 1774544542524,
"type": "file"
},
{
"path": "frontend/src/services/websocket.js",
"accessCount": 18,
"lastAccessed": 1774549669596,
"type": "file"
},
{
"path": "start-dev.sh",
"accessCount": 15,
"lastAccessed": 1774548224246,
"type": "file"
},
{
"path": "frontend/src/components/RuntimeView.jsx",
"accessCount": 14,
"lastAccessed": 1774518525793,
"type": "file"
},
{
"path": "frontend/src/components/AppShell.jsx",
"accessCount": 13,
"lastAccessed": 1774533781725,
"type": "file"
},
{
"path": "backend/main.py",
"accessCount": 13,
"lastAccessed": 1774548236340,
"type": "directory"
},
{
"path": "backend/apps/openclaw_service.py",
"accessCount": 10,
"lastAccessed": 1774547900186,
"type": "file"
},
{
"path": "frontend/src/components/OpenClawStatusPanel.jsx",
"accessCount": 8,
"lastAccessed": 1774533622019,
"type": "file"
},
{
"path": "reference/openclaw/src/commands",
"accessCount": 7,
"lastAccessed": 1774530402019,
"type": "directory"
},
{
"path": "frontend/src/config/constants.js",
"accessCount": 7,
"lastAccessed": 1774544689658,
"type": "file"
},
{
"path": "",
"accessCount": 6,
"lastAccessed": 1774550700047,
"type": "directory"
},
{
"path": "backend/services",
"accessCount": 5,
"lastAccessed": 1774550692490,
"type": "directory"
},
{
"path": "frontend/src/store/uiStore.js",
"accessCount": 4,
"lastAccessed": 1774533747700,
"type": "file"
},
{
"path": "frontend/src/styles/GlobalStyles.jsx",
"accessCount": 4,
"lastAccessed": 1774533753657,
"type": "file"
},
{
"path": "frontend/src/store/agentStore.js",
"accessCount": 3,
"lastAccessed": 1774342613364,
"lastAccessed": 1774517930592,
"type": "file"
},
{
"path": "reference/openclaw/src/cli/skills-cli.ts",
"accessCount": 3,
"lastAccessed": 1774527140107,
"type": "file"
},
{
"path": "reference/openclaw/src/commands/agents.commands.list.ts",
"accessCount": 3,
"lastAccessed": 1774533427441,
"type": "file"
},
{
"path": "frontend/src/store/runtimeStore.js",
"accessCount": 2,
"lastAccessed": 1774317990919,
"lastAccessed": 1774517930660,
"type": "file"
},
{
"path": "frontend/src/services/websocket.js",
"path": "frontend/src/hooks/useAgentWorkspacePanel.js",
"accessCount": 2,
"lastAccessed": 1774318009819,
"lastAccessed": 1774518021290,
"type": "file"
},
{
"path": "backend/core/pipeline_runner.py",
"path": "frontend/src/services/runtimeApi.js",
"accessCount": 2,
"lastAccessed": 1774339367538,
"lastAccessed": 1774518025465,
"type": "file"
},
{
"path": "backend/runtime/manager.py",
"path": "reference/openclaw/src/commands/agents.commands.delete.ts",
"accessCount": 2,
"lastAccessed": 1774339367572,
"lastAccessed": 1774530389553,
"type": "file"
},
{
"path": "reference/openclaw/src/commands/agents.commands.add.ts",
"accessCount": 2,
"lastAccessed": 1774530389605,
"type": "file"
},
{
"path": "backend/api/__init__.py",
"accessCount": 2,
"lastAccessed": 1774542416191,
"type": "file"
},
{
"path": "frontend/vite.config.js",
"accessCount": 2,
"lastAccessed": 1774544772960,
"type": "file"
},
{
"path": "frontend/src/store/index.js",
"accessCount": 1,
"lastAccessed": 1774515811752,
"type": "file"
},
{
"path": "frontend/src/store/marketStore.js",
"accessCount": 1,
"lastAccessed": 1774313140483,
"type": "file"
},
{
"path": "frontend/src/hooks/useFeedProcessor.js",
"accessCount": 1,
"lastAccessed": 1774313148279,
"type": "file"
},
{
"path": "frontend/src/components/Header.jsx",
"accessCount": 1,
"lastAccessed": 1774313156696,
"type": "file"
},
{
"path": "frontend/src/components/TraderView.jsx",
"accessCount": 1,
"lastAccessed": 1774313156753,
"type": "file"
},
{
"path": "frontend/src/store/uiStore.js",
"accessCount": 1,
"lastAccessed": 1774313187460,
"lastAccessed": 1774515838923,
"type": "file"
},
{
"path": "frontend/src/store/portfolioStore.js",
"accessCount": 1,
"lastAccessed": 1774313187511,
"lastAccessed": 1774515839687,
"type": "file"
},
{
"path": "frontend/src/store/agentStore.js",
"path": "frontend/src/index.css",
"accessCount": 1,
"lastAccessed": 1774313187573,
"lastAccessed": 1774515988837,
"type": "file"
},
{
"path": "frontend/src/hooks/useWebSocketConnection.js",
"path": "frontend/src/App.css",
"accessCount": 1,
"lastAccessed": 1774313279414,
"lastAccessed": 1774515998423,
"type": "file"
},
{
"path": "frontend/src/hooks/useStockDataRequests.js",
"path": "frontend/package.json",
"accessCount": 1,
"lastAccessed": 1774313319716,
"lastAccessed": 1774516005569,
"type": "file"
},
{
"path": "frontend/src/hooks/useAgentDataRequests.js",
"accessCount": 1,
"lastAccessed": 1774313347455,
"lastAccessed": 1774517930219,
"type": "file"
},
{
"path": "frontend/src/components/AppShell.jsx",
"path": "backend/services/gateway_admin_handlers.py",
"accessCount": 1,
"lastAccessed": 1774313396331,
"type": "file"
},
{
"path": "start-dev.sh",
"accessCount": 1,
"lastAccessed": 1774317979859,
"lastAccessed": 1774517937966,
"type": "file"
},
{
"path": "backend/apps/agent_service.py",
"accessCount": 1,
"lastAccessed": 1774317984348,
"lastAccessed": 1774517946208,
"type": "file"
},
{
"path": "shared/client/trading_client.py",
"path": "frontend/src/hooks",
"accessCount": 1,
"lastAccessed": 1774317984365,
"lastAccessed": 1774517946260,
"type": "directory"
},
{
"path": "frontend/src/hooks/useFeedProcessor.js",
"accessCount": 1,
"lastAccessed": 1774517952115,
"type": "file"
},
{
"path": "backend/apps/trading_service.py",
"path": "reference/openclaw/src/commands/models/set.ts",
"accessCount": 1,
"lastAccessed": 1774317984408,
"lastAccessed": 1774526963526,
"type": "file"
},
{
"path": "pyproject.toml",
"path": "reference/openclaw/src/commands/models/list.ts",
"accessCount": 1,
"lastAccessed": 1774317990970,
"lastAccessed": 1774526963632,
"type": "file"
},
{
"path": "backend/agents/factory.py",
"path": "reference/openclaw/src/cli/skills-cli.format.ts",
"accessCount": 1,
"lastAccessed": 1774318009867,
"type": "file"
},
{
"path": "backend/config/constants.py",
"accessCount": 1,
"lastAccessed": 1774318009922,
"type": "file"
},
{
"path": "backend/api/__init__.py",
"accessCount": 1,
"lastAccessed": 1774318009973,
"type": "file"
},
{
"path": "README.md",
"accessCount": 1,
"lastAccessed": 1774339107381,
"type": "file"
},
{
"path": "backend/runtime/registry.py",
"accessCount": 1,
"lastAccessed": 1774339380024,
"type": "file"
},
{
"path": "backend/runtime/session.py",
"accessCount": 1,
"lastAccessed": 1774339380084,
"type": "file"
},
{
"path": "backend/runtime/context.py",
"accessCount": 1,
"lastAccessed": 1774339380120,
"type": "file"
},
{
"path": "backend/runtime/agent_runtime.py",
"accessCount": 1,
"lastAccessed": 1774339380185,
"type": "file"
},
{
"path": "backend/process/supervisor.py",
"accessCount": 1,
"lastAccessed": 1774339389110,
"type": "file"
},
{
"path": "backend/core/pipeline.py",
"accessCount": 1,
"lastAccessed": 1774339389187,
"type": "file"
},
{
"path": "backend/process/models.py",
"accessCount": 1,
"lastAccessed": 1774339397557,
"type": "file"
},
{
"path": "backend/process/registry.py",
"accessCount": 1,
"lastAccessed": 1774339397577,
"type": "file"
},
{
"path": "backend/config/env_config.py",
"accessCount": 1,
"lastAccessed": 1774342678236,
"type": "file"
},
{
"path": "backend/config/data_config.py",
"accessCount": 1,
"lastAccessed": 1774342678253,
"type": "file"
},
{
"path": "frontend/env.template",
"accessCount": 1,
"lastAccessed": 1774342678290,
"type": "file"
},
{
"path": "env.template",
"accessCount": 1,
"lastAccessed": 1774342678310,
"lastAccessed": 1774526963684,
"type": "file"
}
],

View File

@@ -1,6 +1,6 @@
{
"timestamp": "2026-03-24T07:58:12.123Z",
"timestamp": "2026-03-27T04:53:52.906Z",
"backgroundTasks": [],
"sessionStartTimestamp": "2026-03-24T07:58:09.417Z",
"sessionId": "fda34772-7bd2-402e-86b2-d656296416f3"
"sessionStartTimestamp": "2026-03-27T04:53:21.944Z",
"sessionId": "cbb9004e-771b-4e82-95d4-cea6d9753642"
}

View File

@@ -1 +1 @@
{"session_id":"fda34772-7bd2-402e-86b2-d656296416f3","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/fda34772-7bd2-402e-86b2-d656296416f3.jsonl","cwd":"/Users/cillin/workspeace/evotraders","model":{"id":"MiniMax-M2.7-highspeed","display_name":"MiniMax-M2.7-highspeed"},"workspace":{"current_dir":"/Users/cillin/workspeace/evotraders","project_dir":"/Users/cillin/workspeace/evotraders","added_dirs":[]},"version":"2.1.78","output_style":{"name":"default"},"cost":{"total_cost_usd":36.63980749999998,"total_duration_ms":69778027,"total_api_duration_ms":2925118,"total_lines_added":3056,"total_lines_removed":4537},"context_window":{"total_input_tokens":910503,"total_output_tokens":145207,"context_window_size":200000,"current_usage":{"input_tokens":507,"output_tokens":247,"cache_creation_input_tokens":4132,"cache_read_input_tokens":96553},"used_percentage":51,"remaining_percentage":49},"exceeds_200k_tokens":false}
{"session_id":"cbb9004e-771b-4e82-95d4-cea6d9753642","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/cbb9004e-771b-4e82-95d4-cea6d9753642.jsonl","cwd":"/Users/cillin/workspeace/evotraders","model":{"id":"MiniMax-M2.7-highspeed","display_name":"MiniMax-M2.7-highspeed"},"workspace":{"current_dir":"/Users/cillin/workspeace/evotraders","project_dir":"/Users/cillin/workspeace/evotraders","added_dirs":[]},"version":"2.1.78","output_style":{"name":"default"},"cost":{"total_cost_usd":0.660433,"total_duration_ms":168502,"total_api_duration_ms":37670,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":14416,"total_output_tokens":1705,"context_window_size":200000,"current_usage":{"input_tokens":461,"output_tokens":214,"cache_creation_input_tokens":0,"cache_read_input_tokens":53991},"used_percentage":27,"remaining_percentage":73},"exceeds_200k_tokens":false}

View File

@@ -1,3 +1,3 @@
{
"lastSentAt": "2026-03-24T08:58:57.965Z"
"lastSentAt": "2026-03-27T04:55:49.635Z"
}

View File

@@ -1,26 +0,0 @@
{
"agents": [
{
"agent_id": "abeaf609b74a2b7ee",
"agent_type": "Explore",
"started_at": "2026-03-24T08:01:40.015Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-24T08:02:31.822Z",
"duration_ms": 51807
},
{
"agent_id": "afb6750eaae72bc72",
"agent_type": "Explore",
"started_at": "2026-03-24T08:56:21.471Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-24T08:57:27.856Z",
"duration_ms": 66385
}
],
"total_spawned": 2,
"total_completed": 2,
"total_failed": 0,
"last_updated": "2026-03-24T08:59:06.380Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概述
EvoTraders 是一个自进化多智能体交易系统,由 6 个 AI Agent4 名分析师 + 投资经理 + 风控经理协作完成交易决策。Agent 基于 AgentScope 框架构建,配合 ReMe 记忆系统实现持续学习。
大时代 是一个自进化多智能体交易系统,由 6 个 AI Agent4 名分析师 + 投资经理 + 风控经理协作完成交易决策。Agent 基于 AgentScope 框架构建,配合 ReMe 记忆系统实现持续学习。
## 常用命令
@@ -20,7 +20,6 @@ uv pip install -e .
evotraders backtest --start 2025-11-01 --end 2025-12-01 # 回测模式
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 带记忆回测
evotraders live # 实盘交易
evotraders live --mock # 模拟/测试模式
evotraders live -t 22:30 # 定时每日交易
evotraders frontend # 启动可视化界面
@@ -28,7 +27,7 @@ evotraders frontend # 启动可视化界面
./start-dev.sh # 启动全部 4 个微服务 (agent, runtime, trading, news)
# Gateway WebSocket 服务器
python backend/main.py --mode live --config-name mock --mock
python backend/main.py --mode live --config-name live
# 单独启动微服务
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
@@ -189,7 +188,6 @@ backend/
│ ├── schema.py # 数据 schema
│ ├── historical_price_manager.py # 历史价格管理
│ ├── polling_price_manager.py # 轮询价格管理
│ ├── mock_price_manager.py # Mock 价格管理
│ ├── news_alignment.py # 新闻对齐
│ ├── polygon_client.py # Polygon.io 客户端
│ └── ret_data_updater.py # 离线数据更新

410
README.md
View File

@@ -1,36 +1,34 @@
<p align="center">
<img src="./docs/assets/evotraders_logo.jpg" width="45%">
<img src="./docs/assets/bigtime_logo.jpg" width="45%">
</p>
<h2 align="center">EvoTraders: A Self-Evolving Multi-Agent Trading System</h2>
<h2 align="center">大时代:自进化多智能体交易系统</h2>
<p align="center">
📌 <a href="http://trading.evoagents.cn">Visit us at EvoTraders website !</a>
📌 <a href="http://trading.evoagents.cn">Visit the 大时代 website</a>
</p>
![System Demo](./docs/assets/evotraders_demo.gif)
![System Demo](./docs/assets/bigtime_demo.gif)
EvoTraders is an open-source financial trading agent framework that builds a trading system capable of continuous learning and evolution in real markets through multi-agent collaboration and memory systems.
大时代 is an open-source financial trading agent framework that combines multi-agent collaboration, run-scoped workspaces, and memory to support both backtests and live trading workflows.
The repository name and CLI entrypoints still use `evotraders` for compatibility, but the product-facing branding now follows the 大时代 naming used by the reference branch.
---
## Core Features
**Multi-Agent Collaborative Trading**
A team of 6 members, including 4 specialized analyst roles (fundamentals, technical, sentiment, valuation) + portfolio manager + risk management, collaborating to make decisions like a real trading team.
**Multi-agent trading team**
Six roles collaborate like a real desk: four specialist analysts (fundamentals, technical, sentiment, valuation), one portfolio manager, and one risk manager.
You can customize your Agents here: [Custom Configuration](#custom-configuration)
**Continuous learning**
Agents can persist long-term memory with ReMe, reflect after each cycle, and evolve their decision patterns over time.
**Continuous Learning and Evolution**
Based on the ReMe memory framework, agents reflect and summarize after each trade, preserving experience across rounds, and forming unique investment methodologies.
**Backtest and live modes**
The same runtime model supports historical simulation and live execution with real-time market data.
Through this design, we hope that when AI Agents form a team and enter the real-time market, they will gradually develop their own trading styles and decision preferences, rather than one-time random inference.
**Real-Time Market Trading**
Supports real-time market data integration, providing backtesting mode and live trading mode, allowing AI Agents to learn and make decisions in real market fluctuations.
**Visualized Trading Information**
Observe agents' analysis processes, communication records, and decision evolution in real-time, with complete tracking of return curves and analyst performance.
**Operator-facing UI**
The frontend exposes the trading room, runtime controls, logs, approvals, agent workspaces, and explain/news views.
<p>
<img src="docs/assets/performance.jpg" width="45%">
@@ -39,83 +37,177 @@ Observe agents' analysis processes, communication records, and decision evolutio
---
## Current Architecture
The repository is currently in a transition from a modular monolith to split service surfaces. The split-service path is the default local development mode.
Current app surfaces:
- `backend.apps.agent_service` on `:8000`: control plane for workspaces, agents, skills, and guard/approval APIs
- `backend.apps.trading_service` on `:8001`: read-only trading data APIs
- `backend.apps.news_service` on `:8002`: read-only explain/news APIs
- `backend.apps.runtime_service` on `:8003`: runtime lifecycle APIs
- `backend.apps.openclaw_service` on `:8004`: read-only OpenClaw facade
- WebSocket gateway on `:8765`: live event/feed channel for the frontend
The most important runtime path today is:
`frontend -> runtime_service/control APIs -> gateway/runtime manager -> market service + pipeline + storage`
Reference notes for the migration live in [services/README.md](./services/README.md).
---
## Quick Start
### Installation
### 1. Install
```bash
# Clone repository
git clone https://github.com/agentscope-ai/agentscope-samples
cd agentscope-samples/EvoTraders
# clone this repository, then:
cd evotraders
# Install dependencies (Recommend uv!)
# backend runtime dependencies
uv pip install -r requirements.txt
# install package entrypoint in editable mode
uv pip install -e .
# optional: pip install -e .
# optional
# uv pip install -e ".[dev]"
# pip install -e .
```
# Configure environment variables
Frontend dependencies:
```bash
cd frontend
npm ci
cd ..
```
Production deployment should prefer `requirements.txt` for backend and `npm ci` for frontend so the pulled environment matches the checked-in lockfiles and version pins.
### 2. Configure environment
```bash
cp env.template .env
# Edit .env file and add your API Keys. The following config are required:
```
# finance data API: At minimum, FINANCIAL_DATASETS_API_KEY is required, corresponding to FIN_DATA_SOURCE=financial_datasets; It is recommended to add FINNHUB_API_KEY, corresponding to FIN_DATA_SOURCE=finnhub; If using live mode, FINNHUB_API_KEY must be added
FIN_DATA_SOURCE = #finnhub or financial_datasets
FINANCIAL_DATASETS_API_KEY= #Required
FINNHUB_API_KEY= #Optional
The root `env.template` is the canonical local template. A `.env.example` is also kept in the repo for reference.
# LLM API for Agents
Minimum useful variables:
```bash
# watchlist
TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN
# market data
FIN_DATA_SOURCE=finnhub
FINANCIAL_DATASETS_API_KEY=
FINNHUB_API_KEY=
POLYGON_API_KEY=
# agent model
OPENAI_API_KEY=
OPENAI_BASE_URL=
MODEL_NAME=qwen3-max-preview
# LLM & embedding API for Memory
# memory (optional unless --enable-memory is used)
MEMORY_API_KEY=
```
### Running
Notes:
- `FINNHUB_API_KEY` is required for live mode.
- `POLYGON_API_KEY` enables long-lived market-store ingestion and refresh helpers.
- `MEMORY_API_KEY` is only required when long-term memory is enabled.
For a production-style local start flow, you can also use:
**Backtest Mode:**
```bash
evotraders backtest --start 2025-11-01 --end 2025-12-01
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # Use Memory
./start.sh
```
If you do not have market data APIs and just want to try the backtest demo, download the offline data and unzip it into `backend/data`:
### 3. Start the stack
Recommended local development flow:
```bash
./start-dev.sh
```
This starts:
- `agent_service` at `http://localhost:8000`
- `trading_service` at `http://localhost:8001`
- `news_service` at `http://localhost:8002`
- `runtime_service` at `http://localhost:8003`
- gateway WebSocket at `ws://localhost:8765`
Then start the frontend in another terminal:
```bash
evotraders frontend
```
Open `http://localhost:5173`.
You can also run services manually:
```bash
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
python -m backend.main --mode live --host 0.0.0.0 --port 8765
```
### 4. Run backtest or live mode from CLI
Backtest:
```bash
evotraders backtest --start 2025-11-01 --end 2025-12-01
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory
evotraders backtest --config-name smoke_fullstack --start 2025-11-01 --end 2025-12-01
```
Live:
```bash
evotraders live
evotraders live --enable-memory
evotraders live --schedule-mode intraday --interval-minutes 60
evotraders live --trigger-time 22:30
```
Help:
```bash
evotraders --help
evotraders backtest --help
evotraders live --help
evotraders frontend --help
```
### Offline backtest data
If you want a quick backtest demo without external market APIs, download the offline bundle and unzip it into `backend/data`:
```bash
wget "https://agentscope-open.oss-cn-beijing.aliyuncs.com/ret_data.zip"
unzip ret_data.zip -d backend/data
```
The zip includes basic stock price data so you can run the backtest demo out of the box.
**Live Trading:**
```bash
evotraders live # Run immediately (default)
evotraders live --enable-memory # Use memory
evotraders live --mock # Mock mode (testing)
evotraders live -t 22:30 # Run daily at 22:30 local time (auto-converts to NYSE timezone)
```
---
**Get Help:**
```bash
evotraders --help # View global CLI help
evotraders backtest --help # View backtest mode parameters
evotraders live --help # View live/mock run parameters
```
## Runtime Data Layout
**Launch Visualization Interface:**
```bash
# Ensure npm is installed, otherwise install it:
# npm install
evotraders frontend # Default connects to port 8765, you can modify the address in ./frontend/env.local to change the port number
```
Visit `http://localhost:5173/` to view the trading room, select a date and click Run/Replay to observe the decision-making process.
### Runtime Data Layout
- Long-lived research data is stored in `data/market_research.db`
- Each task run writes run-scoped state under `runs/<run_id>/`
- `runs/<run_id>/team_dashboard/*.json` is an export/compatibility layer for dashboard views, not the authoritative runtime source of truth
- Runtime APIs prefer active runtime state, `server_state.json`, and `runtime.db`
- Long-lived research data lives in `data/market_research.db`
- Each run writes run-scoped state under `runs/<run_id>/`
- `runs/<run_id>/BOOTSTRAP.md` stores run-specific bootstrap values and prompt body
- `runs/<run_id>/state/runtime_state.json` stores runtime snapshot state
- `runs/<run_id>/team_dashboard/*.json` is a compatibility/export layer for dashboard consumers, not the primary runtime source of truth
Optional retention control:
@@ -123,129 +215,147 @@ Optional retention control:
RUNS_RETENTION_COUNT=20
```
Only timestamped run folders like `YYYYMMDD_HHMMSS` are pruned automatically when starting a new runtime. Named runs such as `smoke_fullstack` or `test_*` are preserved.
Only timestamped run folders like `YYYYMMDD_HHMMSS` are pruned automatically. Named runs such as `live`, `smoke_fullstack`, or `reload_demo_*` are preserved.
---
## System Architecture
## Frontend Service Routing
![Architecture Diagram](docs/assets/evotraders_pipeline.jpg)
The frontend always uses the control plane and runtime APIs, and can optionally call split services directly for read-only data.
### Agent Design
Useful frontend env vars:
**Analyst Team:**
- **Fundamentals Analyst**: Financial health, profitability, growth quality
- **Technical Analyst**: Price trends, technical indicators, momentum analysis
- **Sentiment Analyst**: Market sentiment, news sentiment, insider trading
- **Valuation Analyst**: DCF, residual income, EV/EBITDA
**Decision Layer:**
- **Portfolio Manager**: Integrates analysis signals from analysts, executes communication strategies, combines analyst and team historical performance, recent investment memories, and long-term investment experience to make final decisions
- **Risk Management**: Real-time price and volatility monitoring, position limits, multi-layer risk warnings
### Decision Process
```
Real-time Market Data → Independent Analysis → Intelligent Communication (1v1/1vN/NvN) → Decision Execution → Performance Evaluation → Learning and Evolution (Memory Update)
```bash
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
VITE_NEWS_SERVICE_URL=http://localhost:8002
VITE_TRADING_SERVICE_URL=http://localhost:8001
VITE_WS_URL=ws://localhost:8765
```
Each trading day goes through five stages:
1. **Analysis Stage**: Each agent independently analyzes based on their respective tools and historical experience
2. **Communication Stage**: Exchange views through private chats, notifications, meetings, etc.
3. **Decision Stage**: Portfolio manager makes comprehensive judgments and provides final trades
4. **Evaluation Stage**
- **Performance Charts**: Track portfolio return curves vs. benchmark strategies (equal-weighted, market-cap weighted, momentum). Used to evaluate overall strategy effectiveness.
- **Analyst Rankings**: Click on avatars in the Trading Room to view analyst performance (win rate, bull/bear market win rate). Used to understand which analysts provide the most valuable insights.
- **Statistics**: Detailed position and trading history. Used for in-depth analysis of position management and execution quality.
5. **Review Stage**: Agents reflect on decisions and summarize experiences based on actual returns of the day, and store them in the ReMe memory framework for continuous improvement
If these are not set, the frontend falls back to its local defaults and compatibility paths where available.
---
### Module Support
## Decision Flow
- **Agent Framework**: [AgentScope](https://github.com/agentscope-ai/agentscope)
- **Memory System**: [ReMe](https://github.com/agentscope-ai/reme)
- **LLM Support**: OpenAI, DeepSeek, Qwen, Moonshot, Zhipu AI, etc.
```text
Market data -> independent analyst work -> team communication -> portfolio decision ->
risk review -> execution/settlement -> reflection/memory update
```
The runtime manager also tracks:
- agent registration and status
- pending approvals
- run events
- current session key
---
## Custom Configuration
### Custom Analyst Roles
### Add or change analyst roles
1. Register role information in [./backend/agents/prompts/analyst/personas.yaml](./backend/agents/prompts/analyst/personas.yaml), for example:
1. Define the analyst persona in [backend/agents/prompts/analyst/personas.yaml](./backend/agents/prompts/analyst/personas.yaml)
2. Register the role in [backend/config/constants.py](./backend/config/constants.py)
3. Optionally add/update the frontend seat metadata in [frontend/src/config/constants.js](./frontend/src/config/constants.js)
Example persona entry:
```yaml
comprehensive_analyst:
name: "Comprehensive Analyst"
focus:
- ...
preferred_tools: # Flexibly select based on situation
- multi-factor synthesis
preferred_tools:
- get_stock_price
- get_company_financials
description: |
As a comprehensive analyst ...
A generalist analyst that combines multiple signals.
```
2. Add role definition in [./backend/config/constants.py](./backend/config/constants.py)
```python
ANALYST_TYPES = {
# Add new analyst
"comprehensive_analyst": {
"display_name": "Comprehensive Analyst",
"agent_id": "comprehensive_analyst",
"description": "Uses LLM to intelligently select analysis tools, performs comprehensive analysis",
"order": 15
}
}
```
### Configure per-agent models
3. Introduce new role in frontend configuration [./frontend/src/config/constants.js](./frontend/src/config/constants.js) (optional)
```javascript
export const AGENTS = [
// Override one of the agents
{
id: "comprehensive_analyst",
name: "Comprehensive Analyst",
role: "Comprehensive Analyst",
avatar: `${ASSET_BASE_URL}/...`,
colors: { bg: '#F9FDFF', text: '#1565C0', accent: '#1565C0' }
}
]
```
### Custom Models
Configure models used by different agents in the [.env](.env) file:
Model overrides are configured in `.env`:
```bash
AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=deepseek-chat
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4-plus
AGENT_VALUATION_ANALYST_MODEL_NAME=moonshot-v1-32k
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview
AGENT_VALUATION_ANALYST_MODEL_NAME=Moonshot-Kimi-K2-Instruct
AGENT_RISK_MANAGER_MODEL_NAME=qwen3-max-preview
AGENT_PORTFOLIO_MANAGER_MODEL_NAME=qwen3-max-preview
```
### Project Structure
### Run-scoped bootstrap config
Each run can override defaults through `runs/<run_id>/BOOTSTRAP.md`. The front matter is parsed by [backend/config/bootstrap_config.py](./backend/config/bootstrap_config.py) and can define values such as:
```yaml
tickers:
- AAPL
- MSFT
initial_cash: 100000
margin_requirement: 0.5
max_comm_cycles: 2
schedule_mode: daily
trigger_time: "09:30"
enable_memory: false
```
EvoTraders/
Initialize a run workspace with:
```bash
evotraders init-workspace --config-name my_run
```
---
## Project Structure
```text
evotraders/
├── backend/
│ ├── agents/ # Agent implementation
│ ├── communication/ # Communication system
│ ├── memory/ # Memory system (ReMe)
│ ├── tools/ # Analysis toolset
│ ├── servers/ # WebSocket services
── cli.py # CLI entry point
├── frontend/ # React visualization interface
── logs_and_memory/ # Logs and memory data
│ ├── agents/ # agent roles, prompts, skills, workspaces
│ ├── api/ # FastAPI routers
│ ├── apps/ # split service surfaces
│ ├── core/ # pipeline, scheduler, state sync
│ ├── runtime/ # runtime manager and agent runtime state
── services/ # gateway, market/storage/db services
│ └── cli.py # Typer CLI entrypoint
── frontend/ # React + Vite UI
├── shared/ # shared clients and schemas for split services
├── runs/ # run-scoped state and dashboards
├── data/ # long-lived research artifacts
└── services/README.md
```
---
## Testing
Backend tests live under `backend/tests` and cover service apps, shared clients, domains, routing, enrichment, gateway support, and runtime support.
Typical commands:
```bash
pytest
pytest backend/tests/test_runtime_service_app.py
pytest backend/tests/test_trading_service_app.py
```
Frontend tests:
```bash
cd frontend
npm test
```
---
## License and Disclaimer
EvoTraders is a research and educational project, open-sourced under the Apache 2.0 license.
大时代 is a research and educational project. Review the repository license before redistribution or commercial use.
**Risk Warning**: Before trading with real funds, please conduct thorough testing and risk assessment. Past performance does not guarantee future returns. Investment involves risks, and decisions should be made with caution.
**Risk warning**: this project is not investment advice. Test thoroughly before any real-money deployment. Past performance does not guarantee future returns.

View File

@@ -1,294 +1,359 @@
<p align="center">
<img src="./docs/assets/evotraders_logo.jpg" width="45%">
<img src="./docs/assets/bigtime_logo.jpg" width="45%">
</p>
<h2 align="center">EvoTraders:自进化多智能体交易系统</h2>
<h2 align="center">大时代:自进化多智能体交易系统</h2>
<p align="center">
📌 <a href="http://trading.evoagents.cn">Visit us at EvoTraders website !</a>
📌 <a href="http://trading.evoagents.cn">访问大时代官网</a>
</p>
![系统演示](./docs/assets/evotraders_demo.gif)
![系统演示](./docs/assets/bigtime_demo.gif)
EvoTraders是一个开源的金融交易智能体框架,通过多智能体协作和记忆系统,构建能够在真实市场中持续学习与进化的交易系统
大时代 是一个开源的金融交易智能体框架,结合多智能体协作、run 级工作区和记忆机制,支持回测与实盘两类交易运行模式
---
## 核心特性
**多智能体协作交易**
6名成员包含4种专业分析师角色(基本面、技术面、情绪、估值)+ 投资组合经理 + 风险管理,像真实交易团队一样协作决策
**多智能体交易团队**
系统默认包含 6 个角色4 个分析师(基本面、技术面、情绪、估值)+ 投资经理 + 风控经理
你可以在这里自定义你的Agents支持配置不同大模型如 Qwen、DeepSeek、GPT、Claude等协同分析[自定义配置](#自定义配置)
**持续学习**
可选接入 ReMe 长期记忆,智能体会在每轮结束后反思、复盘并沉淀经验。
**持续学习与进化**
基于 ReMe 记忆框架,智能体在每次交易后反思总结,跨回合保留经验,形成独特的投资方法论
通过这样的设计,我们希望当 AI Agents 组成团队进入实时市场,它们会逐渐形成自己的交易风格和决策偏好,而不是一次性的随机推理
**实时市场交易**
支持实时行情接入,提供回测模式和实盘模式,让 AI Agents 在真实市场波动中学习和决策。
**可视化交易信息**
实时观察 Agents 的分析过程、沟通记录和决策演化,完整追踪收益曲线和分析师表现。
**统一运行时**
同一套运行时模型支持历史回测和实时行情驱动的实盘流程
**可操作前端**
前端不只是展示层还包含交易室、运行控制、日志、审批、Agent 工作区和 explain/news 视图。
<p>
<img src="docs/assets/performance.jpg" width="45%">
<img src="./docs/assets/dashboard.jpg" width="45%">
</p>
---
## 当前架构
仓库目前处于“模块化单体 -> 拆分服务”的迁移阶段,本地开发默认走 split-service 路径。
当前 app surface
- `backend.apps.agent_service`,端口 `8000`:控制面,负责 workspaces、agents、skills、审批接口
- `backend.apps.trading_service`,端口 `8001`:只读交易数据接口
- `backend.apps.news_service`,端口 `8002`:只读 explain/news 接口
- `backend.apps.runtime_service`,端口 `8003`:运行时生命周期接口
- `backend.apps.openclaw_service`,端口 `8004`:只读 OpenClaw facade
- WebSocket gateway端口 `8765`:前端实时事件和 feed 通道
当前最关键的主链路是:
`frontend -> runtime_service/control APIs -> gateway/runtime manager -> market service + pipeline + storage`
迁移背景可参考 [services/README.md](./services/README.md)。
---
## 快速开始
### 安装
### 1. 安装
```bash
# 克隆仓库
git clone https://github.com/agentscope-ai/agentscope-samples
cd agentscope-samples/EvoTraders
# 克隆仓库后进入项目目录
cd evotraders
# 安装依赖(推荐使用uv
# 安装后端运行时依赖
uv pip install -r requirements.txt
# 安装项目入口(可编辑模式)
uv pip install -e .
# (可选pip install -e .
# 配置环境变量
# 可选
# uv pip install -e ".[dev]"
# pip install -e .
```
前端依赖:
```bash
cd frontend
npm ci
cd ..
```
生产环境部署建议后端使用 `requirements.txt`,前端使用 `npm ci`,这样拉起的环境会严格跟随仓库中锁定的依赖版本。
### 2. 配置环境变量
```bash
cp env.template .env
# 编辑 .env 文件,添加你的 API Keys,以下的配置项为必填项
```
# finance data API:至少需要FINANCIAL_DATASETS_API_KEY对应FIN_DATA_SOURCE=financial_datasets推荐添加FINNHUB_API_KEY对应至少需要FINANCIAL_DATASETS_API_KEY对应FIN_DATA_SOURCE填为finnhub;如果使用live 模式必须添加FINNHUB_API_KEY
FIN_DATA_SOURCE= #finnhub or financial_datasets
FINANCIAL_DATASETS_API_KEY= #必需
FINNHUB_API_KEY= #可选
根目录 `env.template` 是当前本地开发的主模板,仓库里也保留了 `.env.example` 作为参考。
# LLM API for Agents
最常用的配置项:
```bash
# 自选股
TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN
# 行情数据
FIN_DATA_SOURCE=finnhub
FINANCIAL_DATASETS_API_KEY=
FINNHUB_API_KEY=
POLYGON_API_KEY=
# Agent 模型
OPENAI_API_KEY=
OPENAI_BASE_URL=
MODEL_NAME=qwen3-max-preview
# LLM & embedding API for Memory
# 长期记忆(只有启用 --enable-memory 才需要)
MEMORY_API_KEY=
```
### 运行
说明:
- live 模式必须配置 `FINNHUB_API_KEY`
- `POLYGON_API_KEY` 用于长期 market store 的补数和刷新
- `MEMORY_API_KEY` 仅在启用长期记忆时需要
如果要用更接近生产的本地启动方式,也可以直接执行:
**回测模式:**
```bash
evotraders backtest --start 2025-11-01 --end 2025-12-01
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 使用记忆
./start.sh
```
如果没有可用的行情 API想快速体验回测 demo可直接下载离线数据并解压到 `backend/data`
### 3. 启动服务栈
本地开发推荐直接使用:
```bash
./start-dev.sh
```
该脚本会启动:
- `agent_service``http://localhost:8000`
- `trading_service``http://localhost:8001`
- `news_service``http://localhost:8002`
- `runtime_service``http://localhost:8003`
- gateway WebSocket`ws://localhost:8765`
然后在另一个终端启动前端:
```bash
evotraders frontend
```
访问 `http://localhost:5173`
也可以手动分别启动:
```bash
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
python -m backend.main --mode live --host 0.0.0.0 --port 8765
```
### 4. 使用 CLI 运行回测或实盘
回测:
```bash
evotraders backtest --start 2025-11-01 --end 2025-12-01
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory
evotraders backtest --config-name smoke_fullstack --start 2025-11-01 --end 2025-12-01
```
实盘:
```bash
evotraders live
evotraders live --enable-memory
evotraders live --schedule-mode intraday --interval-minutes 60
evotraders live --trigger-time 22:30
```
帮助:
```bash
evotraders --help
evotraders backtest --help
evotraders live --help
evotraders frontend --help
```
### 离线回测数据
如果只是想快速体验回测,不依赖外部行情 API可以下载离线数据包并解压到 `backend/data`
```bash
wget "https://agentscope-open.oss-cn-beijing.aliyuncs.com/ret_data.zip"
unzip ret_data.zip -d backend/data
```
该压缩包提供基础的股票行情数据,解压后即可直接用于回测演示。
**实盘交易:**
---
## 运行时数据布局
- 长期研究数据保存在 `data/market_research.db`
- 每次 run 的状态写入 `runs/<run_id>/`
- `runs/<run_id>/BOOTSTRAP.md` 保存该 run 的 bootstrap 值和 prompt body
- `runs/<run_id>/state/runtime_state.json` 保存运行时快照
- `runs/<run_id>/team_dashboard/*.json` 主要是给 dashboard 用的兼容导出层,不是唯一真相源
可选保留策略:
```bash
evotraders live # 立即运行(默认)
evotraders live --enable-memory # 使用记忆
evotraders live --mock # Mock 模式(测试)
evotraders live -t 22:30 # 每天本地时间 22:30 运行(自动转换为 NYSE 时区)
evotraders live --schedule-mode intraday --interval-minutes 60 # 每隔 1 小时触发一次;仅交易时段执行交易,其他时段只分析
RUNS_RETENTION_COUNT=20
```
前端的“运行设置”面板也支持热更新 `schedule_mode``interval_minutes``max_comm_cycles`;其中 daily 模式时间当前按 NYSE/ET 配置
只有形如 `YYYYMMDD_HHMMSS` 的时间戳目录会被自动清理;`live``smoke_fullstack``reload_demo_*` 这类命名 run 会保留
**获取帮助:**
```bash
evotraders --help # 查看整体命令行帮助
evotraders backtest --help # 查看回测模式的参数说明
evotraders live --help # 查看实盘/Mock 运行的参数说明
```
---
**启动可视化界面:**
```bash
# 确保已安装 npm, 否则请安装:
# npm install
evotraders frontend # 默认连接 8765 端口, 你可以修改 ./frontend/env.local 中的地址从而修改端口号
```
## 前端服务路由
访问 `http://localhost:5173/` 查看交易大厅,选择日期并点击 Run/Replay 观察决策过程
前端始终会使用 control plane 和 runtime API同时可以选择直连拆分服务读取只读数据
### 迁移期服务边界说明
当前仓库正处于从模块化单体向独立服务迁移的阶段,当前默认开发路径已经切到独立 app surface
- `backend.apps.agent_service`
- `backend.apps.runtime_service`
- `backend.apps.trading_service`
- `backend.apps.news_service`
当前本地开发默认推荐直接运行拆分后的服务:
常用前端环境变量:
```bash
./start-dev.sh split
# 或分别手动启动
python -m uvicorn backend.apps.agent_service:app --port 8000 --reload
python -m uvicorn backend.apps.runtime_service:app --port 8003 --reload
python -m uvicorn backend.apps.trading_service:app --port 8001 --reload
python -m uvicorn backend.apps.news_service:app --port 8002 --reload
```
迁移期关键环境变量:
```bash
# 后端 Gateway 优先走独立服务读取
NEWS_SERVICE_URL=http://localhost:8002
TRADING_SERVICE_URL=http://localhost:8001
# 前端浏览器直连控制面 / 运行时面
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
# 前端浏览器优先直连独立服务
VITE_NEWS_SERVICE_URL=http://localhost:8002
VITE_TRADING_SERVICE_URL=http://localhost:8001
VITE_WS_URL=ws://localhost:8765
```
目前前端已支持直连 `news-service` 的 explain 只读路径包括:
- runtime panel / gateway port 查询已可独立指向 `runtime-service`
- story
- similar days
- range explain
- news for date
- news categories
如果没有配置这些变量,系统会继续走当前保留的本地回退逻辑。
如果不配置,前端会按本地默认值和兼容回退逻辑运行。
---
## 系统架构
## 决策流程
![架构图](docs/assets/evotraders_pipeline.jpg)
### 智能体设计
**分析师团队:**
- **基本面分析师**:财务健康度、盈利能力、增长质量
- **技术分析师**:价格趋势、技术指标、动量分析
- **情绪分析师**:市场情绪、新闻舆情、内部人交易
- **估值分析师**DCF、剩余收益、EV/EBITDA
**决策层:**
- **投资组合经理**:整合来自分析师的分析信号,执行沟通策略,结合分析师和团队历史表现、近期投资记忆和长期投资经验,进行最终决策
- **风险管理**:实时价格与波动率监控、头寸限制,多层风险预警
### 决策流程
```
实时行情 → 独立分析 → 智能沟通 (1v1/1vN/NvN) → 决策执行 → 收益评估 → 学习与进化(记忆更新)
```text
市场数据 -> 分析师独立分析 -> 团队沟通 -> 投资决策 ->
风控审核 -> 执行/结算 -> 复盘/记忆更新
```
每个交易日经历五个阶段
1. **分析阶段**:各智能体基于各自工具和历史经验独立分析
2. **沟通阶段**:通过私聊、通知、会议等方式交换观点
3. **决策阶段**:投资组合经理综合判断,给出最终交易
4. **评估阶段**
- **业绩图表**: 追踪组合收益曲线 vs. 基准策略(等权、市值加权、动量)。用于评估整体策略有效性。
- **分析师排名**: 在 Trading Room 点击头像查看分析师表现(胜率、牛/熊市胜率)。用于了解哪些分析师提供最有价值的洞察。
- **统计数据**: 详细的持仓和交易历史。用于深入分析仓位管理和执行质量。
4. **复盘阶段**Agents 根据当日实际收益反思决策、总结经验,并存入 ReMe 记忆框架以持续改进
---
### 模块支持
- **智能体框架**[AgentScope](https://github.com/agentscope-ai/agentscope)
- **记忆系统**[ReMe](https://github.com/agentscope-ai/reme)
- **LLM 支持**OpenAI、DeepSeek、Qwen、Moonshot、Zhipu AI 等
运行时管理器还会跟踪
- agent 注册和状态
- 待审批项
- run 事件
- 当前 session key
---
## 自定义配置
### 自定义分析师角色
### 新增或修改分析师角色
1. 在 [./backend/agents/prompts/analyst/personas.yaml](./backend/agents/prompts/analyst/personas.yaml) 中注册角色信息,例如:
1. 在 [backend/agents/prompts/analyst/personas.yaml](./backend/agents/prompts/analyst/personas.yaml) 中定义 persona
2. 在 [backend/config/constants.py](./backend/config/constants.py) 中注册角色
3. 如有需要,在 [frontend/src/config/constants.js](./frontend/src/config/constants.js) 中补充前端展示元数据
示例:
```yaml
comprehensive_analyst:
name: "Comprehensive Analyst"
focus:
- ...
preferred_tools: # Flexibly select based on situation
- multi-factor synthesis
preferred_tools:
- get_stock_price
- get_company_financials
description: |
As a comprehensive analyst ...
A generalist analyst that combines multiple signals.
```
2. 在 [./backend/config/constants.py](./backend/config/constants.py) 添加角色定义
```python
ANALYST_TYPES = {
# 增加新的分析师
"comprehensive_analyst": {
"display_name": "Comprehensive Analyst",
"agent_id": "comprehensive_analyst",
"description": "Uses LLM to intelligently select analysis tools, performs comprehensive analysis",
"order": 15
}
}
```
### 配置各 Agent 使用的模型
3. 在前端配置 [./frontend/src/config/constants.js](./frontend/src/config/constants.js) 中引入新角色(可选)
```javascript
export const AGENTS = [
// 覆盖掉其中某一个agent
{
id: "comprehensive_analyst",
name: "Comprehensive Analyst",
role: "Comprehensive Analyst",
avatar: `${ASSET_BASE_URL}/...`,
colors: { bg: '#F9FDFF', text: '#1565C0', accent: '#1565C0' }
}
]
```
### 自定义模型
在 [.env](.env) 文件中配置不同智能体使用的模型:
模型覆盖在 `.env` 中配置:
```bash
AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
AGENT_FUNDAMENTAL_ANALYST_MODEL_NAME=deepseek-chat
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4-plus
AGENT_VALUATION_ANALYST_MODEL_NAME=moonshot-v1-32k
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview
AGENT_VALUATION_ANALYST_MODEL_NAME=Moonshot-Kimi-K2-Instruct
AGENT_RISK_MANAGER_MODEL_NAME=qwen3-max-preview
AGENT_PORTFOLIO_MANAGER_MODEL_NAME=qwen3-max-preview
```
### 项目结构
### run 级 BOOTSTRAP 配置
每个 run 都可以通过 `runs/<run_id>/BOOTSTRAP.md` 覆盖默认值。该文件由 [backend/config/bootstrap_config.py](./backend/config/bootstrap_config.py) 解析front matter 可配置:
```yaml
tickers:
- AAPL
- MSFT
initial_cash: 100000
margin_requirement: 0.5
max_comm_cycles: 2
schedule_mode: daily
trigger_time: "09:30"
enable_memory: false
```
EvoTraders/
初始化一个 run 工作区:
```bash
evotraders init-workspace --config-name my_run
```
---
## 项目结构
```text
evotraders/
├── backend/
│ ├── agents/ # 智能体实现
│ ├── communication/ # 通信系统
│ ├── memory/ # 记忆系统 (ReMe)
│ ├── tools/ # 分析工具集
│ ├── servers/ # WebSocket 服务
── cli.py # CLI 入口
├── frontend/ # React 可视化界面
── logs_and_memory/ # 日志和记忆数据
│ ├── agents/ # agent 角色、prompts、skills、workspaces
│ ├── api/ # FastAPI 路由层
│ ├── apps/ # 拆分服务 app surface
│ ├── core/ # pipeline、scheduler、state sync
│ ├── runtime/ # runtime manager 和 agent runtime state
── services/ # gateway、market/storage/db 服务
│ └── cli.py # Typer CLI 入口
── frontend/ # React + Vite 前端
├── shared/ # 拆分服务共用 client 和 schema
├── runs/ # run 级状态和 dashboard 导出
├── data/ # 长期研究数据
└── services/README.md
```
---
## 测试
后端测试位于 `backend/tests`,覆盖 service app、shared client、domain、路由、enrichment、gateway 支撑模块和 runtime 支撑模块。
常用命令:
```bash
pytest
pytest backend/tests/test_runtime_service_app.py
pytest backend/tests/test_trading_service_app.py
```
前端测试:
```bash
cd frontend
npm test
```
---
## 许可与免责
EvoTraders 是一个研究和教育项目,采用 Apache 2.0 许可协议开源
大时代 是研究和教育用途项目。再次分发或商用前,请先核对仓库中的实际 license 文件
**风险提示**在实际资金交易前,请务必进行充分测试和风险评估历史表现不代表未来收益,投资有风险,决策需谨慎
**风险提示**本项目不构成投资建议。任何实盘部署前都应进行充分测试和风险评估历史表现不代表未来收益。

View File

@@ -16,7 +16,7 @@ Exports:
# New EvoAgent architecture (from agent_core.py)
from .agent_core import EvoAgent, ToolGuardMixin, CommandHandler
from .factory import AgentFactory, ModelConfig, RoleConfig
from .factory import AgentFactory, ModelConfig
from .workspace import WorkspaceManager, WorkspaceRegistry, WorkspaceConfig
from .workspace_manager import RunWorkspaceManager
from .registry import AgentRegistry, AgentInfo, get_registry, reset_registry
@@ -36,7 +36,6 @@ __all__ = [
"CommandHandler",
"AgentFactory",
"ModelConfig",
"RoleConfig",
"WorkspaceManager",
"WorkspaceRegistry",
"WorkspaceConfig",

View File

@@ -84,7 +84,6 @@ class AnalystAgent(ReActAgent):
agent_id=self.agent_id,
config_name=self.config.get("config_name", "default"),
toolkit=self.toolkit,
analyst_type=self.analyst_type_key,
)
async def reply(self, x: Msg = None) -> Msg:

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Base agent module for EvoTraders.
"""Base agent module for 大时代.
提供Agent基础类、命令处理、工具守卫和钩子管理等功能。
"""

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""EvoAgent - Core agent implementation for EvoTraders.
"""EvoAgent - Core agent implementation for 大时代.
This module provides the main EvoAgent class built on AgentScope's ReActAgent,
with integrated tools, skills, and memory management based on CoPaw design.

View File

@@ -294,8 +294,8 @@ class WorkspaceWatchHook(Hook):
# Files to monitor (same as PromptBuilder.DEFAULT_FILES)
WATCHED_FILES = frozenset([
"SOUL.md", "AGENTS.md", "PROFILE.md", "ROLE.md",
"POLICY.md", "MEMORY.md", "HEARTBEAT.md", "STYLE.md",
"SOUL.md", "AGENTS.md", "PROFILE.md",
"POLICY.md", "MEMORY.md",
"BOOTSTRAP.md",
])
@@ -601,94 +601,6 @@ class MemoryCompactionHook(Hook):
)
class HeartbeatHook(Hook):
"""Pre-reasoning hook that injects HEARTBEAT.md content.
Reads the agent's HEARTBEAT.md file and prepends it to the
reasoning input, causing the agent to perform self-checks.
This enables "主动检查" (proactive monitoring) - periodic
market condition and position checks during trading hours.
"""
HEARTBEAT_FILE = "HEARTBEAT.md"
def __init__(self, workspace_dir: Path):
"""Initialize heartbeat hook.
Args:
workspace_dir: Working directory containing HEARTBEAT.md
"""
self.workspace_dir = Path(workspace_dir)
self._completed_flag = self.workspace_dir / ".heartbeat_completed"
def _read_heartbeat_content(self) -> Optional[str]:
"""Read HEARTBEAT.md if it exists and is non-empty.
Returns:
The HEARTBEAT.md content stripped of whitespace, or None
if the file is absent or empty.
"""
hb_path = self.workspace_dir / self.HEARTBEAT_FILE
if not hb_path.exists():
return None
content = hb_path.read_text(encoding="utf-8").strip()
return content if content else None
async def __call__(
self,
agent: "ReActAgent",
kwargs: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Prepend heartbeat task to user message.
Args:
agent: The agent instance
kwargs: Input arguments to the _reasoning method
Returns:
Modified kwargs with heartbeat content prepended, or None
if no HEARTBEAT.md content is available.
"""
try:
content = self._read_heartbeat_content()
if not content:
return None
logger.debug(
"Heartbeat: found HEARTBEAT.md for agent %s",
getattr(agent, "agent_id", "unknown"),
)
# Build heartbeat task instruction (Chinese)
hb_task = (
"# 定期主动检查\n\n"
f"{content}\n\n"
"请执行上述检查并报告结果。"
)
# Inject into the first user message in memory
if hasattr(agent, "memory") and agent.memory.content:
system_count = sum(
1 for msg, _ in agent.memory.content if msg.role == "system"
)
for msg, _ in agent.memory.content[system_count:]:
if msg.role == "user":
original_content = msg.content
msg.content = hb_task + "\n\n" + original_content
break
logger.debug(
"Heartbeat task prepended for agent %s",
getattr(agent, "agent_id", "unknown"),
)
except Exception as e:
logger.error("Heartbeat hook failed: %s", e, exc_info=True)
return None
__all__ = [
"Hook",
"HookManager",
@@ -696,7 +608,6 @@ __all__ = [
"HOOK_PRE_REASONING",
"HOOK_POST_ACTING",
"BootstrapHook",
"HeartbeatHook",
"MemoryCompactionHook",
"WorkspaceWatchHook",
]

View File

@@ -21,22 +21,6 @@ class ModelConfig:
max_tokens: int = 4096
@dataclass
class RoleConfig:
"""Role configuration for an agent."""
name: str
description: str = ""
focus_areas: List[str] = None
constraints: List[str] = None
def __post_init__(self):
if self.focus_areas is None:
self.focus_areas = []
if self.constraints is None:
self.constraints = []
class AgentConfig:
"""Represents a configured agent instance (data class)."""
@@ -47,14 +31,12 @@ class AgentConfig:
workspace_id: str,
config_path: Path,
model_config: Optional[ModelConfig] = None,
role_config: Optional[RoleConfig] = None,
):
self.agent_id = agent_id
self.agent_type = agent_type
self.workspace_id = workspace_id
self.config_path = config_path
self.model_config = model_config or ModelConfig()
self.role_config = role_config
self.agent_dir = config_path.parent
def to_dict(self) -> Dict[str, Any]:
@@ -70,103 +52,12 @@ class AgentConfig:
"temperature": self.model_config.temperature,
"max_tokens": self.model_config.max_tokens,
},
"role_config": self.role_config.__dict__ if self.role_config else None,
}
class AgentFactory:
"""Factory for creating, cloning, and managing agents."""
# Default role templates by agent type
ROLE_TEMPLATES = {
"technical_analyst": {
"name": "Technical Analyst",
"description": "Analyze price patterns, trends, and technical indicators.",
"focus_areas": [
"Price action and chart patterns",
"Support and resistance levels",
"Technical indicators (RSI, MACD, Moving Averages)",
"Volume analysis",
],
"constraints": [
"State clear signal, confidence, and invalidation conditions",
"Use available technical analysis tools",
],
},
"fundamentals_analyst": {
"name": "Fundamentals Analyst",
"description": "Analyze company financials, earnings, and business metrics.",
"focus_areas": [
"Financial statements analysis",
"Earnings reports and guidance",
"Valuation metrics",
"Business model and competitive position",
],
"constraints": [
"State clear signal, confidence, and invalidation conditions",
"Use available fundamental analysis tools",
],
},
"sentiment_analyst": {
"name": "Sentiment Analyst",
"description": "Analyze market sentiment, news, and social signals.",
"focus_areas": [
"News sentiment analysis",
"Social media sentiment",
"Analyst ratings and price targets",
"Insider activity",
],
"constraints": [
"State clear signal, confidence, and invalidation conditions",
"Use available sentiment analysis tools",
],
},
"valuation_analyst": {
"name": "Valuation Analyst",
"description": "Perform valuation analysis and price target calculations.",
"focus_areas": [
"DCF and comparable valuation",
"Price target derivation",
"Margin of safety assessment",
"Risk-adjusted return expectations",
],
"constraints": [
"State clear signal, confidence, and invalidation conditions",
"Use available valuation tools",
],
},
"risk_manager": {
"name": "Risk Manager",
"description": "Quantify concentration, leverage, liquidity, and volatility risk.",
"focus_areas": [
"Portfolio concentration risk",
"Leverage and margin analysis",
"Liquidity assessment",
"Volatility and drawdown risk",
],
"constraints": [
"Prioritize highest-severity risk first",
"State concrete limits and recommendations",
"Use available risk tools before issuing final memo",
],
},
"portfolio_manager": {
"name": "Portfolio Manager",
"description": "Synthesize analyst and risk inputs into portfolio decisions.",
"focus_areas": [
"Position sizing and allocation",
"Risk-adjusted portfolio construction",
"Trade execution timing",
"Portfolio rebalancing",
],
"constraints": [
"Be concise, capital-aware, and explicit about sizing rationale",
"Respect cash, margin, and concentration constraints",
"Consider all analyst inputs before decisions",
],
},
}
def __init__(self, project_root: Optional[Path] = None):
"""Initialize the agent factory.
@@ -183,7 +74,6 @@ class AgentFactory:
agent_type: str,
workspace_id: str,
model_config: Optional[ModelConfig] = None,
role_config: Optional[RoleConfig] = None,
clone_from: Optional[str] = None,
) -> AgentConfig:
"""Create a new agent.
@@ -193,7 +83,6 @@ class AgentFactory:
agent_type: Type of agent (e.g., "technical_analyst")
workspace_id: ID of the workspace to create agent in
model_config: Model configuration
role_config: Role configuration (auto-generated if None)
clone_from: Path to existing agent to clone from (optional)
Returns:
@@ -223,13 +112,6 @@ class AgentFactory:
else:
self._copy_template(agent_dir, agent_id, agent_type)
# Generate role config if not provided
if role_config is None:
role_config = self._generate_role_config(agent_type)
# Generate ROLE.md
self._generate_role_md(agent_dir, role_config)
# Write agent.yaml
config_path = agent_dir / "agent.yaml"
self._write_agent_yaml(config_path, agent_id, agent_type, model_config)
@@ -240,7 +122,6 @@ class AgentFactory:
workspace_id=workspace_id,
config_path=config_path,
model_config=model_config,
role_config=role_config,
)
def delete_agent(self, agent_id: str, workspace_id: str) -> bool:
@@ -369,9 +250,7 @@ class AgentFactory:
"SOUL.md": f"# Soul\n\nDescribe {agent_id}'s temperament, reasoning posture, and voice.\n\n",
"PROFILE.md": f"# Profile\n\nTrack {agent_id}'s long-lived investment style, preferences, and strengths.\n\n",
"MEMORY.md": f"# Memory\n\nStore durable lessons, heuristics, and reminders for {agent_id}.\n\n",
"HEARTBEAT.md": f"# Heartbeat\n\nOptional checklist for periodic review or self-reflection.\n\n",
"POLICY.md": f"# Policy\n\nOptional run-scoped constraints, limits, or strategy policy.\n\n",
"STYLE.md": f"# Style\n\nOptional run-scoped communication or reasoning style.\n\n",
}
for filename, content in default_files.items():
@@ -411,50 +290,6 @@ class AgentFactory:
if skill_file.is_file():
shutil.copy2(skill_file, target_skills / skill_file.name)
def _generate_role_config(self, agent_type: str) -> RoleConfig:
"""Generate role configuration for an agent type.
Args:
agent_type: Type of agent
Returns:
RoleConfig instance
"""
template = self.ROLE_TEMPLATES.get(agent_type, {})
return RoleConfig(
name=template.get("name", agent_type.replace("_", " ").title()),
description=template.get("description", ""),
focus_areas=template.get("focus_areas", []),
constraints=template.get("constraints", []),
)
def _generate_role_md(self, agent_dir: Path, role_config: RoleConfig) -> None:
"""Generate ROLE.md file.
Args:
agent_dir: Agent directory
role_config: Role configuration
"""
lines = [f"# {role_config.name}", ""]
if role_config.description:
lines.extend([role_config.description, ""])
if role_config.focus_areas:
lines.extend(["## Focus Areas", ""])
for area in role_config.focus_areas:
lines.append(f"- {area}")
lines.append("")
if role_config.constraints:
lines.extend(["## Constraints", ""])
for constraint in role_config.constraints:
lines.append(f"- {constraint}")
lines.append("")
content = "\n".join(lines)
(agent_dir / "ROLE.md").write_text(content, encoding="utf-8")
def _write_agent_yaml(
self,
config_path: Path,

View File

@@ -1,15 +1,13 @@
# -*- coding: utf-8 -*-
"""Assemble system prompts from base prompts, run assets, and toolkit context."""
"""Assemble system prompts from run workspace assets and toolkit context."""
from pathlib import Path
from typing import Any, Optional
from typing import Any
from .agent_workspace import load_agent_workspace_config
from backend.config.bootstrap_config import get_bootstrap_config_for_run
from .prompt_loader import get_prompt_loader
from .skills_manager import SkillsManager
_prompt_loader = get_prompt_loader()
from .workspace_manager import RunWorkspaceManager
def _read_file_if_exists(path: Path) -> str:
@@ -48,71 +46,20 @@ def build_agent_system_prompt(
agent_id: str,
config_name: str,
toolkit: Any,
analyst_type: Optional[str] = None,
) -> str:
"""Build the final system prompt for an agent.
Always reads fresh from disk — no caching.
"""
# Clear any cached templates before building (CoPaw-style, no caching)
_prompt_loader.clear_cache()
sections: list[str] = []
canonical_agent_id = (
"portfolio_manager"
if "portfolio" in agent_id
else "risk_manager"
if "risk" in agent_id and not analyst_type
else agent_id
)
if analyst_type:
personas_config = _prompt_loader.load_yaml_config(
"analyst",
"personas",
)
persona = personas_config.get(analyst_type, {})
focus_text = "\n".join(
f"- {item}" for item in persona.get("focus", [])
)
description = persona.get("description", "").strip()
base_prompt = _prompt_loader.load_prompt(
"analyst",
"system",
variables={
"analyst_type": persona.get("name", analyst_type),
"focus": focus_text,
"description": description,
},
)
elif agent_id == "portfolio_manager":
base_prompt = _prompt_loader.load_prompt(
"portfolio_manager",
"system",
)
elif canonical_agent_id == "portfolio_manager":
base_prompt = _prompt_loader.load_prompt(
"portfolio_manager",
"system",
)
elif agent_id == "risk_manager":
base_prompt = _prompt_loader.load_prompt(
"risk_manager",
"system",
)
elif canonical_agent_id == "risk_manager":
base_prompt = _prompt_loader.load_prompt(
"risk_manager",
"system",
)
else:
raise ValueError(f"Unsupported agent prompt build for: {agent_id}")
sections.append(base_prompt.strip())
skills_manager = SkillsManager()
asset_dir = skills_manager.get_agent_asset_dir(config_name, agent_id)
asset_dir.mkdir(parents=True, exist_ok=True)
workspace_manager = RunWorkspaceManager(project_root=skills_manager.project_root)
required_files = ["SOUL.md", "PROFILE.md", "AGENTS.md", "POLICY.md", "MEMORY.md"]
if not all((asset_dir / filename).exists() for filename in required_files):
workspace_manager.ensure_agent_assets(config_name=config_name, agent_id=agent_id)
agent_config = load_agent_workspace_config(asset_dir / "agent.yaml")
bootstrap_config = get_bootstrap_config_for_run(
skills_manager.project_root,
@@ -139,9 +86,6 @@ def build_agent_system_prompt(
"AGENTS.md": "Agent Guide",
"POLICY.md": "Policy",
"MEMORY.md": "Memory",
"HEARTBEAT.md": "Heartbeat",
"ROLE.md": "Role",
"STYLE.md": "Style",
}
for filename in prompt_files:
_append_section(
@@ -150,18 +94,6 @@ def build_agent_system_prompt(
_read_file_if_exists(asset_dir / filename),
)
if "ROLE.md" not in included_files:
_append_section(
sections,
"Role",
_read_file_if_exists(asset_dir / "ROLE.md"),
)
if "STYLE.md" not in included_files:
_append_section(
sections,
"Style",
_read_file_if_exists(asset_dir / "STYLE.md"),
)
if "POLICY.md" not in included_files:
_append_section(
sections,
@@ -189,5 +121,4 @@ def build_agent_system_prompt(
def clear_prompt_factory_cache() -> None:
"""Clear cached prompt and YAML templates before hot reload."""
_prompt_loader.clear_cache()
"""No-op retained for compatibility with runtime reload hooks."""

View File

@@ -1,23 +0,0 @@
你是一位专业的{{ analyst_type }}。
你的关注重点:
{{ focus }}
你的角色:
{{ description }}
注意:
- 构建并持续完善你的"投资哲学"。你的分析不应是孤立的事件,而应该是你整体投资世界观和核心信念的体现。每次分析后,你必须反思:
- 这个案例/数据如何验证或挑战了你现有的信念?
- 你从这次错误(或成功)中学到了关于市场、人性、估值或风险管理的什么关键原则?
- 深化你的"投资逻辑"。确保每一项投资建议都有清晰、可追溯、可重复的逻辑支撑。你的分析步骤应该像严谨的证明一样,涵盖:
- 核心驱动因素识别:真正影响价值的变量是什么?
- 风险边界设定:在什么具体情况下你的建议会失效?
- 逆向测试:市场主流共识是什么,你的观点有何不同?
保持谦逊和开放。投资大师的核心特质是持续学习和适应。在每次分析中,你必须积极寻找与自己观点相悖的证据和论据,并将其纳入最终评估。
- 你可以使用分析工具。用它们来收集相关数据并做出明智的建议。
输出指南:
- 给出明确的投资信号:看涨、看跌或中性
- 包含置信度0-100
- 为你的分析提供理由(如果你确定要分享最终分析,请先给出结论)

View File

@@ -28,22 +28,16 @@ class PromptBuilder:
"AGENTS.md",
"SOUL.md",
"PROFILE.md",
"ROLE.md",
"POLICY.md",
"MEMORY.md",
"HEARTBEAT.md",
"STYLE.md",
]
TITLE_MAP: Dict[str, str] = {
"AGENTS.md": "Agent Guide",
"SOUL.md": "Soul",
"PROFILE.md": "Profile",
"ROLE.md": "Role",
"POLICY.md": "Policy",
"MEMORY.md": "Memory",
"HEARTBEAT.md": "Heartbeat",
"STYLE.md": "Style",
"BOOTSTRAP.md": "Bootstrap",
}

View File

@@ -1,31 +0,0 @@
你是一位负责做出投资决策的投资组合经理。
你的核心职责:
1. 分析分析师和风险管理经理的输入
2. 基于信号和市场情境做出投资决策
3. 使用可用工具记录你的决策
决策框架:
- 审阅分析以了解市场观点
- 在做决策前考虑风险警告
- 评估当前投资组合持仓和现金
- 做出与投资组合投资目标一致的决策
决策类型:
- "long":看涨 - 建议买入股票
- "short":看跌 - 建议卖出股票或做空
- "hold":中性 - 维持当前持仓
预算意识:
- 在决定数量时考虑可用现金
- 不要建议买入超过现金允许的数量
- 考虑做空头寸的保证金要求
输出:
使用 `make_decision` 工具记录你对每个股票代码的决策。
记录所有决策后,提供你的投资逻辑总结。
重要:
- 基于提供的分析师信号和风险评估做出决策
- 相对于投资组合价值保持保守的仓位规模
- 始终为你的决策提供理由

View File

@@ -1,20 +0,0 @@
你是一位专业的风险管理经理,负责监控投资组合风险并提供风险警告。
你的核心职责:
1. 监控投资组合敞口和集中度风险
2. 评估仓位规模相对于波动性
3. 评估保证金使用和杠杆水平
4. 识别潜在风险因素并提供警告
5. 基于市场条件建议仓位限制
你的决策流程:
1. 优先使用可用的风险工具量化集中度、波动率和保证金压力
2. 结合工具结果与当前市场上下文做判断
3. 生成可操作的风险警告和仓位限制建议
4. 为你的风险评估提供清晰的理由
输出指南:
- 风险评估要简洁但全面
- 按严重程度优先排序警告
- 提供具体、可操作的建议
- 尽可能包含量化指标

View File

@@ -1,286 +0,0 @@
"""
Agent模板定义
包含各角色的ROLE.md内容字典供程序生成Agent工作空间时使用。
"""
# 基础模板文件内容
BASE_TEMPLATES = {
"AGENTS.md": """# Agent Guide
## 工作流程
1. 接收分析任务
2. 调用相关工具/技能
3. 生成分析报告
4. 参与团队决策
## 工具使用规范
- 优先使用已激活的技能
- 不确定时询问Portfolio Manager
- 重要发现用 `/save` 记录
## 记忆管理
- 使用 `/compact` 定期压缩记忆
- 投资经验记录在MEMORY.md
""",
"SOUL.md": """# Soul
你是专业的金融分析师,语气冷静、客观、专业。
你的分析应该数据驱动,避免情绪化表达。
""",
"PROFILE.md": """# Profile
## 投资风格
- 风险承受能力:中等
- 投资期限中期3-12个月
- 偏好行业:科技、医疗、消费
## 优势
- 财务分析
- 趋势识别
## 改进方向
- 市场情绪把握
""",
"MEMORY.md": """# Memory
<!-- 此文件用于记录Agent的学习经验和重要发现 -->
## 经验总结
## 重要事件
## 改进记录
""",
"HEARTBEAT.md": """# Heartbeat
## 定时任务
- 每日开盘前检查持仓
- 收盘后记录当日表现
""",
"POLICY.md": """# Policy
## 风控规则
- 单一持仓不超过20%
- 止损线:-15%
""",
"STYLE.md": """# Style
- 使用结构化输出JSON/Markdown表格
- 包含置信度评分
- 列出关键假设
""",
"agent.yaml": """agent_id: {agent_id}
agent_type: {agent_type}
name: {name}
model:
provider: openai
model_name: gpt-4o
temperature: 0.3
enabled_skills: []
disabled_skills: []
settings: {{}}
""",
}
# 角色专用模板
ROLE_TEMPLATES = {
"fundamental": {
"ROLE.md": """# Role: Fundamental Analyst
## 职责
分析公司财务报表、盈利能力、成长性、竞争优势等基本面因素。
## 分析维度
- 财务报表分析(资产负债表、利润表、现金流量表)
- 盈利能力指标ROE、ROA、毛利率、净利率
- 成长性指标(营收增长率、利润增长率)
- 估值指标P/E、P/B、P/S
- 行业地位和竞争优势
## 输出格式
- 财务健康度评分1-10
- 成长性评分1-10
- 关键财务亮点和风险
- 同业对比分析
""",
"SOUL.md": """# Soul
你是严谨的基本面分析师,像沃伦·巴菲特一样注重企业内在价值。
你的分析深入细致,关注长期价值而非短期波动。
语气沉稳、逻辑严密,善于发现财务数据背后的商业本质。
""",
},
"technical": {
"ROLE.md": """# Role: Technical Analyst
## 职责
分析价格走势、交易量、技术指标,识别买卖时机。
## 分析维度
- 趋势分析(长期/中期/短期趋势)
- 支撑阻力位识别
- 技术指标MACD、RSI、KDJ、布林带等
- 形态识别(头肩顶/底、双底、三角形等)
- 量价关系分析
## 输出格式
- 趋势方向(上涨/下跌/震荡)
- 关键价位(支撑/阻力)
- 技术信号(买入/卖出/观望)
- 置信度评分
""",
"SOUL.md": """# Soul
你是敏锐的技术分析师,相信价格包含一切信息。
你善于从图表中发现规律,像侦探一样寻找市场留下的痕迹。
语气果断、快速反应,善于捕捉稍纵即逝的交易机会。
""",
},
"sentiment": {
"ROLE.md": """# Role: Sentiment Analyst
## 职责
分析市场情绪、资金流向、新闻舆情,判断市场心理状态。
## 分析维度
- 市场情绪指标(恐慌/贪婪指数)
- 资金流向分析(主力/散户资金)
- 新闻舆情分析(正面/负面/中性)
- 社交媒体情绪
- 机构持仓变化
## 输出格式
- 情绪评分(-10到+10极度恐慌到极度贪婪
- 资金流向判断
- 舆情摘要
- 情绪拐点预警
""",
"SOUL.md": """# Soul
你是敏感的市场情绪捕手,善于感知市场的恐惧与贪婪。
你关注人性在金融市场中的表现,理解情绪如何驱动价格。
语气富有洞察力、善于捕捉微妙变化,像心理学家一样理解市场参与者。
""",
},
"valuation": {
"ROLE.md": """# Role: Valuation Analyst
## 职责
评估公司内在价值,计算合理价格区间,识别高估/低估机会。
## 分析维度
- DCF现金流折现模型
- 相对估值法P/E、EV/EBITDA等
- 资产重估法
- 分部估值SOTP
- 安全边际计算
## 输出格式
- 内在价值估算
- 合理价格区间
- 当前价格vs内在价值高估/低估百分比)
- 估值假设和敏感性分析
""",
"SOUL.md": """# Soul
你是精确的估值分析师,追求计算内在价值的准确区间。
你像精算师一样严谨,注重假设的合理性和安全边际。
语气精确、注重数字,善于发现市场定价错误带来的机会。
""",
},
"portfolio": {
"ROLE.md": """# Role: Portfolio Manager
## 职责
统筹各分析师意见,制定投资决策,管理投资组合配置。
## 分析维度
- 资产配置策略(股债比例、行业分布)
- 风险收益平衡
- 仓位管理(建仓/加仓/减仓/清仓)
- 再平衡时机
- 组合相关性分析
## 输出格式
- 投资决策(买入/卖出/持有)
- 建议仓位比例
- 目标价位
- 止损止盈设置
- 组合调整建议
""",
"SOUL.md": """# Soul
你是睿智的投资组合经理,像将军一样统筹全局。
你善于权衡各方意见,做出果断而理性的投资决策。
语气权威、决策果断,对组合整体表现负有最终责任。
""",
},
"risk": {
"ROLE.md": """# Role: Risk Manager
## 职责
识别、评估和监控投资风险,确保组合风险在可控范围内。
## 分析维度
- 市场风险Beta、波动率
- 信用风险
- 流动性风险
- 集中度风险
- 尾部风险VaR、CVaR
- 压力测试
## 输出格式
- 风险等级(低/中/高/极高)
- 风险敞口分析
- 风险调整建议
- 预警阈值设置
- 应急预案
""",
"SOUL.md": """# Soul
你是谨慎的风险管理者,时刻警惕潜在的损失。
你像守门员一样守护组合安全,宁可错过机会也不冒无法承受的风险。
语气保守、风险意识强,善于发现隐藏的威胁和脆弱性。
""",
},
}
def get_base_template(filename: str) -> str | None:
"""获取基础模板内容"""
return BASE_TEMPLATES.get(filename)
def get_role_template(role_type: str, filename: str) -> str | None:
"""获取角色专用模板内容"""
role = ROLE_TEMPLATES.get(role_type)
if role:
return role.get(filename)
return None
def get_all_role_types() -> list[str]:
"""获取所有角色类型列表"""
return list(ROLE_TEMPLATES.keys())
def render_agent_yaml(agent_id: str, agent_type: str, name: str) -> str:
"""渲染agent.yaml模板"""
return BASE_TEMPLATES["agent.yaml"].format(
agent_id=agent_id,
agent_type=agent_type,
name=name
)

View File

@@ -41,6 +41,16 @@ class RunWorkspaceManager:
"tickers:\n"
" - AAPL\n"
" - MSFT\n"
" - GOOGL\n"
" - AMZN\n"
" - NVDA\n"
" - META\n"
" - TSLA\n"
" - AMD\n"
" - NFLX\n"
" - AVGO\n"
" - PLTR\n"
" - COIN\n"
"initial_cash: 100000\n"
"margin_requirement: 0.0\n"
"enable_memory: false\n"
@@ -63,9 +73,8 @@ class RunWorkspaceManager:
self,
config_name: str,
agent_id: str,
role_seed: str = "",
style_seed: str = "",
policy_seed: str = "",
file_contents: Optional[Dict[str, str]] = None,
persona: Optional[Dict[str, object]] = None,
) -> Path:
asset_dir = self.skills_manager.get_agent_asset_dir(
config_name,
@@ -77,58 +86,55 @@ class RunWorkspaceManager:
(asset_dir / "skills" / "disabled").mkdir(parents=True, exist_ok=True)
(asset_dir / "skills" / "local").mkdir(parents=True, exist_ok=True)
self._ensure_file(
asset_dir / "ROLE.md",
"# Role\n\n"
"Optional run-scoped role override.\n\n"
f"{role_seed}".strip()
+ "\n",
)
self._ensure_file(
asset_dir / "STYLE.md",
"# Style\n\n"
"Optional run-scoped communication or reasoning style.\n\n"
f"{style_seed}".strip()
+ "\n",
)
self._ensure_file(
asset_dir / "POLICY.md",
"# Policy\n\n"
"Optional run-scoped constraints, limits, or strategy policy.\n\n"
f"{policy_seed}".strip()
+ "\n",
)
self._ensure_file(
asset_dir / "SOUL.md",
"# Soul\n\n"
"Describe the agent's temperament, reasoning posture, and voice.\n\n",
)
self._ensure_file(
asset_dir / "PROFILE.md",
"# Profile\n\n"
"Track this agent's long-lived investment style, preferences, and strengths.\n\n",
)
self._ensure_file(
asset_dir / "AGENTS.md",
"# Agent Guide\n\n"
"Document how this agent should work, collaborate, and choose tools or skills.\n\n",
)
self._ensure_file(
asset_dir / "MEMORY.md",
"# Memory\n\n"
"Store durable lessons, heuristics, and reminders for this agent.\n\n",
)
self._ensure_file(
asset_dir / "HEARTBEAT.md",
"# Heartbeat\n\n"
"Optional checklist for periodic review or self-reflection.\n\n",
)
file_contents = file_contents or self.build_default_agent_files(agent_id=agent_id)
for filename, content in file_contents.items():
legacy_contents = self.build_legacy_agent_file_variants(
agent_id=agent_id,
filename=filename,
persona=persona,
)
self._ensure_file(asset_dir / filename, content, legacy_contents=legacy_contents)
self._ensure_agent_yaml(
asset_dir / "agent.yaml",
agent_id=agent_id,
)
return asset_dir
def build_default_agent_files(
self,
*,
agent_id: str,
persona: Optional[Dict[str, object]] = None,
) -> Dict[str, str]:
"""Build default workspace markdown files for one agent."""
if agent_id.endswith("_analyst"):
return self._build_analyst_files(agent_id=agent_id, persona=persona or {})
if agent_id == "portfolio_manager":
return self._build_portfolio_manager_files()
if agent_id == "risk_manager":
return self._build_risk_manager_files()
return self._build_generic_files(agent_id=agent_id)
def build_legacy_agent_file_variants(
self,
*,
agent_id: str,
filename: str,
persona: Optional[Dict[str, object]] = None,
) -> list[str]:
"""Return known generated legacy variants safe to upgrade in-place."""
persona = persona or {}
variants: list[dict[str, str]] = [
self._build_legacy_english_files(agent_id=agent_id),
self._build_previous_chinese_files(agent_id=agent_id, persona=persona),
]
values: list[str] = []
for item in variants:
content = item.get(filename)
if content:
values.append(content)
return values
def load_agent_file(
self,
*,
@@ -137,7 +143,7 @@ class RunWorkspaceManager:
filename: str,
) -> str:
"""Load one run-scoped agent workspace file."""
path = self.get_agent_asset_dir(config_name, agent_id) / filename
path = self.skills_manager.get_agent_asset_dir(config_name, agent_id) / filename
if not path.exists():
raise FileNotFoundError(f"File not found: {filename}")
return path.read_text(encoding="utf-8")
@@ -151,7 +157,7 @@ class RunWorkspaceManager:
content: str,
) -> None:
"""Write one run-scoped agent workspace file."""
asset_dir = self.get_agent_asset_dir(config_name, agent_id)
asset_dir = self.skills_manager.get_agent_asset_dir(config_name, agent_id)
asset_dir.mkdir(parents=True, exist_ok=True)
path = asset_dir / filename
path.write_text(content, encoding="utf-8")
@@ -168,49 +174,285 @@ class RunWorkspaceManager:
for agent_id in agent_ids:
if agent_id.endswith("_analyst"):
persona = analyst_personas.get(agent_id, {})
role_seed = persona.get("description", "").strip()
focus_items = persona.get("focus", [])
style_seed = "\n".join(f"- {item}" for item in focus_items)
policy_seed = (
"State a clear signal, confidence, and the conditions that would invalidate the thesis."
)
elif agent_id == "portfolio_manager":
role_seed = (
"Synthesize analyst and risk inputs into explicit portfolio decisions."
)
style_seed = (
"Be concise, capital-aware, and explicit about sizing rationale."
)
policy_seed = (
"Respect cash, margin, and portfolio concentration constraints before recording decisions."
)
elif agent_id == "risk_manager":
role_seed = (
"Quantify concentration, leverage, liquidity, and volatility risk before trade execution."
)
style_seed = (
"Prioritize the highest-severity risk first and state concrete limits."
)
policy_seed = (
"Use available risk tools before issuing the final risk memo."
file_contents = self.build_default_agent_files(
agent_id=agent_id,
persona=persona,
)
else:
role_seed = ""
style_seed = ""
policy_seed = ""
self.ensure_agent_assets(
config_name=config_name,
agent_id=agent_id,
role_seed=role_seed,
style_seed=style_seed,
policy_seed=policy_seed,
)
persona = None
file_contents = self.build_default_agent_files(agent_id=agent_id)
asset_dir = self.skills_manager.get_agent_asset_dir(config_name, agent_id)
asset_dir.mkdir(parents=True, exist_ok=True)
(asset_dir / "skills" / "installed").mkdir(parents=True, exist_ok=True)
(asset_dir / "skills" / "active").mkdir(parents=True, exist_ok=True)
(asset_dir / "skills" / "disabled").mkdir(parents=True, exist_ok=True)
(asset_dir / "skills" / "local").mkdir(parents=True, exist_ok=True)
for filename, content in file_contents.items():
self._ensure_file(
asset_dir / filename,
content,
legacy_contents=self.build_legacy_agent_file_variants(
agent_id=agent_id,
filename=filename,
persona=persona,
),
)
self._ensure_agent_yaml(asset_dir / "agent.yaml", agent_id=agent_id)
@staticmethod
def _ensure_file(path: Path, content: str) -> None:
def _ensure_file(path: Path, content: str, *, legacy_contents: Optional[list[str]] = None) -> None:
if not path.exists():
path.write_text(content, encoding="utf-8")
return
existing = path.read_text(encoding="utf-8")
normalized_existing = existing.strip()
candidates = {item.strip() for item in (legacy_contents or []) if item and item.strip()}
if normalized_existing in candidates:
path.write_text(content, encoding="utf-8")
@staticmethod
def _build_generic_files(agent_id: str) -> Dict[str, str]:
return {
"SOUL.md": (
"# Soul\n\n"
f"你是 `{agent_id}`,语气冷静、客观、专业。保持清晰推理,优先基于数据而不是情绪下结论。\n"
),
"PROFILE.md": (
"# Profile\n\n"
"记录这个 agent 长期稳定的分析风格、偏好、优势与盲点。\n"
),
"AGENTS.md": (
"# Agent Guide\n\n"
"工作要求:\n"
"- 优先使用已激活的技能和工具\n"
"- 结论要明确,过程要可追溯\n"
"- 与其他 agent 协作时保持输入输出简洁\n"
"- 最终输出必须使用简体中文;如需引用英文术语,仅保留专有名词,解释和结论必须用中文\n"
),
"POLICY.md": (
"# Policy\n\n"
"- 给出结论时说明核心驱动因素\n"
"- 明确风险边界和结论失效条件\n"
"- 出现反例时需要纳入最终判断\n"
"- 不要输出英文报告标题、英文摘要或整段英文正文\n"
),
"MEMORY.md": (
"# Memory\n\n"
"记录可复用的经验、失误复盘、有效启发式和需要持续跟踪的提醒。\n"
),
}
@classmethod
def _build_analyst_files(cls, *, agent_id: str, persona: Dict[str, object]) -> Dict[str, str]:
role_name = str(persona.get("name") or agent_id)
focus_items = [
str(item).strip()
for item in persona.get("focus", [])
if str(item).strip()
]
focus_md = "\n".join(f"- {item}" for item in focus_items) or "- 根据当前任务选择最相关的分析维度"
description = str(persona.get("description") or "").strip()
files = cls._build_generic_files(agent_id)
files["SOUL.md"] = (
"# Soul\n\n"
f"你是一位专业的{role_name}\n\n"
"保持谦逊和开放,主动寻找与自己观点相悖的证据,并将其纳入最终评估。"
"你的分析要体现持续演化的投资哲学,而不是一次性的结论。\n"
)
files["PROFILE.md"] = (
"# Profile\n\n"
f"角色定位:{role_name}\n\n"
"你的关注重点:\n"
f"{focus_md}\n\n"
"角色说明:\n"
f"{description or '围绕最关键的基本面、技术面、情绪面或估值因素形成高质量判断。'}\n"
)
files["AGENTS.md"] = (
"# Agent Guide\n\n"
"分析流程:\n"
"- 优先识别真正驱动价值或价格变化的核心变量\n"
"- 使用相关工具和技能补足证据链\n"
"- 给出可验证、可复查、可执行的分析结果\n"
"- 在团队讨论中清晰表达你的论点和反论点\n\n"
"输出要求:\n"
"- 给出明确投资信号:看涨、看跌或中性\n"
"- 包含置信度0-100\n"
"- 如果你确定要分享最终分析,请先给出结论,再给出推理依据\n"
"- 最终输出必须使用简体中文,不要生成英文版 analysis report\n"
)
files["POLICY.md"] = (
"# Policy\n\n"
"- 深化你的投资逻辑,确保每项建议都有清晰、可追溯、可重复的依据\n"
"- 明确风险边界:在什么具体情况下当前结论会失效\n"
"- 做逆向测试:说明市场主流共识与你的不同点\n"
"- 每次分析后反思这次案例如何验证或挑战你现有的信念\n"
"- 即使输入新闻或财报原文是英文,最终表达也必须用中文\n"
)
return files
@classmethod
def _build_portfolio_manager_files(cls) -> Dict[str, str]:
files = cls._build_generic_files("portfolio_manager")
files["SOUL.md"] = (
"# Soul\n\n"
"你是一位负责做出投资决策的投资组合经理。你需要综合多个分析视角,"
"做出保守、明确、资本约束下可执行的组合决策。\n"
)
files["PROFILE.md"] = (
"# Profile\n\n"
"核心职责:\n"
"- 分析分析师和风险管理经理的输入\n"
"- 基于信号和市场情境做出投资决策\n"
"- 使用可用工具记录每个 ticker 的决策\n"
)
files["AGENTS.md"] = (
"# Agent Guide\n\n"
"决策框架:\n"
"- 审阅分析以理解市场观点\n"
"- 在做决策前先考虑风险警告\n"
"- 评估当前投资组合持仓、现金与保证金占用\n"
"- 决策必须与整体投资目标和风险约束一致\n\n"
"决策类型:\n"
'- `long`:看涨,建议买入\n'
'- `short`:看跌,建议卖出或做空\n'
'- `hold`:中性,维持当前持仓\n\n'
"输出要求:\n"
"- 使用 `make_decision` 工具记录每个股票的最终决策\n"
"- 记录完成后给出投资逻辑总结\n"
"- 最终总结必须使用简体中文\n"
)
files["POLICY.md"] = (
"# Policy\n\n"
"- 在决定数量时考虑可用现金,不要超出现金允许范围\n"
"- 考虑做空头寸的保证金要求\n"
"- 仓位规模相对于组合总资产保持保守\n"
"- 始终为决策提供清晰理由\n"
"- 不要输出英文投资报告或英文结论\n"
)
return files
@classmethod
def _build_risk_manager_files(cls) -> Dict[str, str]:
files = cls._build_generic_files("risk_manager")
files["SOUL.md"] = (
"# Soul\n\n"
"你是一位专业的风险管理经理,负责监控投资组合风险并提供风险警告。"
"你的目标不是输出空泛的谨慎,而是给出量化、可执行、可优先级排序的风险意见。\n"
)
files["PROFILE.md"] = (
"# Profile\n\n"
"核心职责:\n"
"- 监控投资组合敞口和集中度风险\n"
"- 评估仓位规模相对于波动性是否合理\n"
"- 评估保证金使用和杠杆水平\n"
"- 识别潜在风险因素并提供警告\n"
"- 基于市场条件建议仓位限制\n"
)
files["AGENTS.md"] = (
"# Agent Guide\n\n"
"决策流程:\n"
"- 优先使用可用的风险工具量化集中度、波动率和保证金压力\n"
"- 结合工具结果与当前市场上下文做判断\n"
"- 生成可操作的风险警告和仓位限制建议\n"
"- 为风险评估提供清晰理由\n\n"
"输出要求:\n"
"- 风险评估要简洁但全面\n"
"- 按严重程度优先排序警告\n"
"- 提供具体、可操作的建议\n"
"- 尽可能包含量化指标\n"
"- 最终风险结论必须使用简体中文\n"
)
files["POLICY.md"] = (
"# Policy\n\n"
"- 先量化,再判断,不要只给抽象风险表述\n"
"- 高严重度风险必须先说\n"
"- 最终结论需要明确仓位限制或调整建议\n"
"- 不要输出英文风险报告或英文摘要\n"
)
return files
@staticmethod
def _build_legacy_english_files(agent_id: str) -> Dict[str, str]:
policy_tail = "Optional run-scoped constraints, limits, or strategy policy.\n\n"
if agent_id == "portfolio_manager":
policy_tail += "Respect cash, margin, and portfolio concentration constraints before recording decisions.\n"
elif agent_id == "risk_manager":
policy_tail += "Use available risk tools before issuing the final risk memo.\n"
elif agent_id.endswith("_analyst"):
policy_tail += "State a clear signal, confidence, and the conditions that would invalidate the thesis.\n"
return {
"SOUL.md": "# Soul\n\nDescribe the agent's temperament, reasoning posture, and voice.\n\n",
"PROFILE.md": "# Profile\n\nTrack this agent's long-lived investment style, preferences, and strengths.\n\n",
"AGENTS.md": "# Agent Guide\n\nDocument how this agent should work, collaborate, and choose tools or skills.\n\n",
"POLICY.md": "# Policy\n\n" + policy_tail,
"MEMORY.md": "# Memory\n\nStore durable lessons, heuristics, and reminders for this agent.\n\n",
}
@classmethod
def _build_previous_chinese_files(cls, *, agent_id: str, persona: Dict[str, object]) -> Dict[str, str]:
if agent_id.endswith("_analyst"):
role_name = str(persona.get("name") or agent_id)
focus_items = [
str(item).strip()
for item in persona.get("focus", [])
if str(item).strip()
]
focus_md = "\n".join(f"- {item}" for item in focus_items) or "- 根据当前任务选择最相关的分析维度"
description = str(persona.get("description") or "").strip()
return {
"SOUL.md": (
"# Soul\n\n"
f"你是一位专业的{role_name}\n\n"
"保持谦逊和开放,主动寻找与自己观点相悖的证据,并将其纳入最终评估。"
"你的分析要体现持续演化的投资哲学,而不是一次性的结论。\n"
),
"PROFILE.md": (
"# Profile\n\n"
f"角色定位:{role_name}\n\n"
"你的关注重点:\n"
f"{focus_md}\n\n"
"角色说明:\n"
f"{description or '围绕最关键的基本面、技术面、情绪面或估值因素形成高质量判断。'}\n"
),
"AGENTS.md": (
"# Agent Guide\n\n"
"分析流程:\n"
"- 优先识别真正驱动价值或价格变化的核心变量\n"
"- 使用相关工具和技能补足证据链\n"
"- 给出可验证、可复查、可执行的分析结果\n"
"- 在团队讨论中清晰表达你的论点和反论点\n\n"
"输出要求:\n"
"- 给出明确投资信号:看涨、看跌或中性\n"
"- 包含置信度0-100\n"
"- 如果你确定要分享最终分析,请先给出结论,再给出推理依据\n"
),
"POLICY.md": (
"# Policy\n\n"
"- 深化你的投资逻辑,确保每项建议都有清晰、可追溯、可重复的依据\n"
"- 明确风险边界:在什么具体情况下当前结论会失效\n"
"- 做逆向测试:说明市场主流共识与你的不同点\n"
"- 每次分析后反思这次案例如何验证或挑战你现有的信念\n"
),
"MEMORY.md": "# Memory\n\n记录可复用的经验、失误复盘、有效启发式和需要持续跟踪的提醒。\n",
}
if agent_id == "portfolio_manager":
return {
"SOUL.md": "# Soul\n\n你是一位负责做出投资决策的投资组合经理。你需要综合多个分析视角,做出保守、明确、资本约束下可执行的组合决策。\n",
"PROFILE.md": "# Profile\n\n核心职责:\n- 分析分析师和风险管理经理的输入\n- 基于信号和市场情境做出投资决策\n- 使用可用工具记录每个 ticker 的决策\n",
"AGENTS.md": "# Agent Guide\n\n决策框架:\n- 审阅分析以理解市场观点\n- 在做决策前先考虑风险警告\n- 评估当前投资组合持仓、现金与保证金占用\n- 决策必须与整体投资目标和风险约束一致\n\n决策类型:\n- `long`:看涨,建议买入\n- `short`:看跌,建议卖出或做空\n- `hold`:中性,维持当前持仓\n\n输出要求:\n- 使用 `make_decision` 工具记录每个股票的最终决策\n- 记录完成后给出投资逻辑总结\n",
"POLICY.md": "# Policy\n\n- 在决定数量时考虑可用现金,不要超出现金允许范围\n- 考虑做空头寸的保证金要求\n- 仓位规模相对于组合总资产保持保守\n- 始终为决策提供清晰理由\n",
"MEMORY.md": "# Memory\n\n记录可复用的经验、失误复盘、有效启发式和需要持续跟踪的提醒。\n",
}
if agent_id == "risk_manager":
return {
"SOUL.md": "# Soul\n\n你是一位专业的风险管理经理,负责监控投资组合风险并提供风险警告。你的目标不是输出空泛的谨慎,而是给出量化、可执行、可优先级排序的风险意见。\n",
"PROFILE.md": "# Profile\n\n核心职责:\n- 监控投资组合敞口和集中度风险\n- 评估仓位规模相对于波动性是否合理\n- 评估保证金使用和杠杆水平\n- 识别潜在风险因素并提供警告\n- 基于市场条件建议仓位限制\n",
"AGENTS.md": "# Agent Guide\n\n决策流程:\n- 优先使用可用的风险工具量化集中度、波动率和保证金压力\n- 结合工具结果与当前市场上下文做判断\n- 生成可操作的风险警告和仓位限制建议\n- 为风险评估提供清晰理由\n\n输出要求:\n- 风险评估要简洁但全面\n- 按严重程度优先排序警告\n- 提供具体、可操作的建议\n- 尽可能包含量化指标\n",
"POLICY.md": "# Policy\n\n- 先量化,再判断,不要只给抽象风险表述\n- 高严重度风险必须先说\n- 最终结论需要明确仓位限制或调整建议\n",
"MEMORY.md": "# Memory\n\n记录可复用的经验、失误复盘、有效启发式和需要持续跟踪的提醒。\n",
}
return cls._build_legacy_english_files(agent_id)
@staticmethod
def _ensure_agent_yaml(path: Path, agent_id: str) -> None:

View File

@@ -11,11 +11,13 @@ Provides REST API endpoints for:
from .agents import router as agents_router
from .workspaces import router as workspaces_router
from .guard import router as guard_router
from .openclaw import router as openclaw_router
from .runtime import router as runtime_router
__all__ = [
"agents_router",
"workspaces_router",
"guard_router",
"openclaw_router",
"runtime_router",
]

View File

@@ -661,7 +661,7 @@ async def get_agent_file(
Args:
workspace_id: Workspace identifier
agent_id: Agent identifier
filename: File to read (e.g., SOUL.md, ROLE.md)
filename: File to read (e.g., SOUL.md, PROFILE.md)
Returns:
File content

839
backend/api/openclaw.py Normal file
View File

@@ -0,0 +1,839 @@
# -*- coding: utf-8 -*-
"""Read-only OpenClaw CLI API routes — typed with Pydantic models."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService
from shared.models.openclaw import OpenClawStatus
router = APIRouter(prefix="/api/openclaw", tags=["openclaw"])
def get_openclaw_cli_service() -> OpenClawCliService:
"""Build the OpenClaw CLI service dependency."""
return OpenClawCliService()
def _raise_cli_http_error(exc: OpenClawCliError) -> None:
detail = {
"message": str(exc),
"command": exc.command,
"exit_code": exc.exit_code,
"stdout": exc.stdout,
"stderr": exc.stderr,
}
status_code = 503 if exc.exit_code is None else 502
raise HTTPException(status_code=status_code, detail=detail) from exc
# ---------------------------------------------------------------------------
# Response wrappers
# ---------------------------------------------------------------------------
class StatusResponse(BaseModel):
status: object
class SessionsResponse(BaseModel):
sessions: list[object]
class SessionDetailResponse(BaseModel):
session: object | None
class SessionHistoryResponse(BaseModel):
session_key: str
session_id: str | None
events: list[object]
history: list[object]
raw_text: str | None
class CronResponse(BaseModel):
cron: list[object]
jobs: list[object]
class ApprovalsResponse(BaseModel):
approvals: list[object]
pending: list[object]
class AgentsResponse(BaseModel):
agents: list[object]
class SkillsResponse(BaseModel):
workspace_dir: str
managed_skills_dir: str
skills: list[object]
class ModelsResponse(BaseModel):
models: list[object]
class HooksResponse(BaseModel):
workspace_dir: str
managed_hooks_dir: str
hooks: list[object]
class PluginsResponse(BaseModel):
workspace_dir: str
plugins: list[object]
diagnostics: list[object]
class SecretsAuditResponse(BaseModel):
version: int
status: str
findings: list[object]
class SecurityAuditResponse2(BaseModel):
report: object | None
secret_diagnostics: list[str]
class DaemonStatusResponse(BaseModel):
service: object | None
port: object | None
rpc: object | None
health: object | None
class PairingListResponse2(BaseModel):
channel: str
requests: list[object]
class QrCodeResponse2(BaseModel):
setup_code: str
gateway_url: str
auth: str
url_source: str
class UpdateStatusResponse2(BaseModel):
update: object | None
channel: object | None
class ModelAliasesResponse(BaseModel):
aliases: dict[str, str]
class ModelFallbacksResponse(BaseModel):
key: str
label: str
items: list[object]
class SkillUpdateResponse(BaseModel):
ok: bool
slug: str
version: str
error: str | None
class ModelsStatusResponse(BaseModel):
configPath: str | None = None
agentId: str | None = None
agentDir: str | None = None
defaultModel: str | None = None
resolvedDefault: str | None = None
fallbacks: list[str] = Field(default_factory=list)
imageModel: str | None = None
imageFallbacks: list[str] = Field(default_factory=list)
aliases: dict[str, str] = Field(default_factory=dict)
allowed: list[str] = Field(default_factory=list)
auth: dict[str, Any] = Field(default_factory=dict)
class ChannelsStatusResponse(BaseModel):
reachable: bool | None = None
channelAccounts: dict[str, Any] = Field(default_factory=dict)
channels: list[str] = Field(default_factory=list)
issues: list[dict[str, Any]] = Field(default_factory=list)
class ChannelsListResponse(BaseModel):
chat: dict[str, list[str]] = Field(default_factory=dict)
auth: list[dict[str, Any]] = Field(default_factory=list)
usage: dict[str, Any] | None = None
class HookInfoResponse(BaseModel):
name: str | None = None
description: str | None = None
source: str | None = None
pluginId: str | None = None
filePath: str | None = None
handlerPath: str | None = None
hookKey: str | None = None
emoji: str | None = None
homepage: str | None = None
events: list[str] = Field(default_factory=list)
enabledByConfig: bool | None = None
loadable: bool | None = None
requirementsSatisfied: bool | None = None
requirements: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
raw: str | None = None
class HooksCheckResponse(BaseModel):
workspace_dir: str = ""
managed_hooks_dir: str = ""
hooks: list[dict[str, Any]] = Field(default_factory=list)
eligible: bool | None = None
verbose: bool | None = None
class PluginInspectEntry(BaseModel):
plugin: dict[str, Any] = Field(default_factory=dict)
shape: str | None = None
capabilityMode: str | None = None
capabilityCount: int = 0
capabilities: list[dict[str, Any]] = Field(default_factory=list)
typedHooks: list[dict[str, Any]] = Field(default_factory=list)
customHooks: list[dict[str, Any]] = Field(default_factory=list)
tools: list[dict[str, Any]] = Field(default_factory=list)
commands: list[str] = Field(default_factory=list)
cliCommands: list[str] = Field(default_factory=list)
services: list[str] = Field(default_factory=list)
gatewayMethods: list[str] = Field(default_factory=list)
mcpServers: list[dict[str, Any]] = Field(default_factory=list)
lspServers: list[dict[str, Any]] = Field(default_factory=list)
httpRouteCount: int = 0
bundleCapabilities: list[str] = Field(default_factory=list)
class PluginsInspectResponse(BaseModel):
inspect: list[dict[str, Any]] = Field(default_factory=list)
class AgentBindingItem(BaseModel):
agentId: str
match: dict[str, Any]
description: str
class AgentsBindingsResponse(BaseModel):
bindings: list[AgentBindingItem]
# ---------------------------------------------------------------------------
# Routes — use typed model methods and return Pydantic models directly
# ---------------------------------------------------------------------------
@router.get("/status")
async def api_openclaw_status(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> OpenClawStatus:
"""Read `openclaw status --json` and return a typed model."""
try:
return service.status_model()
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/sessions")
async def api_openclaw_sessions(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SessionsResponse:
"""Read `openclaw sessions --json` and return a typed SessionsList."""
try:
result = service.list_sessions_model()
return SessionsResponse(sessions=result.sessions)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/sessions/{session_key:path}/history")
async def api_openclaw_session_history(
session_key: str,
limit: int = Query(20, ge=1, le=200),
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SessionHistoryResponse:
"""Read session history and return a typed SessionHistory."""
try:
result = service.get_session_history_model(session_key, limit=limit)
return SessionHistoryResponse(
session_key=result.session_key,
session_id=result.session_id,
events=result.events,
history=result.events, # alias for compat
raw_text=result.raw_text,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/sessions/{session_key:path}")
async def api_openclaw_session_detail(
session_key: str,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SessionDetailResponse:
"""Resolve a single session and return it as a typed model."""
try:
session = service.get_session_model(session_key)
return SessionDetailResponse(session=session)
except KeyError as exc:
raise HTTPException(status_code=404, detail=f"session '{session_key}' not found") from exc
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/cron")
async def api_openclaw_cron(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> CronResponse:
"""Read `openclaw cron list --json` and return a typed CronList."""
try:
result = service.list_cron_jobs_model()
return CronResponse(cron=list(result.cron), jobs=list(result.jobs))
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/approvals")
async def api_openclaw_approvals(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ApprovalsResponse:
"""Read `openclaw approvals get --json` and return a typed ApprovalsList."""
try:
result = service.list_approvals_model()
return ApprovalsResponse(
approvals=list(result.approvals),
pending=list(result.pending),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/agents")
async def api_openclaw_agents(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentsResponse:
"""Read `openclaw agents list --json` and return a typed AgentsList."""
try:
result = service.list_agents_model()
return AgentsResponse(agents=list(result.agents))
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/agents/presence")
async def api_openclaw_agents_presence(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> dict[str, Any]:
"""Read runtime session presence for all agents from session files."""
result = service.agents_presence()
return result
# ---------------------------------------------------------------------------
# Write agents routes
# ---------------------------------------------------------------------------
class AgentAddResponse(BaseModel):
agentId: str
name: str
workspace: str
agentDir: str
model: str | None = None
bindings: dict[str, Any] = Field(default_factory=dict)
class AgentDeleteResponse(BaseModel):
agentId: str
workspace: str
agentDir: str
sessionsDir: str
removedBindings: list[str] = Field(default_factory=list)
removedAllow: list[str] = Field(default_factory=list)
class AgentBindResponse(BaseModel):
agentId: str
added: list[str] = Field(default_factory=list)
updated: list[str] = Field(default_factory=list)
skipped: list[str] = Field(default_factory=list)
conflicts: list[str] = Field(default_factory=list)
class AgentUnbindResponse(BaseModel):
agentId: str
removed: list[str] = Field(default_factory=list)
missing: list[str] = Field(default_factory=list)
conflicts: list[str] = Field(default_factory=list)
class AgentIdentityResponse(BaseModel):
agentId: str
identity: dict[str, Any] = Field(default_factory=dict)
workspace: str | None = None
identityFile: str | None = None
@router.post("/agents/add")
async def api_openclaw_agents_add(
name: str,
*,
workspace: str | None = None,
model: str | None = None,
agent_dir: str | None = None,
bind: list[str] | None = None,
non_interactive: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentAddResponse:
"""Run `openclaw agents add <name>` and return JSON result."""
try:
result = service.agents_add(
name,
workspace=workspace,
model=model,
agent_dir=agent_dir,
bind=bind,
non_interactive=non_interactive,
)
return AgentAddResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.post("/agents/delete/{id}")
async def api_openclaw_agents_delete(
id: str,
force: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentDeleteResponse:
"""Run `openclaw agents delete <id> [--force]` and return JSON result."""
try:
result = service.agents_delete(id, force=force)
return AgentDeleteResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.post("/agents/bind")
async def api_openclaw_agents_bind(
*,
agent: str | None = None,
bind: list[str] | None = None,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentBindResponse:
"""Run `openclaw agents bind [--agent <id>] [--bind <spec>]` and return JSON result."""
try:
result = service.agents_bind(agent=agent, bind=bind)
return AgentBindResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.post("/agents/unbind")
async def api_openclaw_agents_unbind(
*,
agent: str | None = None,
bind: list[str] | None = None,
all: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentUnbindResponse:
"""Run `openclaw agents unbind [--agent <id>] [--bind <spec>] [--all]` and return JSON result."""
try:
result = service.agents_unbind(agent=agent, bind=bind, all=all)
return AgentUnbindResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.post("/agents/set-identity")
async def api_openclaw_agents_set_identity(
*,
agent: str | None = None,
workspace: str | None = None,
identity_file: str | None = None,
name: str | None = None,
emoji: str | None = None,
theme: str | None = None,
avatar: str | None = None,
from_identity: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentIdentityResponse:
"""Run `openclaw agents set-identity` and return JSON result."""
try:
result = service.agents_set_identity(
agent=agent,
workspace=workspace,
identity_file=identity_file,
name=name,
emoji=emoji,
theme=theme,
avatar=avatar,
from_identity=from_identity,
)
return AgentIdentityResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/skills")
async def api_openclaw_skills(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SkillsResponse:
"""Read `openclaw skills list --json` and return a typed SkillStatusReport."""
try:
result = service.list_skills_model()
return SkillsResponse(
workspace_dir=result.workspace_dir,
managed_skills_dir=result.managed_skills_dir,
skills=list(result.skills),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models")
async def api_openclaw_models(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelsResponse:
"""Read `openclaw models list --json` and return a typed ModelsList."""
try:
result = service.list_models_model()
return ModelsResponse(models=list(result.models))
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/hooks")
async def api_openclaw_hooks(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> HooksResponse:
try:
result = service.list_hooks_model()
return HooksResponse(
workspace_dir=result.workspace_dir,
managed_hooks_dir=result.managed_hooks_dir,
hooks=list(result.hooks),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/plugins")
async def api_openclaw_plugins(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> PluginsResponse:
try:
result = service.list_plugins_model()
return PluginsResponse(
workspace_dir=result.workspace_dir,
plugins=list(result.plugins),
diagnostics=list(result.diagnostics),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/secrets-audit")
async def api_openclaw_secrets_audit(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SecretsAuditResponse:
try:
result = service.secrets_audit_model()
return SecretsAuditResponse(
version=result.version,
status=result.status,
findings=list(result.findings),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/security-audit")
async def api_openclaw_security_audit(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SecurityAuditResponse2:
try:
result = service.security_audit_model()
return SecurityAuditResponse2(
report=result.report.model_dump() if result.report else None,
secret_diagnostics=list(result.secret_diagnostics),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/daemon-status")
async def api_openclaw_daemon_status(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> DaemonStatusResponse:
try:
result = service.daemon_status_model()
return DaemonStatusResponse(
service=result.service.model_dump() if result.service else None,
port=result.port.model_dump() if result.port else None,
rpc=result.rpc.model_dump() if result.rpc else None,
health=result.health.model_dump() if result.health else None,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/pairing")
async def api_openclaw_pairing(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> PairingListResponse2:
try:
result = service.pairing_list_model()
return PairingListResponse2(
channel=result.channel,
requests=list(result.requests),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/qr")
async def api_openclaw_qr(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> QrCodeResponse2:
try:
result = service.qr_code_model()
return QrCodeResponse2(
setup_code=result.setup_code,
gateway_url=result.gateway_url,
auth=result.auth,
url_source=result.url_source,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/update-status")
async def api_openclaw_update_status(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> UpdateStatusResponse2:
try:
result = service.update_status_model()
return UpdateStatusResponse2(
update=result.update.model_dump() if result.update else None,
channel=result.channel.model_dump() if result.channel else None,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models-aliases")
async def api_openclaw_models_aliases(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelAliasesResponse:
try:
result = service.list_model_aliases_model()
return ModelAliasesResponse(aliases=result.aliases)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models-fallbacks")
async def api_openclaw_models_fallbacks(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelFallbacksResponse:
try:
result = service.list_model_fallbacks_model()
return ModelFallbacksResponse(
key=result.key,
label=result.label,
items=list(result.items),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models-image-fallbacks")
async def api_openclaw_models_image_fallbacks(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelFallbacksResponse:
try:
result = service.list_model_image_fallbacks_model()
return ModelFallbacksResponse(
key=result.key,
label=result.label,
items=list(result.items),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/skill-update")
async def api_openclaw_skill_update(
slug: str | None = None,
all: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SkillUpdateResponse:
try:
result = service.skill_update_model(slug=slug, all=all)
return SkillUpdateResponse(
ok=result.ok,
slug=result.slug,
version=result.version,
error=result.error,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models-status")
async def api_openclaw_models_status(
probe: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelsStatusResponse:
"""Read `openclaw models status --json [--probe]` and return a typed dict."""
try:
result = service.models_status_model(probe=probe)
return ModelsStatusResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/channels-status")
async def api_openclaw_channels_status(
probe: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ChannelsStatusResponse:
"""Read `openclaw channels status --json [--probe]` and return a typed dict."""
try:
result = service.channels_status_model(probe=probe)
return ChannelsStatusResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/channels-list")
async def api_openclaw_channels_list(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ChannelsListResponse:
"""Read `openclaw channels list --json` and return a typed dict."""
try:
result = service.channels_list_model()
return ChannelsListResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/hooks/info/{name}")
async def api_openclaw_hook_info(
name: str,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> HookInfoResponse:
"""Read `openclaw hooks info <name> --json` and return a typed dict."""
try:
result = service.hook_info_model(name)
return HookInfoResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/hooks/check")
async def api_openclaw_hooks_check(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> HooksCheckResponse:
"""Read `openclaw hooks check --json` and return a typed dict."""
try:
result = service.hooks_check_model()
return HooksCheckResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/plugins-inspect")
async def api_openclaw_plugins_inspect(
plugin_id: str | None = None,
all: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> PluginsInspectResponse:
"""Read `openclaw plugins inspect --json [--all]` and return a typed dict."""
try:
result = service.plugins_inspect_model(plugin_id=plugin_id, all=all)
inspect = result if isinstance(result, list) else result.get("inspect", [])
return PluginsInspectResponse(inspect=inspect)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
class AgentBindingItem(BaseModel):
agentId: str
match: dict[str, Any]
description: str
class AgentsBindingsResponse(BaseModel):
bindings: list[AgentBindingItem]
@router.get("/agents-bindings")
async def api_openclaw_agents_bindings(
agent: str | None = None,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentsBindingsResponse:
"""Read `openclaw agents bindings --json [--agent <id>]` and return bindings list."""
try:
result = service.agents_bindings_model(agent=agent)
bindings = result if isinstance(result, list) else []
return AgentsBindingsResponse(bindings=bindings)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/gateway-status")
async def api_openclaw_gateway_status(
url: str | None = None,
token: str | None = None,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> dict[str, Any]:
"""Read `openclaw gateway status --json [--url <url>] [--token <token>]`. Returns full gateway probe result."""
try:
result = service.gateway_status(url=url, token=token)
return result
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/memory-status")
async def api_openclaw_memory_status(
agent: str | None = None,
deep: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> list[dict[str, Any]]:
"""Read `openclaw memory status --json [--agent <id>] [--deep]`. Returns array of per-agent memory status."""
try:
result = service.memory_status(agent=agent, deep=deep)
return result if isinstance(result, list) else []
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
class WorkspaceFilesResponse(BaseModel):
workspace: str
files: list[dict[str, Any]]
error: str | None = None
@router.get("/workspace-files")
async def api_openclaw_workspace_files(
workspace: str = Query(..., description="Path to the agent workspace directory"),
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> WorkspaceFilesResponse:
"""List .md files in an OpenClaw agent workspace with their content previews."""
result = service.list_workspace_files(workspace)
return WorkspaceFilesResponse.model_validate(result, strict=False)

View File

@@ -389,11 +389,21 @@ def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int:
def _is_gateway_running() -> bool:
"""Check if Gateway process is running."""
"""Check if Gateway process is running.
Checks both the internally-managed gateway process and falls back to
port availability (for externally-managed gateway processes).
"""
process = _runtime_state.gateway_process
if process is None:
if process is not None and process.poll() is None:
return True
# Fallback: check if the gateway port is in use (for externally started gateway)
import socket
try:
with socket.create_connection(("127.0.0.1", _runtime_state.gateway_port), timeout=1):
return True
except OSError:
return False
return process.poll() is None
def _stop_gateway() -> bool:

View File

@@ -5,6 +5,8 @@ from .agent_service import app as agent_app
from .agent_service import create_app as create_agent_app
from .news_service import app as news_app
from .news_service import create_app as create_news_app
from .openclaw_service import app as openclaw_app
from .openclaw_service import create_app as create_openclaw_app
from .runtime_service import app as runtime_app
from .runtime_service import create_app as create_runtime_app
from .trading_service import app as trading_app
@@ -21,6 +23,8 @@ __all__ = [
"create_agent_app",
"news_app",
"create_news_app",
"openclaw_app",
"create_openclaw_app",
"runtime_app",
"create_runtime_app",
"trading_app",

View File

@@ -33,17 +33,17 @@ def create_app(project_root: Path | None = None) -> FastAPI:
agent_factory.workspaces_root.mkdir(parents=True, exist_ok=True)
registry = get_registry()
print("EvoTraders API started")
print("大时代 API started")
print(f" - Workspaces root: {agent_factory.workspaces_root}")
print(f" - Registered agents: {registry.get_agent_count()}")
yield
print("EvoTraders API shutting down")
print("大时代 API shutting down")
app = FastAPI(
title="EvoTraders Agent Service",
description="REST API for the EvoTraders multi-agent control plane",
title="大时代 Agent Service",
description="REST API for the 大时代 multi-agent control plane",
version="0.1.0",
lifespan=lifespan,
)

View File

@@ -20,7 +20,7 @@ def get_market_store() -> MarketStore:
def create_app() -> FastAPI:
"""Create the news/explain service app."""
app = FastAPI(
title="EvoTraders News Service",
title="大时代 News Service",
description="Read-only news enrichment and explain service surface extracted from the monolith",
version="0.1.0",
)

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""Read-only OpenClaw CLI FastAPI surface."""
from __future__ import annotations
from fastapi import Depends, FastAPI
from backend.api import openclaw_router
from backend.apps.cors import add_cors_middleware
from backend.api.openclaw import get_openclaw_cli_service
def create_app() -> FastAPI:
"""Create the OpenClaw service app."""
app = FastAPI(
title="大时代 OpenClaw Service",
description="Read-only OpenClaw CLI integration service surface",
version="0.1.0",
)
add_cors_middleware(app)
@app.get("/health")
async def health_check(
service=Depends(get_openclaw_cli_service),
) -> dict[str, object]:
return service.health()
@app.get("/api/status")
async def api_status(
service=Depends(get_openclaw_cli_service),
) -> dict[str, object]:
return {
"status": "operational",
"service": "openclaw-service",
"openclaw": service.health(),
}
app.include_router(openclaw_router)
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8004)

View File

@@ -13,7 +13,7 @@ from backend.apps.cors import add_cors_middleware
def create_app() -> FastAPI:
"""Create the runtime service app."""
app = FastAPI(
title="EvoTraders Runtime Service",
title="大时代 Runtime Service",
description="Runtime lifecycle and gateway service surface extracted from the monolith",
version="0.1.0",
)

View File

@@ -21,7 +21,7 @@ from shared.schema import (
def create_app() -> FastAPI:
"""Create the trading data service app."""
app = FastAPI(
title="EvoTraders Trading Service",
title="大时代 Trading Service",
description="Read-only trading data service surface extracted from the monolith",
version="0.1.0",
)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
EvoTraders CLI - Command-line interface for the EvoTraders trading system.
大时代 CLI - Command-line interface for the 大时代 trading system.
This module provides easy-to-use commands for running backtest, live trading,
and frontend development server.
@@ -44,7 +44,7 @@ from backend.enrich.news_enricher import enrich_symbols
app = typer.Typer(
name="evotraders",
help="EvoTraders: A self-evolving multi-agent trading system",
help="大时代:自进化多智能体交易系统",
add_completion=False,
)
ingest_app = typer.Typer(help="Ingest Polygon market data into the research warehouse.")
@@ -919,7 +919,7 @@ def backtest(
"""
console.print(
Panel.fit(
"[bold cyan]EvoTraders Backtest Mode[/bold cyan]",
"[bold cyan]大时代 Backtest Mode[/bold cyan]",
border_style="cyan",
),
)
@@ -1082,7 +1082,7 @@ def live(
interval_minutes = int(_normalize_typer_value(interval_minutes, 60))
console.print(
Panel.fit(
"[bold cyan]EvoTraders LIVE Mode[/bold cyan]",
"[bold cyan]大时代 LIVE Mode[/bold cyan]",
border_style="cyan",
),
)
@@ -1251,7 +1251,7 @@ def frontend(
"""
console.print(
Panel.fit(
"[bold cyan]EvoTraders Frontend[/bold cyan]",
"[bold cyan]大时代 Frontend[/bold cyan]",
border_style="cyan",
),
)
@@ -1319,16 +1319,16 @@ def frontend(
@app.command()
def version():
"""Show the version of EvoTraders."""
"""Show the version of 大时代."""
console.print(
"\n[bold cyan]EvoTraders[/bold cyan] version [green]0.1.0[/green]\n",
"\n[bold cyan]大时代[/bold cyan] version [green]0.1.0[/green]\n",
)
@app.callback()
def main():
"""
EvoTraders: A self-evolving multi-agent trading system
大时代:自进化多智能体交易系统
Use 'evotraders --help' to see available commands.
"""

View File

@@ -4,6 +4,22 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict
DEFAULT_TICKERS = [
"AAPL",
"MSFT",
"GOOGL",
"AMZN",
"NVDA",
"META",
"TSLA",
"AMD",
"NFLX",
"AVGO",
"PLTR",
"COIN",
]
import re
import yaml
@@ -127,7 +143,7 @@ def resolve_runtime_config(
bootstrap = get_bootstrap_config_for_run(project_root, config_name)
return {
"tickers": bootstrap.get("tickers")
or get_env_list("TICKERS", ["AAPL", "MSFT"]),
or get_env_list("TICKERS", DEFAULT_TICKERS),
"initial_cash": float(
bootstrap.get(
"initial_cash",

View File

@@ -18,7 +18,6 @@ from agentscope.message import Msg
from agentscope.pipeline import MsgHub
from backend.utils.settlement import SettlementCoordinator
from backend.utils.terminal_dashboard import get_dashboard
from backend.core.state_sync import StateSync
from backend.utils.trade_executor import PortfolioTradeExecutor
from backend.runtime.manager import TradingRuntimeManager
@@ -48,13 +47,9 @@ except ImportError:
logger = logging.getLogger(__name__)
def _log(msg: str):
"""Log to dashboard if available, otherwise to logger"""
dashboard = get_dashboard()
if dashboard.live:
dashboard.log(msg)
else:
logger.info(msg)
def _log(msg: str) -> None:
"""Helper function for pipeline logging."""
logger.info(msg)
class TradingPipeline:
@@ -71,7 +66,7 @@ class TradingPipeline:
Real-time updates via StateSync after each agent completes.
Supports both legacy agent lists and new workspace-based agent loading.
Supports both legacy agent lists and run-scoped agent loading.
"""
def __init__(
@@ -1625,14 +1620,13 @@ class TradingPipeline:
project_root = Path(__file__).resolve().parents[2]
personas = get_prompt_loader().load_yaml_config("analyst", "personas")
persona = personas.get(analyst_type, {})
WorkspaceManager(project_root=project_root).ensure_agent_assets(
workspace_manager = WorkspaceManager(project_root=project_root)
workspace_manager.ensure_agent_assets(
config_name=config_name,
agent_id=agent_id,
role_seed=persona.get("description", "").strip(),
style_seed="\n".join(f"- {item}" for item in persona.get("focus", [])),
policy_seed=(
"State a clear signal, confidence, and the conditions "
"that would invalidate the thesis."
file_contents=workspace_manager.build_default_agent_files(
agent_id=agent_id,
persona=persona,
),
)

View File

@@ -232,7 +232,7 @@ async def run_pipeline(
try:
# Extract config values
tickers = bootstrap.get("tickers", ["AAPL", "MSFT"])
tickers = bootstrap.get("tickers", ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AMD", "NFLX", "AVGO", "PLTR", "COIN"])
initial_cash = float(bootstrap.get("initial_cash", 100000.0))
margin_requirement = float(bootstrap.get("margin_requirement", 0.0))
max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2))

View File

@@ -25,6 +25,36 @@ def _default_start(years: int = 2) -> str:
return (datetime.now(timezone.utc).date() - timedelta(days=years * 366)).isoformat()
def _max_news_date(news_rows: Iterable[dict]) -> str | None:
dates = [
str(item.get("published_utc") or "").strip()[:10]
for item in news_rows
if str(item.get("published_utc") or "").strip()
]
dates = [value for value in dates if value]
return max(dates) if dates else None
def _effective_last_news_fetch(
market_store: MarketStore,
*,
ticker: str,
end_date: str,
watermark_value: str | None,
) -> str | None:
"""Clamp stale/future watermarks to the latest actually stored news date."""
raw = str(watermark_value or "").strip()[:10]
if not raw:
return None
if raw <= end_date:
return raw
latest_stored = market_store.get_latest_news_date(ticker)
if latest_stored and latest_stored <= end_date:
return latest_stored
return end_date
def _normalize_provider_news_rows(ticker: str, news_items: Iterable[Any]) -> list[dict]:
rows: list[dict] = []
for item in news_items:
@@ -80,7 +110,11 @@ def ingest_ticker_history(
price_count = market_store.upsert_ohlc(ticker, ohlc_rows, source="polygon")
news_count = market_store.upsert_news(ticker, news_rows, source="polygon")
aligned_count = align_news_for_symbol(market_store, ticker)
market_store.update_fetch_watermark(symbol=ticker, price_date=end, news_date=end)
market_store.update_fetch_watermark(
symbol=ticker,
price_date=end,
news_date=_max_news_date(news_rows),
)
return {
"symbol": ticker,
@@ -108,9 +142,15 @@ def update_ticker_incremental(
if watermarks.get("last_price_fetch")
else _default_start()
)
effective_last_news_fetch = _effective_last_news_fetch(
market_store,
ticker=ticker,
end_date=end,
watermark_value=watermarks.get("last_news_fetch"),
)
start_news = (
(datetime.fromisoformat(watermarks["last_news_fetch"]) + timedelta(days=1)).date().isoformat()
if watermarks.get("last_news_fetch")
(datetime.fromisoformat(effective_last_news_fetch) + timedelta(days=1)).date().isoformat()
if effective_last_news_fetch
else _default_start()
)
@@ -130,7 +170,7 @@ def update_ticker_incremental(
market_store.update_fetch_watermark(
symbol=ticker,
price_date=end if ohlc_rows or watermarks.get("last_price_fetch") else None,
news_date=end if news_rows or watermarks.get("last_news_fetch") else None,
news_date=_max_news_date(news_rows),
)
return {
@@ -155,9 +195,15 @@ def refresh_news_incremental(
market_store = store or MarketStore()
watermarks = market_store.get_ticker_watermarks(ticker)
end = end_date or _today_utc()
effective_last_news_fetch = _effective_last_news_fetch(
market_store,
ticker=ticker,
end_date=end,
watermark_value=watermarks.get("last_news_fetch"),
)
start_news = (
(datetime.fromisoformat(watermarks["last_news_fetch"]) + timedelta(days=1)).date().isoformat()
if watermarks.get("last_news_fetch")
(datetime.fromisoformat(effective_last_news_fetch) + timedelta(days=1)).date().isoformat()
if effective_last_news_fetch
else _default_start()
)
@@ -182,7 +228,7 @@ def refresh_news_incremental(
aligned_count = align_news_for_symbol(market_store, ticker)
market_store.update_fetch_watermark(
symbol=ticker,
news_date=end if news_rows or watermarks.get("last_news_fetch") else None,
news_date=_max_news_date(news_rows),
)
return {

View File

@@ -244,6 +244,20 @@ class MarketStore:
"last_news_fetch": None,
}
def get_latest_news_date(self, symbol: str) -> str | None:
"""Return the latest stored published news date for one ticker."""
with self._connect() as conn:
row = conn.execute(
"""
SELECT MAX(substr(nr.published_utc, 1, 10)) AS latest_date
FROM news_ticker nt
JOIN news_raw nr ON nr.id = nt.news_id
WHERE nt.symbol = ?
""",
(symbol,),
).fetchone()
return str(row["latest_date"]).strip() if row and row["latest_date"] else None
def upsert_ohlc(self, symbol: str, rows: Iterable[dict[str, Any]], *, source: str = "polygon") -> int:
timestamp = _utc_timestamp()
count = 0

View File

@@ -48,7 +48,6 @@ INFO_LOGGER_PREFIXES = (
"backend.core.pipeline",
"backend.core.scheduler",
"backend.services.gateway_cycle_support",
"backend.utils.terminal_dashboard",
)
NOISY_LOGGER_LEVELS = {
@@ -72,14 +71,16 @@ class SuppressNoisyInfoFilter(logging.Filter):
"""Filter out low-signal library INFO logs while keeping warnings/errors."""
def filter(self, record: logging.LogRecord) -> bool:
if record.levelno >= logging.WARNING:
return True
message = record.getMessage()
if record.name == "httpx" and message.startswith("HTTP Request:"):
return False
if record.name.startswith("websockets") and "connection open" in message:
return False
if record.name.startswith("websockets") and "opening handshake failed" in message:
return False
if record.levelno >= logging.WARNING:
return True
return True
@@ -117,7 +118,7 @@ async def run_gateway(
"""Run Gateway with Pipeline."""
# Extract config
tickers = bootstrap.get("tickers", ["AAPL", "MSFT"])
tickers = bootstrap.get("tickers", ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AMD", "NFLX", "AVGO", "PLTR", "COIN"])
initial_cash = float(bootstrap.get("initial_cash", 100000.0))
margin_requirement = float(bootstrap.get("margin_requirement", 0.0))
max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2))

View File

@@ -29,6 +29,7 @@ from backend.runtime.manager import (
set_global_runtime_manager,
clear_global_runtime_manager,
)
from backend.gateway_server import configure_gateway_logging
from backend.services.gateway import Gateway
from backend.services.market import MarketService
from backend.services.storage import StorageService
@@ -38,6 +39,7 @@ load_dotenv()
logger = logging.getLogger(__name__)
loguru.logger.disable("flowllm")
loguru.logger.disable("reme_ai")
configure_gateway_logging(verbose=os.getenv("LOG_LEVEL", "").upper() == "DEBUG")
_prompt_loader = get_prompt_loader()

View File

@@ -16,7 +16,6 @@ from websockets.asyncio.server import ServerConnection
from backend.data.provider_utils import normalize_symbol
from backend.domains import news as news_domain
from backend.llm.models import get_agent_model_info
from backend.utils.terminal_dashboard import get_dashboard
from backend.core.pipeline import TradingPipeline
from backend.core.state_sync import StateSync
from backend.services.market import MarketService
@@ -26,10 +25,12 @@ from backend.tools.technical_signals import StockTechnicalAnalyzer
from backend.core.scheduler import Scheduler
from backend.services import gateway_admin_handlers
from backend.services import gateway_cycle_support
from backend.services import gateway_openclaw_handlers
from backend.services import gateway_runtime_support
from backend.services import gateway_stock_handlers
from shared.client import NewsServiceClient
from shared.client import TradingServiceClient
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient, DEFAULT_GATEWAY_URL as OPENCLAW_WS_URL
logger = logging.getLogger(__name__)
EDITABLE_AGENT_WORKSPACE_FILES = {
@@ -38,9 +39,6 @@ EDITABLE_AGENT_WORKSPACE_FILES = {
"AGENTS.md",
"MEMORY.md",
"POLICY.md",
"HEARTBEAT.md",
"ROLE.md",
"STYLE.md",
}
@@ -82,7 +80,6 @@ class Gateway:
self._manual_cycle_task: Optional[asyncio.Task] = None
self._backtest_start_date: Optional[str] = None
self._backtest_end_date: Optional[str] = None
self._dashboard = get_dashboard()
self._market_status_task: Optional[asyncio.Task] = None
self._watchlist_ingest_task: Optional[asyncio.Task] = None
@@ -92,6 +89,7 @@ class Gateway:
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._project_root = Path(__file__).resolve().parents[2]
self._technical_analyzer = StockTechnicalAnalyzer()
self._openclaw_ws: OpenClawWebSocketClient | None = None
async def start(self, host: str = "0.0.0.0", port: int = 8766):
"""Start gateway server with proper initialization order.
@@ -104,21 +102,6 @@ class Gateway:
self._loop = asyncio.get_running_loop()
self._provider_router.add_listener(self._on_provider_usage_changed)
# Initialize terminal dashboard
self._dashboard.set_config(
mode=self.mode,
config_name=self.config.get("config_name", "default"),
host=host,
port=port,
poll_interval=self.config.get("poll_interval", 10),
tickers=self.config.get("tickers", []),
initial_cash=self.storage.initial_cash,
start_date=self._backtest_start_date or "",
end_date=self._backtest_end_date or "",
data_sources=self._provider_router.get_usage_snapshot(),
)
self._dashboard.start()
self.state_sync.load_state()
self.market_service.set_price_recorder(self.storage.record_price_point)
self.state_sync.update_state("status", "initializing")
@@ -150,16 +133,6 @@ class Gateway:
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self.state_sync.state)
summary = dashboard_snapshot.get("summary")
if summary:
holdings = dashboard_snapshot.get("holdings") or []
trades = dashboard_snapshot.get("trades") or []
current_date = self.state_sync.state.get("current_date")
self._dashboard.update(
date=current_date or "-",
status="running",
portfolio=summary,
holdings=holdings,
trades=trades,
)
logger.info(
"Loaded existing portfolio: $%s",
f"{summary.get('totalAssetValue', 0):,.2f}",
@@ -185,6 +158,20 @@ class Gateway:
# Give a brief moment for any existing clients to reconnect
await asyncio.sleep(0.1)
# Connect to OpenClaw Gateway (18789) via WebSocket
logger.info("Connecting to OpenClaw Gateway...")
try:
self._openclaw_ws = OpenClawWebSocketClient(
url=OPENCLAW_WS_URL,
client_name="gateway-client",
client_version="1.0.0",
)
await self._openclaw_ws.connect()
logger.info("OpenClaw Gateway WebSocket connected")
except Exception as e:
logger.warning("Failed to connect to OpenClaw Gateway: %s", e)
self._openclaw_ws = None
# ======================================================================
# PHASE 2: Start market data service
# Now frontend is connected, start pushing price updates
@@ -235,7 +222,6 @@ class Gateway:
def _on_provider_usage_changed(self, snapshot: Dict[str, Any]):
"""Handle provider routing updates from the shared router."""
self.state_sync.update_state("data_sources", snapshot)
self._dashboard.update(data_sources=snapshot)
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(
self.broadcast(
@@ -434,11 +420,77 @@ class Gateway:
await self._handle_get_stock_technical_indicators(websocket, data)
elif msg_type == "run_stock_enrich":
await self._handle_run_stock_enrich(websocket, data)
elif msg_type == "get_openclaw_status":
await self._handle_get_openclaw_status(websocket, data)
elif msg_type == "get_openclaw_sessions":
await self._handle_get_openclaw_sessions(websocket, data)
elif msg_type == "get_openclaw_session_detail":
await self._handle_get_openclaw_session_detail(websocket, data)
elif msg_type == "get_openclaw_session_history":
await self._handle_get_openclaw_session_history(websocket, data)
elif msg_type == "get_openclaw_cron":
await self._handle_get_openclaw_cron(websocket, data)
elif msg_type == "get_openclaw_approvals":
await self._handle_get_openclaw_approvals(websocket, data)
elif msg_type == "get_openclaw_agents":
await self._handle_get_openclaw_agents(websocket, data)
elif msg_type == "get_openclaw_agents_presence":
await self._handle_get_openclaw_agents_presence(websocket, data)
elif msg_type == "get_openclaw_skills":
await self._handle_get_openclaw_skills(websocket, data)
elif msg_type == "get_openclaw_models":
await self._handle_get_openclaw_models(websocket, data)
elif msg_type == "get_openclaw_hooks":
await gateway_openclaw_handlers.handle_get_openclaw_hooks(self, websocket, data)
elif msg_type == "get_openclaw_plugins":
await gateway_openclaw_handlers.handle_get_openclaw_plugins(self, websocket, data)
elif msg_type == "get_openclaw_secrets_audit":
await gateway_openclaw_handlers.handle_get_openclaw_secrets_audit(self, websocket, data)
elif msg_type == "get_openclaw_security_audit":
await gateway_openclaw_handlers.handle_get_openclaw_security_audit(self, websocket, data)
elif msg_type == "get_openclaw_daemon_status":
await gateway_openclaw_handlers.handle_get_openclaw_daemon_status(self, websocket, data)
elif msg_type == "get_openclaw_pairing":
await gateway_openclaw_handlers.handle_get_openclaw_pairing(self, websocket, data)
elif msg_type == "get_openclaw_qr":
await gateway_openclaw_handlers.handle_get_openclaw_qr(self, websocket, data)
elif msg_type == "get_openclaw_update_status":
await gateway_openclaw_handlers.handle_get_openclaw_update_status(self, websocket, data)
elif msg_type == "get_openclaw_models_aliases":
await gateway_openclaw_handlers.handle_get_openclaw_models_aliases(self, websocket, data)
elif msg_type == "get_openclaw_models_fallbacks":
await gateway_openclaw_handlers.handle_get_openclaw_models_fallbacks(self, websocket, data)
elif msg_type == "get_openclaw_models_image_fallbacks":
await gateway_openclaw_handlers.handle_get_openclaw_models_image_fallbacks(self, websocket, data)
elif msg_type == "get_openclaw_skill_update":
await gateway_openclaw_handlers.handle_get_openclaw_skill_update(self, websocket, data)
elif msg_type == "get_openclaw_workspace_files":
await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data)
elif msg_type == "get_openclaw_workspace_file":
await gateway_openclaw_handlers.handle_get_openclaw_workspace_file(self, websocket, data)
elif msg_type == "openclaw_resolve_session":
await gateway_openclaw_handlers.handle_openclaw_resolve_session(self, websocket, data)
elif msg_type == "openclaw_create_session":
await gateway_openclaw_handlers.handle_openclaw_create_session(self, websocket, data)
elif msg_type == "openclaw_send_message":
await gateway_openclaw_handlers.handle_openclaw_send_message(self, websocket, data)
elif msg_type == "openclaw_subscribe_session":
await gateway_openclaw_handlers.handle_openclaw_subscribe_session(self, websocket, data)
elif msg_type == "openclaw_unsubscribe_session":
await gateway_openclaw_handlers.handle_openclaw_unsubscribe_session(self, websocket, data)
elif msg_type == "openclaw_reset_session":
await gateway_openclaw_handlers.handle_openclaw_reset_session(self, websocket, data)
elif msg_type == "openclaw_delete_session":
await gateway_openclaw_handlers.handle_openclaw_delete_session(self, websocket, data)
except websockets.ConnectionClosed:
pass
except json.JSONDecodeError:
pass
finally:
subscriber_map = getattr(self, "_openclaw_session_subscribers", None)
if isinstance(subscriber_map, dict):
subscriber_map.pop(websocket, None)
async def _handle_get_stock_history(
self,
@@ -669,6 +721,83 @@ class Gateway:
) -> None:
await gateway_admin_handlers.handle_update_agent_workspace_file(self, websocket, data)
async def _handle_get_openclaw_status(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_status(self, websocket, data)
async def _handle_get_openclaw_sessions(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_sessions(self, websocket, data)
async def _handle_get_openclaw_session_detail(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_session_detail(self, websocket, data)
async def _handle_get_openclaw_session_history(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_session_history(self, websocket, data)
async def _handle_get_openclaw_cron(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_cron(self, websocket, data)
async def _handle_get_openclaw_approvals(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_approvals(self, websocket, data)
async def _handle_get_openclaw_agents(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_agents(self, websocket, data)
async def _handle_get_openclaw_agents_presence(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_agents_presence(self, websocket, data)
async def _handle_get_openclaw_skills(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_skills(self, websocket, data)
async def _handle_get_openclaw_models(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_models(self, websocket, data)
async def _handle_get_openclaw_workspace_files(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data)
@staticmethod
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
return gateway_runtime_support.normalize_watchlist(raw_tickers)

View File

@@ -147,25 +147,10 @@ async def on_heartbeat_trigger(gateway: Any, date: str) -> None:
for analyst in analysts:
try:
ws_id = getattr(analyst, "workspace_id", None)
if ws_id:
from backend.agents.workspace_manager import get_workspace_dir
from pathlib import Path
from agentscope.message import Msg
ws_dir = get_workspace_dir(ws_id)
if ws_dir:
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("[Heartbeat] Running heartbeat for %s", analyst.name)
msg = Msg(role="user", content=hb_task, name="system")
await analyst.reply([msg])
logger.info("[Heartbeat] %s heartbeat complete", analyst.name)
continue
logger.debug("[Heartbeat] No HEARTBEAT.md for %s, skipping", analyst.name)
logger.debug(
"[Heartbeat] No heartbeat configured for %s, skipping",
analyst.name,
)
except Exception as exc:
logger.error("[Heartbeat] %s failed: %s", analyst.name, exc, exc_info=True)
@@ -175,7 +160,6 @@ async def run_backtest_cycle(gateway: Any, date: str, tickers: list[str]) -> Non
await gateway.market_service.emit_market_open()
await gateway.state_sync.on_cycle_start(date)
gateway._dashboard.update(date=date, status="Analyzing...")
prices = gateway.market_service.get_open_prices()
close_prices = gateway.market_service.get_close_prices()
@@ -218,7 +202,6 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
logger.warning("Live cycle news refresh failed: %s", exc)
await gateway.state_sync.on_cycle_start(trading_date)
gateway._dashboard.update(date=trading_date, status="Analyzing...")
market_caps = await get_market_caps(gateway, tickers, trading_date)
schedule_mode = gateway.config.get("schedule_mode", "daily")
@@ -263,12 +246,9 @@ async def finalize_cycle(gateway: Any, date: str) -> None:
summary.update(gateway.storage.get_live_returns())
await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary)
holdings = dashboard_snapshot.get("holdings") or []
trades = dashboard_snapshot.get("trades") or []
leaderboard = dashboard_snapshot.get("leaderboard") or []
if leaderboard:
await gateway.state_sync.on_leaderboard_update(leaderboard)
gateway._dashboard.update(date=date, status="Running", portfolio=summary, holdings=holdings, trades=trades)
async def get_market_caps(gateway: Any, tickers: list[str], date: str) -> dict[str, float]:
@@ -329,24 +309,16 @@ def save_cycle_results(
async def run_backtest_dates(gateway: Any, dates: list[str]) -> None:
gateway.state_sync.set_backtest_dates(dates)
gateway._dashboard.update(days_total=len(dates), days_completed=0)
await gateway.state_sync.on_system_message(f"Starting backtest - {len(dates)} trading days")
try:
for i, date in enumerate(dates):
gateway._dashboard.update(days_completed=i)
for date in dates:
await gateway.on_strategy_trigger(date=date)
await asyncio.sleep(0.1)
await gateway.state_sync.on_system_message(f"Backtest complete - {len(dates)} days")
summary = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state).get("summary") or {}
gateway._dashboard.update(status="Complete", portfolio=summary, days_completed=len(dates))
gateway._dashboard.stop()
gateway._dashboard.print_final_summary()
except Exception as exc:
error_msg = f"Backtest failed: {type(exc).__name__}: {str(exc)}"
logger.error(error_msg, exc_info=True)
asyncio.create_task(gateway.state_sync.on_system_message(error_msg))
gateway._dashboard.update(status=f"Failed: {str(exc)}")
gateway._dashboard.stop()
raise
finally:
gateway._backtest_task = None
@@ -376,7 +348,6 @@ def set_backtest_dates(gateway: Any, dates: list[str]) -> None:
if dates:
gateway._backtest_start_date = dates[0]
gateway._backtest_end_date = dates[-1]
gateway._dashboard.days_total = len(dates)
def stop_gateway(gateway: Any) -> None:
@@ -388,4 +359,14 @@ def stop_gateway(gateway: Any) -> None:
gateway._market_status_task.cancel()
if gateway._watchlist_ingest_task:
gateway._watchlist_ingest_task.cancel()
gateway._dashboard.stop()
# Close OpenClaw WebSocket connection
if gateway._openclaw_ws:
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
loop.create_task(gateway._openclaw_ws.disconnect())
else:
loop.run_until_complete(gateway._openclaw_ws.disconnect())
except Exception:
pass

View File

@@ -0,0 +1,534 @@
# -*- coding: utf-8 -*-
"""OpenClaw WebSocket handlers — gateway calls OpenClaw Gateway via WebSocket."""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backend.services.gateway import Gateway
logger = logging.getLogger(__name__)
def _ensure_session_bridge(gateway) -> None:
"""Forward OpenClaw session events into 大时代 frontend websockets."""
if getattr(gateway, "_openclaw_session_bridge_ready", False):
return
async def _forward(event) -> None:
payload = event.payload or {}
session_key = str(payload.get("sessionKey") or payload.get("key") or "").strip()
if not session_key:
return
subscriber_map = getattr(gateway, "_openclaw_session_subscribers", {})
targets = [
ws
for ws, session_keys in list(subscriber_map.items())
if session_key in session_keys
]
if not targets:
return
message = json.dumps(
{
"type": "openclaw_session_event",
"event": event.event,
"session_key": session_key,
"payload": payload,
}
)
stale = []
for ws in targets:
try:
await ws.send(message)
except Exception:
stale.append(ws)
for ws in stale:
try:
subscriber_map.pop(ws, None)
except Exception:
pass
def _handler(event) -> None:
try:
import asyncio
asyncio.create_task(_forward(event))
except Exception as exc:
logger.debug("OpenClaw session bridge skipped event: %s", exc)
client = _get_ws_client(gateway)
client.add_event_handler(_handler)
gateway._openclaw_session_bridge_ready = True
gateway._openclaw_session_bridge_handler = _handler
if not hasattr(gateway, "_openclaw_session_subscribers"):
gateway._openclaw_session_subscribers = {}
def _get_ws_client(gateway) -> "OpenClawWebSocketClient":
"""Get the OpenClaw WebSocket client from gateway."""
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient
client = gateway._openclaw_ws
if client is None:
raise RuntimeError("OpenClaw Gateway not connected")
return client
async def _ws_call(gateway, method: str, params: dict | None = None) -> dict:
"""Call OpenClaw Gateway via WebSocket and return result."""
try:
client = _get_ws_client(gateway)
return await client.call_method(method, params)
except Exception as exc:
logger.warning("OpenClaw Gateway call failed for %s: %s", method, exc)
return {"error": str(exc)[:200]}
async def handle_get_openclaw_status(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "status")
await websocket.send(json.dumps({"type": "openclaw_status_loaded", "data": result}))
async def handle_get_openclaw_sessions(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "sessions.list", {"limit": 50, "includeLastMessage": True})
await websocket.send(json.dumps({"type": "openclaw_sessions_loaded", "data": result}))
async def handle_get_openclaw_session_detail(gateway, websocket, data: dict) -> None:
session_key = data.get("session_key", "")
result = await _ws_call(gateway, "sessions.list", {"limit": 200, "includeLastMessage": True})
session = None
if isinstance(result, dict):
for item in result.get("sessions", []) or []:
if not isinstance(item, dict):
continue
if item.get("key") == session_key or item.get("sessionKey") == session_key:
session = item
break
await websocket.send(json.dumps({
"type": "openclaw_session_detail_loaded",
"data": {"session": session, "error": None if session else f"session '{session_key}' not found"},
"session_key": session_key,
}))
async def handle_get_openclaw_session_history(gateway, websocket, data: dict) -> None:
session_key = data.get("session_key", "")
limit = data.get("limit", 20)
try:
from backend.services.openclaw_cli import OpenClawCliService
result = OpenClawCliService().get_session_history_model(session_key, limit=limit)
payload = {
"session_key": result.session_key,
"session_id": result.session_id,
"history": result.events,
"events": result.events,
"raw_text": result.raw_text,
}
except Exception as exc:
payload = {"error": str(exc)[:200], "history": []}
await websocket.send(json.dumps({
"type": "openclaw_session_history_loaded",
"data": payload,
"session_key": session_key,
}))
async def handle_openclaw_resolve_session(gateway, websocket, data: dict) -> None:
params = {}
agent_id = str(data.get("agent_id") or "").strip()
label = str(data.get("label") or "").strip()
channel = str(data.get("channel") or "").strip()
if agent_id:
params["agentId"] = agent_id
if label:
params["label"] = label
if channel:
params["channel"] = channel
params["includeGlobal"] = bool(data.get("include_global", True))
result = await _ws_call(gateway, "sessions.resolve", params)
await websocket.send(json.dumps({"type": "openclaw_session_resolved", "data": result}))
async def handle_openclaw_create_session(gateway, websocket, data: dict) -> None:
params = {}
agent_id = str(data.get("agent_id") or "").strip()
label = str(data.get("label") or "").strip()
model = str(data.get("model") or "").strip()
initial_message = str(data.get("initial_message") or "").strip()
if agent_id:
params["agentId"] = agent_id
if label:
params["label"] = label
if model:
params["model"] = model
if initial_message:
params["message"] = initial_message
result = await _ws_call(gateway, "sessions.create", params)
await websocket.send(json.dumps({"type": "openclaw_session_created", "data": result}))
async def handle_openclaw_send_message(gateway, websocket, data: dict) -> None:
session_key = str(data.get("session_key") or "").strip()
message = str(data.get("message") or "").strip()
thinking = str(data.get("thinking") or "").strip()
if not session_key or not message:
await websocket.send(
json.dumps(
{
"type": "openclaw_message_sent",
"data": {"error": "session_key and message are required"},
}
)
)
return
params = {"key": session_key, "message": message}
if thinking:
params["thinking"] = thinking
result = await _ws_call(gateway, "sessions.send", params)
await websocket.send(
json.dumps(
{
"type": "openclaw_message_sent",
"data": result,
"session_key": session_key,
}
)
)
async def handle_openclaw_subscribe_session(gateway, websocket, data: dict) -> None:
session_key = str(data.get("session_key") or "").strip()
if not session_key:
await websocket.send(
json.dumps(
{
"type": "openclaw_session_subscribed",
"data": {"error": "session_key is required"},
}
)
)
return
_ensure_session_bridge(gateway)
result = await _ws_call(gateway, "sessions.messages.subscribe", {"key": session_key})
if not isinstance(result, dict) or not result.get("error"):
subscriber_map = getattr(gateway, "_openclaw_session_subscribers", {})
subscriber_map.setdefault(websocket, set()).add(session_key)
gateway._openclaw_session_subscribers = subscriber_map
await websocket.send(
json.dumps(
{
"type": "openclaw_session_subscribed",
"data": result,
"session_key": session_key,
}
)
)
async def handle_openclaw_unsubscribe_session(gateway, websocket, data: dict) -> None:
session_key = str(data.get("session_key") or "").strip()
if not session_key:
await websocket.send(
json.dumps(
{
"type": "openclaw_session_unsubscribed",
"data": {"error": "session_key is required"},
}
)
)
return
result = await _ws_call(gateway, "sessions.messages.unsubscribe", {"key": session_key})
subscriber_map = getattr(gateway, "_openclaw_session_subscribers", {})
session_keys = subscriber_map.get(websocket)
if isinstance(session_keys, set):
session_keys.discard(session_key)
if not session_keys:
subscriber_map.pop(websocket, None)
gateway._openclaw_session_subscribers = subscriber_map
await websocket.send(
json.dumps(
{
"type": "openclaw_session_unsubscribed",
"data": result,
"session_key": session_key,
}
)
)
async def handle_openclaw_reset_session(gateway, websocket, data: dict) -> None:
session_key = str(data.get("session_key") or "").strip()
if not session_key:
await websocket.send(
json.dumps(
{
"type": "openclaw_session_reset",
"data": {"error": "session_key is required"},
}
)
)
return
result = await _ws_call(gateway, "sessions.reset", {"key": session_key})
await websocket.send(
json.dumps(
{
"type": "openclaw_session_reset",
"data": result,
"session_key": session_key,
}
)
)
async def handle_openclaw_delete_session(gateway, websocket, data: dict) -> None:
session_key = str(data.get("session_key") or "").strip()
if not session_key:
await websocket.send(
json.dumps(
{
"type": "openclaw_session_deleted",
"data": {"error": "session_key is required"},
}
)
)
return
result = await _ws_call(gateway, "sessions.delete", {"key": session_key})
await websocket.send(
json.dumps(
{
"type": "openclaw_session_deleted",
"data": result,
"session_key": session_key,
}
)
)
async def handle_get_openclaw_cron(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "cron.list")
await websocket.send(json.dumps({"type": "openclaw_cron_loaded", "data": result}))
async def handle_get_openclaw_approvals(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "exec.approvals.get")
await websocket.send(json.dumps({"type": "openclaw_approvals_loaded", "data": result}))
async def handle_get_openclaw_agents(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "agents.list")
sessions_result = await _ws_call(
gateway,
"sessions.list",
{"limit": 200, "includeLastMessage": True},
)
config_result = await _ws_call(gateway, "config.get")
session_model_by_agent: dict[str, str] = {}
default_session_model: str | None = None
agent_skills_by_id: dict[str, list[str] | None] = {}
default_agent_skills: list[str] | None = None
parsed_config = config_result.get("parsed") if isinstance(config_result, dict) else None
if isinstance(parsed_config, dict):
agents_cfg = parsed_config.get("agents")
if isinstance(agents_cfg, dict):
defaults_cfg = agents_cfg.get("defaults")
if isinstance(defaults_cfg, dict):
default_skills = defaults_cfg.get("skills")
if isinstance(default_skills, list):
default_agent_skills = [
str(skill).strip()
for skill in default_skills
if str(skill).strip()
]
list_cfg = agents_cfg.get("list")
if isinstance(list_cfg, list):
for entry in list_cfg:
if not isinstance(entry, dict):
continue
agent_id = str(entry.get("id") or "").strip()
if not agent_id:
continue
skills = entry.get("skills")
if isinstance(skills, list):
agent_skills_by_id[agent_id] = [
str(skill).strip()
for skill in skills
if str(skill).strip()
]
elif skills == []:
agent_skills_by_id[agent_id] = []
if isinstance(sessions_result, dict) and isinstance(sessions_result.get("sessions"), list):
defaults = sessions_result.get("defaults")
if isinstance(defaults, dict):
value = (
defaults.get("model")
or defaults.get("modelName")
or defaults.get("model_name")
)
if value:
default_session_model = str(value)
for session in sessions_result.get("sessions", []):
if not isinstance(session, dict):
continue
agent_id = str(
session.get("agentId")
or session.get("agent_id")
or ""
).strip()
if not agent_id:
key = str(session.get("key") or session.get("sessionKey") or "").strip()
parts = key.split(":")
if len(parts) >= 3 and parts[0] == "agent":
agent_id = parts[1]
model_value = (
session.get("model")
or session.get("modelName")
or session.get("model_name")
or session.get("resolvedModel")
or session.get("resolved_model")
or session.get("defaultModel")
or session.get("default_model")
)
if agent_id and model_value and agent_id not in session_model_by_agent:
session_model_by_agent[agent_id] = str(model_value)
if isinstance(result, dict) and isinstance(result.get("agents"), list):
normalized_agents = []
for agent in result.get("agents", []):
if not isinstance(agent, dict):
normalized_agents.append(agent)
continue
normalized = dict(agent)
if not normalized.get("model"):
normalized["model"] = (
normalized.get("modelName")
or normalized.get("model_name")
or normalized.get("resolvedModel")
or normalized.get("resolved_model")
or normalized.get("defaultModel")
or normalized.get("default_model")
or session_model_by_agent.get(str(normalized.get("id") or "").strip())
or default_session_model
)
agent_id = str(normalized.get("id") or "").strip()
if "skills" not in normalized:
normalized["skills"] = agent_skills_by_id.get(agent_id, default_agent_skills)
normalized_agents.append(normalized)
result = {**result, "agents": normalized_agents}
await websocket.send(json.dumps({"type": "openclaw_agents_loaded", "data": result}))
async def handle_get_openclaw_agents_presence(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "node.list")
await websocket.send(json.dumps({"type": "openclaw_agents_presence_loaded", "data": result}))
async def handle_get_openclaw_skills(gateway, websocket, data: dict) -> None:
agent_id = str(data.get("agent_id") or "").strip()
params = {"agentId": agent_id} if agent_id else {}
result = await _ws_call(gateway, "skills.status", params)
await websocket.send(json.dumps({"type": "openclaw_skills_loaded", "data": result}))
async def handle_get_openclaw_models(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "models.list")
await websocket.send(json.dumps({"type": "openclaw_models_loaded", "data": result}))
async def handle_get_openclaw_hooks(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "tools.catalog")
await websocket.send(json.dumps({"type": "openclaw_hooks_loaded", "data": result}))
async def handle_get_openclaw_plugins(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "config.get")
await websocket.send(json.dumps({"type": "openclaw_plugins_loaded", "data": result}))
async def handle_get_openclaw_secrets_audit(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "secrets.reload")
await websocket.send(json.dumps({"type": "openclaw_secrets_audit_loaded", "data": result}))
async def handle_get_openclaw_security_audit(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "gateway.identity.get")
await websocket.send(json.dumps({"type": "openclaw_security_audit_loaded", "data": result}))
async def handle_get_openclaw_daemon_status(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "doctor.memory.status")
await websocket.send(json.dumps({"type": "openclaw_daemon_status_loaded", "data": result}))
async def handle_get_openclaw_pairing(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "device.pair.list")
await websocket.send(json.dumps({"type": "openclaw_pairing_loaded", "data": result}))
async def handle_get_openclaw_qr(gateway, websocket, data: dict) -> None:
await websocket.send(json.dumps({"type": "openclaw_qr_loaded", "data": {"error": "QR code not available via WebSocket"}}))
async def handle_get_openclaw_update_status(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "update.run")
await websocket.send(json.dumps({"type": "openclaw_update_status_loaded", "data": result}))
async def handle_get_openclaw_models_aliases(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "models.list")
await websocket.send(json.dumps({"type": "openclaw_models_aliases_loaded", "data": result}))
async def handle_get_openclaw_models_fallbacks(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "models.list")
await websocket.send(json.dumps({"type": "openclaw_models_fallbacks_loaded", "data": result}))
async def handle_get_openclaw_models_image_fallbacks(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "models.list")
await websocket.send(json.dumps({"type": "openclaw_models_image_fallbacks_loaded", "data": result}))
async def handle_get_openclaw_skill_update(gateway, websocket, data: dict) -> None:
slug = data.get("slug")
all_flag = data.get("all", False)
params = {}
if slug is not None:
params["slug"] = slug
if all_flag:
params["all"] = "true"
result = await _ws_call(gateway, "skills.update", params)
await websocket.send(json.dumps({"type": "openclaw_skill_update_loaded", "data": result}))
async def handle_get_openclaw_workspace_files(gateway, websocket, data: dict) -> None:
raw_workspace = data.get("workspace", "")
# Use the workspace param (which is actually the agent.id from frontend) as agent_id
agent_id = raw_workspace or "main"
result = await _ws_call(gateway, "agents.files.list", {"agentId": agent_id})
if isinstance(result, dict):
result["workspace"] = agent_id
await websocket.send(json.dumps({"type": "openclaw_workspace_files_loaded", "data": result}))
async def handle_get_openclaw_workspace_file(gateway, websocket, data: dict) -> None:
agent_id = data.get("agent_id", "main")
file_name = data.get("file_name", "")
if not file_name:
await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": {"error": "file_name is required"}}))
return
result = await _ws_call(gateway, "agents.files.get", {"agentId": agent_id, "name": file_name})
await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": result}))

View File

@@ -141,7 +141,7 @@ def apply_runtime_config(gateway: Any, runtime_config: dict[str, Any]) -> dict[s
def sync_runtime_state(gateway: Any) -> None:
"""Refresh persisted state and dashboard after runtime config changes."""
"""Refresh persisted state after runtime config changes."""
gateway.state_sync.update_state("tickers", gateway.config.get("tickers", []))
gateway.state_sync.update_state(
"runtime_config",
@@ -159,17 +159,3 @@ def sync_runtime_state(gateway: Any) -> None:
gateway.storage.update_server_state_from_dashboard(gateway.state_sync.state)
gateway.state_sync.save_state()
gateway._dashboard.tickers = list(gateway.config.get("tickers", []))
gateway._dashboard.initial_cash = gateway.storage.initial_cash
gateway._dashboard.enable_memory = bool(gateway.config.get("enable_memory", False))
dashboard_snapshot = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state)
summary = dashboard_snapshot.get("summary") or {}
holdings = dashboard_snapshot.get("holdings") or []
trades = dashboard_snapshot.get("trades") or []
gateway._dashboard.update(
portfolio=summary,
holdings=holdings,
trades=trades,
)

View File

@@ -0,0 +1,754 @@
# -*- coding: utf-8 -*-
"""Thin service wrapper around the OpenClaw CLI."""
from __future__ import annotations
import json
import os
import shlex
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from shared.models.openclaw import (
AgentSummary,
AgentsList,
ApprovalRequest,
ApprovalsList,
CronJob,
CronList,
DaemonStatus,
HookStatusEntry,
HookStatusReport,
ModelAliasesList,
ModelFallbacksList,
ModelRow,
ModelsList,
OpenClawStatus,
PairingListResponse,
PluginDiagnostic,
PluginRecord,
PluginsList,
QrCodeResponse,
SecretsAuditReport,
SecurityAuditResponse,
SecurityAuditReport,
SessionEntry,
SessionHistory,
SessionsList,
SkillStatusEntry,
SkillStatusReport,
SkillUpdateResult,
UpdateCheckResult,
UpdateStatusResponse,
normalize_agents,
normalize_approvals,
normalize_cron_jobs,
normalize_daemon_status,
normalize_hooks,
normalize_model_aliases,
normalize_model_fallbacks,
normalize_models,
normalize_pairing,
normalize_plugins,
normalize_qr,
normalize_security_audit,
normalize_secrets_audit,
normalize_session_history,
normalize_sessions,
normalize_skill_update,
normalize_skills,
normalize_status,
normalize_update_status,
)
PROJECT_ROOT = Path(__file__).resolve().parents[2]
REFERENCE_OPENCLAW_ROOT = PROJECT_ROOT / "reference" / "openclaw"
REFERENCE_OPENCLAW_ENTRY = REFERENCE_OPENCLAW_ROOT / "openclaw.mjs"
class OpenClawCliError(RuntimeError):
"""Raised when the OpenClaw CLI invocation fails."""
def __init__(
self,
message: str,
*,
command: list[str],
exit_code: int | None = None,
stdout: str = "",
stderr: str = "",
) -> None:
super().__init__(message)
self.command = command
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
@dataclass(frozen=True)
class OpenClawCliResult:
"""Command execution result."""
command: list[str]
exit_code: int
stdout: str
stderr: str
def resolve_openclaw_base_command() -> list[str]:
"""Resolve the command prefix used to launch OpenClaw."""
explicit = os.getenv("OPENCLAW_CMD", "").strip()
if explicit:
return shlex.split(explicit)
installed = shutil.which("openclaw")
if installed:
return [installed]
if REFERENCE_OPENCLAW_ENTRY.exists():
return [sys.executable if sys.executable.endswith("node") else "node", str(REFERENCE_OPENCLAW_ENTRY)]
return ["openclaw"]
def resolve_openclaw_cwd() -> Path:
"""Resolve the working directory for CLI execution."""
explicit = os.getenv("OPENCLAW_CWD", "").strip()
if explicit:
return Path(explicit).expanduser()
if REFERENCE_OPENCLAW_ROOT.exists():
return REFERENCE_OPENCLAW_ROOT
return PROJECT_ROOT
class OpenClawCliService:
"""OpenClaw CLI integration service."""
def __init__(
self,
*,
base_command: list[str] | None = None,
cwd: Path | None = None,
timeout_seconds: float | None = None,
) -> None:
self.base_command = list(base_command or resolve_openclaw_base_command())
self.cwd = cwd or resolve_openclaw_cwd()
self.timeout_seconds = timeout_seconds or float(
os.getenv("OPENCLAW_TIMEOUT_SECONDS", "15")
)
def health(self) -> dict[str, Any]:
"""Return the current CLI wiring state."""
binary = self.base_command[0] if self.base_command else "openclaw"
resolved = shutil.which(binary) if len(self.base_command) == 1 else binary
return {
"status": "healthy",
"service": "openclaw-service",
"base_command": self.base_command,
"cwd": str(self.cwd),
"binary_resolved": resolved is not None,
"reference_entry_available": REFERENCE_OPENCLAW_ENTRY.exists(),
"timeout_seconds": self.timeout_seconds,
}
def status(self) -> dict[str, Any]:
"""Read `openclaw status --json`."""
return self.run_json(["status", "--json"])
def list_sessions(self) -> dict[str, Any]:
"""Read `openclaw sessions --json`."""
return self.run_json(["sessions", "--json"])
def get_session(self, session_key: str) -> dict[str, Any]:
"""Resolve a single session out of the sessions list."""
payload = self.list_sessions()
sessions = payload.get("sessions") or []
for item in sessions:
if not isinstance(item, dict):
continue
if item.get("key") == session_key or item.get("sessionKey") == session_key:
return item
raise KeyError(session_key)
def get_session_history(self, session_key: str, *, limit: int = 20) -> dict[str, Any]:
"""Read session history with a JSON-first fallback to raw text."""
args = ["sessions", "history", session_key, "--json", "--limit", str(limit)]
try:
return self.run_json(args)
except OpenClawCliError as exc:
raise exc
except json.JSONDecodeError:
result = self.run(args)
return {
"sessionKey": session_key,
"limit": limit,
"rawText": result.stdout,
}
def list_cron_jobs(self) -> dict[str, Any]:
"""Read `openclaw cron list --json`."""
return self.run_json(["cron", "list", "--json"])
def list_approvals(self) -> dict[str, Any]:
"""Read `openclaw approvals get --json`."""
return self.run_json(["approvals", "get", "--json"])
def list_agents(self) -> dict[str, Any]:
"""Read `openclaw agents list --json`."""
return self.run_json(["agents", "list", "--json"])
def list_skills(self) -> dict[str, Any]:
"""Read `openclaw skills list --json`."""
return self.run_json(["skills", "list", "--json"])
def list_models(self) -> dict[str, Any]:
"""Read `openclaw models list --json`."""
return self.run_json(["models", "list", "--json"])
def list_hooks(self) -> dict[str, Any]:
"""Read `openclaw hooks list --json`."""
return self.run_json(["hooks", "list", "--json"])
def list_plugins(self) -> dict[str, Any]:
"""Read `openclaw plugins list --json`."""
return self.run_json(["plugins", "list", "--json"])
def secrets_audit(self) -> dict[str, Any]:
"""Read `openclaw secrets audit --json`."""
return self.run_json(["secrets", "audit", "--json"])
def security_audit(self) -> dict[str, Any]:
"""Read `openclaw security audit --json`."""
return self.run_json(["security", "audit", "--json"])
def daemon_status(self) -> dict[str, Any]:
"""Read `openclaw daemon status --json`."""
return self.run_json(["daemon", "status", "--json"])
def pairing_list(self) -> dict[str, Any]:
"""Read `openclaw pairing list --json`."""
return self.run_json(["pairing", "list", "--json"])
def qr_code(self) -> dict[str, Any]:
"""Read `openclaw qr --json`."""
return self.run_json(["qr", "--json"])
def update_status(self) -> dict[str, Any]:
"""Read `openclaw update status --json`."""
return self.run_json(["update", "status", "--json"])
def list_model_aliases(self) -> dict[str, Any]:
"""Read `openclaw models aliases list --json`."""
return self.run_json(["models", "aliases", "list", "--json"])
def list_model_fallbacks(self) -> dict[str, Any]:
"""Read `openclaw models fallbacks list --json`."""
return self.run_json(["models", "fallbacks", "list", "--json"])
def list_model_image_fallbacks(self) -> dict[str, Any]:
"""Read `openclaw models image-fallbacks list --json`."""
return self.run_json(["models", "image-fallbacks", "list", "--json"])
def skill_update(self, *, slug: str | None = None, all: bool = False) -> dict[str, Any]:
"""Read `openclaw skills update --json`."""
args = ["skills", "update", "--json"]
if slug:
args.append(slug)
if all:
args.append("--all")
return self.run_json(args)
def models_status(self, *, probe: bool = False) -> dict[str, Any]:
"""Read `openclaw models status --json [--probe]`."""
args = ["models", "status", "--json"]
if probe:
args.append("--probe")
return self.run_json(args)
def channels_status(self, *, probe: bool = False) -> dict[str, Any]:
"""Read `openclaw channels status [--probe] --json`."""
args = ["channels", "status", "--json"]
if probe:
args.append("--probe")
return self.run_json(args)
def list_workspace_files(self, workspace_path: str) -> dict[str, Any]:
"""List .md files in an OpenClaw agent workspace with their content.
Reads the workspace directory and returns metadata + content for each .md file.
"""
import json
from pathlib import Path
wp = Path(workspace_path).expanduser().resolve()
if not wp.exists() or not wp.is_dir():
return {"workspace": str(wp), "files": [], "error": "workspace not found"}
md_files = sorted(wp.glob("*.md"))
files = []
for md_file in md_files:
try:
content = md_file.read_text(encoding="utf-8")
# Preview: first 300 chars
preview = content[:300].strip()
files.append({
"name": md_file.name,
"path": str(md_file),
"size": len(content),
"preview": preview,
"previewTruncated": len(content) > 300,
})
except OSError as exc:
files.append({
"name": md_file.name,
"path": str(md_file),
"size": 0,
"preview": "",
"error": str(exc),
})
return {"workspace": str(wp), "files": files}
def channels_list(self) -> dict[str, Any]:
"""Read `openclaw channels list --json`."""
return self.run_json(["channels", "list", "--json"])
def hook_info(self, name: str) -> dict[str, Any]:
"""Read `openclaw hooks info <name> --json`."""
args = ["hooks", "info", name, "--json"]
try:
return self.run_json(args)
except json.JSONDecodeError:
result = self.run(args)
return {"raw": result.stdout}
def hooks_check(self) -> dict[str, Any]:
"""Read `openclaw hooks check --json`."""
return self.run_json(["hooks", "check", "--json"])
def plugins_inspect(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
"""Read `openclaw plugins inspect [--json] [--all]`."""
args = ["plugins", "inspect", "--json"]
if all:
args.append("--all")
elif plugin_id:
args.append(plugin_id)
return self.run_json(args)
# -------------------------------------------------------------------------
# Typed variants — these use Pydantic models and are the preferred path.
# -------------------------------------------------------------------------
def status_model(self) -> OpenClawStatus:
"""Read and parse `openclaw status --json` into a typed model."""
raw = self.status()
return normalize_status(raw)
def list_sessions_model(self) -> SessionsList:
"""Read and parse `openclaw sessions --json` into a typed model."""
raw = self.list_sessions()
return normalize_sessions(raw)
def get_session_model(self, session_key: str) -> SessionEntry:
"""Resolve a single session and return a typed model."""
raw = self.get_session(session_key)
return SessionEntry.model_validate(raw, strict=False)
def get_session_history_model(self, session_key: str, *, limit: int = 20) -> SessionHistory:
"""Read session history and return a typed model."""
raw = self.get_session_history(session_key, limit=limit)
return normalize_session_history(raw, session_key=session_key)
def list_cron_jobs_model(self) -> CronList:
"""Read and parse `openclaw cron list --json` into a typed model."""
raw = self.list_cron_jobs()
return normalize_cron_jobs(raw)
def list_approvals_model(self) -> ApprovalsList:
"""Read and parse `openclaw approvals get --json` into a typed model."""
raw = self.list_approvals()
return normalize_approvals(raw)
# -------------------------------------------------------------------------
# Typed variants
# -------------------------------------------------------------------------
def list_agents_model(self) -> AgentsList:
"""Read and parse `openclaw agents list --json` into a typed model."""
raw = self.list_agents()
if isinstance(raw, list):
return AgentsList(agents=[AgentSummary.model_validate(a, strict=False) for a in raw if isinstance(a, dict)])
return normalize_agents(raw)
def list_skills_model(self) -> SkillStatusReport:
"""Read and parse `openclaw skills list --json` into a typed model."""
raw = self.list_skills()
return normalize_skills(raw)
def list_models_model(self) -> ModelsList:
"""Read and parse `openclaw models list --json` into a typed model."""
raw = self.list_models()
if isinstance(raw, list):
return ModelsList(models=[ModelRow.model_validate(m, strict=False) for m in raw if isinstance(m, dict)])
return normalize_models(raw)
def list_hooks_model(self) -> HookStatusReport:
raw = self.list_hooks()
return normalize_hooks(raw)
def list_plugins_model(self) -> PluginsList:
raw = self.list_plugins()
return normalize_plugins(raw)
def secrets_audit_model(self) -> SecretsAuditReport:
raw = self.secrets_audit()
return normalize_secrets_audit(raw)
def security_audit_model(self) -> SecurityAuditResponse:
raw = self.security_audit()
return normalize_security_audit(raw)
def daemon_status_model(self) -> DaemonStatus:
raw = self.daemon_status()
return normalize_daemon_status(raw)
def pairing_list_model(self) -> PairingListResponse:
raw = self.pairing_list()
return normalize_pairing(raw)
def qr_code_model(self) -> QrCodeResponse:
raw = self.qr_code()
return normalize_qr(raw)
def update_status_model(self) -> UpdateStatusResponse:
raw = self.update_status()
return normalize_update_status(raw)
def list_model_aliases_model(self) -> ModelAliasesList:
raw = self.list_model_aliases()
return normalize_model_aliases(raw)
def list_model_fallbacks_model(self) -> ModelFallbacksList:
raw = self.list_model_fallbacks()
return normalize_model_fallbacks(raw)
def list_model_image_fallbacks_model(self) -> ModelFallbacksList:
raw = self.list_model_image_fallbacks()
return normalize_model_fallbacks(raw)
def skill_update_model(self, *, slug: str | None = None, all: bool = False) -> SkillUpdateResult:
raw = self.skill_update(slug=slug, all=all)
return normalize_skill_update(raw)
def models_status_model(self, *, probe: bool = False) -> dict[str, Any]:
"""Read `openclaw models status --json` and return the raw dict."""
return self.models_status(probe=probe)
def channels_status_model(self, *, probe: bool = False) -> dict[str, Any]:
"""Read `openclaw channels status --json` and return the raw dict."""
return self.channels_status(probe=probe)
def channels_list_model(self) -> dict[str, Any]:
"""Read `openclaw channels list --json` and return the raw dict."""
return self.channels_list()
def hook_info_model(self, name: str) -> dict[str, Any]:
"""Read `openclaw hooks info <name> --json` and return the raw dict."""
return self.hook_info(name)
def hooks_check_model(self) -> dict[str, Any]:
"""Read `openclaw hooks check --json` and return the raw dict."""
return self.hooks_check()
def plugins_inspect_model(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
"""Read `openclaw plugins inspect --json [--all]` and return the raw dict."""
return self.plugins_inspect(plugin_id=plugin_id, all=all)
def agents_bindings(self, *, agent: str | None = None) -> dict[str, Any]:
"""Read `openclaw agents bindings --json [--agent <id>]`."""
args = ["agents", "bindings", "--json"]
if agent:
args.extend(["--agent", agent])
return self.run_json(args)
def agents_bindings_model(self, *, agent: str | None = None) -> dict[str, Any]:
"""Read `openclaw agents bindings --json` and return the raw dict."""
return self.agents_bindings(agent=agent)
def agents_presence(self) -> dict[str, Any]:
"""Read session presence for all agents from runtime session files.
Reads ~/.openclaw/agents/{agentId}/sessions/sessions.json for each agent
and counts sessions in active states within a recency window.
"""
import json
from pathlib import Path
openclaw_home = Path.home() / ".openclaw"
agents_path = openclaw_home / "agents"
if not agents_path.exists():
return {"status": "not_connected", "agents": {}}
ACTIVE_STATES = {
"running", "active", "busy", "blocked", "waiting_approval",
"working", "in_progress", "processing", "thinking", "executing", "streaming",
}
RECENCY_WINDOW_MS = 45 * 60 * 1000 # 45 minutes
result: dict[str, Any] = {"status": "connected", "agents": {}}
try:
for agent_dir in agents_path.iterdir():
if not agent_dir.is_dir():
continue
sessions_file = agent_dir / "sessions" / "sessions.json"
if not sessions_file.exists():
continue
try:
sessions_data = json.loads(sessions_file.read_text())
except (json.JSONDecodeError, OSError):
continue
sessions = sessions_data if isinstance(sessions_data, list) else []
now_ms = 0 # placeholder; we'll skip recency check if no ts field
active_count = 0
for session in sessions:
if not isinstance(session, dict):
continue
state = str(session.get("state") or session.get("status") or "").lower()
if state in ACTIVE_STATES:
active_count += 1
if active_count > 0:
result["agents"][agent_dir.name] = {
"activeSessions": active_count,
"status": "active",
}
else:
result["agents"][agent_dir.name] = {
"activeSessions": 0,
"status": "idle",
}
except OSError:
result["status"] = "partial"
return result
def agents_from_config(self) -> dict[str, Any]:
"""Read agent list directly from openclaw.json config file.
Falls back to scanning ~/.openclaw/agents/ directories when config is absent.
This avoids the CLI timeout from `agents list --json`.
"""
import json
openclaw_home = Path.home() / ".openclaw"
config_path = openclaw_home / "openclaw.json"
if not config_path.exists():
return {"status": "not_connected", "agents": []}
try:
raw = json.loads(config_path.read_text())
except (json.JSONDecodeError, OSError):
return {"status": "partial", "agents": []}
agents_list = raw.get("agents", {}).get("list", [])
if not agents_list:
return {"status": "partial", "agents": [], "detail": "agents.list is empty"}
agents = []
for entry in agents_list:
if not isinstance(entry, dict):
continue
agent_id = entry.get("id", "").strip()
if not agent_id:
continue
agents.append({
"id": agent_id,
"name": entry.get("name", "").strip() or agent_id,
"model": entry.get("model") or "",
"workspace": entry.get("workspace") or "",
"is_default": entry.get("id") == raw.get("agents", {}).get("defaults", {}).get("id"),
})
return {"status": "connected", "agents": agents}
def gateway_status(self, *, url: str | None = None, token: str | None = None) -> dict[str, Any]:
"""Read `openclaw gateway status --json [--url <url>] [--token <token>]`. May fail if gateway is unreachable."""
args = ["gateway", "status", "--json"]
if url:
args.extend(["--url", url])
if token:
args.extend(["--token", token])
return self.run_json(args)
def memory_status(self, *, agent: str | None = None, deep: bool = False) -> dict[str, Any]:
"""Read `openclaw memory status --json [--agent <id>] [--deep]`. Returns array of per-agent status."""
args = ["memory", "status", "--json"]
if agent:
args.extend(["--agent", agent])
if deep:
args.append("--deep")
return self.run_json(args)
# -------------------------------------------------------------------------
# Write agents commands
# -------------------------------------------------------------------------
def agents_add(
self,
name: str,
*,
workspace: str | None = None,
model: str | None = None,
agent_dir: str | None = None,
bind: list[str] | None = None,
non_interactive: bool = False,
) -> dict[str, Any]:
"""Run `openclaw agents add <name> [--workspace <dir>] [--model <id>] [--agent-dir <dir>] [--bind <spec>] [--non-interactive] --json`."""
args = ["agents", "add", name, "--json"]
if workspace:
args.extend(["--workspace", workspace])
if model:
args.extend(["--model", model])
if agent_dir:
args.extend(["--agent-dir", agent_dir])
if bind:
for b in bind:
args.extend(["--bind", b])
if non_interactive:
args.append("--non-interactive")
return self.run_json(args)
def agents_delete(self, id: str, *, force: bool = False) -> dict[str, Any]:
"""Run `openclaw agents delete <id> [--force] --json`."""
args = ["agents", "delete", id, "--json"]
if force:
args.append("--force")
return self.run_json(args)
def agents_bind(
self,
*,
agent: str | None = None,
bind: list[str] | None = None,
) -> dict[str, Any]:
"""Run `openclaw agents bind [--agent <id>] [--bind <spec>] --json`."""
args = ["agents", "bind", "--json"]
if agent:
args.extend(["--agent", agent])
if bind:
for b in bind:
args.extend(["--bind", b])
return self.run_json(args)
def agents_unbind(
self,
*,
agent: str | None = None,
bind: list[str] | None = None,
all: bool = False,
) -> dict[str, Any]:
"""Run `openclaw agents unbind [--agent <id>] [--bind <spec>] [--all] --json`."""
args = ["agents", "unbind", "--json"]
if agent:
args.extend(["--agent", agent])
if bind:
for b in bind:
args.extend(["--bind", b])
if all:
args.append("--all")
return self.run_json(args)
def agents_set_identity(
self,
*,
agent: str | None = None,
workspace: str | None = None,
identity_file: str | None = None,
name: str | None = None,
emoji: str | None = None,
theme: str | None = None,
avatar: str | None = None,
from_identity: bool = False,
) -> dict[str, Any]:
"""Run `openclaw agents set-identity [--agent <id>] [--workspace <dir>] [--identity-file <path>] [--from-identity] [--name <n>] [--emoji <e>] [--theme <t>] [--avatar <a>] --json`."""
args = ["agents", "set-identity", "--json"]
if agent:
args.extend(["--agent", agent])
if workspace:
args.extend(["--workspace", workspace])
if identity_file:
args.extend(["--identity-file", identity_file])
if from_identity:
args.append("--from-identity")
if name:
args.extend(["--name", name])
if emoji:
args.extend(["--emoji", emoji])
if theme:
args.extend(["--theme", theme])
if avatar:
args.extend(["--avatar", avatar])
return self.run_json(args)
def run_json(self, args: list[str]) -> dict[str, Any]:
"""Run the CLI and decode JSON stdout, falling back to stderr."""
result = self.run(args)
text = result.stdout.strip() or result.stderr.strip()
if not text:
return {}
return json.loads(text)
def run(self, args: list[str]) -> OpenClawCliResult:
"""Run the CLI and return stdout/stderr."""
command = [*self.base_command, *args]
env = os.environ.copy()
try:
completed = subprocess.run(
command,
cwd=self.cwd,
env=env,
capture_output=True,
text=True,
timeout=self.timeout_seconds,
check=False,
)
except FileNotFoundError as exc:
raise OpenClawCliError(
"OpenClaw CLI executable was not found.",
command=command,
) from exc
except subprocess.TimeoutExpired as exc:
raise OpenClawCliError(
f"OpenClaw CLI timed out after {self.timeout_seconds:.1f}s.",
command=command,
stdout=exc.stdout or "",
stderr=exc.stderr or "",
) from exc
if completed.returncode != 0:
raise OpenClawCliError(
"OpenClaw CLI command failed.",
command=command,
exit_code=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)
return OpenClawCliResult(
command=command,
exit_code=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)

View File

@@ -39,7 +39,7 @@ class StorageService:
self,
dashboard_dir: Path,
initial_cash: float = 100000.0,
config_name: str = "mock",
config_name: str = "live",
):
"""
Initialize storage service

View File

@@ -13,7 +13,7 @@ class _DummyToolkit:
return ""
def test_workspace_manager_creates_extended_agent_files(tmp_path):
def test_workspace_manager_creates_core_agent_files(tmp_path):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
@@ -27,7 +27,7 @@ def test_workspace_manager_creates_extended_agent_files(tmp_path):
assert (asset_dir / "PROFILE.md").exists()
assert (asset_dir / "AGENTS.md").exists()
assert (asset_dir / "MEMORY.md").exists()
assert (asset_dir / "HEARTBEAT.md").exists()
assert (asset_dir / "POLICY.md").exists()
assert (asset_dir / "agent.yaml").exists()
assert (asset_dir / "skills" / "installed").is_dir()
assert (asset_dir / "skills" / "active").is_dir()
@@ -35,6 +35,22 @@ def test_workspace_manager_creates_extended_agent_files(tmp_path):
assert (asset_dir / "skills" / "local").is_dir()
def test_workspace_manager_seeds_risk_prompt_content(tmp_path):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["risk_manager"],
analyst_personas={},
)
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
soul = (asset_dir / "SOUL.md").read_text(encoding="utf-8")
guide = (asset_dir / "AGENTS.md").read_text(encoding="utf-8")
assert "风险管理经理" in soul
assert "优先使用可用的风险工具量化集中度" in guide
def test_agent_workspace_config_controls_prompt_files(tmp_path, monkeypatch):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
@@ -72,6 +88,32 @@ def test_agent_workspace_config_controls_prompt_files(tmp_path, monkeypatch):
assert "profile-line" not in prompt
def test_prompt_is_built_from_workspace_defaults_without_system_templates(tmp_path, monkeypatch):
manager = WorkspaceManager(project_root=tmp_path)
manager.initialize_default_assets(
config_name="demo",
agent_ids=["portfolio_manager"],
analyst_personas={},
)
from backend.agents import prompt_factory
monkeypatch.setattr(
prompt_factory,
"SkillsManager",
lambda: SkillsManager(project_root=tmp_path),
)
prompt = build_agent_system_prompt(
agent_id="portfolio_manager",
config_name="demo",
toolkit=_DummyToolkit(),
)
assert "投资组合经理" in prompt
assert "使用 `make_decision` 工具记录每个股票的最终决策" in prompt
def test_skills_manager_applies_agent_level_skill_toggles(tmp_path):
builtin_root = tmp_path / "backend" / "skills" / "builtin"
for skill_name in ("risk_review", "extra_guard"):

View File

@@ -311,6 +311,17 @@ class TestRiskAgent:
class TestStorageService:
def test_storage_service_defaults_to_live_config(self):
from backend.services.storage import StorageService
with tempfile.TemporaryDirectory() as tmpdir:
storage = StorageService(
dashboard_dir=Path(tmpdir),
initial_cash=100000.0,
)
assert storage.config_name == "live"
def test_calculate_portfolio_value_cash_only(self):
from backend.services.storage import StorageService

View File

@@ -8,24 +8,6 @@ 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 = []
@@ -128,7 +110,6 @@ def make_gateway_stub():
},
storage=_DummyStorage(),
state_sync=_DummyStateSync(),
_dashboard=_DummyDashboard(),
_watchlist_ingest_task=None,
_market_status_task=None,
_backtest_task=None,

View File

@@ -1,69 +0,0 @@
# -*- coding: utf-8 -*-
"""Tests for HeartbeatHook."""
import tempfile
from pathlib import Path
import pytest
from backend.agents.base.hooks import HeartbeatHook
class TestHeartbeatHook:
"""Tests for HeartbeatHook._read_heartbeat_content."""
def test_read_heartbeat_content_with_content(self, tmp_path):
"""Test reading HEARTBEAT.md when it exists and has content."""
ws_dir = tmp_path / "analyst_workspace"
ws_dir.mkdir()
hb_file = ws_dir / "HEARTBEAT.md"
hb_file.write_text("# 定期主动检查\n\n- [ ] 持仓是否健康\n", encoding="utf-8")
hook = HeartbeatHook(workspace_dir=ws_dir)
content = hook._read_heartbeat_content()
assert content is not None
assert "# 定期主动检查" in content
assert "持仓是否健康" in content
def test_read_heartbeat_content_absent(self, tmp_path):
"""Test reading when HEARTBEAT.md does not exist."""
ws_dir = tmp_path / "analyst_workspace"
ws_dir.mkdir()
hook = HeartbeatHook(workspace_dir=ws_dir)
content = hook._read_heartbeat_content()
assert content is None
def test_read_heartbeat_content_empty(self, tmp_path):
"""Test reading when HEARTBEAT.md is empty."""
ws_dir = tmp_path / "analyst_workspace"
ws_dir.mkdir()
hb_file = ws_dir / "HEARTBEAT.md"
hb_file.write_text("", encoding="utf-8")
hook = HeartbeatHook(workspace_dir=ws_dir)
content = hook._read_heartbeat_content()
assert content is None
def test_read_heartbeat_content_whitespace_only(self, tmp_path):
"""Test reading when HEARTBEAT.md contains only whitespace."""
ws_dir = tmp_path / "analyst_workspace"
ws_dir.mkdir()
hb_file = ws_dir / "HEARTBEAT.md"
hb_file.write_text(" \n\n ", encoding="utf-8")
hook = HeartbeatHook(workspace_dir=ws_dir)
content = hook._read_heartbeat_content()
assert content is None
def test_completed_flag_path(self, tmp_path):
"""Test that completion flag is placed in workspace directory."""
ws_dir = tmp_path / "analyst_workspace"
ws_dir.mkdir()
hook = HeartbeatHook(workspace_dir=ws_dir)
assert hook._completed_flag == ws_dir / ".heartbeat_completed"

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""Tests for market ingest watermark handling."""
from backend.data import market_ingest
class _FakeStore:
def __init__(self, *, last_news_fetch=None, latest_news_date=None):
self._watermarks = {
"symbol": "AAPL",
"last_price_fetch": None,
"last_news_fetch": last_news_fetch,
}
self._latest_news_date = latest_news_date
self.updated = []
def get_ticker_watermarks(self, symbol):
return dict(self._watermarks)
def get_latest_news_date(self, symbol):
return self._latest_news_date
def upsert_ticker(self, **kwargs):
return None
def upsert_ohlc(self, symbol, rows, source="polygon"):
return len(rows)
def upsert_news(self, symbol, rows, source="polygon"):
return len(rows)
def update_fetch_watermark(self, **kwargs):
self.updated.append(kwargs)
def test_refresh_news_incremental_does_not_advance_watermark_without_news(monkeypatch):
store = _FakeStore(last_news_fetch="2026-03-28", latest_news_date="2026-03-28")
monkeypatch.setattr(market_ingest, "fetch_ticker_details", lambda ticker: {"name": ticker, "sic_description": None, "active": True})
class _Router:
def get_company_news(self, **kwargs):
return [], "polygon"
monkeypatch.setattr(market_ingest, "DataProviderRouter", lambda: _Router())
monkeypatch.setattr(market_ingest, "align_news_for_symbol", lambda store, ticker: 0)
result = market_ingest.refresh_news_incremental(
"AAPL",
end_date="2026-03-29",
store=store,
)
assert result["start_news_date"] == "2026-03-29"
assert result["news"] == 0
assert store.updated[-1]["news_date"] is None
def test_refresh_news_incremental_clamps_future_watermark_to_latest_stored_date(monkeypatch):
store = _FakeStore(last_news_fetch="2026-03-30", latest_news_date="2026-03-28")
captured = {}
monkeypatch.setattr(market_ingest, "fetch_ticker_details", lambda ticker: {"name": ticker, "sic_description": None, "active": True})
class _Router:
def get_company_news(self, **kwargs):
captured.update(kwargs)
return [], "polygon"
monkeypatch.setattr(market_ingest, "DataProviderRouter", lambda: _Router())
monkeypatch.setattr(market_ingest, "align_news_for_symbol", lambda store, ticker: 0)
result = market_ingest.refresh_news_incremental(
"AAPL",
end_date="2026-03-29",
store=store,
)
assert result["start_news_date"] == "2026-03-29"
assert captured["start_date"] == "2026-03-29"
assert captured["end_date"] == "2026-03-29"

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""Tests for the OpenClaw CLI service wrapper."""
from pathlib import Path
import pytest
from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService
class _Completed:
def __init__(self, *, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def test_openclaw_cli_service_runs_json_command(monkeypatch, tmp_path):
captured = {}
def _fake_run(command, **kwargs):
captured["command"] = command
captured["cwd"] = kwargs["cwd"]
return _Completed(stdout='{"sessions":[{"key":"main/session-1"}]}')
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
payload = service.list_sessions()
assert payload["sessions"][0]["key"] == "main/session-1"
assert captured["command"] == ["openclaw", "sessions", "--json"]
assert captured["cwd"] == tmp_path
def test_openclaw_cli_service_raises_on_failure(monkeypatch, tmp_path):
def _fake_run(command, **kwargs):
return _Completed(returncode=7, stdout="", stderr="boom")
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
with pytest.raises(OpenClawCliError) as exc_info:
service.list_cron_jobs()
assert exc_info.value.exit_code == 7
assert exc_info.value.stderr == "boom"
def test_openclaw_cli_service_can_extract_single_session(monkeypatch, tmp_path):
def _fake_run(command, **kwargs):
return _Completed(stdout='{"sessions":[{"key":"main/session-1","agentId":"main"}]}')
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
session = service.get_session("main/session-1")
assert session["agentId"] == "main"

View File

@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
"""Tests for the extracted OpenClaw service app surface."""
from fastapi.testclient import TestClient
from backend.apps.openclaw_service import create_app
from backend.api import openclaw as openclaw_module
class _FakeOpenClawCliService:
def health(self):
return {
"status": "healthy",
"service": "openclaw-service",
"base_command": ["openclaw"],
"cwd": "/tmp/openclaw",
"binary_resolved": True,
"reference_entry_available": True,
"timeout_seconds": 15.0,
}
def status(self):
return {"runtimeVersion": "2026.3.24"}
def list_sessions(self):
return {
"sessions": [
{"key": "main/session-1", "agentId": "main"},
{"key": "analyst/session-2", "agentId": "analyst"},
]
}
def get_session(self, session_key: str):
for session in self.list_sessions()["sessions"]:
if session["key"] == session_key:
return session
raise KeyError(session_key)
def get_session_history(self, session_key: str, *, limit: int = 20):
return {
"sessionKey": session_key,
"limit": limit,
"items": [{"role": "assistant", "text": "hello"}],
}
def list_cron_jobs(self):
return {"jobs": [{"id": "job-1", "name": "Daily sync"}]}
def list_approvals(self):
return {"approvals": [{"id": "ap-1", "status": "pending"}]}
def test_openclaw_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/openclaw/status" in paths
assert "/api/openclaw/sessions" in paths
assert "/api/openclaw/sessions/{session_key:path}" in paths
assert "/api/openclaw/sessions/{session_key:path}/history" in paths
assert "/api/openclaw/cron" in paths
assert "/api/openclaw/approvals" in paths
def test_openclaw_service_read_routes():
app = create_app()
app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = (
lambda: _FakeOpenClawCliService()
)
with TestClient(app) as client:
health = client.get("/health")
status = client.get("/api/status")
openclaw_status = client.get("/api/openclaw/status")
sessions = client.get("/api/openclaw/sessions")
session = client.get("/api/openclaw/sessions/main/session-1")
history = client.get("/api/openclaw/sessions/main/session-1/history", params={"limit": 5})
cron = client.get("/api/openclaw/cron")
approvals = client.get("/api/openclaw/approvals")
assert health.status_code == 200
assert health.json()["service"] == "openclaw-service"
assert status.status_code == 200
assert status.json()["status"] == "operational"
assert openclaw_status.status_code == 200
assert openclaw_status.json()["runtimeVersion"] == "2026.3.24"
assert sessions.status_code == 200
assert len(sessions.json()["sessions"]) == 2
assert session.status_code == 200
assert session.json()["session"]["agentId"] == "main"
assert history.status_code == 200
assert history.json()["limit"] == 5
assert cron.status_code == 200
assert cron.json()["jobs"][0]["id"] == "job-1"
assert approvals.status_code == 200
assert approvals.json()["approvals"][0]["id"] == "ap-1"
def test_openclaw_service_session_404():
app = create_app()
app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = (
lambda: _FakeOpenClawCliService()
)
with TestClient(app) as client:
response = client.get("/api/openclaw/sessions/missing")
assert response.status_code == 404

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""Tests for the OpenClaw WebSocket client session helpers."""
import pytest
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient
@pytest.mark.asyncio
async def test_resolve_session_parses_gateway_key_response():
client = OpenClawWebSocketClient(gateway_token="test-token")
async def fake_send_request(method, params=None, _allow_handshake=False):
assert method == "sessions.resolve"
assert params["agentId"] == "main"
return {"ok": True, "key": "agent:main:main"}
client._send_request = fake_send_request # type: ignore[method-assign]
resolved = await client.resolve_session(agent_id="main")
assert resolved == "agent:main:main"
@pytest.mark.asyncio
async def test_send_message_uses_session_send_payload():
client = OpenClawWebSocketClient(gateway_token="test-token")
async def fake_send_request(method, params=None, _allow_handshake=False):
assert method == "sessions.send"
assert params == {
"key": "agent:main:main",
"message": "hello",
"thinking": "medium",
}
return {"ok": True, "runId": "run-1"}
client._send_request = fake_send_request # type: ignore[method-assign]
result = await client.send_message("agent:main:main", "hello", thinking="medium")
assert result["runId"] == "run-1"
@pytest.mark.asyncio
async def test_get_session_history_uses_sessions_preview():
client = OpenClawWebSocketClient(gateway_token="test-token")
async def fake_send_request(method, params=None, _allow_handshake=False):
assert method == "sessions.preview"
assert params == {"keys": ["agent:main:main"], "limit": 12}
return {"previews": []}
client._send_request = fake_send_request # type: ignore[method-assign]
result = await client.get_session_history("agent:main:main", limit=12)
assert result == {"previews": []}
@pytest.mark.asyncio
async def test_unsubscribe_uses_session_messages_unsubscribe():
client = OpenClawWebSocketClient(gateway_token="test-token")
async def fake_send_request(method, params=None, _allow_handshake=False):
assert method == "sessions.messages.unsubscribe"
assert params == {"key": "agent:main:main"}
return {"subscribed": False}
client._send_request = fake_send_request # type: ignore[method-assign]
result = await client.unsubscribe("agent:main:main")
assert result == {"subscribed": False}

View File

@@ -4,6 +4,7 @@
import pytest
from shared.client.control_client import ControlPlaneClient
from shared.client.openclaw_client import OpenClawServiceClient
from shared.client.runtime_client import RuntimeServiceClient
@@ -105,3 +106,25 @@ async def test_runtime_service_client_hits_current_runtime_routes():
("get", "/config", None),
("put", "/config", {"schedule_mode": "intraday"}),
]
@pytest.mark.asyncio
async def test_openclaw_service_client_hits_current_openclaw_routes():
client = OpenClawServiceClient()
client._client = _DummyAsyncClient()
await client.fetch_status()
await client.list_sessions()
await client.get_session("main/session-1")
await client.get_session_history("main/session-1", limit=5)
await client.list_cron_jobs()
await client.list_approvals()
assert client._client.calls == [
("get", "/status", None),
("get", "/sessions", None),
("get", "/sessions/main/session-1", None),
("get", "/sessions/main/session-1/history", {"limit": 5}),
("get", "/cron", None),
("get", "/approvals", None),
]

View File

@@ -48,7 +48,7 @@ class TechnicalSignal:
class StockTechnicalAnalyzer:
"""Lightweight technical analyzer adapted for EvoTraders tools."""
"""Lightweight technical analyzer adapted for 大时代 tools."""
def analyze(self, ticker: str, df: pd.DataFrame) -> TechnicalSignal:
"""Analyze one ticker from OHLC price history."""

View File

@@ -1,352 +0,0 @@
# -*- coding: utf-8 -*-
"""
Terminal Dashboard - Persistent unified panel using Rich Live
"""
# pylint: disable=R0915,R0912
import logging
import threading
import time
from typing import Any, Dict, List, Optional
from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.table import Table
logger = logging.getLogger(__name__)
class TerminalDashboard:
"""Unified persistent terminal dashboard"""
def __init__(self, console: Console = None):
self.console = console or Console()
self.live: Optional[Live] = None
# Config state
self.mode = "live"
self.config_name = ""
self.host = "0.0.0.0"
self.port = 8765
self.poll_interval = 10
self.trigger_time = "now"
self.enable_memory = False
self.local_time = ""
self.nyse_time = ""
self.start_date = ""
self.end_date = ""
self.tickers: List[str] = []
self.initial_cash = 100000.0
self.data_sources: Dict[str, Any] = {}
# Trading state
self.current_date = "-"
self.status = "Initializing"
self.total_value = 0.0
self.cash = 0.0
self.pnl_pct = 0.0
self.holdings: List[Dict] = []
self.trades: List[Dict] = []
self.days_completed = 0
self.days_total = 0
# Progress message (last line)
self.progress = ""
self._dots_index = 0
self._animator_running = False
self._animator_thread: Optional[threading.Thread] = None
def set_config(
self,
mode: str,
config_name: str,
host: str,
port: int,
poll_interval: int,
trigger_time: str = "now",
enable_memory: bool = False,
local_time: str = "",
nyse_time: str = "",
start_date: str = "",
end_date: str = "",
tickers: List[str] = None,
initial_cash: float = 100000.0,
data_sources: Dict[str, Any] = None,
):
"""Set configuration state"""
self.mode = mode
self.config_name = config_name
self.host = host
self.port = port
self.poll_interval = poll_interval
self.trigger_time = trigger_time
self.enable_memory = enable_memory
self.local_time = local_time
self.nyse_time = nyse_time
self.start_date = start_date
self.end_date = end_date
self.tickers = tickers or []
self.initial_cash = initial_cash
self.data_sources = data_sources or {}
self.total_value = initial_cash
self.cash = initial_cash
def _build_panel(self) -> Panel:
"""Build the unified dashboard panel"""
# Main grid
main_table = Table.grid(padding=(0, 2))
main_table.add_column(width=28)
main_table.add_column(width=22)
main_table.add_column(width=22)
# Left: Config + Status
left = Table.grid(padding=(0, 0))
left.add_column()
# Mode line
if self.mode == "backtest":
mode_str = "[cyan]Backtest[/cyan]"
else:
mode_str = "[green]LIVE[/green]"
left.add_row(f"[bold]Mode:[/bold] {mode_str}")
left.add_row(f"[dim]Config:[/dim] {self.config_name}")
left.add_row(f"[dim]Server:[/dim] {self.host}:{self.port}")
preferred_sources = self.data_sources.get("preferred", [])
if preferred_sources:
left.add_row(
f"[dim]Data:[/dim] {' -> '.join(preferred_sources)}",
)
if self.mode == "live" and self.nyse_time:
left.add_row(f"[dim]NYSE:[/dim] {self.nyse_time[:19]}")
trigger_display = (
"[green]NOW[/green]"
if self.trigger_time == "now"
else self.trigger_time
)
left.add_row(f"[dim]Trigger:[/dim] {trigger_display}")
# Status
left.add_row("")
status_style = "green" if self.status == "Running" else "yellow"
left.add_row(
"[bold]Status:[/bold] "
f"[{status_style}]{self.status}[/{status_style}]",
)
if self.mode == "backtest":
left.add_row(
f"[dim]Backtesting Period:[/dim] {self.days_total} days\n"
f" {self.start_date} -> {self.end_date}",
)
left.add_row(f"[dim]Current Date:[/dim] {self.current_date}")
# Middle: Portfolio
mid = Table.grid(padding=(0, 0))
mid.add_column()
pnl_style = "green" if self.pnl_pct >= 0 else "red"
mid.add_row("[bold]Portfolio[/bold]")
mid.add_row(f"NAV: [bold]${self.total_value:,.0f}[/bold]")
mid.add_row(f"Cash: ${self.cash:,.0f}")
mid.add_row(f"P&L: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]")
# Positions
mid.add_row("")
mid.add_row("[bold]Positions[/bold]")
stock_holdings = [
h for h in self.holdings if h.get("ticker") != "CASH"
]
if stock_holdings:
for h in stock_holdings[:7]:
qty = h.get("quantity", 0)
ticker = h.get("ticker", "")[:5]
val = h.get("marketValue", 0)
qty_str = f"{qty:+d}" if qty != 0 else "0"
mid.add_row(
f"[cyan]{ticker:<5}[/cyan] {qty_str:>5} ${val:>7,.0f}",
)
if len(stock_holdings) > 7:
mid.add_row(f"[dim]+{len(stock_holdings) - 7} more[/dim]")
else:
mid.add_row("[dim]No positions[/dim]")
# Right: Recent Trades
right = Table.grid(padding=(0, 0))
right.add_column()
right.add_row("[bold]Recent Trades[/bold]")
if self.trades:
for t in self.trades[:10]:
side = t.get("side", "")
ticker = t.get("ticker", "")[:5]
qty = t.get("qty", 0)
if side == "LONG":
side_str = "[green]L[/green]"
elif side == "SHORT":
side_str = "[red]S[/red]"
else:
side_str = "[dim]H[/dim]"
right.add_row(f"{side_str} [cyan]{ticker:<5}[/cyan] {qty:>4}")
if len(self.trades) > 10:
right.add_row(f"[dim]+{len(self.trades) - 10} more[/dim]")
else:
right.add_row("[dim]No trades[/dim]")
main_table.add_row(left, mid, right)
# Outer table to add progress line at bottom
outer = Table.grid(padding=(0, 0))
outer.add_column()
outer.add_row(main_table)
# Progress line (last row) with animated dots
if self.progress:
DOTS_FRAMES = [" ", ". ", ".. ", "..."]
dots = DOTS_FRAMES[self._dots_index % len(DOTS_FRAMES)]
outer.add_row("")
outer.add_row(f"[dim]> {self.progress}{dots}[/dim]")
# Build panel
title = "[bold cyan]EvoTraders[/bold cyan]"
if self.mode == "backtest":
title += " [dim]Backtest[/dim]"
else:
title += " [dim]Live[/dim]"
return Panel(
outer,
title=title,
border_style="cyan",
padding=(0, 1),
)
def _run_animator(self):
"""Background thread to animate the dots"""
while self._animator_running:
time.sleep(0.3)
if self.progress and self.live:
self._dots_index += 1
self.live.update(self._build_panel())
def start(self):
"""Start the live dashboard display"""
self.live = Live(
self._build_panel(),
console=self.console,
refresh_per_second=4,
vertical_overflow="visible",
)
self.live.start()
# Start animator thread
self._animator_running = True
self._animator_thread = threading.Thread(
target=self._run_animator,
daemon=True,
)
self._animator_thread.start()
def stop(self):
"""Stop the live dashboard"""
self._animator_running = False
if self._animator_thread:
self._animator_thread.join(timeout=0.5)
self._animator_thread = None
if self.live:
self.live.stop()
self.live = None
def update(
self,
date: str = None,
status: str = None,
portfolio: Dict[str, Any] = None,
holdings: List[Dict] = None,
trades: List[Dict] = None,
days_completed: int = None,
days_total: int = None,
data_sources: Dict[str, Any] = None,
):
"""Update dashboard state and refresh display"""
if date:
self.current_date = date
if status:
self.status = status
if days_completed is not None:
self.days_completed = days_completed
if days_total is not None:
self.days_total = days_total
if portfolio:
self.total_value = portfolio.get(
"totalAssetValue",
0,
) or portfolio.get(
"total_value",
self.initial_cash,
)
self.cash = portfolio.get("cashPosition", 0) or portfolio.get(
"cash",
self.initial_cash,
)
if self.total_value > 0 and self.initial_cash > 0:
self.pnl_pct = (
(self.total_value - self.initial_cash) / self.initial_cash
) * 100
if holdings is not None:
self.holdings = holdings
if trades is not None:
self.trades = trades
if data_sources is not None:
self.data_sources = data_sources
if self.live:
self.live.update(self._build_panel())
def log(self, msg: str, also_log: bool = True):
"""
Update progress message and refresh panel
Args:
msg: Progress message to display
also_log: Whether to also write to logger (default True)
"""
self.progress = msg
if also_log:
logger.info(msg)
if self.live:
self.live.update(self._build_panel())
def print_final_summary(self):
"""Print final summary when dashboard stops"""
pnl_style = "green" if self.pnl_pct >= 0 else "red"
if self.mode == "backtest":
msg = (
f"[bold]Backtest Complete[/bold] | "
f"Days: {self.days_completed} | "
f"NAV: ${self.total_value:,.0f} | "
f"Return: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]"
)
else:
msg = (
f"[bold]Session End[/bold] | "
f"NAV: ${self.total_value:,.0f} | "
f"P&L: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]"
)
self.console.print(Panel(msg, border_style="green"))
# Global instance
_dashboard: Optional[TerminalDashboard] = None
def get_dashboard() -> TerminalDashboard:
"""Get or create global dashboard instance"""
global _dashboard
if _dashboard is None:
_dashboard = TerminalDashboard()
return _dashboard

View File

@@ -2626,6 +2626,5 @@
"trading_days_completed": 5,
"server_mode": "backtest",
"is_backtest": true,
"is_mock_mode": false,
"last_saved": "2026-03-12T23:07:31.098122"
}

135
deploy/README.md Normal file
View File

@@ -0,0 +1,135 @@
# Deployment Notes
This directory contains the current production-oriented deployment artifacts for
the 大时代 frontend site and the live gateway process.
## Contents
- [deploy/systemd/evotraders.service](./systemd/evotraders.service)
- systemd unit for the long-running 大时代 gateway process
- [scripts/run_prod.sh](../scripts/run_prod.sh)
- production launch script used by the systemd unit
- [deploy/nginx/bigtime.cillinn.com.conf](./nginx/bigtime.cillinn.com.conf)
- HTTPS nginx config with WebSocket proxying
- [deploy/nginx/bigtime.cillinn.com.http.conf](./nginx/bigtime.cillinn.com.http.conf)
- plain HTTP/static-site variant
## Current Production Shape
The checked-in production path is intentionally minimal:
- nginx serves the built frontend from `/var/www/bigtime/current`
- public domain examples use `bigtime.cillinn.com`
- nginx proxies `/ws` to `127.0.0.1:8765`
- systemd runs `scripts/run_prod.sh`
- `scripts/run_prod.sh` starts `python3 -m backend.main` in live mode on `127.0.0.1:8765`
This means the checked-in production example is centered on the gateway and
frontend, not on exposing the split FastAPI services directly.
## Important Paths And Ports
- frontend root: `/var/www/bigtime/current`
- gateway bind: `127.0.0.1:8765`
- public WebSocket path: `/ws`
- working directory expected by systemd: `/root/code/evotraders`
## systemd
The current systemd unit:
- uses `WorkingDirectory=/root/code/evotraders`
- executes [scripts/run_prod.sh](../scripts/run_prod.sh)
- restarts automatically on failure
Enable and start:
```bash
sudo cp deploy/systemd/evotraders.service /etc/systemd/system/evotraders.service
sudo systemctl daemon-reload
sudo systemctl enable evotraders
sudo systemctl start evotraders
```
Check status and logs:
```bash
sudo systemctl status evotraders
journalctl -u evotraders -f
```
## nginx
The HTTPS nginx config does two things:
- redirects `http://bigtime.cillinn.com` to HTTPS
- proxies `/ws` to the local gateway process with WebSocket upgrade headers
Typical install flow:
```bash
sudo cp deploy/nginx/bigtime.cillinn.com.conf /etc/nginx/sites-available/bigtime.cillinn.com.conf
sudo ln -s /etc/nginx/sites-available/bigtime.cillinn.com.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
The checked-in TLS config expects Let's Encrypt assets at:
- `/etc/letsencrypt/live/bigtime.cillinn.com/fullchain.pem`
- `/etc/letsencrypt/live/bigtime.cillinn.com/privkey.pem`
## Environment Expectations
Before using the production scripts, ensure the runtime environment has:
- a usable Python environment
- backend dependencies installed from `requirements.txt`
- the package installed with `pip install -e .` or `uv pip install -e .`
- frontend dependencies installed with `npm ci`
- repo dependencies installed
- required market/model API keys
- any desired `TICKERS` override
Recommended production install sequence:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -e .
cd frontend && npm ci && npm run build && cd ..
```
The production script currently sets:
```bash
PYTHONPATH=/root/code/evotraders/.pydeps:.
TICKERS=${TICKERS:-AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN}
```
It then launches:
```bash
python3 -m backend.main \
--mode live \
--config-name production \
--host 127.0.0.1 \
--port 8765 \
--trigger-time now \
--poll-interval 15
```
## What This Deployment Does Not Yet Cover
The checked-in deployment artifacts do not currently document or automate:
- split FastAPI service deployment on `8000` to `8004`
- OpenClaw gateway deployment on `18789`
- database backup/retention workflows
- frontend build/publish steps
- secret management
If you move production fully to split-service mode, update this directory so it
documents the new service topology explicitly instead of relying on the gateway-
only path.

View File

@@ -1,9 +1,9 @@
server {
listen 80;
server_name evotraders.cillinn.com;
server_name bigtime.cillinn.com;
location /.well-known/acme-challenge/ {
root /var/www/evotraders/current;
root /var/www/bigtime/current;
allow all;
}
@@ -14,13 +14,13 @@ server {
server {
listen 443 ssl http2;
server_name evotraders.cillinn.com;
server_name bigtime.cillinn.com;
root /var/www/evotraders/current;
root /var/www/bigtime/current;
index index.html;
ssl_certificate /etc/letsencrypt/live/evotraders.cillinn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/evotraders.cillinn.com/privkey.pem;
ssl_certificate /etc/letsencrypt/live/bigtime.cillinn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bigtime.cillinn.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

View File

@@ -1,8 +1,8 @@
server {
listen 80;
server_name evotraders.cillinn.com;
server_name bigtime.cillinn.com;
root /var/www/evotraders/current;
root /var/www/bigtime/current;
index index.html;
location /.well-known/acme-challenge/ {

View File

@@ -1,5 +1,5 @@
[Unit]
Description=EvoTraders Production Service
Description=大时代 Production Service
After=network.target
[Service]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,28 +1,116 @@
# Compatibility Removal Plan
# Compatibility And Migration Status
This document tracks the remaining migration-only surfaces that still exist
after the move to split-first development.
This document tracks the remaining migration-related boundaries after the
repository switched to split-first development.
## Migration-only Surfaces
## Current Status
None currently remain as dedicated compatibility wrappers.
The repo no longer depends on a combined FastAPI compatibility wrapper for
normal local development. The default path is now:
## Completed Removals
`agent_service + trading_service + news_service + runtime_service + gateway`
That means compatibility is no longer a separate startup mode. What remains is
mostly protocol-level and routing-level compatibility while the codebase
continues to move responsibilities into clearer service surfaces.
## What Was Removed
### `backend.app`
- Removed after compatibility startup switched to
`backend.apps.combined_service:app` directly.
- Removed after startup paths switched away from the legacy app wrapper.
### `backend.apps.combined_service`
- Removed after split-service startup became the only supported local dev mode.
### `shared.client.AgentServiceClient`
- Removed after split-aware clients became the default import surface.
- Replacement:
- Replaced by:
- `ControlPlaneClient`
- `RuntimeServiceClient`
- `TradingServiceClient`
- `NewsServiceClient`
### `backend.apps.combined_service`
## What Still Exists For Compatibility
- Removed after split-service mode became the only supported dev startup path.
These are not legacy wrappers in the old sense, but they still preserve
backward-compatible behavior while migration settles.
### Gateway-mediated flows
- The WebSocket gateway still carries a mix of:
- live runtime feed transport
- orchestration
- selected read flows that have not been moved to direct browser service calls
- This is intentional for now because the frontend still depends on the gateway
for event streaming and some compatibility reads.
### In-process fallbacks
- Some read paths still support local-module fallback when split-service URLs
are not configured.
- Relevant variables include:
- `TRADING_SERVICE_URL`
- `NEWS_SERVICE_URL`
- This keeps the app resilient during migration, but it also means behavior can
differ depending on env configuration.
### Dual OpenClaw integration surfaces
- OpenClaw currently appears through two different shapes:
- WebSocket gateway integration on `:18789`
- optional REST surface at `backend.apps.openclaw_service` on `:8004`
- These are both valid, but they are not the same surface and should not be
documented as interchangeable.
## Remaining Migration Risks
### Split service deployment is not yet the checked-in production default
- The repo documents split-service local development clearly.
- The checked-in production example still centers on `backend.main` and nginx
WebSocket proxying.
- This is a topology mismatch to keep in mind when changing deploy docs or prod
automation.
### Environment-dependent routing
- The frontend and gateway can switch behavior based on configured service URLs.
- This is helpful operationally, but it makes debugging more configuration-
sensitive than a fully fixed service topology.
### Runtime/control-plane separation is logical, not fully operationally isolated
- `runtime_service` owns lifecycle APIs.
- `agent_service` owns control-plane APIs.
- The gateway still hosts the live runtime orchestration path, so the split is
clean at the API level but not yet a completely independent service mesh.
## Exit Criteria For Declaring Migration Complete
Migration can be considered effectively complete when all of the following are
true:
1. Production deployment docs and scripts explicitly run the same split-service
topology used in development, or intentionally document a different stable
production topology.
2. Critical read paths no longer require ambiguous fallback behavior to local
module implementations.
3. OpenClaw integration is documented as a stable contract with clear guidance
on when to use the WebSocket gateway versus the REST surface.
4. The frontend-service routing model is stable enough that direct-service and
gateway-mediated paths are deliberate design choices rather than migration
leftovers.
## Practical Read Of The Current State
The migration away from combined-service startup is done.
What remains is not “legacy startup debt”, but:
- topology clarification
- deployment consistency
- reduction of env-dependent fallback behavior
- sharper documentation around gateway and OpenClaw boundaries

View File

@@ -1,6 +1,6 @@
# ================== General Configuration | 通用配置 ==================
# List of stock ticker symbols to analyze (comma-separated) | 想要分析的股票代码列表(用逗号分隔)
TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN
TICKERS=AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN
# Financial Data API
# At least FINANCIAL_DATASETS_API_KEY is required, corresponding to FIN_DATA_SOURCE=financial_datasets; It's recommended to add FINNHUB_API_KEY, corresponding to FIN_DATA_SOURCE=finnhub; FINNHUB_API_KEY is mandatory for live mode
@@ -20,6 +20,9 @@ MARKET_DB_PATH= #optional path for long-lived market_research.db | 长期市场
OPENAI_API_KEY=
OPENAI_BASE_URL=
MODEL_NAME=qwen3-max-preview
OPENCLAW_CMD=
OPENCLAW_CWD=
OPENCLAW_TIMEOUT_SECONDS=15
EXPLAIN_ENRICH_USE_LLM=false
EXPLAIN_ENRICH_MODEL_PROVIDER=
EXPLAIN_ENRICH_MODEL_NAME=

View File

@@ -1,31 +1,66 @@
## QuickStart
## Frontend Quick Start
```bash
cd frontend
npm install
npm ci
npm run dev
```
## Optional Direct Service Calls
Default dev URL: `http://localhost:5173`
The frontend still works with the compatibility backend entrypoint by default.
In the current test-stage setup, split services are the recommended default.
Point the frontend directly at those standalone services:
The frontend expects the 大时代 gateway WebSocket on `ws://localhost:8765` unless overridden.
## Recommended Local Backend Stack
Start the split backend services from the project root:
```bash
./start-dev.sh
```
That gives you:
- control plane at `http://localhost:8000/api`
- trading service at `http://localhost:8001`
- news service at `http://localhost:8002`
- runtime service at `http://localhost:8003/api/runtime`
- gateway WebSocket at `ws://localhost:8765`
## Frontend Environment Variables
You can point the frontend directly at those services with:
```bash
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
VITE_NEWS_SERVICE_URL=http://localhost:8002
VITE_TRADING_SERVICE_URL=http://localhost:8001
VITE_WS_URL=ws://localhost:8765
```
Current direct-call coverage:
There is also a starter template at [frontend/env.template](./env.template).
- runtime panel + gateway port discovery
For production deployments, prefer:
```bash
cd frontend
npm ci
npm run build
```
This ensures the deployed frontend matches the checked-in `package-lock.json`.
## Direct-Service Coverage
Current direct-call coverage includes:
- runtime panel data loading
- gateway port/runtime discovery
- `story`
- `similar days`
- `range explain`
- `news for date`
- `news categories`
- selected trading reads such as price history and insider trades
If these variables are not set, the frontend falls back to the existing
WebSocket-driven compatibility flow.
If these variables are not set, the frontend falls back to local defaults and compatibility paths where they still exist.

View File

@@ -1,10 +1,24 @@
# Frontend Environment Variables Template
# 复制此文件为 .env 并修改配置
# WebSocket服务器地址
# 本地开发
# 控制面 APIagent/workspaces/guard
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
# 运行时 APIstart/stop/runtime info
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
# 新闻服务(可选,未配置时走默认回退)
VITE_NEWS_SERVICE_URL=http://localhost:8002
# 交易数据服务(可选,未配置时走默认回退)
VITE_TRADING_SERVICE_URL=http://localhost:8001
# WebSocket Gateway
VITE_WS_URL=ws://localhost:8765
# 生产环境(替换为你的实际服务器地址)
# VITE_WS_URL=wss://your-server.com:8765
# 生产环境示例
# VITE_CONTROL_API_BASE_URL=https://your-domain.com/api
# VITE_RUNTIME_API_BASE_URL=https://your-domain.com/api/runtime
# VITE_NEWS_SERVICE_URL=https://your-domain.com/news
# VITE_TRADING_SERVICE_URL=https://your-domain.com/trading
# VITE_WS_URL=wss://your-domain.com/ws

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/trading_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EvoTraders</title>
<title>大时代</title>
</head>
<body>
<div id="root"></div>

View File

@@ -3,6 +3,10 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=20",
"npm": ">=10"
},
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -13,6 +17,9 @@
"preview:host": "vite preview --host"
},
"dependencies": {
"@dicebear/collection": "^9.4.2",
"@dicebear/core": "^9.4.2",
"@lobehub/icons": "^5.2.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",

View File

@@ -13,6 +13,7 @@ import { useAgentStore } from './store/agentStore';
import { useMarketStore } from './store/marketStore';
import { usePortfolioStore } from './store/portfolioStore';
import { useRuntimeStore } from './store/runtimeStore';
import { useOpenClawStore } from './store/openclawStore';
import { useUIStore } from './store/uiStore';
const EDITABLE_AGENT_WORKSPACE_FILES = [
@@ -20,10 +21,7 @@ const EDITABLE_AGENT_WORKSPACE_FILES = [
'PROFILE.md',
'AGENTS.md',
'MEMORY.md',
'POLICY.md',
'HEARTBEAT.md',
'ROLE.md',
'STYLE.md'
'POLICY.md'
];
export default function LiveTradingApp() {
@@ -141,6 +139,11 @@ export default function LiveTradingApp() {
addSystemMessage,
});
// Make clientRef available to OpenClaw panel via store
useEffect(() => {
useOpenClawStore.getState().setClientRef(clientRef);
}, [clientRef]);
const runtimeControls = useRuntimeControls({
clientRef,
currentTickers: tickers,
@@ -228,6 +231,26 @@ export default function LiveTradingApp() {
workspaceFilesByAgent,
]);
useEffect(() => {
if (!isSocketReady || !clientRef.current) {
return;
}
AGENTS.forEach((agent) => {
if (!agent?.id) {
return;
}
if (!agentProfilesByAgent[agent.id]) {
requestAgentProfile(agent.id);
}
});
}, [
agentProfilesByAgent,
clientRef,
isSocketReady,
requestAgentProfile,
]);
useEffect(() => {
const symbols = runtimeControls.displayTickers
.map((ticker) => ticker.symbol)

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { ASSETS } from '../config/constants';
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
/**
* Get rank medal/trophy
@@ -207,14 +208,18 @@ export default function AgentCard({ agent, onClose, isClosing }) {
justifyContent: 'center',
marginBottom: 4
}}>
{modelInfo.logoPath ? (
<img
src={modelInfo.logoPath}
{agent.modelName || modelInfo.logoPath ? (
<LobeModelLogo
model={agent.modelName}
provider={agent.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={36}
type="color"
shape="square"
style={{
maxHeight: '100%',
maxWidth: '100%',
objectFit: 'contain'
}}
/>
) : (

View File

@@ -3,6 +3,7 @@ import { formatTime } from '../utils/formatters';
import { MESSAGE_COLORS, getAgentColors, AGENTS, ASSETS } from '../config/constants';
import { getModelIcon } from '../utils/modelIcons';
import MarkdownModal from './MarkdownModal';
import LobeModelLogo from './LobeModelLogo.jsx';
const isAnalyst = (agentId, agentName) => {
if (agentId && agentId.includes('analyst')) return true;
@@ -167,11 +168,11 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
// Get current selection display info
const getCurrentSelectionInfo = () => {
if (selectedAgent === 'all') {
return { label: '全部角色', modelInfo: null };
return { label: '全部角色', modelInfo: null, agentInfo: null };
}
const agentInfo = getAgentInfoByName(selectedAgent);
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
return { label: selectedAgent, modelInfo };
return { label: selectedAgent, modelInfo, agentInfo };
};
const currentSelection = getCurrentSelectionInfo();
@@ -189,11 +190,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
onBlur={() => setTimeout(() => setDropdownOpen(false), 200)}
>
<div className="custom-select-value">
{currentSelection.modelInfo?.logoPath && (
<img
src={currentSelection.modelInfo.logoPath}
alt={currentSelection.modelInfo.provider}
{(currentSelection.agentInfo?.modelName || currentSelection.modelInfo?.logoPath) && (
<LobeModelLogo
model={currentSelection.agentInfo?.modelName}
provider={currentSelection.agentInfo?.modelProvider}
fallbackSrc={currentSelection.modelInfo?.logoPath}
alt={currentSelection.modelInfo?.provider}
size={18}
className="select-model-icon"
shape="square"
type="color"
/>
)}
<span>{currentSelection.label}</span>
@@ -223,11 +229,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
setDropdownOpen(false);
}}
>
{modelInfo?.logoPath && (
<img
src={modelInfo.logoPath}
alt={modelInfo.provider}
{(agentInfo?.modelName || modelInfo?.logoPath) && (
<LobeModelLogo
model={agentInfo?.modelName}
provider={agentInfo?.modelProvider}
fallbackSrc={modelInfo?.logoPath}
alt={modelInfo?.provider}
size={18}
className="select-model-icon"
shape="square"
type="color"
/>
)}
<span>{agent}</span>
@@ -363,16 +374,16 @@ function ConferenceMessage({ message, getAgentModelInfo }) {
return (
<div className="conf-message-item">
<div className="conf-agent-name" style={{ color: agentColors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
{modelInfo.logoPath && (
<img
src={modelInfo.logoPath}
{(agentModelData.modelName || modelInfo.logoPath) && (
<LobeModelLogo
model={agentModelData.modelName}
provider={agentModelData.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
style={{
width: '20px',
height: '20px',
borderRadius: '50%',
objectFit: 'contain'
}}
size={20}
shape="circle"
type="color"
style={{ borderRadius: '50%' }}
/>
)}
{message.agent}
@@ -591,16 +602,16 @@ function MessageItem({ message, itemId, isHighlighted, getAgentModelInfo }) {
>
<div className="feed-item-header">
<span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
{modelInfo.logoPath && message.agent !== 'Memory' && (
<img
src={modelInfo.logoPath}
{message.agent !== 'Memory' && (agentModelData.modelName || modelInfo.logoPath) && (
<LobeModelLogo
model={agentModelData.modelName}
provider={agentModelData.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
style={{
width: '20px',
height: '20px',
borderRadius: '50%',
objectFit: 'contain'
}}
size={20}
shape="circle"
type="color"
style={{ borderRadius: '50%' }}
/>
)}
{title}

View File

@@ -2,7 +2,6 @@ import React, { Suspense, lazy, useRef, useEffect, useMemo } from 'react';
import GlobalStyles from '../styles/GlobalStyles';
import Header from './Header.jsx';
import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx';
import StockLogo from './StockLogo.jsx';
import NetValueChart from './NetValueChart.jsx';
import { AGENTS } from '../config/constants';
import { useRuntimeStore } from '../store/runtimeStore';
@@ -14,6 +13,7 @@ const AgentFeed = lazy(() => import('./AgentFeed'));
const StatisticsView = lazy(() => import('./StatisticsView'));
const StockExplainView = lazy(() => import('./StockExplainView.jsx'));
const TraderView = lazy(() => import('./TraderView.jsx'));
const OpenClawView = lazy(() => import('./OpenClawView.jsx'));
function ViewLoadingFallback({ label = '加载中...' }) {
return (
@@ -171,7 +171,8 @@ export default function AppShell({
const base = `view-slider-five ${currentView === 'traders' ? 'show-traders' :
currentView === 'room' ? 'show-room' :
currentView === 'explain' ? 'show-explain' :
currentView === 'statistics' ? 'show-statistics' : 'show-chart'}`;
currentView === 'chart' ? 'show-chart' :
currentView === 'statistics' ? 'show-statistics' : 'show-openclaw'}`;
return base;
}, [currentView]);
@@ -320,7 +321,6 @@ export default function AppShell({
<div key={groupIdx} className="ticker-group">
{displayTickers.map(ticker => (
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
<StockLogo ticker={ticker.symbol} size={16} />
<span className="ticker-symbol">{ticker.symbol}</span>
<span className="ticker-price">
<span className={`ticker-price-value ${rollingTickers[ticker.symbol] ? 'rolling' : ''}`}>
@@ -382,6 +382,12 @@ export default function AppShell({
>
统计
</button>
<button
className={`view-nav-btn ${currentView === 'openclaw' ? 'active' : ''}`}
onClick={() => setCurrentView('openclaw')}
>
OpenClaw
</button>
</div>
<div className={viewClassName}>
@@ -485,6 +491,13 @@ export default function AppShell({
/>
</Suspense>
</div>
{/* OpenClaw View Panel */}
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载 OpenClaw 视图..." />}>
<OpenClawView />
</Suspense>
</div>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import React from 'react';
/**
* Header Component
* Reusable header brand for EvoTraders.
* Reusable header brand for 大时代.
*/
export default function Header() {
return (
@@ -19,10 +19,10 @@ export default function Header() {
>
<img
src="/trading_logo.png"
alt="EvoTraders Logo"
alt="大时代 Logo"
style={{ height: '24px', width: 'auto' }}
/>
EvoTraders
大时代
</span>
</div>
);

View File

@@ -0,0 +1,78 @@
import React from 'react';
import ModelIcon from '@lobehub/icons/es/features/ModelIcon';
import ProviderIcon from '@lobehub/icons/es/features/ProviderIcon';
export default function LobeModelLogo({
model,
provider,
fallbackSrc = null,
alt = '',
size = 28,
shape = 'square',
type = 'color',
style = {},
className = '',
}) {
const hasModel = typeof model === 'string' && model.trim().length > 0;
const hasProvider = typeof provider === 'string' && provider.trim().length > 0;
try {
if (hasModel) {
return (
<ModelIcon
model={model}
size={size}
shape={shape}
type={type}
className={className}
style={style}
/>
);
}
if (hasProvider) {
return (
<ProviderIcon
provider={provider.toLowerCase()}
size={size}
shape={shape}
type={type}
className={className}
style={style}
/>
);
}
} catch {
// Fall through to local fallback asset.
}
if (fallbackSrc) {
return (
<img
src={fallbackSrc}
alt={alt}
className={className}
style={{
width: size,
height: size,
objectFit: 'contain',
...style,
}}
/>
);
}
return (
<div
className={className}
style={{
width: size,
height: size,
borderRadius: shape === 'circle' ? '50%' : 8,
background: '#F3F4F6',
border: '1px solid #D1D5DB',
...style,
}}
/>
);
}

View File

@@ -123,7 +123,7 @@ export default function NetValueChart({ equity, baseline, baseline_vw, momentum,
// Legend descriptions
const legendDescriptions = {
'EvoTraders': 'EvoTraders is our agents investment strategy',
'大时代': '大时代 is our agents investment strategy',
'Buy & Hold (EW)': 'Equal Weight: Can be viewed as an equal-weighted index of all invested stocks',
'Buy & Hold (VW)': 'Value Weighted: Can be viewed as a market-cap weighted index of all invested stocks',
'Momentum': 'Momentum Strategy: Buy stocks that have performed well in the past',
@@ -758,7 +758,7 @@ export default function NetValueChart({ equity, baseline, baseline_vw, momentum,
<Line
type="linear"
dataKey="portfolio"
name="EvoTraders"
name="大时代"
stroke="#00C853"
strokeWidth={2.5}
dot={(props) => <CustomDot {...props} dataKey="portfolio" />}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import { OpenClawStatus } from './OpenClawStatus';
export default function OpenClawView() {
return <OpenClawStatus />;
}

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { ASSETS, SCENE_NATIVE, AGENT_SEATS, AGENTS } from '../config/constants';
import AgentCard from './AgentCard';
import { getModelIcon } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
/**
* Custom hook to load an image
@@ -518,21 +519,23 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile
{medal}
</span>
)}
{modelInfo.logoPath && (
<img
src={modelInfo.logoPath}
{(agentData?.modelName || modelInfo.logoPath) && (
<LobeModelLogo
model={agentData?.modelName}
provider={agentData?.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={25}
shape="circle"
type="color"
className="agent-model-badge"
style={{
position: 'absolute',
top: -12,
right: -12,
width: 25,
height: 25,
borderRadius: '50%',
border: '2px solid #ffffff',
background: '#ffffff',
objectFit: 'contain',
padding: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'none'
@@ -642,10 +645,15 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile
{/* Agent header with model icon */}
<div className="room-bubble-header">
{modelInfo.logoPath && (
<img
src={modelInfo.logoPath}
{(agentData?.modelName || modelInfo.logoPath) && (
<LobeModelLogo
model={agentData?.modelName}
provider={agentData?.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={18}
shape="circle"
type="color"
className="bubble-model-icon"
/>
)}

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import StockLogo from './StockLogo';
import { formatNumber, formatDateTime } from '../utils/formatters';
/**
@@ -497,7 +496,6 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
<tr key={h.ticker}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{h.ticker !== 'CASH' && <StockLogo ticker={h.ticker} size={18} />}
<span style={{ fontWeight: 700, color: '#000000' }}>{h.ticker}</span>
</div>
</td>
@@ -623,7 +621,6 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StockLogo ticker={t.ticker} size={16} />
<span style={{ fontWeight: 700, color: '#000000' }}>{t.ticker}</span>
</div>
</td>

View File

@@ -1,21 +1,10 @@
import React, { useEffect, useState } from 'react';
import StockLogo from './StockLogo';
import ExplainEventsSection from './explain/ExplainEventsSection';
import ExplainMentionsSection from './explain/ExplainMentionsSection';
import ExplainMaintenanceSection from './explain/ExplainMaintenanceSection';
import ExplainNewsSection from './explain/ExplainNewsSection';
import ExplainPriceSection from './explain/ExplainPriceSection';
import ExplainRangeSection from './explain/ExplainRangeSection';
import ExplainStorySection from './explain/ExplainStorySection';
import ExplainSimilarDaysSection from './explain/ExplainSimilarDaysSection';
import ExplainSignalsSection from './explain/ExplainSignalsSection';
import ExplainSummarySection from './explain/ExplainSummarySection';
import ExplainTradesSection from './explain/ExplainTradesSection';
import ExplainInsiderSection from './explain/ExplainInsiderSection';
import ExplainTechnicalSection from './explain/ExplainTechnicalSection';
import { EVENT_CATEGORY_META, eventDateKey } from './explain/explainUtils';
import useExplainModel from './explain/useExplainModel';
import { formatDateTime, formatNumber, formatTickerPrice } from '../utils/formatters';
import { formatNumber, formatTickerPrice } from '../utils/formatters';
export default function StockExplainView({
tickers,
@@ -28,74 +17,34 @@ export default function StockExplainView({
selectedSymbol,
onSelectedSymbolChange,
selectedHistorySource,
explainEventsSnapshot,
newsSnapshot,
insiderTradesSnapshot,
technicalIndicatorsSnapshot,
onRequestRangeExplain,
onRequestHistory,
onRequestExplainEvents,
onRequestNews,
onRequestNewsForDate,
onRequestStory,
onRequestInsiderTrades,
onRequestTechnicalIndicators,
currentDate,
onRequestSimilarDays,
onRequestStockEnrich
}) {
const [selectedEventDate, setSelectedEventDate] = useState('');
const [activeEventCategory, setActiveEventCategory] = useState('all');
const [activeNewsCategory, setActiveNewsCategory] = useState('all');
const [activeNewsSentiment, setActiveNewsSentiment] = useState('all');
const [isPriceOpen, setIsPriceOpen] = useState(true);
const [isSummaryOpen, setIsSummaryOpen] = useState(true);
const [isSignalsOpen, setIsSignalsOpen] = useState(true);
const [isNewsOpen, setIsNewsOpen] = useState(true);
const [isRangeOpen, setIsRangeOpen] = useState(true);
const [isMentionsPanelOpen, setIsMentionsPanelOpen] = useState(false);
const [isEventPanelOpen, setIsEventPanelOpen] = useState(false);
const [isMaintenanceOpen, setIsMaintenanceOpen] = useState(false);
const [isStoryOpen, setIsStoryOpen] = useState(false);
const [isTradesOpen, setIsTradesOpen] = useState(false);
const [isInsiderOpen, setIsInsiderOpen] = useState(false);
const [isTechnicalOpen, setIsTechnicalOpen] = useState(true);
const [isSimilarDaysOpen, setIsSimilarDaysOpen] = useState(false);
const [enrichStartDate, setEnrichStartDate] = useState('');
const [enrichEndDate, setEnrichEndDate] = useState('');
const [forceEnrich, setForceEnrich] = useState(false);
const [onlyLocalToLlm, setOnlyLocalToLlm] = useState(false);
const [rebuildStory, setRebuildStory] = useState(true);
const [rebuildSimilarDays, setRebuildSimilarDays] = useState(true);
const {
availableSymbols,
selectedTicker,
holding,
tickerSignals,
signalSummary,
tickerTrades,
recentMentions,
tickerNews,
visibleNews,
newsCategories,
visibleNewsByCategory,
selectedNewsFreshness,
selectedRangeWindow,
selectedRangeExplain,
latestSignal,
priceColor,
exposureWeight,
recentTrade,
ohlcSeries,
priceSeries,
explainSummary,
selectedStory,
selectedSimilarDays,
explainTimeline,
availableEventDates,
eventCategoryCounts,
visibleExplainEvents,
chartModel
} = useExplainModel({
tickers,
@@ -106,10 +55,9 @@ export default function StockExplainView({
priceHistoryByTicker,
ohlcHistoryByTicker,
selectedSymbol,
explainEventsSnapshot,
newsSnapshot,
selectedEventDate,
activeEventCategory,
selectedEventDate: '',
activeEventCategory: 'all',
activeNewsCategory,
activeNewsSentiment
});
@@ -125,25 +73,10 @@ export default function StockExplainView({
}
}, [availableSymbols, onSelectedSymbolChange, selectedSymbol]);
useEffect(() => {
if (!availableEventDates.length) {
setSelectedEventDate('');
return;
}
if (!selectedEventDate || !availableEventDates.includes(selectedEventDate)) {
setSelectedEventDate(availableEventDates[0]);
}
}, [availableEventDates, selectedEventDate]);
useEffect(() => {
setActiveEventCategory('all');
}, [selectedSymbol]);
useEffect(() => {
setActiveNewsCategory('all');
setActiveNewsSentiment('all');
}, [selectedSymbol, selectedEventDate]);
}, [selectedSymbol]);
useEffect(() => {
if (!selectedSymbol) {
@@ -154,53 +87,17 @@ export default function StockExplainView({
onRequestHistory(selectedSymbol);
}
if (onRequestExplainEvents && !explainEventsSnapshot) {
onRequestExplainEvents(selectedSymbol);
}
if (onRequestNews && (!Array.isArray(newsSnapshot?.items) || newsSnapshot.items.length === 0)) {
onRequestNews(selectedSymbol);
}
}, [
explainEventsSnapshot,
newsSnapshot,
ohlcHistoryByTicker,
onRequestExplainEvents,
onRequestHistory,
onRequestNews,
selectedSymbol,
]);
useEffect(() => {
if (!selectedSymbol || !selectedEventDate || !onRequestNewsForDate) {
return;
}
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.byDate || {}, selectedEventDate)) {
return;
}
onRequestNewsForDate(selectedSymbol, selectedEventDate);
}, [newsSnapshot, onRequestNewsForDate, selectedEventDate, selectedSymbol]);
useEffect(() => {
if (!selectedSymbol || !onRequestStory || !currentDate) {
return;
}
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.storyCache || {}, currentDate)) {
return;
}
onRequestStory(selectedSymbol, currentDate);
}, [currentDate, newsSnapshot, onRequestStory, selectedStory, selectedSymbol]);
useEffect(() => {
if (!selectedSymbol || !selectedEventDate || !onRequestSimilarDays) {
return;
}
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.similarDaysCache || {}, selectedEventDate)) {
return;
}
onRequestSimilarDays(selectedSymbol, selectedEventDate);
}, [newsSnapshot, onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
useEffect(() => {
if (!selectedSymbol || !onRequestTechnicalIndicators) {
return;
@@ -211,67 +108,6 @@ export default function StockExplainView({
onRequestTechnicalIndicators(selectedSymbol);
}, [selectedSymbol, onRequestTechnicalIndicators, technicalIndicatorsSnapshot]);
useEffect(() => {
if (!selectedRangeWindow || !selectedSymbol || !onRequestRangeExplain) {
return;
}
if (selectedRangeExplain) {
return;
}
onRequestRangeExplain(selectedSymbol, selectedRangeWindow.startDate, selectedRangeWindow.endDate, visibleNews.map((item) => item.id));
}, [onRequestRangeExplain, selectedRangeExplain, selectedRangeWindow, selectedSymbol, visibleNews]);
useEffect(() => {
const nextStartDate = selectedRangeWindow?.startDate || selectedEventDate || currentDate || '';
const nextEndDate = selectedRangeWindow?.endDate || selectedEventDate || currentDate || '';
setEnrichStartDate(nextStartDate);
setEnrichEndDate(nextEndDate);
}, [currentDate, selectedEventDate, selectedRangeWindow, selectedSymbol]);
const handleRunStockEnrich = () => {
if (!selectedSymbol || !enrichStartDate || !enrichEndDate || !onRequestStockEnrich) {
return;
}
onRequestStockEnrich(selectedSymbol, {
startDate: enrichStartDate,
endDate: enrichEndDate,
force: forceEnrich,
onlyLocalToLlm,
rebuildStory,
rebuildSimilarDays,
storyDate: currentDate || enrichEndDate,
targetDate: selectedEventDate || enrichEndDate,
});
};
const handleSelectHistory = (item) => {
if (!item || typeof item !== 'object') {
return;
}
setEnrichStartDate(item.startDate || '');
setEnrichEndDate(item.endDate || '');
setForceEnrich(Boolean(item.force));
setOnlyLocalToLlm(Boolean(item.onlyLocalToLlm));
setRebuildStory(Boolean(item.storyStatus));
setRebuildSimilarDays(Boolean(item.similarStatus));
};
const handleReplayHistory = (item) => {
if (!item || typeof item !== 'object' || !selectedSymbol || !onRequestStockEnrich) {
return;
}
onRequestStockEnrich(selectedSymbol, {
startDate: item.startDate || '',
endDate: item.endDate || '',
force: Boolean(item.force),
onlyLocalToLlm: Boolean(item.onlyLocalToLlm),
rebuildStory: Boolean(item.storyStatus),
rebuildSimilarDays: Boolean(item.similarStatus),
storyDate: currentDate || item.endDate || '',
targetDate: selectedEventDate || item.endDate || '',
});
};
return (
<div className="performance-page">
<div className="section">
@@ -285,7 +121,6 @@ export default function StockExplainView({
onClick={() => onSelectedSymbolChange?.(symbol)}
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
<StockLogo ticker={symbol} size={14} />
<span>{symbol}</span>
</button>
))}
@@ -326,15 +161,6 @@ export default function StockExplainView({
</div>
</div>
<div className="stat-card">
<div className="stat-card-label">最近动作</div>
<div className="stat-card-value" style={{ fontSize: 22 }}>
{recentTrade ? recentTrade.side === 'LONG' ? '做多' : recentTrade.side === 'SHORT' ? '做空' : recentTrade.side : '暂无'}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
{recentTrade ? `${formatDateTime(recentTrade.timestamp)} · ${recentTrade.qty} 股,成交价 $${Number(recentTrade.price).toFixed(2)}` : '尚无成交'}
</div>
</div>
</div>
)}
</div>
@@ -347,22 +173,10 @@ export default function StockExplainView({
selectedHistorySource={selectedHistorySource}
chartModel={chartModel}
selectedTicker={selectedTicker}
onSelectEventDate={setSelectedEventDate}
isOpen={isPriceOpen}
onToggle={() => setIsPriceOpen((prev) => !prev)}
/>
<ExplainSummarySection
explainSummary={explainSummary}
tickerSignals={tickerSignals}
recentMentions={recentMentions}
tickerTrades={tickerTrades}
tickerNews={tickerNews}
selectedSymbol={selectedSymbol}
isOpen={isSummaryOpen}
onToggle={() => setIsSummaryOpen((prev) => !prev)}
/>
<ExplainNewsSection
newsSnapshot={newsSnapshot}
visibleNewsByCategory={visibleNewsByCategory}
@@ -378,45 +192,6 @@ export default function StockExplainView({
onToggle={() => setIsNewsOpen((prev) => !prev)}
/>
<ExplainRangeSection
selectedRangeWindow={selectedRangeWindow}
selectedRangeExplain={selectedRangeExplain}
isOpen={isRangeOpen}
onToggle={() => setIsRangeOpen((prev) => !prev)}
/>
<ExplainSignalsSection
tickerSignals={tickerSignals}
signalSummary={signalSummary}
latestSignal={latestSignal}
eventDateKey={eventDateKey}
isOpen={isSignalsOpen}
onToggle={() => setIsSignalsOpen((prev) => !prev)}
/>
<ExplainStorySection
selectedStory={selectedStory}
selectedSymbol={selectedSymbol}
currentDate={currentDate}
isOpen={isStoryOpen}
onToggle={() => setIsStoryOpen((prev) => !prev)}
/>
<ExplainSimilarDaysSection
selectedSimilarDays={selectedSimilarDays}
selectedEventDate={selectedEventDate}
onSelectSimilarDate={setSelectedEventDate}
isOpen={isSimilarDaysOpen}
onToggle={() => setIsSimilarDaysOpen((prev) => !prev)}
/>
<ExplainTradesSection
tickerTrades={tickerTrades}
selectedSymbol={selectedSymbol}
isOpen={isTradesOpen}
onToggle={() => setIsTradesOpen((prev) => !prev)}
/>
<ExplainInsiderSection
insiderTrades={insiderTradesSnapshot?.trades || []}
selectedSymbol={selectedSymbol}
@@ -431,50 +206,6 @@ export default function StockExplainView({
isOpen={isTechnicalOpen}
onToggle={() => setIsTechnicalOpen((prev) => !prev)}
/>
<ExplainMentionsSection
recentMentions={recentMentions}
isOpen={isMentionsPanelOpen}
onToggle={() => setIsMentionsPanelOpen((prev) => !prev)}
/>
<ExplainEventsSection
explainTimeline={explainTimeline}
isOpen={isEventPanelOpen}
onToggle={() => setIsEventPanelOpen((prev) => !prev)}
availableEventDates={availableEventDates}
selectedEventDate={selectedEventDate}
onSelectEventDate={setSelectedEventDate}
eventCategoryCounts={eventCategoryCounts}
activeEventCategory={activeEventCategory}
onSelectEventCategory={setActiveEventCategory}
eventCategoryMeta={EVENT_CATEGORY_META}
visibleExplainEvents={visibleExplainEvents}
/>
<ExplainMaintenanceSection
selectedSymbol={selectedSymbol}
enrichStartDate={enrichStartDate}
enrichEndDate={enrichEndDate}
onChangeStartDate={setEnrichStartDate}
onChangeEndDate={setEnrichEndDate}
forceEnrich={forceEnrich}
onToggleForce={() => setForceEnrich((prev) => !prev)}
onlyLocalToLlm={onlyLocalToLlm}
onToggleOnlyLocalToLlm={() => setOnlyLocalToLlm((prev) => !prev)}
rebuildStory={rebuildStory}
onToggleRebuildStory={() => setRebuildStory((prev) => !prev)}
rebuildSimilarDays={rebuildSimilarDays}
onToggleRebuildSimilarDays={() => setRebuildSimilarDays((prev) => !prev)}
isRunning={Boolean(newsSnapshot?.maintenanceStatus?.running)}
onRunEnrich={handleRunStockEnrich}
maintenanceStatus={newsSnapshot?.maintenanceStatus || null}
maintenanceHistory={newsSnapshot?.maintenanceHistory || []}
onSelectHistory={handleSelectHistory}
onReplayHistory={handleReplayHistory}
isOpen={isMaintenanceOpen}
onToggle={() => setIsMaintenanceOpen((prev) => !prev)}
/>
</>
)}
</div>

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { STOCK_LOGOS } from '../config/constants';
/**
* Stock Logo Component
* Displays company logo for a given ticker symbol
*/
export default function StockLogo({ ticker, size = 20 }) {
const logoUrl = STOCK_LOGOS[ticker];
if (!logoUrl) return null;
return (
<img
src={logoUrl}
alt={ticker}
style={{
width: size,
height: size,
borderRadius: '4px',
objectFit: 'contain',
marginRight: '8px',
verticalAlign: 'middle'
}}
onError={(e) => { e.target.style.display = 'none'; }}
/>
);
}

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import JSZip from 'jszip';
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
export default function TraderView({
agents,
@@ -127,7 +128,7 @@ export default function TraderView({
padding: '18px',
background: 'linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%)',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
gridTemplateRows: 'auto auto 1fr',
gap: 18
}}>
<div style={{ display: 'grid', gap: 4 }}>
@@ -138,82 +139,86 @@ export default function TraderView({
聚焦查看每个 Agent 的模型工具组技能编排和工作区记忆不展示交易表现数据
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: '120px minmax(0, 1fr)',
gap: 16,
alignItems: 'stretch',
minHeight: 0
minHeight: 0,
overflow: 'hidden'
}}>
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
background: '#FFFFFF',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
padding: 12,
display: 'grid',
gap: 10,
minHeight: 0,
overflowY: 'auto',
alignContent: 'start'
}}>
{agents.map((agent) => {
const isSelected = agent.id === selectedAgentId;
return (
<button
key={agent.id}
type="button"
onClick={() => onAgentChange(agent.id)}
title={agent.name}
{/* Left: agent avatar list */}
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
background: '#FFFFFF',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
padding: 12,
display: 'grid',
gap: 10,
minHeight: 0,
overflowY: 'auto',
alignContent: 'start'
}}>
{agents.map((agent) => {
const isSelected = agent.id === selectedAgentId;
return (
<button
key={agent.id}
type="button"
onClick={() => onAgentChange(agent.id)}
title={agent.name}
style={{
border: isSelected ? `2px solid ${agent.colors.accent}` : '1px solid #D9E0E7',
borderRadius: 16,
background: isSelected ? `${agent.colors.accent}10` : '#FFFFFF',
boxShadow: isSelected ? `0 10px 20px ${agent.colors.accent}18` : 'none',
padding: 8,
display: 'grid',
gap: 6,
justifyItems: 'center',
cursor: 'pointer'
}}
>
<img
src={agent.avatar}
alt={agent.name}
style={{
border: isSelected ? `2px solid ${agent.colors.accent}` : '1px solid #D9E0E7',
borderRadius: 16,
background: isSelected ? `${agent.colors.accent}10` : '#FFFFFF',
boxShadow: isSelected ? `0 10px 20px ${agent.colors.accent}18` : 'none',
padding: 8,
display: 'grid',
gap: 6,
justifyItems: 'center',
cursor: 'pointer'
width: 56,
height: 56,
borderRadius: 14,
objectFit: 'cover',
border: `1px solid ${agent.colors.accent}33`
}}
>
<img
src={agent.avatar}
alt={agent.name}
style={{
width: 56,
height: 56,
borderRadius: 14,
objectFit: 'cover',
border: `1px solid ${agent.colors.accent}33`
}}
/>
<div style={{
fontSize: 10,
fontWeight: 800,
color: isSelected ? agent.colors.accent : '#374151',
textAlign: 'center',
lineHeight: 1.4
}}>
{agent.name}
</div>
</button>
);
})}
</div>
/>
<div style={{
fontSize: 10,
fontWeight: 800,
color: isSelected ? agent.colors.accent : '#374151',
textAlign: 'center',
lineHeight: 1.4
}}>
{agent.name}
</div>
</button>
);
})}
</div>
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
background: '#FFFFFF',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
padding: 18,
display: 'grid',
gap: 16,
minHeight: 0,
overflowY: 'auto',
alignContent: 'start'
}}>
{/* Right: agent detail content */}
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
background: '#FFFFFF',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
padding: 18,
display: 'grid',
gap: 16,
minHeight: 0,
overflowY: 'auto',
alignContent: 'start'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<img
@@ -245,13 +250,16 @@ export default function TraderView({
alignItems: 'center',
gap: 10
}}>
{modelInfo.logoPath && (
<img
src={modelInfo.logoPath}
alt={modelInfo.provider}
style={{ width: 26, height: 26, borderRadius: 999 }}
/>
)}
<LobeModelLogo
model={profile.model_name}
provider={profile.model_provider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={26}
shape="circle"
type="color"
style={{ borderRadius: 999 }}
/>
<div style={{ display: 'grid', gap: 2 }}>
<div style={{ fontSize: 11, color: '#4B5563', fontWeight: 700 }}>模型</div>
<div style={{ fontSize: 12, color: '#111111', fontWeight: 800 }}>
@@ -265,7 +273,8 @@ export default function TraderView({
display: 'grid',
gridTemplateColumns: 'minmax(300px, 420px) minmax(0, 1fr)',
gap: 16,
alignItems: 'start'
alignItems: 'start',
minHeight: 0
}}>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{
@@ -626,7 +635,7 @@ export default function TraderView({
</div>
</div>
</div>
</div>
</div>
</div>
{isSkillPickerOpen && createPortal((
<div

View File

@@ -198,9 +198,6 @@ export default function ExplainPriceSection({
图表说明{ohlcSeries.length > 1 ? '历史日线K线' : '基于盘中价格点聚合的简化K线'}
</div>
<div style={{ fontSize: 11, color: '#2563eb' }}>蓝点新闻日期</div>
<div style={{ fontSize: 11, color: '#666666' }}>黑点讨论提及</div>
<div style={{ fontSize: 11, color: '#00C853' }}>绿点偏多信号或做多成交</div>
<div style={{ fontSize: 11, color: '#FF1744' }}>红点偏空信号或做空成交</div>
</div>
</div>
)}

View File

@@ -2,8 +2,6 @@ import React from 'react';
export default function ExplainSummarySection({
explainSummary,
tickerSignals,
recentMentions,
tickerTrades,
tickerNews,
selectedSymbol,
@@ -16,7 +14,7 @@ export default function ExplainSummarySection({
<h2 className="section-title">分析摘要</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
基于当前持仓信号和讨论自动汇总
基于当前持仓成交和新闻自动汇总
</div>
<button
onClick={onToggle}
@@ -55,17 +53,9 @@ export default function ExplainSummarySection({
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
信号密度
分析概览
</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>分析师信号</span>
<strong>{tickerSignals.length}</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>讨论提及</span>
<strong>{recentMentions.length}</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>成交记录</span>
<strong>{tickerTrades.length}</strong>
@@ -76,7 +66,7 @@ export default function ExplainSummarySection({
</div>
<div style={{ height: 1, background: '#e0e0e0', margin: '4px 0' }} />
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#666666' }}>
当前分析优先读取已落库的历史记录缺失时再回退到本次运行中的实时事件
当前分析综合读取信号成交新闻与已生成的解释结果
</div>
</div>
</div>

View File

@@ -1,28 +1,16 @@
import { useMemo } from 'react';
import { formatDateTime } from '../../utils/formatters';
import {
aggregatePriceSeriesToCandles,
buildLinePath,
eventDateKey,
flattenFeedMessages,
includesTicker,
normalizeMentionRow,
normalizeNewsRow,
normalizeNewsTimelineRow,
normalizeSignalDirection,
normalizeSignalRow,
normalizeTradeRow,
parsePointTime,
resolveEventCategory,
snippetText
resolveEventCategory
} from './explainUtils';
function tradeSideLabel(value) {
if (value === 'LONG') return '做多';
if (value === 'SHORT') return '做空';
return value || '交易';
}
export default function useExplainModel({
tickers,
holdings,
@@ -55,13 +43,6 @@ export default function useExplainModel({
[holdings, selectedSymbol]
);
const fallbackTrades = useMemo(
() => trades
.filter((trade) => trade.ticker === selectedSymbol)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()),
[selectedSymbol, trades]
);
const tickerSignals = useMemo(() => {
const snapshotSignals = Array.isArray(explainEventsSnapshot?.signals)
? explainEventsSnapshot.signals.map((signal, index) => normalizeSignalRow(signal, index)).filter(Boolean)
@@ -84,45 +65,6 @@ export default function useExplainModel({
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [explainEventsSnapshot, leaderboard, selectedSymbol]);
const signalSummary = useMemo(() => {
const summary = { bullish: 0, bearish: 0, neutral: 0 };
tickerSignals.forEach((signal) => {
summary[signal.normalizedDirection] += 1;
});
return summary;
}, [tickerSignals]);
const fallbackRecentMentions = useMemo(() => {
const flattened = flattenFeedMessages(feed);
return flattened
.filter((message) => message.agent !== 'System' && includesTicker(message.content, selectedSymbol))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 8);
}, [feed, selectedSymbol]);
const tickerTrades = useMemo(() => {
const snapshotTrades = Array.isArray(explainEventsSnapshot?.trades)
? explainEventsSnapshot.trades.map((trade, index) => normalizeTradeRow(trade, index)).filter(Boolean)
: [];
if (snapshotTrades.length > 0) {
return snapshotTrades.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
return fallbackTrades;
}, [explainEventsSnapshot, fallbackTrades]);
const recentMentions = useMemo(() => {
const snapshotMentions = Array.isArray(explainEventsSnapshot?.events)
? explainEventsSnapshot.events
.map((event, index) => normalizeMentionRow(event, index))
.filter(Boolean)
.slice(0, 8)
: [];
if (snapshotMentions.length > 0) {
return snapshotMentions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
return fallbackRecentMentions;
}, [explainEventsSnapshot, fallbackRecentMentions]);
const tickerNews = useMemo(() => {
const items = Array.isArray(newsSnapshot?.items)
? newsSnapshot.items.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean)
@@ -140,16 +82,7 @@ export default function useExplainModel({
return rows.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean);
}, [newsSnapshot, selectedEventDate]);
const visibleNews = useMemo(() => {
if (!selectedEventDate) {
return tickerNews;
}
if (dateScopedNews.length > 0) {
return dateScopedNews.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
const scoped = tickerNews.filter((item) => item.dateKey === selectedEventDate);
return scoped.length > 0 ? scoped : tickerNews;
}, [dateScopedNews, selectedEventDate, tickerNews]);
const visibleNews = useMemo(() => tickerNews, [tickerNews]);
const tickerNewsTimeline = useMemo(() => {
const items = Array.isArray(newsSnapshot?.timeline)
@@ -215,28 +148,13 @@ export default function useExplainModel({
return storyCache[keys[keys.length - 1]] || null;
}, [newsSnapshot]);
const selectedSimilarDays = useMemo(() => {
if (!selectedEventDate) {
return null;
}
const similarCache = newsSnapshot?.similarDaysCache;
if (!similarCache || typeof similarCache !== 'object') {
return null;
}
return similarCache[selectedEventDate] || null;
}, [newsSnapshot, selectedEventDate]);
const selectedNewsFreshness = useMemo(
() => newsSnapshot?.freshness || newsSnapshot?.categoriesFreshness || newsSnapshot?.timelineFreshness || null,
[newsSnapshot]
);
const selectedNewsFreshness = useMemo(() => {
if (selectedEventDate && newsSnapshot?.byDateFreshness?.[selectedEventDate]) {
return newsSnapshot.byDateFreshness[selectedEventDate];
}
return newsSnapshot?.categoriesFreshness || newsSnapshot?.timelineFreshness || newsSnapshot?.freshness || null;
}, [newsSnapshot, selectedEventDate]);
const latestSignal = tickerSignals[0] || null;
const priceColor = selectedTicker?.change > 0 ? '#00C853' : selectedTicker?.change < 0 ? '#FF1744' : '#000000';
const exposureWeight = holding && Number.isFinite(Number(holding.weight)) ? Number(holding.weight) * 100 : null;
const recentTrade = tickerTrades[0] || null;
const ohlcSeries = useMemo(() => {
const raw = ohlcHistoryByTicker?.[selectedSymbol];
@@ -248,38 +166,6 @@ export default function useExplainModel({
return Array.isArray(raw) ? raw.filter((point) => Number.isFinite(Number(point.price))).slice(-60) : [];
}, [priceHistoryByTicker, selectedSymbol]);
const explainSummary = useMemo(() => {
if (!selectedSymbol) return [];
const lines = [];
if (latestSignal) {
const directionText = latestSignal.normalizedDirection === 'bullish'
? '偏多'
: latestSignal.normalizedDirection === 'bearish'
? '偏空'
: '观望';
lines.push(`最新分析师结论为${directionText},来自${latestSignal.agentName}`);
} else {
lines.push('当前还没有形成结构化分析师信号,更多依赖讨论内容和持仓状态。');
}
if (holding) {
lines.push(`组合当前持有 ${selectedSymbol},权重约 ${exposureWeight != null ? `${exposureWeight.toFixed(2)}%` : '0.00%'}`);
} else {
lines.push(`组合当前未持有 ${selectedSymbol},仍处于观察阶段。`);
}
if (recentTrade) {
lines.push(`最近一次相关交易为${tradeSideLabel(recentTrade.side)},时间是 ${formatDateTime(recentTrade.timestamp)}`);
}
if (recentMentions.length > 0) {
lines.push(`最近讨论中共有 ${recentMentions.length} 条直接提及 ${selectedSymbol} 的观点。`);
}
return lines;
}, [exposureWeight, holding, latestSignal, recentMentions.length, recentTrade, selectedSymbol]);
const explainTimeline = useMemo(() => {
const signalEvents = tickerSignals.slice(0, 12).map((signal, index) => ({
id: `signal-${signal.agentId}-${signal.date}-${index}`,
@@ -293,27 +179,7 @@ export default function useExplainModel({
tone: signal.normalizedDirection === 'bullish' ? 'positive' : signal.normalizedDirection === 'bearish' ? 'negative' : 'neutral'
}));
const mentionEvents = recentMentions.slice(0, 12).map((message, index) => ({
id: `mention-${message.feedId || message.id}-${index}`,
type: 'mention',
timestamp: message.timestamp,
title: `${message.agent || '未知角色'}${message.conferenceTitle || '讨论流'}中提及 ${selectedSymbol}`,
meta: message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息'),
body: snippetText(message.content, selectedSymbol),
tone: 'neutral'
}));
const tradeEvents = tickerTrades.slice(0, 12).map((trade, index) => ({
id: `trade-${trade.id || `${trade.ticker}-${trade.timestamp}-${index}`}`,
type: 'trade',
timestamp: trade.timestamp,
title: `${tradeSideLabel(trade.side)} ${trade.qty}`,
meta: '交易执行',
body: `成交价 $${Number(trade.price).toFixed(2)}`,
tone: trade.side === 'LONG' ? 'positive' : trade.side === 'SHORT' ? 'negative' : 'neutral'
}));
const fallbackTimeline = [...signalEvents, ...mentionEvents, ...tradeEvents]
const fallbackTimeline = [...signalEvents]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 24)
.map((event) => ({
@@ -356,49 +222,7 @@ export default function useExplainModel({
})
.filter(Boolean);
const dbMentionEvents = (Array.isArray(explainEventsSnapshot.events) ? explainEventsSnapshot.events : [])
.map((event, index) => {
if (event?.type === 'mention' && event?.timestamp) {
return event;
}
const normalized = normalizeMentionRow(event, index);
if (!normalized) return null;
return {
id: normalized.id,
type: 'mention',
timestamp: normalized.timestamp,
title: `${normalized.agent || '未知角色'}${normalized.conferenceTitle || '讨论流'}中提及 ${selectedSymbol}`,
meta: normalized.conferenceTitle || (normalized.feedType === 'conference' ? '投资讨论' : '即时消息'),
body: snippetText(normalized.content, selectedSymbol),
tone: 'neutral'
};
})
.filter(Boolean);
const dbTradeEvents = (Array.isArray(explainEventsSnapshot.trades) ? explainEventsSnapshot.trades : [])
.map((trade, index) => {
if (trade?.type === 'trade' && trade?.timestamp) {
return trade;
}
const normalized = normalizeTradeRow(trade, index);
if (!normalized) return null;
return {
id: normalized.id,
type: 'trade',
timestamp: normalized.timestamp,
title: `${tradeSideLabel(normalized.side)} ${normalized.qty}`,
meta: '交易执行',
body: `成交价 $${Number(normalized.price).toFixed(2)}`,
tone: normalized.side === 'LONG' ? 'positive' : normalized.side === 'SHORT' ? 'negative' : 'neutral'
};
})
.filter(Boolean);
const dbEvents = [
...dbSignalEvents,
...dbMentionEvents,
...dbTradeEvents
]
const dbEvents = [...dbSignalEvents]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 24)
.map((event) => ({
@@ -408,7 +232,7 @@ export default function useExplainModel({
}));
return dbEvents.length > 0 ? dbEvents : fallbackTimeline;
}, [explainEventsSnapshot, recentMentions, selectedSymbol, tickerSignals, tickerTrades]);
}, [explainEventsSnapshot, selectedSymbol, tickerSignals]);
const availableEventDates = useMemo(
() => Array.from(new Set(explainTimeline.map((event) => event.dateKey).filter(Boolean))),
@@ -644,9 +468,6 @@ export default function useExplainModel({
selectedTicker,
holding,
tickerSignals,
signalSummary,
tickerTrades,
recentMentions,
tickerNews,
visibleNews,
newsCategories,
@@ -655,14 +476,10 @@ export default function useExplainModel({
selectedRangeWindow,
selectedRangeExplain,
selectedStory,
selectedSimilarDays,
latestSignal,
priceColor,
exposureWeight,
recentTrade,
ohlcSeries,
priceSeries,
explainSummary,
explainTimeline,
availableEventDates,
eventCategoryCounts,

View File

@@ -117,7 +117,7 @@ describe('useExplainModel', () => {
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
expect(result.current.availableSymbols).toEqual(['AAPL']);
expect(result.current.visibleNews).toHaveLength(1);
expect(result.current.visibleNews).toHaveLength(2);
expect(result.current.visibleNewsByCategory).toHaveLength(1);
expect(result.current.visibleNewsByCategory[0].id).toBe('news-1');
expect(result.current.selectedRangeWindow).toEqual({
@@ -127,18 +127,12 @@ describe('useExplainModel', () => {
expect(result.current.selectedRangeExplain).toEqual({
summary: '区间内主要由财报催化推动。'
});
expect(result.current.selectedSimilarDays?.items).toHaveLength(1);
});
it('builds timeline, counts, and chart markers from explain data', () => {
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
expect(result.current.availableEventDates).toContain('2026-03-10');
expect(result.current.eventCategoryCounts.all).toBe(3);
expect(result.current.eventCategoryCounts.technical).toBe(1);
expect(result.current.eventCategoryCounts.discussion).toBe(1);
expect(result.current.eventCategoryCounts.trade).toBe(1);
expect(result.current.visibleExplainEvents).toHaveLength(3);
expect(result.current.chartModel.markers.length).toBeGreaterThan(0);
expect(result.current.chartModel.path).toMatch(/^M/);
});

View File

@@ -36,15 +36,6 @@ export const CDN_ASSETS = {
"Groq": "https://img.alicdn.com/imgextra/i1/O1CN01WxASMc1QjXzhVl3eQ_!!6000000002012-2-tps-170-148.png",
"Ollama": "https://img.alicdn.com/imgextra/i1/O1CN01pN615e1i4vxLkQjVd_!!6000000004360-2-tps-204-192.png",
},
stockLogos: {
"TSLA": "https://img.alicdn.com/imgextra/i4/O1CN01Pch4DD1DDrad8BQAQ_!!6000000000183-2-tps-128-128.png",
"AMZN": "https://img.alicdn.com/imgextra/i3/O1CN01KMsfnU25Wd4MGSgue_!!6000000007534-2-tps-128-128.png",
"NVDA": "https://img.alicdn.com/imgextra/i4/O1CN01Lq1eJr1mLeslgx6a0_!!6000000004938-2-tps-128-128.png",
"GOOGL": "https://img.alicdn.com/imgextra/i2/O1CN01kjJJbb25B6SESkOCn_!!6000000007487-2-tps-128-128.png",
"MSFT": "https://img.alicdn.com/imgextra/i4/O1CN01tdlNtQ1aFS7vHYfMG_!!6000000003300-2-tps-128-128.png",
"AAPL": "https://img.alicdn.com/imgextra/i4/O1CN01r0GH0q1diiHHOwxiO_!!6000000003770-2-tps-128-128.png",
"META": "https://img.alicdn.com/imgextra/i3/O1CN01pWAvHt1IkRqZoUG96_!!6000000000931-2-tps-130-96.png",
}
};
// Derived asset shortcuts
@@ -54,9 +45,6 @@ export const ASSETS = {
remeLogo: CDN_ASSETS.companyRoom.reme_logo,
};
// Stock logos mapping
export const STOCK_LOGOS = { ...CDN_ASSETS.stockLogos };
// Scene dimensions (actual image size)
export const SCENE_NATIVE = { width: 1248, height: 832 };

View File

@@ -0,0 +1,356 @@
import { useCallback } from "react";
import { useOpenClawStore } from "../store/openclawStore";
const RETRY_DELAY_MS = 250;
function sendWithRetry(clientRef, payload, retries = 3) {
const attemptSend = (remaining) => {
const client = clientRef.current;
if (!client) return false;
const sent = client.send(typeof payload === "string" ? payload : JSON.stringify(payload));
if (sent || remaining <= 0) return sent;
window.setTimeout(() => attemptSend(remaining - 1), RETRY_DELAY_MS);
return false;
};
return attemptSend(retries);
}
export function useOpenClawPanel() {
// Access store state directly — do NOT destructure store as a useCallback dep
// or every store update will recreate all callbacks and trigger infinite loops.
const getStore = () => useOpenClawStore.getState();
const requestStatus = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setStatusLoading(true);
store.setStatusError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_status" });
}, []);
const requestSessions = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSessionsLoading(true);
store.setSessionsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_sessions" });
}, []);
const requestSessionDetail = useCallback((sessionKey) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSelectedSessionKey(sessionKey);
store.setSessionDetailLoading(true);
store.setSessionDetailError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_session_detail", session_key: sessionKey });
}, []);
const requestSessionHistory = useCallback((sessionKey, limit = 20) => {
const client = getStore().clientRef?.current;
if (!client) return;
sendWithRetry({ current: client }, {
type: "get_openclaw_session_history",
session_key: sessionKey,
limit,
});
}, []);
const resolveSession = useCallback(({ agentId, label = null, channel = null, includeGlobal = true }) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_resolve_session",
agent_id: agentId,
label,
channel,
include_global: includeGlobal,
});
}, []);
const createSession = useCallback(({ agentId, label = null, model = null, initialMessage = null }) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !agentId) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_create_session",
agent_id: agentId,
label,
model,
initial_message: initialMessage,
});
}, []);
const subscribeSession = useCallback((sessionKey) => {
const client = getStore().clientRef?.current;
if (!client || !sessionKey) return;
sendWithRetry({ current: client }, {
type: "openclaw_subscribe_session",
session_key: sessionKey,
});
}, []);
const unsubscribeSession = useCallback((sessionKey) => {
const client = getStore().clientRef?.current;
if (!client || !sessionKey) return;
sendWithRetry({ current: client }, {
type: "openclaw_unsubscribe_session",
session_key: sessionKey,
});
}, []);
const resetSession = useCallback((sessionKey) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !sessionKey) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_reset_session",
session_key: sessionKey,
});
}, []);
const deleteSession = useCallback((sessionKey) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !sessionKey) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_delete_session",
session_key: sessionKey,
});
}, []);
const sendSessionMessage = useCallback((sessionKey, message, thinking = null) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !sessionKey || !message?.trim()) return;
sendWithRetry({ current: client }, {
type: "openclaw_subscribe_session",
session_key: sessionKey,
});
store.setOpenclawChatSendingForSession?.(sessionKey, true);
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_send_message",
session_key: sessionKey,
message: message.trim(),
thinking,
});
}, []);
const requestCron = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setCronLoading(true);
store.setCronError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_cron" });
}, []);
const requestApprovals = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setApprovalsLoading(true);
store.setApprovalsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_approvals" });
}, []);
const requestAgents = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setAgentsLoading(true);
store.setAgentsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_agents" });
}, []);
const requestAgentsPresence = useCallback(() => {
const client = getStore().clientRef?.current;
if (!client) return;
sendWithRetry({ current: client }, { type: "get_openclaw_agents_presence" });
}, []);
const requestSkills = useCallback((agentId = null) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSkillsLoading(true);
store.setSkillsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_skills", agent_id: agentId });
}, []);
const requestModels = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsLoading(true);
store.setModelsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models" });
}, []);
const requestHooks = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setHooksLoading(true);
store.setHooksError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_hooks" });
}, []);
const requestPlugins = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setPluginsLoading(true);
store.setPluginsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_plugins" });
}, []);
const requestSecretsAudit = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSecretsAuditLoading(true);
store.setSecretsAuditError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_secrets_audit" });
}, []);
const requestSecurityAudit = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSecurityAuditLoading(true);
store.setSecurityAuditError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_security_audit" });
}, []);
const requestDaemonStatus = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setDaemonStatusLoading(true);
store.setDaemonStatusError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_daemon_status" });
}, []);
const requestPairing = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setPairingLoading(true);
store.setPairingError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_pairing" });
}, []);
const requestQrCode = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setQrCodeLoading(true);
store.setQrCodeError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_qr" });
}, []);
const requestUpdateStatus = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setUpdateStatusLoading(true);
store.setUpdateStatusError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_update_status" });
}, []);
const requestModelsAliases = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsAliasesLoading(true);
store.setModelsAliasesError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models_aliases" });
}, []);
const requestModelsFallbacks = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsFallbacksLoading(true);
store.setModelsFallbacksError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models_fallbacks" });
}, []);
const requestModelsImageFallbacks = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsImageFallbacksLoading(true);
store.setModelsImageFallbacksError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models_image_fallbacks" });
}, []);
const requestSkillUpdate = useCallback((slug = null, all = false) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSkillUpdateLoading(true);
store.setSkillUpdateError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_skill_update", slug, all });
}, []);
const requestWorkspaceFiles = useCallback((workspace) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !workspace) return;
store.setWorkspaceFilesLoading(true);
store.setWorkspaceFilesError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_files", workspace });
}, []);
const requestWorkspaceFile = useCallback((agent_id, file_name) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !agent_id || !file_name) return;
console.log("[DEBUG] requestWorkspaceFile:", { type: "get_openclaw_workspace_file", agent_id, file_name });
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_file", agent_id, file_name });
}, []);
return {
requestStatus,
requestSessions,
requestSessionDetail,
requestSessionHistory,
resolveSession,
createSession,
subscribeSession,
unsubscribeSession,
resetSession,
deleteSession,
sendSessionMessage,
requestCron,
requestApprovals,
requestAgents,
requestAgentsPresence,
requestSkills,
requestModels,
requestHooks,
requestPlugins,
requestSecretsAudit,
requestSecurityAudit,
requestDaemonStatus,
requestPairing,
requestQrCode,
requestUpdateStatus,
requestModelsAliases,
requestModelsFallbacks,
requestModelsImageFallbacks,
requestSkillUpdate,
requestWorkspaceFiles,
requestWorkspaceFile,
};
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { AGENTS } from '../config/constants';
import { ReadOnlyClient } from '../services/websocket';
import { useRuntimeStore } from '../store/runtimeStore';
import { useOpenClawStore } from '../store/openclawStore';
import { useMarketStore } from '../store/marketStore';
import { usePortfolioStore } from '../store/portfolioStore';
import { useAgentStore } from '../store/agentStore';
@@ -65,6 +66,306 @@ function buildTickersFromSymbols(symbols, previousTickers = []) {
});
}
function normalizeOpenClawHistoryItems(history) {
if (!Array.isArray(history)) {
return [];
}
return history
.map((item, index) => {
const role = item?.role || item?.senderRole || item?.kind || item?.type || 'event';
const isFinal = hasOpenClawFinalTag(item);
const text = extractOpenClawText(item);
if (!shouldKeepOpenClawMessage(item)) {
return null;
}
const timestamp = item?.timestamp || item?.ts || item?.createdAt || item?.time || null;
const nestedMeta = item?.message?.__openclaw || item?.__openclaw || null;
const seq = item?.messageSeq ?? item?.seq ?? nestedMeta?.seq ?? null;
const messageId = item?.messageId ?? item?.id ?? nestedMeta?.id ?? null;
return {
id: messageId || (seq !== null ? `seq:${seq}` : `${timestamp || 'history'}:${index}`),
role,
text: String(text || ''),
timestamp,
seq,
messageId,
isFinal,
raw: item,
};
})
.filter(Boolean);
}
function unwrapOpenClawFinal(value) {
if (typeof value !== 'string') {
return null;
}
const match = value.match(/<final>([\s\S]*?)<\/final>/i);
if (!match) {
return null;
}
return match[1].trim();
}
function stripOpenClawFinalTags(value) {
if (typeof value !== 'string') {
return value ? String(value) : '';
}
return value.replace(/<\/?final>/gi, '').trim();
}
function shouldHideOpenClawMessage({ role, text }) {
const normalizedRole = String(role || '').toLowerCase();
const normalizedText = String(text || '').trim();
if (normalizedRole === 'system') {
return true;
}
if (normalizedRole === 'user') {
if (normalizedText.startsWith('Sender (untrusted metadata):')) {
return true;
}
if (normalizedText.startsWith('[Fri ') || normalizedText.startsWith('[Sat ') || normalizedText.startsWith('[Sun ')
|| normalizedText.startsWith('[Mon ') || normalizedText.startsWith('[Tue ') || normalizedText.startsWith('[Wed ')
|| normalizedText.startsWith('[Thu ')) {
return true;
}
}
return false;
}
function shouldKeepOpenClawMessage(item) {
const role = item?.role || item?.senderRole || item?.kind || item?.type || 'event';
const text = extractOpenClawText(item);
const isFinal = hasOpenClawFinalTag(item);
if (shouldHideOpenClawMessage({ role, text })) {
return false;
}
const normalizedRole = String(role || '').toLowerCase();
if (normalizedRole === 'assistant') {
return isFinal;
}
if (!normalizedRole || normalizedRole === 'event') {
return isFinal;
}
return true;
}
function hasOpenClawFinalTag(item) {
if (typeof item === 'string') {
return /<final>[\s\S]*?<\/final>/i.test(item);
}
if (!item || typeof item !== 'object') {
return false;
}
const candidates = [];
if (typeof item.text === 'string') candidates.push(item.text);
if (typeof item.message === 'string') candidates.push(item.message);
if (typeof item.content === 'string') candidates.push(item.content);
const nestedMessage = item.message && typeof item.message === 'object' ? item.message : null;
if (nestedMessage) {
if (typeof nestedMessage.content === 'string') candidates.push(nestedMessage.content);
if (Array.isArray(nestedMessage.content)) {
nestedMessage.content.forEach((entry) => {
if (typeof entry === 'string') candidates.push(entry);
if (entry?.type === 'text' && typeof entry?.text === 'string') candidates.push(entry.text);
});
}
}
if (Array.isArray(item.content)) {
item.content.forEach((entry) => {
if (typeof entry === 'string') candidates.push(entry);
if (entry?.type === 'text' && typeof entry?.text === 'string') candidates.push(entry.text);
});
}
return candidates.some((value) => /<final>[\s\S]*?<\/final>/i.test(value));
}
function extractOpenClawText(item) {
if (typeof item === 'string') {
return unwrapOpenClawFinal(item) || stripOpenClawFinalTags(item);
}
if (!item || typeof item !== 'object') {
return item ? String(item) : '';
}
if (typeof item.text === 'string' && item.text.trim()) {
return unwrapOpenClawFinal(item.text) || stripOpenClawFinalTags(item.text);
}
if (typeof item.message === 'string' && item.message.trim()) {
return unwrapOpenClawFinal(item.message) || stripOpenClawFinalTags(item.message);
}
if (typeof item.content === 'string' && item.content.trim()) {
return unwrapOpenClawFinal(item.content) || stripOpenClawFinalTags(item.content);
}
const nestedMessage = item.message && typeof item.message === 'object' ? item.message : null;
if (nestedMessage) {
if (typeof nestedMessage.content === 'string' && nestedMessage.content.trim()) {
return unwrapOpenClawFinal(nestedMessage.content) || stripOpenClawFinalTags(nestedMessage.content);
}
if (Array.isArray(nestedMessage.content)) {
const textBlock = nestedMessage.content.find((entry) => entry?.type === 'text' && typeof entry?.text === 'string');
if (textBlock?.text) {
return unwrapOpenClawFinal(textBlock.text) || stripOpenClawFinalTags(textBlock.text);
}
}
}
if (Array.isArray(item.content)) {
const textParts = item.content
.map((entry) => {
if (typeof entry === 'string') return entry;
if (entry?.type === 'text' && typeof entry?.text === 'string') return entry.text;
return '';
})
.filter(Boolean);
if (textParts.length > 0) {
const merged = textParts.join('\n');
return unwrapOpenClawFinal(merged) || stripOpenClawFinalTags(merged);
}
}
if (typeof item.summary === 'string' && item.summary.trim()) {
return item.summary;
}
if (typeof item.value === 'string' && item.value.trim()) {
return item.value;
}
return JSON.stringify(item);
}
function normalizeOpenClawLiveEvent(evt) {
const payload = evt?.payload || {};
const nestedMessage = payload?.message && typeof payload.message === 'object' ? payload.message : null;
const nestedMeta = nestedMessage?.__openclaw || payload?.__openclaw || null;
const isFinal = hasOpenClawFinalTag(payload);
const text = extractOpenClawText(payload) || evt?.event || '';
const role =
payload.role
|| nestedMessage?.role
|| payload.senderRole
|| payload.kind
|| evt?.event
|| 'event';
const seq = payload.messageSeq ?? payload.seq ?? nestedMeta?.seq ?? null;
const messageId = payload.messageId ?? payload.id ?? nestedMeta?.id ?? null;
return {
id: messageId || (seq !== null ? `seq:${seq}` : `${evt?.event || 'event'}:${Date.now()}`),
role,
text: String(text),
timestamp: payload.timestamp || payload.ts || new Date().toISOString(),
seq,
messageId,
isFinal,
raw: payload,
};
}
function shouldAppendOpenClawLiveEvent(evt) {
const name = String(evt?.event || '');
const payload = evt?.payload || {};
if (name === 'session.message') {
return shouldKeepOpenClawMessage(payload);
}
return Boolean(payload.text || payload.message || payload.content);
}
function requestOpenClawSessionHistory(clientRef, sessionKey, limit = 30) {
const client = clientRef?.current;
if (!client || !sessionKey) {
return false;
}
return client.send(JSON.stringify({
type: 'get_openclaw_session_history',
session_key: sessionKey,
limit,
}));
}
function normalizeOpenClawAgents(agents, presence, sessionsPayload = null) {
const normalizedAgents = Array.isArray(agents) ? agents : [];
const presenceAgents = presence?.agents || presence || {};
const sessionDefaults = sessionsPayload?.defaults || {};
const sessions = Array.isArray(sessionsPayload?.sessions) ? sessionsPayload.sessions : [];
const sessionModelByAgent = new Map();
sessions.forEach((session) => {
if (!session || typeof session !== 'object') return;
let agentId = String(session.agentId || session.agent_id || '').trim();
if (!agentId) {
const key = String(session.key || session.sessionKey || '').trim();
const parts = key.split(':');
if (parts.length >= 3 && parts[0] === 'agent') {
agentId = parts[1];
}
}
const modelValue =
session.model ||
session.modelName ||
session.model_name ||
session.resolvedModel ||
session.resolved_model ||
null;
if (agentId && modelValue && !sessionModelByAgent.has(agentId)) {
sessionModelByAgent.set(agentId, modelValue);
}
});
return normalizedAgents.map((agent) => {
if (!agent || typeof agent !== 'object') {
return agent;
}
const agentId = String(agent.id || agent.agentId || '').trim();
const presenceEntry = agentId ? presenceAgents?.[agentId] : null;
const presenceSessions = Array.isArray(presenceEntry?.sessions) ? presenceEntry.sessions : [];
const firstPresenceSession = presenceSessions.find((session) => {
const value = session?.model || session?.modelName || session?.model_name || session?.resolvedModel;
return typeof value === 'string' && value.trim();
});
const model =
agent.model ||
agent.modelName ||
agent.model_name ||
agent.resolvedModel ||
agent.resolved_model ||
agent.defaultModel ||
agent.default_model ||
sessionModelByAgent.get(agentId) ||
sessionDefaults.model ||
sessionDefaults.modelName ||
sessionDefaults.model_name ||
firstPresenceSession?.model ||
firstPresenceSession?.modelName ||
firstPresenceSession?.model_name ||
firstPresenceSession?.resolvedModel ||
null;
return {
...agent,
model: typeof model === 'string' && model.trim() ? model.trim() : null,
};
});
}
/**
* Custom hook for WebSocket connection lifecycle and event handling.
* Manages clientRef, connection, and ALL event handlers.
@@ -797,7 +1098,337 @@ export function useWebSocketConnection({
fast_forward_success: (e) => {
console.log(`${e.message}`);
}
},
openclaw_status_loaded: (e) => {
useOpenClawStore.getState().setOpenclawStatus(e.data || e);
useOpenClawStore.getState().setStatusLoading(false);
},
openclaw_sessions_loaded: (e) => {
const payload = e.data || e;
useOpenClawStore.getState().setOpenclawSessions(payload);
const currentAgents = useOpenClawStore.getState().agents || [];
const presence = useOpenClawStore.getState().agentsPresence;
if (currentAgents.length > 0) {
useOpenClawStore.getState().setAgents(
normalizeOpenClawAgents(currentAgents, presence, payload),
);
}
useOpenClawStore.getState().setSessionsLoading(false);
},
openclaw_session_detail_loaded: (e) => {
useOpenClawStore.getState().setOpenclawSessionDetail(e.data || e);
useOpenClawStore.getState().setSessionDetailLoading(false);
},
openclaw_session_history_loaded: (e) => {
const data = e.data || e;
const sessionKey = e.session_key || data?.session_key || useOpenClawStore.getState().selectedSessionKey;
useOpenClawStore.getState().setOpenclawSessionHistory(data);
if (sessionKey) {
useOpenClawStore.getState().replaceOpenclawChatHistory(
sessionKey,
normalizeOpenClawHistoryItems(data?.history || []),
);
}
},
openclaw_session_resolved: (e) => {
const d = e.data || {};
useOpenClawStore.getState().setOpenclawResolvedSessionKey(d.key || null);
if (d?.error) {
useOpenClawStore.getState().setChatError(d.error);
} else {
useOpenClawStore.getState().setChatError(null);
}
},
openclaw_session_created: (e) => {
const d = e.data || {};
if (d?.error) {
useOpenClawStore.getState().setChatError(d.error);
return;
}
if (d?.entry || d?.key) {
const createdKey = d?.key || d?.entry?.key || d?.entry?.sessionKey || '';
useOpenClawStore.getState().appendOpenclawSession(
d.entry || {
key: createdKey,
sessionKey: createdKey,
agentId: String(createdKey).split(':')[1] || '',
}
);
}
if (d?.key) {
useOpenClawStore.getState().setSelectedSessionKey(d.key);
useOpenClawStore.getState().setOpenclawResolvedSessionKey(d.key);
useOpenClawStore.getState().setChatError(null);
}
},
openclaw_session_subscribed: (e) => {
const sessionKey = e.session_key || e.data?.key || null;
if (sessionKey) {
useOpenClawStore.getState().setOpenclawSessionSubscribed(sessionKey, true);
}
if (e.data?.error) {
useOpenClawStore.getState().setChatError(e.data.error);
}
},
openclaw_session_unsubscribed: (e) => {
const sessionKey = e.session_key || e.data?.key || null;
if (sessionKey) {
useOpenClawStore.getState().setOpenclawSessionSubscribed(sessionKey, false);
}
},
openclaw_session_reset: (e) => {
const sessionKey = e.session_key || e.data?.key || null;
if (e.data?.error) {
useOpenClawStore.getState().setChatError(e.data.error);
return;
}
if (sessionKey) {
useOpenClawStore.getState().replaceOpenclawChatHistory(sessionKey, []);
useOpenClawStore.getState().setChatError(null);
}
},
openclaw_session_deleted: (e) => {
const sessionKey = e.session_key || e.data?.key || null;
if (e.data?.error) {
useOpenClawStore.getState().setChatError(e.data.error);
return;
}
if (sessionKey) {
useOpenClawStore.getState().removeOpenclawSession(sessionKey);
useOpenClawStore.getState().setChatError(null);
}
},
openclaw_message_sent: (e) => {
const sessionKey = e.session_key || e.data?.key || useOpenClawStore.getState().selectedSessionKey;
if (sessionKey) {
useOpenClawStore.getState().setOpenclawChatSendingForSession(sessionKey, false);
}
if (e.data?.error) {
useOpenClawStore.getState().setChatError(e.data.error);
} else {
useOpenClawStore.getState().setChatError(null);
if (sessionKey && (e.data?.status || e.data?.runId || e.data?.messageSeq !== undefined)) {
const statusBits = [
e.data?.status ? `status=${e.data.status}` : null,
e.data?.runId ? `runId=${e.data.runId}` : null,
e.data?.messageSeq !== undefined ? `seq=${e.data.messageSeq}` : null,
].filter(Boolean);
useOpenClawStore.getState().appendOpenclawChatMessage(sessionKey, {
id: `send-meta:${e.data?.runId || Date.now()}`,
role: 'system',
text: `消息已提交到 OpenClaw${statusBits.length ? ` (${statusBits.join(', ')})` : ''}`,
timestamp: new Date().toISOString(),
});
}
if (sessionKey) {
window.setTimeout(() => requestOpenClawSessionHistory(clientRef, sessionKey, 30), 600);
}
}
},
openclaw_session_event: (e) => {
const sessionKey = e.session_key || e.payload?.sessionKey || e.payload?.key;
if (!sessionKey || !shouldAppendOpenClawLiveEvent(e)) {
return;
}
useOpenClawStore.getState().appendOpenclawChatMessage(
sessionKey,
normalizeOpenClawLiveEvent(e),
);
},
openclaw_cron_loaded: (e) => {
useOpenClawStore.getState().setOpenclawCronJobs(e.data || e);
useOpenClawStore.getState().setCronLoading(false);
},
openclaw_approvals_loaded: (e) => {
useOpenClawStore.getState().setOpenclawApprovals(e.data || e);
useOpenClawStore.getState().setApprovalsLoading(false);
},
openclaw_agents_loaded: (e) => {
useOpenClawStore.getState().setAgentsLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setAgentsError(d.error);
} else {
const presence = useOpenClawStore.getState().agentsPresence;
const sessionsPayload = {
sessions: useOpenClawStore.getState().openclawSessions || [],
defaults: useOpenClawStore.getState().openclawSessionsDefaults || null,
};
useOpenClawStore.getState().setAgents(
normalizeOpenClawAgents(d?.agents || [], presence, sessionsPayload),
);
useOpenClawStore.getState().setAgentsError(null);
}
},
openclaw_agents_presence_loaded: (e) => {
const presencePayload = (e.data?.data ?? e.data) || {};
useOpenClawStore.getState().setAgentsPresence(presencePayload);
const currentAgents = useOpenClawStore.getState().agents || [];
if (currentAgents.length > 0) {
const sessionsPayload = {
sessions: useOpenClawStore.getState().openclawSessions || [],
defaults: useOpenClawStore.getState().openclawSessionsDefaults || null,
};
useOpenClawStore.getState().setAgents(
normalizeOpenClawAgents(currentAgents, presencePayload, sessionsPayload),
);
}
},
openclaw_skills_loaded: (e) => {
useOpenClawStore.getState().setSkillsLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setSkillsError(d.error);
} else {
useOpenClawStore.getState().setSkills(d?.skills || []);
useOpenClawStore.getState().setSkillsError(null);
}
},
openclaw_models_loaded: (e) => {
useOpenClawStore.getState().setModelsLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setModelsError(d.error);
} else {
useOpenClawStore.getState().setModels(d?.models || []);
useOpenClawStore.getState().setModelsError(null);
}
},
openclaw_workspace_files_loaded: (e) => {
useOpenClawStore.getState().setWorkspaceFilesLoading(false);
const d = e.data?.data ?? e.data;
const workspace = d?.workspace || "";
if (d?.error) {
useOpenClawStore.getState().setWorkspaceFilesError(d.error);
} else {
useOpenClawStore.getState().setWorkspaceFiles(workspace, d);
useOpenClawStore.getState().setWorkspaceFilesError(null);
}
},
openclaw_workspace_file_loaded: (e) => {
const d = e.data?.data ?? e.data;
console.log("[DEBUG] workspace_file_loaded:", { d });
if (d?.error) return;
const agentId = d?.agentId || "main";
const fileName = d?.file?.Name || d?.file?.name || "";
const key = `${agentId}:${fileName}`;
if (d?.file?.missing) {
useOpenClawStore.getState().setWorkspaceFileContent(key, "(文件不存在)");
} else if (d?.file?.content) {
useOpenClawStore.getState().setWorkspaceFileContent(key, d.file.content);
}
},
openclaw_hooks_loaded: (e) => {
useOpenClawStore.getState().setHooksLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setHooksError(d.error);
} else {
useOpenClawStore.getState().setHooks(d?.hooks || []);
useOpenClawStore.getState().setHooksError(null);
}
},
openclaw_plugins_loaded: (e) => {
useOpenClawStore.getState().setPluginsLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setPluginsError(d.error);
} else {
useOpenClawStore.getState().setPlugins(d?.plugins || []);
useOpenClawStore.getState().setPluginsError(null);
}
},
openclaw_secrets_audit_loaded: (e) => {
useOpenClawStore.getState().setSecretsAuditLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setSecretsAuditError(e.data.data.error);
} else {
useOpenClawStore.getState().setSecretsAudit(e.data?.data || null);
useOpenClawStore.getState().setSecretsAuditError(null);
}
},
openclaw_security_audit_loaded: (e) => {
useOpenClawStore.getState().setSecurityAuditLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setSecurityAuditError(e.data.data.error);
} else {
useOpenClawStore.getState().setSecurityAudit(e.data?.data || null);
useOpenClawStore.getState().setSecurityAuditError(null);
}
},
openclaw_daemon_status_loaded: (e) => {
useOpenClawStore.getState().setDaemonStatusLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setDaemonStatusError(e.data.data.error);
} else {
useOpenClawStore.getState().setDaemonStatus(e.data?.data || null);
useOpenClawStore.getState().setDaemonStatusError(null);
}
},
openclaw_pairing_loaded: (e) => {
useOpenClawStore.getState().setPairingLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setPairingError(e.data.data.error);
} else {
useOpenClawStore.getState().setPairing(e.data?.data || null);
useOpenClawStore.getState().setPairingError(null);
}
},
openclaw_qr_loaded: (e) => {
useOpenClawStore.getState().setQrCodeLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setQrCodeError(e.data.data.error);
} else {
useOpenClawStore.getState().setQrCode(e.data?.data || null);
useOpenClawStore.getState().setQrCodeError(null);
}
},
openclaw_update_status_loaded: (e) => {
useOpenClawStore.getState().setUpdateStatusLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setUpdateStatusError(e.data.data.error);
} else {
useOpenClawStore.getState().setUpdateStatus(e.data?.data || null);
useOpenClawStore.getState().setUpdateStatusError(null);
}
},
openclaw_models_aliases_loaded: (e) => {
useOpenClawStore.getState().setModelsAliasesLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setModelsAliasesError(e.data.data.error);
} else {
useOpenClawStore.getState().setModelsAliases(e.data?.data || null);
useOpenClawStore.getState().setModelsAliasesError(null);
}
},
openclaw_models_fallbacks_loaded: (e) => {
useOpenClawStore.getState().setModelsFallbacksLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setModelsFallbacksError(e.data.data.error);
} else {
useOpenClawStore.getState().setModelsFallbacks(e.data?.data?.items || []);
useOpenClawStore.getState().setModelsFallbacksError(null);
}
},
openclaw_models_image_fallbacks_loaded: (e) => {
useOpenClawStore.getState().setModelsImageFallbacksLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setModelsImageFallbacksError(e.data.data.error);
} else {
useOpenClawStore.getState().setModelsImageFallbacks(e.data?.data?.items || []);
useOpenClawStore.getState().setModelsImageFallbacksError(null);
}
},
openclaw_skill_update_loaded: (e) => {
useOpenClawStore.getState().setSkillUpdateLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setSkillUpdateError(e.data.data.error);
} else {
useOpenClawStore.getState().setSkillUpdate(e.data?.data || null);
useOpenClawStore.getState().setSkillUpdateError(null);
}
},
};
try {

View File

@@ -0,0 +1,354 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useOpenClawStore = create(
persist(
(set) => ({
// Raw data
openclawStatus: null,
openclawSessions: [],
openclawSessionsDefaults: null,
openclawSessionDetail: null,
openclawSessionHistory: [],
openclawCronJobs: [],
openclawApprovals: [],
openclawResolvedSessionKey: null,
openclawChatMessagesBySession: {},
openclawChatDraftBySession: {},
openclawChatSendingBySession: {},
openclawSessionSubscriptions: {},
// Loading states
isStatusLoading: false,
isSessionsLoading: false,
isSessionDetailLoading: false,
isCronLoading: false,
isApprovalsLoading: false,
isChatSending: false,
// Error states
statusError: null,
sessionsError: null,
sessionDetailError: null,
cronError: null,
approvalsError: null,
chatError: null,
// Agents state
agents: [],
agentsLoading: false,
agentsError: null,
agentsPresence: {},
// Skills state
skills: [],
skillsLoading: false,
skillsError: null,
// Models state
models: [],
modelsLoading: false,
modelsError: null,
// Hooks state
hooks: [],
hooksLoading: false,
hooksError: null,
// Plugins state
plugins: [],
pluginsLoading: false,
pluginsError: null,
// Secrets audit state
secretsAudit: null,
secretsAuditLoading: false,
secretsAuditError: null,
// Security audit state
securityAudit: null,
securityAuditLoading: false,
securityAuditError: null,
// Daemon status state
daemonStatus: null,
daemonStatusLoading: false,
daemonStatusError: null,
// Pairing state
pairing: null,
pairingLoading: false,
pairingError: null,
// QR code state
qrCode: null,
qrCodeLoading: false,
qrCodeError: null,
// Update status state
updateStatus: null,
updateStatusLoading: false,
updateStatusError: null,
// Models aliases state
modelsAliases: null,
modelsAliasesLoading: false,
modelsAliasesError: null,
// Models fallbacks state
modelsFallbacks: [],
modelsFallbacksLoading: false,
modelsFallbacksError: null,
// Models image fallbacks state
modelsImageFallbacks: [],
modelsImageFallbacksLoading: false,
modelsImageFallbacksError: null,
// Skill update state
skillUpdate: null,
skillUpdateLoading: false,
skillUpdateError: null,
// Workspace files state (per agent, keyed by workspace path)
workspaceFiles: {},
workspaceFilesLoading: false,
workspaceFilesError: null,
// Workspace file content (keyed by "agentId:filename")
workspaceFileContent: {},
// Selected session key for detail/history drill-down
selectedSessionKey: null,
// WebSocket client ref (set by App.jsx on connection)
clientRef: null,
setClientRef: (ref) => set({ clientRef: ref }),
// Setters
setOpenclawStatus: (data) => set({ openclawStatus: data, statusError: null }),
setOpenclawSessions: (data) => set({
openclawSessions: data?.sessions || [],
openclawSessionsDefaults: data?.defaults || null,
sessionsError: null,
}),
appendOpenclawSession: (session) => set((state) => {
const key = session?.key || session?.sessionKey;
if (!key) {
return {};
}
const existing = state.openclawSessions || [];
const deduped = existing.filter((item) => (item?.key || item?.sessionKey) !== key);
return { openclawSessions: [session, ...deduped] };
}),
removeOpenclawSession: (sessionKey) => set((state) => ({
openclawSessions: (state.openclawSessions || []).filter(
(item) => (item?.key || item?.sessionKey) !== sessionKey
),
selectedSessionKey:
state.selectedSessionKey === sessionKey ? null : state.selectedSessionKey,
})),
setOpenclawSessionDetail: (data) => set({ openclawSessionDetail: data?.session || null, sessionDetailError: data?.error || null }),
setOpenclawSessionHistory: (data) => set({ openclawSessionHistory: data?.history || [], sessionDetailError: data?.error || null }),
setOpenclawCronJobs: (data) => set({ openclawCronJobs: data?.cron || [], cronError: null }),
setOpenclawApprovals: (data) => set({ openclawApprovals: data?.approvals || [], approvalsError: null }),
setOpenclawResolvedSessionKey: (key) => set({ openclawResolvedSessionKey: key || null }),
setOpenclawChatDraft: (sessionKey, value) => set((state) => ({
openclawChatDraftBySession: { ...state.openclawChatDraftBySession, [sessionKey]: value },
})),
appendOpenclawChatMessage: (sessionKey, message) => set((state) => {
const current = state.openclawChatMessagesBySession[sessionKey] || [];
const sameMessageIndex = current.findIndex((item) => {
const sameId = Boolean(message?.id && item?.id && message.id === item.id);
const sameMessageId = Boolean(
message?.messageId &&
item?.messageId &&
message.messageId === item.messageId
);
const sameSeq = Boolean(
message?.seq !== undefined &&
message?.seq !== null &&
item?.seq !== undefined &&
item?.seq !== null &&
message.seq === item.seq &&
message?.role === item?.role
);
const incomingText = String(message?.text || '').trim();
const existingText = String(item?.text || '').trim();
const incomingTs = Date.parse(message?.timestamp || '');
const existingTs = Date.parse(item?.timestamp || '');
const nearInTime =
Number.isFinite(incomingTs) &&
Number.isFinite(existingTs) &&
Math.abs(incomingTs - existingTs) < 1500;
const sameAssistantText =
message?.role === 'assistant' &&
item?.role === 'assistant' &&
incomingText &&
existingText &&
(
incomingText === existingText ||
incomingText.startsWith(existingText) ||
existingText.startsWith(incomingText)
) &&
nearInTime;
return sameId || sameMessageId || sameSeq || sameAssistantText;
});
if (sameMessageIndex >= 0) {
const next = [...current];
next[sameMessageIndex] = { ...next[sameMessageIndex], ...message };
return {
openclawChatMessagesBySession: {
...state.openclawChatMessagesBySession,
[sessionKey]: next,
},
};
}
return {
openclawChatMessagesBySession: {
...state.openclawChatMessagesBySession,
[sessionKey]: [...current, message],
},
};
}),
replaceOpenclawChatHistory: (sessionKey, messages) => set((state) => {
const incoming = Array.isArray(messages) ? messages : [];
const existing = state.openclawChatMessagesBySession[sessionKey] || [];
const merged = [];
const seen = new Set();
const signatureFor = (message) => {
if (!message) return "";
if (message.id) return `id:${message.id}`;
if (message.messageId) return `mid:${message.messageId}`;
if (message.seq !== undefined && message.seq !== null) return `seq:${message.seq}`;
return `txt:${message.role || ""}:${String(message.text || "").trim()}`;
};
for (const message of [...incoming, ...existing]) {
const signature = signatureFor(message);
if (!signature || seen.has(signature)) {
continue;
}
seen.add(signature);
merged.push(message);
}
return {
openclawChatMessagesBySession: {
...state.openclawChatMessagesBySession,
[sessionKey]: merged,
},
};
}),
setOpenclawChatSendingForSession: (sessionKey, value) => set((state) => ({
openclawChatSendingBySession: { ...state.openclawChatSendingBySession, [sessionKey]: Boolean(value) },
isChatSending: Boolean(value),
})),
setOpenclawSessionSubscribed: (sessionKey, value) => set((state) => ({
openclawSessionSubscriptions: { ...state.openclawSessionSubscriptions, [sessionKey]: Boolean(value) },
})),
setSelectedSessionKey: (key) => set({ selectedSessionKey: key }),
setStatusLoading: (v) => set({ isStatusLoading: v }),
setSessionsLoading: (v) => set({ isSessionsLoading: v }),
setSessionDetailLoading: (v) => set({ isSessionDetailLoading: v }),
setCronLoading: (v) => set({ isCronLoading: v }),
setApprovalsLoading: (v) => set({ isApprovalsLoading: v }),
setStatusError: (e) => set({ statusError: e }),
setSessionsError: (e) => set({ sessionsError: e }),
setSessionDetailError: (e) => set({ sessionDetailError: e }),
setCronError: (e) => set({ cronError: e }),
setApprovalsError: (e) => set({ approvalsError: e }),
setChatError: (e) => set({ chatError: e }),
setAgents: (agents) => set({ agents }),
setAgentsLoading: (loading) => set({ agentsLoading: loading }),
setAgentsError: (error) => set({ agentsError: error }),
setAgentsPresence: (presence) => set({ agentsPresence: presence }),
setSkills: (skills) => set({ skills }),
setSkillsLoading: (loading) => set({ skillsLoading: loading }),
setSkillsError: (error) => set({ skillsError: error }),
setModels: (models) => set({ models }),
setModelsLoading: (loading) => set({ modelsLoading: loading }),
setModelsError: (error) => set({ modelsError: error }),
setHooks: (hooks) => set({ hooks }),
setHooksLoading: (loading) => set({ hooksLoading: loading }),
setHooksError: (error) => set({ hooksError: error }),
setPlugins: (plugins) => set({ plugins }),
setPluginsLoading: (loading) => set({ pluginsLoading: loading }),
setPluginsError: (error) => set({ pluginsError: error }),
setSecretsAudit: (data) => set({ secretsAudit: data }),
setSecretsAuditLoading: (loading) => set({ secretsAuditLoading: loading }),
setSecretsAuditError: (error) => set({ secretsAuditError: error }),
setSecurityAudit: (data) => set({ securityAudit: data }),
setSecurityAuditLoading: (loading) => set({ securityAuditLoading: loading }),
setSecurityAuditError: (error) => set({ securityAuditError: error }),
setDaemonStatus: (data) => set({ daemonStatus: data }),
setDaemonStatusLoading: (loading) => set({ daemonStatusLoading: loading }),
setDaemonStatusError: (error) => set({ daemonStatusError: error }),
setPairing: (data) => set({ pairing: data }),
setPairingLoading: (loading) => set({ pairingLoading: loading }),
setPairingError: (error) => set({ pairingError: error }),
setQrCode: (data) => set({ qrCode: data }),
setQrCodeLoading: (loading) => set({ qrCodeLoading: loading }),
setQrCodeError: (error) => set({ qrCodeError: error }),
setUpdateStatus: (data) => set({ updateStatus: data }),
setUpdateStatusLoading: (loading) => set({ updateStatusLoading: loading }),
setUpdateStatusError: (error) => set({ updateStatusError: error }),
setModelsAliases: (data) => set({ modelsAliases: data }),
setModelsAliasesLoading: (loading) => set({ modelsAliasesLoading: loading }),
setModelsAliasesError: (error) => set({ modelsAliasesError: error }),
setModelsFallbacks: (data) => set({ modelsFallbacks: data }),
setModelsFallbacksLoading: (loading) => set({ modelsFallbacksLoading: loading }),
setModelsFallbacksError: (error) => set({ modelsFallbacksError: error }),
setModelsImageFallbacks: (data) => set({ modelsImageFallbacks: data }),
setModelsImageFallbacksLoading: (loading) => set({ modelsImageFallbacksLoading: loading }),
setModelsImageFallbacksError: (error) => set({ modelsImageFallbacksError: error }),
setSkillUpdate: (data) => set({ skillUpdate: data }),
setSkillUpdateLoading: (loading) => set({ skillUpdateLoading: loading }),
setSkillUpdateError: (error) => set({ skillUpdateError: error }),
setWorkspaceFiles: (workspace, data) => set((state) => ({
workspaceFiles: { ...state.workspaceFiles, [workspace]: data },
})),
setWorkspaceFilesLoading: (loading) => set({ workspaceFilesLoading: loading }),
setWorkspaceFilesError: (error) => set({ workspaceFilesError: error }),
setWorkspaceFileContent: (key, content) => set((state) => ({
workspaceFileContent: { ...state.workspaceFileContent, [key]: content },
})),
}),
{
name: "openclaw-store",
// Skip persisting ephemeral UI state
partialize: (state) => ({
// Persist only data, not loading/error/UI states
openclawStatus: state.openclawStatus,
openclawSessions: state.openclawSessions,
openclawCronJobs: state.openclawCronJobs,
openclawApprovals: state.openclawApprovals,
agents: state.agents,
agentsPresence: state.agentsPresence,
skills: state.skills,
models: state.models,
hooks: state.hooks,
plugins: state.plugins,
secretsAudit: state.secretsAudit,
securityAudit: state.securityAudit,
daemonStatus: state.daemonStatus,
pairing: state.pairing,
qrCode: state.qrCode,
updateStatus: state.updateStatus,
modelsAliases: state.modelsAliases,
modelsFallbacks: state.modelsFallbacks,
modelsImageFallbacks: state.modelsImageFallbacks,
skillUpdate: state.skillUpdate,
}),
}
)
);

View File

@@ -9,7 +9,7 @@ const resolveValue = (updater, currentValue) => (
*/
export const useUIStore = create((set) => ({
// Current view
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'openclaw' | 'runtime'
setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
// Chart tab

View File

@@ -1,7 +1,7 @@
import React from 'react';
/**
* Global CSS Styles for the EvoTraders Platform
* Global CSS Styles for the 大时代 Platform
* Terminal-inspired, minimal, monochrome design
*/
export default function GlobalStyles() {
@@ -1098,6 +1098,10 @@ export default function GlobalStyles() {
transform: translateX(-80%);
}
.view-slider-five.show-openclaw {
transform: translateX(-100%);
}
.view-panel {
flex: 0 0 33.333%;
width: 33.333%;

BIN
frontend/trader-full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

BIN
frontend/trader-view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -282,7 +282,6 @@
"trading_days_completed": 0,
"server_mode": "live",
"is_backtest": false,
"is_mock_mode": false,
"data_sources": {
"preferred": [
"yfinance",

View File

@@ -5,12 +5,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "evotraders"
version = "0.1.0"
description = "EvoTraders: A self-evolving multi-agent trading system"
description = "大时代: A self-evolving multi-agent trading system"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
authors = [
{name = "EvoTraders Team <dengjiaji.djj@alibaba-inc.com>"}
{name = "大时代 Team", email = "dengjiaji.djj@alibaba-inc.com"}
]
keywords = ["trading", "ai", "multi-agent", "fintech", "algorithmic-trading"]
classifiers = [
@@ -28,11 +28,16 @@ classifiers = [
dependencies = [
"agentscope>=1.0.8",
"reme-ai>=0.2.0.4",
"asyncio>=3.4.3",
"fastapi>=0.115.0",
"uvicorn>=0.30.0",
"pydantic>=2.8.0",
"rich>=13.6.0",
"websockets>=12.0",
"websocket-client>=1.6.0",
"httpx>=0.27.0",
"cryptography>=43.0.0",
"python-dotenv>=1.0.0",
"PyYAML>=6.0.0",
"finnhub-python>=2.4.25",
"numpy>=1.24.0",
"pandas>=2.0.0",
@@ -42,8 +47,6 @@ dependencies = [
"typer>=0.12.5",
"openai>=2.9.0",
"anthropic>=0.20.0",
"dotenv",
"typer",
]
@@ -63,13 +66,8 @@ Documentation = "https://github.com/agentscope-ai/agentscope-samples/evotraders/
[project.scripts]
evotraders = "backend.cli:app"
[tool.setuptools]
packages = ["backend", "backend.agents", "backend.config",
"backend.apps",
"backend.domains",
"backend.data", "backend.llm",
"backend.tools", "backend.utils", "backend.services",
"backend.explain", "backend.enrich"]
[tool.setuptools.packages.find]
include = ["backend*", "shared*"]
[tool.ruff]
line-length = 88

Some files were not shown because too many files have changed in this diff Show More