2 Commits

Author SHA1 Message Date
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
53 changed files with 3541 additions and 1022 deletions

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

@@ -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 # 离线数据更新

383
README.md
View File

@@ -5,32 +5,28 @@
<h2 align="center">EvoTraders: A Self-Evolving Multi-Agent Trading System</h2>
<p align="center">
📌 <a href="http://trading.evoagents.cn">Visit us at EvoTraders website !</a>
📌 <a href="http://trading.evoagents.cn">Visit the EvoTraders website</a>
</p>
![System Demo](./docs/assets/evotraders_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.
EvoTraders 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.
---
## 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 +35,158 @@ 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!)
# recommended
uv pip install -e .
# optional: pip install -e .
# optional
# uv pip install -e ".[dev]"
# pip install -e .
```
# Configure environment variables
### 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.
### 3. Start the stack
Recommended local development flow:
**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-dev.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`:
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 +194,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.
EvoTraders 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

@@ -4,291 +4,337 @@
<h2 align="center">EvoTraders自我进化的多智能体交易系统</h2>
<p align="center">
📌 <a href="http://trading.evoagents.cn">Visit us at EvoTraders website !</a>
📌 <a href="http://trading.evoagents.cn">访问 EvoTraders 官网</a>
</p>
![系统演示](./docs/assets/evotraders_demo.gif)
EvoTraders是一个开源的金融交易智能体框架通过多智能体协作和记忆系统,构建能够在真实市场中持续学习与进化的交易系统
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 -e .
# (可选pip install -e .
# 配置环境变量
# 可选
# uv pip install -e ".[dev]"
# pip install -e .
```
### 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` 仅在启用长期记忆时需要
### 3. 启动服务栈
本地开发推荐直接使用:
**回测模式:**
```bash
evotraders backtest --start 2025-11-01 --end 2025-12-01
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 使用记忆
./start-dev.sh
```
如果没有可用的行情 API想快速体验回测 demo可直接下载离线数据并解压到 `backend/data`
该脚本会启动
- `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 许可协议开源
EvoTraders 是研究和教育用途项目。再次分发或商用前,请先核对仓库中的实际 license 文件
**风险提示**在实际资金交易前,请务必进行充分测试和风险评估历史表现不代表未来收益,投资有风险,决策需谨慎
**风险提示**本项目不构成投资建议。任何实盘部署前都应进行充分测试和风险评估历史表现不代表未来收益。

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

@@ -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

@@ -499,11 +499,29 @@ class Gateway:
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,

View File

@@ -388,4 +388,15 @@ def stop_gateway(gateway: Any) -> None:
gateway._market_status_task.cancel()
if gateway._watchlist_ingest_task:
gateway._watchlist_ingest_task.cancel()
# 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
gateway._dashboard.stop()

View File

@@ -13,6 +13,63 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _ensure_session_bridge(gateway) -> None:
"""Forward OpenClaw session events into EvoTraders 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
@@ -44,10 +101,18 @@ async def handle_get_openclaw_sessions(gateway, websocket, data: dict) -> None:
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": 1, "agentId": session_key.split(":")[1] if session_key else None})
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": result,
"data": {"session": session, "error": None if session else f"session '{session_key}' not found"},
"session_key": session_key,
}))
@@ -55,14 +120,202 @@ async def handle_get_openclaw_session_detail(gateway, websocket, data: dict) ->
async def handle_get_openclaw_session_history(gateway, websocket, data: dict) -> None:
session_key = data.get("session_key", "")
limit = data.get("limit", 20)
result = await _ws_call(gateway, "sessions.list", {"limit": limit})
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": result,
"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}))
@@ -75,6 +328,106 @@ async def handle_get_openclaw_approvals(gateway, websocket, data: dict) -> None:
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}))
@@ -84,7 +437,9 @@ async def handle_get_openclaw_agents_presence(gateway, websocket, data: dict) ->
async def handle_get_openclaw_skills(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "skills.status")
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}))

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

@@ -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

@@ -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

@@ -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"
}

121
deploy/README.md Normal file
View File

@@ -0,0 +1,121 @@
# Deployment Notes
This directory contains the current production-oriented deployment artifacts for
the EvoTraders frontend site and the live gateway process.
## Contents
- [deploy/systemd/evotraders.service](./systemd/evotraders.service)
- systemd unit for the long-running EvoTraders gateway process
- [scripts/run_prod.sh](../scripts/run_prod.sh)
- production launch script used by the systemd unit
- [deploy/nginx/evotraders.cillinn.com.conf](./nginx/evotraders.cillinn.com.conf)
- HTTPS nginx config with WebSocket proxying
- [deploy/nginx/evotraders.cillinn.com.http.conf](./nginx/evotraders.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/evotraders/current`
- 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/evotraders/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://evotraders.cillinn.com` to HTTPS
- proxies `/ws` to the local gateway process with WebSocket upgrade headers
Typical install flow:
```bash
sudo cp deploy/nginx/evotraders.cillinn.com.conf /etc/nginx/sites-available/evotraders.cillinn.com.conf
sudo ln -s /etc/nginx/sites-available/evotraders.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/evotraders.cillinn.com/fullchain.pem`
- `/etc/letsencrypt/live/evotraders.cillinn.com/privkey.pem`
## Environment Expectations
Before using the production scripts, ensure the runtime environment has:
- a usable Python environment
- repo dependencies installed
- required market/model API keys
- any desired `TICKERS` override
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,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

@@ -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,56 @@
## QuickStart
## Frontend Quick Start
```bash
cd frontend
npm install
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 EvoTraders 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
## 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

@@ -13,6 +13,9 @@
"preview:host": "vite preview --host"
},
"dependencies": {
"@dicebear/collection": "^9.4.2",
"@dicebear/core": "^9.4.2",
"@lobehub/icons": "^5.0.1",
"@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 = [
@@ -141,6 +142,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 +234,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

@@ -14,6 +14,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 +172,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]);
@@ -382,6 +384,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 +493,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

@@ -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,
}}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,5 @@
import { OpenClawStatus } from './OpenClawStatus';
export default function OpenClawView() {
return (
<div style={{
height: '100%',
overflow: 'auto',
padding: '16px',
background: '#F3F4F6',
}}>
<OpenClawStatus />
</div>
);
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

@@ -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,13 +139,16 @@ 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'
}}>
{/* Left: agent avatar list */}
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
@@ -202,6 +206,7 @@ export default function TraderView({
})}
</div>
{/* Right: agent detail content */}
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
@@ -245,13 +250,16 @@ export default function TraderView({
alignItems: 'center',
gap: 10
}}>
{modelInfo.logoPath && (
<img
src={modelInfo.logoPath}
<LobeModelLogo
model={profile.model_name}
provider={profile.model_provider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
style={{ width: 26, height: 26, borderRadius: 999 }}
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={{

View File

@@ -58,6 +58,92 @@ export function useOpenClawPanel() {
});
}, []);
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;
@@ -91,13 +177,13 @@ export function useOpenClawPanel() {
sendWithRetry({ current: client }, { type: "get_openclaw_agents_presence" });
}, []);
const requestSkills = useCallback(() => {
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" });
sendWithRetry({ current: client }, { type: "get_openclaw_skills", agent_id: agentId });
}, []);
const requestModels = useCallback(() => {
@@ -239,6 +325,13 @@ export function useOpenClawPanel() {
requestSessions,
requestSessionDetail,
requestSessionHistory,
resolveSession,
createSession,
subscribeSession,
unsubscribeSession,
resetSession,
deleteSession,
sendSessionMessage,
requestCron,
requestApprovals,
requestAgents,

View File

@@ -66,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.
@@ -805,7 +1105,15 @@ export function useWebSocketConnection({
useOpenClawStore.getState().setStatusLoading(false);
},
openclaw_sessions_loaded: (e) => {
useOpenClawStore.getState().setOpenclawSessions(e.data || 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) => {
@@ -813,7 +1121,120 @@ export function useWebSocketConnection({
useOpenClawStore.getState().setSessionDetailLoading(false);
},
openclaw_session_history_loaded: (e) => {
useOpenClawStore.getState().setOpenclawSessionHistory(e.data || 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);
@@ -829,12 +1250,30 @@ export function useWebSocketConnection({
if (d?.error) {
useOpenClawStore.getState().setAgentsError(d.error);
} else {
useOpenClawStore.getState().setAgents(d?.agents || []);
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) => {
useOpenClawStore.getState().setAgentsPresence((e.data?.data ?? e.data) || {});
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);

View File

@@ -7,10 +7,16 @@ export const useOpenClawStore = create(
// Raw data
openclawStatus: null,
openclawSessions: [],
openclawSessionsDefaults: null,
openclawSessionDetail: null,
openclawSessionHistory: [],
openclawCronJobs: [],
openclawApprovals: [],
openclawResolvedSessionKey: null,
openclawChatMessagesBySession: {},
openclawChatDraftBySession: {},
openclawChatSendingBySession: {},
openclawSessionSubscriptions: {},
// Loading states
isStatusLoading: false,
@@ -18,6 +24,7 @@ export const useOpenClawStore = create(
isSessionDetailLoading: false,
isCronLoading: false,
isApprovalsLoading: false,
isChatSending: false,
// Error states
statusError: null,
@@ -25,6 +32,7 @@ export const useOpenClawStore = create(
sessionDetailError: null,
cronError: null,
approvalsError: null,
chatError: null,
// Agents state
agents: [],
@@ -119,11 +127,129 @@ export const useOpenClawStore = create(
// Setters
setOpenclawStatus: (data) => set({ openclawStatus: data, statusError: null }),
setOpenclawSessions: (data) => set({ openclawSessions: data?.sessions || [], sessionsError: null }),
setOpenclawSessionDetail: (data) => set({ openclawSessionDetail: data?.session || null, sessionDetailError: null }),
setOpenclawSessionHistory: (data) => set({ openclawSessionHistory: data?.history || [], sessionDetailError: 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 }),
@@ -138,6 +264,7 @@ export const useOpenClawStore = create(
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 }),

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

@@ -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",

Submodule reference/CoPaw deleted from 934cfce0a7

Submodule reference/Hyper-Alpha-Arena deleted from f137cff476

Submodule reference/PokieTicker deleted from 4fed7755e5

Submodule reference/openclaw updated: 7b151afeeb...f92c92515b

Submodule reference/openclaw-control-center added at 473f42bb41

View File

@@ -1,73 +1,160 @@
# EvoTraders Services Architecture
# EvoTraders Service Surfaces
This repo is currently in a **migration state** between a modular monolith and
fully split services. Service boundaries now exist as dedicated FastAPI app
surfaces, and local development now runs those split services directly.
This repository is in a split-first state: local development now assumes
separate app surfaces and a dedicated WebSocket gateway instead of a single
combined backend entrypoint.
## Current App Surfaces
## Service Map
| App surface | Default port | Responsibility |
| Surface | Default port | Role |
| --- | --- | --- |
| `backend.apps.agent_service` | 8000 | Control-plane only: workspaces, agents, guard. |
| `backend.apps.runtime_service` | 8003 | Runtime lifecycle only: `/api/runtime/*`. |
| `backend.apps.trading_service` | 8001 | Read-only trading data: prices, financials, insider trades, market status, market cap. |
| `backend.apps.news_service` | 8002 | Read-only explain/news data: enriched news, categories, story, similar days, range explain. |
| `backend.apps.agent_service` | `8000` | Control plane for workspaces, agents, skills, guard/approvals |
| `backend.apps.trading_service` | `8001` | Read-only trading data APIs such as prices, financials, insider trades |
| `backend.apps.news_service` | `8002` | Read-only explain/news APIs such as story, similar days, range explain |
| `backend.apps.runtime_service` | `8003` | Runtime lifecycle APIs under `/api/runtime/*` |
| `backend.apps.openclaw_service` | `8004` | Read-only OpenClaw REST facade |
| Gateway (`backend.main`) | `8765` | WebSocket feed, runtime event stream, legacy/compat orchestration path |
| OpenClaw Gateway | `18789` | External OpenClaw WebSocket endpoint consumed by EvoTraders gateway |
## Local Development Modes
## What Runs By Default In Dev
### 1. Split-service mode
This is now the default development mode.
The supported local dev path is:
```bash
./start-dev.sh
# explicit
./start-dev.sh split
```
Run dedicated service surfaces explicitly:
That script starts:
- `agent_service` on `8000`
- `trading_service` on `8001`
- `news_service` on `8002`
- `runtime_service` on `8003`
- EvoTraders gateway on `8765`
It does **not** start `openclaw_service` on `8004`.
Instead, the gateway expects an OpenClaw WebSocket server to already be
available at `ws://localhost:18789` unless you override the OpenClaw gateway
configuration outside the script.
## Manual Startup
Run split service surfaces explicitly:
```bash
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
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
```
## Migration Variables
Optional OpenClaw REST surface:
These env vars control whether the app still uses local-module fallbacks or
prefers service boundaries:
```bash
python -m uvicorn backend.apps.openclaw_service:app --host 0.0.0.0 --port 8004 --reload
```
| Variable | Used by | Purpose |
## Runtime Responsibilities
The runtime path is intentionally split:
- `runtime_service` handles start, stop, restart, current runtime info, logs, and runtime state APIs
- `agent_service` handles control-plane reads and writes for agents, workspaces, files, and approvals
- `backend.main` / gateway hosts the live WebSocket channel and coordinates market service, scheduler, and pipeline execution
The practical request path looks like:
`frontend -> runtime_service/control APIs -> gateway/runtime manager -> market service + pipeline + storage`
## Environment Variables
### Backend routing preferences
These variables let the gateway or tools prefer split services over in-process fallbacks:
| Variable | Used by | Meaning |
| --- | --- | --- |
| `NEWS_SERVICE_URL` | backend Gateway | Prefer `news-service` for explain/news read paths |
| `TRADING_SERVICE_URL` | backend Gateway | Prefer `trading-service` for trading read paths |
| `RUNTIME_SERVICE_URL` | reserved | Future runtime/control-plane split follow-up |
| `VITE_NEWS_SERVICE_URL` | frontend | Direct browser calls to `news-service` for selected explain paths |
| `VITE_TRADING_SERVICE_URL` | frontend | Reserved for future direct trading reads |
| `TRADING_SERVICE_URL` | gateway, data tools | Prefer `trading_service` for trading reads |
| `NEWS_SERVICE_URL` | gateway, data tools | Prefer `news_service` for explain/news reads |
| `RUNTIME_SERVICE_URL` | dev scripts / future follow-up | Reserved for runtime-service-aware flows |
| `OPENCLAW_SERVICE_URL` | dev scripts / future follow-up | Points at the OpenClaw gateway origin in current dev setup |
If these are empty, the repo keeps using local module fallbacks where they still exist.
Current `start-dev.sh` defaults:
## Current Internal Direction
```bash
TRADING_SERVICE_URL=http://localhost:8001
NEWS_SERVICE_URL=http://localhost:8002
RUNTIME_SERVICE_URL=http://localhost:8003
OPENCLAW_SERVICE_URL=http://localhost:18789
```
The repository is now organized around split service surfaces:
Note that `OPENCLAW_SERVICE_URL` currently points at the OpenClaw gateway origin used by the live WebSocket bridge, not the optional REST app on `:8004`.
### Frontend service targets
The frontend can directly call split 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 Frontend Direct-Call Coverage
Direct browser calls currently cover:
- runtime panel loading and runtime discovery
- story
- similar days
- range explain
- news for date
- news categories
- selected trading reads such as stock history and insider trades
Other flows still depend on the gateway WebSocket and control plane APIs.
## OpenClaw Integration Notes
There are two separate OpenClaw integration surfaces in this repo:
- OpenClaw WebSocket gateway on `:18789`
- used directly by `backend/services/gateway.py`
- this is what `start-dev.sh` assumes exists
- `backend.apps.openclaw_service` on `:8004`
- optional REST facade over OpenClaw CLI-backed reads
- useful for typed client access and service-level testing
Do not treat those as interchangeable in docs or deployment config.
## Internal Module Direction
The codebase is now organized around these boundaries:
```text
frontend
├─ runtime/control/news/trading split endpoints
└─ selective per-request fallbacks where still retained
├─ runtime/control/news/trading API clients
└─ WebSocket runtime feed
backend.apps.agent_service
└─ control-plane routes
backend.apps.runtime_service
└─ runtime lifecycle + gateway discovery
└─ runtime lifecycle routes
backend.apps.trading_service
└─ read-only trading contract
backend.apps.news_service
└─ read-only explain/news contract
backend.apps.openclaw_service
└─ optional OpenClaw REST facade
backend.main / backend.services.gateway
└─ live orchestration, feed transport, scheduler, runtime coordination
```

View File

@@ -2,13 +2,15 @@
"""Shared client package."""
from shared.client.control_client import ControlPlaneClient
from shared.client.trading_client import TradingServiceClient
from shared.client.news_client import NewsServiceClient
from shared.client.openclaw_client import OpenClawServiceClient
from shared.client.runtime_client import RuntimeServiceClient
from shared.client.trading_client import TradingServiceClient
__all__ = [
"ControlPlaneClient",
"RuntimeServiceClient",
"TradingServiceClient",
"NewsServiceClient",
"OpenClawServiceClient",
]

View File

@@ -517,11 +517,13 @@ class OpenClawWebSocketClient:
params["agentId"] = agent_id
if label:
params["label"] = label
if channel:
params["channel"] = channel
result = await self._send_request("sessions.resolve", params)
sessions = result.get("sessions", [])
if sessions:
return sessions[0].get("key")
key = result.get("key")
if isinstance(key, str) and key.strip():
return key.strip()
return None
async def send_message(
@@ -549,12 +551,30 @@ class OpenClawWebSocketClient:
if thinking:
params["thinking"] = thinking
# Use shorter timeout for send since it waits for agent response
result = await self._send_request(
"sessions.send",
params,
)
return result
previous_timeout_ms = self.timeout_ms
if timeout_ms is not None:
self.timeout_ms = timeout_ms
try:
return await self._send_request("sessions.send", params)
finally:
self.timeout_ms = previous_timeout_ms
async def unsubscribe(self, session_key: str) -> dict[str, Any]:
"""Unsubscribe from messages for a session."""
return await self._send_request("sessions.messages.unsubscribe", {"key": session_key})
async def get_session_history(
self,
session_key: str,
limit: int = 20,
) -> dict[str, Any]:
"""Best-effort session history read.
OpenClaw's public Gateway surface is subscription-first for live message flow.
History is not consistently exposed over the same WS methods across builds, so
callers should still keep a CLI or REST fallback available.
"""
return await self._send_request("sessions.preview", {"keys": [session_key], "limit": limit})
async def subscribe(self, session_key: str) -> AsyncMessageIterator:
"""Subscribe to messages from a session.
@@ -670,6 +690,11 @@ class AsyncMessageIterator:
async def __anext__(self) -> MessageEvent:
return await self._queue.get()
async def aclose(self) -> None:
if self._handler_added:
self._client.remove_event_handler(self._on_event)
self._handler_added = False
# -----------------------------------------------------------------------------
# Synchronous convenience functions

View File

@@ -1,8 +1,8 @@
#!/bin/bash
#!/usr/bin/env bash
# EvoTraders Development Startup Script
# Split-service mode only
set -e
set -euo pipefail
echo "=========================================="
echo "EvoTraders Development Environment"
@@ -14,23 +14,83 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check virtual environment
if [ -z "$VIRTUAL_ENV" ]; then
echo -e "${YELLOW}Warning: Virtual environment not activated${NC}"
echo "Activating .venv..."
source .venv/bin/activate
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}"
# Load environment variables
PIDS=()
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo -e "${RED}Missing required command: ${command_name}${NC}"
exit 1
fi
}
check_python_module() {
local module_name="$1"
if ! python -c "import ${module_name}" >/dev/null 2>&1; then
echo -e "${RED}Missing required Python module: ${module_name}${NC}"
echo "Install dependencies with one of:"
echo " pip install -r requirements.txt"
echo " pip install -r requirements-dev.txt"
echo " uv pip install -e '.[dev]'"
exit 1
fi
}
load_env_file() {
if [ -f .env ]; then
echo -e "${GREEN}Loading environment from .env${NC}"
export $(grep -v '^#' .env | xargs)
set -a
source .env
set +a
else
echo -e "${YELLOW}Warning: .env file not found${NC}"
echo -e "${YELLOW}Warning: .env file not found. Copy env.template to .env first if you need live credentials.${NC}"
fi
}
cd /Users/cillin/workspeace/evotraders
PIDS=()
check_env_var() {
local var_name="$1"
local severity="${2:-warn}"
local value="${!var_name:-}"
if [ -z "${value}" ]; then
if [ "${severity}" = "error" ]; then
echo -e "${RED}Missing required environment variable: ${var_name}${NC}"
exit 1
fi
echo -e "${YELLOW}Warning: ${var_name} is not set${NC}"
fi
}
check_openclaw_gateway() {
local target_host="127.0.0.1"
local target_port="18789"
if python - <<PY >/dev/null 2>&1
import socket
sock = socket.socket()
sock.settimeout(1.0)
sock.connect(("${target_host}", ${target_port}))
sock.close()
PY
then
echo -e "${GREEN}OpenClaw gateway reachable at ws://${target_host}:${target_port}${NC}"
else
echo -e "${YELLOW}Warning: OpenClaw gateway is not reachable at ws://${target_host}:${target_port}${NC}"
echo " OpenClaw panel features may be unavailable until it is started."
fi
}
print_prereq_help() {
echo "Environment checks:"
echo " - repo root: ${SCRIPT_DIR}"
echo " - python: $(command -v python)"
if [ -n "${VIRTUAL_ENV:-}" ]; then
echo " - virtualenv: ${VIRTUAL_ENV}"
else
echo " - virtualenv: not activated"
fi
}
start_service() {
local name="$1"
@@ -74,11 +134,51 @@ if [ $# -gt 0 ]; then
echo "Split-service mode is now the only supported development mode."
fi
require_command python
require_command lsof
if [ -z "${VIRTUAL_ENV:-}" ]; then
if [ -f ".venv/bin/activate" ]; then
echo -e "${YELLOW}Virtual environment not activated; auto-activating .venv${NC}"
# shellcheck disable=SC1091
source .venv/bin/activate
else
echo -e "${YELLOW}Warning: no active virtual environment and .venv not found${NC}"
fi
fi
load_env_file
print_prereq_help
python - <<'PY'
import sys
if sys.version_info < (3, 9):
raise SystemExit("Python 3.9+ is required")
print(f"Python version OK: {sys.version.split()[0]}")
PY
check_python_module fastapi
check_python_module uvicorn
check_python_module websockets
check_python_module yaml
check_python_module dotenv
check_env_var OPENAI_API_KEY
check_env_var FINNHUB_API_KEY
check_env_var FIN_DATA_SOURCE
if ! command -v npm >/dev/null 2>&1; then
echo -e "${YELLOW}Warning: npm is not installed. Frontend startup via 'evotraders frontend' will not work.${NC}"
fi
export TRADING_SERVICE_URL="${TRADING_SERVICE_URL:-http://localhost:8001}"
export NEWS_SERVICE_URL="${NEWS_SERVICE_URL:-http://localhost:8002}"
export RUNTIME_SERVICE_URL="${RUNTIME_SERVICE_URL:-http://localhost:8003}"
export OPENCLAW_SERVICE_URL="${OPENCLAW_SERVICE_URL:-http://localhost:18789}"
check_openclaw_gateway
echo ""
echo -e "${GREEN}Starting EvoTraders split services (default mode)...${NC}"
echo " agent_service: http://localhost:8000"

68
test_openclaw_ws.py Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Quick test script for OpenClaw WebSocket client."""
import asyncio
import sys
sys.path.insert(0, '.')
from shared.client.openclaw_websocket_client import (
OpenClawWebSocketClient,
DEFAULT_GATEWAY_URL,
)
async def test_connection():
"""Test basic connection to OpenClaw Gateway."""
print(f"Connecting to {DEFAULT_GATEWAY_URL}...")
client = OpenClawWebSocketClient(
url=DEFAULT_GATEWAY_URL,
client_name="cli",
client_version="1.0.0",
gateway_token="d4b2d831b8a177b5cac07e781f438af3840bd0dfaca630ee",
)
try:
hello = await client.connect()
print(f"✓ Connected!")
print(f" Protocol version: {hello.protocol}")
print(f" Server version: {hello.server_version}")
print(f" Connection ID: {hello.conn_id}")
# List sessions
print("\nListing sessions...")
sessions = await client.list_sessions(limit=5)
print(f" Found {len(sessions)} sessions")
for session in sessions[:3]:
print(f" - {session.get('key', 'unknown')}")
# List agents
print("\nListing agents...")
agents = await client.list_agents()
print(f" Found {len(agents)} agents")
for agent in agents[:3]:
print(f" - {agent.get('id', 'unknown')}: {agent.get('name', 'unknown')}")
# Send a message to agent
if sessions:
session_key = sessions[0].get('key')
if session_key:
print(f"\nSending message to session: {session_key}")
result = await client.send_message(session_key, "Hello! This is a test message from Python.")
print(f" Message sent: {result}")
await client.disconnect()
print("\n✓ All tests passed!")
return True
except Exception as e:
print(f"✗ Error: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = asyncio.run(test_connection())
sys.exit(0 if success else 1)