Compare commits
18 Commits
456748b01e
...
codex/remo
| Author | SHA1 | Date | |
|---|---|---|---|
| e69c637dba | |||
| 728cf36e7c | |||
| 346208dc2b | |||
| 4295293a21 | |||
| 4aa69650e8 | |||
| 5c08c1865c | |||
| 6ecc224427 | |||
| 9bcc4221a4 | |||
| fecf8a9466 | |||
| 86eb8c37a9 | |||
| 1f9063edad | |||
| 7e7a58769a | |||
| 16bb3c4211 | |||
| da6d642aaa | |||
| 8d6c3c5647 | |||
| 6413edf8c9 | |||
| c5eaf2b5ad | |||
| 032c37538f |
41
.env.example
Normal file
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Copy this file to `.env` for local development.
|
||||||
|
# Keep `.env` untracked and never paste real secrets into tracked files.
|
||||||
|
|
||||||
|
# ================== General Configuration | 通用配置 ==================
|
||||||
|
TICKERS=AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN
|
||||||
|
|
||||||
|
# Financial Data API
|
||||||
|
# At least `FINANCIAL_DATASETS_API_KEY` is required when using `FIN_DATA_SOURCE=financial_datasets`.
|
||||||
|
# `FINNHUB_API_KEY` is recommended for `FIN_DATA_SOURCE=finnhub` and required for live mode.
|
||||||
|
FIN_DATA_SOURCE=finnhub
|
||||||
|
ENABLED_DATA_SOURCES=financial_datasets,finnhub,yfinance,local_csv
|
||||||
|
FINANCIAL_DATASETS_API_KEY=
|
||||||
|
FINNHUB_API_KEY=
|
||||||
|
POLYGON_API_KEY=
|
||||||
|
MARKET_DB_PATH=
|
||||||
|
|
||||||
|
# Model API
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_BASE_URL=
|
||||||
|
MODEL_NAME=qwen3-max-preview
|
||||||
|
EXPLAIN_ENRICH_USE_LLM=false
|
||||||
|
EXPLAIN_ENRICH_MODEL_PROVIDER=
|
||||||
|
EXPLAIN_ENRICH_MODEL_NAME=
|
||||||
|
EXPLAIN_RANGE_USE_LLM=
|
||||||
|
|
||||||
|
# Memory module
|
||||||
|
MEMORY_API_KEY=
|
||||||
|
|
||||||
|
# ================== Agent-Specific Model Configuration | Agent特定模型配置 ==================
|
||||||
|
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
|
||||||
|
|
||||||
|
# ================== Advanced Configuration | 高阶配置 ==================
|
||||||
|
MAX_COMM_CYCLES=2
|
||||||
|
MARGIN_REQUIREMENT=0.5
|
||||||
|
DATA_START_DATE=2022-01-01
|
||||||
|
AUTO_UPDATE_DATA=true
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -51,13 +51,15 @@ node_modules
|
|||||||
outputs/
|
outputs/
|
||||||
/production/
|
/production/
|
||||||
/smoke_test/
|
/smoke_test/
|
||||||
/smoke_live_mock/
|
|
||||||
|
|
||||||
# Local tooling state
|
# Local tooling state
|
||||||
/.omc/
|
.omc/
|
||||||
/.pydeps/
|
/.pydeps/
|
||||||
/referance/
|
/referance/
|
||||||
|
|
||||||
|
# Run outputs
|
||||||
|
/runs/
|
||||||
|
|
||||||
# Data files
|
# Data files
|
||||||
backend/data/ret_data/
|
backend/data/ret_data/
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastScanned": 1773938154948,
|
"lastScanned": 1774515151036,
|
||||||
"projectRoot": "/Users/cillin/workspeace/evotraders",
|
"projectRoot": "/Users/cillin/workspeace/evotraders",
|
||||||
"techStack": {
|
"techStack": {
|
||||||
"languages": [
|
"languages": [
|
||||||
@@ -11,14 +11,6 @@
|
|||||||
"markers": [
|
"markers": [
|
||||||
"pyproject.toml"
|
"pyproject.toml"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "C/C++",
|
|
||||||
"version": null,
|
|
||||||
"confidence": "high",
|
|
||||||
"markers": [
|
|
||||||
"Makefile"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"frameworks": [
|
"frameworks": [
|
||||||
@@ -32,8 +24,8 @@
|
|||||||
"runtime": null
|
"runtime": null
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"buildCommand": "make build",
|
"buildCommand": null,
|
||||||
"testCommand": "make test",
|
"testCommand": "pytest",
|
||||||
"lintCommand": "ruff check",
|
"lintCommand": "ruff check",
|
||||||
"devCommand": null,
|
"devCommand": null,
|
||||||
"scripts": {}
|
"scripts": {}
|
||||||
@@ -58,24 +50,13 @@
|
|||||||
},
|
},
|
||||||
"customNotes": [],
|
"customNotes": [],
|
||||||
"directoryMap": {
|
"directoryMap": {
|
||||||
"agent-service": {
|
|
||||||
"path": "agent-service",
|
|
||||||
"purpose": null,
|
|
||||||
"fileCount": 2,
|
|
||||||
"lastAccessed": 1773938154941,
|
|
||||||
"keyFiles": [
|
|
||||||
"Dockerfile",
|
|
||||||
"requirements.txt"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"backend": {
|
"backend": {
|
||||||
"path": "backend",
|
"path": "backend",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 5,
|
"fileCount": 4,
|
||||||
"lastAccessed": 1773938154941,
|
"lastAccessed": 1774515151025,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"app.py",
|
|
||||||
"cli.py",
|
"cli.py",
|
||||||
"gateway_server.py",
|
"gateway_server.py",
|
||||||
"main.py"
|
"main.py"
|
||||||
@@ -85,37 +66,41 @@
|
|||||||
"path": "backtest",
|
"path": "backtest",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154941,
|
"lastAccessed": 1774515151026,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"path": "data",
|
"path": "data",
|
||||||
"purpose": "Data files",
|
"purpose": "Data files",
|
||||||
"fileCount": 1,
|
"fileCount": 3,
|
||||||
"lastAccessed": 1773938154941,
|
"lastAccessed": 1774515151027,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"market_research.db"
|
"market_research.db",
|
||||||
|
"market_research.db-shm",
|
||||||
|
"market_research.db-wal"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"deploy": {
|
"deploy": {
|
||||||
"path": "deploy",
|
"path": "deploy",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154942,
|
"lastAccessed": 1774515151027,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"path": "docs",
|
"path": "docs",
|
||||||
"purpose": "Documentation",
|
"purpose": "Documentation",
|
||||||
"fileCount": 0,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1773938154942,
|
"lastAccessed": 1774515151027,
|
||||||
"keyFiles": []
|
"keyFiles": [
|
||||||
|
"compat-removal-plan.md"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"evotraders.egg-info": {
|
"evotraders.egg-info": {
|
||||||
"path": "evotraders.egg-info",
|
"path": "evotraders.egg-info",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 6,
|
"fileCount": 6,
|
||||||
"lastAccessed": 1773938154942,
|
"lastAccessed": 1774515151028,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"PKG-INFO",
|
"PKG-INFO",
|
||||||
"SOURCES.txt",
|
"SOURCES.txt",
|
||||||
@@ -128,7 +113,7 @@
|
|||||||
"path": "frontend",
|
"path": "frontend",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 13,
|
"fileCount": 13,
|
||||||
"lastAccessed": 1773938154942,
|
"lastAccessed": 1774515151028,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"README.md",
|
"README.md",
|
||||||
"components.json",
|
"components.json",
|
||||||
@@ -141,51 +126,28 @@
|
|||||||
"path": "live",
|
"path": "live",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154943,
|
"lastAccessed": 1774515151028,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"logs": {
|
|
||||||
"path": "logs",
|
|
||||||
"purpose": null,
|
|
||||||
"fileCount": 7,
|
|
||||||
"lastAccessed": 1773938154943,
|
|
||||||
"keyFiles": [
|
|
||||||
"2026-03-16_00-48-03.log",
|
|
||||||
"2026-03-18_23-17-29.log",
|
|
||||||
"2026-03-18_23-17-30.2026-03-18_23-17-30_000801.log.zip",
|
|
||||||
"2026-03-18_23-17-30.log",
|
|
||||||
"2026-03-19_00-18-04.log"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"news-service": {
|
|
||||||
"path": "news-service",
|
|
||||||
"purpose": null,
|
|
||||||
"fileCount": 3,
|
|
||||||
"lastAccessed": 1773938154943,
|
|
||||||
"keyFiles": [
|
|
||||||
"Dockerfile",
|
|
||||||
"requirements.txt"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"reference": {
|
"reference": {
|
||||||
"path": "reference",
|
"path": "reference",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154943,
|
"lastAccessed": 1774515151028,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"runs": {
|
"runs": {
|
||||||
"path": "runs",
|
"path": "runs",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774515151029,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"path": "scripts",
|
"path": "scripts",
|
||||||
"purpose": "Build/utility scripts",
|
"purpose": "Build/utility scripts",
|
||||||
"fileCount": 1,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774515151030,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"run_prod.sh"
|
"run_prod.sh"
|
||||||
]
|
]
|
||||||
@@ -194,7 +156,7 @@
|
|||||||
"path": "services",
|
"path": "services",
|
||||||
"purpose": "Business logic services",
|
"purpose": "Business logic services",
|
||||||
"fileCount": 1,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774515151030,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"README.md"
|
"README.md"
|
||||||
]
|
]
|
||||||
@@ -203,43 +165,14 @@
|
|||||||
"path": "shared",
|
"path": "shared",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774515151030,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"trading-service": {
|
|
||||||
"path": "trading-service",
|
|
||||||
"purpose": null,
|
|
||||||
"fileCount": 4,
|
|
||||||
"lastAccessed": 1773938154944,
|
|
||||||
"keyFiles": [
|
|
||||||
"Dockerfile",
|
|
||||||
"README.md",
|
|
||||||
"requirements.txt"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"workspaces": {
|
|
||||||
"path": "workspaces",
|
|
||||||
"purpose": null,
|
|
||||||
"fileCount": 0,
|
|
||||||
"lastAccessed": 1773938154944,
|
|
||||||
"keyFiles": []
|
|
||||||
},
|
|
||||||
"agent-service/src": {
|
|
||||||
"path": "agent-service/src",
|
|
||||||
"purpose": "Source code",
|
|
||||||
"fileCount": 5,
|
|
||||||
"lastAccessed": 1773938154944,
|
|
||||||
"keyFiles": [
|
|
||||||
"__init__.py",
|
|
||||||
"config.py",
|
|
||||||
"main.py"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"backend/api": {
|
"backend/api": {
|
||||||
"path": "backend/api",
|
"path": "backend/api",
|
||||||
"purpose": "API routes",
|
"purpose": "API routes",
|
||||||
"fileCount": 5,
|
"fileCount": 5,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774515151030,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"agents.py",
|
"agents.py",
|
||||||
@@ -250,7 +183,7 @@
|
|||||||
"path": "backend/config",
|
"path": "backend/config",
|
||||||
"purpose": "Configuration files",
|
"purpose": "Configuration files",
|
||||||
"fileCount": 6,
|
"fileCount": 6,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774515151030,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"agent_profiles.yaml",
|
"agent_profiles.yaml",
|
||||||
@@ -260,8 +193,8 @@
|
|||||||
"backend/data": {
|
"backend/data": {
|
||||||
"path": "backend/data",
|
"path": "backend/data",
|
||||||
"purpose": "Data files",
|
"purpose": "Data files",
|
||||||
"fileCount": 13,
|
"fileCount": 12,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774515151031,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"cache.py",
|
"cache.py",
|
||||||
@@ -272,7 +205,7 @@
|
|||||||
"path": "docs/assets",
|
"path": "docs/assets",
|
||||||
"purpose": "Static assets",
|
"purpose": "Static assets",
|
||||||
"fileCount": 5,
|
"fileCount": 5,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774515151031,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"dashboard.jpg",
|
"dashboard.jpg",
|
||||||
"evotraders_demo.gif",
|
"evotraders_demo.gif",
|
||||||
@@ -283,7 +216,7 @@
|
|||||||
"path": "frontend/dist",
|
"path": "frontend/dist",
|
||||||
"purpose": "Distribution/build output",
|
"purpose": "Distribution/build output",
|
||||||
"fileCount": 2,
|
"fileCount": 2,
|
||||||
"lastAccessed": 1773938154945,
|
"lastAccessed": 1774515151031,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"index.html",
|
"index.html",
|
||||||
"trading_logo.png"
|
"trading_logo.png"
|
||||||
@@ -293,331 +226,309 @@
|
|||||||
"path": "frontend/node_modules",
|
"path": "frontend/node_modules",
|
||||||
"purpose": "Dependencies",
|
"purpose": "Dependencies",
|
||||||
"fileCount": 1,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1773938154947,
|
"lastAccessed": 1774515151036,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
|
||||||
"news-service/src": {
|
|
||||||
"path": "news-service/src",
|
|
||||||
"purpose": "Source code",
|
|
||||||
"fileCount": 3,
|
|
||||||
"lastAccessed": 1773938154948,
|
|
||||||
"keyFiles": [
|
|
||||||
"__init__.py",
|
|
||||||
"config.py",
|
|
||||||
"main.py"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"trading-service/src": {
|
|
||||||
"path": "trading-service/src",
|
|
||||||
"purpose": "Source code",
|
|
||||||
"fileCount": 8,
|
|
||||||
"lastAccessed": 1773938154948,
|
|
||||||
"keyFiles": [
|
|
||||||
"__init__.py",
|
|
||||||
"config.py",
|
|
||||||
"main.py"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotPaths": [
|
"hotPaths": [
|
||||||
{
|
{
|
||||||
"path": "backend/agents/factory.py",
|
"path": "frontend/src/hooks/useWebSocketConnection.js",
|
||||||
"accessCount": 17,
|
"accessCount": 100,
|
||||||
"lastAccessed": 1773939950376,
|
"lastAccessed": 1774550862686,
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend",
|
|
||||||
"accessCount": 16,
|
|
||||||
"lastAccessed": 1773940042371,
|
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "",
|
|
||||||
"accessCount": 13,
|
|
||||||
"lastAccessed": 1773939899611,
|
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/main.py",
|
|
||||||
"accessCount": 7,
|
|
||||||
"lastAccessed": 1773939993951,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/gateway_server.py",
|
|
||||||
"accessCount": 7,
|
|
||||||
"lastAccessed": 1773940004402,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/main.py",
|
|
||||||
"accessCount": 5,
|
|
||||||
"lastAccessed": 1773938385662,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/core/pipeline.py",
|
|
||||||
"accessCount": 5,
|
|
||||||
"lastAccessed": 1773940024933,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/enrich/news_enricher.py",
|
|
||||||
"accessCount": 4,
|
|
||||||
"lastAccessed": 1773938508417,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "start-dev.sh",
|
|
||||||
"accessCount": 4,
|
|
||||||
"lastAccessed": 1773939259381,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "services/README.md",
|
|
||||||
"accessCount": 4,
|
|
||||||
"lastAccessed": 1773939281935,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/app.py",
|
|
||||||
"accessCount": 4,
|
|
||||||
"lastAccessed": 1773939648215,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/routes/news.py",
|
|
||||||
"accessCount": 3,
|
|
||||||
"lastAccessed": 1773938438928,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news",
|
|
||||||
"accessCount": 3,
|
|
||||||
"lastAccessed": 1773938468730,
|
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "frontend/src/config/constants.js",
|
|
||||||
"accessCount": 3,
|
|
||||||
"lastAccessed": 1773939204395,
|
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/gateway.py",
|
"path": "backend/services/gateway.py",
|
||||||
"accessCount": 3,
|
"accessCount": 98,
|
||||||
"lastAccessed": 1773939672930,
|
"lastAccessed": 1774550272354,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/core/__init__.py",
|
"path": "backend/services/gateway_openclaw_handlers.py",
|
||||||
"accessCount": 3,
|
"accessCount": 91,
|
||||||
"lastAccessed": 1773939963627,
|
"lastAccessed": 1774550256325,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/trading/main.py",
|
"path": "backend/api/openclaw.py",
|
||||||
"accessCount": 2,
|
"accessCount": 48,
|
||||||
"lastAccessed": 1773938360736,
|
"lastAccessed": 1774545375555,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/agents/main.py",
|
"path": "frontend/src/hooks/useOpenClawPanel.js",
|
||||||
"accessCount": 2,
|
"accessCount": 42,
|
||||||
"lastAccessed": 1773938361040,
|
"lastAccessed": 1774550688926,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/trading/data/__init__.py",
|
"path": "shared/client/openclaw_client.py",
|
||||||
"accessCount": 2,
|
"accessCount": 39,
|
||||||
"lastAccessed": 1773938402496,
|
"lastAccessed": 1774545484770,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/news/explain/__init__.py",
|
"path": "frontend/src",
|
||||||
"accessCount": 2,
|
"accessCount": 35,
|
||||||
"lastAccessed": 1773938460019,
|
"lastAccessed": 1774550715529,
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/enrich/__init__.py",
|
|
||||||
"accessCount": 2,
|
|
||||||
"lastAccessed": 1773938465216,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/explain/range_explainer.py",
|
|
||||||
"accessCount": 2,
|
|
||||||
"lastAccessed": 1773938481152,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/enrich/llm_enricher.py",
|
|
||||||
"accessCount": 2,
|
|
||||||
"lastAccessed": 1773938499885,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "CLAUDE.md",
|
|
||||||
"accessCount": 2,
|
|
||||||
"lastAccessed": 1773939273598,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/agents/__init__.py",
|
|
||||||
"accessCount": 2,
|
|
||||||
"lastAccessed": 1773939883015,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/agents/agent_core.py",
|
|
||||||
"accessCount": 2,
|
|
||||||
"lastAccessed": 1773939886997,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "Makefile",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938226307,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "docker-compose.yml",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938226360,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/shared/trading_client.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938370618,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/agents",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938397772,
|
|
||||||
"type": "directory"
|
"type": "directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/trading",
|
"path": "reference/openclaw/src",
|
||||||
"accessCount": 1,
|
"accessCount": 33,
|
||||||
"lastAccessed": 1773938397823,
|
"lastAccessed": 1774550840611,
|
||||||
"type": "directory"
|
"type": "directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services",
|
"path": "backend/services/openclaw_cli.py",
|
||||||
"accessCount": 1,
|
"accessCount": 31,
|
||||||
"lastAccessed": 1773938405541,
|
"lastAccessed": 1774545484887,
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/config.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938638664,
|
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "shared/client/news_client.py",
|
"path": "frontend/src/components/TraderView.jsx",
|
||||||
"accessCount": 1,
|
"accessCount": 23,
|
||||||
"lastAccessed": 1773938638715,
|
"lastAccessed": 1774543366574,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "shared/client/trading_client.py",
|
"path": "shared/models/openclaw.py",
|
||||||
"accessCount": 1,
|
"accessCount": 22,
|
||||||
"lastAccessed": 1773938638770,
|
"lastAccessed": 1774545419541,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/api",
|
"path": "frontend/src/store/openclawStore.js",
|
||||||
"accessCount": 1,
|
"accessCount": 20,
|
||||||
"lastAccessed": 1773938669143,
|
"lastAccessed": 1774550319533,
|
||||||
"type": "directory"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend",
|
"path": "frontend/src/App.jsx",
|
||||||
"accessCount": 1,
|
"accessCount": 18,
|
||||||
"lastAccessed": 1773938669195,
|
"lastAccessed": 1774544542524,
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": ".env.example",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938849397,
|
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/src/services/websocket.js",
|
"path": "frontend/src/services/websocket.js",
|
||||||
"accessCount": 1,
|
"accessCount": 18,
|
||||||
"lastAccessed": 1773938849448,
|
"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": 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": 1774517930660,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/hooks/useAgentWorkspacePanel.js",
|
||||||
|
"accessCount": 2,
|
||||||
|
"lastAccessed": 1774518021290,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/src/services/runtimeApi.js",
|
"path": "frontend/src/services/runtimeApi.js",
|
||||||
"accessCount": 1,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1773938849500,
|
"lastAccessed": 1774518025465,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/agents/routes/websocket.py",
|
"path": "reference/openclaw/src/commands/agents.commands.delete.ts",
|
||||||
"accessCount": 1,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1773939001692,
|
"lastAccessed": 1774530389553,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/agents/routes/agents.py",
|
"path": "reference/openclaw/src/commands/agents.commands.add.ts",
|
||||||
"accessCount": 1,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1773939016291,
|
"lastAccessed": 1774530389605,
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/agents/routes/run.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939016343,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/__init__.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939648323,
|
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/api/__init__.py",
|
"path": "backend/api/__init__.py",
|
||||||
"accessCount": 1,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1773939658650,
|
"lastAccessed": 1774542416191,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/runtime/__init__.py",
|
"path": "frontend/vite.config.js",
|
||||||
"accessCount": 1,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1773939658687,
|
"lastAccessed": 1774544772960,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/agents/base/evo_agent.py",
|
"path": "frontend/src/store/index.js",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773939664916,
|
"lastAccessed": 1774515811752,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/agents/analyst.py",
|
"path": "frontend/src/store/marketStore.js",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773939664967,
|
"lastAccessed": 1774515838923,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/agents/base/hooks.py",
|
"path": "frontend/src/store/portfolioStore.js",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773939672727,
|
"lastAccessed": 1774515839687,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pyproject.toml",
|
"path": "frontend/src/index.css",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773939672778,
|
"lastAccessed": 1774515988837,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/App.css",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774515998423,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/package.json",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774516005569,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/hooks/useAgentDataRequests.js",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774517930219,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/services/gateway_admin_handlers.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774517937966,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/apps/agent_service.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774517946208,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/hooks",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774517946260,
|
||||||
|
"type": "directory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/hooks/useFeedProcessor.js",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774517952115,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "reference/openclaw/src/commands/models/set.ts",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774526963526,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "reference/openclaw/src/commands/models/list.ts",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774526963632,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "reference/openclaw/src/cli/skills-cli.format.ts",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774526963684,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"timestamp": "2026-03-19T16:36:52.471Z",
|
"timestamp": "2026-03-27T04:53:52.906Z",
|
||||||
"backgroundTasks": [],
|
"backgroundTasks": [],
|
||||||
"sessionStartTimestamp": "2026-03-19T16:36:42.224Z",
|
"sessionStartTimestamp": "2026-03-27T04:53:21.944Z",
|
||||||
"sessionId": "ef02339a-1eec-4c7a-95ac-c8cfa0b5067d"
|
"sessionId": "cbb9004e-771b-4e82-95d4-cea6d9753642"
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"session_id":"ef02339a-1eec-4c7a-95ac-c8cfa0b5067d","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/ef02339a-1eec-4c7a-95ac-c8cfa0b5067d.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":17.458779250000003,"total_duration_ms":1866224,"total_api_duration_ms":1188013,"total_lines_added":257,"total_lines_removed":290},"context_window":{"total_input_tokens":195204,"total_output_tokens":48917,"context_window_size":200000,"current_usage":{"input_tokens":481,"output_tokens":0,"cache_creation_input_tokens":149,"cache_read_input_tokens":163286},"used_percentage":82,"remaining_percentage":18},"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}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"lastSentAt": "2026-03-19T17:02:32.170Z"
|
"lastSentAt": "2026-03-27T04:55:49.635Z"
|
||||||
}
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"agents": [
|
|
||||||
{
|
|
||||||
"agent_id": "a8305a91e192b2196",
|
|
||||||
"agent_type": "Explore",
|
|
||||||
"started_at": "2026-03-19T17:00:33.284Z",
|
|
||||||
"parent_mode": "none",
|
|
||||||
"status": "completed",
|
|
||||||
"completed_at": "2026-03-19T17:02:19.439Z",
|
|
||||||
"duration_ms": 106155
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_spawned": 1,
|
|
||||||
"total_completed": 1,
|
|
||||||
"total_failed": 0,
|
|
||||||
"last_updated": "2026-03-19T17:02:39.175Z"
|
|
||||||
}
|
|
||||||
BIN
.playwright-mcp/page-2026-03-26T12-28-14-006Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-26T12-28-14-006Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
392
CLAUDE.md
392
CLAUDE.md
@@ -1,10 +1,12 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
|
本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
EvoTraders 是一个自进化多智能体交易系统,由 6 个 AI Agent(4 名分析师 + 投资经理 + 风控经理)协作完成交易决策。Agent 基于 AgentScope 框架构建,配合 ReMe 记忆系统实现持续学习。
|
大时代 是一个自进化多智能体交易系统,由 6 个 AI Agent(4 名分析师 + 投资经理 + 风控经理)协作完成交易决策。Agent 基于 AgentScope 框架构建,配合 ReMe 记忆系统实现持续学习。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
@@ -18,23 +20,24 @@ uv pip install -e .
|
|||||||
evotraders backtest --start 2025-11-01 --end 2025-12-01 # 回测模式
|
evotraders backtest --start 2025-11-01 --end 2025-12-01 # 回测模式
|
||||||
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 带记忆回测
|
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 带记忆回测
|
||||||
evotraders live # 实盘交易
|
evotraders live # 实盘交易
|
||||||
evotraders live --mock # 模拟/测试模式
|
|
||||||
evotraders live -t 22:30 # 定时每日交易
|
evotraders live -t 22:30 # 定时每日交易
|
||||||
evotraders frontend # 启动可视化界面
|
evotraders frontend # 启动可视化界面
|
||||||
|
|
||||||
# 开发服务器
|
# 开发服务器
|
||||||
./start-dev.sh # 启动全部 4 个微服务
|
./start-dev.sh # 启动全部 4 个微服务 (agent, runtime, trading, news)
|
||||||
|
|
||||||
# 单独启动某个服务
|
# Gateway WebSocket 服务器
|
||||||
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
|
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
|
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --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.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.news_service:app --host 0.0.0.0 --port 8002 --reload
|
||||||
|
|
||||||
# 测试
|
# 测试
|
||||||
pytest backend/tests # 运行全部测试
|
pytest backend/tests # 运行全部测试
|
||||||
pytest backend/tests/test_news_service_app.py -v # 运行单个测试文件
|
pytest backend/tests/test_news_service_app.py -v # 运行单个测试
|
||||||
pytest backend/tests/test_news_service_app.py::test_news_service_routes_are_exposed -v # 运行单个测试
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (React)
|
### Frontend (React)
|
||||||
@@ -46,142 +49,236 @@ npm run build # 生产构建
|
|||||||
npm run lint # ESLint 检查
|
npm run lint # ESLint 检查
|
||||||
npm run lint:fix # ESLint 自动修复
|
npm run lint:fix # ESLint 自动修复
|
||||||
npm run test # Vitest 单元测试
|
npm run test # Vitest 单元测试
|
||||||
npm run test:watch # 监听模式
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 架构概览
|
## 架构概览
|
||||||
|
|
||||||
### 微服务架构 (`backend/apps/`)
|
### 系统分层
|
||||||
|
|
||||||
项目采用 split-first 微服务架构,4 个独立的 FastAPI 服务:
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
| 服务 | 入口 | 端口 | 职责 |
|
│ Frontend (React) │
|
||||||
|------|------|------|------|
|
│ WebSocket ws://localhost:8765 连接 Gateway │
|
||||||
| agent_service | `backend.apps.agent_service:app` | 8000 | Agent 生命周期、工作区管理 |
|
└─────────────────────────────────────────────────────────────┘
|
||||||
| runtime_service | `backend.apps.runtime_service:app` | 8003 | 运行时配置、任务启动 |
|
│
|
||||||
| trading_service | `backend.apps.trading_service:app` | 8001 | 市场数据、交易操作 |
|
▼
|
||||||
| news_service | `backend.apps.news_service:app` | 8002 | 新闻、新闻富化、解释功能 |
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gateway (backend/services/gateway.py) │
|
||||||
服务间通过环境变量通信(详见 `start-dev.sh`):
|
│ WebSocket 服务器,编排 Pipeline,4 阶段启动 │
|
||||||
```bash
|
└─────────────────────────────────────────────────────────────┘
|
||||||
export TRADING_SERVICE_URL=http://localhost:8001
|
│ │ │ │
|
||||||
export NEWS_SERVICE_URL=http://localhost:8002
|
▼ ▼ ▼ ▼
|
||||||
export RUNTIME_SERVICE_URL=http://localhost:8003
|
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||||
|
│ Market │ │ Storage │ │ Pipeline │ │ Scheduler │
|
||||||
|
│ Service │ │ Service │ │ │ │ │
|
||||||
|
└────────────┘ └────────────┘ └────────────┘ └────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────┼──────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ Analysts │ │ PM │ │ Risk │
|
||||||
|
│ (4 个) │ │ │ │ Manager │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gateway 网关 (`backend/services/gateway.py`)
|
### 微服务架构 (`backend/apps/`)
|
||||||
|
|
||||||
Gateway 是统一的请求路由器,根据路径前缀将请求转发到对应的微服务:
|
| 服务 | 端口 | 职责 |
|
||||||
- `/control/*` → agent_service
|
|------|------|------|
|
||||||
- `/runtime/*` → runtime_service
|
| runtime_service | 8003 | 运行时配置、任务启动、Pipeline Runner |
|
||||||
- `/trading/*` → trading_service
|
| agent_service | 8000 | Agent 生命周期、工作区管理 |
|
||||||
- `/news/*` → news_service
|
| trading_service | 8001 | 市场数据、交易操作 |
|
||||||
|
| news_service | 8002 | 新闻、新闻富化、解释功能 |
|
||||||
|
|
||||||
新增接口时应注册到对应的 service app,而非直接添加到 gateway。
|
### Gateway 4 阶段启动 (`backend/services/gateway.py`)
|
||||||
|
|
||||||
### 共享客户端 (`shared/client/`)
|
1. **WebSocket Server** - 前端立即可连接
|
||||||
|
2. **Market Service** - 价格数据开始推送
|
||||||
|
3. **Market Status Monitor** - 市场状态监控
|
||||||
|
4. **Scheduler** - 交易周期开始
|
||||||
|
|
||||||
统一的服务客户端库,所有前端和后端服务间通信都使用此处定义的客户端:
|
### 运行时管理层 (`backend/runtime/`)
|
||||||
|
|
||||||
| 客户端 | 用途 |
|
| 文件 | 职责 |
|
||||||
|--------|------|
|
|------|------|
|
||||||
| `ControlPlaneClient` | Agent 服务通信 |
|
| `manager.py` | TradingRuntimeManager - 全局运行时管理器,agent 注册、会话、事件快照 |
|
||||||
| `RuntimeServiceClient` | 运行时服务通信 |
|
| `agent_runtime.py` | AgentRuntimeState - 单 agent 状态(status、last_session) |
|
||||||
| `TradingServiceClient` | 交易服务通信 |
|
| `context.py` | TradingRunContext - 运行上下文 |
|
||||||
| `NewsServiceClient` | 新闻服务通信 |
|
| `session.py` | TradingSessionKey - 交易日会话键 |
|
||||||
|
| `registry.py` | RuntimeRegistry - agent 状态注册表 |
|
||||||
|
|
||||||
### 领域层 (`backend/domains/`)
|
快照持久化到 `runs/<run_id>/state/runtime_state.json`。
|
||||||
|
|
||||||
业务逻辑按领域分离:
|
### Pipeline 执行 (`backend/core/`)
|
||||||
|
|
||||||
- `news.py` - 新闻领域操作
|
| 文件 | 职责 |
|
||||||
- `trading.py` - 交易领域操作
|
|------|------|
|
||||||
|
| `pipeline.py` | TradingPipeline - 核心编排器(分析→沟通→决策→执行→评估) |
|
||||||
|
| `pipeline_runner.py` | REST API 触发的独立执行,5 阶段启动 |
|
||||||
|
| `scheduler.py` | BacktestScheduler、Scheduler - 回测/实盘调度 |
|
||||||
|
| `state_sync.py` | StateSync - 状态同步和广播 |
|
||||||
|
|
||||||
## 后端结构
|
## 后端结构
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── agents/ # 多智能体实现
|
├── agents/ # 多智能体实现
|
||||||
│ ├── base/ # 核心类、Hooks、评估
|
│ ├── analyst.py # AnalystAgent 基类
|
||||||
│ │ ├── evo_agent.py # 基于 AgentScope 的核心实现
|
│ ├── portfolio_manager.py # PMAgent 投资经理
|
||||||
│ │ ├── hooks.py # 生命周期 Hooks
|
│ ├── risk_manager.py # RiskAgent 风控经理
|
||||||
│ │ │ ├── BootstrapHook # 启动初始化
|
|
||||||
│ │ │ ├── MemoryCompactionHook # 内存压缩(基于 CoPaw)
|
|
||||||
│ │ │ ├── HeartbeatHook # 心跳检测
|
|
||||||
│ │ │ └── WorkspaceWatchHook # 工作区监控
|
|
||||||
│ │ ├── evaluation_hook.py # 执行后评估
|
|
||||||
│ │ ├── skill_adaptation_hook.py # 动态技能适配
|
|
||||||
│ │ └── tool_guard.py # 工具调用守卫
|
|
||||||
│ ├── prompts/ # Agent 提示词和角色定义
|
|
||||||
│ │ ├── analyst/personas.yaml # 分析师角色配置
|
|
||||||
│ │ └── portfolio_manager/
|
|
||||||
│ ├── team/ # 团队协作逻辑
|
|
||||||
│ │ ├── registry.py # Agent 注册表
|
|
||||||
│ │ ├── coordinator.py # 协作协调器
|
|
||||||
│ │ ├── messenger.py # 消息传递
|
|
||||||
│ │ └── task_delegator.py # 任务分发
|
|
||||||
│ ├── factory.py # Agent 实例工厂
|
│ ├── factory.py # Agent 实例工厂
|
||||||
│ ├── skills_manager.py # 技能加载管理(6 种作用域)
|
│ ├── toolkit_factory.py # 工具集工厂
|
||||||
│ └── toolkit_factory.py # 工具集工厂
|
│ ├── skills_manager.py # 技能加载管理
|
||||||
├── apps/ # 微服务入口(split-first)
|
│ ├── workspace_manager.py # 工作区管理
|
||||||
│ ├── agent_service.py
|
│ ├── skill_loader.py # 技能加载器
|
||||||
│ ├── runtime_service.py
|
│ ├── agent_workspace.py # Agent 工作区
|
||||||
│ ├── trading_service.py
|
│ ├── prompt_loader.py # Prompt 加载器
|
||||||
│ └── news_service.py
|
│ ├── prompt_factory.py # Prompt 工厂
|
||||||
|
│ ├── skill_metadata.py # 技能元数据
|
||||||
|
│ ├── registry.py # Agent 注册表
|
||||||
|
│ ├── team_pipeline_config.py # 团队 Pipeline 配置
|
||||||
|
│ ├── compat.py # 兼容性层
|
||||||
|
│ ├── templates.py # 模板
|
||||||
|
│ ├── workspace.py # 工作区
|
||||||
|
│ ├── base/ # 核心类、Hooks
|
||||||
|
│ │ ├── evo_agent.py # 基于 AgentScope 的核心实现
|
||||||
|
│ │ └── hooks.py # 生命周期 Hooks
|
||||||
|
│ └── prompts/ # Agent 提示词
|
||||||
|
│ └── analyst/personas.yaml
|
||||||
|
│
|
||||||
|
├── apps/ # 微服务入口
|
||||||
|
│ ├── runtime_service.py # 运行时服务(端口 8003)
|
||||||
|
│ ├── agent_service.py # Agent 服务(端口 8000)
|
||||||
|
│ ├── trading_service.py # 交易服务(端口 8001)
|
||||||
|
│ ├── news_service.py # 新闻服务(端口 8002)
|
||||||
|
│ └── cors.py
|
||||||
|
│
|
||||||
|
├── runtime/ # 运行时管理层
|
||||||
|
│ ├── manager.py # TradingRuntimeManager
|
||||||
|
│ ├── agent_runtime.py # AgentRuntimeState
|
||||||
|
│ ├── context.py # TradingRunContext
|
||||||
|
│ ├── session.py # TradingSessionKey
|
||||||
|
│ └── registry.py # RuntimeRegistry
|
||||||
|
│
|
||||||
|
├── process/ # 进程监管层
|
||||||
|
│ ├── supervisor.py # ProcessSupervisor
|
||||||
|
│ ├── registry.py # RunRegistry
|
||||||
|
│ └── models.py # ProcessRun、ProcessRunState
|
||||||
|
│
|
||||||
|
├── core/ # Pipeline 执行
|
||||||
|
│ ├── pipeline.py # TradingPipeline(核心编排器)
|
||||||
|
│ ├── pipeline_runner.py # 独立 Pipeline 执行
|
||||||
|
│ ├── scheduler.py # 调度器
|
||||||
|
│ └── state_sync.py # 状态同步
|
||||||
|
│
|
||||||
|
├── services/ # Gateway 和服务
|
||||||
|
│ ├── gateway.py # WebSocket 网关
|
||||||
|
│ ├── gateway_*.py # Gateway 子模块
|
||||||
|
│ ├── market.py # 市场数据服务
|
||||||
|
│ ├── storage.py # 存储服务
|
||||||
|
│ ├── runtime_db.py # 运行时数据库
|
||||||
|
│ └── research_db.py # 研究数据库
|
||||||
|
│
|
||||||
|
├── data/ # 市场数据处理
|
||||||
|
│ ├── provider_router.py # 数据源路由
|
||||||
|
│ ├── provider_utils.py # 数据源工具
|
||||||
|
│ ├── market_store.py # 市场数据存储
|
||||||
|
│ ├── market_ingest.py # 数据采集
|
||||||
|
│ ├── cache.py # 缓存
|
||||||
|
│ ├── schema.py # 数据 schema
|
||||||
|
│ ├── historical_price_manager.py # 历史价格管理
|
||||||
|
│ ├── polling_price_manager.py # 轮询价格管理
|
||||||
|
│ ├── news_alignment.py # 新闻对齐
|
||||||
|
│ ├── polygon_client.py # Polygon.io 客户端
|
||||||
|
│ └── ret_data_updater.py # 离线数据更新
|
||||||
|
│
|
||||||
|
├── config/ # 配置
|
||||||
|
│ ├── constants.py # Agent 配置、显示名称
|
||||||
|
│ ├── bootstrap_config.py # 启动配置解析
|
||||||
|
│ ├── env_config.py # 环境变量配置
|
||||||
|
│ ├── data_config.py # 数据源配置
|
||||||
|
│ └── agent_profiles.yaml # Agent Profile 配置
|
||||||
|
│
|
||||||
├── domains/ # 领域业务逻辑
|
├── domains/ # 领域业务逻辑
|
||||||
│ ├── news.py
|
│ ├── news.py
|
||||||
│ └── trading.py
|
│ └── trading.py
|
||||||
├── services/ # Gateway 和辅助服务
|
│
|
||||||
│ ├── gateway.py # 统一路由网关
|
|
||||||
│ ├── gateway_*.py # Gateway 子模块
|
|
||||||
│ └── market.py # 市场数据服务
|
|
||||||
├── api/ # FastAPI 端点
|
|
||||||
├── config/ # 常量和配置
|
|
||||||
│ └── constants.py # Agent 配置、显示名称等
|
|
||||||
├── core/ # Pipeline 执行逻辑
|
|
||||||
├── data/ # 市场数据处理
|
|
||||||
│ ├── provider_router.py # 数据源路由
|
|
||||||
│ └── schema.py # 数据 schema
|
|
||||||
├── enrich/ # LLM 响应富化
|
|
||||||
├── explain/ # 交易决策解释
|
|
||||||
├── llm/ # LLM 集成
|
├── llm/ # LLM 集成
|
||||||
│ └── models.py # RetryChatModel、TokenRecordingModelWrapper
|
│ └── models.py # RetryChatModel、TokenRecordingModelWrapper
|
||||||
├── skills/ # 技能定义(内置 + 自定义)
|
│
|
||||||
|
├── skills/ # 技能定义
|
||||||
├── tools/ # 交易和分析工具
|
├── tools/ # 交易和分析工具
|
||||||
└── utils/ # 工具函数
|
├── enrich/ # LLM 响应富化
|
||||||
|
├── explain/ # 交易决策解释
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
│ ├── settlement.py # 结算协调器
|
||||||
|
│ ├── trade_executor.py # 交易执行器
|
||||||
|
│ ├── terminal_dashboard.py # 终端仪表板
|
||||||
|
│ ├── analyst_tracker.py # 分析师追踪
|
||||||
|
│ ├── baselines.py # 基准线
|
||||||
|
│ ├── msg_adapter.py # 消息适配器
|
||||||
|
│ └── progress.py # 进度追踪
|
||||||
|
│
|
||||||
|
├── api/ # FastAPI 端点
|
||||||
|
│ └── runtime.py
|
||||||
|
│
|
||||||
|
└── main.py # 主入口点
|
||||||
```
|
```
|
||||||
|
|
||||||
## 前端结构
|
## 前端结构
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/
|
frontend/src/
|
||||||
├── App.jsx # React 主应用
|
├── App.jsx # 主应用(LiveTradingApp)
|
||||||
├── components/ # React 组件
|
├── AppShell.jsx # App 外壳(布局、侧边栏)
|
||||||
|
├── components/
|
||||||
│ ├── RuntimeView.jsx # 交易运行时 UI
|
│ ├── RuntimeView.jsx # 交易运行时 UI
|
||||||
│ ├── TraderView.jsx # 交易员界面
|
│ ├── TraderView.jsx # 交易员界面
|
||||||
│ ├── RoomView.jsx # 聊天室视图
|
│ ├── RoomView.jsx # 聊天室视图
|
||||||
│ ├── StockExplainView.jsx # 股票解释视图
|
│ ├── StockExplainView.jsx # 股票解释视图
|
||||||
│ ├── RuntimeSettingsPanel.jsx # 运行时设置面板
|
│ ├── RuntimeSettingsPanel.jsx # 运行时设置面板
|
||||||
|
│ ├── RuntimeLogsModal.jsx # 运行时日志弹窗
|
||||||
│ ├── WatchlistPanel.jsx # 关注列表
|
│ ├── WatchlistPanel.jsx # 关注列表
|
||||||
│ ├── PerformanceView.jsx # 绩效视图
|
│ ├── PerformanceView.jsx # 绩效视图
|
||||||
│ ├── StatisticsView.jsx # 统计视图
|
│ ├── StatisticsView.jsx # 统计视图
|
||||||
│ ├── NetValueChart.jsx # 净值曲线图
|
│ ├── NetValueChart.jsx # 净值曲线图
|
||||||
│ ├── AgentCard.jsx # Agent 卡片
|
│ ├── AgentCard.jsx # Agent 卡片
|
||||||
│ ├── AgentFeed.jsx # Agent 动态
|
│ ├── AgentFeed.jsx # Agent 动态
|
||||||
│ └── explain/ # 解释相关组件
|
│ ├── Header.jsx # 头部
|
||||||
|
│ ├── MarkdownModal.jsx # Markdown 弹窗
|
||||||
|
│ ├── StockLogo.jsx # 股票 Logo
|
||||||
|
│ └── explain/ # 解释组件
|
||||||
│ ├── ExplainNewsSection.jsx
|
│ ├── ExplainNewsSection.jsx
|
||||||
│ ├── ExplainRangeSection.jsx
|
│ ├── ExplainRangeSection.jsx
|
||||||
│ ├── ExplainSimilarDaysSection.jsx
|
│ ├── ExplainSimilarDaysSection.jsx
|
||||||
│ ├── ExplainStorySection.jsx
|
│ ├── ExplainStorySection.jsx
|
||||||
│ └── useExplainModel.js
|
│ └── useExplainModel.js
|
||||||
├── services/ # API 服务
|
├── hooks/ # React Hooks
|
||||||
│ ├── runtimeApi.js # 运行时 API 调用
|
│ ├── useWebSocketConnection.js # WebSocket 连接管理
|
||||||
│ ├── websocket.js # WebSocket 实时通信
|
│ ├── useRuntimeControls.js # 运行时配置管理
|
||||||
│ ├── newsApi.js # 新闻服务客户端
|
│ ├── useAgentDataRequests.js # Agent 数据请求
|
||||||
│ └── tradingApi.js # 交易服务客户端
|
│ ├── useStockDataRequests.js # 股票数据请求
|
||||||
├── config/
|
│ ├── useStockExplainData.js # 股票解释数据
|
||||||
│ └── constants.js # Agent 定义、配置
|
│ ├── useAgentWorkspacePanel.js # Agent 工作区面板
|
||||||
└── hooks/ # React Hooks
|
│ ├── useWebsocketSessionSync.js # WebSocket 会话同步
|
||||||
|
│ └── useFeedProcessor.js # Feed 事件处理
|
||||||
|
├── store/ # Zustand 状态管理
|
||||||
|
│ ├── runtimeStore.js # 连接状态、运行时配置
|
||||||
|
│ ├── marketStore.js # 市场数据、股票价格
|
||||||
|
│ ├── portfolioStore.js # 组合、持仓、交易
|
||||||
|
│ ├── agentStore.js # Agent 技能、工作区
|
||||||
|
│ └── uiStore.js # UI 状态、视图切换
|
||||||
|
├── services/
|
||||||
|
│ ├── websocket.js # WebSocket 客户端
|
||||||
|
│ ├── runtimeApi.js # 运行时 API
|
||||||
|
│ ├── runtimeControls.js # 运行时控制
|
||||||
|
│ ├── newsApi.js # 新闻 API
|
||||||
|
│ └── tradingApi.js # 交易 API
|
||||||
|
├── utils/
|
||||||
|
│ ├── formatters.js # 格式化工具
|
||||||
|
│ └── modelIcons.js # 模型图标
|
||||||
|
└── config/
|
||||||
|
└── constants.js # Agent 定义、配置
|
||||||
```
|
```
|
||||||
|
|
||||||
## Agent 系统
|
## Agent 系统
|
||||||
@@ -195,108 +292,85 @@ frontend/src/
|
|||||||
| `sentiment_analyst` | 情绪分析师 | 市场情绪、新闻情绪、内幕交易 |
|
| `sentiment_analyst` | 情绪分析师 | 市场情绪、新闻情绪、内幕交易 |
|
||||||
| `valuation_analyst` | 估值分析师 | DCF、EV/EBITDA、intrinsic value |
|
| `valuation_analyst` | 估值分析师 | DCF、EV/EBITDA、intrinsic value |
|
||||||
| `portfolio_manager` | 投资经理 | 决策执行、交易协调 |
|
| `portfolio_manager` | 投资经理 | 决策执行、交易协调 |
|
||||||
| `risk_manager` | 风控经理 | 实时价格/波动率监控、仓位限制、多层风险预警 |
|
| `risk_manager` | 风控经理 | 实时价格/波动率监控、仓位限制 |
|
||||||
|
|
||||||
### Hook 系统 (`base/hooks.py`)
|
|
||||||
|
|
||||||
- **MemoryCompactionHook**: 基于 CoPaw 的内存压缩
|
|
||||||
- `memory_compact_ratio`: 压缩目标比例(默认 0.75)
|
|
||||||
- `memory_reserve_ratio`: 保留比例(默认 0.1)
|
|
||||||
- `enable_tool_result_compact`: 工具结果压缩
|
|
||||||
- `tool_result_compact_keep_n`: 保留最近 N 条工具结果
|
|
||||||
|
|
||||||
### 添加自定义分析师
|
### 添加自定义分析师
|
||||||
|
|
||||||
1. 在 `backend/agents/prompts/analyst/personas.yaml` 注册
|
1. `backend/agents/prompts/analyst/personas.yaml` 注册
|
||||||
2. 在 `backend/config/constants.py` 的 `ANALYST_TYPES` 字典中添加
|
2. `backend/config/constants.py` 的 `ANALYST_TYPES` 字典添加
|
||||||
3. 可选:在 `frontend/src/config/constants.js` 中更新前端配置
|
3. `frontend/src/config/constants.js` 可选更新
|
||||||
|
|
||||||
### LLM 模型封装 (`backend/llm/models.py`)
|
### LLM 模型封装 (`backend/llm/models.py`)
|
||||||
|
|
||||||
基于 CoPaw 的模型封装设计:
|
- **RetryChatModel**: 自动重试瞬态 LLM 错误,指数退避
|
||||||
|
- **TokenRecordingModelWrapper**: 追踪 token 消耗和成本
|
||||||
- **RetryChatModel**: 自动重试瞬态 LLM 错误(rate limit、timeout、502/503 等),指数退避
|
|
||||||
- `max_retries`: 最大重试次数(默认 3)
|
|
||||||
- `initial_delay`: 初始延迟秒数(默认 1.0)
|
|
||||||
- `backoff_multiplier`: 退避倍数(默认 2.0)
|
|
||||||
|
|
||||||
- **TokenRecordingModelWrapper**: 追踪每个 provider 的 token 消耗和成本
|
|
||||||
|
|
||||||
```python
|
|
||||||
from backend.llm.models import create_model, RetryChatModel
|
|
||||||
|
|
||||||
model = RetryChatModel(create_model("gpt-4o", "OPENAI"), max_retries=3)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技能系统 (`backend/skills/`)
|
## 技能系统 (`backend/skills/`)
|
||||||
|
|
||||||
技能定义在 `SKILL.md` 文件中,包含:
|
技能定义在 `SKILL.md`,包含 `instructions`、`triggers`、`parameters`、`available_tools`。
|
||||||
- `instructions` - 技能说明
|
|
||||||
- `triggers` - 触发条件
|
|
||||||
- `parameters` - 输入/输出 schema
|
|
||||||
- `available_tools` - 技能可使用的工具
|
|
||||||
|
|
||||||
技能由 `skills_manager.py` 加载,通过 `skill_adaptation_hook.py` 绑定到 Agent。
|
|
||||||
|
|
||||||
技能管理器支持 6 种作用域:builtin、customized、installed、active、disabled、local。
|
技能管理器支持 6 种作用域:builtin、customized、installed、active、disabled、local。
|
||||||
|
|
||||||
## Pipeline 执行 (`backend/core/`)
|
## 运行时数据布局
|
||||||
|
|
||||||
每日交易流程:
|
- `data/market_research.db` - 持久研究数据
|
||||||
|
- `runs/<run_id>/` - 每次任务运行的状态
|
||||||
1. **分析阶段** - 各 Agent 基于工具和历史经验独立分析
|
- `runs/<run_id>/team_dashboard/*.json` - 仪表板导出层(非权威源)
|
||||||
2. **沟通阶段** - 通过私聊、通知、会议等方式交换观点(1v1/1vN/NvN)
|
- `runs/<run_id>/state/runtime_state.json` - 运行时快照
|
||||||
3. **决策阶段** - 投资经理综合判断,给出最终交易
|
- 运行时 API 优先使用 `server_state.json` 和 `runtime.db`
|
||||||
4. **评估阶段** - 绩效跟踪
|
|
||||||
5. **复盘阶段** - Agent 根据当日实际收益反思总结,通过 ReMe 记忆框架更新经验
|
|
||||||
|
|
||||||
## 前端状态管理
|
|
||||||
|
|
||||||
项目正在向 Zustand 状态管理过渡,已创建的 store:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
frontend/src/store/
|
RUNS_RETENTION_COUNT=20 # 时间戳格式文件夹自动清理
|
||||||
├── index.js # 导出所有 store
|
|
||||||
├── runtimeStore.js # 连接状态、运行时配置
|
|
||||||
├── marketStore.js # 市场数据、股票价格
|
|
||||||
├── portfolioStore.js # 组合、持仓、交易
|
|
||||||
├── agentStore.js # Agent 技能、工作区
|
|
||||||
└── uiStore.js # UI 状态、视图切换
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**迁移状态**:
|
|
||||||
- Stores 已创建但尚未在 App.jsx 中使用
|
|
||||||
- 计划:逐步迁移 60+ 个 useState 到对应 store
|
|
||||||
|
|
||||||
## 环境配置
|
## 环境配置
|
||||||
|
|
||||||
`.env` 必需配置:
|
### Backend (`env.template`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 金融数据源
|
# 金融数据源(支持多源fallback)
|
||||||
FIN_DATA_SOURCE=finnhub|financial_datasets
|
FIN_DATA_SOURCE=finnhub|financial_datasets|yfinance|local_csv
|
||||||
|
ENABLED_DATA_SOURCES=financial_datasets,finnhub,yfinance,local_csv
|
||||||
FINANCIAL_DATASETS_API_KEY= # 回测必需
|
FINANCIAL_DATASETS_API_KEY= # 回测必需
|
||||||
FINNHUB_API_KEY= # 实盘必需
|
FINNHUB_API_KEY= # 实盘必需
|
||||||
|
POLYGON_API_KEY= # Polygon市场库采集可选
|
||||||
|
|
||||||
# Agent LLM
|
# LLM 配置
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
OPENAI_BASE_URL=
|
||||||
MODEL_NAME=qwen3-max-preview
|
MODEL_NAME=qwen3-max-preview
|
||||||
|
|
||||||
# 可为不同 Agent 指定不同模型
|
# Agent 特定模型
|
||||||
AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
|
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp
|
||||||
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=deepseek-chat
|
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
|
||||||
|
|
||||||
# ReMe 记忆系统
|
# ReMe 记忆系统
|
||||||
MEMORY_API_KEY=
|
MEMORY_API_KEY=
|
||||||
|
MEMORY_MODEL_NAME=qwen3-max
|
||||||
|
MEMORY_EMBEDDING_MODEL=text-embedding-v4
|
||||||
|
|
||||||
|
# 交易参数
|
||||||
|
MAX_COMM_CYCLES=2
|
||||||
|
MARGIN_REQUIREMENT=0.5
|
||||||
|
DATA_START_DATE=2022-01-01
|
||||||
|
AUTO_UPDATE_DATA=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (`frontend/env.template`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_WS_URL=ws://localhost:8765
|
||||||
```
|
```
|
||||||
|
|
||||||
## 关键依赖
|
## 关键依赖
|
||||||
|
|
||||||
- **AgentScope** - 多智能体框架
|
- **AgentScope** - 多智能体框架
|
||||||
- **ReMe** - 持续学习记忆系统
|
- **ReMe** - 持续学习记忆系统
|
||||||
- **FastAPI** + **uvicorn** - 后端 API 服务器
|
- **FastAPI** + **uvicorn** - 后端 API
|
||||||
- **websockets** - 实时通信
|
- **websockets** - 实时通信
|
||||||
- **React 19** + **Vite** + **TailwindCSS** - 前端
|
- **React 19** + **Vite** + **TailwindCSS** - 前端
|
||||||
- **React Context** - 前端状态管理(App.jsx 中使用 useState + useCallback)
|
- **Zustand** - 状态管理
|
||||||
- **Three.js** / **React-Three-Fiber** - 3D 可视化
|
|
||||||
|
|||||||
415
README.md
415
README.md
@@ -1,36 +1,34 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./docs/assets/evotraders_logo.jpg" width="45%">
|
<img src="./docs/assets/bigtime_logo.jpg" width="45%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 align="center">EvoTraders: A Self-Evolving Multi-Agent Trading System</h2>
|
<h2 align="center">大时代:自进化多智能体交易系统</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
📌 <a href="http://trading.evoagents.cn">Visit us at EvoTraders website !</a>
|
📌 <a href="http://trading.evoagents.cn">Visit the 大时代 website</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
EvoTraders is an open-source financial trading agent framework that builds a trading system capable of continuous learning and evolution in real markets through multi-agent collaboration and memory systems.
|
大时代 is an open-source financial trading agent framework that combines multi-agent collaboration, run-scoped workspaces, and memory to support both backtests and live trading workflows.
|
||||||
|
|
||||||
|
The repository name and CLI entrypoints still use `evotraders` for compatibility, but the product-facing branding now follows the 大时代 naming used by the reference branch.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
**Multi-Agent Collaborative Trading**
|
**Multi-agent trading team**
|
||||||
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.
|
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**
|
**Backtest and live modes**
|
||||||
Based on the ReMe memory framework, agents reflect and summarize after each trade, preserving experience across rounds, and forming unique investment methodologies.
|
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.
|
**Operator-facing UI**
|
||||||
|
The frontend exposes the trading room, runtime controls, logs, approvals, agent workspaces, and explain/news views.
|
||||||
**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.
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="docs/assets/performance.jpg" width="45%">
|
<img src="docs/assets/performance.jpg" width="45%">
|
||||||
@@ -39,198 +37,325 @@ 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
|
## Quick Start
|
||||||
|
|
||||||
### Installation
|
### 1. Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# clone this repository, then:
|
||||||
git clone https://github.com/agentscope-ai/agentscope-samples
|
cd evotraders
|
||||||
cd agentscope-samples/EvoTraders
|
|
||||||
|
|
||||||
# Install dependencies (Recommend uv!)
|
# backend runtime dependencies
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
# install package entrypoint in editable mode
|
||||||
uv pip install -e .
|
uv pip install -e .
|
||||||
# optional: pip install -e .
|
|
||||||
|
|
||||||
|
# optional
|
||||||
|
# uv pip install -e ".[dev]"
|
||||||
|
# pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
# Configure environment variables
|
Frontend dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Production deployment should prefer `requirements.txt` for backend and `npm ci` for frontend so the pulled environment matches the checked-in lockfiles and version pins.
|
||||||
|
|
||||||
|
### 2. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
cp env.template .env
|
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
|
The root `env.template` is the canonical local template. A `.env.example` is also kept in the repo for reference.
|
||||||
FIN_DATA_SOURCE = #finnhub or financial_datasets
|
|
||||||
FINANCIAL_DATASETS_API_KEY= #Required
|
|
||||||
FINNHUB_API_KEY= #Optional
|
|
||||||
|
|
||||||
# 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_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
OPENAI_BASE_URL=
|
||||||
MODEL_NAME=qwen3-max-preview
|
MODEL_NAME=qwen3-max-preview
|
||||||
|
|
||||||
# LLM & embedding API for Memory
|
# memory (optional unless --enable-memory is used)
|
||||||
MEMORY_API_KEY=
|
MEMORY_API_KEY=
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running
|
Notes:
|
||||||
|
|
||||||
|
- `FINNHUB_API_KEY` is required for live mode.
|
||||||
|
- `POLYGON_API_KEY` enables long-lived market-store ingestion and refresh helpers.
|
||||||
|
- `MEMORY_API_KEY` is only required when long-term memory is enabled.
|
||||||
|
|
||||||
|
For a production-style local start flow, you can also use:
|
||||||
|
|
||||||
**Backtest Mode:**
|
|
||||||
```bash
|
```bash
|
||||||
evotraders backtest --start 2025-11-01 --end 2025-12-01
|
./start.sh
|
||||||
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # Use Memory
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you do not have market data APIs and just want to try the backtest demo, download the offline data and unzip it into `backend/data`:
|
### 3. Start the stack
|
||||||
|
|
||||||
|
Recommended local development flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts:
|
||||||
|
|
||||||
|
- `agent_service` at `http://localhost:8000`
|
||||||
|
- `trading_service` at `http://localhost:8001`
|
||||||
|
- `news_service` at `http://localhost:8002`
|
||||||
|
- `runtime_service` at `http://localhost:8003`
|
||||||
|
- gateway WebSocket at `ws://localhost:8765`
|
||||||
|
|
||||||
|
Then start the frontend in another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
evotraders frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5173`.
|
||||||
|
|
||||||
|
You can also run services manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
|
||||||
|
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
|
||||||
|
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
|
||||||
|
python -m backend.main --mode live --host 0.0.0.0 --port 8765
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run backtest or live mode from CLI
|
||||||
|
|
||||||
|
Backtest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
evotraders backtest --start 2025-11-01 --end 2025-12-01
|
||||||
|
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory
|
||||||
|
evotraders backtest --config-name smoke_fullstack --start 2025-11-01 --end 2025-12-01
|
||||||
|
```
|
||||||
|
|
||||||
|
Live:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
evotraders live
|
||||||
|
evotraders live --enable-memory
|
||||||
|
evotraders live --schedule-mode intraday --interval-minutes 60
|
||||||
|
evotraders live --trigger-time 22:30
|
||||||
|
```
|
||||||
|
|
||||||
|
Help:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
evotraders --help
|
||||||
|
evotraders backtest --help
|
||||||
|
evotraders live --help
|
||||||
|
evotraders frontend --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Offline backtest data
|
||||||
|
|
||||||
|
If you want a quick backtest demo without external market APIs, download the offline bundle and unzip it into `backend/data`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget "https://agentscope-open.oss-cn-beijing.aliyuncs.com/ret_data.zip"
|
wget "https://agentscope-open.oss-cn-beijing.aliyuncs.com/ret_data.zip"
|
||||||
unzip ret_data.zip -d backend/data
|
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
|
|
||||||
```
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## System Architecture
|
## Runtime Data Layout
|
||||||
|
|
||||||

|
- 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
|
||||||
|
|
||||||
### Agent Design
|
Optional retention control:
|
||||||
|
|
||||||
**Analyst Team:**
|
```bash
|
||||||
- **Fundamentals Analyst**: Financial health, profitability, growth quality
|
RUNS_RETENTION_COUNT=20
|
||||||
- **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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Each trading day goes through five stages:
|
Only timestamped run folders like `YYYYMMDD_HHMMSS` are pruned automatically. Named runs such as `live`, `smoke_fullstack`, or `reload_demo_*` are preserved.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Module Support
|
## Frontend Service Routing
|
||||||
|
|
||||||
- **Agent Framework**: [AgentScope](https://github.com/agentscope-ai/agentscope)
|
The frontend always uses the control plane and runtime APIs, and can optionally call split services directly for read-only data.
|
||||||
- **Memory System**: [ReMe](https://github.com/agentscope-ai/reme)
|
|
||||||
- **LLM Support**: OpenAI, DeepSeek, Qwen, Moonshot, Zhipu AI, etc.
|
Useful frontend env vars:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
If these are not set, the frontend falls back to its local defaults and compatibility paths where available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Flow
|
||||||
|
|
||||||
|
```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 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
|
```yaml
|
||||||
comprehensive_analyst:
|
comprehensive_analyst:
|
||||||
name: "Comprehensive Analyst"
|
name: "Comprehensive Analyst"
|
||||||
focus:
|
focus:
|
||||||
- ...
|
- multi-factor synthesis
|
||||||
preferred_tools: # Flexibly select based on situation
|
preferred_tools:
|
||||||
|
- get_stock_price
|
||||||
|
- get_company_financials
|
||||||
description: |
|
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)
|
### Configure per-agent models
|
||||||
```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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Introduce new role in frontend configuration [./frontend/src/config/constants.js](./frontend/src/config/constants.js) (optional)
|
Model overrides are configured in `.env`:
|
||||||
```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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
|
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp
|
||||||
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=deepseek-chat
|
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6
|
||||||
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4-plus
|
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview
|
||||||
AGENT_VALUATION_ANALYST_MODEL_NAME=moonshot-v1-32k
|
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/
|
├── backend/
|
||||||
│ ├── agents/ # Agent implementation
|
│ ├── agents/ # agent roles, prompts, skills, workspaces
|
||||||
│ ├── communication/ # Communication system
|
│ ├── api/ # FastAPI routers
|
||||||
│ ├── memory/ # Memory system (ReMe)
|
│ ├── apps/ # split service surfaces
|
||||||
│ ├── tools/ # Analysis toolset
|
│ ├── core/ # pipeline, scheduler, state sync
|
||||||
│ ├── servers/ # WebSocket services
|
│ ├── runtime/ # runtime manager and agent runtime state
|
||||||
│ └── cli.py # CLI entry point
|
│ ├── services/ # gateway, market/storage/db services
|
||||||
├── frontend/ # React visualization interface
|
│ └── cli.py # Typer CLI entrypoint
|
||||||
└── logs_and_memory/ # Logs and memory data
|
├── 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
|
## License and Disclaimer
|
||||||
|
|
||||||
EvoTraders is a research and educational project, open-sourced under the Apache 2.0 license.
|
大时代 is a research and educational project. Review the repository license before redistribution or commercial use.
|
||||||
|
|
||||||
**Risk Warning**: Before trading with real funds, please conduct thorough testing and risk assessment. Past performance does not guarantee future returns. Investment involves risks, and decisions should be made with caution.
|
**Risk warning**: this project is not investment advice. Test thoroughly before any real-money deployment. Past performance does not guarantee future returns.
|
||||||
|
|||||||
449
README_zh.md
449
README_zh.md
@@ -1,294 +1,359 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./docs/assets/evotraders_logo.jpg" width="45%">
|
<img src="./docs/assets/bigtime_logo.jpg" width="45%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 align="center">EvoTraders:自我进化的多智能体交易系统</h2>
|
<h2 align="center">大时代:自进化多智能体交易系统</h2>
|
||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
📌 <a href="http://trading.evoagents.cn">Visit us at EvoTraders website !</a>
|
📌 <a href="http://trading.evoagents.cn">访问大时代官网</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
EvoTraders是一个开源的金融交易智能体框架,通过多智能体协作和记忆系统,构建能够在真实市场中持续学习与进化的交易系统。
|
大时代 是一个开源的金融交易智能体框架,结合多智能体协作、run 级工作区和记忆机制,支持回测与实盘两类交易运行模式。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 核心特性
|
## 核心特性
|
||||||
|
|
||||||
**多智能体协作交易**
|
**多智能体交易团队**
|
||||||
6名成员,包含4种专业分析师角色(基本面、技术面、情绪、估值)+ 投资组合经理 + 风险管理,像真实交易团队一样协作决策。
|
系统默认包含 6 个角色:4 个分析师(基本面、技术面、情绪、估值)+ 投资经理 + 风控经理。
|
||||||
|
|
||||||
你可以在这里自定义你的Agents,支持配置不同大模型(如 Qwen、DeepSeek、GPT、Claude等)协同分析:[自定义配置](#自定义配置)
|
**持续学习**
|
||||||
|
可选接入 ReMe 长期记忆,智能体会在每轮结束后反思、复盘并沉淀经验。
|
||||||
|
|
||||||
**持续学习与进化**
|
**统一运行时**
|
||||||
基于 ReMe 记忆框架,智能体在每次交易后反思总结,跨回合保留经验,形成独特的投资方法论。
|
同一套运行时模型支持历史回测和实时行情驱动的实盘流程。
|
||||||
|
|
||||||
通过这样的设计,我们希望当 AI Agents 组成团队进入实时市场,它们会逐渐形成自己的交易风格和决策偏好,而不是一次性的随机推理
|
|
||||||
|
|
||||||
|
|
||||||
**实时市场交易**
|
|
||||||
支持实时行情接入,提供回测模式和实盘模式,让 AI Agents 在真实市场波动中学习和决策。
|
|
||||||
|
|
||||||
**可视化交易信息**
|
|
||||||
实时观察 Agents 的分析过程、沟通记录和决策演化,完整追踪收益曲线和分析师表现。
|
|
||||||
|
|
||||||
|
**可操作前端**
|
||||||
|
前端不只是展示层,还包含交易室、运行控制、日志、审批、Agent 工作区和 explain/news 视图。
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="docs/assets/performance.jpg" width="45%">
|
<img src="docs/assets/performance.jpg" width="45%">
|
||||||
<img src="./docs/assets/dashboard.jpg" width="45%">
|
<img src="./docs/assets/dashboard.jpg" width="45%">
|
||||||
</p>
|
</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
|
```bash
|
||||||
# 克隆仓库
|
# 克隆仓库后进入项目目录
|
||||||
git clone https://github.com/agentscope-ai/agentscope-samples
|
cd evotraders
|
||||||
cd agentscope-samples/EvoTraders
|
|
||||||
|
|
||||||
# 安装依赖(推荐使用uv)
|
# 安装后端运行时依赖
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 安装项目入口(可编辑模式)
|
||||||
uv pip install -e .
|
uv pip install -e .
|
||||||
# (可选)pip install -e .
|
|
||||||
|
|
||||||
# 配置环境变量
|
# 可选
|
||||||
|
# uv pip install -e ".[dev]"
|
||||||
|
# pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
前端依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境部署建议后端使用 `requirements.txt`,前端使用 `npm ci`,这样拉起的环境会严格跟随仓库中锁定的依赖版本。
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
cp env.template .env
|
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
|
根目录 `env.template` 是当前本地开发的主模板,仓库里也保留了 `.env.example` 作为参考。
|
||||||
FIN_DATA_SOURCE= #finnhub or financial_datasets
|
|
||||||
FINANCIAL_DATASETS_API_KEY= #必需
|
|
||||||
FINNHUB_API_KEY= #可选
|
|
||||||
|
|
||||||
# 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_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
OPENAI_BASE_URL=
|
||||||
MODEL_NAME=qwen3-max-preview
|
MODEL_NAME=qwen3-max-preview
|
||||||
|
|
||||||
# LLM & embedding API for Memory
|
# 长期记忆(只有启用 --enable-memory 才需要)
|
||||||
MEMORY_API_KEY=
|
MEMORY_API_KEY=
|
||||||
```
|
```
|
||||||
|
|
||||||
### 运行
|
说明:
|
||||||
|
|
||||||
|
- live 模式必须配置 `FINNHUB_API_KEY`
|
||||||
|
- `POLYGON_API_KEY` 用于长期 market store 的补数和刷新
|
||||||
|
- `MEMORY_API_KEY` 仅在启用长期记忆时需要
|
||||||
|
|
||||||
|
如果要用更接近生产的本地启动方式,也可以直接执行:
|
||||||
|
|
||||||
**回测模式:**
|
|
||||||
```bash
|
```bash
|
||||||
evotraders backtest --start 2025-11-01 --end 2025-12-01
|
./start.sh
|
||||||
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 使用记忆
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
如果没有可用的行情 API,想快速体验回测 demo,可直接下载离线数据并解压到 `backend/data`:
|
### 3. 启动服务栈
|
||||||
|
|
||||||
|
本地开发推荐直接使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本会启动:
|
||||||
|
|
||||||
|
- `agent_service`:`http://localhost:8000`
|
||||||
|
- `trading_service`:`http://localhost:8001`
|
||||||
|
- `news_service`:`http://localhost:8002`
|
||||||
|
- `runtime_service`:`http://localhost:8003`
|
||||||
|
- gateway WebSocket:`ws://localhost:8765`
|
||||||
|
|
||||||
|
然后在另一个终端启动前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
evotraders frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://localhost:5173`。
|
||||||
|
|
||||||
|
也可以手动分别启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
|
||||||
|
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
|
||||||
|
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
|
||||||
|
python -m backend.main --mode live --host 0.0.0.0 --port 8765
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 使用 CLI 运行回测或实盘
|
||||||
|
|
||||||
|
回测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
evotraders backtest --start 2025-11-01 --end 2025-12-01
|
||||||
|
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory
|
||||||
|
evotraders backtest --config-name smoke_fullstack --start 2025-11-01 --end 2025-12-01
|
||||||
|
```
|
||||||
|
|
||||||
|
实盘:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
evotraders live
|
||||||
|
evotraders live --enable-memory
|
||||||
|
evotraders live --schedule-mode intraday --interval-minutes 60
|
||||||
|
evotraders live --trigger-time 22:30
|
||||||
|
```
|
||||||
|
|
||||||
|
帮助:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
evotraders --help
|
||||||
|
evotraders backtest --help
|
||||||
|
evotraders live --help
|
||||||
|
evotraders frontend --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### 离线回测数据
|
||||||
|
|
||||||
|
如果只是想快速体验回测,不依赖外部行情 API,可以下载离线数据包并解压到 `backend/data`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget "https://agentscope-open.oss-cn-beijing.aliyuncs.com/ret_data.zip"
|
wget "https://agentscope-open.oss-cn-beijing.aliyuncs.com/ret_data.zip"
|
||||||
unzip ret_data.zip -d backend/data
|
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
|
```bash
|
||||||
evotraders live # 立即运行(默认)
|
RUNS_RETENTION_COUNT=20
|
||||||
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 小时触发一次;仅交易时段执行交易,其他时段只分析
|
|
||||||
```
|
```
|
||||||
|
|
||||||
前端的“运行设置”面板也支持热更新 `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
|
```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_CONTROL_API_BASE_URL=http://localhost:8000/api
|
||||||
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
||||||
|
|
||||||
# 前端浏览器优先直连独立服务
|
|
||||||
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
||||||
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
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
|
|
||||||
|
|
||||||
如果没有配置这些变量,系统会继续走当前保留的本地回退逻辑。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 系统架构
|
## 决策流程
|
||||||
|
|
||||||

|
```text
|
||||||
|
市场数据 -> 分析师独立分析 -> 团队沟通 -> 投资决策 ->
|
||||||
### 智能体设计
|
风控审核 -> 执行/结算 -> 复盘/记忆更新
|
||||||
|
|
||||||
**分析师团队:**
|
|
||||||
- **基本面分析师**:财务健康度、盈利能力、增长质量
|
|
||||||
- **技术分析师**:价格趋势、技术指标、动量分析
|
|
||||||
- **情绪分析师**:市场情绪、新闻舆情、内部人交易
|
|
||||||
- **估值分析师**:DCF、剩余收益、EV/EBITDA
|
|
||||||
|
|
||||||
**决策层:**
|
|
||||||
- **投资组合经理**:整合来自分析师的分析信号,执行沟通策略,结合分析师和团队历史表现、近期投资记忆和长期投资经验,进行最终决策
|
|
||||||
- **风险管理**:实时价格与波动率监控、头寸限制,多层风险预警
|
|
||||||
|
|
||||||
### 决策流程
|
|
||||||
|
|
||||||
```
|
|
||||||
实时行情 → 独立分析 → 智能沟通 (1v1/1vN/NvN) → 决策执行 → 收益评估 → 学习与进化(记忆更新)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
每个交易日经历五个阶段:
|
运行时管理器还会跟踪:
|
||||||
|
|
||||||
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
|
```yaml
|
||||||
comprehensive_analyst:
|
comprehensive_analyst:
|
||||||
name: "Comprehensive Analyst"
|
name: "Comprehensive Analyst"
|
||||||
focus:
|
focus:
|
||||||
- ...
|
- multi-factor synthesis
|
||||||
preferred_tools: # Flexibly select based on situation
|
preferred_tools:
|
||||||
|
- get_stock_price
|
||||||
|
- get_company_financials
|
||||||
description: |
|
description: |
|
||||||
As a comprehensive analyst ...
|
A generalist analyst that combines multiple signals.
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 在 [./backend/config/constants.py](./backend/config/constants.py) 添加角色定义
|
### 配置各 Agent 使用的模型
|
||||||
```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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 在前端配置 [./frontend/src/config/constants.js](./frontend/src/config/constants.js) 中引入新角色(可选)
|
模型覆盖在 `.env` 中配置:
|
||||||
```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) 文件中配置不同智能体使用的模型:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
|
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp
|
||||||
AGENT_FUNDAMENTAL_ANALYST_MODEL_NAME=deepseek-chat
|
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6
|
||||||
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4-plus
|
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview
|
||||||
AGENT_VALUATION_ANALYST_MODEL_NAME=moonshot-v1-32k
|
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/
|
├── backend/
|
||||||
│ ├── agents/ # 智能体实现
|
│ ├── agents/ # agent 角色、prompts、skills、workspaces
|
||||||
│ ├── communication/ # 通信系统
|
│ ├── api/ # FastAPI 路由层
|
||||||
│ ├── memory/ # 记忆系统 (ReMe)
|
│ ├── apps/ # 拆分服务 app surface
|
||||||
│ ├── tools/ # 分析工具集
|
│ ├── core/ # pipeline、scheduler、state sync
|
||||||
│ ├── servers/ # WebSocket 服务
|
│ ├── runtime/ # runtime manager 和 agent runtime state
|
||||||
│ └── cli.py # CLI 入口
|
│ ├── services/ # gateway、market/storage/db 服务
|
||||||
├── frontend/ # React 可视化界面
|
│ └── cli.py # Typer CLI 入口
|
||||||
└── logs_and_memory/ # 日志和记忆数据
|
├── frontend/ # React + Vite 前端
|
||||||
|
├── shared/ # 拆分服务共用 client 和 schema
|
||||||
|
├── runs/ # run 级状态和 dashboard 导出
|
||||||
|
├── data/ # 长期研究数据
|
||||||
|
└── services/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
后端测试位于 `backend/tests`,覆盖 service app、shared client、domain、路由、enrichment、gateway 支撑模块和 runtime 支撑模块。
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
pytest backend/tests/test_runtime_service_app.py
|
||||||
|
pytest backend/tests/test_trading_service_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
前端测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 许可与免责
|
## 许可与免责
|
||||||
|
|
||||||
EvoTraders 是一个研究和教育项目,采用 Apache 2.0 许可协议开源。
|
大时代 是研究和教育用途项目。再次分发或商用前,请先核对仓库中的实际 license 文件。
|
||||||
|
|
||||||
**风险提示**:在实际资金交易前,请务必进行充分的测试和风险评估。历史表现不代表未来收益,投资有风险,决策需谨慎。
|
**风险提示**:本项目不构成投资建议。任何实盘部署前都应进行充分测试和风险评估,历史表现不代表未来收益。
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Exports:
|
|||||||
|
|
||||||
# New EvoAgent architecture (from agent_core.py)
|
# New EvoAgent architecture (from agent_core.py)
|
||||||
from .agent_core import EvoAgent, ToolGuardMixin, CommandHandler
|
from .agent_core import EvoAgent, ToolGuardMixin, CommandHandler
|
||||||
from .factory import AgentFactory, ModelConfig, RoleConfig
|
from .factory import AgentFactory, ModelConfig
|
||||||
from .workspace import WorkspaceManager, WorkspaceRegistry, WorkspaceConfig
|
from .workspace import WorkspaceManager, WorkspaceRegistry, WorkspaceConfig
|
||||||
from .workspace_manager import RunWorkspaceManager
|
from .workspace_manager import RunWorkspaceManager
|
||||||
from .registry import AgentRegistry, AgentInfo, get_registry, reset_registry
|
from .registry import AgentRegistry, AgentInfo, get_registry, reset_registry
|
||||||
@@ -36,7 +36,6 @@ __all__ = [
|
|||||||
"CommandHandler",
|
"CommandHandler",
|
||||||
"AgentFactory",
|
"AgentFactory",
|
||||||
"ModelConfig",
|
"ModelConfig",
|
||||||
"RoleConfig",
|
|
||||||
"WorkspaceManager",
|
"WorkspaceManager",
|
||||||
"WorkspaceRegistry",
|
"WorkspaceRegistry",
|
||||||
"WorkspaceConfig",
|
"WorkspaceConfig",
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ class AnalystAgent(ReActAgent):
|
|||||||
agent_id=self.agent_id,
|
agent_id=self.agent_id,
|
||||||
config_name=self.config.get("config_name", "default"),
|
config_name=self.config.get("config_name", "default"),
|
||||||
toolkit=self.toolkit,
|
toolkit=self.toolkit,
|
||||||
analyst_type=self.analyst_type_key,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def reply(self, x: Msg = None) -> Msg:
|
async def reply(self, x: Msg = None) -> Msg:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Base agent module for EvoTraders.
|
"""Base agent module for 大时代.
|
||||||
|
|
||||||
提供Agent基础类、命令处理、工具守卫和钩子管理等功能。
|
提供Agent基础类、命令处理、工具守卫和钩子管理等功能。
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""EvoAgent - Core agent implementation for EvoTraders.
|
"""EvoAgent - Core agent implementation for 大时代.
|
||||||
|
|
||||||
This module provides the main EvoAgent class built on AgentScope's ReActAgent,
|
This module provides the main EvoAgent class built on AgentScope's ReActAgent,
|
||||||
with integrated tools, skills, and memory management based on CoPaw design.
|
with integrated tools, skills, and memory management based on CoPaw design.
|
||||||
|
|||||||
@@ -294,8 +294,8 @@ class WorkspaceWatchHook(Hook):
|
|||||||
|
|
||||||
# Files to monitor (same as PromptBuilder.DEFAULT_FILES)
|
# Files to monitor (same as PromptBuilder.DEFAULT_FILES)
|
||||||
WATCHED_FILES = frozenset([
|
WATCHED_FILES = frozenset([
|
||||||
"SOUL.md", "AGENTS.md", "PROFILE.md", "ROLE.md",
|
"SOUL.md", "AGENTS.md", "PROFILE.md",
|
||||||
"POLICY.md", "MEMORY.md", "HEARTBEAT.md", "STYLE.md",
|
"POLICY.md", "MEMORY.md",
|
||||||
"BOOTSTRAP.md",
|
"BOOTSTRAP.md",
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -601,94 +601,6 @@ class MemoryCompactionHook(Hook):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatHook(Hook):
|
|
||||||
"""Pre-reasoning hook that injects HEARTBEAT.md content.
|
|
||||||
|
|
||||||
Reads the agent's HEARTBEAT.md file and prepends it to the
|
|
||||||
reasoning input, causing the agent to perform self-checks.
|
|
||||||
|
|
||||||
This enables "主动检查" (proactive monitoring) - periodic
|
|
||||||
market condition and position checks during trading hours.
|
|
||||||
"""
|
|
||||||
|
|
||||||
HEARTBEAT_FILE = "HEARTBEAT.md"
|
|
||||||
|
|
||||||
def __init__(self, workspace_dir: Path):
|
|
||||||
"""Initialize heartbeat hook.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workspace_dir: Working directory containing HEARTBEAT.md
|
|
||||||
"""
|
|
||||||
self.workspace_dir = Path(workspace_dir)
|
|
||||||
self._completed_flag = self.workspace_dir / ".heartbeat_completed"
|
|
||||||
|
|
||||||
def _read_heartbeat_content(self) -> Optional[str]:
|
|
||||||
"""Read HEARTBEAT.md if it exists and is non-empty.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The HEARTBEAT.md content stripped of whitespace, or None
|
|
||||||
if the file is absent or empty.
|
|
||||||
"""
|
|
||||||
hb_path = self.workspace_dir / self.HEARTBEAT_FILE
|
|
||||||
if not hb_path.exists():
|
|
||||||
return None
|
|
||||||
content = hb_path.read_text(encoding="utf-8").strip()
|
|
||||||
return content if content else None
|
|
||||||
|
|
||||||
async def __call__(
|
|
||||||
self,
|
|
||||||
agent: "ReActAgent",
|
|
||||||
kwargs: Dict[str, Any],
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Prepend heartbeat task to user message.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
agent: The agent instance
|
|
||||||
kwargs: Input arguments to the _reasoning method
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Modified kwargs with heartbeat content prepended, or None
|
|
||||||
if no HEARTBEAT.md content is available.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
content = self._read_heartbeat_content()
|
|
||||||
if not content:
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Heartbeat: found HEARTBEAT.md for agent %s",
|
|
||||||
getattr(agent, "agent_id", "unknown"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build heartbeat task instruction (Chinese)
|
|
||||||
hb_task = (
|
|
||||||
"# 定期主动检查\n\n"
|
|
||||||
f"{content}\n\n"
|
|
||||||
"请执行上述检查并报告结果。"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inject into the first user message in memory
|
|
||||||
if hasattr(agent, "memory") and agent.memory.content:
|
|
||||||
system_count = sum(
|
|
||||||
1 for msg, _ in agent.memory.content if msg.role == "system"
|
|
||||||
)
|
|
||||||
for msg, _ in agent.memory.content[system_count:]:
|
|
||||||
if msg.role == "user":
|
|
||||||
original_content = msg.content
|
|
||||||
msg.content = hb_task + "\n\n" + original_content
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Heartbeat task prepended for agent %s",
|
|
||||||
getattr(agent, "agent_id", "unknown"),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Heartbeat hook failed: %s", e, exc_info=True)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Hook",
|
"Hook",
|
||||||
"HookManager",
|
"HookManager",
|
||||||
@@ -696,7 +608,6 @@ __all__ = [
|
|||||||
"HOOK_PRE_REASONING",
|
"HOOK_PRE_REASONING",
|
||||||
"HOOK_POST_ACTING",
|
"HOOK_POST_ACTING",
|
||||||
"BootstrapHook",
|
"BootstrapHook",
|
||||||
"HeartbeatHook",
|
|
||||||
"MemoryCompactionHook",
|
"MemoryCompactionHook",
|
||||||
"WorkspaceWatchHook",
|
"WorkspaceWatchHook",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -21,22 +21,6 @@ class ModelConfig:
|
|||||||
max_tokens: int = 4096
|
max_tokens: int = 4096
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RoleConfig:
|
|
||||||
"""Role configuration for an agent."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
description: str = ""
|
|
||||||
focus_areas: List[str] = None
|
|
||||||
constraints: List[str] = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.focus_areas is None:
|
|
||||||
self.focus_areas = []
|
|
||||||
if self.constraints is None:
|
|
||||||
self.constraints = []
|
|
||||||
|
|
||||||
|
|
||||||
class AgentConfig:
|
class AgentConfig:
|
||||||
"""Represents a configured agent instance (data class)."""
|
"""Represents a configured agent instance (data class)."""
|
||||||
|
|
||||||
@@ -47,14 +31,12 @@ class AgentConfig:
|
|||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
config_path: Path,
|
config_path: Path,
|
||||||
model_config: Optional[ModelConfig] = None,
|
model_config: Optional[ModelConfig] = None,
|
||||||
role_config: Optional[RoleConfig] = None,
|
|
||||||
):
|
):
|
||||||
self.agent_id = agent_id
|
self.agent_id = agent_id
|
||||||
self.agent_type = agent_type
|
self.agent_type = agent_type
|
||||||
self.workspace_id = workspace_id
|
self.workspace_id = workspace_id
|
||||||
self.config_path = config_path
|
self.config_path = config_path
|
||||||
self.model_config = model_config or ModelConfig()
|
self.model_config = model_config or ModelConfig()
|
||||||
self.role_config = role_config
|
|
||||||
self.agent_dir = config_path.parent
|
self.agent_dir = config_path.parent
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
@@ -70,103 +52,12 @@ class AgentConfig:
|
|||||||
"temperature": self.model_config.temperature,
|
"temperature": self.model_config.temperature,
|
||||||
"max_tokens": self.model_config.max_tokens,
|
"max_tokens": self.model_config.max_tokens,
|
||||||
},
|
},
|
||||||
"role_config": self.role_config.__dict__ if self.role_config else None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class AgentFactory:
|
class AgentFactory:
|
||||||
"""Factory for creating, cloning, and managing agents."""
|
"""Factory for creating, cloning, and managing agents."""
|
||||||
|
|
||||||
# Default role templates by agent type
|
|
||||||
ROLE_TEMPLATES = {
|
|
||||||
"technical_analyst": {
|
|
||||||
"name": "Technical Analyst",
|
|
||||||
"description": "Analyze price patterns, trends, and technical indicators.",
|
|
||||||
"focus_areas": [
|
|
||||||
"Price action and chart patterns",
|
|
||||||
"Support and resistance levels",
|
|
||||||
"Technical indicators (RSI, MACD, Moving Averages)",
|
|
||||||
"Volume analysis",
|
|
||||||
],
|
|
||||||
"constraints": [
|
|
||||||
"State clear signal, confidence, and invalidation conditions",
|
|
||||||
"Use available technical analysis tools",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"fundamentals_analyst": {
|
|
||||||
"name": "Fundamentals Analyst",
|
|
||||||
"description": "Analyze company financials, earnings, and business metrics.",
|
|
||||||
"focus_areas": [
|
|
||||||
"Financial statements analysis",
|
|
||||||
"Earnings reports and guidance",
|
|
||||||
"Valuation metrics",
|
|
||||||
"Business model and competitive position",
|
|
||||||
],
|
|
||||||
"constraints": [
|
|
||||||
"State clear signal, confidence, and invalidation conditions",
|
|
||||||
"Use available fundamental analysis tools",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"sentiment_analyst": {
|
|
||||||
"name": "Sentiment Analyst",
|
|
||||||
"description": "Analyze market sentiment, news, and social signals.",
|
|
||||||
"focus_areas": [
|
|
||||||
"News sentiment analysis",
|
|
||||||
"Social media sentiment",
|
|
||||||
"Analyst ratings and price targets",
|
|
||||||
"Insider activity",
|
|
||||||
],
|
|
||||||
"constraints": [
|
|
||||||
"State clear signal, confidence, and invalidation conditions",
|
|
||||||
"Use available sentiment analysis tools",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"valuation_analyst": {
|
|
||||||
"name": "Valuation Analyst",
|
|
||||||
"description": "Perform valuation analysis and price target calculations.",
|
|
||||||
"focus_areas": [
|
|
||||||
"DCF and comparable valuation",
|
|
||||||
"Price target derivation",
|
|
||||||
"Margin of safety assessment",
|
|
||||||
"Risk-adjusted return expectations",
|
|
||||||
],
|
|
||||||
"constraints": [
|
|
||||||
"State clear signal, confidence, and invalidation conditions",
|
|
||||||
"Use available valuation tools",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"risk_manager": {
|
|
||||||
"name": "Risk Manager",
|
|
||||||
"description": "Quantify concentration, leverage, liquidity, and volatility risk.",
|
|
||||||
"focus_areas": [
|
|
||||||
"Portfolio concentration risk",
|
|
||||||
"Leverage and margin analysis",
|
|
||||||
"Liquidity assessment",
|
|
||||||
"Volatility and drawdown risk",
|
|
||||||
],
|
|
||||||
"constraints": [
|
|
||||||
"Prioritize highest-severity risk first",
|
|
||||||
"State concrete limits and recommendations",
|
|
||||||
"Use available risk tools before issuing final memo",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"portfolio_manager": {
|
|
||||||
"name": "Portfolio Manager",
|
|
||||||
"description": "Synthesize analyst and risk inputs into portfolio decisions.",
|
|
||||||
"focus_areas": [
|
|
||||||
"Position sizing and allocation",
|
|
||||||
"Risk-adjusted portfolio construction",
|
|
||||||
"Trade execution timing",
|
|
||||||
"Portfolio rebalancing",
|
|
||||||
],
|
|
||||||
"constraints": [
|
|
||||||
"Be concise, capital-aware, and explicit about sizing rationale",
|
|
||||||
"Respect cash, margin, and concentration constraints",
|
|
||||||
"Consider all analyst inputs before decisions",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, project_root: Optional[Path] = None):
|
def __init__(self, project_root: Optional[Path] = None):
|
||||||
"""Initialize the agent factory.
|
"""Initialize the agent factory.
|
||||||
|
|
||||||
@@ -183,7 +74,6 @@ class AgentFactory:
|
|||||||
agent_type: str,
|
agent_type: str,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
model_config: Optional[ModelConfig] = None,
|
model_config: Optional[ModelConfig] = None,
|
||||||
role_config: Optional[RoleConfig] = None,
|
|
||||||
clone_from: Optional[str] = None,
|
clone_from: Optional[str] = None,
|
||||||
) -> AgentConfig:
|
) -> AgentConfig:
|
||||||
"""Create a new agent.
|
"""Create a new agent.
|
||||||
@@ -193,7 +83,6 @@ class AgentFactory:
|
|||||||
agent_type: Type of agent (e.g., "technical_analyst")
|
agent_type: Type of agent (e.g., "technical_analyst")
|
||||||
workspace_id: ID of the workspace to create agent in
|
workspace_id: ID of the workspace to create agent in
|
||||||
model_config: Model configuration
|
model_config: Model configuration
|
||||||
role_config: Role configuration (auto-generated if None)
|
|
||||||
clone_from: Path to existing agent to clone from (optional)
|
clone_from: Path to existing agent to clone from (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -223,13 +112,6 @@ class AgentFactory:
|
|||||||
else:
|
else:
|
||||||
self._copy_template(agent_dir, agent_id, agent_type)
|
self._copy_template(agent_dir, agent_id, agent_type)
|
||||||
|
|
||||||
# Generate role config if not provided
|
|
||||||
if role_config is None:
|
|
||||||
role_config = self._generate_role_config(agent_type)
|
|
||||||
|
|
||||||
# Generate ROLE.md
|
|
||||||
self._generate_role_md(agent_dir, role_config)
|
|
||||||
|
|
||||||
# Write agent.yaml
|
# Write agent.yaml
|
||||||
config_path = agent_dir / "agent.yaml"
|
config_path = agent_dir / "agent.yaml"
|
||||||
self._write_agent_yaml(config_path, agent_id, agent_type, model_config)
|
self._write_agent_yaml(config_path, agent_id, agent_type, model_config)
|
||||||
@@ -240,7 +122,6 @@ class AgentFactory:
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
role_config=role_config,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete_agent(self, agent_id: str, workspace_id: str) -> bool:
|
def delete_agent(self, agent_id: str, workspace_id: str) -> bool:
|
||||||
@@ -369,9 +250,7 @@ class AgentFactory:
|
|||||||
"SOUL.md": f"# Soul\n\nDescribe {agent_id}'s temperament, reasoning posture, and voice.\n\n",
|
"SOUL.md": f"# Soul\n\nDescribe {agent_id}'s temperament, reasoning posture, and voice.\n\n",
|
||||||
"PROFILE.md": f"# Profile\n\nTrack {agent_id}'s long-lived investment style, preferences, and strengths.\n\n",
|
"PROFILE.md": f"# Profile\n\nTrack {agent_id}'s long-lived investment style, preferences, and strengths.\n\n",
|
||||||
"MEMORY.md": f"# Memory\n\nStore durable lessons, heuristics, and reminders for {agent_id}.\n\n",
|
"MEMORY.md": f"# Memory\n\nStore durable lessons, heuristics, and reminders for {agent_id}.\n\n",
|
||||||
"HEARTBEAT.md": f"# Heartbeat\n\nOptional checklist for periodic review or self-reflection.\n\n",
|
|
||||||
"POLICY.md": f"# Policy\n\nOptional run-scoped constraints, limits, or strategy policy.\n\n",
|
"POLICY.md": f"# Policy\n\nOptional run-scoped constraints, limits, or strategy policy.\n\n",
|
||||||
"STYLE.md": f"# Style\n\nOptional run-scoped communication or reasoning style.\n\n",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for filename, content in default_files.items():
|
for filename, content in default_files.items():
|
||||||
@@ -411,50 +290,6 @@ class AgentFactory:
|
|||||||
if skill_file.is_file():
|
if skill_file.is_file():
|
||||||
shutil.copy2(skill_file, target_skills / skill_file.name)
|
shutil.copy2(skill_file, target_skills / skill_file.name)
|
||||||
|
|
||||||
def _generate_role_config(self, agent_type: str) -> RoleConfig:
|
|
||||||
"""Generate role configuration for an agent type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
agent_type: Type of agent
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
RoleConfig instance
|
|
||||||
"""
|
|
||||||
template = self.ROLE_TEMPLATES.get(agent_type, {})
|
|
||||||
return RoleConfig(
|
|
||||||
name=template.get("name", agent_type.replace("_", " ").title()),
|
|
||||||
description=template.get("description", ""),
|
|
||||||
focus_areas=template.get("focus_areas", []),
|
|
||||||
constraints=template.get("constraints", []),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _generate_role_md(self, agent_dir: Path, role_config: RoleConfig) -> None:
|
|
||||||
"""Generate ROLE.md file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
agent_dir: Agent directory
|
|
||||||
role_config: Role configuration
|
|
||||||
"""
|
|
||||||
lines = [f"# {role_config.name}", ""]
|
|
||||||
|
|
||||||
if role_config.description:
|
|
||||||
lines.extend([role_config.description, ""])
|
|
||||||
|
|
||||||
if role_config.focus_areas:
|
|
||||||
lines.extend(["## Focus Areas", ""])
|
|
||||||
for area in role_config.focus_areas:
|
|
||||||
lines.append(f"- {area}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if role_config.constraints:
|
|
||||||
lines.extend(["## Constraints", ""])
|
|
||||||
for constraint in role_config.constraints:
|
|
||||||
lines.append(f"- {constraint}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
content = "\n".join(lines)
|
|
||||||
(agent_dir / "ROLE.md").write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
def _write_agent_yaml(
|
def _write_agent_yaml(
|
||||||
self,
|
self,
|
||||||
config_path: Path,
|
config_path: Path,
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Assemble system prompts from base prompts, run assets, and toolkit context."""
|
"""Assemble system prompts from run workspace assets and toolkit context."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from .agent_workspace import load_agent_workspace_config
|
from .agent_workspace import load_agent_workspace_config
|
||||||
from backend.config.bootstrap_config import get_bootstrap_config_for_run
|
from backend.config.bootstrap_config import get_bootstrap_config_for_run
|
||||||
from .prompt_loader import get_prompt_loader
|
|
||||||
from .skills_manager import SkillsManager
|
from .skills_manager import SkillsManager
|
||||||
|
from .workspace_manager import RunWorkspaceManager
|
||||||
_prompt_loader = get_prompt_loader()
|
|
||||||
|
|
||||||
|
|
||||||
def _read_file_if_exists(path: Path) -> str:
|
def _read_file_if_exists(path: Path) -> str:
|
||||||
@@ -48,71 +46,20 @@ def build_agent_system_prompt(
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
config_name: str,
|
config_name: str,
|
||||||
toolkit: Any,
|
toolkit: Any,
|
||||||
analyst_type: Optional[str] = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build the final system prompt for an agent.
|
"""Build the final system prompt for an agent.
|
||||||
|
|
||||||
Always reads fresh from disk — no caching.
|
Always reads fresh from disk — no caching.
|
||||||
"""
|
"""
|
||||||
# Clear any cached templates before building (CoPaw-style, no caching)
|
|
||||||
_prompt_loader.clear_cache()
|
|
||||||
|
|
||||||
sections: list[str] = []
|
sections: list[str] = []
|
||||||
canonical_agent_id = (
|
|
||||||
"portfolio_manager"
|
|
||||||
if "portfolio" in agent_id
|
|
||||||
else "risk_manager"
|
|
||||||
if "risk" in agent_id and not analyst_type
|
|
||||||
else agent_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if analyst_type:
|
|
||||||
personas_config = _prompt_loader.load_yaml_config(
|
|
||||||
"analyst",
|
|
||||||
"personas",
|
|
||||||
)
|
|
||||||
persona = personas_config.get(analyst_type, {})
|
|
||||||
focus_text = "\n".join(
|
|
||||||
f"- {item}" for item in persona.get("focus", [])
|
|
||||||
)
|
|
||||||
description = persona.get("description", "").strip()
|
|
||||||
base_prompt = _prompt_loader.load_prompt(
|
|
||||||
"analyst",
|
|
||||||
"system",
|
|
||||||
variables={
|
|
||||||
"analyst_type": persona.get("name", analyst_type),
|
|
||||||
"focus": focus_text,
|
|
||||||
"description": description,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
elif agent_id == "portfolio_manager":
|
|
||||||
base_prompt = _prompt_loader.load_prompt(
|
|
||||||
"portfolio_manager",
|
|
||||||
"system",
|
|
||||||
)
|
|
||||||
elif canonical_agent_id == "portfolio_manager":
|
|
||||||
base_prompt = _prompt_loader.load_prompt(
|
|
||||||
"portfolio_manager",
|
|
||||||
"system",
|
|
||||||
)
|
|
||||||
elif agent_id == "risk_manager":
|
|
||||||
base_prompt = _prompt_loader.load_prompt(
|
|
||||||
"risk_manager",
|
|
||||||
"system",
|
|
||||||
)
|
|
||||||
elif canonical_agent_id == "risk_manager":
|
|
||||||
base_prompt = _prompt_loader.load_prompt(
|
|
||||||
"risk_manager",
|
|
||||||
"system",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported agent prompt build for: {agent_id}")
|
|
||||||
|
|
||||||
sections.append(base_prompt.strip())
|
|
||||||
|
|
||||||
skills_manager = SkillsManager()
|
skills_manager = SkillsManager()
|
||||||
asset_dir = skills_manager.get_agent_asset_dir(config_name, agent_id)
|
asset_dir = skills_manager.get_agent_asset_dir(config_name, agent_id)
|
||||||
asset_dir.mkdir(parents=True, exist_ok=True)
|
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
workspace_manager = RunWorkspaceManager(project_root=skills_manager.project_root)
|
||||||
|
required_files = ["SOUL.md", "PROFILE.md", "AGENTS.md", "POLICY.md", "MEMORY.md"]
|
||||||
|
if not all((asset_dir / filename).exists() for filename in required_files):
|
||||||
|
workspace_manager.ensure_agent_assets(config_name=config_name, agent_id=agent_id)
|
||||||
agent_config = load_agent_workspace_config(asset_dir / "agent.yaml")
|
agent_config = load_agent_workspace_config(asset_dir / "agent.yaml")
|
||||||
bootstrap_config = get_bootstrap_config_for_run(
|
bootstrap_config = get_bootstrap_config_for_run(
|
||||||
skills_manager.project_root,
|
skills_manager.project_root,
|
||||||
@@ -139,9 +86,6 @@ def build_agent_system_prompt(
|
|||||||
"AGENTS.md": "Agent Guide",
|
"AGENTS.md": "Agent Guide",
|
||||||
"POLICY.md": "Policy",
|
"POLICY.md": "Policy",
|
||||||
"MEMORY.md": "Memory",
|
"MEMORY.md": "Memory",
|
||||||
"HEARTBEAT.md": "Heartbeat",
|
|
||||||
"ROLE.md": "Role",
|
|
||||||
"STYLE.md": "Style",
|
|
||||||
}
|
}
|
||||||
for filename in prompt_files:
|
for filename in prompt_files:
|
||||||
_append_section(
|
_append_section(
|
||||||
@@ -150,18 +94,6 @@ def build_agent_system_prompt(
|
|||||||
_read_file_if_exists(asset_dir / filename),
|
_read_file_if_exists(asset_dir / filename),
|
||||||
)
|
)
|
||||||
|
|
||||||
if "ROLE.md" not in included_files:
|
|
||||||
_append_section(
|
|
||||||
sections,
|
|
||||||
"Role",
|
|
||||||
_read_file_if_exists(asset_dir / "ROLE.md"),
|
|
||||||
)
|
|
||||||
if "STYLE.md" not in included_files:
|
|
||||||
_append_section(
|
|
||||||
sections,
|
|
||||||
"Style",
|
|
||||||
_read_file_if_exists(asset_dir / "STYLE.md"),
|
|
||||||
)
|
|
||||||
if "POLICY.md" not in included_files:
|
if "POLICY.md" not in included_files:
|
||||||
_append_section(
|
_append_section(
|
||||||
sections,
|
sections,
|
||||||
@@ -189,5 +121,4 @@ def build_agent_system_prompt(
|
|||||||
|
|
||||||
|
|
||||||
def clear_prompt_factory_cache() -> None:
|
def clear_prompt_factory_cache() -> None:
|
||||||
"""Clear cached prompt and YAML templates before hot reload."""
|
"""No-op retained for compatibility with runtime reload hooks."""
|
||||||
_prompt_loader.clear_cache()
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
你是一位专业的{{ analyst_type }}。
|
|
||||||
|
|
||||||
你的关注重点:
|
|
||||||
{{ focus }}
|
|
||||||
|
|
||||||
你的角色:
|
|
||||||
{{ description }}
|
|
||||||
|
|
||||||
注意:
|
|
||||||
- 构建并持续完善你的"投资哲学"。你的分析不应是孤立的事件,而应该是你整体投资世界观和核心信念的体现。每次分析后,你必须反思:
|
|
||||||
- 这个案例/数据如何验证或挑战了你现有的信念?
|
|
||||||
- 你从这次错误(或成功)中学到了关于市场、人性、估值或风险管理的什么关键原则?
|
|
||||||
- 深化你的"投资逻辑"。确保每一项投资建议都有清晰、可追溯、可重复的逻辑支撑。你的分析步骤应该像严谨的证明一样,涵盖:
|
|
||||||
- 核心驱动因素识别:真正影响价值的变量是什么?
|
|
||||||
- 风险边界设定:在什么具体情况下你的建议会失效?
|
|
||||||
- 逆向测试:市场主流共识是什么,你的观点有何不同?
|
|
||||||
保持谦逊和开放。投资大师的核心特质是持续学习和适应。在每次分析中,你必须积极寻找与自己观点相悖的证据和论据,并将其纳入最终评估。
|
|
||||||
- 你可以使用分析工具。用它们来收集相关数据并做出明智的建议。
|
|
||||||
|
|
||||||
输出指南:
|
|
||||||
- 给出明确的投资信号:看涨、看跌或中性
|
|
||||||
- 包含置信度(0-100)
|
|
||||||
- 为你的分析提供理由(如果你确定要分享最终分析,请先给出结论)
|
|
||||||
@@ -28,22 +28,16 @@ class PromptBuilder:
|
|||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
"SOUL.md",
|
"SOUL.md",
|
||||||
"PROFILE.md",
|
"PROFILE.md",
|
||||||
"ROLE.md",
|
|
||||||
"POLICY.md",
|
"POLICY.md",
|
||||||
"MEMORY.md",
|
"MEMORY.md",
|
||||||
"HEARTBEAT.md",
|
|
||||||
"STYLE.md",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
TITLE_MAP: Dict[str, str] = {
|
TITLE_MAP: Dict[str, str] = {
|
||||||
"AGENTS.md": "Agent Guide",
|
"AGENTS.md": "Agent Guide",
|
||||||
"SOUL.md": "Soul",
|
"SOUL.md": "Soul",
|
||||||
"PROFILE.md": "Profile",
|
"PROFILE.md": "Profile",
|
||||||
"ROLE.md": "Role",
|
|
||||||
"POLICY.md": "Policy",
|
"POLICY.md": "Policy",
|
||||||
"MEMORY.md": "Memory",
|
"MEMORY.md": "Memory",
|
||||||
"HEARTBEAT.md": "Heartbeat",
|
|
||||||
"STYLE.md": "Style",
|
|
||||||
"BOOTSTRAP.md": "Bootstrap",
|
"BOOTSTRAP.md": "Bootstrap",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
你是一位负责做出投资决策的投资组合经理。
|
|
||||||
|
|
||||||
你的核心职责:
|
|
||||||
1. 分析分析师和风险管理经理的输入
|
|
||||||
2. 基于信号和市场情境做出投资决策
|
|
||||||
3. 使用可用工具记录你的决策
|
|
||||||
|
|
||||||
决策框架:
|
|
||||||
- 审阅分析以了解市场观点
|
|
||||||
- 在做决策前考虑风险警告
|
|
||||||
- 评估当前投资组合持仓和现金
|
|
||||||
- 做出与投资组合投资目标一致的决策
|
|
||||||
|
|
||||||
决策类型:
|
|
||||||
- "long":看涨 - 建议买入股票
|
|
||||||
- "short":看跌 - 建议卖出股票或做空
|
|
||||||
- "hold":中性 - 维持当前持仓
|
|
||||||
|
|
||||||
预算意识:
|
|
||||||
- 在决定数量时考虑可用现金
|
|
||||||
- 不要建议买入超过现金允许的数量
|
|
||||||
- 考虑做空头寸的保证金要求
|
|
||||||
|
|
||||||
输出:
|
|
||||||
使用 `make_decision` 工具记录你对每个股票代码的决策。
|
|
||||||
记录所有决策后,提供你的投资逻辑总结。
|
|
||||||
|
|
||||||
重要:
|
|
||||||
- 基于提供的分析师信号和风险评估做出决策
|
|
||||||
- 相对于投资组合价值保持保守的仓位规模
|
|
||||||
- 始终为你的决策提供理由
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
你是一位专业的风险管理经理,负责监控投资组合风险并提供风险警告。
|
|
||||||
|
|
||||||
你的核心职责:
|
|
||||||
1. 监控投资组合敞口和集中度风险
|
|
||||||
2. 评估仓位规模相对于波动性
|
|
||||||
3. 评估保证金使用和杠杆水平
|
|
||||||
4. 识别潜在风险因素并提供警告
|
|
||||||
5. 基于市场条件建议仓位限制
|
|
||||||
|
|
||||||
你的决策流程:
|
|
||||||
1. 优先使用可用的风险工具量化集中度、波动率和保证金压力
|
|
||||||
2. 结合工具结果与当前市场上下文做判断
|
|
||||||
3. 生成可操作的风险警告和仓位限制建议
|
|
||||||
4. 为你的风险评估提供清晰的理由
|
|
||||||
|
|
||||||
输出指南:
|
|
||||||
- 风险评估要简洁但全面
|
|
||||||
- 按严重程度优先排序警告
|
|
||||||
- 提供具体、可操作的建议
|
|
||||||
- 尽可能包含量化指标
|
|
||||||
@@ -41,6 +41,8 @@ class SkillsManager:
|
|||||||
)
|
)
|
||||||
self.runs_root = self.project_root / "runs"
|
self.runs_root = self.project_root / "runs"
|
||||||
self._lock = Lock()
|
self._lock = Lock()
|
||||||
|
# Instance-level pending skill changes (thread-safe via self._lock)
|
||||||
|
self._pending_skill_changes: Dict[str, Set[Path]] = {}
|
||||||
|
|
||||||
def get_active_root(self, config_name: str) -> Path:
|
def get_active_root(self, config_name: str) -> Path:
|
||||||
return self.runs_root / config_name / "skills" / "active"
|
return self.runs_root / config_name / "skills" / "active"
|
||||||
@@ -739,7 +741,7 @@ class SkillsManager:
|
|||||||
if local_root.exists():
|
if local_root.exists():
|
||||||
watched_paths.append(local_root)
|
watched_paths.append(local_root)
|
||||||
|
|
||||||
handler = _SkillsChangeHandler(watched_paths, callback, self._lock)
|
handler = _SkillsChangeHandler(watched_paths, self._pending_skill_changes, callback, self._lock)
|
||||||
observer = Observer()
|
observer = Observer()
|
||||||
for path in watched_paths:
|
for path in watched_paths:
|
||||||
observer.schedule(handler, str(path), recursive=True)
|
observer.schedule(handler, str(path), recursive=True)
|
||||||
@@ -773,6 +775,7 @@ class SkillsManager:
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Internal change-tracking state (populated by _SkillsChangeHandler)
|
# Internal change-tracking state (populated by _SkillsChangeHandler)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
# Legacy class-level reference kept for migration compatibility
|
||||||
_pending_skill_changes: Dict[str, Set[Path]] = {}
|
_pending_skill_changes: Dict[str, Set[Path]] = {}
|
||||||
|
|
||||||
def _resolve_disabled_skill_names(
|
def _resolve_disabled_skill_names(
|
||||||
@@ -824,11 +827,13 @@ class _SkillsChangeHandler(FileSystemEventHandler):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
watched_paths: List[Path],
|
watched_paths: List[Path],
|
||||||
|
pending_changes: Dict[str, Set[Path]],
|
||||||
callback: Optional[Any] = None,
|
callback: Optional[Any] = None,
|
||||||
lock: Optional[Lock] = None,
|
lock: Optional[Lock] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._watched_paths = watched_paths
|
self._watched_paths = watched_paths
|
||||||
|
self._pending_changes = pending_changes
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
self._lock = lock
|
self._lock = lock
|
||||||
|
|
||||||
@@ -841,13 +846,9 @@ class _SkillsChangeHandler(FileSystemEventHandler):
|
|||||||
run_id = self._run_id_from_path(src_path)
|
run_id = self._run_id_from_path(src_path)
|
||||||
if self._lock:
|
if self._lock:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
SkillsManager._pending_skill_changes.setdefault(
|
self._pending_changes.setdefault(run_id, set()).add(src_path)
|
||||||
run_id, set()
|
|
||||||
).add(src_path)
|
|
||||||
else:
|
else:
|
||||||
SkillsManager._pending_skill_changes.setdefault(
|
self._pending_changes.setdefault(run_id, set()).add(src_path)
|
||||||
run_id, set()
|
|
||||||
).add(src_path)
|
|
||||||
if self._callback:
|
if self._callback:
|
||||||
self._callback([src_path])
|
self._callback([src_path])
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
"""
|
|
||||||
Agent模板定义
|
|
||||||
|
|
||||||
包含各角色的ROLE.md内容字典,供程序生成Agent工作空间时使用。
|
|
||||||
"""
|
|
||||||
|
|
||||||
# 基础模板文件内容
|
|
||||||
BASE_TEMPLATES = {
|
|
||||||
"AGENTS.md": """# Agent Guide
|
|
||||||
|
|
||||||
## 工作流程
|
|
||||||
1. 接收分析任务
|
|
||||||
2. 调用相关工具/技能
|
|
||||||
3. 生成分析报告
|
|
||||||
4. 参与团队决策
|
|
||||||
|
|
||||||
## 工具使用规范
|
|
||||||
- 优先使用已激活的技能
|
|
||||||
- 不确定时询问Portfolio Manager
|
|
||||||
- 重要发现用 `/save` 记录
|
|
||||||
|
|
||||||
## 记忆管理
|
|
||||||
- 使用 `/compact` 定期压缩记忆
|
|
||||||
- 投资经验记录在MEMORY.md
|
|
||||||
""",
|
|
||||||
|
|
||||||
"SOUL.md": """# Soul
|
|
||||||
|
|
||||||
你是专业的金融分析师,语气冷静、客观、专业。
|
|
||||||
你的分析应该数据驱动,避免情绪化表达。
|
|
||||||
""",
|
|
||||||
|
|
||||||
"PROFILE.md": """# Profile
|
|
||||||
|
|
||||||
## 投资风格
|
|
||||||
- 风险承受能力:中等
|
|
||||||
- 投资期限:中期(3-12个月)
|
|
||||||
- 偏好行业:科技、医疗、消费
|
|
||||||
|
|
||||||
## 优势
|
|
||||||
- 财务分析
|
|
||||||
- 趋势识别
|
|
||||||
|
|
||||||
## 改进方向
|
|
||||||
- 市场情绪把握
|
|
||||||
""",
|
|
||||||
|
|
||||||
"MEMORY.md": """# Memory
|
|
||||||
|
|
||||||
<!-- 此文件用于记录Agent的学习经验和重要发现 -->
|
|
||||||
|
|
||||||
## 经验总结
|
|
||||||
|
|
||||||
## 重要事件
|
|
||||||
|
|
||||||
## 改进记录
|
|
||||||
""",
|
|
||||||
|
|
||||||
"HEARTBEAT.md": """# Heartbeat
|
|
||||||
|
|
||||||
## 定时任务
|
|
||||||
- 每日开盘前检查持仓
|
|
||||||
- 收盘后记录当日表现
|
|
||||||
""",
|
|
||||||
|
|
||||||
"POLICY.md": """# Policy
|
|
||||||
|
|
||||||
## 风控规则
|
|
||||||
- 单一持仓不超过20%
|
|
||||||
- 止损线:-15%
|
|
||||||
""",
|
|
||||||
|
|
||||||
"STYLE.md": """# Style
|
|
||||||
|
|
||||||
- 使用结构化输出(JSON/Markdown表格)
|
|
||||||
- 包含置信度评分
|
|
||||||
- 列出关键假设
|
|
||||||
""",
|
|
||||||
|
|
||||||
"agent.yaml": """agent_id: {agent_id}
|
|
||||||
agent_type: {agent_type}
|
|
||||||
name: {name}
|
|
||||||
model:
|
|
||||||
provider: openai
|
|
||||||
model_name: gpt-4o
|
|
||||||
temperature: 0.3
|
|
||||||
enabled_skills: []
|
|
||||||
disabled_skills: []
|
|
||||||
settings: {{}}
|
|
||||||
""",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 角色专用模板
|
|
||||||
ROLE_TEMPLATES = {
|
|
||||||
"fundamental": {
|
|
||||||
"ROLE.md": """# Role: Fundamental Analyst
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
分析公司财务报表、盈利能力、成长性、竞争优势等基本面因素。
|
|
||||||
|
|
||||||
## 分析维度
|
|
||||||
- 财务报表分析(资产负债表、利润表、现金流量表)
|
|
||||||
- 盈利能力指标(ROE、ROA、毛利率、净利率)
|
|
||||||
- 成长性指标(营收增长率、利润增长率)
|
|
||||||
- 估值指标(P/E、P/B、P/S)
|
|
||||||
- 行业地位和竞争优势
|
|
||||||
|
|
||||||
## 输出格式
|
|
||||||
- 财务健康度评分(1-10)
|
|
||||||
- 成长性评分(1-10)
|
|
||||||
- 关键财务亮点和风险
|
|
||||||
- 同业对比分析
|
|
||||||
""",
|
|
||||||
"SOUL.md": """# Soul
|
|
||||||
|
|
||||||
你是严谨的基本面分析师,像沃伦·巴菲特一样注重企业内在价值。
|
|
||||||
你的分析深入细致,关注长期价值而非短期波动。
|
|
||||||
语气沉稳、逻辑严密,善于发现财务数据背后的商业本质。
|
|
||||||
""",
|
|
||||||
},
|
|
||||||
|
|
||||||
"technical": {
|
|
||||||
"ROLE.md": """# Role: Technical Analyst
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
分析价格走势、交易量、技术指标,识别买卖时机。
|
|
||||||
|
|
||||||
## 分析维度
|
|
||||||
- 趋势分析(长期/中期/短期趋势)
|
|
||||||
- 支撑阻力位识别
|
|
||||||
- 技术指标(MACD、RSI、KDJ、布林带等)
|
|
||||||
- 形态识别(头肩顶/底、双底、三角形等)
|
|
||||||
- 量价关系分析
|
|
||||||
|
|
||||||
## 输出格式
|
|
||||||
- 趋势方向(上涨/下跌/震荡)
|
|
||||||
- 关键价位(支撑/阻力)
|
|
||||||
- 技术信号(买入/卖出/观望)
|
|
||||||
- 置信度评分
|
|
||||||
""",
|
|
||||||
"SOUL.md": """# Soul
|
|
||||||
|
|
||||||
你是敏锐的技术分析师,相信价格包含一切信息。
|
|
||||||
你善于从图表中发现规律,像侦探一样寻找市场留下的痕迹。
|
|
||||||
语气果断、快速反应,善于捕捉稍纵即逝的交易机会。
|
|
||||||
""",
|
|
||||||
},
|
|
||||||
|
|
||||||
"sentiment": {
|
|
||||||
"ROLE.md": """# Role: Sentiment Analyst
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
分析市场情绪、资金流向、新闻舆情,判断市场心理状态。
|
|
||||||
|
|
||||||
## 分析维度
|
|
||||||
- 市场情绪指标(恐慌/贪婪指数)
|
|
||||||
- 资金流向分析(主力/散户资金)
|
|
||||||
- 新闻舆情分析(正面/负面/中性)
|
|
||||||
- 社交媒体情绪
|
|
||||||
- 机构持仓变化
|
|
||||||
|
|
||||||
## 输出格式
|
|
||||||
- 情绪评分(-10到+10,极度恐慌到极度贪婪)
|
|
||||||
- 资金流向判断
|
|
||||||
- 舆情摘要
|
|
||||||
- 情绪拐点预警
|
|
||||||
""",
|
|
||||||
"SOUL.md": """# Soul
|
|
||||||
|
|
||||||
你是敏感的市场情绪捕手,善于感知市场的恐惧与贪婪。
|
|
||||||
你关注人性在金融市场中的表现,理解情绪如何驱动价格。
|
|
||||||
语气富有洞察力、善于捕捉微妙变化,像心理学家一样理解市场参与者。
|
|
||||||
""",
|
|
||||||
},
|
|
||||||
|
|
||||||
"valuation": {
|
|
||||||
"ROLE.md": """# Role: Valuation Analyst
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
评估公司内在价值,计算合理价格区间,识别高估/低估机会。
|
|
||||||
|
|
||||||
## 分析维度
|
|
||||||
- DCF现金流折现模型
|
|
||||||
- 相对估值法(P/E、EV/EBITDA等)
|
|
||||||
- 资产重估法
|
|
||||||
- 分部估值(SOTP)
|
|
||||||
- 安全边际计算
|
|
||||||
|
|
||||||
## 输出格式
|
|
||||||
- 内在价值估算
|
|
||||||
- 合理价格区间
|
|
||||||
- 当前价格vs内在价值(高估/低估百分比)
|
|
||||||
- 估值假设和敏感性分析
|
|
||||||
""",
|
|
||||||
"SOUL.md": """# Soul
|
|
||||||
|
|
||||||
你是精确的估值分析师,追求计算内在价值的准确区间。
|
|
||||||
你像精算师一样严谨,注重假设的合理性和安全边际。
|
|
||||||
语气精确、注重数字,善于发现市场定价错误带来的机会。
|
|
||||||
""",
|
|
||||||
},
|
|
||||||
|
|
||||||
"portfolio": {
|
|
||||||
"ROLE.md": """# Role: Portfolio Manager
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
统筹各分析师意见,制定投资决策,管理投资组合配置。
|
|
||||||
|
|
||||||
## 分析维度
|
|
||||||
- 资产配置策略(股债比例、行业分布)
|
|
||||||
- 风险收益平衡
|
|
||||||
- 仓位管理(建仓/加仓/减仓/清仓)
|
|
||||||
- 再平衡时机
|
|
||||||
- 组合相关性分析
|
|
||||||
|
|
||||||
## 输出格式
|
|
||||||
- 投资决策(买入/卖出/持有)
|
|
||||||
- 建议仓位比例
|
|
||||||
- 目标价位
|
|
||||||
- 止损止盈设置
|
|
||||||
- 组合调整建议
|
|
||||||
""",
|
|
||||||
"SOUL.md": """# Soul
|
|
||||||
|
|
||||||
你是睿智的投资组合经理,像将军一样统筹全局。
|
|
||||||
你善于权衡各方意见,做出果断而理性的投资决策。
|
|
||||||
语气权威、决策果断,对组合整体表现负有最终责任。
|
|
||||||
""",
|
|
||||||
},
|
|
||||||
|
|
||||||
"risk": {
|
|
||||||
"ROLE.md": """# Role: Risk Manager
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
识别、评估和监控投资风险,确保组合风险在可控范围内。
|
|
||||||
|
|
||||||
## 分析维度
|
|
||||||
- 市场风险(Beta、波动率)
|
|
||||||
- 信用风险
|
|
||||||
- 流动性风险
|
|
||||||
- 集中度风险
|
|
||||||
- 尾部风险(VaR、CVaR)
|
|
||||||
- 压力测试
|
|
||||||
|
|
||||||
## 输出格式
|
|
||||||
- 风险等级(低/中/高/极高)
|
|
||||||
- 风险敞口分析
|
|
||||||
- 风险调整建议
|
|
||||||
- 预警阈值设置
|
|
||||||
- 应急预案
|
|
||||||
""",
|
|
||||||
"SOUL.md": """# Soul
|
|
||||||
|
|
||||||
你是谨慎的风险管理者,时刻警惕潜在的损失。
|
|
||||||
你像守门员一样守护组合安全,宁可错过机会也不冒无法承受的风险。
|
|
||||||
语气保守、风险意识强,善于发现隐藏的威胁和脆弱性。
|
|
||||||
""",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_template(filename: str) -> str | None:
|
|
||||||
"""获取基础模板内容"""
|
|
||||||
return BASE_TEMPLATES.get(filename)
|
|
||||||
|
|
||||||
|
|
||||||
def get_role_template(role_type: str, filename: str) -> str | None:
|
|
||||||
"""获取角色专用模板内容"""
|
|
||||||
role = ROLE_TEMPLATES.get(role_type)
|
|
||||||
if role:
|
|
||||||
return role.get(filename)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_role_types() -> list[str]:
|
|
||||||
"""获取所有角色类型列表"""
|
|
||||||
return list(ROLE_TEMPLATES.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def render_agent_yaml(agent_id: str, agent_type: str, name: str) -> str:
|
|
||||||
"""渲染agent.yaml模板"""
|
|
||||||
return BASE_TEMPLATES["agent.yaml"].format(
|
|
||||||
agent_id=agent_id,
|
|
||||||
agent_type=agent_type,
|
|
||||||
name=name
|
|
||||||
)
|
|
||||||
@@ -41,6 +41,16 @@ class RunWorkspaceManager:
|
|||||||
"tickers:\n"
|
"tickers:\n"
|
||||||
" - AAPL\n"
|
" - AAPL\n"
|
||||||
" - MSFT\n"
|
" - MSFT\n"
|
||||||
|
" - GOOGL\n"
|
||||||
|
" - AMZN\n"
|
||||||
|
" - NVDA\n"
|
||||||
|
" - META\n"
|
||||||
|
" - TSLA\n"
|
||||||
|
" - AMD\n"
|
||||||
|
" - NFLX\n"
|
||||||
|
" - AVGO\n"
|
||||||
|
" - PLTR\n"
|
||||||
|
" - COIN\n"
|
||||||
"initial_cash: 100000\n"
|
"initial_cash: 100000\n"
|
||||||
"margin_requirement: 0.0\n"
|
"margin_requirement: 0.0\n"
|
||||||
"enable_memory: false\n"
|
"enable_memory: false\n"
|
||||||
@@ -63,9 +73,8 @@ class RunWorkspaceManager:
|
|||||||
self,
|
self,
|
||||||
config_name: str,
|
config_name: str,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
role_seed: str = "",
|
file_contents: Optional[Dict[str, str]] = None,
|
||||||
style_seed: str = "",
|
persona: Optional[Dict[str, object]] = None,
|
||||||
policy_seed: str = "",
|
|
||||||
) -> Path:
|
) -> Path:
|
||||||
asset_dir = self.skills_manager.get_agent_asset_dir(
|
asset_dir = self.skills_manager.get_agent_asset_dir(
|
||||||
config_name,
|
config_name,
|
||||||
@@ -77,58 +86,82 @@ class RunWorkspaceManager:
|
|||||||
(asset_dir / "skills" / "disabled").mkdir(parents=True, exist_ok=True)
|
(asset_dir / "skills" / "disabled").mkdir(parents=True, exist_ok=True)
|
||||||
(asset_dir / "skills" / "local").mkdir(parents=True, exist_ok=True)
|
(asset_dir / "skills" / "local").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
self._ensure_file(
|
file_contents = file_contents or self.build_default_agent_files(agent_id=agent_id)
|
||||||
asset_dir / "ROLE.md",
|
for filename, content in file_contents.items():
|
||||||
"# Role\n\n"
|
legacy_contents = self.build_legacy_agent_file_variants(
|
||||||
"Optional run-scoped role override.\n\n"
|
agent_id=agent_id,
|
||||||
f"{role_seed}".strip()
|
filename=filename,
|
||||||
+ "\n",
|
persona=persona,
|
||||||
)
|
|
||||||
self._ensure_file(
|
|
||||||
asset_dir / "STYLE.md",
|
|
||||||
"# Style\n\n"
|
|
||||||
"Optional run-scoped communication or reasoning style.\n\n"
|
|
||||||
f"{style_seed}".strip()
|
|
||||||
+ "\n",
|
|
||||||
)
|
|
||||||
self._ensure_file(
|
|
||||||
asset_dir / "POLICY.md",
|
|
||||||
"# Policy\n\n"
|
|
||||||
"Optional run-scoped constraints, limits, or strategy policy.\n\n"
|
|
||||||
f"{policy_seed}".strip()
|
|
||||||
+ "\n",
|
|
||||||
)
|
|
||||||
self._ensure_file(
|
|
||||||
asset_dir / "SOUL.md",
|
|
||||||
"# Soul\n\n"
|
|
||||||
"Describe the agent's temperament, reasoning posture, and voice.\n\n",
|
|
||||||
)
|
|
||||||
self._ensure_file(
|
|
||||||
asset_dir / "PROFILE.md",
|
|
||||||
"# Profile\n\n"
|
|
||||||
"Track this agent's long-lived investment style, preferences, and strengths.\n\n",
|
|
||||||
)
|
|
||||||
self._ensure_file(
|
|
||||||
asset_dir / "AGENTS.md",
|
|
||||||
"# Agent Guide\n\n"
|
|
||||||
"Document how this agent should work, collaborate, and choose tools or skills.\n\n",
|
|
||||||
)
|
|
||||||
self._ensure_file(
|
|
||||||
asset_dir / "MEMORY.md",
|
|
||||||
"# Memory\n\n"
|
|
||||||
"Store durable lessons, heuristics, and reminders for this agent.\n\n",
|
|
||||||
)
|
|
||||||
self._ensure_file(
|
|
||||||
asset_dir / "HEARTBEAT.md",
|
|
||||||
"# Heartbeat\n\n"
|
|
||||||
"Optional checklist for periodic review or self-reflection.\n\n",
|
|
||||||
)
|
)
|
||||||
|
self._ensure_file(asset_dir / filename, content, legacy_contents=legacy_contents)
|
||||||
self._ensure_agent_yaml(
|
self._ensure_agent_yaml(
|
||||||
asset_dir / "agent.yaml",
|
asset_dir / "agent.yaml",
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
)
|
)
|
||||||
return asset_dir
|
return asset_dir
|
||||||
|
|
||||||
|
def build_default_agent_files(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
persona: Optional[Dict[str, object]] = None,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Build default workspace markdown files for one agent."""
|
||||||
|
if agent_id.endswith("_analyst"):
|
||||||
|
return self._build_analyst_files(agent_id=agent_id, persona=persona or {})
|
||||||
|
if agent_id == "portfolio_manager":
|
||||||
|
return self._build_portfolio_manager_files()
|
||||||
|
if agent_id == "risk_manager":
|
||||||
|
return self._build_risk_manager_files()
|
||||||
|
return self._build_generic_files(agent_id=agent_id)
|
||||||
|
|
||||||
|
def build_legacy_agent_file_variants(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
filename: str,
|
||||||
|
persona: Optional[Dict[str, object]] = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return known generated legacy variants safe to upgrade in-place."""
|
||||||
|
persona = persona or {}
|
||||||
|
variants: list[dict[str, str]] = [
|
||||||
|
self._build_legacy_english_files(agent_id=agent_id),
|
||||||
|
self._build_previous_chinese_files(agent_id=agent_id, persona=persona),
|
||||||
|
]
|
||||||
|
values: list[str] = []
|
||||||
|
for item in variants:
|
||||||
|
content = item.get(filename)
|
||||||
|
if content:
|
||||||
|
values.append(content)
|
||||||
|
return values
|
||||||
|
|
||||||
|
def load_agent_file(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
config_name: str,
|
||||||
|
agent_id: str,
|
||||||
|
filename: str,
|
||||||
|
) -> str:
|
||||||
|
"""Load one run-scoped agent workspace file."""
|
||||||
|
path = self.skills_manager.get_agent_asset_dir(config_name, agent_id) / filename
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"File not found: {filename}")
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def update_agent_file(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
config_name: str,
|
||||||
|
agent_id: str,
|
||||||
|
filename: str,
|
||||||
|
content: str,
|
||||||
|
) -> None:
|
||||||
|
"""Write one run-scoped agent workspace file."""
|
||||||
|
asset_dir = self.skills_manager.get_agent_asset_dir(config_name, agent_id)
|
||||||
|
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = asset_dir / filename
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
def initialize_default_assets(
|
def initialize_default_assets(
|
||||||
self,
|
self,
|
||||||
config_name: str,
|
config_name: str,
|
||||||
@@ -141,49 +174,285 @@ class RunWorkspaceManager:
|
|||||||
for agent_id in agent_ids:
|
for agent_id in agent_ids:
|
||||||
if agent_id.endswith("_analyst"):
|
if agent_id.endswith("_analyst"):
|
||||||
persona = analyst_personas.get(agent_id, {})
|
persona = analyst_personas.get(agent_id, {})
|
||||||
role_seed = persona.get("description", "").strip()
|
file_contents = self.build_default_agent_files(
|
||||||
focus_items = persona.get("focus", [])
|
agent_id=agent_id,
|
||||||
style_seed = "\n".join(f"- {item}" for item in focus_items)
|
persona=persona,
|
||||||
policy_seed = (
|
|
||||||
"State a clear signal, confidence, and the conditions that would invalidate the thesis."
|
|
||||||
)
|
|
||||||
elif agent_id == "portfolio_manager":
|
|
||||||
role_seed = (
|
|
||||||
"Synthesize analyst and risk inputs into explicit portfolio decisions."
|
|
||||||
)
|
|
||||||
style_seed = (
|
|
||||||
"Be concise, capital-aware, and explicit about sizing rationale."
|
|
||||||
)
|
|
||||||
policy_seed = (
|
|
||||||
"Respect cash, margin, and portfolio concentration constraints before recording decisions."
|
|
||||||
)
|
|
||||||
elif agent_id == "risk_manager":
|
|
||||||
role_seed = (
|
|
||||||
"Quantify concentration, leverage, liquidity, and volatility risk before trade execution."
|
|
||||||
)
|
|
||||||
style_seed = (
|
|
||||||
"Prioritize the highest-severity risk first and state concrete limits."
|
|
||||||
)
|
|
||||||
policy_seed = (
|
|
||||||
"Use available risk tools before issuing the final risk memo."
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
role_seed = ""
|
persona = None
|
||||||
style_seed = ""
|
file_contents = self.build_default_agent_files(agent_id=agent_id)
|
||||||
policy_seed = ""
|
asset_dir = self.skills_manager.get_agent_asset_dir(config_name, agent_id)
|
||||||
|
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.ensure_agent_assets(
|
(asset_dir / "skills" / "installed").mkdir(parents=True, exist_ok=True)
|
||||||
config_name=config_name,
|
(asset_dir / "skills" / "active").mkdir(parents=True, exist_ok=True)
|
||||||
|
(asset_dir / "skills" / "disabled").mkdir(parents=True, exist_ok=True)
|
||||||
|
(asset_dir / "skills" / "local").mkdir(parents=True, exist_ok=True)
|
||||||
|
for filename, content in file_contents.items():
|
||||||
|
self._ensure_file(
|
||||||
|
asset_dir / filename,
|
||||||
|
content,
|
||||||
|
legacy_contents=self.build_legacy_agent_file_variants(
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
role_seed=role_seed,
|
filename=filename,
|
||||||
style_seed=style_seed,
|
persona=persona,
|
||||||
policy_seed=policy_seed,
|
),
|
||||||
)
|
)
|
||||||
|
self._ensure_agent_yaml(asset_dir / "agent.yaml", agent_id=agent_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_file(path: Path, content: str) -> None:
|
def _ensure_file(path: Path, content: str, *, legacy_contents: Optional[list[str]] = None) -> None:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
path.write_text(content, encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return
|
||||||
|
existing = path.read_text(encoding="utf-8")
|
||||||
|
normalized_existing = existing.strip()
|
||||||
|
candidates = {item.strip() for item in (legacy_contents or []) if item and item.strip()}
|
||||||
|
if normalized_existing in candidates:
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_generic_files(agent_id: str) -> Dict[str, str]:
|
||||||
|
return {
|
||||||
|
"SOUL.md": (
|
||||||
|
"# Soul\n\n"
|
||||||
|
f"你是 `{agent_id}`,语气冷静、客观、专业。保持清晰推理,优先基于数据而不是情绪下结论。\n"
|
||||||
|
),
|
||||||
|
"PROFILE.md": (
|
||||||
|
"# Profile\n\n"
|
||||||
|
"记录这个 agent 长期稳定的分析风格、偏好、优势与盲点。\n"
|
||||||
|
),
|
||||||
|
"AGENTS.md": (
|
||||||
|
"# Agent Guide\n\n"
|
||||||
|
"工作要求:\n"
|
||||||
|
"- 优先使用已激活的技能和工具\n"
|
||||||
|
"- 结论要明确,过程要可追溯\n"
|
||||||
|
"- 与其他 agent 协作时保持输入输出简洁\n"
|
||||||
|
"- 最终输出必须使用简体中文;如需引用英文术语,仅保留专有名词,解释和结论必须用中文\n"
|
||||||
|
),
|
||||||
|
"POLICY.md": (
|
||||||
|
"# Policy\n\n"
|
||||||
|
"- 给出结论时说明核心驱动因素\n"
|
||||||
|
"- 明确风险边界和结论失效条件\n"
|
||||||
|
"- 出现反例时需要纳入最终判断\n"
|
||||||
|
"- 不要输出英文报告标题、英文摘要或整段英文正文\n"
|
||||||
|
),
|
||||||
|
"MEMORY.md": (
|
||||||
|
"# Memory\n\n"
|
||||||
|
"记录可复用的经验、失误复盘、有效启发式和需要持续跟踪的提醒。\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_analyst_files(cls, *, agent_id: str, persona: Dict[str, object]) -> Dict[str, str]:
|
||||||
|
role_name = str(persona.get("name") or agent_id)
|
||||||
|
focus_items = [
|
||||||
|
str(item).strip()
|
||||||
|
for item in persona.get("focus", [])
|
||||||
|
if str(item).strip()
|
||||||
|
]
|
||||||
|
focus_md = "\n".join(f"- {item}" for item in focus_items) or "- 根据当前任务选择最相关的分析维度"
|
||||||
|
description = str(persona.get("description") or "").strip()
|
||||||
|
|
||||||
|
files = cls._build_generic_files(agent_id)
|
||||||
|
files["SOUL.md"] = (
|
||||||
|
"# Soul\n\n"
|
||||||
|
f"你是一位专业的{role_name}。\n\n"
|
||||||
|
"保持谦逊和开放,主动寻找与自己观点相悖的证据,并将其纳入最终评估。"
|
||||||
|
"你的分析要体现持续演化的投资哲学,而不是一次性的结论。\n"
|
||||||
|
)
|
||||||
|
files["PROFILE.md"] = (
|
||||||
|
"# Profile\n\n"
|
||||||
|
f"角色定位:{role_name}\n\n"
|
||||||
|
"你的关注重点:\n"
|
||||||
|
f"{focus_md}\n\n"
|
||||||
|
"角色说明:\n"
|
||||||
|
f"{description or '围绕最关键的基本面、技术面、情绪面或估值因素形成高质量判断。'}\n"
|
||||||
|
)
|
||||||
|
files["AGENTS.md"] = (
|
||||||
|
"# Agent Guide\n\n"
|
||||||
|
"分析流程:\n"
|
||||||
|
"- 优先识别真正驱动价值或价格变化的核心变量\n"
|
||||||
|
"- 使用相关工具和技能补足证据链\n"
|
||||||
|
"- 给出可验证、可复查、可执行的分析结果\n"
|
||||||
|
"- 在团队讨论中清晰表达你的论点和反论点\n\n"
|
||||||
|
"输出要求:\n"
|
||||||
|
"- 给出明确投资信号:看涨、看跌或中性\n"
|
||||||
|
"- 包含置信度(0-100)\n"
|
||||||
|
"- 如果你确定要分享最终分析,请先给出结论,再给出推理依据\n"
|
||||||
|
"- 最终输出必须使用简体中文,不要生成英文版 analysis report\n"
|
||||||
|
)
|
||||||
|
files["POLICY.md"] = (
|
||||||
|
"# Policy\n\n"
|
||||||
|
"- 深化你的投资逻辑,确保每项建议都有清晰、可追溯、可重复的依据\n"
|
||||||
|
"- 明确风险边界:在什么具体情况下当前结论会失效\n"
|
||||||
|
"- 做逆向测试:说明市场主流共识与你的不同点\n"
|
||||||
|
"- 每次分析后反思这次案例如何验证或挑战你现有的信念\n"
|
||||||
|
"- 即使输入新闻或财报原文是英文,最终表达也必须用中文\n"
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_portfolio_manager_files(cls) -> Dict[str, str]:
|
||||||
|
files = cls._build_generic_files("portfolio_manager")
|
||||||
|
files["SOUL.md"] = (
|
||||||
|
"# Soul\n\n"
|
||||||
|
"你是一位负责做出投资决策的投资组合经理。你需要综合多个分析视角,"
|
||||||
|
"做出保守、明确、资本约束下可执行的组合决策。\n"
|
||||||
|
)
|
||||||
|
files["PROFILE.md"] = (
|
||||||
|
"# Profile\n\n"
|
||||||
|
"核心职责:\n"
|
||||||
|
"- 分析分析师和风险管理经理的输入\n"
|
||||||
|
"- 基于信号和市场情境做出投资决策\n"
|
||||||
|
"- 使用可用工具记录每个 ticker 的决策\n"
|
||||||
|
)
|
||||||
|
files["AGENTS.md"] = (
|
||||||
|
"# Agent Guide\n\n"
|
||||||
|
"决策框架:\n"
|
||||||
|
"- 审阅分析以理解市场观点\n"
|
||||||
|
"- 在做决策前先考虑风险警告\n"
|
||||||
|
"- 评估当前投资组合持仓、现金与保证金占用\n"
|
||||||
|
"- 决策必须与整体投资目标和风险约束一致\n\n"
|
||||||
|
"决策类型:\n"
|
||||||
|
'- `long`:看涨,建议买入\n'
|
||||||
|
'- `short`:看跌,建议卖出或做空\n'
|
||||||
|
'- `hold`:中性,维持当前持仓\n\n'
|
||||||
|
"输出要求:\n"
|
||||||
|
"- 使用 `make_decision` 工具记录每个股票的最终决策\n"
|
||||||
|
"- 记录完成后给出投资逻辑总结\n"
|
||||||
|
"- 最终总结必须使用简体中文\n"
|
||||||
|
)
|
||||||
|
files["POLICY.md"] = (
|
||||||
|
"# Policy\n\n"
|
||||||
|
"- 在决定数量时考虑可用现金,不要超出现金允许范围\n"
|
||||||
|
"- 考虑做空头寸的保证金要求\n"
|
||||||
|
"- 仓位规模相对于组合总资产保持保守\n"
|
||||||
|
"- 始终为决策提供清晰理由\n"
|
||||||
|
"- 不要输出英文投资报告或英文结论\n"
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_risk_manager_files(cls) -> Dict[str, str]:
|
||||||
|
files = cls._build_generic_files("risk_manager")
|
||||||
|
files["SOUL.md"] = (
|
||||||
|
"# Soul\n\n"
|
||||||
|
"你是一位专业的风险管理经理,负责监控投资组合风险并提供风险警告。"
|
||||||
|
"你的目标不是输出空泛的谨慎,而是给出量化、可执行、可优先级排序的风险意见。\n"
|
||||||
|
)
|
||||||
|
files["PROFILE.md"] = (
|
||||||
|
"# Profile\n\n"
|
||||||
|
"核心职责:\n"
|
||||||
|
"- 监控投资组合敞口和集中度风险\n"
|
||||||
|
"- 评估仓位规模相对于波动性是否合理\n"
|
||||||
|
"- 评估保证金使用和杠杆水平\n"
|
||||||
|
"- 识别潜在风险因素并提供警告\n"
|
||||||
|
"- 基于市场条件建议仓位限制\n"
|
||||||
|
)
|
||||||
|
files["AGENTS.md"] = (
|
||||||
|
"# Agent Guide\n\n"
|
||||||
|
"决策流程:\n"
|
||||||
|
"- 优先使用可用的风险工具量化集中度、波动率和保证金压力\n"
|
||||||
|
"- 结合工具结果与当前市场上下文做判断\n"
|
||||||
|
"- 生成可操作的风险警告和仓位限制建议\n"
|
||||||
|
"- 为风险评估提供清晰理由\n\n"
|
||||||
|
"输出要求:\n"
|
||||||
|
"- 风险评估要简洁但全面\n"
|
||||||
|
"- 按严重程度优先排序警告\n"
|
||||||
|
"- 提供具体、可操作的建议\n"
|
||||||
|
"- 尽可能包含量化指标\n"
|
||||||
|
"- 最终风险结论必须使用简体中文\n"
|
||||||
|
)
|
||||||
|
files["POLICY.md"] = (
|
||||||
|
"# Policy\n\n"
|
||||||
|
"- 先量化,再判断,不要只给抽象风险表述\n"
|
||||||
|
"- 高严重度风险必须先说\n"
|
||||||
|
"- 最终结论需要明确仓位限制或调整建议\n"
|
||||||
|
"- 不要输出英文风险报告或英文摘要\n"
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_legacy_english_files(agent_id: str) -> Dict[str, str]:
|
||||||
|
policy_tail = "Optional run-scoped constraints, limits, or strategy policy.\n\n"
|
||||||
|
if agent_id == "portfolio_manager":
|
||||||
|
policy_tail += "Respect cash, margin, and portfolio concentration constraints before recording decisions.\n"
|
||||||
|
elif agent_id == "risk_manager":
|
||||||
|
policy_tail += "Use available risk tools before issuing the final risk memo.\n"
|
||||||
|
elif agent_id.endswith("_analyst"):
|
||||||
|
policy_tail += "State a clear signal, confidence, and the conditions that would invalidate the thesis.\n"
|
||||||
|
return {
|
||||||
|
"SOUL.md": "# Soul\n\nDescribe the agent's temperament, reasoning posture, and voice.\n\n",
|
||||||
|
"PROFILE.md": "# Profile\n\nTrack this agent's long-lived investment style, preferences, and strengths.\n\n",
|
||||||
|
"AGENTS.md": "# Agent Guide\n\nDocument how this agent should work, collaborate, and choose tools or skills.\n\n",
|
||||||
|
"POLICY.md": "# Policy\n\n" + policy_tail,
|
||||||
|
"MEMORY.md": "# Memory\n\nStore durable lessons, heuristics, and reminders for this agent.\n\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_previous_chinese_files(cls, *, agent_id: str, persona: Dict[str, object]) -> Dict[str, str]:
|
||||||
|
if agent_id.endswith("_analyst"):
|
||||||
|
role_name = str(persona.get("name") or agent_id)
|
||||||
|
focus_items = [
|
||||||
|
str(item).strip()
|
||||||
|
for item in persona.get("focus", [])
|
||||||
|
if str(item).strip()
|
||||||
|
]
|
||||||
|
focus_md = "\n".join(f"- {item}" for item in focus_items) or "- 根据当前任务选择最相关的分析维度"
|
||||||
|
description = str(persona.get("description") or "").strip()
|
||||||
|
return {
|
||||||
|
"SOUL.md": (
|
||||||
|
"# Soul\n\n"
|
||||||
|
f"你是一位专业的{role_name}。\n\n"
|
||||||
|
"保持谦逊和开放,主动寻找与自己观点相悖的证据,并将其纳入最终评估。"
|
||||||
|
"你的分析要体现持续演化的投资哲学,而不是一次性的结论。\n"
|
||||||
|
),
|
||||||
|
"PROFILE.md": (
|
||||||
|
"# Profile\n\n"
|
||||||
|
f"角色定位:{role_name}\n\n"
|
||||||
|
"你的关注重点:\n"
|
||||||
|
f"{focus_md}\n\n"
|
||||||
|
"角色说明:\n"
|
||||||
|
f"{description or '围绕最关键的基本面、技术面、情绪面或估值因素形成高质量判断。'}\n"
|
||||||
|
),
|
||||||
|
"AGENTS.md": (
|
||||||
|
"# Agent Guide\n\n"
|
||||||
|
"分析流程:\n"
|
||||||
|
"- 优先识别真正驱动价值或价格变化的核心变量\n"
|
||||||
|
"- 使用相关工具和技能补足证据链\n"
|
||||||
|
"- 给出可验证、可复查、可执行的分析结果\n"
|
||||||
|
"- 在团队讨论中清晰表达你的论点和反论点\n\n"
|
||||||
|
"输出要求:\n"
|
||||||
|
"- 给出明确投资信号:看涨、看跌或中性\n"
|
||||||
|
"- 包含置信度(0-100)\n"
|
||||||
|
"- 如果你确定要分享最终分析,请先给出结论,再给出推理依据\n"
|
||||||
|
),
|
||||||
|
"POLICY.md": (
|
||||||
|
"# Policy\n\n"
|
||||||
|
"- 深化你的投资逻辑,确保每项建议都有清晰、可追溯、可重复的依据\n"
|
||||||
|
"- 明确风险边界:在什么具体情况下当前结论会失效\n"
|
||||||
|
"- 做逆向测试:说明市场主流共识与你的不同点\n"
|
||||||
|
"- 每次分析后反思这次案例如何验证或挑战你现有的信念\n"
|
||||||
|
),
|
||||||
|
"MEMORY.md": "# Memory\n\n记录可复用的经验、失误复盘、有效启发式和需要持续跟踪的提醒。\n",
|
||||||
|
}
|
||||||
|
if agent_id == "portfolio_manager":
|
||||||
|
return {
|
||||||
|
"SOUL.md": "# Soul\n\n你是一位负责做出投资决策的投资组合经理。你需要综合多个分析视角,做出保守、明确、资本约束下可执行的组合决策。\n",
|
||||||
|
"PROFILE.md": "# Profile\n\n核心职责:\n- 分析分析师和风险管理经理的输入\n- 基于信号和市场情境做出投资决策\n- 使用可用工具记录每个 ticker 的决策\n",
|
||||||
|
"AGENTS.md": "# Agent Guide\n\n决策框架:\n- 审阅分析以理解市场观点\n- 在做决策前先考虑风险警告\n- 评估当前投资组合持仓、现金与保证金占用\n- 决策必须与整体投资目标和风险约束一致\n\n决策类型:\n- `long`:看涨,建议买入\n- `short`:看跌,建议卖出或做空\n- `hold`:中性,维持当前持仓\n\n输出要求:\n- 使用 `make_decision` 工具记录每个股票的最终决策\n- 记录完成后给出投资逻辑总结\n",
|
||||||
|
"POLICY.md": "# Policy\n\n- 在决定数量时考虑可用现金,不要超出现金允许范围\n- 考虑做空头寸的保证金要求\n- 仓位规模相对于组合总资产保持保守\n- 始终为决策提供清晰理由\n",
|
||||||
|
"MEMORY.md": "# Memory\n\n记录可复用的经验、失误复盘、有效启发式和需要持续跟踪的提醒。\n",
|
||||||
|
}
|
||||||
|
if agent_id == "risk_manager":
|
||||||
|
return {
|
||||||
|
"SOUL.md": "# Soul\n\n你是一位专业的风险管理经理,负责监控投资组合风险并提供风险警告。你的目标不是输出空泛的谨慎,而是给出量化、可执行、可优先级排序的风险意见。\n",
|
||||||
|
"PROFILE.md": "# Profile\n\n核心职责:\n- 监控投资组合敞口和集中度风险\n- 评估仓位规模相对于波动性是否合理\n- 评估保证金使用和杠杆水平\n- 识别潜在风险因素并提供警告\n- 基于市场条件建议仓位限制\n",
|
||||||
|
"AGENTS.md": "# Agent Guide\n\n决策流程:\n- 优先使用可用的风险工具量化集中度、波动率和保证金压力\n- 结合工具结果与当前市场上下文做判断\n- 生成可操作的风险警告和仓位限制建议\n- 为风险评估提供清晰理由\n\n输出要求:\n- 风险评估要简洁但全面\n- 按严重程度优先排序警告\n- 提供具体、可操作的建议\n- 尽可能包含量化指标\n",
|
||||||
|
"POLICY.md": "# Policy\n\n- 先量化,再判断,不要只给抽象风险表述\n- 高严重度风险必须先说\n- 最终结论需要明确仓位限制或调整建议\n",
|
||||||
|
"MEMORY.md": "# Memory\n\n记录可复用的经验、失误复盘、有效启发式和需要持续跟踪的提醒。\n",
|
||||||
|
}
|
||||||
|
return cls._build_legacy_english_files(agent_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_agent_yaml(path: Path, agent_id: str) -> None:
|
def _ensure_agent_yaml(path: Path, agent_id: str) -> None:
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ Provides REST API endpoints for:
|
|||||||
from .agents import router as agents_router
|
from .agents import router as agents_router
|
||||||
from .workspaces import router as workspaces_router
|
from .workspaces import router as workspaces_router
|
||||||
from .guard import router as guard_router
|
from .guard import router as guard_router
|
||||||
|
from .openclaw import router as openclaw_router
|
||||||
from .runtime import router as runtime_router
|
from .runtime import router as runtime_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"agents_router",
|
"agents_router",
|
||||||
"workspaces_router",
|
"workspaces_router",
|
||||||
"guard_router",
|
"guard_router",
|
||||||
|
"openclaw_router",
|
||||||
"runtime_router",
|
"runtime_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ from typing import Any, Dict, List, Optional
|
|||||||
from fastapi import APIRouter, HTTPException, Depends, Body, UploadFile, File, Form
|
from fastapi import APIRouter, HTTPException, Depends, Body, UploadFile, File, Form
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from backend.agents import AgentFactory, WorkspaceManager, get_registry
|
from backend.agents import AgentFactory, get_registry
|
||||||
|
from backend.agents.workspace_manager import RunWorkspaceManager
|
||||||
|
from backend.agents.agent_workspace import load_agent_workspace_config
|
||||||
from backend.agents.skills_manager import SkillsManager
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
from backend.agents.toolkit_factory import load_agent_profiles
|
||||||
|
from backend.config.bootstrap_config import get_bootstrap_config_for_run
|
||||||
|
from backend.llm.models import get_agent_model_info
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -47,6 +52,14 @@ class InstallExternalSkillRequest(BaseModel):
|
|||||||
activate: bool = Field(True, description="Whether to enable skill immediately")
|
activate: bool = Field(True, description="Whether to enable skill immediately")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSkillRequest(BaseModel):
|
||||||
|
skill_name: str = Field(..., description="Local skill name")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSkillContentRequest(BaseModel):
|
||||||
|
content: str = Field(..., description="Updated SKILL.md content")
|
||||||
|
|
||||||
|
|
||||||
class AgentResponse(BaseModel):
|
class AgentResponse(BaseModel):
|
||||||
"""Agent information response."""
|
"""Agent information response."""
|
||||||
agent_id: str
|
agent_id: str
|
||||||
@@ -63,6 +76,24 @@ class AgentFileResponse(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentProfileResponse(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
workspace_id: str
|
||||||
|
profile: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSkillsResponse(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
workspace_id: str
|
||||||
|
skills: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDetailResponse(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
workspace_id: str
|
||||||
|
skill: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
def get_agent_factory():
|
def get_agent_factory():
|
||||||
"""Get AgentFactory instance."""
|
"""Get AgentFactory instance."""
|
||||||
@@ -70,8 +101,8 @@ def get_agent_factory():
|
|||||||
|
|
||||||
|
|
||||||
def get_workspace_manager():
|
def get_workspace_manager():
|
||||||
"""Get WorkspaceManager instance."""
|
"""Get run-scoped workspace manager instance."""
|
||||||
return WorkspaceManager()
|
return RunWorkspaceManager()
|
||||||
|
|
||||||
|
|
||||||
def get_skills_manager():
|
def get_skills_manager():
|
||||||
@@ -199,6 +230,108 @@ async def get_agent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/profile", response_model=AgentProfileResponse)
|
||||||
|
async def get_agent_profile(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skills_manager: SkillsManager = Depends(get_skills_manager),
|
||||||
|
):
|
||||||
|
asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id)
|
||||||
|
agent_config = load_agent_workspace_config(asset_dir / "agent.yaml")
|
||||||
|
profiles = load_agent_profiles()
|
||||||
|
profile = profiles.get(agent_id, {})
|
||||||
|
bootstrap = get_bootstrap_config_for_run(skills_manager.project_root, workspace_id)
|
||||||
|
override = bootstrap.agent_override(agent_id)
|
||||||
|
active_tool_groups = override.get("active_tool_groups", agent_config.active_tool_groups or profile.get("active_tool_groups", []))
|
||||||
|
if not isinstance(active_tool_groups, list):
|
||||||
|
active_tool_groups = []
|
||||||
|
disabled_tool_groups = agent_config.disabled_tool_groups
|
||||||
|
if disabled_tool_groups:
|
||||||
|
disabled_set = set(disabled_tool_groups)
|
||||||
|
active_tool_groups = [group_name for group_name in active_tool_groups if group_name not in disabled_set]
|
||||||
|
|
||||||
|
default_skills = profile.get("skills", [])
|
||||||
|
if not isinstance(default_skills, list):
|
||||||
|
default_skills = []
|
||||||
|
resolved_skills = skills_manager.resolve_agent_skill_names(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
default_skills=default_skills,
|
||||||
|
)
|
||||||
|
prompt_files = agent_config.prompt_files or ["SOUL.md", "PROFILE.md", "AGENTS.md", "POLICY.md", "MEMORY.md"]
|
||||||
|
model_name, model_provider = get_agent_model_info(agent_id)
|
||||||
|
|
||||||
|
return AgentProfileResponse(
|
||||||
|
agent_id=agent_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
profile={
|
||||||
|
"model_name": model_name,
|
||||||
|
"model_provider": model_provider,
|
||||||
|
"prompt_files": prompt_files,
|
||||||
|
"default_skills": default_skills,
|
||||||
|
"resolved_skills": resolved_skills,
|
||||||
|
"active_tool_groups": active_tool_groups,
|
||||||
|
"disabled_tool_groups": disabled_tool_groups,
|
||||||
|
"enabled_skills": agent_config.enabled_skills,
|
||||||
|
"disabled_skills": agent_config.disabled_skills,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/skills", response_model=AgentSkillsResponse)
|
||||||
|
async def get_agent_skills(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skills_manager: SkillsManager = Depends(get_skills_manager),
|
||||||
|
):
|
||||||
|
agent_asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id)
|
||||||
|
agent_config = load_agent_workspace_config(agent_asset_dir / "agent.yaml")
|
||||||
|
resolved_skills = set(skills_manager.resolve_agent_skill_names(config_name=workspace_id, agent_id=agent_id, default_skills=[]))
|
||||||
|
enabled = set(agent_config.enabled_skills)
|
||||||
|
disabled = set(agent_config.disabled_skills)
|
||||||
|
|
||||||
|
payload = []
|
||||||
|
for item in skills_manager.list_agent_skill_catalog(workspace_id, agent_id):
|
||||||
|
if item.skill_name in disabled:
|
||||||
|
status = "disabled"
|
||||||
|
elif item.skill_name in enabled:
|
||||||
|
status = "enabled"
|
||||||
|
elif item.skill_name in resolved_skills:
|
||||||
|
status = "active"
|
||||||
|
else:
|
||||||
|
status = "available"
|
||||||
|
payload.append({
|
||||||
|
"skill_name": item.skill_name,
|
||||||
|
"name": item.name,
|
||||||
|
"description": item.description,
|
||||||
|
"version": item.version,
|
||||||
|
"source": item.source,
|
||||||
|
"tools": item.tools,
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
|
||||||
|
return AgentSkillsResponse(agent_id=agent_id, workspace_id=workspace_id, skills=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/skills/{skill_name}", response_model=SkillDetailResponse)
|
||||||
|
async def get_agent_skill_detail(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skill_name: str,
|
||||||
|
skills_manager: SkillsManager = Depends(get_skills_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
detail = skills_manager.load_agent_skill_document(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown skill: {skill_name}")
|
||||||
|
|
||||||
|
return SkillDetailResponse(agent_id=agent_id, workspace_id=workspace_id, skill=detail)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{agent_id}")
|
@router.delete("/{agent_id}")
|
||||||
async def delete_agent(
|
async def delete_agent(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -386,6 +519,85 @@ async def install_external_skill(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{agent_id}/skills/local")
|
||||||
|
async def create_local_skill(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
request: LocalSkillRequest,
|
||||||
|
registry=Depends(get_registry),
|
||||||
|
):
|
||||||
|
agent_info = registry.get(agent_id)
|
||||||
|
if not agent_info or agent_info.workspace_id != workspace_id:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
skills_manager = SkillsManager()
|
||||||
|
try:
|
||||||
|
skills_manager.create_agent_local_skill(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_name=request.skill_name,
|
||||||
|
)
|
||||||
|
except (ValueError, FileExistsError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
return {"message": f"Created local skill '{request.skill_name}' for '{agent_id}'"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{agent_id}/skills/local/{skill_name}")
|
||||||
|
async def update_local_skill(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skill_name: str,
|
||||||
|
request: LocalSkillContentRequest,
|
||||||
|
registry=Depends(get_registry),
|
||||||
|
):
|
||||||
|
agent_info = registry.get(agent_id)
|
||||||
|
if not agent_info or agent_info.workspace_id != workspace_id:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
skills_manager = SkillsManager()
|
||||||
|
try:
|
||||||
|
skills_manager.update_agent_local_skill(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
content=request.content,
|
||||||
|
)
|
||||||
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
return {"message": f"Updated local skill '{skill_name}' for '{agent_id}'"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{agent_id}/skills/local/{skill_name}")
|
||||||
|
async def delete_local_skill(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skill_name: str,
|
||||||
|
registry=Depends(get_registry),
|
||||||
|
):
|
||||||
|
agent_info = registry.get(agent_id)
|
||||||
|
if not agent_info or agent_info.workspace_id != workspace_id:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
skills_manager = SkillsManager()
|
||||||
|
try:
|
||||||
|
skills_manager.delete_agent_local_skill(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
)
|
||||||
|
skills_manager.forget_agent_skill_overrides(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_names=[skill_name],
|
||||||
|
)
|
||||||
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
return {"message": f"Deleted local skill '{skill_name}' for '{agent_id}'"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{agent_id}/skills/upload")
|
@router.post("/{agent_id}/skills/upload")
|
||||||
async def upload_external_skill(
|
async def upload_external_skill(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -441,7 +653,7 @@ async def get_agent_file(
|
|||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
workspace_manager: WorkspaceManager = Depends(get_workspace_manager),
|
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Read an agent's workspace file.
|
Read an agent's workspace file.
|
||||||
@@ -449,7 +661,7 @@ async def get_agent_file(
|
|||||||
Args:
|
Args:
|
||||||
workspace_id: Workspace identifier
|
workspace_id: Workspace identifier
|
||||||
agent_id: Agent identifier
|
agent_id: Agent identifier
|
||||||
filename: File to read (e.g., SOUL.md, ROLE.md)
|
filename: File to read (e.g., SOUL.md, PROFILE.md)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
File content
|
File content
|
||||||
@@ -471,7 +683,7 @@ async def update_agent_file(
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
content: str = Body(..., media_type="text/plain"),
|
content: str = Body(..., media_type="text/plain"),
|
||||||
workspace_manager: WorkspaceManager = Depends(get_workspace_manager),
|
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update an agent's workspace file.
|
Update an agent's workspace file.
|
||||||
|
|||||||
839
backend/api/openclaw.py
Normal file
839
backend/api/openclaw.py
Normal file
@@ -0,0 +1,839 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Read-only OpenClaw CLI API routes — typed with Pydantic models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService
|
||||||
|
from shared.models.openclaw import OpenClawStatus
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/openclaw", tags=["openclaw"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_openclaw_cli_service() -> OpenClawCliService:
|
||||||
|
"""Build the OpenClaw CLI service dependency."""
|
||||||
|
return OpenClawCliService()
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_cli_http_error(exc: OpenClawCliError) -> None:
|
||||||
|
detail = {
|
||||||
|
"message": str(exc),
|
||||||
|
"command": exc.command,
|
||||||
|
"exit_code": exc.exit_code,
|
||||||
|
"stdout": exc.stdout,
|
||||||
|
"stderr": exc.stderr,
|
||||||
|
}
|
||||||
|
status_code = 503 if exc.exit_code is None else 502
|
||||||
|
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Response wrappers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class StatusResponse(BaseModel):
|
||||||
|
status: object
|
||||||
|
|
||||||
|
|
||||||
|
class SessionsResponse(BaseModel):
|
||||||
|
sessions: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDetailResponse(BaseModel):
|
||||||
|
session: object | None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionHistoryResponse(BaseModel):
|
||||||
|
session_key: str
|
||||||
|
session_id: str | None
|
||||||
|
events: list[object]
|
||||||
|
history: list[object]
|
||||||
|
raw_text: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class CronResponse(BaseModel):
|
||||||
|
cron: list[object]
|
||||||
|
jobs: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalsResponse(BaseModel):
|
||||||
|
approvals: list[object]
|
||||||
|
pending: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentsResponse(BaseModel):
|
||||||
|
agents: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class SkillsResponse(BaseModel):
|
||||||
|
workspace_dir: str
|
||||||
|
managed_skills_dir: str
|
||||||
|
skills: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class ModelsResponse(BaseModel):
|
||||||
|
models: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class HooksResponse(BaseModel):
|
||||||
|
workspace_dir: str
|
||||||
|
managed_hooks_dir: str
|
||||||
|
hooks: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class PluginsResponse(BaseModel):
|
||||||
|
workspace_dir: str
|
||||||
|
plugins: list[object]
|
||||||
|
diagnostics: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class SecretsAuditResponse(BaseModel):
|
||||||
|
version: int
|
||||||
|
status: str
|
||||||
|
findings: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAuditResponse2(BaseModel):
|
||||||
|
report: object | None
|
||||||
|
secret_diagnostics: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class DaemonStatusResponse(BaseModel):
|
||||||
|
service: object | None
|
||||||
|
port: object | None
|
||||||
|
rpc: object | None
|
||||||
|
health: object | None
|
||||||
|
|
||||||
|
|
||||||
|
class PairingListResponse2(BaseModel):
|
||||||
|
channel: str
|
||||||
|
requests: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class QrCodeResponse2(BaseModel):
|
||||||
|
setup_code: str
|
||||||
|
gateway_url: str
|
||||||
|
auth: str
|
||||||
|
url_source: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateStatusResponse2(BaseModel):
|
||||||
|
update: object | None
|
||||||
|
channel: object | None
|
||||||
|
|
||||||
|
|
||||||
|
class ModelAliasesResponse(BaseModel):
|
||||||
|
aliases: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class ModelFallbacksResponse(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
items: list[object]
|
||||||
|
|
||||||
|
|
||||||
|
class SkillUpdateResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
slug: str
|
||||||
|
version: str
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ModelsStatusResponse(BaseModel):
|
||||||
|
configPath: str | None = None
|
||||||
|
agentId: str | None = None
|
||||||
|
agentDir: str | None = None
|
||||||
|
defaultModel: str | None = None
|
||||||
|
resolvedDefault: str | None = None
|
||||||
|
fallbacks: list[str] = Field(default_factory=list)
|
||||||
|
imageModel: str | None = None
|
||||||
|
imageFallbacks: list[str] = Field(default_factory=list)
|
||||||
|
aliases: dict[str, str] = Field(default_factory=dict)
|
||||||
|
allowed: list[str] = Field(default_factory=list)
|
||||||
|
auth: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelsStatusResponse(BaseModel):
|
||||||
|
reachable: bool | None = None
|
||||||
|
channelAccounts: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
channels: list[str] = Field(default_factory=list)
|
||||||
|
issues: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelsListResponse(BaseModel):
|
||||||
|
chat: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
|
auth: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
usage: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HookInfoResponse(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
source: str | None = None
|
||||||
|
pluginId: str | None = None
|
||||||
|
filePath: str | None = None
|
||||||
|
handlerPath: str | None = None
|
||||||
|
hookKey: str | None = None
|
||||||
|
emoji: str | None = None
|
||||||
|
homepage: str | None = None
|
||||||
|
events: list[str] = Field(default_factory=list)
|
||||||
|
enabledByConfig: bool | None = None
|
||||||
|
loadable: bool | None = None
|
||||||
|
requirementsSatisfied: bool | None = None
|
||||||
|
requirements: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
error: str | None = None
|
||||||
|
raw: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HooksCheckResponse(BaseModel):
|
||||||
|
workspace_dir: str = ""
|
||||||
|
managed_hooks_dir: str = ""
|
||||||
|
hooks: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
eligible: bool | None = None
|
||||||
|
verbose: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PluginInspectEntry(BaseModel):
|
||||||
|
plugin: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
shape: str | None = None
|
||||||
|
capabilityMode: str | None = None
|
||||||
|
capabilityCount: int = 0
|
||||||
|
capabilities: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
typedHooks: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
customHooks: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
tools: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
commands: list[str] = Field(default_factory=list)
|
||||||
|
cliCommands: list[str] = Field(default_factory=list)
|
||||||
|
services: list[str] = Field(default_factory=list)
|
||||||
|
gatewayMethods: list[str] = Field(default_factory=list)
|
||||||
|
mcpServers: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
lspServers: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
httpRouteCount: int = 0
|
||||||
|
bundleCapabilities: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginsInspectResponse(BaseModel):
|
||||||
|
inspect: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBindingItem(BaseModel):
|
||||||
|
agentId: str
|
||||||
|
match: dict[str, Any]
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentsBindingsResponse(BaseModel):
|
||||||
|
bindings: list[AgentBindingItem]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes — use typed model methods and return Pydantic models directly
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def api_openclaw_status(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> OpenClawStatus:
|
||||||
|
"""Read `openclaw status --json` and return a typed model."""
|
||||||
|
try:
|
||||||
|
return service.status_model()
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions")
|
||||||
|
async def api_openclaw_sessions(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> SessionsResponse:
|
||||||
|
"""Read `openclaw sessions --json` and return a typed SessionsList."""
|
||||||
|
try:
|
||||||
|
result = service.list_sessions_model()
|
||||||
|
return SessionsResponse(sessions=result.sessions)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_key:path}/history")
|
||||||
|
async def api_openclaw_session_history(
|
||||||
|
session_key: str,
|
||||||
|
limit: int = Query(20, ge=1, le=200),
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> SessionHistoryResponse:
|
||||||
|
"""Read session history and return a typed SessionHistory."""
|
||||||
|
try:
|
||||||
|
result = service.get_session_history_model(session_key, limit=limit)
|
||||||
|
return SessionHistoryResponse(
|
||||||
|
session_key=result.session_key,
|
||||||
|
session_id=result.session_id,
|
||||||
|
events=result.events,
|
||||||
|
history=result.events, # alias for compat
|
||||||
|
raw_text=result.raw_text,
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_key:path}")
|
||||||
|
async def api_openclaw_session_detail(
|
||||||
|
session_key: str,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> SessionDetailResponse:
|
||||||
|
"""Resolve a single session and return it as a typed model."""
|
||||||
|
try:
|
||||||
|
session = service.get_session_model(session_key)
|
||||||
|
return SessionDetailResponse(session=session)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=f"session '{session_key}' not found") from exc
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cron")
|
||||||
|
async def api_openclaw_cron(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> CronResponse:
|
||||||
|
"""Read `openclaw cron list --json` and return a typed CronList."""
|
||||||
|
try:
|
||||||
|
result = service.list_cron_jobs_model()
|
||||||
|
return CronResponse(cron=list(result.cron), jobs=list(result.jobs))
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/approvals")
|
||||||
|
async def api_openclaw_approvals(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> ApprovalsResponse:
|
||||||
|
"""Read `openclaw approvals get --json` and return a typed ApprovalsList."""
|
||||||
|
try:
|
||||||
|
result = service.list_approvals_model()
|
||||||
|
return ApprovalsResponse(
|
||||||
|
approvals=list(result.approvals),
|
||||||
|
pending=list(result.pending),
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agents")
|
||||||
|
async def api_openclaw_agents(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> AgentsResponse:
|
||||||
|
"""Read `openclaw agents list --json` and return a typed AgentsList."""
|
||||||
|
try:
|
||||||
|
result = service.list_agents_model()
|
||||||
|
return AgentsResponse(agents=list(result.agents))
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agents/presence")
|
||||||
|
async def api_openclaw_agents_presence(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read runtime session presence for all agents from session files."""
|
||||||
|
result = service.agents_presence()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Write agents routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAddResponse(BaseModel):
|
||||||
|
agentId: str
|
||||||
|
name: str
|
||||||
|
workspace: str
|
||||||
|
agentDir: str
|
||||||
|
model: str | None = None
|
||||||
|
bindings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDeleteResponse(BaseModel):
|
||||||
|
agentId: str
|
||||||
|
workspace: str
|
||||||
|
agentDir: str
|
||||||
|
sessionsDir: str
|
||||||
|
removedBindings: list[str] = Field(default_factory=list)
|
||||||
|
removedAllow: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBindResponse(BaseModel):
|
||||||
|
agentId: str
|
||||||
|
added: list[str] = Field(default_factory=list)
|
||||||
|
updated: list[str] = Field(default_factory=list)
|
||||||
|
skipped: list[str] = Field(default_factory=list)
|
||||||
|
conflicts: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentUnbindResponse(BaseModel):
|
||||||
|
agentId: str
|
||||||
|
removed: list[str] = Field(default_factory=list)
|
||||||
|
missing: list[str] = Field(default_factory=list)
|
||||||
|
conflicts: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentIdentityResponse(BaseModel):
|
||||||
|
agentId: str
|
||||||
|
identity: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
workspace: str | None = None
|
||||||
|
identityFile: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/add")
|
||||||
|
async def api_openclaw_agents_add(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
workspace: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
agent_dir: str | None = None,
|
||||||
|
bind: list[str] | None = None,
|
||||||
|
non_interactive: bool = False,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> AgentAddResponse:
|
||||||
|
"""Run `openclaw agents add <name>` and return JSON result."""
|
||||||
|
try:
|
||||||
|
result = service.agents_add(
|
||||||
|
name,
|
||||||
|
workspace=workspace,
|
||||||
|
model=model,
|
||||||
|
agent_dir=agent_dir,
|
||||||
|
bind=bind,
|
||||||
|
non_interactive=non_interactive,
|
||||||
|
)
|
||||||
|
return AgentAddResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/delete/{id}")
|
||||||
|
async def api_openclaw_agents_delete(
|
||||||
|
id: str,
|
||||||
|
force: bool = False,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> AgentDeleteResponse:
|
||||||
|
"""Run `openclaw agents delete <id> [--force]` and return JSON result."""
|
||||||
|
try:
|
||||||
|
result = service.agents_delete(id, force=force)
|
||||||
|
return AgentDeleteResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/bind")
|
||||||
|
async def api_openclaw_agents_bind(
|
||||||
|
*,
|
||||||
|
agent: str | None = None,
|
||||||
|
bind: list[str] | None = None,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> AgentBindResponse:
|
||||||
|
"""Run `openclaw agents bind [--agent <id>] [--bind <spec>]` and return JSON result."""
|
||||||
|
try:
|
||||||
|
result = service.agents_bind(agent=agent, bind=bind)
|
||||||
|
return AgentBindResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/unbind")
|
||||||
|
async def api_openclaw_agents_unbind(
|
||||||
|
*,
|
||||||
|
agent: str | None = None,
|
||||||
|
bind: list[str] | None = None,
|
||||||
|
all: bool = False,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> AgentUnbindResponse:
|
||||||
|
"""Run `openclaw agents unbind [--agent <id>] [--bind <spec>] [--all]` and return JSON result."""
|
||||||
|
try:
|
||||||
|
result = service.agents_unbind(agent=agent, bind=bind, all=all)
|
||||||
|
return AgentUnbindResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/set-identity")
|
||||||
|
async def api_openclaw_agents_set_identity(
|
||||||
|
*,
|
||||||
|
agent: str | None = None,
|
||||||
|
workspace: str | None = None,
|
||||||
|
identity_file: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
emoji: str | None = None,
|
||||||
|
theme: str | None = None,
|
||||||
|
avatar: str | None = None,
|
||||||
|
from_identity: bool = False,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> AgentIdentityResponse:
|
||||||
|
"""Run `openclaw agents set-identity` and return JSON result."""
|
||||||
|
try:
|
||||||
|
result = service.agents_set_identity(
|
||||||
|
agent=agent,
|
||||||
|
workspace=workspace,
|
||||||
|
identity_file=identity_file,
|
||||||
|
name=name,
|
||||||
|
emoji=emoji,
|
||||||
|
theme=theme,
|
||||||
|
avatar=avatar,
|
||||||
|
from_identity=from_identity,
|
||||||
|
)
|
||||||
|
return AgentIdentityResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/skills")
|
||||||
|
async def api_openclaw_skills(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> SkillsResponse:
|
||||||
|
"""Read `openclaw skills list --json` and return a typed SkillStatusReport."""
|
||||||
|
try:
|
||||||
|
result = service.list_skills_model()
|
||||||
|
return SkillsResponse(
|
||||||
|
workspace_dir=result.workspace_dir,
|
||||||
|
managed_skills_dir=result.managed_skills_dir,
|
||||||
|
skills=list(result.skills),
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models")
|
||||||
|
async def api_openclaw_models(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> ModelsResponse:
|
||||||
|
"""Read `openclaw models list --json` and return a typed ModelsList."""
|
||||||
|
try:
|
||||||
|
result = service.list_models_model()
|
||||||
|
return ModelsResponse(models=list(result.models))
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hooks")
|
||||||
|
async def api_openclaw_hooks(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> HooksResponse:
|
||||||
|
try:
|
||||||
|
result = service.list_hooks_model()
|
||||||
|
return HooksResponse(
|
||||||
|
workspace_dir=result.workspace_dir,
|
||||||
|
managed_hooks_dir=result.managed_hooks_dir,
|
||||||
|
hooks=list(result.hooks),
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plugins")
|
||||||
|
async def api_openclaw_plugins(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> PluginsResponse:
|
||||||
|
try:
|
||||||
|
result = service.list_plugins_model()
|
||||||
|
return PluginsResponse(
|
||||||
|
workspace_dir=result.workspace_dir,
|
||||||
|
plugins=list(result.plugins),
|
||||||
|
diagnostics=list(result.diagnostics),
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/secrets-audit")
|
||||||
|
async def api_openclaw_secrets_audit(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> SecretsAuditResponse:
|
||||||
|
try:
|
||||||
|
result = service.secrets_audit_model()
|
||||||
|
return SecretsAuditResponse(
|
||||||
|
version=result.version,
|
||||||
|
status=result.status,
|
||||||
|
findings=list(result.findings),
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/security-audit")
|
||||||
|
async def api_openclaw_security_audit(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> SecurityAuditResponse2:
|
||||||
|
try:
|
||||||
|
result = service.security_audit_model()
|
||||||
|
return SecurityAuditResponse2(
|
||||||
|
report=result.report.model_dump() if result.report else None,
|
||||||
|
secret_diagnostics=list(result.secret_diagnostics),
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daemon-status")
|
||||||
|
async def api_openclaw_daemon_status(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> DaemonStatusResponse:
|
||||||
|
try:
|
||||||
|
result = service.daemon_status_model()
|
||||||
|
return DaemonStatusResponse(
|
||||||
|
service=result.service.model_dump() if result.service else None,
|
||||||
|
port=result.port.model_dump() if result.port else None,
|
||||||
|
rpc=result.rpc.model_dump() if result.rpc else None,
|
||||||
|
health=result.health.model_dump() if result.health else None,
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pairing")
|
||||||
|
async def api_openclaw_pairing(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> PairingListResponse2:
|
||||||
|
try:
|
||||||
|
result = service.pairing_list_model()
|
||||||
|
return PairingListResponse2(
|
||||||
|
channel=result.channel,
|
||||||
|
requests=list(result.requests),
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qr")
|
||||||
|
async def api_openclaw_qr(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> QrCodeResponse2:
|
||||||
|
try:
|
||||||
|
result = service.qr_code_model()
|
||||||
|
return QrCodeResponse2(
|
||||||
|
setup_code=result.setup_code,
|
||||||
|
gateway_url=result.gateway_url,
|
||||||
|
auth=result.auth,
|
||||||
|
url_source=result.url_source,
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/update-status")
|
||||||
|
async def api_openclaw_update_status(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> UpdateStatusResponse2:
|
||||||
|
try:
|
||||||
|
result = service.update_status_model()
|
||||||
|
return UpdateStatusResponse2(
|
||||||
|
update=result.update.model_dump() if result.update else None,
|
||||||
|
channel=result.channel.model_dump() if result.channel else None,
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models-aliases")
|
||||||
|
async def api_openclaw_models_aliases(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> ModelAliasesResponse:
|
||||||
|
try:
|
||||||
|
result = service.list_model_aliases_model()
|
||||||
|
return ModelAliasesResponse(aliases=result.aliases)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models-fallbacks")
|
||||||
|
async def api_openclaw_models_fallbacks(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> ModelFallbacksResponse:
|
||||||
|
try:
|
||||||
|
result = service.list_model_fallbacks_model()
|
||||||
|
return ModelFallbacksResponse(
|
||||||
|
key=result.key,
|
||||||
|
label=result.label,
|
||||||
|
items=list(result.items),
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models-image-fallbacks")
|
||||||
|
async def api_openclaw_models_image_fallbacks(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> ModelFallbacksResponse:
|
||||||
|
try:
|
||||||
|
result = service.list_model_image_fallbacks_model()
|
||||||
|
return ModelFallbacksResponse(
|
||||||
|
key=result.key,
|
||||||
|
label=result.label,
|
||||||
|
items=list(result.items),
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/skill-update")
|
||||||
|
async def api_openclaw_skill_update(
|
||||||
|
slug: str | None = None,
|
||||||
|
all: bool = False,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> SkillUpdateResponse:
|
||||||
|
try:
|
||||||
|
result = service.skill_update_model(slug=slug, all=all)
|
||||||
|
return SkillUpdateResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
slug=result.slug,
|
||||||
|
version=result.version,
|
||||||
|
error=result.error,
|
||||||
|
)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models-status")
|
||||||
|
async def api_openclaw_models_status(
|
||||||
|
probe: bool = False,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> ModelsStatusResponse:
|
||||||
|
"""Read `openclaw models status --json [--probe]` and return a typed dict."""
|
||||||
|
try:
|
||||||
|
result = service.models_status_model(probe=probe)
|
||||||
|
return ModelsStatusResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/channels-status")
|
||||||
|
async def api_openclaw_channels_status(
|
||||||
|
probe: bool = False,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> ChannelsStatusResponse:
|
||||||
|
"""Read `openclaw channels status --json [--probe]` and return a typed dict."""
|
||||||
|
try:
|
||||||
|
result = service.channels_status_model(probe=probe)
|
||||||
|
return ChannelsStatusResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/channels-list")
|
||||||
|
async def api_openclaw_channels_list(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> ChannelsListResponse:
|
||||||
|
"""Read `openclaw channels list --json` and return a typed dict."""
|
||||||
|
try:
|
||||||
|
result = service.channels_list_model()
|
||||||
|
return ChannelsListResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hooks/info/{name}")
|
||||||
|
async def api_openclaw_hook_info(
|
||||||
|
name: str,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> HookInfoResponse:
|
||||||
|
"""Read `openclaw hooks info <name> --json` and return a typed dict."""
|
||||||
|
try:
|
||||||
|
result = service.hook_info_model(name)
|
||||||
|
return HookInfoResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hooks/check")
|
||||||
|
async def api_openclaw_hooks_check(
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> HooksCheckResponse:
|
||||||
|
"""Read `openclaw hooks check --json` and return a typed dict."""
|
||||||
|
try:
|
||||||
|
result = service.hooks_check_model()
|
||||||
|
return HooksCheckResponse.model_validate(result, strict=False)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plugins-inspect")
|
||||||
|
async def api_openclaw_plugins_inspect(
|
||||||
|
plugin_id: str | None = None,
|
||||||
|
all: bool = False,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> PluginsInspectResponse:
|
||||||
|
"""Read `openclaw plugins inspect --json [--all]` and return a typed dict."""
|
||||||
|
try:
|
||||||
|
result = service.plugins_inspect_model(plugin_id=plugin_id, all=all)
|
||||||
|
inspect = result if isinstance(result, list) else result.get("inspect", [])
|
||||||
|
return PluginsInspectResponse(inspect=inspect)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBindingItem(BaseModel):
|
||||||
|
agentId: str
|
||||||
|
match: dict[str, Any]
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentsBindingsResponse(BaseModel):
|
||||||
|
bindings: list[AgentBindingItem]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agents-bindings")
|
||||||
|
async def api_openclaw_agents_bindings(
|
||||||
|
agent: str | None = None,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> AgentsBindingsResponse:
|
||||||
|
"""Read `openclaw agents bindings --json [--agent <id>]` and return bindings list."""
|
||||||
|
try:
|
||||||
|
result = service.agents_bindings_model(agent=agent)
|
||||||
|
bindings = result if isinstance(result, list) else []
|
||||||
|
return AgentsBindingsResponse(bindings=bindings)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gateway-status")
|
||||||
|
async def api_openclaw_gateway_status(
|
||||||
|
url: str | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw gateway status --json [--url <url>] [--token <token>]`. Returns full gateway probe result."""
|
||||||
|
try:
|
||||||
|
result = service.gateway_status(url=url, token=token)
|
||||||
|
return result
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/memory-status")
|
||||||
|
async def api_openclaw_memory_status(
|
||||||
|
agent: str | None = None,
|
||||||
|
deep: bool = False,
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Read `openclaw memory status --json [--agent <id>] [--deep]`. Returns array of per-agent memory status."""
|
||||||
|
try:
|
||||||
|
result = service.memory_status(agent=agent, deep=deep)
|
||||||
|
return result if isinstance(result, list) else []
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
_raise_cli_http_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceFilesResponse(BaseModel):
|
||||||
|
workspace: str
|
||||||
|
files: list[dict[str, Any]]
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workspace-files")
|
||||||
|
async def api_openclaw_workspace_files(
|
||||||
|
workspace: str = Query(..., description="Path to the agent workspace directory"),
|
||||||
|
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||||
|
) -> WorkspaceFilesResponse:
|
||||||
|
"""List .md files in an OpenClaw agent workspace with their content previews."""
|
||||||
|
result = service.list_workspace_files(workspace)
|
||||||
|
return WorkspaceFilesResponse.model_validate(result, strict=False)
|
||||||
@@ -8,6 +8,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -38,9 +39,10 @@ class RuntimeState:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_instance: Optional["RuntimeState"] = None
|
_instance: Optional["RuntimeState"] = None
|
||||||
_lock: asyncio.Lock = asyncio.Lock()
|
_lock: "threading.Lock" = __import__("threading").Lock()
|
||||||
|
|
||||||
def __new__(cls) -> "RuntimeState":
|
def __new__(cls) -> "RuntimeState":
|
||||||
|
with cls._lock:
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super().__new__(cls)
|
cls._instance = super().__new__(cls)
|
||||||
cls._instance._initialized = False
|
cls._instance._initialized = False
|
||||||
@@ -165,6 +167,8 @@ class RuntimeEventsResponse(BaseModel):
|
|||||||
|
|
||||||
class LaunchConfig(BaseModel):
|
class LaunchConfig(BaseModel):
|
||||||
"""Configuration for launching a new trading task."""
|
"""Configuration for launching a new trading task."""
|
||||||
|
launch_mode: str = Field(default="fresh", description="启动形式: fresh, restore")
|
||||||
|
restore_run_id: Optional[str] = Field(default=None, description="历史任务 run_id,用于恢复启动")
|
||||||
tickers: List[str] = Field(default_factory=list, description="股票池")
|
tickers: List[str] = Field(default_factory=list, description="股票池")
|
||||||
schedule_mode: str = Field(default="daily", description="调度模式: daily, interval")
|
schedule_mode: str = Field(default="daily", description="调度模式: daily, interval")
|
||||||
interval_minutes: int = Field(default=60, ge=1, description="间隔分钟数")
|
interval_minutes: int = Field(default=60, ge=1, description="间隔分钟数")
|
||||||
@@ -177,7 +181,6 @@ class LaunchConfig(BaseModel):
|
|||||||
start_date: Optional[str] = Field(default=None, description="回测开始日期 YYYY-MM-DD")
|
start_date: Optional[str] = Field(default=None, description="回测开始日期 YYYY-MM-DD")
|
||||||
end_date: Optional[str] = Field(default=None, description="回测结束日期 YYYY-MM-DD")
|
end_date: Optional[str] = Field(default=None, description="回测结束日期 YYYY-MM-DD")
|
||||||
poll_interval: int = Field(default=10, ge=1, le=300, description="市场数据轮询间隔(秒)")
|
poll_interval: int = Field(default=10, ge=1, le=300, description="市场数据轮询间隔(秒)")
|
||||||
enable_mock: bool = Field(default=False, description="是否启用模拟模式(使用模拟价格数据)")
|
|
||||||
|
|
||||||
|
|
||||||
class LaunchResponse(BaseModel):
|
class LaunchResponse(BaseModel):
|
||||||
@@ -188,11 +191,30 @@ class LaunchResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHistoryItem(BaseModel):
|
||||||
|
run_id: str
|
||||||
|
run_dir: str
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
total_trades: int = 0
|
||||||
|
total_asset_value: Optional[float] = None
|
||||||
|
bootstrap: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHistoryResponse(BaseModel):
|
||||||
|
runs: List[RuntimeHistoryItem]
|
||||||
|
|
||||||
|
|
||||||
class StopResponse(BaseModel):
|
class StopResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
kept: int
|
||||||
|
pruned_run_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
class GatewayStatusResponse(BaseModel):
|
class GatewayStatusResponse(BaseModel):
|
||||||
is_running: bool
|
is_running: bool
|
||||||
port: int
|
port: int
|
||||||
@@ -207,6 +229,13 @@ class RuntimeConfigResponse(BaseModel):
|
|||||||
resolved: Dict[str, Any]
|
resolved: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeLogResponse(BaseModel):
|
||||||
|
run_id: Optional[str] = None
|
||||||
|
is_running: bool
|
||||||
|
log_path: Optional[str] = None
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
|
||||||
class UpdateRuntimeConfigRequest(BaseModel):
|
class UpdateRuntimeConfigRequest(BaseModel):
|
||||||
schedule_mode: Optional[str] = None
|
schedule_mode: Optional[str] = None
|
||||||
interval_minutes: Optional[int] = Field(default=None, ge=1)
|
interval_minutes: Optional[int] = Field(default=None, ge=1)
|
||||||
@@ -227,6 +256,128 @@ def _get_run_dir(run_id: str) -> Path:
|
|||||||
return PROJECT_ROOT / "runs" / run_id
|
return PROJECT_ROOT / "runs" / run_id
|
||||||
|
|
||||||
|
|
||||||
|
def _load_run_snapshot(run_id: str) -> Dict[str, Any]:
|
||||||
|
"""Load a specific run snapshot by run_id."""
|
||||||
|
snapshot_path = _get_run_dir(run_id) / "state" / "runtime_state.json"
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Run snapshot not found: {run_id}")
|
||||||
|
return json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_path_if_exists(src: Path, dst: Path) -> None:
|
||||||
|
if not src.exists():
|
||||||
|
return
|
||||||
|
if src.is_dir():
|
||||||
|
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_run_assets(source_run_id: str, target_run_dir: Path) -> None:
|
||||||
|
"""Seed a fresh run directory from a historical run snapshot."""
|
||||||
|
source_run_dir = _get_run_dir(source_run_id)
|
||||||
|
if not source_run_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Source run not found: {source_run_id}")
|
||||||
|
|
||||||
|
for relative in [
|
||||||
|
"team_dashboard",
|
||||||
|
"agents",
|
||||||
|
"skills",
|
||||||
|
"memory",
|
||||||
|
"state/server_state.json",
|
||||||
|
"state/runtime.db",
|
||||||
|
"state/research.db",
|
||||||
|
]:
|
||||||
|
_copy_path_if_exists(source_run_dir / relative, target_run_dir / relative)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_runs(limit: int = 50) -> list[RuntimeHistoryItem]:
|
||||||
|
runs_root = PROJECT_ROOT / "runs"
|
||||||
|
if not runs_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
items: list[RuntimeHistoryItem] = []
|
||||||
|
run_dirs = sorted(
|
||||||
|
[path for path in runs_root.iterdir() if path.is_dir()],
|
||||||
|
key=lambda path: path.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for run_dir in run_dirs[: max(1, int(limit))]:
|
||||||
|
run_id = run_dir.name
|
||||||
|
runtime_state_path = run_dir / "state" / "runtime_state.json"
|
||||||
|
summary_path = run_dir / "team_dashboard" / "summary.json"
|
||||||
|
|
||||||
|
bootstrap: Dict[str, Any] = {}
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
total_trades = 0
|
||||||
|
total_asset_value: Optional[float] = None
|
||||||
|
|
||||||
|
if runtime_state_path.exists():
|
||||||
|
try:
|
||||||
|
snapshot = json.loads(runtime_state_path.read_text(encoding="utf-8"))
|
||||||
|
context = snapshot.get("context") or {}
|
||||||
|
bootstrap = dict(context.get("bootstrap_values") or {})
|
||||||
|
updated_at = snapshot.get("events", [{}])[-1].get("timestamp") if snapshot.get("events") else None
|
||||||
|
except Exception:
|
||||||
|
bootstrap = {}
|
||||||
|
|
||||||
|
if summary_path.exists():
|
||||||
|
try:
|
||||||
|
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||||
|
total_trades = int(summary.get("totalTrades") or 0)
|
||||||
|
total_asset_value = float(summary.get("totalAssetValue")) if summary.get("totalAssetValue") is not None else None
|
||||||
|
except Exception:
|
||||||
|
total_trades = 0
|
||||||
|
total_asset_value = None
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
RuntimeHistoryItem(
|
||||||
|
run_id=run_id,
|
||||||
|
run_dir=str(run_dir),
|
||||||
|
updated_at=updated_at,
|
||||||
|
total_trades=total_trades,
|
||||||
|
total_asset_value=total_asset_value,
|
||||||
|
bootstrap=bootstrap,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _is_timestamped_run_dir(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
datetime.strptime(path.name, "%Y%m%d_%H%M%S")
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_old_timestamped_runs(*, keep: int = 20, exclude_run_ids: Optional[set[str]] = None) -> list[str]:
|
||||||
|
"""Prune old timestamped run directories, preserving the newest N and excluded ids."""
|
||||||
|
exclude = exclude_run_ids or set()
|
||||||
|
runs_root = PROJECT_ROOT / "runs"
|
||||||
|
if not runs_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates = sorted(
|
||||||
|
[
|
||||||
|
path
|
||||||
|
for path in runs_root.iterdir()
|
||||||
|
if path.is_dir() and _is_timestamped_run_dir(path) and path.name not in exclude
|
||||||
|
],
|
||||||
|
key=lambda path: path.name,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pruned: list[str] = []
|
||||||
|
for path in candidates[max(0, keep):]:
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
pruned.append(path.name)
|
||||||
|
return pruned
|
||||||
|
|
||||||
|
|
||||||
def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int:
|
def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int:
|
||||||
"""Find an available port for Gateway."""
|
"""Find an available port for Gateway."""
|
||||||
import socket
|
import socket
|
||||||
@@ -238,11 +389,21 @@ def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _is_gateway_running() -> bool:
|
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
|
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 False
|
||||||
return process.poll() is None
|
|
||||||
|
|
||||||
|
|
||||||
def _stop_gateway() -> bool:
|
def _stop_gateway() -> bool:
|
||||||
@@ -288,29 +449,29 @@ def _start_gateway_process(
|
|||||||
"--bootstrap", json.dumps(bootstrap)
|
"--bootstrap", json.dumps(bootstrap)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Start process
|
log_path = run_dir / "logs" / "gateway.log"
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
log_file = log_path.open("ab")
|
||||||
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
env=env,
|
env=env,
|
||||||
stdout=subprocess.PIPE,
|
stdout=log_file,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.STDOUT,
|
||||||
cwd=PROJECT_ROOT
|
cwd=PROJECT_ROOT
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
log_file.close()
|
||||||
|
|
||||||
return process
|
return process
|
||||||
|
|
||||||
|
|
||||||
@router.get("/context", response_model=RunContextResponse)
|
@router.get("/context", response_model=RunContextResponse)
|
||||||
async def get_run_context() -> RunContextResponse:
|
async def get_run_context() -> RunContextResponse:
|
||||||
"""Return the most recent run context."""
|
"""Return active runtime context, or latest persisted context when stopped."""
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
context = snapshot.get("context")
|
||||||
|
|
||||||
if not snapshots:
|
|
||||||
raise HTTPException(status_code=404, detail="No run context available")
|
|
||||||
|
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
|
||||||
context = latest.get("context")
|
|
||||||
if context is None:
|
if context is None:
|
||||||
raise HTTPException(status_code=404, detail="Run context is not ready")
|
raise HTTPException(status_code=404, detail="Run context is not ready")
|
||||||
|
|
||||||
@@ -323,15 +484,9 @@ async def get_run_context() -> RunContextResponse:
|
|||||||
|
|
||||||
@router.get("/agents", response_model=RuntimeAgentsResponse)
|
@router.get("/agents", response_model=RuntimeAgentsResponse)
|
||||||
async def get_runtime_agents() -> RuntimeAgentsResponse:
|
async def get_runtime_agents() -> RuntimeAgentsResponse:
|
||||||
"""Return agent states from the most recent run."""
|
"""Return agent states from the active runtime, or latest persisted run."""
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
agents = snapshot.get("agents", [])
|
||||||
|
|
||||||
if not snapshots:
|
|
||||||
raise HTTPException(status_code=404, detail="No runtime state available")
|
|
||||||
|
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
|
||||||
agents = latest.get("agents", [])
|
|
||||||
|
|
||||||
return RuntimeAgentsResponse(
|
return RuntimeAgentsResponse(
|
||||||
agents=[RuntimeAgentState(**a) for a in agents]
|
agents=[RuntimeAgentState(**a) for a in agents]
|
||||||
@@ -340,21 +495,21 @@ async def get_runtime_agents() -> RuntimeAgentsResponse:
|
|||||||
|
|
||||||
@router.get("/events", response_model=RuntimeEventsResponse)
|
@router.get("/events", response_model=RuntimeEventsResponse)
|
||||||
async def get_runtime_events() -> RuntimeEventsResponse:
|
async def get_runtime_events() -> RuntimeEventsResponse:
|
||||||
"""Return events from the most recent run."""
|
"""Return events from the active runtime, or latest persisted run."""
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
events = snapshot.get("events", [])
|
||||||
|
|
||||||
if not snapshots:
|
|
||||||
raise HTTPException(status_code=404, detail="No runtime state available")
|
|
||||||
|
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
|
||||||
events = latest.get("events", [])
|
|
||||||
|
|
||||||
return RuntimeEventsResponse(
|
return RuntimeEventsResponse(
|
||||||
events=[RuntimeEvent(**e) for e in events]
|
events=[RuntimeEvent(**e) for e in events]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history", response_model=RuntimeHistoryResponse)
|
||||||
|
async def get_runtime_history(limit: int = 20) -> RuntimeHistoryResponse:
|
||||||
|
"""List recent historical runs for restore/start selection."""
|
||||||
|
return RuntimeHistoryResponse(runs=_list_runs(limit=limit))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/gateway/status", response_model=GatewayStatusResponse)
|
@router.get("/gateway/status", response_model=GatewayStatusResponse)
|
||||||
async def get_gateway_status() -> GatewayStatusResponse:
|
async def get_gateway_status() -> GatewayStatusResponse:
|
||||||
"""Get Gateway process status and port."""
|
"""Get Gateway process status and port."""
|
||||||
@@ -362,15 +517,10 @@ async def get_gateway_status() -> GatewayStatusResponse:
|
|||||||
run_id = None
|
run_id = None
|
||||||
|
|
||||||
if is_running:
|
if is_running:
|
||||||
# Try to find run_id from runtime state
|
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
||||||
if snapshots:
|
|
||||||
try:
|
try:
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
run_id = _get_active_runtime_context().get("config_name")
|
||||||
run_id = latest.get("context", {}).get("config_name")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to parse latest snapshot: {e}")
|
logger.warning(f"Failed to resolve active runtime context: {e}")
|
||||||
|
|
||||||
return GatewayStatusResponse(
|
return GatewayStatusResponse(
|
||||||
is_running=is_running,
|
is_running=is_running,
|
||||||
@@ -390,6 +540,26 @@ async def get_gateway_port(request: Request) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs", response_model=RuntimeLogResponse)
|
||||||
|
async def get_runtime_logs() -> RuntimeLogResponse:
|
||||||
|
"""Return current runtime log tail, or the latest run log if runtime is stopped."""
|
||||||
|
try:
|
||||||
|
context = _get_active_runtime_context() if _is_gateway_running() else _get_runtime_context_from_latest_snapshot()
|
||||||
|
except HTTPException:
|
||||||
|
return RuntimeLogResponse(is_running=False, content="")
|
||||||
|
|
||||||
|
run_id = str(context.get("config_name") or "").strip() or None
|
||||||
|
log_path = _get_gateway_log_path_for_run(run_id) if run_id else None
|
||||||
|
content = _read_log_tail(log_path) if log_path else ""
|
||||||
|
|
||||||
|
return RuntimeLogResponse(
|
||||||
|
run_id=run_id,
|
||||||
|
is_running=_is_gateway_running(),
|
||||||
|
log_path=str(log_path) if log_path else None,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_gateway_ws_url(request: Request, port: int) -> str:
|
def _build_gateway_ws_url(request: Request, port: int) -> str:
|
||||||
"""Build a proxy-safe Gateway WebSocket URL."""
|
"""Build a proxy-safe Gateway WebSocket URL."""
|
||||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip()
|
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip()
|
||||||
@@ -416,10 +586,23 @@ def _load_latest_runtime_snapshot() -> Dict[str, Any]:
|
|||||||
return json.loads(snapshots[0].read_text(encoding="utf-8"))
|
return json.loads(snapshots[0].read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def _get_current_runtime_context() -> Dict[str, Any]:
|
def _get_active_runtime_snapshot() -> Dict[str, Any]:
|
||||||
"""Return the active runtime context from the latest snapshot."""
|
"""Return the active runtime snapshot, preferring in-memory manager state."""
|
||||||
if not _is_gateway_running():
|
if not _is_gateway_running():
|
||||||
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||||
|
|
||||||
|
manager = _runtime_state.runtime_manager
|
||||||
|
if manager is not None and hasattr(manager, "build_snapshot"):
|
||||||
|
snapshot = manager.build_snapshot()
|
||||||
|
context = snapshot.get("context") or {}
|
||||||
|
if context.get("config_name"):
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
return _load_latest_runtime_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_runtime_context_from_latest_snapshot() -> Dict[str, Any]:
|
||||||
|
"""Return the latest persisted runtime context regardless of active process state."""
|
||||||
latest = _load_latest_runtime_snapshot()
|
latest = _load_latest_runtime_snapshot()
|
||||||
context = latest.get("context") or {}
|
context = latest.get("context") or {}
|
||||||
if not context.get("config_name"):
|
if not context.get("config_name"):
|
||||||
@@ -427,6 +610,35 @@ def _get_current_runtime_context() -> Dict[str, Any]:
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _get_gateway_log_path_for_run(run_id: str) -> Path:
|
||||||
|
return _get_run_dir(run_id) / "logs" / "gateway.log"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_tail(path: Path, max_chars: int = 120_000) -> str:
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
return ""
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
return text
|
||||||
|
return text[-max_chars:]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_runtime_context() -> Dict[str, Any]:
|
||||||
|
"""Return the active runtime context from the latest snapshot."""
|
||||||
|
if not _is_gateway_running():
|
||||||
|
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||||
|
snapshot = _get_active_runtime_snapshot()
|
||||||
|
context = snapshot.get("context") or {}
|
||||||
|
if not context.get("config_name"):
|
||||||
|
raise HTTPException(status_code=404, detail="No runtime context available")
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_runtime_context() -> Dict[str, Any]:
|
||||||
|
"""Return the active runtime context, preferring in-memory runtime manager state."""
|
||||||
|
return _get_current_runtime_context()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse:
|
def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse:
|
||||||
"""Build a normalized runtime config response for the active run."""
|
"""Build a normalized runtime config response for the active run."""
|
||||||
context = _get_current_runtime_context()
|
context = _get_current_runtime_context()
|
||||||
@@ -517,12 +729,30 @@ async def start_runtime(
|
|||||||
_stop_gateway()
|
_stop_gateway()
|
||||||
await asyncio.sleep(1) # Wait for port release
|
await asyncio.sleep(1) # Wait for port release
|
||||||
|
|
||||||
# 2. Generate run ID and directory
|
launch_mode = str(config.launch_mode or "fresh").strip().lower()
|
||||||
|
if launch_mode not in {"fresh", "restore"}:
|
||||||
|
raise HTTPException(status_code=400, detail="launch_mode must be 'fresh' or 'restore'")
|
||||||
|
|
||||||
|
# 2. Resolve run ID, directory, and bootstrap
|
||||||
|
if launch_mode == "restore":
|
||||||
|
restore_run_id = str(config.restore_run_id or "").strip()
|
||||||
|
if not restore_run_id:
|
||||||
|
raise HTTPException(status_code=400, detail="restore_run_id is required when launch_mode=restore")
|
||||||
|
snapshot = _load_run_snapshot(restore_run_id)
|
||||||
|
context = snapshot.get("context") or {}
|
||||||
|
if not context.get("config_name"):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Run context not found: {restore_run_id}")
|
||||||
|
run_id = restore_run_id
|
||||||
|
run_dir = _get_run_dir(run_id)
|
||||||
|
bootstrap = dict(context.get("bootstrap_values") or {})
|
||||||
|
bootstrap["launch_mode"] = "restore"
|
||||||
|
bootstrap["restore_run_id"] = restore_run_id
|
||||||
|
else:
|
||||||
run_id = _generate_run_id()
|
run_id = _generate_run_id()
|
||||||
run_dir = _get_run_dir(run_id)
|
run_dir = _get_run_dir(run_id)
|
||||||
|
|
||||||
# 3. Prepare bootstrap config
|
|
||||||
bootstrap = {
|
bootstrap = {
|
||||||
|
"launch_mode": "fresh",
|
||||||
|
"restore_run_id": None,
|
||||||
"tickers": config.tickers,
|
"tickers": config.tickers,
|
||||||
"schedule_mode": config.schedule_mode,
|
"schedule_mode": config.schedule_mode,
|
||||||
"interval_minutes": config.interval_minutes,
|
"interval_minutes": config.interval_minutes,
|
||||||
@@ -535,9 +765,16 @@ async def start_runtime(
|
|||||||
"start_date": config.start_date,
|
"start_date": config.start_date,
|
||||||
"end_date": config.end_date,
|
"end_date": config.end_date,
|
||||||
"poll_interval": config.poll_interval,
|
"poll_interval": config.poll_interval,
|
||||||
"enable_mock": config.enable_mock,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retention_keep = max(1, int(os.getenv("RUNS_RETENTION_COUNT", "20") or "20"))
|
||||||
|
pruned_run_ids = _prune_old_timestamped_runs(
|
||||||
|
keep=retention_keep,
|
||||||
|
exclude_run_ids={run_id},
|
||||||
|
)
|
||||||
|
if pruned_run_ids:
|
||||||
|
logger.info("Pruned old run directories: %s", ", ".join(pruned_run_ids))
|
||||||
|
|
||||||
# 4. Create runtime manager
|
# 4. Create runtime manager
|
||||||
manager = TradingRuntimeManager(
|
manager = TradingRuntimeManager(
|
||||||
config_name=run_id,
|
config_name=run_id,
|
||||||
@@ -567,11 +804,12 @@ async def start_runtime(
|
|||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
if not _is_gateway_running():
|
if not _is_gateway_running():
|
||||||
stdout, stderr = process.communicate(timeout=1)
|
|
||||||
_runtime_state.gateway_process = None
|
_runtime_state.gateway_process = None
|
||||||
|
log_path = _get_gateway_log_path_for_run(run_id)
|
||||||
|
log_tail = _read_log_tail(log_path, max_chars=4000)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Gateway failed to start: {stderr.decode() if stderr else 'Unknown error'}"
|
detail=f"Gateway failed to start: {log_tail or 'Unknown error'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -637,6 +875,25 @@ async def stop_runtime(force: bool = True) -> StopResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cleanup", response_model=CleanupResponse)
|
||||||
|
async def cleanup_old_runs(keep: int = 20) -> CleanupResponse:
|
||||||
|
"""Prune old timestamped run directories while preserving named runs."""
|
||||||
|
keep_count = max(1, int(keep))
|
||||||
|
exclude: set[str] = set()
|
||||||
|
|
||||||
|
if _is_gateway_running():
|
||||||
|
try:
|
||||||
|
active_context = _get_active_runtime_context()
|
||||||
|
active_run_id = str(active_context.get("config_name") or "").strip()
|
||||||
|
if active_run_id:
|
||||||
|
exclude.add(active_run_id)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pruned = _prune_old_timestamped_runs(keep=keep_count, exclude_run_ids=exclude)
|
||||||
|
return CleanupResponse(status="ok", kept=keep_count, pruned_run_ids=pruned)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/restart")
|
@router.post("/restart")
|
||||||
async def restart_runtime(
|
async def restart_runtime(
|
||||||
config: LaunchConfig,
|
config: LaunchConfig,
|
||||||
@@ -663,15 +920,7 @@ async def get_current_runtime():
|
|||||||
if not _is_gateway_running():
|
if not _is_gateway_running():
|
||||||
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||||
|
|
||||||
# Find latest runtime state
|
context = _get_active_runtime_context()
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
||||||
|
|
||||||
if not snapshots:
|
|
||||||
raise HTTPException(status_code=404, detail="No runtime information available")
|
|
||||||
|
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
|
||||||
context = latest.get("context", {})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"run_id": context.get("config_name"),
|
"run_id": context.get("config_name"),
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ from .agent_service import app as agent_app
|
|||||||
from .agent_service import create_app as create_agent_app
|
from .agent_service import create_app as create_agent_app
|
||||||
from .news_service import app as news_app
|
from .news_service import app as news_app
|
||||||
from .news_service import create_app as create_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 app as runtime_app
|
||||||
from .runtime_service import create_app as create_runtime_app
|
from .runtime_service import create_app as create_runtime_app
|
||||||
from .trading_service import app as trading_app
|
from .trading_service import app as trading_app
|
||||||
from .trading_service import create_app as create_trading_app
|
from .trading_service import create_app as create_trading_app
|
||||||
|
from .cors import add_cors_middleware, get_cors_origins
|
||||||
|
|
||||||
app = agent_app
|
app = agent_app
|
||||||
create_app = create_agent_app
|
create_app = create_agent_app
|
||||||
@@ -20,8 +23,12 @@ __all__ = [
|
|||||||
"create_agent_app",
|
"create_agent_app",
|
||||||
"news_app",
|
"news_app",
|
||||||
"create_news_app",
|
"create_news_app",
|
||||||
|
"openclaw_app",
|
||||||
|
"create_openclaw_app",
|
||||||
"runtime_app",
|
"runtime_app",
|
||||||
"create_runtime_app",
|
"create_runtime_app",
|
||||||
"trading_app",
|
"trading_app",
|
||||||
"create_trading_app",
|
"create_trading_app",
|
||||||
|
"add_cors_middleware",
|
||||||
|
"get_cors_origins",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from pathlib import Path
|
|||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
from backend.apps.cors import add_cors_middleware
|
||||||
|
|
||||||
from backend.api import agents_router, guard_router, workspaces_router
|
from backend.api import agents_router, guard_router, workspaces_router
|
||||||
from backend.agents import AgentFactory, WorkspaceManager, get_registry
|
from backend.agents import AgentFactory, WorkspaceManager, get_registry
|
||||||
@@ -32,28 +33,22 @@ def create_app(project_root: Path | None = None) -> FastAPI:
|
|||||||
agent_factory.workspaces_root.mkdir(parents=True, exist_ok=True)
|
agent_factory.workspaces_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
print("✓ EvoTraders API started")
|
print("✓ 大时代 API started")
|
||||||
print(f" - Workspaces root: {agent_factory.workspaces_root}")
|
print(f" - Workspaces root: {agent_factory.workspaces_root}")
|
||||||
print(f" - Registered agents: {registry.get_agent_count()}")
|
print(f" - Registered agents: {registry.get_agent_count()}")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
print("✓ EvoTraders API shutting down")
|
print("✓ 大时代 API shutting down")
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="EvoTraders Agent Service",
|
title="大时代 Agent Service",
|
||||||
description="REST API for the EvoTraders multi-agent control plane",
|
description="REST API for the 大时代 multi-agent control plane",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
add_cors_middleware(app)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, object]:
|
async def health_check() -> dict[str, object]:
|
||||||
|
|||||||
30
backend/apps/cors.py
Normal file
30
backend/apps/cors.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Shared CORS configuration for all microservice apps."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def get_cors_origins() -> Sequence[str]:
|
||||||
|
"""Get allowed CORS origins from environment variable.
|
||||||
|
|
||||||
|
Defaults to ["*"] for backward compatibility.
|
||||||
|
Set CORS_ALLOWED_ORIGINS env var (comma-separated) in production.
|
||||||
|
"""
|
||||||
|
origins = os.getenv("CORS_ALLOWED_ORIGINS", "").strip()
|
||||||
|
if not origins:
|
||||||
|
return ["*"]
|
||||||
|
return [o.strip() for o in origins.split(",") if o.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def add_cors_middleware(app: "FastAPI") -> None:
|
||||||
|
"""Add CORS middleware to app with environment-configured origins."""
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=get_cors_origins(),
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
@@ -6,32 +6,26 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Query
|
from fastapi import Depends, FastAPI, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from backend.apps.cors import add_cors_middleware
|
||||||
|
|
||||||
from backend.data.market_store import MarketStore
|
from backend.data.market_store import MarketStore
|
||||||
from backend.domains import news as news_domain
|
from backend.domains import news as news_domain
|
||||||
|
|
||||||
|
|
||||||
def get_market_store() -> MarketStore:
|
def get_market_store() -> MarketStore:
|
||||||
"""Create a market store dependency."""
|
"""Get the MarketStore singleton dependency."""
|
||||||
return MarketStore()
|
return MarketStore.get_instance()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create the news/explain service app."""
|
"""Create the news/explain service app."""
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="EvoTraders News Service",
|
title="大时代 News Service",
|
||||||
description="Read-only news enrichment and explain service surface extracted from the monolith",
|
description="Read-only news enrichment and explain service surface extracted from the monolith",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
add_cors_middleware(app)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, str]:
|
async def health_check() -> dict[str, str]:
|
||||||
@@ -51,6 +45,7 @@ def create_app() -> FastAPI:
|
|||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/news-for-date")
|
@app.get("/api/news-for-date")
|
||||||
@@ -65,6 +60,7 @@ def create_app() -> FastAPI:
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
date=date,
|
date=date,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/news-timeline")
|
@app.get("/api/news-timeline")
|
||||||
@@ -79,6 +75,7 @@ def create_app() -> FastAPI:
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/categories")
|
@app.get("/api/categories")
|
||||||
@@ -95,6 +92,7 @@ def create_app() -> FastAPI:
|
|||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/similar-days")
|
@app.get("/api/similar-days")
|
||||||
@@ -109,6 +107,7 @@ def create_app() -> FastAPI:
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
date=date,
|
date=date,
|
||||||
n_similar=n_similar,
|
n_similar=n_similar,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/stories/{ticker}")
|
@app.get("/api/stories/{ticker}")
|
||||||
@@ -121,6 +120,7 @@ def create_app() -> FastAPI:
|
|||||||
store,
|
store,
|
||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
as_of_date=as_of_date,
|
as_of_date=as_of_date,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/range-explain")
|
@app.get("/api/range-explain")
|
||||||
@@ -139,6 +139,7 @@ def create_app() -> FastAPI:
|
|||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
article_ids=article_ids,
|
article_ids=article_ids,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
49
backend/apps/openclaw_service.py
Normal file
49
backend/apps/openclaw_service.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Read-only OpenClaw CLI FastAPI surface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
|
||||||
|
from backend.api import openclaw_router
|
||||||
|
from backend.apps.cors import add_cors_middleware
|
||||||
|
from backend.api.openclaw import get_openclaw_cli_service
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""Create the OpenClaw service app."""
|
||||||
|
app = FastAPI(
|
||||||
|
title="大时代 OpenClaw Service",
|
||||||
|
description="Read-only OpenClaw CLI integration service surface",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_cors_middleware(app)
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check(
|
||||||
|
service=Depends(get_openclaw_cli_service),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return service.health()
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
async def api_status(
|
||||||
|
service=Depends(get_openclaw_cli_service),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"status": "operational",
|
||||||
|
"service": "openclaw-service",
|
||||||
|
"openclaw": service.health(),
|
||||||
|
}
|
||||||
|
|
||||||
|
app.include_router(openclaw_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8004)
|
||||||
@@ -4,27 +4,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from backend.api import runtime_router
|
from backend.api import runtime_router
|
||||||
from backend.api.runtime import get_runtime_state
|
from backend.api.runtime import get_runtime_state
|
||||||
|
from backend.apps.cors import add_cors_middleware
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create the runtime service app."""
|
"""Create the runtime service app."""
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="EvoTraders Runtime Service",
|
title="大时代 Runtime Service",
|
||||||
description="Runtime lifecycle and gateway service surface extracted from the monolith",
|
description="Runtime lifecycle and gateway service surface extracted from the monolith",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
add_cors_middleware(app)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, object]:
|
async def health_check() -> dict[str, object]:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from backend.apps.cors import add_cors_middleware
|
||||||
|
|
||||||
from backend.domains import trading as trading_domain
|
from backend.domains import trading as trading_domain
|
||||||
from shared.schema import (
|
from shared.schema import (
|
||||||
@@ -21,18 +21,12 @@ from shared.schema import (
|
|||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create the trading data service app."""
|
"""Create the trading data service app."""
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="EvoTraders Trading Service",
|
title="大时代 Trading Service",
|
||||||
description="Read-only trading data service surface extracted from the monolith",
|
description="Read-only trading data service surface extracted from the monolith",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
add_cors_middleware(app)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, str]:
|
async def health_check() -> dict[str, str]:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
EvoTraders CLI - Command-line interface for the EvoTraders trading system.
|
大时代 CLI - Command-line interface for the 大时代 trading system.
|
||||||
|
|
||||||
This module provides easy-to-use commands for running backtest, live trading,
|
This module provides easy-to-use commands for running backtest, live trading,
|
||||||
and frontend development server.
|
and frontend development server.
|
||||||
@@ -44,7 +44,7 @@ from backend.enrich.news_enricher import enrich_symbols
|
|||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="evotraders",
|
name="evotraders",
|
||||||
help="EvoTraders: A self-evolving multi-agent trading system",
|
help="大时代:自进化多智能体交易系统",
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
)
|
)
|
||||||
ingest_app = typer.Typer(help="Ingest Polygon market data into the research warehouse.")
|
ingest_app = typer.Typer(help="Ingest Polygon market data into the research warehouse.")
|
||||||
@@ -919,7 +919,7 @@ def backtest(
|
|||||||
"""
|
"""
|
||||||
console.print(
|
console.print(
|
||||||
Panel.fit(
|
Panel.fit(
|
||||||
"[bold cyan]EvoTraders Backtest Mode[/bold cyan]",
|
"[bold cyan]大时代 Backtest Mode[/bold cyan]",
|
||||||
border_style="cyan",
|
border_style="cyan",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1019,11 +1019,6 @@ def backtest(
|
|||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def live(
|
def live(
|
||||||
mock: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
"--mock",
|
|
||||||
help="Use mock mode with simulated prices (for testing)",
|
|
||||||
),
|
|
||||||
config_name: str = typer.Option(
|
config_name: str = typer.Option(
|
||||||
"live",
|
"live",
|
||||||
"--config-name",
|
"--config-name",
|
||||||
@@ -1078,7 +1073,6 @@ def live(
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
evotraders live # Run immediately (default)
|
evotraders live # Run immediately (default)
|
||||||
evotraders live --mock # Mock mode
|
|
||||||
evotraders live -t 22:30 # Run at 22:30 local time daily
|
evotraders live -t 22:30 # Run at 22:30 local time daily
|
||||||
evotraders live --schedule-mode intraday --interval-minutes 60
|
evotraders live --schedule-mode intraday --interval-minutes 60
|
||||||
evotraders live --trigger-time now # Run immediately
|
evotraders live --trigger-time now # Run immediately
|
||||||
@@ -1086,16 +1080,14 @@ def live(
|
|||||||
"""
|
"""
|
||||||
schedule_mode = str(_normalize_typer_value(schedule_mode, "daily"))
|
schedule_mode = str(_normalize_typer_value(schedule_mode, "daily"))
|
||||||
interval_minutes = int(_normalize_typer_value(interval_minutes, 60))
|
interval_minutes = int(_normalize_typer_value(interval_minutes, 60))
|
||||||
mode_name = "MOCK" if mock else "LIVE"
|
|
||||||
console.print(
|
console.print(
|
||||||
Panel.fit(
|
Panel.fit(
|
||||||
f"[bold cyan]EvoTraders {mode_name} Mode[/bold cyan]",
|
"[bold cyan]大时代 LIVE Mode[/bold cyan]",
|
||||||
border_style="cyan",
|
border_style="cyan",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for required API key in live mode
|
# Check for required API key in live mode
|
||||||
if not mock:
|
|
||||||
env_file = get_project_root() / ".env"
|
env_file = get_project_root() / ".env"
|
||||||
if not env_file.exists():
|
if not env_file.exists():
|
||||||
console.print("\n[yellow]Warning: .env file not found[/yellow]")
|
console.print("\n[yellow]Warning: .env file not found[/yellow]")
|
||||||
@@ -1168,9 +1160,6 @@ def live(
|
|||||||
|
|
||||||
# Display configuration
|
# Display configuration
|
||||||
console.print("\n[bold]Configuration:[/bold]")
|
console.print("\n[bold]Configuration:[/bold]")
|
||||||
if mock:
|
|
||||||
console.print(" Mode: [yellow]MOCK[/yellow] (Simulated prices)")
|
|
||||||
else:
|
|
||||||
console.print(
|
console.print(
|
||||||
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
||||||
)
|
)
|
||||||
@@ -1188,8 +1177,7 @@ def live(
|
|||||||
project_root = get_project_root()
|
project_root = get_project_root()
|
||||||
os.chdir(project_root)
|
os.chdir(project_root)
|
||||||
|
|
||||||
# Data update (if not mock mode)
|
# Data update
|
||||||
if not mock:
|
|
||||||
run_data_updater(project_root)
|
run_data_updater(project_root)
|
||||||
auto_update_market_store(
|
auto_update_market_store(
|
||||||
config_name,
|
config_name,
|
||||||
@@ -1200,10 +1188,6 @@ def live(
|
|||||||
end_date=nyse_now.date().isoformat(),
|
end_date=nyse_now.date().isoformat(),
|
||||||
force=False,
|
force=False,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
console.print(
|
|
||||||
"\n[dim]Mock mode enabled - skipping data update[/dim]\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build command using backend.main
|
# Build command using backend.main
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -1229,8 +1213,6 @@ def live(
|
|||||||
str(interval_minutes),
|
str(interval_minutes),
|
||||||
]
|
]
|
||||||
|
|
||||||
if mock:
|
|
||||||
cmd.append("--mock")
|
|
||||||
if enable_memory:
|
if enable_memory:
|
||||||
cmd.append("--enable-memory")
|
cmd.append("--enable-memory")
|
||||||
|
|
||||||
@@ -1269,7 +1251,7 @@ def frontend(
|
|||||||
"""
|
"""
|
||||||
console.print(
|
console.print(
|
||||||
Panel.fit(
|
Panel.fit(
|
||||||
"[bold cyan]EvoTraders Frontend[/bold cyan]",
|
"[bold cyan]大时代 Frontend[/bold cyan]",
|
||||||
border_style="cyan",
|
border_style="cyan",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1337,16 +1319,16 @@ def frontend(
|
|||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def version():
|
def version():
|
||||||
"""Show the version of EvoTraders."""
|
"""Show the version of 大时代."""
|
||||||
console.print(
|
console.print(
|
||||||
"\n[bold cyan]EvoTraders[/bold cyan] version [green]0.1.0[/green]\n",
|
"\n[bold cyan]大时代[/bold cyan] version [green]0.1.0[/green]\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
@app.callback()
|
||||||
def main():
|
def main():
|
||||||
"""
|
"""
|
||||||
EvoTraders: A self-evolving multi-agent trading system
|
大时代:自进化多智能体交易系统
|
||||||
|
|
||||||
Use 'evotraders --help' to see available commands.
|
Use 'evotraders --help' to see available commands.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,6 +4,22 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TICKERS = [
|
||||||
|
"AAPL",
|
||||||
|
"MSFT",
|
||||||
|
"GOOGL",
|
||||||
|
"AMZN",
|
||||||
|
"NVDA",
|
||||||
|
"META",
|
||||||
|
"TSLA",
|
||||||
|
"AMD",
|
||||||
|
"NFLX",
|
||||||
|
"AVGO",
|
||||||
|
"PLTR",
|
||||||
|
"COIN",
|
||||||
|
]
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@@ -127,7 +143,7 @@ def resolve_runtime_config(
|
|||||||
bootstrap = get_bootstrap_config_for_run(project_root, config_name)
|
bootstrap = get_bootstrap_config_for_run(project_root, config_name)
|
||||||
return {
|
return {
|
||||||
"tickers": bootstrap.get("tickers")
|
"tickers": bootstrap.get("tickers")
|
||||||
or get_env_list("TICKERS", ["AAPL", "MSFT"]),
|
or get_env_list("TICKERS", DEFAULT_TICKERS),
|
||||||
"initial_cash": float(
|
"initial_cash": float(
|
||||||
bootstrap.get(
|
bootstrap.get(
|
||||||
"initial_cash",
|
"initial_cash",
|
||||||
|
|||||||
@@ -76,27 +76,19 @@ def _resolve_config() -> DataSourceConfig:
|
|||||||
"""
|
"""
|
||||||
Resolve data source configuration based on available API keys.
|
Resolve data source configuration based on available API keys.
|
||||||
|
|
||||||
Priority:
|
The effective source should always match the first item in the resolved
|
||||||
1. FINNHUB_API_KEY (if set)
|
ordered source list.
|
||||||
2. FINANCIAL_DATASETS_API_KEY (if set)
|
|
||||||
3. Raises error if neither is available
|
|
||||||
"""
|
"""
|
||||||
sources = _ordered_sources()
|
sources = _ordered_sources()
|
||||||
if "finnhub" in sources:
|
source = sources[0] if sources else "local_csv"
|
||||||
return DataSourceConfig(
|
|
||||||
source="finnhub",
|
api_key = ""
|
||||||
api_key=os.getenv("FINNHUB_API_KEY", "").strip(),
|
if source == "finnhub":
|
||||||
sources=sources,
|
api_key = os.getenv("FINNHUB_API_KEY", "").strip()
|
||||||
)
|
elif source == "financial_datasets":
|
||||||
if "financial_datasets" in sources:
|
api_key = os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip()
|
||||||
return DataSourceConfig(
|
|
||||||
source="financial_datasets",
|
return DataSourceConfig(source=source, api_key=api_key, sources=sources)
|
||||||
api_key=os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip(),
|
|
||||||
sources=sources,
|
|
||||||
)
|
|
||||||
if "yfinance" in sources:
|
|
||||||
return DataSourceConfig(source="yfinance", api_key="", sources=sources)
|
|
||||||
return DataSourceConfig(source="local_csv", api_key="", sources=sources)
|
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> DataSourceConfig:
|
def get_config() -> DataSourceConfig:
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from agentscope.message import Msg
|
|||||||
from agentscope.pipeline import MsgHub
|
from agentscope.pipeline import MsgHub
|
||||||
|
|
||||||
from backend.utils.settlement import SettlementCoordinator
|
from backend.utils.settlement import SettlementCoordinator
|
||||||
from backend.utils.terminal_dashboard import get_dashboard
|
|
||||||
from backend.core.state_sync import StateSync
|
from backend.core.state_sync import StateSync
|
||||||
from backend.utils.trade_executor import PortfolioTradeExecutor
|
from backend.utils.trade_executor import PortfolioTradeExecutor
|
||||||
from backend.runtime.manager import TradingRuntimeManager
|
from backend.runtime.manager import TradingRuntimeManager
|
||||||
@@ -48,12 +47,8 @@ except ImportError:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _log(msg: str):
|
def _log(msg: str) -> None:
|
||||||
"""Log to dashboard if available, otherwise to logger"""
|
"""Helper function for pipeline logging."""
|
||||||
dashboard = get_dashboard()
|
|
||||||
if dashboard.live:
|
|
||||||
dashboard.log(msg)
|
|
||||||
else:
|
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +66,7 @@ class TradingPipeline:
|
|||||||
|
|
||||||
Real-time updates via StateSync after each agent completes.
|
Real-time updates via StateSync after each agent completes.
|
||||||
|
|
||||||
Supports both legacy agent lists and new workspace-based agent loading.
|
Supports both legacy agent lists and run-scoped agent loading.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -1625,14 +1620,13 @@ class TradingPipeline:
|
|||||||
project_root = Path(__file__).resolve().parents[2]
|
project_root = Path(__file__).resolve().parents[2]
|
||||||
personas = get_prompt_loader().load_yaml_config("analyst", "personas")
|
personas = get_prompt_loader().load_yaml_config("analyst", "personas")
|
||||||
persona = personas.get(analyst_type, {})
|
persona = personas.get(analyst_type, {})
|
||||||
WorkspaceManager(project_root=project_root).ensure_agent_assets(
|
workspace_manager = WorkspaceManager(project_root=project_root)
|
||||||
|
workspace_manager.ensure_agent_assets(
|
||||||
config_name=config_name,
|
config_name=config_name,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
role_seed=persona.get("description", "").strip(),
|
file_contents=workspace_manager.build_default_agent_files(
|
||||||
style_seed="\n".join(f"- {item}" for item in persona.get("focus", [])),
|
agent_id=agent_id,
|
||||||
policy_seed=(
|
persona=persona,
|
||||||
"State a clear signal, confidence, and the conditions "
|
|
||||||
"that would invalidate the thesis."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ async def run_pipeline(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Extract config values
|
# Extract config values
|
||||||
tickers = bootstrap.get("tickers", ["AAPL", "MSFT"])
|
tickers = bootstrap.get("tickers", ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AMD", "NFLX", "AVGO", "PLTR", "COIN"])
|
||||||
initial_cash = float(bootstrap.get("initial_cash", 100000.0))
|
initial_cash = float(bootstrap.get("initial_cash", 100000.0))
|
||||||
margin_requirement = float(bootstrap.get("margin_requirement", 0.0))
|
margin_requirement = float(bootstrap.get("margin_requirement", 0.0))
|
||||||
max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2))
|
max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2))
|
||||||
@@ -244,10 +244,8 @@ async def run_pipeline(
|
|||||||
start_date = bootstrap.get("start_date")
|
start_date = bootstrap.get("start_date")
|
||||||
end_date = bootstrap.get("end_date")
|
end_date = bootstrap.get("end_date")
|
||||||
enable_memory = bootstrap.get("enable_memory", False)
|
enable_memory = bootstrap.get("enable_memory", False)
|
||||||
enable_mock = bootstrap.get("enable_mock", False)
|
|
||||||
|
|
||||||
is_backtest = mode == "backtest"
|
is_backtest = mode == "backtest"
|
||||||
is_mock = enable_mock or mode == "mock" or (not is_backtest and os.getenv("MOCK_MODE", "false").lower() == "true")
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# PHASE 0: Initialize runtime manager
|
# PHASE 0: Initialize runtime manager
|
||||||
@@ -266,10 +264,6 @@ async def run_pipeline(
|
|||||||
|
|
||||||
set_global_runtime_manager(runtime_manager)
|
set_global_runtime_manager(runtime_manager)
|
||||||
|
|
||||||
# Register runtime manager with API
|
|
||||||
from backend.api.runtime import register_runtime_manager
|
|
||||||
register_runtime_manager(runtime_manager)
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# PHASE 1 & 2: Create infrastructure services (Market, Storage)
|
# PHASE 1 & 2: Create infrastructure services (Market, Storage)
|
||||||
# These will be started by Gateway in the correct order
|
# These will be started by Gateway in the correct order
|
||||||
@@ -292,9 +286,8 @@ async def run_pipeline(
|
|||||||
market_service = MarketService(
|
market_service = MarketService(
|
||||||
tickers=tickers,
|
tickers=tickers,
|
||||||
poll_interval=10,
|
poll_interval=10,
|
||||||
mock_mode=is_mock and not is_backtest,
|
|
||||||
backtest_mode=is_backtest,
|
backtest_mode=is_backtest,
|
||||||
api_key=os.getenv("FINNHUB_API_KEY") if not is_mock and not is_backtest else None,
|
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None,
|
||||||
backtest_start_date=start_date if is_backtest else None,
|
backtest_start_date=start_date if is_backtest else None,
|
||||||
backtest_end_date=end_date if is_backtest else None,
|
backtest_end_date=end_date if is_backtest else None,
|
||||||
)
|
)
|
||||||
@@ -391,7 +384,6 @@ async def run_pipeline(
|
|||||||
scheduler_callback=scheduler_callback,
|
scheduler_callback=scheduler_callback,
|
||||||
config={
|
config={
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"mock_mode": is_mock,
|
|
||||||
"backtest_mode": is_backtest,
|
"backtest_mode": is_backtest,
|
||||||
"tickers": tickers,
|
"tickers": tickers,
|
||||||
"config_name": run_id,
|
"config_name": run_id,
|
||||||
|
|||||||
@@ -465,7 +465,6 @@ class StateSync:
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"server_mode": self._state.get("server_mode", "live"),
|
"server_mode": self._state.get("server_mode", "live"),
|
||||||
"is_mock_mode": self._state.get("is_mock_mode", False),
|
|
||||||
"is_backtest": self._state.get("is_backtest", False),
|
"is_backtest": self._state.get("is_backtest", False),
|
||||||
"tickers": self._state.get("tickers"),
|
"tickers": self._state.get("tickers"),
|
||||||
"runtime_config": self._state.get("runtime_config"),
|
"runtime_config": self._state.get("runtime_config"),
|
||||||
@@ -488,12 +487,13 @@ class StateSync:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if include_dashboard:
|
if include_dashboard:
|
||||||
|
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self._state)
|
||||||
payload["dashboard"] = {
|
payload["dashboard"] = {
|
||||||
"summary": self.storage.load_file("summary"),
|
"summary": dashboard_snapshot.get("summary"),
|
||||||
"holdings": self.storage.load_file("holdings"),
|
"holdings": dashboard_snapshot.get("holdings"),
|
||||||
"stats": self.storage.load_file("stats"),
|
"stats": dashboard_snapshot.get("stats"),
|
||||||
"trades": self.storage.load_file("trades"),
|
"trades": dashboard_snapshot.get("trades"),
|
||||||
"leaderboard": self.storage.load_file("leaderboard"),
|
"leaderboard": dashboard_snapshot.get("leaderboard"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from backend.data.historical_price_manager import HistoricalPriceManager
|
from backend.data.historical_price_manager import HistoricalPriceManager
|
||||||
from backend.data.mock_price_manager import MockPriceManager
|
|
||||||
from backend.data.polling_price_manager import PollingPriceManager
|
from backend.data.polling_price_manager import PollingPriceManager
|
||||||
|
|
||||||
__all__ = ["MockPriceManager", "PollingPriceManager", "HistoricalPriceManager"]
|
__all__ = ["PollingPriceManager", "HistoricalPriceManager"]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Iterable
|
|||||||
|
|
||||||
from backend.data.market_store import MarketStore
|
from backend.data.market_store import MarketStore
|
||||||
from backend.data.news_alignment import align_news_for_symbol
|
from backend.data.news_alignment import align_news_for_symbol
|
||||||
|
from backend.data.provider_router import DataProviderRouter
|
||||||
from backend.data.polygon_client import (
|
from backend.data.polygon_client import (
|
||||||
fetch_news,
|
fetch_news,
|
||||||
fetch_ohlc,
|
fetch_ohlc,
|
||||||
@@ -24,6 +25,65 @@ def _default_start(years: int = 2) -> str:
|
|||||||
return (datetime.now(timezone.utc).date() - timedelta(days=years * 366)).isoformat()
|
return (datetime.now(timezone.utc).date() - timedelta(days=years * 366)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _max_news_date(news_rows: Iterable[dict]) -> str | None:
|
||||||
|
dates = [
|
||||||
|
str(item.get("published_utc") or "").strip()[:10]
|
||||||
|
for item in news_rows
|
||||||
|
if str(item.get("published_utc") or "").strip()
|
||||||
|
]
|
||||||
|
dates = [value for value in dates if value]
|
||||||
|
return max(dates) if dates else None
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_last_news_fetch(
|
||||||
|
market_store: MarketStore,
|
||||||
|
*,
|
||||||
|
ticker: str,
|
||||||
|
end_date: str,
|
||||||
|
watermark_value: str | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Clamp stale/future watermarks to the latest actually stored news date."""
|
||||||
|
raw = str(watermark_value or "").strip()[:10]
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if raw <= end_date:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
latest_stored = market_store.get_latest_news_date(ticker)
|
||||||
|
if latest_stored and latest_stored <= end_date:
|
||||||
|
return latest_stored
|
||||||
|
return end_date
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_provider_news_rows(ticker: str, news_items: Iterable[Any]) -> list[dict]:
|
||||||
|
rows: list[dict] = []
|
||||||
|
for item in news_items:
|
||||||
|
payload = item.model_dump() if hasattr(item, "model_dump") else dict(item or {})
|
||||||
|
related = payload.get("related")
|
||||||
|
if isinstance(related, str):
|
||||||
|
related_list = [value.strip().upper() for value in related.split(",") if value.strip()]
|
||||||
|
elif isinstance(related, list):
|
||||||
|
related_list = [str(value).strip().upper() for value in related if str(value).strip()]
|
||||||
|
else:
|
||||||
|
related_list = []
|
||||||
|
if ticker not in related_list:
|
||||||
|
related_list.append(ticker)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"title": payload.get("title"),
|
||||||
|
"description": payload.get("summary"),
|
||||||
|
"summary": payload.get("summary"),
|
||||||
|
"article_url": payload.get("url"),
|
||||||
|
"published_utc": payload.get("date"),
|
||||||
|
"publisher": payload.get("source"),
|
||||||
|
"tickers": related_list,
|
||||||
|
"category": payload.get("category"),
|
||||||
|
"raw_json": payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def ingest_ticker_history(
|
def ingest_ticker_history(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
*,
|
*,
|
||||||
@@ -50,7 +110,11 @@ def ingest_ticker_history(
|
|||||||
price_count = market_store.upsert_ohlc(ticker, ohlc_rows, source="polygon")
|
price_count = market_store.upsert_ohlc(ticker, ohlc_rows, source="polygon")
|
||||||
news_count = market_store.upsert_news(ticker, news_rows, source="polygon")
|
news_count = market_store.upsert_news(ticker, news_rows, source="polygon")
|
||||||
aligned_count = align_news_for_symbol(market_store, ticker)
|
aligned_count = align_news_for_symbol(market_store, ticker)
|
||||||
market_store.update_fetch_watermark(symbol=ticker, price_date=end, news_date=end)
|
market_store.update_fetch_watermark(
|
||||||
|
symbol=ticker,
|
||||||
|
price_date=end,
|
||||||
|
news_date=_max_news_date(news_rows),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"symbol": ticker,
|
"symbol": ticker,
|
||||||
@@ -78,9 +142,15 @@ def update_ticker_incremental(
|
|||||||
if watermarks.get("last_price_fetch")
|
if watermarks.get("last_price_fetch")
|
||||||
else _default_start()
|
else _default_start()
|
||||||
)
|
)
|
||||||
|
effective_last_news_fetch = _effective_last_news_fetch(
|
||||||
|
market_store,
|
||||||
|
ticker=ticker,
|
||||||
|
end_date=end,
|
||||||
|
watermark_value=watermarks.get("last_news_fetch"),
|
||||||
|
)
|
||||||
start_news = (
|
start_news = (
|
||||||
(datetime.fromisoformat(watermarks["last_news_fetch"]) + timedelta(days=1)).date().isoformat()
|
(datetime.fromisoformat(effective_last_news_fetch) + timedelta(days=1)).date().isoformat()
|
||||||
if watermarks.get("last_news_fetch")
|
if effective_last_news_fetch
|
||||||
else _default_start()
|
else _default_start()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,7 +170,7 @@ def update_ticker_incremental(
|
|||||||
market_store.update_fetch_watermark(
|
market_store.update_fetch_watermark(
|
||||||
symbol=ticker,
|
symbol=ticker,
|
||||||
price_date=end if ohlc_rows or watermarks.get("last_price_fetch") else None,
|
price_date=end if ohlc_rows or watermarks.get("last_price_fetch") else None,
|
||||||
news_date=end if news_rows or watermarks.get("last_news_fetch") else None,
|
news_date=_max_news_date(news_rows),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -114,6 +184,86 @@ def update_ticker_incremental(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_news_incremental(
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
end_date: str | None = None,
|
||||||
|
store: MarketStore | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Incrementally fetch company news using the configured provider router."""
|
||||||
|
ticker = normalize_symbol(symbol)
|
||||||
|
market_store = store or MarketStore()
|
||||||
|
watermarks = market_store.get_ticker_watermarks(ticker)
|
||||||
|
end = end_date or _today_utc()
|
||||||
|
effective_last_news_fetch = _effective_last_news_fetch(
|
||||||
|
market_store,
|
||||||
|
ticker=ticker,
|
||||||
|
end_date=end,
|
||||||
|
watermark_value=watermarks.get("last_news_fetch"),
|
||||||
|
)
|
||||||
|
start_news = (
|
||||||
|
(datetime.fromisoformat(effective_last_news_fetch) + timedelta(days=1)).date().isoformat()
|
||||||
|
if effective_last_news_fetch
|
||||||
|
else _default_start()
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_news > end:
|
||||||
|
return {
|
||||||
|
"symbol": ticker,
|
||||||
|
"start_news_date": start_news,
|
||||||
|
"end_date": end,
|
||||||
|
"news": 0,
|
||||||
|
"aligned": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
router = DataProviderRouter()
|
||||||
|
news_items, source = router.get_company_news(
|
||||||
|
ticker=ticker,
|
||||||
|
start_date=start_news,
|
||||||
|
end_date=end,
|
||||||
|
limit=1000,
|
||||||
|
)
|
||||||
|
news_rows = _normalize_provider_news_rows(ticker, news_items)
|
||||||
|
news_count = market_store.upsert_news(ticker, news_rows, source=source) if news_rows else 0
|
||||||
|
aligned_count = align_news_for_symbol(market_store, ticker)
|
||||||
|
market_store.update_fetch_watermark(
|
||||||
|
symbol=ticker,
|
||||||
|
news_date=_max_news_date(news_rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"symbol": ticker,
|
||||||
|
"start_news_date": start_news,
|
||||||
|
"end_date": end,
|
||||||
|
"news": news_count,
|
||||||
|
"aligned": aligned_count,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_news_for_symbols(
|
||||||
|
symbols: Iterable[str],
|
||||||
|
*,
|
||||||
|
end_date: str | None = None,
|
||||||
|
store: MarketStore | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Incrementally refresh company news for a list of tickers."""
|
||||||
|
market_store = store or MarketStore()
|
||||||
|
results = []
|
||||||
|
for symbol in symbols:
|
||||||
|
ticker = normalize_symbol(symbol)
|
||||||
|
if not ticker:
|
||||||
|
continue
|
||||||
|
results.append(
|
||||||
|
refresh_news_incremental(
|
||||||
|
ticker,
|
||||||
|
end_date=end_date,
|
||||||
|
store=market_store,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def ingest_symbols(
|
def ingest_symbols(
|
||||||
symbols: Iterable[str],
|
symbols: Iterable[str],
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import os
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
@@ -147,12 +147,30 @@ def _utc_timestamp() -> str:
|
|||||||
|
|
||||||
|
|
||||||
class MarketStore:
|
class MarketStore:
|
||||||
"""SQLite-backed market research warehouse."""
|
"""SQLite-backed market research warehouse. Use get_instance() for the singleton."""
|
||||||
|
|
||||||
|
_instance: Optional["MarketStore"] = None
|
||||||
|
|
||||||
|
def __new__(cls, db_path: Path | None = None) -> "MarketStore":
|
||||||
|
if cls._instance is not None:
|
||||||
|
if db_path is None or cls._instance.db_path == Path(db_path or get_market_db_path()):
|
||||||
|
return cls._instance
|
||||||
|
instance = super().__new__(cls)
|
||||||
|
cls._instance = instance
|
||||||
|
return instance
|
||||||
|
|
||||||
def __init__(self, db_path: Path | None = None):
|
def __init__(self, db_path: Path | None = None):
|
||||||
|
if getattr(self, "_initialized", False):
|
||||||
|
return
|
||||||
self.db_path = Path(db_path or get_market_db_path())
|
self.db_path = Path(db_path or get_market_db_path())
|
||||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls, db_path: Path | None = None) -> "MarketStore":
|
||||||
|
"""Get the MarketStore singleton instance."""
|
||||||
|
return cls(db_path)
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(self.db_path)
|
conn = sqlite3.connect(self.db_path)
|
||||||
@@ -226,6 +244,20 @@ class MarketStore:
|
|||||||
"last_news_fetch": None,
|
"last_news_fetch": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_latest_news_date(self, symbol: str) -> str | None:
|
||||||
|
"""Return the latest stored published news date for one ticker."""
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT MAX(substr(nr.published_utc, 1, 10)) AS latest_date
|
||||||
|
FROM news_ticker nt
|
||||||
|
JOIN news_raw nr ON nr.id = nt.news_id
|
||||||
|
WHERE nt.symbol = ?
|
||||||
|
""",
|
||||||
|
(symbol,),
|
||||||
|
).fetchone()
|
||||||
|
return str(row["latest_date"]).strip() if row and row["latest_date"] else None
|
||||||
|
|
||||||
def upsert_ohlc(self, symbol: str, rows: Iterable[dict[str, Any]], *, source: str = "polygon") -> int:
|
def upsert_ohlc(self, symbol: str, rows: Iterable[dict[str, Any]], *, source: str = "polygon") -> int:
|
||||||
timestamp = _utc_timestamp()
|
timestamp = _utc_timestamp()
|
||||||
count = 0
|
count = 0
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Mock Price Manager - For testing during non-trading hours
|
|
||||||
Generates virtual real-time price data
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Callable, Dict, List, Optional
|
|
||||||
from backend.data.provider_utils import normalize_symbol
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MockPriceManager:
|
|
||||||
"""Mock Price Manager - Generates virtual prices for testing"""
|
|
||||||
|
|
||||||
def __init__(self, poll_interval: int = 10, volatility: float = 0.5):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
poll_interval: Price update interval in seconds
|
|
||||||
volatility: Price volatility percentage
|
|
||||||
"""
|
|
||||||
if poll_interval is None:
|
|
||||||
poll_interval = int(os.getenv("MOCK_POLL_INTERVAL", "5"))
|
|
||||||
if volatility is None:
|
|
||||||
volatility = float(os.getenv("MOCK_VOLATILITY", "0.5"))
|
|
||||||
|
|
||||||
self.poll_interval = poll_interval
|
|
||||||
self.volatility = volatility
|
|
||||||
|
|
||||||
self.subscribed_symbols: List[str] = []
|
|
||||||
self.base_prices: Dict[str, float] = {}
|
|
||||||
self.open_prices: Dict[str, float] = {}
|
|
||||||
self.latest_prices: Dict[str, float] = {}
|
|
||||||
self.price_callbacks: List[Callable] = []
|
|
||||||
|
|
||||||
self.running = False
|
|
||||||
self._thread: Optional[threading.Thread] = None
|
|
||||||
|
|
||||||
self.default_base_prices = {
|
|
||||||
"AAPL": 237.50,
|
|
||||||
"MSFT": 425.30,
|
|
||||||
"GOOGL": 161.50,
|
|
||||||
"AMZN": 218.45,
|
|
||||||
"NVDA": 950.00,
|
|
||||||
"META": 573.22,
|
|
||||||
"TSLA": 342.15,
|
|
||||||
"AMD": 168.90,
|
|
||||||
"NFLX": 688.25,
|
|
||||||
"INTC": 42.18,
|
|
||||||
"COIN": 285.50,
|
|
||||||
"PLTR": 45.80,
|
|
||||||
"BABA": 88.30,
|
|
||||||
"DIS": 112.50,
|
|
||||||
"BKNG": 4850.00,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"MockPriceManager initialized (interval: {self.poll_interval}s, "
|
|
||||||
f"volatility: {self.volatility}%)",
|
|
||||||
)
|
|
||||||
|
|
||||||
def subscribe(
|
|
||||||
self,
|
|
||||||
symbols: List[str],
|
|
||||||
base_prices: Dict[str, float] = None,
|
|
||||||
):
|
|
||||||
"""Subscribe to stock symbols"""
|
|
||||||
for symbol in symbols:
|
|
||||||
symbol = normalize_symbol(symbol)
|
|
||||||
if symbol not in self.subscribed_symbols:
|
|
||||||
self.subscribed_symbols.append(symbol)
|
|
||||||
|
|
||||||
if base_prices and symbol in base_prices:
|
|
||||||
base_price = base_prices[symbol]
|
|
||||||
elif symbol in self.default_base_prices:
|
|
||||||
base_price = self.default_base_prices[symbol]
|
|
||||||
else:
|
|
||||||
base_price = random.uniform(50, 500)
|
|
||||||
|
|
||||||
self.base_prices[symbol] = base_price
|
|
||||||
self.open_prices[symbol] = base_price
|
|
||||||
self.latest_prices[symbol] = base_price
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Subscribed to mock price: {symbol} (base: ${base_price:.2f})", # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
def unsubscribe(self, symbols: List[str]):
|
|
||||||
"""Unsubscribe from symbols"""
|
|
||||||
for symbol in symbols:
|
|
||||||
symbol = normalize_symbol(symbol)
|
|
||||||
if symbol in self.subscribed_symbols:
|
|
||||||
self.subscribed_symbols.remove(symbol)
|
|
||||||
self.base_prices.pop(symbol, None)
|
|
||||||
self.open_prices.pop(symbol, None)
|
|
||||||
self.latest_prices.pop(symbol, None)
|
|
||||||
logger.info(f"Unsubscribed: {symbol}")
|
|
||||||
|
|
||||||
def add_price_callback(self, callback: Callable):
|
|
||||||
"""Add price update callback"""
|
|
||||||
self.price_callbacks.append(callback)
|
|
||||||
|
|
||||||
def _generate_price_update(self, symbol: str) -> float:
|
|
||||||
"""Generate price update based on random walk"""
|
|
||||||
current_price = self.latest_prices.get(
|
|
||||||
symbol,
|
|
||||||
self.base_prices[symbol],
|
|
||||||
)
|
|
||||||
|
|
||||||
change_percent = random.uniform(-self.volatility, self.volatility)
|
|
||||||
new_price = current_price * (1 + change_percent / 100)
|
|
||||||
|
|
||||||
# 10% chance of larger movement
|
|
||||||
if random.random() < 0.1:
|
|
||||||
trend_factor = random.uniform(-2, 2)
|
|
||||||
new_price = new_price * (1 + trend_factor / 100)
|
|
||||||
|
|
||||||
# Limit intraday movement to +/-10%
|
|
||||||
open_price = self.open_prices[symbol]
|
|
||||||
max_price = open_price * 1.10
|
|
||||||
min_price = open_price * 0.90
|
|
||||||
new_price = max(min_price, min(max_price, new_price))
|
|
||||||
|
|
||||||
return new_price
|
|
||||||
|
|
||||||
def _update_prices(self):
|
|
||||||
"""Update prices for all subscribed stocks"""
|
|
||||||
timestamp = int(time.time() * 1000)
|
|
||||||
|
|
||||||
for symbol in self.subscribed_symbols:
|
|
||||||
try:
|
|
||||||
new_price = self._generate_price_update(symbol)
|
|
||||||
self.latest_prices[symbol] = new_price
|
|
||||||
|
|
||||||
open_price = self.open_prices[symbol]
|
|
||||||
ret = ((new_price - open_price) / open_price) * 100
|
|
||||||
|
|
||||||
price_data = {
|
|
||||||
"symbol": symbol,
|
|
||||||
"price": new_price,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"volume": random.randint(1000000, 10000000),
|
|
||||||
"open": open_price,
|
|
||||||
"high": max(new_price, open_price),
|
|
||||||
"low": min(new_price, open_price),
|
|
||||||
"previous_close": open_price,
|
|
||||||
"ret": ret,
|
|
||||||
}
|
|
||||||
|
|
||||||
for callback in self.price_callbacks:
|
|
||||||
try:
|
|
||||||
callback(price_data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Mock price callback error ({symbol}): {e}",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Mock {symbol}: ${new_price:.2f} [ret: {ret:+.2f}%]",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to generate mock price ({symbol}): {e}")
|
|
||||||
|
|
||||||
def _polling_loop(self):
|
|
||||||
"""Main polling loop"""
|
|
||||||
logger.info(
|
|
||||||
f"Mock price generation started (interval: {self.poll_interval}s)",
|
|
||||||
)
|
|
||||||
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
start_time = time.time()
|
|
||||||
self._update_prices()
|
|
||||||
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
sleep_time = max(0, self.poll_interval - elapsed)
|
|
||||||
if sleep_time > 0:
|
|
||||||
time.sleep(sleep_time)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Mock polling loop error: {e}")
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""Start mock price generation"""
|
|
||||||
if self.running:
|
|
||||||
logger.warning("Mock price manager already running")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.subscribed_symbols:
|
|
||||||
logger.warning("No stocks subscribed")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.running = True
|
|
||||||
self._thread = threading.Thread(target=self._polling_loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Mock price manager started: {', '.join(self.subscribed_symbols)}", # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Stop mock price generation"""
|
|
||||||
self.running = False
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=5)
|
|
||||||
logger.info("Mock price manager stopped")
|
|
||||||
|
|
||||||
def get_latest_price(self, symbol: str) -> Optional[float]:
|
|
||||||
"""Get latest price for symbol"""
|
|
||||||
return self.latest_prices.get(symbol)
|
|
||||||
|
|
||||||
def get_all_latest_prices(self) -> Dict[str, float]:
|
|
||||||
"""Get all latest prices"""
|
|
||||||
return self.latest_prices.copy()
|
|
||||||
|
|
||||||
def get_open_price(self, symbol: str) -> Optional[float]:
|
|
||||||
"""Get open price for symbol"""
|
|
||||||
return self.open_prices.get(symbol)
|
|
||||||
|
|
||||||
def reset_open_prices(self):
|
|
||||||
"""Reset open prices for new trading day"""
|
|
||||||
for symbol in self.subscribed_symbols:
|
|
||||||
last_close = self.latest_prices[symbol]
|
|
||||||
gap_percent = random.uniform(-1, 1)
|
|
||||||
new_open = last_close * (1 + gap_percent / 100)
|
|
||||||
self.open_prices[symbol] = new_open
|
|
||||||
self.latest_prices[symbol] = new_open
|
|
||||||
logger.info("Open prices reset")
|
|
||||||
|
|
||||||
def set_base_price(self, symbol: str, price: float):
|
|
||||||
"""Manually set base price for testing"""
|
|
||||||
if symbol in self.subscribed_symbols:
|
|
||||||
self.base_prices[symbol] = price
|
|
||||||
self.open_prices[symbol] = price
|
|
||||||
self.latest_prices[symbol] = price
|
|
||||||
logger.info(f"{symbol} base price set to: ${price:.2f}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"{symbol} not subscribed")
|
|
||||||
@@ -15,6 +15,9 @@ from backend.data.provider_utils import normalize_symbol
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_SUPPRESSED_LOG_EVERY = 20
|
||||||
|
|
||||||
|
|
||||||
class PollingPriceManager:
|
class PollingPriceManager:
|
||||||
"""Polling-based price manager using Finnhub or yfinance."""
|
"""Polling-based price manager using Finnhub or yfinance."""
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ class PollingPriceManager:
|
|||||||
self.latest_prices: Dict[str, float] = {}
|
self.latest_prices: Dict[str, float] = {}
|
||||||
self.open_prices: Dict[str, float] = {}
|
self.open_prices: Dict[str, float] = {}
|
||||||
self.price_callbacks: List[Callable] = []
|
self.price_callbacks: List[Callable] = []
|
||||||
|
self._failure_counts: Dict[str, int] = {}
|
||||||
|
|
||||||
self.running = False
|
self.running = False
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
@@ -77,6 +81,8 @@ class PollingPriceManager:
|
|||||||
for symbol in self.subscribed_symbols:
|
for symbol in self.subscribed_symbols:
|
||||||
try:
|
try:
|
||||||
quote_data = self._fetch_quote(symbol)
|
quote_data = self._fetch_quote(symbol)
|
||||||
|
if not isinstance(quote_data, dict):
|
||||||
|
raise ValueError(f"{symbol}: Empty quote payload")
|
||||||
|
|
||||||
current_price = quote_data.get("c")
|
current_price = quote_data.get("c")
|
||||||
open_price = quote_data.get("o")
|
open_price = quote_data.get("o")
|
||||||
@@ -103,6 +109,13 @@ class PollingPriceManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.latest_prices[symbol] = current_price
|
self.latest_prices[symbol] = current_price
|
||||||
|
previous_failures = self._failure_counts.pop(symbol, 0)
|
||||||
|
if previous_failures > 0:
|
||||||
|
logger.info(
|
||||||
|
"%s quote polling recovered after %d consecutive failures",
|
||||||
|
symbol,
|
||||||
|
previous_failures,
|
||||||
|
)
|
||||||
|
|
||||||
price_data = {
|
price_data = {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
@@ -128,7 +141,20 @@ class PollingPriceManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch {symbol} price: {e}")
|
failure_count = self._failure_counts.get(symbol, 0) + 1
|
||||||
|
self._failure_counts[symbol] = failure_count
|
||||||
|
message = f"Failed to fetch {symbol} price: {e}"
|
||||||
|
|
||||||
|
if failure_count == 1:
|
||||||
|
logger.warning(message)
|
||||||
|
elif failure_count % _SUPPRESSED_LOG_EVERY == 0:
|
||||||
|
logger.warning(
|
||||||
|
"%s (repeated %d times; suppressing intermediate failures)",
|
||||||
|
message,
|
||||||
|
failure_count,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(message)
|
||||||
|
|
||||||
def _fetch_quote(self, symbol: str) -> Dict[str, float]:
|
def _fetch_quote(self, symbol: str) -> Dict[str, float]:
|
||||||
"""Fetch a normalized quote payload from the configured provider."""
|
"""Fetch a normalized quote payload from the configured provider."""
|
||||||
@@ -136,7 +162,10 @@ class PollingPriceManager:
|
|||||||
return self._fetch_yfinance_quote(symbol)
|
return self._fetch_yfinance_quote(symbol)
|
||||||
if not self.finnhub_client:
|
if not self.finnhub_client:
|
||||||
raise ValueError("Finnhub API key required for finnhub polling")
|
raise ValueError("Finnhub API key required for finnhub polling")
|
||||||
return self.finnhub_client.quote(symbol)
|
quote = self.finnhub_client.quote(symbol)
|
||||||
|
if not isinstance(quote, dict):
|
||||||
|
raise ValueError(f"{symbol}: Invalid Finnhub quote payload")
|
||||||
|
return quote
|
||||||
|
|
||||||
def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]:
|
def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]:
|
||||||
"""Fetch quote data from yfinance and normalize to Finnhub-like keys."""
|
"""Fetch quote data from yfinance and normalize to Finnhub-like keys."""
|
||||||
@@ -162,6 +191,8 @@ class PollingPriceManager:
|
|||||||
|
|
||||||
if current_price is None:
|
if current_price is None:
|
||||||
history = ticker.history(period="1d", interval="1m", auto_adjust=False)
|
history = ticker.history(period="1d", interval="1m", auto_adjust=False)
|
||||||
|
if history is None:
|
||||||
|
raise ValueError(f"{symbol}: yfinance returned no history frame")
|
||||||
if history.empty:
|
if history.empty:
|
||||||
raise ValueError(f"{symbol}: No yfinance quote data")
|
raise ValueError(f"{symbol}: No yfinance quote data")
|
||||||
latest = history.iloc[-1]
|
latest = history.iloc[-1]
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def ensure_news_fresh(
|
|||||||
*,
|
*,
|
||||||
ticker: str,
|
ticker: str,
|
||||||
target_date: str | None = None,
|
target_date: str | None = None,
|
||||||
|
refresh_if_stale: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Refresh raw news incrementally when stored watermarks are stale."""
|
"""Refresh raw news incrementally when stored watermarks are stale."""
|
||||||
normalized_target = str(target_date or "").strip()[:10]
|
normalized_target = str(target_date or "").strip()[:10]
|
||||||
@@ -44,7 +45,7 @@ def ensure_news_fresh(
|
|||||||
watermarks = store.get_ticker_watermarks(ticker)
|
watermarks = store.get_ticker_watermarks(ticker)
|
||||||
last_news_fetch = str(watermarks.get("last_news_fetch") or "").strip()[:10]
|
last_news_fetch = str(watermarks.get("last_news_fetch") or "").strip()[:10]
|
||||||
refreshed = False
|
refreshed = False
|
||||||
if not last_news_fetch or last_news_fetch < normalized_target:
|
if refresh_if_stale and (not last_news_fetch or last_news_fetch < normalized_target):
|
||||||
update_ticker_incremental(
|
update_ticker_incremental(
|
||||||
ticker,
|
ticker,
|
||||||
end_date=normalized_target,
|
end_date=normalized_target,
|
||||||
@@ -69,8 +70,14 @@ def get_enriched_news(
|
|||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=end_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
rows = store.get_news_items_enriched(
|
rows = store.get_news_items_enriched(
|
||||||
ticker,
|
ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
@@ -100,8 +107,14 @@ def get_news_for_date(
|
|||||||
ticker: str,
|
ticker: str,
|
||||||
date: str,
|
date: str,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
rows = store.get_news_items_enriched(
|
rows = store.get_news_items_enriched(
|
||||||
ticker,
|
ticker,
|
||||||
trade_date=date,
|
trade_date=date,
|
||||||
@@ -129,8 +142,14 @@ def get_news_timeline(
|
|||||||
ticker: str,
|
ticker: str,
|
||||||
start_date: str,
|
start_date: str,
|
||||||
end_date: str,
|
end_date: str,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=end_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
timeline = store.get_news_timeline_enriched(
|
timeline = store.get_news_timeline_enriched(
|
||||||
ticker,
|
ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
@@ -165,8 +184,14 @@ def get_news_categories(
|
|||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=end_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
rows = store.get_news_items_enriched(
|
rows = store.get_news_items_enriched(
|
||||||
ticker,
|
ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
@@ -196,8 +221,14 @@ def get_similar_days_payload(
|
|||||||
ticker: str,
|
ticker: str,
|
||||||
date: str,
|
date: str,
|
||||||
n_similar: int = 5,
|
n_similar: int = 5,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
result = find_similar_days(
|
result = find_similar_days(
|
||||||
store,
|
store,
|
||||||
symbol=ticker,
|
symbol=ticker,
|
||||||
@@ -213,8 +244,14 @@ def get_story_payload(
|
|||||||
*,
|
*,
|
||||||
ticker: str,
|
ticker: str,
|
||||||
as_of_date: str,
|
as_of_date: str,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=as_of_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=as_of_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
enrich_news_for_symbol(
|
enrich_news_for_symbol(
|
||||||
store,
|
store,
|
||||||
ticker,
|
ticker,
|
||||||
@@ -238,8 +275,14 @@ def get_range_explain_payload(
|
|||||||
end_date: str,
|
end_date: str,
|
||||||
article_ids: list[str] | None = None,
|
article_ids: list[str] | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=end_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
news_rows = []
|
news_rows = []
|
||||||
if article_ids:
|
if article_ids:
|
||||||
news_rows = store.get_news_by_ids_enriched(ticker, article_ids)
|
news_rows = store.get_news_by_ids_enriched(ticker, article_ids)
|
||||||
|
|||||||
@@ -43,6 +43,72 @@ logger = logging.getLogger(__name__)
|
|||||||
_prompt_loader = get_prompt_loader()
|
_prompt_loader = get_prompt_loader()
|
||||||
|
|
||||||
|
|
||||||
|
INFO_LOGGER_PREFIXES = (
|
||||||
|
"backend.agents",
|
||||||
|
"backend.core.pipeline",
|
||||||
|
"backend.core.scheduler",
|
||||||
|
"backend.services.gateway_cycle_support",
|
||||||
|
)
|
||||||
|
|
||||||
|
NOISY_LOGGER_LEVELS = {
|
||||||
|
"aiohttp": logging.WARNING,
|
||||||
|
"asyncio": logging.WARNING,
|
||||||
|
"dashscope": logging.WARNING,
|
||||||
|
"finnhub": logging.WARNING,
|
||||||
|
"httpcore": logging.WARNING,
|
||||||
|
"httpx": logging.WARNING,
|
||||||
|
"urllib3": logging.WARNING,
|
||||||
|
"websockets": logging.WARNING,
|
||||||
|
"yfinance": logging.WARNING,
|
||||||
|
"backend.data.polling_price_manager": logging.WARNING,
|
||||||
|
"backend.services.gateway": logging.WARNING,
|
||||||
|
"backend.services.market": logging.WARNING,
|
||||||
|
"backend.services.storage": logging.WARNING,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SuppressNoisyInfoFilter(logging.Filter):
|
||||||
|
"""Filter out low-signal library INFO logs while keeping warnings/errors."""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
message = record.getMessage()
|
||||||
|
if record.name == "httpx" and message.startswith("HTTP Request:"):
|
||||||
|
return False
|
||||||
|
if record.name.startswith("websockets") and "connection open" in message:
|
||||||
|
return False
|
||||||
|
if record.name.startswith("websockets") and "opening handshake failed" in message:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if record.levelno >= logging.WARNING:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def configure_gateway_logging(verbose: bool = False) -> None:
|
||||||
|
"""Configure gateway logging with low-noise defaults for runtime logs."""
|
||||||
|
root_level = logging.DEBUG if verbose else logging.WARNING
|
||||||
|
logging.basicConfig(
|
||||||
|
level=root_level,
|
||||||
|
format="%(asctime)s | %(levelname)-7s | %(name)s:%(lineno)d - %(message)s",
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not verbose:
|
||||||
|
suppress_filter = SuppressNoisyInfoFilter()
|
||||||
|
for handler in logging.getLogger().handlers:
|
||||||
|
handler.addFilter(suppress_filter)
|
||||||
|
|
||||||
|
for logger_name, level in NOISY_LOGGER_LEVELS.items():
|
||||||
|
logging.getLogger(logger_name).setLevel(logging.DEBUG if verbose else level)
|
||||||
|
|
||||||
|
if not verbose:
|
||||||
|
for prefix in INFO_LOGGER_PREFIXES:
|
||||||
|
logging.getLogger(prefix).setLevel(logging.INFO)
|
||||||
|
|
||||||
|
logging.getLogger(__name__).setLevel(logging.INFO if not verbose else logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
async def run_gateway(
|
async def run_gateway(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
run_dir: Path,
|
run_dir: Path,
|
||||||
@@ -52,7 +118,7 @@ async def run_gateway(
|
|||||||
"""Run Gateway with Pipeline."""
|
"""Run Gateway with Pipeline."""
|
||||||
|
|
||||||
# Extract config
|
# Extract config
|
||||||
tickers = bootstrap.get("tickers", ["AAPL", "MSFT"])
|
tickers = bootstrap.get("tickers", ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AMD", "NFLX", "AVGO", "PLTR", "COIN"])
|
||||||
initial_cash = float(bootstrap.get("initial_cash", 100000.0))
|
initial_cash = float(bootstrap.get("initial_cash", 100000.0))
|
||||||
margin_requirement = float(bootstrap.get("margin_requirement", 0.0))
|
margin_requirement = float(bootstrap.get("margin_requirement", 0.0))
|
||||||
max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2))
|
max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2))
|
||||||
@@ -65,10 +131,8 @@ async def run_gateway(
|
|||||||
end_date = bootstrap.get("end_date")
|
end_date = bootstrap.get("end_date")
|
||||||
enable_memory = bootstrap.get("enable_memory", False)
|
enable_memory = bootstrap.get("enable_memory", False)
|
||||||
poll_interval = int(bootstrap.get("poll_interval", 10))
|
poll_interval = int(bootstrap.get("poll_interval", 10))
|
||||||
enable_mock = bootstrap.get("enable_mock", False)
|
|
||||||
|
|
||||||
is_backtest = mode == "backtest"
|
is_backtest = mode == "backtest"
|
||||||
is_mock = enable_mock or mode == "mock" or (not is_backtest and os.getenv("MOCK_MODE", "false").lower() == "true")
|
|
||||||
|
|
||||||
logger.info(f"[Gateway Server] Starting run {run_id} on port {port}")
|
logger.info(f"[Gateway Server] Starting run {run_id} on port {port}")
|
||||||
|
|
||||||
@@ -87,9 +151,8 @@ async def run_gateway(
|
|||||||
market_service = MarketService(
|
market_service = MarketService(
|
||||||
tickers=tickers,
|
tickers=tickers,
|
||||||
poll_interval=poll_interval,
|
poll_interval=poll_interval,
|
||||||
mock_mode=is_mock and not is_backtest,
|
|
||||||
backtest_mode=is_backtest,
|
backtest_mode=is_backtest,
|
||||||
api_key=os.getenv("FINNHUB_API_KEY") if not is_mock and not is_backtest else None,
|
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None,
|
||||||
backtest_start_date=start_date if is_backtest else None,
|
backtest_start_date=start_date if is_backtest else None,
|
||||||
backtest_end_date=end_date if is_backtest else None,
|
backtest_end_date=end_date if is_backtest else None,
|
||||||
)
|
)
|
||||||
@@ -182,7 +245,6 @@ async def run_gateway(
|
|||||||
scheduler_callback=scheduler_callback,
|
scheduler_callback=scheduler_callback,
|
||||||
config={
|
config={
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"mock_mode": is_mock,
|
|
||||||
"backtest_mode": is_backtest,
|
"backtest_mode": is_backtest,
|
||||||
"tickers": tickers,
|
"tickers": tickers,
|
||||||
"config_name": run_id,
|
"config_name": run_id,
|
||||||
@@ -222,11 +284,7 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
level = logging.DEBUG if args.verbose else logging.INFO
|
configure_gateway_logging(verbose=args.verbose)
|
||||||
logging.basicConfig(
|
|
||||||
level=level,
|
|
||||||
format="%(asctime)s | %(levelname)-7s | %(name)s:%(lineno)d - %(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse bootstrap
|
# Parse bootstrap
|
||||||
bootstrap = json.loads(args.bootstrap)
|
bootstrap = json.loads(args.bootstrap)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
AgentScope Native Model Factory
|
AgentScope Native Model Factory
|
||||||
Uses native AgentScope model classes for LLM calls
|
Uses native AgentScope model classes for LLM calls
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
@@ -34,6 +36,27 @@ logger = logging.getLogger(__name__)
|
|||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_value(usage: Any, key: str, default: Any = 0) -> Any:
|
||||||
|
"""Read usage fields from both object-style and dict-style usage payloads."""
|
||||||
|
if usage is None:
|
||||||
|
return default
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
return usage.get(key, default)
|
||||||
|
try:
|
||||||
|
return getattr(usage, key)
|
||||||
|
except (AttributeError, KeyError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_total_tokens(usage: Any) -> int:
|
||||||
|
total = _usage_value(usage, "total_tokens", None)
|
||||||
|
if total is not None:
|
||||||
|
return int(total or 0)
|
||||||
|
input_tokens = _usage_value(usage, "input_tokens", 0)
|
||||||
|
output_tokens = _usage_value(usage, "output_tokens", 0)
|
||||||
|
return int((input_tokens or 0) + (output_tokens or 0))
|
||||||
|
|
||||||
|
|
||||||
class RetryChatModel:
|
class RetryChatModel:
|
||||||
"""Wraps an AgentScope model with automatic retry for transient errors.
|
"""Wraps an AgentScope model with automatic retry for transient errors.
|
||||||
|
|
||||||
@@ -55,6 +78,7 @@ class RetryChatModel:
|
|||||||
"502",
|
"502",
|
||||||
"504",
|
"504",
|
||||||
"connection",
|
"connection",
|
||||||
|
"disconnected",
|
||||||
"temporary",
|
"temporary",
|
||||||
"overloaded",
|
"overloaded",
|
||||||
"too_many_requests",
|
"too_many_requests",
|
||||||
@@ -150,8 +174,8 @@ class RetryChatModel:
|
|||||||
# Track usage if available
|
# Track usage if available
|
||||||
if hasattr(result, "usage") and result.usage:
|
if hasattr(result, "usage") and result.usage:
|
||||||
usage = result.usage
|
usage = result.usage
|
||||||
self._total_tokens_used += getattr(usage, "total_tokens", 0)
|
self._total_tokens_used += _usage_total_tokens(usage)
|
||||||
self._total_cost += getattr(usage, "cost", 0.0)
|
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -192,9 +216,66 @@ class RetryChatModel:
|
|||||||
raise last_error
|
raise last_error
|
||||||
raise RuntimeError("RetryChatModel: Unexpected state, no error but no result")
|
raise RuntimeError("RetryChatModel: Unexpected state, no error but no result")
|
||||||
|
|
||||||
|
async def _call_with_retry_async(self, func: Callable[..., T], *args, **kwargs) -> T:
|
||||||
|
"""Call an async function with retry logic for transient errors."""
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(1, self._max_retries + 1):
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
|
||||||
|
if hasattr(result, "usage") and result.usage:
|
||||||
|
usage = result.usage
|
||||||
|
self._total_tokens_used += _usage_total_tokens(usage)
|
||||||
|
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
|
||||||
|
if attempt >= self._max_retries:
|
||||||
|
logger.error(
|
||||||
|
"RetryChatModel: Max retries (%d) exhausted for %s",
|
||||||
|
self._max_retries,
|
||||||
|
self.model_name,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not self._is_transient_error(e):
|
||||||
|
logger.warning(
|
||||||
|
"RetryChatModel: Non-transient error, not retrying: %s",
|
||||||
|
str(e),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
delay = self._calculate_delay(attempt)
|
||||||
|
logger.warning(
|
||||||
|
"RetryChatModel: Transient async error on attempt %d/%d, "
|
||||||
|
"retrying in %.1fs: %s",
|
||||||
|
attempt,
|
||||||
|
self._max_retries,
|
||||||
|
delay,
|
||||||
|
str(e)[:200],
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._on_retry:
|
||||||
|
self._on_retry(attempt, e, delay)
|
||||||
|
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
raise RuntimeError("RetryChatModel: Unexpected async state, no error but no result")
|
||||||
|
|
||||||
def __call__(self, *args, **kwargs) -> Any:
|
def __call__(self, *args, **kwargs) -> Any:
|
||||||
"""Forward calls to the wrapped model with retry logic."""
|
"""Forward calls to the wrapped model with retry logic."""
|
||||||
return self._call_with_retry(self._model, *args, **kwargs)
|
model_call = getattr(self._model, "__call__", None)
|
||||||
|
if inspect.iscoroutinefunction(self._model) or inspect.iscoroutinefunction(model_call):
|
||||||
|
return self._call_with_retry_async(self._model, *args, **kwargs)
|
||||||
|
|
||||||
|
result = self._model(*args, **kwargs)
|
||||||
|
return result
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
def __getattr__(self, name: str) -> Any:
|
||||||
"""Proxy attribute access to the wrapped model."""
|
"""Proxy attribute access to the wrapped model."""
|
||||||
@@ -248,10 +329,18 @@ class TokenRecordingModelWrapper:
|
|||||||
if usage is None:
|
if usage is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._prompt_tokens += getattr(usage, "prompt_tokens", 0)
|
prompt_tokens = _usage_value(usage, "prompt_tokens", None)
|
||||||
self._completion_tokens += getattr(usage, "completion_tokens", 0)
|
completion_tokens = _usage_value(usage, "completion_tokens", None)
|
||||||
self._total_tokens += getattr(usage, "total_tokens", 0)
|
|
||||||
self._total_cost += getattr(usage, "cost", 0.0)
|
if prompt_tokens is None:
|
||||||
|
prompt_tokens = _usage_value(usage, "input_tokens", 0)
|
||||||
|
if completion_tokens is None:
|
||||||
|
completion_tokens = _usage_value(usage, "output_tokens", 0)
|
||||||
|
|
||||||
|
self._prompt_tokens += int(prompt_tokens or 0)
|
||||||
|
self._completion_tokens += int(completion_tokens or 0)
|
||||||
|
self._total_tokens += _usage_total_tokens(usage)
|
||||||
|
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
|
||||||
|
|
||||||
def __call__(self, *args, **kwargs) -> Any:
|
def __call__(self, *args, **kwargs) -> Any:
|
||||||
"""Forward calls and record usage."""
|
"""Forward calls and record usage."""
|
||||||
@@ -401,7 +490,8 @@ def create_model(
|
|||||||
if host:
|
if host:
|
||||||
model_kwargs["host"] = host
|
model_kwargs["host"] = host
|
||||||
|
|
||||||
return model_class(**model_kwargs)
|
model = model_class(**model_kwargs)
|
||||||
|
return RetryChatModel(model)
|
||||||
|
|
||||||
|
|
||||||
def get_agent_model(agent_id: str, stream: bool = False):
|
def get_agent_model(agent_id: str, stream: bool = False):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Main Entry Point
|
Main Entry Point
|
||||||
Supports: backtest, live, mock modes
|
Supports: backtest, live modes
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -29,6 +29,7 @@ from backend.runtime.manager import (
|
|||||||
set_global_runtime_manager,
|
set_global_runtime_manager,
|
||||||
clear_global_runtime_manager,
|
clear_global_runtime_manager,
|
||||||
)
|
)
|
||||||
|
from backend.gateway_server import configure_gateway_logging
|
||||||
from backend.services.gateway import Gateway
|
from backend.services.gateway import Gateway
|
||||||
from backend.services.market import MarketService
|
from backend.services.market import MarketService
|
||||||
from backend.services.storage import StorageService
|
from backend.services.storage import StorageService
|
||||||
@@ -38,6 +39,7 @@ load_dotenv()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
loguru.logger.disable("flowllm")
|
loguru.logger.disable("flowllm")
|
||||||
loguru.logger.disable("reme_ai")
|
loguru.logger.disable("reme_ai")
|
||||||
|
configure_gateway_logging(verbose=os.getenv("LOG_LEVEL", "").upper() == "DEBUG")
|
||||||
_prompt_loader = get_prompt_loader()
|
_prompt_loader = get_prompt_loader()
|
||||||
|
|
||||||
|
|
||||||
@@ -226,17 +228,13 @@ async def run_with_gateway(args):
|
|||||||
)
|
)
|
||||||
runtime_manager.prepare_run()
|
runtime_manager.prepare_run()
|
||||||
set_global_runtime_manager(runtime_manager)
|
set_global_runtime_manager(runtime_manager)
|
||||||
register_runtime_manager(runtime_manager)
|
|
||||||
|
|
||||||
# Create market service
|
# Create market service
|
||||||
market_service = MarketService(
|
market_service = MarketService(
|
||||||
tickers=tickers,
|
tickers=tickers,
|
||||||
poll_interval=args.poll_interval,
|
poll_interval=args.poll_interval,
|
||||||
mock_mode=args.mock and not is_backtest,
|
|
||||||
backtest_mode=is_backtest,
|
backtest_mode=is_backtest,
|
||||||
api_key=os.getenv("FINNHUB_API_KEY")
|
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None,
|
||||||
if not args.mock and not is_backtest
|
|
||||||
else None,
|
|
||||||
backtest_start_date=args.start_date if is_backtest else None,
|
backtest_start_date=args.start_date if is_backtest else None,
|
||||||
backtest_end_date=args.end_date if is_backtest else None,
|
backtest_end_date=args.end_date if is_backtest else None,
|
||||||
)
|
)
|
||||||
@@ -321,7 +319,6 @@ async def run_with_gateway(args):
|
|||||||
scheduler_callback=scheduler_callback,
|
scheduler_callback=scheduler_callback,
|
||||||
config={
|
config={
|
||||||
"mode": args.mode,
|
"mode": args.mode,
|
||||||
"mock_mode": args.mock,
|
|
||||||
"backtest_mode": is_backtest,
|
"backtest_mode": is_backtest,
|
||||||
"tickers": tickers,
|
"tickers": tickers,
|
||||||
"config_name": config_name,
|
"config_name": config_name,
|
||||||
@@ -354,8 +351,7 @@ def main():
|
|||||||
"""Main entry point"""
|
"""Main entry point"""
|
||||||
parser = argparse.ArgumentParser(description="Trading System")
|
parser = argparse.ArgumentParser(description="Trading System")
|
||||||
parser.add_argument("--mode", choices=["live", "backtest"], default="live")
|
parser.add_argument("--mode", choices=["live", "backtest"], default="live")
|
||||||
parser.add_argument("--mock", action="store_true")
|
parser.add_argument("--config-name", default="live")
|
||||||
parser.add_argument("--config-name", default="mock")
|
|
||||||
parser.add_argument("--host", default="0.0.0.0")
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
parser.add_argument("--port", type=int, default=8765)
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -13,15 +13,30 @@ from .registry import RuntimeRegistry
|
|||||||
_global_runtime_manager: Optional["TradingRuntimeManager"] = None
|
_global_runtime_manager: Optional["TradingRuntimeManager"] = None
|
||||||
_shutdown_event: Optional[asyncio.Event] = None
|
_shutdown_event: Optional[asyncio.Event] = None
|
||||||
|
|
||||||
|
# Lazy import to avoid circular dependency
|
||||||
|
_api_runtime = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_runtime():
|
||||||
|
global _api_runtime
|
||||||
|
if _api_runtime is None:
|
||||||
|
from backend.api import runtime as api_runtime_module
|
||||||
|
_api_runtime = api_runtime_module
|
||||||
|
return _api_runtime
|
||||||
|
|
||||||
|
|
||||||
def set_global_runtime_manager(manager: "TradingRuntimeManager") -> None:
|
def set_global_runtime_manager(manager: "TradingRuntimeManager") -> None:
|
||||||
global _global_runtime_manager
|
global _global_runtime_manager
|
||||||
_global_runtime_manager = manager
|
_global_runtime_manager = manager
|
||||||
|
# Sync to RuntimeState for consistency
|
||||||
|
_get_api_runtime().register_runtime_manager(manager)
|
||||||
|
|
||||||
|
|
||||||
def clear_global_runtime_manager() -> None:
|
def clear_global_runtime_manager() -> None:
|
||||||
global _global_runtime_manager
|
global _global_runtime_manager
|
||||||
_global_runtime_manager = None
|
_global_runtime_manager = None
|
||||||
|
# Sync to RuntimeState for consistency
|
||||||
|
_get_api_runtime().unregister_runtime_manager()
|
||||||
|
|
||||||
|
|
||||||
def get_global_runtime_manager() -> Optional["TradingRuntimeManager"]:
|
def get_global_runtime_manager() -> Optional["TradingRuntimeManager"]:
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from websockets.asyncio.server import ServerConnection
|
|||||||
from backend.data.provider_utils import normalize_symbol
|
from backend.data.provider_utils import normalize_symbol
|
||||||
from backend.domains import news as news_domain
|
from backend.domains import news as news_domain
|
||||||
from backend.llm.models import get_agent_model_info
|
from backend.llm.models import get_agent_model_info
|
||||||
from backend.utils.terminal_dashboard import get_dashboard
|
|
||||||
from backend.core.pipeline import TradingPipeline
|
from backend.core.pipeline import TradingPipeline
|
||||||
from backend.core.state_sync import StateSync
|
from backend.core.state_sync import StateSync
|
||||||
from backend.services.market import MarketService
|
from backend.services.market import MarketService
|
||||||
@@ -26,10 +25,12 @@ from backend.tools.technical_signals import StockTechnicalAnalyzer
|
|||||||
from backend.core.scheduler import Scheduler
|
from backend.core.scheduler import Scheduler
|
||||||
from backend.services import gateway_admin_handlers
|
from backend.services import gateway_admin_handlers
|
||||||
from backend.services import gateway_cycle_support
|
from backend.services import gateway_cycle_support
|
||||||
|
from backend.services import gateway_openclaw_handlers
|
||||||
from backend.services import gateway_runtime_support
|
from backend.services import gateway_runtime_support
|
||||||
from backend.services import gateway_stock_handlers
|
from backend.services import gateway_stock_handlers
|
||||||
from shared.client import NewsServiceClient
|
from shared.client import NewsServiceClient
|
||||||
from shared.client import TradingServiceClient
|
from shared.client import TradingServiceClient
|
||||||
|
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient, DEFAULT_GATEWAY_URL as OPENCLAW_WS_URL
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
EDITABLE_AGENT_WORKSPACE_FILES = {
|
EDITABLE_AGENT_WORKSPACE_FILES = {
|
||||||
@@ -38,9 +39,6 @@ EDITABLE_AGENT_WORKSPACE_FILES = {
|
|||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
"MEMORY.md",
|
"MEMORY.md",
|
||||||
"POLICY.md",
|
"POLICY.md",
|
||||||
"HEARTBEAT.md",
|
|
||||||
"ROLE.md",
|
|
||||||
"STYLE.md",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +80,6 @@ class Gateway:
|
|||||||
self._manual_cycle_task: Optional[asyncio.Task] = None
|
self._manual_cycle_task: Optional[asyncio.Task] = None
|
||||||
self._backtest_start_date: Optional[str] = None
|
self._backtest_start_date: Optional[str] = None
|
||||||
self._backtest_end_date: Optional[str] = None
|
self._backtest_end_date: Optional[str] = None
|
||||||
self._dashboard = get_dashboard()
|
|
||||||
self._market_status_task: Optional[asyncio.Task] = None
|
self._market_status_task: Optional[asyncio.Task] = None
|
||||||
self._watchlist_ingest_task: Optional[asyncio.Task] = None
|
self._watchlist_ingest_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
@@ -92,6 +89,7 @@ class Gateway:
|
|||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._project_root = Path(__file__).resolve().parents[2]
|
self._project_root = Path(__file__).resolve().parents[2]
|
||||||
self._technical_analyzer = StockTechnicalAnalyzer()
|
self._technical_analyzer = StockTechnicalAnalyzer()
|
||||||
|
self._openclaw_ws: OpenClawWebSocketClient | None = None
|
||||||
|
|
||||||
async def start(self, host: str = "0.0.0.0", port: int = 8766):
|
async def start(self, host: str = "0.0.0.0", port: int = 8766):
|
||||||
"""Start gateway server with proper initialization order.
|
"""Start gateway server with proper initialization order.
|
||||||
@@ -104,31 +102,11 @@ class Gateway:
|
|||||||
self._loop = asyncio.get_running_loop()
|
self._loop = asyncio.get_running_loop()
|
||||||
self._provider_router.add_listener(self._on_provider_usage_changed)
|
self._provider_router.add_listener(self._on_provider_usage_changed)
|
||||||
|
|
||||||
# Initialize terminal dashboard
|
|
||||||
self._dashboard.set_config(
|
|
||||||
mode=self.mode,
|
|
||||||
config_name=self.config.get("config_name", "default"),
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
poll_interval=self.config.get("poll_interval", 10),
|
|
||||||
mock=self.config.get("mock_mode", False),
|
|
||||||
tickers=self.config.get("tickers", []),
|
|
||||||
initial_cash=self.storage.initial_cash,
|
|
||||||
start_date=self._backtest_start_date or "",
|
|
||||||
end_date=self._backtest_end_date or "",
|
|
||||||
data_sources=self._provider_router.get_usage_snapshot(),
|
|
||||||
)
|
|
||||||
self._dashboard.start()
|
|
||||||
|
|
||||||
self.state_sync.load_state()
|
self.state_sync.load_state()
|
||||||
self.market_service.set_price_recorder(self.storage.record_price_point)
|
self.market_service.set_price_recorder(self.storage.record_price_point)
|
||||||
self.state_sync.update_state("status", "initializing")
|
self.state_sync.update_state("status", "initializing")
|
||||||
self.state_sync.update_state("server_mode", self.mode)
|
self.state_sync.update_state("server_mode", self.mode)
|
||||||
self.state_sync.update_state("is_backtest", self.is_backtest)
|
self.state_sync.update_state("is_backtest", self.is_backtest)
|
||||||
self.state_sync.update_state(
|
|
||||||
"is_mock_mode",
|
|
||||||
self.config.get("mock_mode", False),
|
|
||||||
)
|
|
||||||
self.state_sync.update_state("tickers", self.config.get("tickers", []))
|
self.state_sync.update_state("tickers", self.config.get("tickers", []))
|
||||||
self.state_sync.update_state(
|
self.state_sync.update_state(
|
||||||
"runtime_config",
|
"runtime_config",
|
||||||
@@ -152,18 +130,9 @@ class Gateway:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Load and display existing portfolio state if available
|
# Load and display existing portfolio state if available
|
||||||
summary = self.storage.load_file("summary")
|
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self.state_sync.state)
|
||||||
|
summary = dashboard_snapshot.get("summary")
|
||||||
if summary:
|
if summary:
|
||||||
holdings = self.storage.load_file("holdings") or []
|
|
||||||
trades = self.storage.load_file("trades") or []
|
|
||||||
current_date = self.state_sync.state.get("current_date")
|
|
||||||
self._dashboard.update(
|
|
||||||
date=current_date or "-",
|
|
||||||
status="running",
|
|
||||||
portfolio=summary,
|
|
||||||
holdings=holdings,
|
|
||||||
trades=trades,
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Loaded existing portfolio: $%s",
|
"Loaded existing portfolio: $%s",
|
||||||
f"{summary.get('totalAssetValue', 0):,.2f}",
|
f"{summary.get('totalAssetValue', 0):,.2f}",
|
||||||
@@ -189,6 +158,20 @@ class Gateway:
|
|||||||
# Give a brief moment for any existing clients to reconnect
|
# Give a brief moment for any existing clients to reconnect
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Connect to OpenClaw Gateway (18789) via WebSocket
|
||||||
|
logger.info("Connecting to OpenClaw Gateway...")
|
||||||
|
try:
|
||||||
|
self._openclaw_ws = OpenClawWebSocketClient(
|
||||||
|
url=OPENCLAW_WS_URL,
|
||||||
|
client_name="gateway-client",
|
||||||
|
client_version="1.0.0",
|
||||||
|
)
|
||||||
|
await self._openclaw_ws.connect()
|
||||||
|
logger.info("OpenClaw Gateway WebSocket connected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to connect to OpenClaw Gateway: %s", e)
|
||||||
|
self._openclaw_ws = None
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# PHASE 2: Start market data service
|
# PHASE 2: Start market data service
|
||||||
# Now frontend is connected, start pushing price updates
|
# Now frontend is connected, start pushing price updates
|
||||||
@@ -239,7 +222,6 @@ class Gateway:
|
|||||||
def _on_provider_usage_changed(self, snapshot: Dict[str, Any]):
|
def _on_provider_usage_changed(self, snapshot: Dict[str, Any]):
|
||||||
"""Handle provider routing updates from the shared router."""
|
"""Handle provider routing updates from the shared router."""
|
||||||
self.state_sync.update_state("data_sources", snapshot)
|
self.state_sync.update_state("data_sources", snapshot)
|
||||||
self._dashboard.update(data_sources=snapshot)
|
|
||||||
if self._loop and self._loop.is_running():
|
if self._loop and self._loop.is_running():
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self.broadcast(
|
self.broadcast(
|
||||||
@@ -438,11 +420,77 @@ class Gateway:
|
|||||||
await self._handle_get_stock_technical_indicators(websocket, data)
|
await self._handle_get_stock_technical_indicators(websocket, data)
|
||||||
elif msg_type == "run_stock_enrich":
|
elif msg_type == "run_stock_enrich":
|
||||||
await self._handle_run_stock_enrich(websocket, data)
|
await self._handle_run_stock_enrich(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_status":
|
||||||
|
await self._handle_get_openclaw_status(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_sessions":
|
||||||
|
await self._handle_get_openclaw_sessions(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_session_detail":
|
||||||
|
await self._handle_get_openclaw_session_detail(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_session_history":
|
||||||
|
await self._handle_get_openclaw_session_history(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_cron":
|
||||||
|
await self._handle_get_openclaw_cron(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_approvals":
|
||||||
|
await self._handle_get_openclaw_approvals(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_agents":
|
||||||
|
await self._handle_get_openclaw_agents(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_agents_presence":
|
||||||
|
await self._handle_get_openclaw_agents_presence(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_skills":
|
||||||
|
await self._handle_get_openclaw_skills(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_models":
|
||||||
|
await self._handle_get_openclaw_models(websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_hooks":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_hooks(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_plugins":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_plugins(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_secrets_audit":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_secrets_audit(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_security_audit":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_security_audit(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_daemon_status":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_daemon_status(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_pairing":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_pairing(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_qr":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_qr(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_update_status":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_update_status(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_models_aliases":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_models_aliases(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_models_fallbacks":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_models_fallbacks(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_models_image_fallbacks":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_models_image_fallbacks(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_skill_update":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_skill_update(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_workspace_files":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data)
|
||||||
|
elif msg_type == "get_openclaw_workspace_file":
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_workspace_file(self, websocket, data)
|
||||||
|
elif msg_type == "openclaw_resolve_session":
|
||||||
|
await gateway_openclaw_handlers.handle_openclaw_resolve_session(self, websocket, data)
|
||||||
|
elif msg_type == "openclaw_create_session":
|
||||||
|
await gateway_openclaw_handlers.handle_openclaw_create_session(self, websocket, data)
|
||||||
|
elif msg_type == "openclaw_send_message":
|
||||||
|
await gateway_openclaw_handlers.handle_openclaw_send_message(self, websocket, data)
|
||||||
|
elif msg_type == "openclaw_subscribe_session":
|
||||||
|
await gateway_openclaw_handlers.handle_openclaw_subscribe_session(self, websocket, data)
|
||||||
|
elif msg_type == "openclaw_unsubscribe_session":
|
||||||
|
await gateway_openclaw_handlers.handle_openclaw_unsubscribe_session(self, websocket, data)
|
||||||
|
elif msg_type == "openclaw_reset_session":
|
||||||
|
await gateway_openclaw_handlers.handle_openclaw_reset_session(self, websocket, data)
|
||||||
|
elif msg_type == "openclaw_delete_session":
|
||||||
|
await gateway_openclaw_handlers.handle_openclaw_delete_session(self, websocket, data)
|
||||||
|
|
||||||
except websockets.ConnectionClosed:
|
except websockets.ConnectionClosed:
|
||||||
pass
|
pass
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
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(
|
async def _handle_get_stock_history(
|
||||||
self,
|
self,
|
||||||
@@ -544,13 +592,13 @@ class Gateway:
|
|||||||
websocket: ServerConnection,
|
websocket: ServerConnection,
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run one live/mock trading cycle on demand."""
|
"""Run one live trading cycle on demand."""
|
||||||
if self.is_backtest:
|
if self.is_backtest:
|
||||||
await websocket.send(
|
await websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"message": "Manual trigger is only available in live/mock mode.",
|
"message": "Manual trigger is only available in live mode.",
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
@@ -673,6 +721,83 @@ class Gateway:
|
|||||||
) -> None:
|
) -> None:
|
||||||
await gateway_admin_handlers.handle_update_agent_workspace_file(self, websocket, data)
|
await gateway_admin_handlers.handle_update_agent_workspace_file(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_status(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_status(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_sessions(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_sessions(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_session_detail(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_session_detail(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_session_history(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_session_history(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_cron(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_cron(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_approvals(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_approvals(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_agents(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_agents(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_agents_presence(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_agents_presence(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_skills(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_skills(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_models(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_models(self, websocket, data)
|
||||||
|
|
||||||
|
async def _handle_get_openclaw_workspace_files(
|
||||||
|
self,
|
||||||
|
websocket: ServerConnection,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
|
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
|
||||||
return gateway_runtime_support.normalize_watchlist(raw_tickers)
|
return gateway_runtime_support.normalize_watchlist(raw_tickers)
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Runtime/workspace/skills handlers extracted from the main Gateway module."""
|
"""Runtime/workspace/skills handlers extracted from the main Gateway module.
|
||||||
|
|
||||||
|
Deprecated note:
|
||||||
|
Agent/workspace/skill read-write operations are being migrated to
|
||||||
|
agent_service REST endpoints. These websocket handlers remain as a
|
||||||
|
compatibility fallback and should not be considered the primary control
|
||||||
|
plane path for frontend reads/writes.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.data.market_ingest import ingest_symbols
|
from backend.data.market_ingest import ingest_symbols, refresh_news_for_symbols
|
||||||
from backend.domains import trading as trading_domain
|
from backend.domains import trading as trading_domain
|
||||||
from backend.utils.msg_adapter import FrontendAdapter
|
from backend.utils.msg_adapter import FrontendAdapter
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ async def market_status_monitor(gateway: Any) -> None:
|
|||||||
status = gateway.market_service.get_market_status()
|
status = gateway.market_service.get_market_status()
|
||||||
if status["status"] == "open" and not gateway.storage.is_live_session_active:
|
if status["status"] == "open" and not gateway.storage.is_live_session_active:
|
||||||
gateway.storage.start_live_session()
|
gateway.storage.start_live_session()
|
||||||
summary = gateway.storage.load_file("summary") or {}
|
summary = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state).get("summary") or {}
|
||||||
gateway._session_start_portfolio_value = summary.get(
|
gateway._session_start_portfolio_value = summary.get(
|
||||||
"totalAssetValue",
|
"totalAssetValue",
|
||||||
gateway.storage.initial_cash,
|
gateway.storage.initial_cash,
|
||||||
@@ -147,25 +147,10 @@ async def on_heartbeat_trigger(gateway: Any, date: str) -> None:
|
|||||||
|
|
||||||
for analyst in analysts:
|
for analyst in analysts:
|
||||||
try:
|
try:
|
||||||
ws_id = getattr(analyst, "workspace_id", None)
|
logger.debug(
|
||||||
if ws_id:
|
"[Heartbeat] No heartbeat configured for %s, skipping",
|
||||||
from backend.agents.workspace_manager import get_workspace_dir
|
analyst.name,
|
||||||
from pathlib import Path
|
)
|
||||||
from agentscope.message import Msg
|
|
||||||
|
|
||||||
ws_dir = get_workspace_dir(ws_id)
|
|
||||||
if ws_dir:
|
|
||||||
hb_path = Path(ws_dir) / "HEARTBEAT.md"
|
|
||||||
if hb_path.exists():
|
|
||||||
content = hb_path.read_text(encoding="utf-8").strip()
|
|
||||||
if content:
|
|
||||||
hb_task = f"# 定期主动检查\n\n{content}\n\n请执行上述检查并报告结果。"
|
|
||||||
logger.info("[Heartbeat] Running heartbeat for %s", analyst.name)
|
|
||||||
msg = Msg(role="user", content=hb_task, name="system")
|
|
||||||
await analyst.reply([msg])
|
|
||||||
logger.info("[Heartbeat] %s heartbeat complete", analyst.name)
|
|
||||||
continue
|
|
||||||
logger.debug("[Heartbeat] No HEARTBEAT.md for %s, skipping", analyst.name)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("[Heartbeat] %s failed: %s", analyst.name, exc, exc_info=True)
|
logger.error("[Heartbeat] %s failed: %s", analyst.name, exc, exc_info=True)
|
||||||
|
|
||||||
@@ -175,7 +160,6 @@ async def run_backtest_cycle(gateway: Any, date: str, tickers: list[str]) -> Non
|
|||||||
await gateway.market_service.emit_market_open()
|
await gateway.market_service.emit_market_open()
|
||||||
|
|
||||||
await gateway.state_sync.on_cycle_start(date)
|
await gateway.state_sync.on_cycle_start(date)
|
||||||
gateway._dashboard.update(date=date, status="Analyzing...")
|
|
||||||
|
|
||||||
prices = gateway.market_service.get_open_prices()
|
prices = gateway.market_service.get_open_prices()
|
||||||
close_prices = gateway.market_service.get_close_prices()
|
close_prices = gateway.market_service.get_close_prices()
|
||||||
@@ -200,8 +184,24 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
|
|||||||
trading_date = gateway.market_service.get_live_trading_date()
|
trading_date = gateway.market_service.get_live_trading_date()
|
||||||
logger.info("Live cycle: triggered=%s, trading_date=%s", date, trading_date)
|
logger.info("Live cycle: triggered=%s, trading_date=%s", date, trading_date)
|
||||||
|
|
||||||
|
try:
|
||||||
|
news_refresh = await asyncio.to_thread(
|
||||||
|
refresh_news_for_symbols,
|
||||||
|
tickers,
|
||||||
|
end_date=trading_date,
|
||||||
|
store=gateway.storage.market_store,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"News refresh complete: %s",
|
||||||
|
", ".join(
|
||||||
|
f"{item['symbol']} news={item['news']}"
|
||||||
|
for item in news_refresh
|
||||||
|
) or "no symbols",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Live cycle news refresh failed: %s", exc)
|
||||||
|
|
||||||
await gateway.state_sync.on_cycle_start(trading_date)
|
await gateway.state_sync.on_cycle_start(trading_date)
|
||||||
gateway._dashboard.update(date=trading_date, status="Analyzing...")
|
|
||||||
|
|
||||||
market_caps = await get_market_caps(gateway, tickers, trading_date)
|
market_caps = await get_market_caps(gateway, tickers, trading_date)
|
||||||
schedule_mode = gateway.config.get("schedule_mode", "daily")
|
schedule_mode = gateway.config.get("schedule_mode", "daily")
|
||||||
@@ -240,17 +240,15 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def finalize_cycle(gateway: Any, date: str) -> None:
|
async def finalize_cycle(gateway: Any, date: str) -> None:
|
||||||
summary = gateway.storage.load_file("summary") or {}
|
dashboard_snapshot = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state)
|
||||||
|
summary = dashboard_snapshot.get("summary") or {}
|
||||||
if gateway.storage.is_live_session_active:
|
if gateway.storage.is_live_session_active:
|
||||||
summary.update(gateway.storage.get_live_returns())
|
summary.update(gateway.storage.get_live_returns())
|
||||||
|
|
||||||
await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary)
|
await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary)
|
||||||
holdings = gateway.storage.load_file("holdings") or []
|
leaderboard = dashboard_snapshot.get("leaderboard") or []
|
||||||
trades = gateway.storage.load_file("trades") or []
|
|
||||||
leaderboard = gateway.storage.load_file("leaderboard") or []
|
|
||||||
if leaderboard:
|
if leaderboard:
|
||||||
await gateway.state_sync.on_leaderboard_update(leaderboard)
|
await gateway.state_sync.on_leaderboard_update(leaderboard)
|
||||||
gateway._dashboard.update(date=date, status="Running", portfolio=summary, holdings=holdings, trades=trades)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_market_caps(gateway: Any, tickers: list[str], date: str) -> dict[str, float]:
|
async def get_market_caps(gateway: Any, tickers: list[str], date: str) -> dict[str, float]:
|
||||||
@@ -311,24 +309,16 @@ def save_cycle_results(
|
|||||||
|
|
||||||
async def run_backtest_dates(gateway: Any, dates: list[str]) -> None:
|
async def run_backtest_dates(gateway: Any, dates: list[str]) -> None:
|
||||||
gateway.state_sync.set_backtest_dates(dates)
|
gateway.state_sync.set_backtest_dates(dates)
|
||||||
gateway._dashboard.update(days_total=len(dates), days_completed=0)
|
|
||||||
await gateway.state_sync.on_system_message(f"Starting backtest - {len(dates)} trading days")
|
await gateway.state_sync.on_system_message(f"Starting backtest - {len(dates)} trading days")
|
||||||
try:
|
try:
|
||||||
for i, date in enumerate(dates):
|
for date in dates:
|
||||||
gateway._dashboard.update(days_completed=i)
|
|
||||||
await gateway.on_strategy_trigger(date=date)
|
await gateway.on_strategy_trigger(date=date)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
await gateway.state_sync.on_system_message(f"Backtest complete - {len(dates)} days")
|
await gateway.state_sync.on_system_message(f"Backtest complete - {len(dates)} days")
|
||||||
summary = gateway.storage.load_file("summary") or {}
|
|
||||||
gateway._dashboard.update(status="Complete", portfolio=summary, days_completed=len(dates))
|
|
||||||
gateway._dashboard.stop()
|
|
||||||
gateway._dashboard.print_final_summary()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error_msg = f"Backtest failed: {type(exc).__name__}: {str(exc)}"
|
error_msg = f"Backtest failed: {type(exc).__name__}: {str(exc)}"
|
||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
asyncio.create_task(gateway.state_sync.on_system_message(error_msg))
|
asyncio.create_task(gateway.state_sync.on_system_message(error_msg))
|
||||||
gateway._dashboard.update(status=f"Failed: {str(exc)}")
|
|
||||||
gateway._dashboard.stop()
|
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
gateway._backtest_task = None
|
gateway._backtest_task = None
|
||||||
@@ -358,7 +348,6 @@ def set_backtest_dates(gateway: Any, dates: list[str]) -> None:
|
|||||||
if dates:
|
if dates:
|
||||||
gateway._backtest_start_date = dates[0]
|
gateway._backtest_start_date = dates[0]
|
||||||
gateway._backtest_end_date = dates[-1]
|
gateway._backtest_end_date = dates[-1]
|
||||||
gateway._dashboard.days_total = len(dates)
|
|
||||||
|
|
||||||
|
|
||||||
def stop_gateway(gateway: Any) -> None:
|
def stop_gateway(gateway: Any) -> None:
|
||||||
@@ -370,4 +359,14 @@ def stop_gateway(gateway: Any) -> None:
|
|||||||
gateway._market_status_task.cancel()
|
gateway._market_status_task.cancel()
|
||||||
if gateway._watchlist_ingest_task:
|
if gateway._watchlist_ingest_task:
|
||||||
gateway._watchlist_ingest_task.cancel()
|
gateway._watchlist_ingest_task.cancel()
|
||||||
gateway._dashboard.stop()
|
# Close OpenClaw WebSocket connection
|
||||||
|
if gateway._openclaw_ws:
|
||||||
|
import asyncio
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
loop.create_task(gateway._openclaw_ws.disconnect())
|
||||||
|
else:
|
||||||
|
loop.run_until_complete(gateway._openclaw_ws.disconnect())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
534
backend/services/gateway_openclaw_handlers.py
Normal file
534
backend/services/gateway_openclaw_handlers.py
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""OpenClaw WebSocket handlers — gateway calls OpenClaw Gateway via WebSocket."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from backend.services.gateway import Gateway
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_session_bridge(gateway) -> None:
|
||||||
|
"""Forward OpenClaw session events into 大时代 frontend websockets."""
|
||||||
|
if getattr(gateway, "_openclaw_session_bridge_ready", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _forward(event) -> None:
|
||||||
|
payload = event.payload or {}
|
||||||
|
session_key = str(payload.get("sessionKey") or payload.get("key") or "").strip()
|
||||||
|
if not session_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
subscriber_map = getattr(gateway, "_openclaw_session_subscribers", {})
|
||||||
|
targets = [
|
||||||
|
ws
|
||||||
|
for ws, session_keys in list(subscriber_map.items())
|
||||||
|
if session_key in session_keys
|
||||||
|
]
|
||||||
|
if not targets:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_session_event",
|
||||||
|
"event": event.event,
|
||||||
|
"session_key": session_key,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stale = []
|
||||||
|
for ws in targets:
|
||||||
|
try:
|
||||||
|
await ws.send(message)
|
||||||
|
except Exception:
|
||||||
|
stale.append(ws)
|
||||||
|
|
||||||
|
for ws in stale:
|
||||||
|
try:
|
||||||
|
subscriber_map.pop(ws, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _handler(event) -> None:
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.create_task(_forward(event))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("OpenClaw session bridge skipped event: %s", exc)
|
||||||
|
|
||||||
|
client = _get_ws_client(gateway)
|
||||||
|
client.add_event_handler(_handler)
|
||||||
|
gateway._openclaw_session_bridge_ready = True
|
||||||
|
gateway._openclaw_session_bridge_handler = _handler
|
||||||
|
if not hasattr(gateway, "_openclaw_session_subscribers"):
|
||||||
|
gateway._openclaw_session_subscribers = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ws_client(gateway) -> "OpenClawWebSocketClient":
|
||||||
|
"""Get the OpenClaw WebSocket client from gateway."""
|
||||||
|
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient
|
||||||
|
client = gateway._openclaw_ws
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError("OpenClaw Gateway not connected")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def _ws_call(gateway, method: str, params: dict | None = None) -> dict:
|
||||||
|
"""Call OpenClaw Gateway via WebSocket and return result."""
|
||||||
|
try:
|
||||||
|
client = _get_ws_client(gateway)
|
||||||
|
return await client.call_method(method, params)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("OpenClaw Gateway call failed for %s: %s", method, exc)
|
||||||
|
return {"error": str(exc)[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_status(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "status")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_status_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_sessions(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "sessions.list", {"limit": 50, "includeLastMessage": True})
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_sessions_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_session_detail(gateway, websocket, data: dict) -> None:
|
||||||
|
session_key = data.get("session_key", "")
|
||||||
|
result = await _ws_call(gateway, "sessions.list", {"limit": 200, "includeLastMessage": True})
|
||||||
|
session = None
|
||||||
|
if isinstance(result, dict):
|
||||||
|
for item in result.get("sessions", []) or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("key") == session_key or item.get("sessionKey") == session_key:
|
||||||
|
session = item
|
||||||
|
break
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "openclaw_session_detail_loaded",
|
||||||
|
"data": {"session": session, "error": None if session else f"session '{session_key}' not found"},
|
||||||
|
"session_key": session_key,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_session_history(gateway, websocket, data: dict) -> None:
|
||||||
|
session_key = data.get("session_key", "")
|
||||||
|
limit = data.get("limit", 20)
|
||||||
|
try:
|
||||||
|
from backend.services.openclaw_cli import OpenClawCliService
|
||||||
|
|
||||||
|
result = OpenClawCliService().get_session_history_model(session_key, limit=limit)
|
||||||
|
payload = {
|
||||||
|
"session_key": result.session_key,
|
||||||
|
"session_id": result.session_id,
|
||||||
|
"history": result.events,
|
||||||
|
"events": result.events,
|
||||||
|
"raw_text": result.raw_text,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
payload = {"error": str(exc)[:200], "history": []}
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "openclaw_session_history_loaded",
|
||||||
|
"data": payload,
|
||||||
|
"session_key": session_key,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_openclaw_resolve_session(gateway, websocket, data: dict) -> None:
|
||||||
|
params = {}
|
||||||
|
agent_id = str(data.get("agent_id") or "").strip()
|
||||||
|
label = str(data.get("label") or "").strip()
|
||||||
|
channel = str(data.get("channel") or "").strip()
|
||||||
|
if agent_id:
|
||||||
|
params["agentId"] = agent_id
|
||||||
|
if label:
|
||||||
|
params["label"] = label
|
||||||
|
if channel:
|
||||||
|
params["channel"] = channel
|
||||||
|
params["includeGlobal"] = bool(data.get("include_global", True))
|
||||||
|
result = await _ws_call(gateway, "sessions.resolve", params)
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_session_resolved", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_openclaw_create_session(gateway, websocket, data: dict) -> None:
|
||||||
|
params = {}
|
||||||
|
agent_id = str(data.get("agent_id") or "").strip()
|
||||||
|
label = str(data.get("label") or "").strip()
|
||||||
|
model = str(data.get("model") or "").strip()
|
||||||
|
initial_message = str(data.get("initial_message") or "").strip()
|
||||||
|
if agent_id:
|
||||||
|
params["agentId"] = agent_id
|
||||||
|
if label:
|
||||||
|
params["label"] = label
|
||||||
|
if model:
|
||||||
|
params["model"] = model
|
||||||
|
if initial_message:
|
||||||
|
params["message"] = initial_message
|
||||||
|
result = await _ws_call(gateway, "sessions.create", params)
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_session_created", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_openclaw_send_message(gateway, websocket, data: dict) -> None:
|
||||||
|
session_key = str(data.get("session_key") or "").strip()
|
||||||
|
message = str(data.get("message") or "").strip()
|
||||||
|
thinking = str(data.get("thinking") or "").strip()
|
||||||
|
if not session_key or not message:
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_message_sent",
|
||||||
|
"data": {"error": "session_key and message are required"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
params = {"key": session_key, "message": message}
|
||||||
|
if thinking:
|
||||||
|
params["thinking"] = thinking
|
||||||
|
result = await _ws_call(gateway, "sessions.send", params)
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_message_sent",
|
||||||
|
"data": result,
|
||||||
|
"session_key": session_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_openclaw_subscribe_session(gateway, websocket, data: dict) -> None:
|
||||||
|
session_key = str(data.get("session_key") or "").strip()
|
||||||
|
if not session_key:
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_session_subscribed",
|
||||||
|
"data": {"error": "session_key is required"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_ensure_session_bridge(gateway)
|
||||||
|
result = await _ws_call(gateway, "sessions.messages.subscribe", {"key": session_key})
|
||||||
|
if not isinstance(result, dict) or not result.get("error"):
|
||||||
|
subscriber_map = getattr(gateway, "_openclaw_session_subscribers", {})
|
||||||
|
subscriber_map.setdefault(websocket, set()).add(session_key)
|
||||||
|
gateway._openclaw_session_subscribers = subscriber_map
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_session_subscribed",
|
||||||
|
"data": result,
|
||||||
|
"session_key": session_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_openclaw_unsubscribe_session(gateway, websocket, data: dict) -> None:
|
||||||
|
session_key = str(data.get("session_key") or "").strip()
|
||||||
|
if not session_key:
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_session_unsubscribed",
|
||||||
|
"data": {"error": "session_key is required"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await _ws_call(gateway, "sessions.messages.unsubscribe", {"key": session_key})
|
||||||
|
subscriber_map = getattr(gateway, "_openclaw_session_subscribers", {})
|
||||||
|
session_keys = subscriber_map.get(websocket)
|
||||||
|
if isinstance(session_keys, set):
|
||||||
|
session_keys.discard(session_key)
|
||||||
|
if not session_keys:
|
||||||
|
subscriber_map.pop(websocket, None)
|
||||||
|
gateway._openclaw_session_subscribers = subscriber_map
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_session_unsubscribed",
|
||||||
|
"data": result,
|
||||||
|
"session_key": session_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_openclaw_reset_session(gateway, websocket, data: dict) -> None:
|
||||||
|
session_key = str(data.get("session_key") or "").strip()
|
||||||
|
if not session_key:
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_session_reset",
|
||||||
|
"data": {"error": "session_key is required"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await _ws_call(gateway, "sessions.reset", {"key": session_key})
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_session_reset",
|
||||||
|
"data": result,
|
||||||
|
"session_key": session_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_openclaw_delete_session(gateway, websocket, data: dict) -> None:
|
||||||
|
session_key = str(data.get("session_key") or "").strip()
|
||||||
|
if not session_key:
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_session_deleted",
|
||||||
|
"data": {"error": "session_key is required"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await _ws_call(gateway, "sessions.delete", {"key": session_key})
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "openclaw_session_deleted",
|
||||||
|
"data": result,
|
||||||
|
"session_key": session_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_cron(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "cron.list")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_cron_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_approvals(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "exec.approvals.get")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_approvals_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_agents(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "agents.list")
|
||||||
|
sessions_result = await _ws_call(
|
||||||
|
gateway,
|
||||||
|
"sessions.list",
|
||||||
|
{"limit": 200, "includeLastMessage": True},
|
||||||
|
)
|
||||||
|
config_result = await _ws_call(gateway, "config.get")
|
||||||
|
session_model_by_agent: dict[str, str] = {}
|
||||||
|
default_session_model: str | None = None
|
||||||
|
agent_skills_by_id: dict[str, list[str] | None] = {}
|
||||||
|
default_agent_skills: list[str] | None = None
|
||||||
|
|
||||||
|
parsed_config = config_result.get("parsed") if isinstance(config_result, dict) else None
|
||||||
|
if isinstance(parsed_config, dict):
|
||||||
|
agents_cfg = parsed_config.get("agents")
|
||||||
|
if isinstance(agents_cfg, dict):
|
||||||
|
defaults_cfg = agents_cfg.get("defaults")
|
||||||
|
if isinstance(defaults_cfg, dict):
|
||||||
|
default_skills = defaults_cfg.get("skills")
|
||||||
|
if isinstance(default_skills, list):
|
||||||
|
default_agent_skills = [
|
||||||
|
str(skill).strip()
|
||||||
|
for skill in default_skills
|
||||||
|
if str(skill).strip()
|
||||||
|
]
|
||||||
|
list_cfg = agents_cfg.get("list")
|
||||||
|
if isinstance(list_cfg, list):
|
||||||
|
for entry in list_cfg:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
agent_id = str(entry.get("id") or "").strip()
|
||||||
|
if not agent_id:
|
||||||
|
continue
|
||||||
|
skills = entry.get("skills")
|
||||||
|
if isinstance(skills, list):
|
||||||
|
agent_skills_by_id[agent_id] = [
|
||||||
|
str(skill).strip()
|
||||||
|
for skill in skills
|
||||||
|
if str(skill).strip()
|
||||||
|
]
|
||||||
|
elif skills == []:
|
||||||
|
agent_skills_by_id[agent_id] = []
|
||||||
|
|
||||||
|
if isinstance(sessions_result, dict) and isinstance(sessions_result.get("sessions"), list):
|
||||||
|
defaults = sessions_result.get("defaults")
|
||||||
|
if isinstance(defaults, dict):
|
||||||
|
value = (
|
||||||
|
defaults.get("model")
|
||||||
|
or defaults.get("modelName")
|
||||||
|
or defaults.get("model_name")
|
||||||
|
)
|
||||||
|
if value:
|
||||||
|
default_session_model = str(value)
|
||||||
|
for session in sessions_result.get("sessions", []):
|
||||||
|
if not isinstance(session, dict):
|
||||||
|
continue
|
||||||
|
agent_id = str(
|
||||||
|
session.get("agentId")
|
||||||
|
or session.get("agent_id")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not agent_id:
|
||||||
|
key = str(session.get("key") or session.get("sessionKey") or "").strip()
|
||||||
|
parts = key.split(":")
|
||||||
|
if len(parts) >= 3 and parts[0] == "agent":
|
||||||
|
agent_id = parts[1]
|
||||||
|
model_value = (
|
||||||
|
session.get("model")
|
||||||
|
or session.get("modelName")
|
||||||
|
or session.get("model_name")
|
||||||
|
or session.get("resolvedModel")
|
||||||
|
or session.get("resolved_model")
|
||||||
|
or session.get("defaultModel")
|
||||||
|
or session.get("default_model")
|
||||||
|
)
|
||||||
|
if agent_id and model_value and agent_id not in session_model_by_agent:
|
||||||
|
session_model_by_agent[agent_id] = str(model_value)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and isinstance(result.get("agents"), list):
|
||||||
|
normalized_agents = []
|
||||||
|
for agent in result.get("agents", []):
|
||||||
|
if not isinstance(agent, dict):
|
||||||
|
normalized_agents.append(agent)
|
||||||
|
continue
|
||||||
|
normalized = dict(agent)
|
||||||
|
if not normalized.get("model"):
|
||||||
|
normalized["model"] = (
|
||||||
|
normalized.get("modelName")
|
||||||
|
or normalized.get("model_name")
|
||||||
|
or normalized.get("resolvedModel")
|
||||||
|
or normalized.get("resolved_model")
|
||||||
|
or normalized.get("defaultModel")
|
||||||
|
or normalized.get("default_model")
|
||||||
|
or session_model_by_agent.get(str(normalized.get("id") or "").strip())
|
||||||
|
or default_session_model
|
||||||
|
)
|
||||||
|
agent_id = str(normalized.get("id") or "").strip()
|
||||||
|
if "skills" not in normalized:
|
||||||
|
normalized["skills"] = agent_skills_by_id.get(agent_id, default_agent_skills)
|
||||||
|
normalized_agents.append(normalized)
|
||||||
|
result = {**result, "agents": normalized_agents}
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_agents_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_agents_presence(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "node.list")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_agents_presence_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_skills(gateway, websocket, data: dict) -> None:
|
||||||
|
agent_id = str(data.get("agent_id") or "").strip()
|
||||||
|
params = {"agentId": agent_id} if agent_id else {}
|
||||||
|
result = await _ws_call(gateway, "skills.status", params)
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_skills_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_models(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "models.list")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_models_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_hooks(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "tools.catalog")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_hooks_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_plugins(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "config.get")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_plugins_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_secrets_audit(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "secrets.reload")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_secrets_audit_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_security_audit(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "gateway.identity.get")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_security_audit_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_daemon_status(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "doctor.memory.status")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_daemon_status_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_pairing(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "device.pair.list")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_pairing_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_qr(gateway, websocket, data: dict) -> None:
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_qr_loaded", "data": {"error": "QR code not available via WebSocket"}}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_update_status(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "update.run")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_update_status_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_models_aliases(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "models.list")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_models_aliases_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_models_fallbacks(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "models.list")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_models_fallbacks_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_models_image_fallbacks(gateway, websocket, data: dict) -> None:
|
||||||
|
result = await _ws_call(gateway, "models.list")
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_models_image_fallbacks_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_skill_update(gateway, websocket, data: dict) -> None:
|
||||||
|
slug = data.get("slug")
|
||||||
|
all_flag = data.get("all", False)
|
||||||
|
params = {}
|
||||||
|
if slug is not None:
|
||||||
|
params["slug"] = slug
|
||||||
|
if all_flag:
|
||||||
|
params["all"] = "true"
|
||||||
|
result = await _ws_call(gateway, "skills.update", params)
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_skill_update_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_workspace_files(gateway, websocket, data: dict) -> None:
|
||||||
|
raw_workspace = data.get("workspace", "")
|
||||||
|
# Use the workspace param (which is actually the agent.id from frontend) as agent_id
|
||||||
|
agent_id = raw_workspace or "main"
|
||||||
|
result = await _ws_call(gateway, "agents.files.list", {"agentId": agent_id})
|
||||||
|
if isinstance(result, dict):
|
||||||
|
result["workspace"] = agent_id
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_workspace_files_loaded", "data": result}))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_get_openclaw_workspace_file(gateway, websocket, data: dict) -> None:
|
||||||
|
agent_id = data.get("agent_id", "main")
|
||||||
|
file_name = data.get("file_name", "")
|
||||||
|
if not file_name:
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": {"error": "file_name is required"}}))
|
||||||
|
return
|
||||||
|
result = await _ws_call(gateway, "agents.files.get", {"agentId": agent_id, "name": file_name})
|
||||||
|
await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": result}))
|
||||||
@@ -141,7 +141,7 @@ def apply_runtime_config(gateway: Any, runtime_config: dict[str, Any]) -> dict[s
|
|||||||
|
|
||||||
|
|
||||||
def sync_runtime_state(gateway: Any) -> None:
|
def sync_runtime_state(gateway: Any) -> None:
|
||||||
"""Refresh persisted state and dashboard after runtime config changes."""
|
"""Refresh persisted state after runtime config changes."""
|
||||||
gateway.state_sync.update_state("tickers", gateway.config.get("tickers", []))
|
gateway.state_sync.update_state("tickers", gateway.config.get("tickers", []))
|
||||||
gateway.state_sync.update_state(
|
gateway.state_sync.update_state(
|
||||||
"runtime_config",
|
"runtime_config",
|
||||||
@@ -159,16 +159,3 @@ def sync_runtime_state(gateway: Any) -> None:
|
|||||||
|
|
||||||
gateway.storage.update_server_state_from_dashboard(gateway.state_sync.state)
|
gateway.storage.update_server_state_from_dashboard(gateway.state_sync.state)
|
||||||
gateway.state_sync.save_state()
|
gateway.state_sync.save_state()
|
||||||
|
|
||||||
gateway._dashboard.tickers = list(gateway.config.get("tickers", []))
|
|
||||||
gateway._dashboard.initial_cash = gateway.storage.initial_cash
|
|
||||||
gateway._dashboard.enable_memory = bool(gateway.config.get("enable_memory", False))
|
|
||||||
|
|
||||||
summary = gateway.storage.load_file("summary") or {}
|
|
||||||
holdings = gateway.storage.load_file("holdings") or []
|
|
||||||
trades = gateway.storage.load_file("trades") or []
|
|
||||||
gateway._dashboard.update(
|
|
||||||
portfolio=summary,
|
|
||||||
holdings=holdings,
|
|
||||||
trades=trades,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ async def handle_get_stock_news(gateway: Any, websocket: Any, data: dict[str, An
|
|||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
limit=max(limit, 50),
|
limit=max(limit, 50),
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
news_rows = (payload.get("news") or [])[-limit:]
|
news_rows = (payload.get("news") or [])[-limit:]
|
||||||
source = "market_store"
|
source = "market_store"
|
||||||
@@ -202,6 +203,7 @@ async def handle_get_stock_news_for_date(gateway: Any, websocket: Any, data: dic
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
date=trade_date,
|
date=trade_date,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
news_rows = payload.get("news") or []
|
news_rows = payload.get("news") or []
|
||||||
source = "market_store"
|
source = "market_store"
|
||||||
@@ -255,6 +257,7 @@ async def handle_get_stock_news_timeline(gateway: Any, websocket: Any, data: dic
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
timeline = payload.get("timeline") or []
|
timeline = payload.get("timeline") or []
|
||||||
|
|
||||||
@@ -313,6 +316,7 @@ async def handle_get_stock_news_categories(gateway: Any, websocket: Any, data: d
|
|||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
limit=200,
|
limit=200,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
categories = payload.get("categories") or {}
|
categories = payload.get("categories") or {}
|
||||||
|
|
||||||
@@ -361,6 +365,7 @@ async def handle_get_stock_range_explain(gateway: Any, websocket: Any, data: dic
|
|||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
article_ids=article_ids if isinstance(article_ids, list) else None,
|
article_ids=article_ids if isinstance(article_ids, list) else None,
|
||||||
limit=100,
|
limit=100,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
result = payload.get("result")
|
result = payload.get("result")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Market Data Service
|
Market Data Service
|
||||||
Supports live, mock, and backtest modes
|
Supports live and backtest modes
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
@@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pandas_market_calendars as mcal
|
import pandas_market_calendars as mcal
|
||||||
from backend.config.data_config import get_data_source
|
from backend.config.data_config import get_data_sources
|
||||||
from backend.data.provider_utils import normalize_symbol
|
from backend.data.provider_utils import normalize_symbol
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -36,7 +36,6 @@ class MarketService:
|
|||||||
self,
|
self,
|
||||||
tickers: List[str],
|
tickers: List[str],
|
||||||
poll_interval: int = 10,
|
poll_interval: int = 10,
|
||||||
mock_mode: bool = False,
|
|
||||||
backtest_mode: bool = False,
|
backtest_mode: bool = False,
|
||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
backtest_start_date: Optional[str] = None,
|
backtest_start_date: Optional[str] = None,
|
||||||
@@ -44,7 +43,6 @@ class MarketService:
|
|||||||
):
|
):
|
||||||
self.tickers = [normalize_symbol(ticker) for ticker in tickers]
|
self.tickers = [normalize_symbol(ticker) for ticker in tickers]
|
||||||
self.poll_interval = poll_interval
|
self.poll_interval = poll_interval
|
||||||
self.mock_mode = mock_mode
|
|
||||||
self.backtest_mode = backtest_mode
|
self.backtest_mode = backtest_mode
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.backtest_start_date = backtest_start_date
|
self.backtest_start_date = backtest_start_date
|
||||||
@@ -69,8 +67,6 @@ class MarketService:
|
|||||||
"""Return the active live quote provider for UI/debugging."""
|
"""Return the active live quote provider for UI/debugging."""
|
||||||
if self.backtest_mode:
|
if self.backtest_mode:
|
||||||
return "backtest"
|
return "backtest"
|
||||||
if self.mock_mode:
|
|
||||||
return "mock"
|
|
||||||
if self._price_manager and hasattr(self._price_manager, "provider"):
|
if self._price_manager and hasattr(self._price_manager, "provider"):
|
||||||
provider = getattr(self._price_manager, "provider", None)
|
provider = getattr(self._price_manager, "provider", None)
|
||||||
if isinstance(provider, str) and provider.strip():
|
if isinstance(provider, str) and provider.strip():
|
||||||
@@ -81,8 +77,6 @@ class MarketService:
|
|||||||
def mode_name(self) -> str:
|
def mode_name(self) -> str:
|
||||||
if self.backtest_mode:
|
if self.backtest_mode:
|
||||||
return "BACKTEST"
|
return "BACKTEST"
|
||||||
elif self.mock_mode:
|
|
||||||
return "MOCK"
|
|
||||||
return "LIVE"
|
return "LIVE"
|
||||||
|
|
||||||
async def start(self, broadcast_func: Callable):
|
async def start(self, broadcast_func: Callable):
|
||||||
@@ -96,8 +90,6 @@ class MarketService:
|
|||||||
|
|
||||||
if self.backtest_mode:
|
if self.backtest_mode:
|
||||||
self._start_backtest_mode()
|
self._start_backtest_mode()
|
||||||
elif self.mock_mode:
|
|
||||||
self._start_mock_mode()
|
|
||||||
else:
|
else:
|
||||||
self._start_real_mode()
|
self._start_real_mode()
|
||||||
|
|
||||||
@@ -125,26 +117,10 @@ class MarketService:
|
|||||||
|
|
||||||
return callback
|
return callback
|
||||||
|
|
||||||
def _start_mock_mode(self):
|
|
||||||
from backend.data.mock_price_manager import MockPriceManager
|
|
||||||
|
|
||||||
self._price_manager = MockPriceManager(
|
|
||||||
poll_interval=self.poll_interval,
|
|
||||||
volatility=0.5,
|
|
||||||
)
|
|
||||||
self._price_manager.add_price_callback(self._make_price_callback())
|
|
||||||
self._price_manager.subscribe(
|
|
||||||
self.tickers,
|
|
||||||
base_prices={t: 100.0 for t in self.tickers},
|
|
||||||
)
|
|
||||||
self._price_manager.start()
|
|
||||||
|
|
||||||
def _start_real_mode(self):
|
def _start_real_mode(self):
|
||||||
from backend.data.polling_price_manager import PollingPriceManager
|
from backend.data.polling_price_manager import PollingPriceManager
|
||||||
|
|
||||||
provider = get_data_source()
|
provider = self._resolve_live_quote_provider()
|
||||||
if provider == "local_csv":
|
|
||||||
provider = "yfinance"
|
|
||||||
|
|
||||||
if provider == "finnhub" and not self.api_key:
|
if provider == "finnhub" and not self.api_key:
|
||||||
raise ValueError("API key required for live mode")
|
raise ValueError("API key required for live mode")
|
||||||
@@ -157,6 +133,13 @@ class MarketService:
|
|||||||
self._price_manager.subscribe(self.tickers)
|
self._price_manager.subscribe(self.tickers)
|
||||||
self._price_manager.start()
|
self._price_manager.start()
|
||||||
|
|
||||||
|
def _resolve_live_quote_provider(self) -> str:
|
||||||
|
"""Pick the first configured provider that supports live quote polling."""
|
||||||
|
for provider in get_data_sources():
|
||||||
|
if provider in {"finnhub", "yfinance"}:
|
||||||
|
return provider
|
||||||
|
return "yfinance"
|
||||||
|
|
||||||
def _start_backtest_mode(self):
|
def _start_backtest_mode(self):
|
||||||
from backend.data.historical_price_manager import (
|
from backend.data.historical_price_manager import (
|
||||||
HistoricalPriceManager,
|
HistoricalPriceManager,
|
||||||
@@ -257,12 +240,6 @@ class MarketService:
|
|||||||
if removed:
|
if removed:
|
||||||
self._price_manager.unsubscribe(removed)
|
self._price_manager.unsubscribe(removed)
|
||||||
if added:
|
if added:
|
||||||
if self.mock_mode:
|
|
||||||
self._price_manager.subscribe(
|
|
||||||
added,
|
|
||||||
base_prices={ticker: 100.0 for ticker in added},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._price_manager.subscribe(added)
|
self._price_manager.subscribe(added)
|
||||||
|
|
||||||
if self.backtest_mode and self._current_date:
|
if self.backtest_mode and self._current_date:
|
||||||
|
|||||||
754
backend/services/openclaw_cli.py
Normal file
754
backend/services/openclaw_cli.py
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Thin service wrapper around the OpenClaw CLI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from shared.models.openclaw import (
|
||||||
|
AgentSummary,
|
||||||
|
AgentsList,
|
||||||
|
ApprovalRequest,
|
||||||
|
ApprovalsList,
|
||||||
|
CronJob,
|
||||||
|
CronList,
|
||||||
|
DaemonStatus,
|
||||||
|
HookStatusEntry,
|
||||||
|
HookStatusReport,
|
||||||
|
ModelAliasesList,
|
||||||
|
ModelFallbacksList,
|
||||||
|
ModelRow,
|
||||||
|
ModelsList,
|
||||||
|
OpenClawStatus,
|
||||||
|
PairingListResponse,
|
||||||
|
PluginDiagnostic,
|
||||||
|
PluginRecord,
|
||||||
|
PluginsList,
|
||||||
|
QrCodeResponse,
|
||||||
|
SecretsAuditReport,
|
||||||
|
SecurityAuditResponse,
|
||||||
|
SecurityAuditReport,
|
||||||
|
SessionEntry,
|
||||||
|
SessionHistory,
|
||||||
|
SessionsList,
|
||||||
|
SkillStatusEntry,
|
||||||
|
SkillStatusReport,
|
||||||
|
SkillUpdateResult,
|
||||||
|
UpdateCheckResult,
|
||||||
|
UpdateStatusResponse,
|
||||||
|
normalize_agents,
|
||||||
|
normalize_approvals,
|
||||||
|
normalize_cron_jobs,
|
||||||
|
normalize_daemon_status,
|
||||||
|
normalize_hooks,
|
||||||
|
normalize_model_aliases,
|
||||||
|
normalize_model_fallbacks,
|
||||||
|
normalize_models,
|
||||||
|
normalize_pairing,
|
||||||
|
normalize_plugins,
|
||||||
|
normalize_qr,
|
||||||
|
normalize_security_audit,
|
||||||
|
normalize_secrets_audit,
|
||||||
|
normalize_session_history,
|
||||||
|
normalize_sessions,
|
||||||
|
normalize_skill_update,
|
||||||
|
normalize_skills,
|
||||||
|
normalize_status,
|
||||||
|
normalize_update_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REFERENCE_OPENCLAW_ROOT = PROJECT_ROOT / "reference" / "openclaw"
|
||||||
|
REFERENCE_OPENCLAW_ENTRY = REFERENCE_OPENCLAW_ROOT / "openclaw.mjs"
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawCliError(RuntimeError):
|
||||||
|
"""Raised when the OpenClaw CLI invocation fails."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
command: list[str],
|
||||||
|
exit_code: int | None = None,
|
||||||
|
stdout: str = "",
|
||||||
|
stderr: str = "",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.command = command
|
||||||
|
self.exit_code = exit_code
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenClawCliResult:
|
||||||
|
"""Command execution result."""
|
||||||
|
|
||||||
|
command: list[str]
|
||||||
|
exit_code: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_openclaw_base_command() -> list[str]:
|
||||||
|
"""Resolve the command prefix used to launch OpenClaw."""
|
||||||
|
explicit = os.getenv("OPENCLAW_CMD", "").strip()
|
||||||
|
if explicit:
|
||||||
|
return shlex.split(explicit)
|
||||||
|
|
||||||
|
installed = shutil.which("openclaw")
|
||||||
|
if installed:
|
||||||
|
return [installed]
|
||||||
|
|
||||||
|
if REFERENCE_OPENCLAW_ENTRY.exists():
|
||||||
|
return [sys.executable if sys.executable.endswith("node") else "node", str(REFERENCE_OPENCLAW_ENTRY)]
|
||||||
|
|
||||||
|
return ["openclaw"]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_openclaw_cwd() -> Path:
|
||||||
|
"""Resolve the working directory for CLI execution."""
|
||||||
|
explicit = os.getenv("OPENCLAW_CWD", "").strip()
|
||||||
|
if explicit:
|
||||||
|
return Path(explicit).expanduser()
|
||||||
|
if REFERENCE_OPENCLAW_ROOT.exists():
|
||||||
|
return REFERENCE_OPENCLAW_ROOT
|
||||||
|
return PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawCliService:
|
||||||
|
"""OpenClaw CLI integration service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_command: list[str] | None = None,
|
||||||
|
cwd: Path | None = None,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.base_command = list(base_command or resolve_openclaw_base_command())
|
||||||
|
self.cwd = cwd or resolve_openclaw_cwd()
|
||||||
|
self.timeout_seconds = timeout_seconds or float(
|
||||||
|
os.getenv("OPENCLAW_TIMEOUT_SECONDS", "15")
|
||||||
|
)
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
"""Return the current CLI wiring state."""
|
||||||
|
binary = self.base_command[0] if self.base_command else "openclaw"
|
||||||
|
resolved = shutil.which(binary) if len(self.base_command) == 1 else binary
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "openclaw-service",
|
||||||
|
"base_command": self.base_command,
|
||||||
|
"cwd": str(self.cwd),
|
||||||
|
"binary_resolved": resolved is not None,
|
||||||
|
"reference_entry_available": REFERENCE_OPENCLAW_ENTRY.exists(),
|
||||||
|
"timeout_seconds": self.timeout_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw status --json`."""
|
||||||
|
return self.run_json(["status", "--json"])
|
||||||
|
|
||||||
|
def list_sessions(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw sessions --json`."""
|
||||||
|
return self.run_json(["sessions", "--json"])
|
||||||
|
|
||||||
|
def get_session(self, session_key: str) -> dict[str, Any]:
|
||||||
|
"""Resolve a single session out of the sessions list."""
|
||||||
|
payload = self.list_sessions()
|
||||||
|
sessions = payload.get("sessions") or []
|
||||||
|
for item in sessions:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("key") == session_key or item.get("sessionKey") == session_key:
|
||||||
|
return item
|
||||||
|
raise KeyError(session_key)
|
||||||
|
|
||||||
|
def get_session_history(self, session_key: str, *, limit: int = 20) -> dict[str, Any]:
|
||||||
|
"""Read session history with a JSON-first fallback to raw text."""
|
||||||
|
args = ["sessions", "history", session_key, "--json", "--limit", str(limit)]
|
||||||
|
try:
|
||||||
|
return self.run_json(args)
|
||||||
|
except OpenClawCliError as exc:
|
||||||
|
raise exc
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
result = self.run(args)
|
||||||
|
return {
|
||||||
|
"sessionKey": session_key,
|
||||||
|
"limit": limit,
|
||||||
|
"rawText": result.stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_cron_jobs(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw cron list --json`."""
|
||||||
|
return self.run_json(["cron", "list", "--json"])
|
||||||
|
|
||||||
|
def list_approvals(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw approvals get --json`."""
|
||||||
|
return self.run_json(["approvals", "get", "--json"])
|
||||||
|
|
||||||
|
def list_agents(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw agents list --json`."""
|
||||||
|
return self.run_json(["agents", "list", "--json"])
|
||||||
|
|
||||||
|
def list_skills(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw skills list --json`."""
|
||||||
|
return self.run_json(["skills", "list", "--json"])
|
||||||
|
|
||||||
|
def list_models(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw models list --json`."""
|
||||||
|
return self.run_json(["models", "list", "--json"])
|
||||||
|
|
||||||
|
def list_hooks(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw hooks list --json`."""
|
||||||
|
return self.run_json(["hooks", "list", "--json"])
|
||||||
|
|
||||||
|
def list_plugins(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw plugins list --json`."""
|
||||||
|
return self.run_json(["plugins", "list", "--json"])
|
||||||
|
|
||||||
|
def secrets_audit(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw secrets audit --json`."""
|
||||||
|
return self.run_json(["secrets", "audit", "--json"])
|
||||||
|
|
||||||
|
def security_audit(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw security audit --json`."""
|
||||||
|
return self.run_json(["security", "audit", "--json"])
|
||||||
|
|
||||||
|
def daemon_status(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw daemon status --json`."""
|
||||||
|
return self.run_json(["daemon", "status", "--json"])
|
||||||
|
|
||||||
|
def pairing_list(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw pairing list --json`."""
|
||||||
|
return self.run_json(["pairing", "list", "--json"])
|
||||||
|
|
||||||
|
def qr_code(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw qr --json`."""
|
||||||
|
return self.run_json(["qr", "--json"])
|
||||||
|
|
||||||
|
def update_status(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw update status --json`."""
|
||||||
|
return self.run_json(["update", "status", "--json"])
|
||||||
|
|
||||||
|
def list_model_aliases(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw models aliases list --json`."""
|
||||||
|
return self.run_json(["models", "aliases", "list", "--json"])
|
||||||
|
|
||||||
|
def list_model_fallbacks(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw models fallbacks list --json`."""
|
||||||
|
return self.run_json(["models", "fallbacks", "list", "--json"])
|
||||||
|
|
||||||
|
def list_model_image_fallbacks(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw models image-fallbacks list --json`."""
|
||||||
|
return self.run_json(["models", "image-fallbacks", "list", "--json"])
|
||||||
|
|
||||||
|
def skill_update(self, *, slug: str | None = None, all: bool = False) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw skills update --json`."""
|
||||||
|
args = ["skills", "update", "--json"]
|
||||||
|
if slug:
|
||||||
|
args.append(slug)
|
||||||
|
if all:
|
||||||
|
args.append("--all")
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def models_status(self, *, probe: bool = False) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw models status --json [--probe]`."""
|
||||||
|
args = ["models", "status", "--json"]
|
||||||
|
if probe:
|
||||||
|
args.append("--probe")
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def channels_status(self, *, probe: bool = False) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw channels status [--probe] --json`."""
|
||||||
|
args = ["channels", "status", "--json"]
|
||||||
|
if probe:
|
||||||
|
args.append("--probe")
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def list_workspace_files(self, workspace_path: str) -> dict[str, Any]:
|
||||||
|
"""List .md files in an OpenClaw agent workspace with their content.
|
||||||
|
|
||||||
|
Reads the workspace directory and returns metadata + content for each .md file.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
wp = Path(workspace_path).expanduser().resolve()
|
||||||
|
if not wp.exists() or not wp.is_dir():
|
||||||
|
return {"workspace": str(wp), "files": [], "error": "workspace not found"}
|
||||||
|
|
||||||
|
md_files = sorted(wp.glob("*.md"))
|
||||||
|
files = []
|
||||||
|
for md_file in md_files:
|
||||||
|
try:
|
||||||
|
content = md_file.read_text(encoding="utf-8")
|
||||||
|
# Preview: first 300 chars
|
||||||
|
preview = content[:300].strip()
|
||||||
|
files.append({
|
||||||
|
"name": md_file.name,
|
||||||
|
"path": str(md_file),
|
||||||
|
"size": len(content),
|
||||||
|
"preview": preview,
|
||||||
|
"previewTruncated": len(content) > 300,
|
||||||
|
})
|
||||||
|
except OSError as exc:
|
||||||
|
files.append({
|
||||||
|
"name": md_file.name,
|
||||||
|
"path": str(md_file),
|
||||||
|
"size": 0,
|
||||||
|
"preview": "",
|
||||||
|
"error": str(exc),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"workspace": str(wp), "files": files}
|
||||||
|
|
||||||
|
def channels_list(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw channels list --json`."""
|
||||||
|
return self.run_json(["channels", "list", "--json"])
|
||||||
|
|
||||||
|
def hook_info(self, name: str) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw hooks info <name> --json`."""
|
||||||
|
args = ["hooks", "info", name, "--json"]
|
||||||
|
try:
|
||||||
|
return self.run_json(args)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
result = self.run(args)
|
||||||
|
return {"raw": result.stdout}
|
||||||
|
|
||||||
|
def hooks_check(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw hooks check --json`."""
|
||||||
|
return self.run_json(["hooks", "check", "--json"])
|
||||||
|
|
||||||
|
def plugins_inspect(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw plugins inspect [--json] [--all]`."""
|
||||||
|
args = ["plugins", "inspect", "--json"]
|
||||||
|
if all:
|
||||||
|
args.append("--all")
|
||||||
|
elif plugin_id:
|
||||||
|
args.append(plugin_id)
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Typed variants — these use Pydantic models and are the preferred path.
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def status_model(self) -> OpenClawStatus:
|
||||||
|
"""Read and parse `openclaw status --json` into a typed model."""
|
||||||
|
raw = self.status()
|
||||||
|
return normalize_status(raw)
|
||||||
|
|
||||||
|
def list_sessions_model(self) -> SessionsList:
|
||||||
|
"""Read and parse `openclaw sessions --json` into a typed model."""
|
||||||
|
raw = self.list_sessions()
|
||||||
|
return normalize_sessions(raw)
|
||||||
|
|
||||||
|
def get_session_model(self, session_key: str) -> SessionEntry:
|
||||||
|
"""Resolve a single session and return a typed model."""
|
||||||
|
raw = self.get_session(session_key)
|
||||||
|
return SessionEntry.model_validate(raw, strict=False)
|
||||||
|
|
||||||
|
def get_session_history_model(self, session_key: str, *, limit: int = 20) -> SessionHistory:
|
||||||
|
"""Read session history and return a typed model."""
|
||||||
|
raw = self.get_session_history(session_key, limit=limit)
|
||||||
|
return normalize_session_history(raw, session_key=session_key)
|
||||||
|
|
||||||
|
def list_cron_jobs_model(self) -> CronList:
|
||||||
|
"""Read and parse `openclaw cron list --json` into a typed model."""
|
||||||
|
raw = self.list_cron_jobs()
|
||||||
|
return normalize_cron_jobs(raw)
|
||||||
|
|
||||||
|
def list_approvals_model(self) -> ApprovalsList:
|
||||||
|
"""Read and parse `openclaw approvals get --json` into a typed model."""
|
||||||
|
raw = self.list_approvals()
|
||||||
|
return normalize_approvals(raw)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Typed variants
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_agents_model(self) -> AgentsList:
|
||||||
|
"""Read and parse `openclaw agents list --json` into a typed model."""
|
||||||
|
raw = self.list_agents()
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return AgentsList(agents=[AgentSummary.model_validate(a, strict=False) for a in raw if isinstance(a, dict)])
|
||||||
|
return normalize_agents(raw)
|
||||||
|
|
||||||
|
def list_skills_model(self) -> SkillStatusReport:
|
||||||
|
"""Read and parse `openclaw skills list --json` into a typed model."""
|
||||||
|
raw = self.list_skills()
|
||||||
|
return normalize_skills(raw)
|
||||||
|
|
||||||
|
def list_models_model(self) -> ModelsList:
|
||||||
|
"""Read and parse `openclaw models list --json` into a typed model."""
|
||||||
|
raw = self.list_models()
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return ModelsList(models=[ModelRow.model_validate(m, strict=False) for m in raw if isinstance(m, dict)])
|
||||||
|
return normalize_models(raw)
|
||||||
|
|
||||||
|
def list_hooks_model(self) -> HookStatusReport:
|
||||||
|
raw = self.list_hooks()
|
||||||
|
return normalize_hooks(raw)
|
||||||
|
|
||||||
|
def list_plugins_model(self) -> PluginsList:
|
||||||
|
raw = self.list_plugins()
|
||||||
|
return normalize_plugins(raw)
|
||||||
|
|
||||||
|
def secrets_audit_model(self) -> SecretsAuditReport:
|
||||||
|
raw = self.secrets_audit()
|
||||||
|
return normalize_secrets_audit(raw)
|
||||||
|
|
||||||
|
def security_audit_model(self) -> SecurityAuditResponse:
|
||||||
|
raw = self.security_audit()
|
||||||
|
return normalize_security_audit(raw)
|
||||||
|
|
||||||
|
def daemon_status_model(self) -> DaemonStatus:
|
||||||
|
raw = self.daemon_status()
|
||||||
|
return normalize_daemon_status(raw)
|
||||||
|
|
||||||
|
def pairing_list_model(self) -> PairingListResponse:
|
||||||
|
raw = self.pairing_list()
|
||||||
|
return normalize_pairing(raw)
|
||||||
|
|
||||||
|
def qr_code_model(self) -> QrCodeResponse:
|
||||||
|
raw = self.qr_code()
|
||||||
|
return normalize_qr(raw)
|
||||||
|
|
||||||
|
def update_status_model(self) -> UpdateStatusResponse:
|
||||||
|
raw = self.update_status()
|
||||||
|
return normalize_update_status(raw)
|
||||||
|
|
||||||
|
def list_model_aliases_model(self) -> ModelAliasesList:
|
||||||
|
raw = self.list_model_aliases()
|
||||||
|
return normalize_model_aliases(raw)
|
||||||
|
|
||||||
|
def list_model_fallbacks_model(self) -> ModelFallbacksList:
|
||||||
|
raw = self.list_model_fallbacks()
|
||||||
|
return normalize_model_fallbacks(raw)
|
||||||
|
|
||||||
|
def list_model_image_fallbacks_model(self) -> ModelFallbacksList:
|
||||||
|
raw = self.list_model_image_fallbacks()
|
||||||
|
return normalize_model_fallbacks(raw)
|
||||||
|
|
||||||
|
def skill_update_model(self, *, slug: str | None = None, all: bool = False) -> SkillUpdateResult:
|
||||||
|
raw = self.skill_update(slug=slug, all=all)
|
||||||
|
return normalize_skill_update(raw)
|
||||||
|
|
||||||
|
def models_status_model(self, *, probe: bool = False) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw models status --json` and return the raw dict."""
|
||||||
|
return self.models_status(probe=probe)
|
||||||
|
|
||||||
|
def channels_status_model(self, *, probe: bool = False) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw channels status --json` and return the raw dict."""
|
||||||
|
return self.channels_status(probe=probe)
|
||||||
|
|
||||||
|
def channels_list_model(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw channels list --json` and return the raw dict."""
|
||||||
|
return self.channels_list()
|
||||||
|
|
||||||
|
def hook_info_model(self, name: str) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw hooks info <name> --json` and return the raw dict."""
|
||||||
|
return self.hook_info(name)
|
||||||
|
|
||||||
|
def hooks_check_model(self) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw hooks check --json` and return the raw dict."""
|
||||||
|
return self.hooks_check()
|
||||||
|
|
||||||
|
def plugins_inspect_model(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw plugins inspect --json [--all]` and return the raw dict."""
|
||||||
|
return self.plugins_inspect(plugin_id=plugin_id, all=all)
|
||||||
|
|
||||||
|
def agents_bindings(self, *, agent: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw agents bindings --json [--agent <id>]`."""
|
||||||
|
args = ["agents", "bindings", "--json"]
|
||||||
|
if agent:
|
||||||
|
args.extend(["--agent", agent])
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def agents_bindings_model(self, *, agent: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw agents bindings --json` and return the raw dict."""
|
||||||
|
return self.agents_bindings(agent=agent)
|
||||||
|
|
||||||
|
def agents_presence(self) -> dict[str, Any]:
|
||||||
|
"""Read session presence for all agents from runtime session files.
|
||||||
|
|
||||||
|
Reads ~/.openclaw/agents/{agentId}/sessions/sessions.json for each agent
|
||||||
|
and counts sessions in active states within a recency window.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
openclaw_home = Path.home() / ".openclaw"
|
||||||
|
agents_path = openclaw_home / "agents"
|
||||||
|
|
||||||
|
if not agents_path.exists():
|
||||||
|
return {"status": "not_connected", "agents": {}}
|
||||||
|
|
||||||
|
ACTIVE_STATES = {
|
||||||
|
"running", "active", "busy", "blocked", "waiting_approval",
|
||||||
|
"working", "in_progress", "processing", "thinking", "executing", "streaming",
|
||||||
|
}
|
||||||
|
|
||||||
|
RECENCY_WINDOW_MS = 45 * 60 * 1000 # 45 minutes
|
||||||
|
|
||||||
|
result: dict[str, Any] = {"status": "connected", "agents": {}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for agent_dir in agents_path.iterdir():
|
||||||
|
if not agent_dir.is_dir():
|
||||||
|
continue
|
||||||
|
sessions_file = agent_dir / "sessions" / "sessions.json"
|
||||||
|
if not sessions_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sessions_data = json.loads(sessions_file.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
sessions = sessions_data if isinstance(sessions_data, list) else []
|
||||||
|
now_ms = 0 # placeholder; we'll skip recency check if no ts field
|
||||||
|
|
||||||
|
active_count = 0
|
||||||
|
for session in sessions:
|
||||||
|
if not isinstance(session, dict):
|
||||||
|
continue
|
||||||
|
state = str(session.get("state") or session.get("status") or "").lower()
|
||||||
|
if state in ACTIVE_STATES:
|
||||||
|
active_count += 1
|
||||||
|
|
||||||
|
if active_count > 0:
|
||||||
|
result["agents"][agent_dir.name] = {
|
||||||
|
"activeSessions": active_count,
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result["agents"][agent_dir.name] = {
|
||||||
|
"activeSessions": 0,
|
||||||
|
"status": "idle",
|
||||||
|
}
|
||||||
|
except OSError:
|
||||||
|
result["status"] = "partial"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def agents_from_config(self) -> dict[str, Any]:
|
||||||
|
"""Read agent list directly from openclaw.json config file.
|
||||||
|
|
||||||
|
Falls back to scanning ~/.openclaw/agents/ directories when config is absent.
|
||||||
|
This avoids the CLI timeout from `agents list --json`.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
openclaw_home = Path.home() / ".openclaw"
|
||||||
|
config_path = openclaw_home / "openclaw.json"
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
return {"status": "not_connected", "agents": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(config_path.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {"status": "partial", "agents": []}
|
||||||
|
|
||||||
|
agents_list = raw.get("agents", {}).get("list", [])
|
||||||
|
if not agents_list:
|
||||||
|
return {"status": "partial", "agents": [], "detail": "agents.list is empty"}
|
||||||
|
|
||||||
|
agents = []
|
||||||
|
for entry in agents_list:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
agent_id = entry.get("id", "").strip()
|
||||||
|
if not agent_id:
|
||||||
|
continue
|
||||||
|
agents.append({
|
||||||
|
"id": agent_id,
|
||||||
|
"name": entry.get("name", "").strip() or agent_id,
|
||||||
|
"model": entry.get("model") or "",
|
||||||
|
"workspace": entry.get("workspace") or "",
|
||||||
|
"is_default": entry.get("id") == raw.get("agents", {}).get("defaults", {}).get("id"),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"status": "connected", "agents": agents}
|
||||||
|
|
||||||
|
def gateway_status(self, *, url: str | None = None, token: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw gateway status --json [--url <url>] [--token <token>]`. May fail if gateway is unreachable."""
|
||||||
|
args = ["gateway", "status", "--json"]
|
||||||
|
if url:
|
||||||
|
args.extend(["--url", url])
|
||||||
|
if token:
|
||||||
|
args.extend(["--token", token])
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def memory_status(self, *, agent: str | None = None, deep: bool = False) -> dict[str, Any]:
|
||||||
|
"""Read `openclaw memory status --json [--agent <id>] [--deep]`. Returns array of per-agent status."""
|
||||||
|
args = ["memory", "status", "--json"]
|
||||||
|
if agent:
|
||||||
|
args.extend(["--agent", agent])
|
||||||
|
if deep:
|
||||||
|
args.append("--deep")
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Write agents commands
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def agents_add(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
workspace: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
agent_dir: str | None = None,
|
||||||
|
bind: list[str] | None = None,
|
||||||
|
non_interactive: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run `openclaw agents add <name> [--workspace <dir>] [--model <id>] [--agent-dir <dir>] [--bind <spec>] [--non-interactive] --json`."""
|
||||||
|
args = ["agents", "add", name, "--json"]
|
||||||
|
if workspace:
|
||||||
|
args.extend(["--workspace", workspace])
|
||||||
|
if model:
|
||||||
|
args.extend(["--model", model])
|
||||||
|
if agent_dir:
|
||||||
|
args.extend(["--agent-dir", agent_dir])
|
||||||
|
if bind:
|
||||||
|
for b in bind:
|
||||||
|
args.extend(["--bind", b])
|
||||||
|
if non_interactive:
|
||||||
|
args.append("--non-interactive")
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def agents_delete(self, id: str, *, force: bool = False) -> dict[str, Any]:
|
||||||
|
"""Run `openclaw agents delete <id> [--force] --json`."""
|
||||||
|
args = ["agents", "delete", id, "--json"]
|
||||||
|
if force:
|
||||||
|
args.append("--force")
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def agents_bind(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent: str | None = None,
|
||||||
|
bind: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run `openclaw agents bind [--agent <id>] [--bind <spec>] --json`."""
|
||||||
|
args = ["agents", "bind", "--json"]
|
||||||
|
if agent:
|
||||||
|
args.extend(["--agent", agent])
|
||||||
|
if bind:
|
||||||
|
for b in bind:
|
||||||
|
args.extend(["--bind", b])
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def agents_unbind(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent: str | None = None,
|
||||||
|
bind: list[str] | None = None,
|
||||||
|
all: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run `openclaw agents unbind [--agent <id>] [--bind <spec>] [--all] --json`."""
|
||||||
|
args = ["agents", "unbind", "--json"]
|
||||||
|
if agent:
|
||||||
|
args.extend(["--agent", agent])
|
||||||
|
if bind:
|
||||||
|
for b in bind:
|
||||||
|
args.extend(["--bind", b])
|
||||||
|
if all:
|
||||||
|
args.append("--all")
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def agents_set_identity(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent: str | None = None,
|
||||||
|
workspace: str | None = None,
|
||||||
|
identity_file: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
emoji: str | None = None,
|
||||||
|
theme: str | None = None,
|
||||||
|
avatar: str | None = None,
|
||||||
|
from_identity: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run `openclaw agents set-identity [--agent <id>] [--workspace <dir>] [--identity-file <path>] [--from-identity] [--name <n>] [--emoji <e>] [--theme <t>] [--avatar <a>] --json`."""
|
||||||
|
args = ["agents", "set-identity", "--json"]
|
||||||
|
if agent:
|
||||||
|
args.extend(["--agent", agent])
|
||||||
|
if workspace:
|
||||||
|
args.extend(["--workspace", workspace])
|
||||||
|
if identity_file:
|
||||||
|
args.extend(["--identity-file", identity_file])
|
||||||
|
if from_identity:
|
||||||
|
args.append("--from-identity")
|
||||||
|
if name:
|
||||||
|
args.extend(["--name", name])
|
||||||
|
if emoji:
|
||||||
|
args.extend(["--emoji", emoji])
|
||||||
|
if theme:
|
||||||
|
args.extend(["--theme", theme])
|
||||||
|
if avatar:
|
||||||
|
args.extend(["--avatar", avatar])
|
||||||
|
return self.run_json(args)
|
||||||
|
|
||||||
|
def run_json(self, args: list[str]) -> dict[str, Any]:
|
||||||
|
"""Run the CLI and decode JSON stdout, falling back to stderr."""
|
||||||
|
result = self.run(args)
|
||||||
|
text = result.stdout.strip() or result.stderr.strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
def run(self, args: list[str]) -> OpenClawCliResult:
|
||||||
|
"""Run the CLI and return stdout/stderr."""
|
||||||
|
command = [*self.base_command, *args]
|
||||||
|
env = os.environ.copy()
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=self.cwd,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise OpenClawCliError(
|
||||||
|
"OpenClaw CLI executable was not found.",
|
||||||
|
command=command,
|
||||||
|
) from exc
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise OpenClawCliError(
|
||||||
|
f"OpenClaw CLI timed out after {self.timeout_seconds:.1f}s.",
|
||||||
|
command=command,
|
||||||
|
stdout=exc.stdout or "",
|
||||||
|
stderr=exc.stderr or "",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise OpenClawCliError(
|
||||||
|
"OpenClaw CLI command failed.",
|
||||||
|
command=command,
|
||||||
|
exit_code=completed.returncode,
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return OpenClawCliResult(
|
||||||
|
command=command,
|
||||||
|
exit_code=completed.returncode,
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
)
|
||||||
@@ -11,7 +11,6 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from backend.data.market_store import MarketStore
|
from backend.data.market_store import MarketStore
|
||||||
from .research_db import ResearchDb
|
|
||||||
from .runtime_db import RuntimeDb
|
from .runtime_db import RuntimeDb
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -22,19 +21,25 @@ class StorageService:
|
|||||||
Storage service for data persistence
|
Storage service for data persistence
|
||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
1. Load/save dashboard JSON files
|
1. Export dashboard JSON files
|
||||||
(summary, holdings, stats, trades, leaderboard)
|
(summary, holdings, stats, trades, leaderboard)
|
||||||
2. Load/save internal state (_internal_state.json)
|
2. Load/save internal state (_internal_state.json)
|
||||||
3. Load/save server state (server_state.json) with feed history
|
3. Load/save server state (server_state.json) with feed history
|
||||||
4. Manage portfolio state persistence
|
4. Manage portfolio state persistence
|
||||||
5. Support loading from saved state to resume execution
|
5. Support loading from saved state to resume execution
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- team_dashboard/*.json is treated as an export/compatibility layer
|
||||||
|
rather than the authoritative runtime source of truth.
|
||||||
|
- authoritative runtime reads should prefer in-memory state, server_state,
|
||||||
|
runtime.db, and market_research.db.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
dashboard_dir: Path,
|
dashboard_dir: Path,
|
||||||
initial_cash: float = 100000.0,
|
initial_cash: float = 100000.0,
|
||||||
config_name: str = "mock",
|
config_name: str = "live",
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize storage service
|
Initialize storage service
|
||||||
@@ -49,7 +54,7 @@ class StorageService:
|
|||||||
self.initial_cash = initial_cash
|
self.initial_cash = initial_cash
|
||||||
self.config_name = config_name
|
self.config_name = config_name
|
||||||
|
|
||||||
# Dashboard file paths
|
# Dashboard export file paths
|
||||||
self.files = {
|
self.files = {
|
||||||
"summary": self.dashboard_dir / "summary.json",
|
"summary": self.dashboard_dir / "summary.json",
|
||||||
"holdings": self.dashboard_dir / "holdings.json",
|
"holdings": self.dashboard_dir / "holdings.json",
|
||||||
@@ -66,7 +71,6 @@ class StorageService:
|
|||||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.server_state_file = self.state_dir / "server_state.json"
|
self.server_state_file = self.state_dir / "server_state.json"
|
||||||
self.runtime_db = RuntimeDb(self.state_dir / "runtime.db")
|
self.runtime_db = RuntimeDb(self.state_dir / "runtime.db")
|
||||||
self.research_db = ResearchDb(self.state_dir / "research.db")
|
|
||||||
self.market_store = MarketStore()
|
self.market_store = MarketStore()
|
||||||
|
|
||||||
# Feed history (for agent messages)
|
# Feed history (for agent messages)
|
||||||
@@ -84,16 +88,8 @@ class StorageService:
|
|||||||
|
|
||||||
logger.info(f"Storage service initialized: {self.dashboard_dir}")
|
logger.info(f"Storage service initialized: {self.dashboard_dir}")
|
||||||
|
|
||||||
def load_file(self, file_type: str) -> Optional[Any]:
|
def load_export_file(self, file_type: str) -> Optional[Any]:
|
||||||
"""
|
"""Load dashboard export JSON file."""
|
||||||
Load dashboard JSON file
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_type: One of: summary, holdings, stats, trades, leaderboard
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Loaded data or None if file doesn't exist
|
|
||||||
"""
|
|
||||||
file_path = self.files.get(file_type)
|
file_path = self.files.get(file_type)
|
||||||
if not file_path or not file_path.exists():
|
if not file_path or not file_path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -105,14 +101,12 @@ class StorageService:
|
|||||||
logger.error(f"Failed to load {file_type}.json: {e}")
|
logger.error(f"Failed to load {file_type}.json: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def save_file(self, file_type: str, data: Any):
|
def load_file(self, file_type: str) -> Optional[Any]:
|
||||||
"""
|
"""Backward-compatible alias for export-layer JSON reads."""
|
||||||
Save dashboard JSON file
|
return self.load_export_file(file_type)
|
||||||
|
|
||||||
Args:
|
def save_export_file(self, file_type: str, data: Any):
|
||||||
file_type: One of: summary, holdings, stats, trades, leaderboard
|
"""Save dashboard export JSON file."""
|
||||||
data: Data to save
|
|
||||||
"""
|
|
||||||
file_path = self.files.get(file_type)
|
file_path = self.files.get(file_type)
|
||||||
if not file_path:
|
if not file_path:
|
||||||
logger.error(f"Unknown file type: {file_type}")
|
logger.error(f"Unknown file type: {file_type}")
|
||||||
@@ -129,6 +123,48 @@ class StorageService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save {file_type}.json: {e}")
|
logger.error(f"Failed to save {file_type}.json: {e}")
|
||||||
|
|
||||||
|
def save_file(self, file_type: str, data: Any):
|
||||||
|
"""Backward-compatible alias for export-layer JSON writes."""
|
||||||
|
self.save_export_file(file_type, data)
|
||||||
|
|
||||||
|
def build_dashboard_snapshot_from_state(
|
||||||
|
self,
|
||||||
|
state: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Build dashboard view data from runtime state instead of JSON exports."""
|
||||||
|
runtime_state = state or self.load_server_state()
|
||||||
|
portfolio = dict(runtime_state.get("portfolio") or {})
|
||||||
|
holdings = list(runtime_state.get("holdings") or [])
|
||||||
|
stats = runtime_state.get("stats") or self._get_default_stats()
|
||||||
|
trades = list(runtime_state.get("trades") or [])
|
||||||
|
leaderboard = list(runtime_state.get("leaderboard") or [])
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"totalAssetValue": portfolio.get("total_value", self.initial_cash),
|
||||||
|
"totalReturn": portfolio.get("pnl_percent", 0.0),
|
||||||
|
"cashPosition": portfolio.get("cash", self.initial_cash),
|
||||||
|
"tickerWeights": stats.get("tickerWeights", {}),
|
||||||
|
"totalTrades": len(trades),
|
||||||
|
"pnlPct": portfolio.get("pnl_percent", 0.0),
|
||||||
|
"balance": portfolio.get("total_value", self.initial_cash),
|
||||||
|
"equity": portfolio.get("equity", []),
|
||||||
|
"baseline": portfolio.get("baseline", []),
|
||||||
|
"baseline_vw": portfolio.get("baseline_vw", []),
|
||||||
|
"momentum": portfolio.get("momentum", []),
|
||||||
|
"equity_return": portfolio.get("equity_return", []),
|
||||||
|
"baseline_return": portfolio.get("baseline_return", []),
|
||||||
|
"baseline_vw_return": portfolio.get("baseline_vw_return", []),
|
||||||
|
"momentum_return": portfolio.get("momentum_return", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"holdings": holdings,
|
||||||
|
"stats": stats,
|
||||||
|
"trades": trades,
|
||||||
|
"leaderboard": leaderboard,
|
||||||
|
}
|
||||||
|
|
||||||
def check_file_updates(self) -> Dict[str, bool]:
|
def check_file_updates(self) -> Dict[str, bool]:
|
||||||
"""
|
"""
|
||||||
Check which dashboard files have been updated since last check
|
Check which dashboard files have been updated since last check
|
||||||
@@ -297,7 +333,7 @@ class StorageService:
|
|||||||
def initialize_empty_dashboard(self):
|
def initialize_empty_dashboard(self):
|
||||||
"""Initialize empty dashboard files with default values"""
|
"""Initialize empty dashboard files with default values"""
|
||||||
# Summary
|
# Summary
|
||||||
self.save_file(
|
self.save_export_file(
|
||||||
"summary",
|
"summary",
|
||||||
{
|
{
|
||||||
"totalAssetValue": self.initial_cash,
|
"totalAssetValue": self.initial_cash,
|
||||||
@@ -315,10 +351,10 @@ class StorageService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Holdings
|
# Holdings
|
||||||
self.save_file("holdings", [])
|
self.save_export_file("holdings", [])
|
||||||
|
|
||||||
# Stats
|
# Stats
|
||||||
self.save_file(
|
self.save_export_file(
|
||||||
"stats",
|
"stats",
|
||||||
{
|
{
|
||||||
"totalAssetValue": self.initial_cash,
|
"totalAssetValue": self.initial_cash,
|
||||||
@@ -335,7 +371,7 @@ class StorageService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Trades
|
# Trades
|
||||||
self.save_file("trades", [])
|
self.save_export_file("trades", [])
|
||||||
|
|
||||||
# Leaderboard with model info
|
# Leaderboard with model info
|
||||||
self.generate_leaderboard()
|
self.generate_leaderboard()
|
||||||
@@ -375,7 +411,7 @@ class StorageService:
|
|||||||
ranking_entries.append(entry)
|
ranking_entries.append(entry)
|
||||||
|
|
||||||
leaderboard = team_entries + ranking_entries
|
leaderboard = team_entries + ranking_entries
|
||||||
self.save_file("leaderboard", leaderboard)
|
self.save_export_file("leaderboard", leaderboard)
|
||||||
logger.info("Leaderboard generated with model info")
|
logger.info("Leaderboard generated with model info")
|
||||||
|
|
||||||
def update_leaderboard_model_info(self):
|
def update_leaderboard_model_info(self):
|
||||||
@@ -398,7 +434,7 @@ class StorageService:
|
|||||||
entry["modelName"] = model_name
|
entry["modelName"] = model_name
|
||||||
entry["modelProvider"] = model_provider
|
entry["modelProvider"] = model_provider
|
||||||
|
|
||||||
self.save_file("leaderboard", existing)
|
self.save_export_file("leaderboard", existing)
|
||||||
logger.info("Leaderboard model info updated")
|
logger.info("Leaderboard model info updated")
|
||||||
|
|
||||||
def get_current_timestamp_ms(self, date: str = None) -> int:
|
def get_current_timestamp_ms(self, date: str = None) -> int:
|
||||||
@@ -653,7 +689,7 @@ class StorageService:
|
|||||||
"momentum": state.get("momentum_history", []),
|
"momentum": state.get("momentum_history", []),
|
||||||
}
|
}
|
||||||
|
|
||||||
self.save_file("summary", summary)
|
self.save_export_file("summary", summary)
|
||||||
|
|
||||||
def _generate_holdings(
|
def _generate_holdings(
|
||||||
self,
|
self,
|
||||||
@@ -715,7 +751,7 @@ class StorageService:
|
|||||||
# Sort by weight
|
# Sort by weight
|
||||||
holdings.sort(key=lambda x: abs(x["weight"]), reverse=True)
|
holdings.sort(key=lambda x: abs(x["weight"]), reverse=True)
|
||||||
|
|
||||||
self.save_file("holdings", holdings)
|
self.save_export_file("holdings", holdings)
|
||||||
|
|
||||||
def _generate_stats(self, state: Dict[str, Any], net_value: float):
|
def _generate_stats(self, state: Dict[str, Any], net_value: float):
|
||||||
"""Generate stats.json"""
|
"""Generate stats.json"""
|
||||||
@@ -738,7 +774,7 @@ class StorageService:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
self.save_file("stats", stats)
|
self.save_export_file("stats", stats)
|
||||||
|
|
||||||
def _generate_trades(self, state: Dict[str, Any]):
|
def _generate_trades(self, state: Dict[str, Any]):
|
||||||
"""Generate trades.json"""
|
"""Generate trades.json"""
|
||||||
@@ -764,7 +800,7 @@ class StorageService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.save_file("trades", trades)
|
self.save_export_file("trades", trades)
|
||||||
|
|
||||||
# Server State Management Methods
|
# Server State Management Methods
|
||||||
|
|
||||||
@@ -1001,12 +1037,12 @@ class StorageService:
|
|||||||
Args:
|
Args:
|
||||||
state: Server state dictionary to update
|
state: Server state dictionary to update
|
||||||
"""
|
"""
|
||||||
# Load dashboard data
|
dashboard_snapshot = self.build_dashboard_snapshot_from_state(state)
|
||||||
summary = self.load_file("summary") or {}
|
summary = dashboard_snapshot.get("summary") or {}
|
||||||
holdings = self.load_file("holdings") or []
|
holdings = dashboard_snapshot.get("holdings") or []
|
||||||
stats = self.load_file("stats") or self._get_default_stats()
|
stats = dashboard_snapshot.get("stats") or self._get_default_stats()
|
||||||
trades = self.load_file("trades") or []
|
trades = dashboard_snapshot.get("trades") or []
|
||||||
leaderboard = self.load_file("leaderboard") or []
|
leaderboard = dashboard_snapshot.get("leaderboard") or []
|
||||||
internal_state = self.load_internal_state()
|
internal_state = self.load_internal_state()
|
||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
@@ -1040,7 +1076,6 @@ class StorageService:
|
|||||||
Start tracking live returns for current trading session.
|
Start tracking live returns for current trading session.
|
||||||
Captures current values as session start baseline.
|
Captures current values as session start baseline.
|
||||||
"""
|
"""
|
||||||
summary = self.load_file("summary") or {}
|
|
||||||
state = self.load_internal_state()
|
state = self.load_internal_state()
|
||||||
|
|
||||||
# Capture current values as session start
|
# Capture current values as session start
|
||||||
@@ -1052,7 +1087,7 @@ class StorageService:
|
|||||||
self._session_start_equity = (
|
self._session_start_equity = (
|
||||||
equity_history[-1]["v"]
|
equity_history[-1]["v"]
|
||||||
if equity_history
|
if equity_history
|
||||||
else summary.get("totalAssetValue", self.initial_cash)
|
else self.initial_cash
|
||||||
)
|
)
|
||||||
self._session_start_baseline = (
|
self._session_start_baseline = (
|
||||||
baseline_history[-1]["v"]
|
baseline_history[-1]["v"]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from backend.apps.agent_service import create_app
|
from backend.apps.agent_service import create_app
|
||||||
|
from backend.api import agents as agents_module
|
||||||
|
|
||||||
|
|
||||||
def test_agent_service_routes_include_control_plane_endpoints(tmp_path):
|
def test_agent_service_routes_include_control_plane_endpoints(tmp_path):
|
||||||
@@ -25,3 +26,79 @@ def test_agent_service_excludes_runtime_routes(tmp_path):
|
|||||||
|
|
||||||
assert "/api/runtime/start" not in paths
|
assert "/api/runtime/start" not in paths
|
||||||
assert "/api/runtime/gateway/port" not in paths
|
assert "/api/runtime/gateway/port" not in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_service_read_routes(monkeypatch, tmp_path):
|
||||||
|
class _FakeSkillsManager:
|
||||||
|
project_root = tmp_path
|
||||||
|
|
||||||
|
def get_agent_asset_dir(self, config_name, agent_id):
|
||||||
|
return tmp_path / "runs" / config_name / "agents" / agent_id
|
||||||
|
|
||||||
|
def resolve_agent_skill_names(self, config_name, agent_id, default_skills=None):
|
||||||
|
return ["demo_skill"]
|
||||||
|
|
||||||
|
def list_agent_skill_catalog(self, config_name, agent_id):
|
||||||
|
return [
|
||||||
|
type(
|
||||||
|
"Skill",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"skill_name": "demo_skill",
|
||||||
|
"name": "Demo Skill",
|
||||||
|
"description": "demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "builtin",
|
||||||
|
"tools": [],
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
]
|
||||||
|
|
||||||
|
def load_agent_skill_document(self, config_name, agent_id, skill_name):
|
||||||
|
return {"skill_name": skill_name, "content": "# demo"}
|
||||||
|
|
||||||
|
class _FakeWorkspaceManager:
|
||||||
|
def load_agent_file(self, config_name, agent_id, filename):
|
||||||
|
return f"{config_name}:{agent_id}:{filename}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(agents_module, "load_agent_profiles", lambda: {"portfolio_manager": {"skills": ["demo_skill"]}})
|
||||||
|
monkeypatch.setattr(agents_module, "get_agent_model_info", lambda agent_id: ("deepseek-v3.2", "DASHSCOPE"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agents_module,
|
||||||
|
"load_agent_workspace_config",
|
||||||
|
lambda path: type(
|
||||||
|
"Cfg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"active_tool_groups": ["portfolio_ops"],
|
||||||
|
"disabled_tool_groups": [],
|
||||||
|
"enabled_skills": [],
|
||||||
|
"disabled_skills": [],
|
||||||
|
"prompt_files": ["SOUL.md", "MEMORY.md"],
|
||||||
|
},
|
||||||
|
)(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agents_module,
|
||||||
|
"get_bootstrap_config_for_run",
|
||||||
|
lambda project_root, config_name: type("Bootstrap", (), {"agent_override": lambda self, agent_id: {}})(),
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_app(project_root=tmp_path)
|
||||||
|
app.dependency_overrides[agents_module.get_skills_manager] = lambda: _FakeSkillsManager()
|
||||||
|
app.dependency_overrides[agents_module.get_workspace_manager] = lambda: _FakeWorkspaceManager()
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
profile = client.get("/api/workspaces/demo/agents/portfolio_manager/profile")
|
||||||
|
skills = client.get("/api/workspaces/demo/agents/portfolio_manager/skills")
|
||||||
|
detail = client.get("/api/workspaces/demo/agents/portfolio_manager/skills/demo_skill")
|
||||||
|
workspace_file = client.get("/api/workspaces/demo/agents/portfolio_manager/files/MEMORY.md")
|
||||||
|
|
||||||
|
assert profile.status_code == 200
|
||||||
|
assert profile.json()["profile"]["model_name"] == "deepseek-v3.2"
|
||||||
|
assert skills.status_code == 200
|
||||||
|
assert skills.json()["skills"][0]["skill_name"] == "demo_skill"
|
||||||
|
assert detail.status_code == 200
|
||||||
|
assert detail.json()["skill"]["content"] == "# demo"
|
||||||
|
assert workspace_file.status_code == 200
|
||||||
|
assert workspace_file.json()["content"] == "demo:portfolio_manager:MEMORY.md"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class _DummyToolkit:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_manager_creates_extended_agent_files(tmp_path):
|
def test_workspace_manager_creates_core_agent_files(tmp_path):
|
||||||
manager = WorkspaceManager(project_root=tmp_path)
|
manager = WorkspaceManager(project_root=tmp_path)
|
||||||
|
|
||||||
manager.initialize_default_assets(
|
manager.initialize_default_assets(
|
||||||
@@ -27,7 +27,7 @@ def test_workspace_manager_creates_extended_agent_files(tmp_path):
|
|||||||
assert (asset_dir / "PROFILE.md").exists()
|
assert (asset_dir / "PROFILE.md").exists()
|
||||||
assert (asset_dir / "AGENTS.md").exists()
|
assert (asset_dir / "AGENTS.md").exists()
|
||||||
assert (asset_dir / "MEMORY.md").exists()
|
assert (asset_dir / "MEMORY.md").exists()
|
||||||
assert (asset_dir / "HEARTBEAT.md").exists()
|
assert (asset_dir / "POLICY.md").exists()
|
||||||
assert (asset_dir / "agent.yaml").exists()
|
assert (asset_dir / "agent.yaml").exists()
|
||||||
assert (asset_dir / "skills" / "installed").is_dir()
|
assert (asset_dir / "skills" / "installed").is_dir()
|
||||||
assert (asset_dir / "skills" / "active").is_dir()
|
assert (asset_dir / "skills" / "active").is_dir()
|
||||||
@@ -35,6 +35,22 @@ def test_workspace_manager_creates_extended_agent_files(tmp_path):
|
|||||||
assert (asset_dir / "skills" / "local").is_dir()
|
assert (asset_dir / "skills" / "local").is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_manager_seeds_risk_prompt_content(tmp_path):
|
||||||
|
manager = WorkspaceManager(project_root=tmp_path)
|
||||||
|
manager.initialize_default_assets(
|
||||||
|
config_name="demo",
|
||||||
|
agent_ids=["risk_manager"],
|
||||||
|
analyst_personas={},
|
||||||
|
)
|
||||||
|
|
||||||
|
asset_dir = tmp_path / "runs" / "demo" / "agents" / "risk_manager"
|
||||||
|
soul = (asset_dir / "SOUL.md").read_text(encoding="utf-8")
|
||||||
|
guide = (asset_dir / "AGENTS.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "风险管理经理" in soul
|
||||||
|
assert "优先使用可用的风险工具量化集中度" in guide
|
||||||
|
|
||||||
|
|
||||||
def test_agent_workspace_config_controls_prompt_files(tmp_path, monkeypatch):
|
def test_agent_workspace_config_controls_prompt_files(tmp_path, monkeypatch):
|
||||||
manager = WorkspaceManager(project_root=tmp_path)
|
manager = WorkspaceManager(project_root=tmp_path)
|
||||||
manager.initialize_default_assets(
|
manager.initialize_default_assets(
|
||||||
@@ -72,6 +88,32 @@ def test_agent_workspace_config_controls_prompt_files(tmp_path, monkeypatch):
|
|||||||
assert "profile-line" not in prompt
|
assert "profile-line" not in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_is_built_from_workspace_defaults_without_system_templates(tmp_path, monkeypatch):
|
||||||
|
manager = WorkspaceManager(project_root=tmp_path)
|
||||||
|
manager.initialize_default_assets(
|
||||||
|
config_name="demo",
|
||||||
|
agent_ids=["portfolio_manager"],
|
||||||
|
analyst_personas={},
|
||||||
|
)
|
||||||
|
|
||||||
|
from backend.agents import prompt_factory
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
prompt_factory,
|
||||||
|
"SkillsManager",
|
||||||
|
lambda: SkillsManager(project_root=tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = build_agent_system_prompt(
|
||||||
|
agent_id="portfolio_manager",
|
||||||
|
config_name="demo",
|
||||||
|
toolkit=_DummyToolkit(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "投资组合经理" in prompt
|
||||||
|
assert "使用 `make_decision` 工具记录每个股票的最终决策" in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_skills_manager_applies_agent_level_skill_toggles(tmp_path):
|
def test_skills_manager_applies_agent_level_skill_toggles(tmp_path):
|
||||||
builtin_root = tmp_path / "backend" / "skills" / "builtin"
|
builtin_root = tmp_path / "backend" / "skills" / "builtin"
|
||||||
for skill_name in ("risk_review", "extra_guard"):
|
for skill_name in ("risk_review", "extra_guard"):
|
||||||
|
|||||||
@@ -311,6 +311,17 @@ class TestRiskAgent:
|
|||||||
|
|
||||||
|
|
||||||
class TestStorageService:
|
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):
|
def test_calculate_portfolio_value_cash_only(self):
|
||||||
from backend.services.storage import StorageService
|
from backend.services.storage import StorageService
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ def test_live_runs_incremental_market_store_update_before_start(monkeypatch, tmp
|
|||||||
monkeypatch.setattr(cli.subprocess, "run", fake_run)
|
monkeypatch.setattr(cli.subprocess, "run", fake_run)
|
||||||
|
|
||||||
cli.live(
|
cli.live(
|
||||||
mock=False,
|
|
||||||
config_name="smoke_fullstack",
|
config_name="smoke_fullstack",
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=8765,
|
port=8765,
|
||||||
|
|||||||
@@ -8,24 +8,6 @@ import pytest
|
|||||||
from backend.services import gateway_cycle_support, gateway_runtime_support
|
from backend.services import gateway_cycle_support, gateway_runtime_support
|
||||||
|
|
||||||
|
|
||||||
class _DummyDashboard:
|
|
||||||
def __init__(self):
|
|
||||||
self.updated = []
|
|
||||||
self.tickers = []
|
|
||||||
self.initial_cash = None
|
|
||||||
self.enable_memory = False
|
|
||||||
self.days_total = 0
|
|
||||||
|
|
||||||
def update(self, **kwargs):
|
|
||||||
self.updated.append(kwargs)
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def print_final_summary(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _DummyScheduler:
|
class _DummyScheduler:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
@@ -77,6 +59,15 @@ class _DummyStorage:
|
|||||||
return {"totalAssetValue": self.initial_cash}
|
return {"totalAssetValue": self.initial_cash}
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def build_dashboard_snapshot_from_state(self, state):
|
||||||
|
return {
|
||||||
|
"summary": {"totalAssetValue": self.initial_cash},
|
||||||
|
"holdings": [],
|
||||||
|
"stats": {},
|
||||||
|
"trades": [],
|
||||||
|
"leaderboard": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class _DummyPM:
|
class _DummyPM:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -119,7 +110,6 @@ def make_gateway_stub():
|
|||||||
},
|
},
|
||||||
storage=_DummyStorage(),
|
storage=_DummyStorage(),
|
||||||
state_sync=_DummyStateSync(),
|
state_sync=_DummyStateSync(),
|
||||||
_dashboard=_DummyDashboard(),
|
|
||||||
_watchlist_ingest_task=None,
|
_watchlist_ingest_task=None,
|
||||||
_market_status_task=None,
|
_market_status_task=None,
|
||||||
_backtest_task=None,
|
_backtest_task=None,
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""Tests for HeartbeatHook."""
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from backend.agents.base.hooks import HeartbeatHook
|
|
||||||
|
|
||||||
|
|
||||||
class TestHeartbeatHook:
|
|
||||||
"""Tests for HeartbeatHook._read_heartbeat_content."""
|
|
||||||
|
|
||||||
def test_read_heartbeat_content_with_content(self, tmp_path):
|
|
||||||
"""Test reading HEARTBEAT.md when it exists and has content."""
|
|
||||||
ws_dir = tmp_path / "analyst_workspace"
|
|
||||||
ws_dir.mkdir()
|
|
||||||
hb_file = ws_dir / "HEARTBEAT.md"
|
|
||||||
hb_file.write_text("# 定期主动检查\n\n- [ ] 持仓是否健康\n", encoding="utf-8")
|
|
||||||
|
|
||||||
hook = HeartbeatHook(workspace_dir=ws_dir)
|
|
||||||
content = hook._read_heartbeat_content()
|
|
||||||
|
|
||||||
assert content is not None
|
|
||||||
assert "# 定期主动检查" in content
|
|
||||||
assert "持仓是否健康" in content
|
|
||||||
|
|
||||||
def test_read_heartbeat_content_absent(self, tmp_path):
|
|
||||||
"""Test reading when HEARTBEAT.md does not exist."""
|
|
||||||
ws_dir = tmp_path / "analyst_workspace"
|
|
||||||
ws_dir.mkdir()
|
|
||||||
|
|
||||||
hook = HeartbeatHook(workspace_dir=ws_dir)
|
|
||||||
content = hook._read_heartbeat_content()
|
|
||||||
|
|
||||||
assert content is None
|
|
||||||
|
|
||||||
def test_read_heartbeat_content_empty(self, tmp_path):
|
|
||||||
"""Test reading when HEARTBEAT.md is empty."""
|
|
||||||
ws_dir = tmp_path / "analyst_workspace"
|
|
||||||
ws_dir.mkdir()
|
|
||||||
hb_file = ws_dir / "HEARTBEAT.md"
|
|
||||||
hb_file.write_text("", encoding="utf-8")
|
|
||||||
|
|
||||||
hook = HeartbeatHook(workspace_dir=ws_dir)
|
|
||||||
content = hook._read_heartbeat_content()
|
|
||||||
|
|
||||||
assert content is None
|
|
||||||
|
|
||||||
def test_read_heartbeat_content_whitespace_only(self, tmp_path):
|
|
||||||
"""Test reading when HEARTBEAT.md contains only whitespace."""
|
|
||||||
ws_dir = tmp_path / "analyst_workspace"
|
|
||||||
ws_dir.mkdir()
|
|
||||||
hb_file = ws_dir / "HEARTBEAT.md"
|
|
||||||
hb_file.write_text(" \n\n ", encoding="utf-8")
|
|
||||||
|
|
||||||
hook = HeartbeatHook(workspace_dir=ws_dir)
|
|
||||||
content = hook._read_heartbeat_content()
|
|
||||||
|
|
||||||
assert content is None
|
|
||||||
|
|
||||||
def test_completed_flag_path(self, tmp_path):
|
|
||||||
"""Test that completion flag is placed in workspace directory."""
|
|
||||||
ws_dir = tmp_path / "analyst_workspace"
|
|
||||||
ws_dir.mkdir()
|
|
||||||
|
|
||||||
hook = HeartbeatHook(workspace_dir=ws_dir)
|
|
||||||
|
|
||||||
assert hook._completed_flag == ws_dir / ".heartbeat_completed"
|
|
||||||
81
backend/tests/test_market_ingest.py
Normal file
81
backend/tests/test_market_ingest.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for market ingest watermark handling."""
|
||||||
|
|
||||||
|
from backend.data import market_ingest
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStore:
|
||||||
|
def __init__(self, *, last_news_fetch=None, latest_news_date=None):
|
||||||
|
self._watermarks = {
|
||||||
|
"symbol": "AAPL",
|
||||||
|
"last_price_fetch": None,
|
||||||
|
"last_news_fetch": last_news_fetch,
|
||||||
|
}
|
||||||
|
self._latest_news_date = latest_news_date
|
||||||
|
self.updated = []
|
||||||
|
|
||||||
|
def get_ticker_watermarks(self, symbol):
|
||||||
|
return dict(self._watermarks)
|
||||||
|
|
||||||
|
def get_latest_news_date(self, symbol):
|
||||||
|
return self._latest_news_date
|
||||||
|
|
||||||
|
def upsert_ticker(self, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def upsert_ohlc(self, symbol, rows, source="polygon"):
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
def upsert_news(self, symbol, rows, source="polygon"):
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
def update_fetch_watermark(self, **kwargs):
|
||||||
|
self.updated.append(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_news_incremental_does_not_advance_watermark_without_news(monkeypatch):
|
||||||
|
store = _FakeStore(last_news_fetch="2026-03-28", latest_news_date="2026-03-28")
|
||||||
|
|
||||||
|
monkeypatch.setattr(market_ingest, "fetch_ticker_details", lambda ticker: {"name": ticker, "sic_description": None, "active": True})
|
||||||
|
|
||||||
|
class _Router:
|
||||||
|
def get_company_news(self, **kwargs):
|
||||||
|
return [], "polygon"
|
||||||
|
|
||||||
|
monkeypatch.setattr(market_ingest, "DataProviderRouter", lambda: _Router())
|
||||||
|
monkeypatch.setattr(market_ingest, "align_news_for_symbol", lambda store, ticker: 0)
|
||||||
|
|
||||||
|
result = market_ingest.refresh_news_incremental(
|
||||||
|
"AAPL",
|
||||||
|
end_date="2026-03-29",
|
||||||
|
store=store,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["start_news_date"] == "2026-03-29"
|
||||||
|
assert result["news"] == 0
|
||||||
|
assert store.updated[-1]["news_date"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_news_incremental_clamps_future_watermark_to_latest_stored_date(monkeypatch):
|
||||||
|
store = _FakeStore(last_news_fetch="2026-03-30", latest_news_date="2026-03-28")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(market_ingest, "fetch_ticker_details", lambda ticker: {"name": ticker, "sic_description": None, "active": True})
|
||||||
|
|
||||||
|
class _Router:
|
||||||
|
def get_company_news(self, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return [], "polygon"
|
||||||
|
|
||||||
|
monkeypatch.setattr(market_ingest, "DataProviderRouter", lambda: _Router())
|
||||||
|
monkeypatch.setattr(market_ingest, "align_news_for_symbol", lambda store, ticker: 0)
|
||||||
|
|
||||||
|
result = market_ingest.refresh_news_incremental(
|
||||||
|
"AAPL",
|
||||||
|
end_date="2026-03-29",
|
||||||
|
store=store,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["start_news_date"] == "2026-03-29"
|
||||||
|
assert captured["start_date"] == "2026-03-29"
|
||||||
|
assert captured["end_date"] == "2026-03-29"
|
||||||
@@ -2,153 +2,12 @@
|
|||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
from unittest.mock import MagicMock, AsyncMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
from backend.services.market import MarketService
|
from backend.services.market import MarketService
|
||||||
from backend.data.mock_price_manager import MockPriceManager
|
|
||||||
from backend.data.polling_price_manager import PollingPriceManager
|
from backend.data.polling_price_manager import PollingPriceManager
|
||||||
|
from backend.llm.models import RetryChatModel
|
||||||
|
|
||||||
class TestMockPriceManager:
|
|
||||||
def test_init_default(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
|
|
||||||
assert manager.poll_interval == 10
|
|
||||||
assert manager.volatility == 0.5
|
|
||||||
assert manager.running is False
|
|
||||||
assert len(manager.subscribed_symbols) == 0
|
|
||||||
|
|
||||||
def test_init_custom(self):
|
|
||||||
manager = MockPriceManager(poll_interval=5, volatility=1.0)
|
|
||||||
|
|
||||||
assert manager.poll_interval == 5
|
|
||||||
assert manager.volatility == 1.0
|
|
||||||
|
|
||||||
def test_subscribe(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL", "MSFT"])
|
|
||||||
|
|
||||||
assert "AAPL" in manager.subscribed_symbols
|
|
||||||
assert "MSFT" in manager.subscribed_symbols
|
|
||||||
assert manager.base_prices["AAPL"] == 237.50 # default price
|
|
||||||
assert manager.base_prices["MSFT"] == 425.30 # default price
|
|
||||||
|
|
||||||
def test_subscribe_with_base_prices(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
assert manager.base_prices["AAPL"] == 100.0
|
|
||||||
assert manager.open_prices["AAPL"] == 100.0
|
|
||||||
assert manager.latest_prices["AAPL"] == 100.0
|
|
||||||
|
|
||||||
def test_subscribe_unknown_symbol(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["UNKNOWN"])
|
|
||||||
|
|
||||||
assert "UNKNOWN" in manager.subscribed_symbols
|
|
||||||
assert manager.base_prices["UNKNOWN"] > 0 # random price generated
|
|
||||||
|
|
||||||
def test_unsubscribe(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL", "MSFT"])
|
|
||||||
manager.unsubscribe(["AAPL"])
|
|
||||||
|
|
||||||
assert "AAPL" not in manager.subscribed_symbols
|
|
||||||
assert "MSFT" in manager.subscribed_symbols
|
|
||||||
|
|
||||||
def test_add_price_callback(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
callback = MagicMock()
|
|
||||||
manager.add_price_callback(callback)
|
|
||||||
|
|
||||||
assert callback in manager.price_callbacks
|
|
||||||
|
|
||||||
def test_generate_price_update_within_bounds(self):
|
|
||||||
manager = MockPriceManager(volatility=0.5)
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
for _ in range(100):
|
|
||||||
new_price = manager._generate_price_update("AAPL")
|
|
||||||
# Should be within +/-10% of open
|
|
||||||
assert 90.0 <= new_price <= 110.0
|
|
||||||
|
|
||||||
def test_update_prices_triggers_callback(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
callback = MagicMock()
|
|
||||||
manager.add_price_callback(callback)
|
|
||||||
|
|
||||||
manager._update_prices()
|
|
||||||
|
|
||||||
callback.assert_called_once()
|
|
||||||
call_args = callback.call_args[0][0]
|
|
||||||
assert call_args["symbol"] == "AAPL"
|
|
||||||
assert "price" in call_args
|
|
||||||
assert "timestamp" in call_args
|
|
||||||
|
|
||||||
def test_start_stop(self):
|
|
||||||
manager = MockPriceManager(poll_interval=1)
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
manager.start()
|
|
||||||
assert manager.running is True
|
|
||||||
|
|
||||||
time.sleep(0.1) # let thread start
|
|
||||||
|
|
||||||
manager.stop()
|
|
||||||
assert manager.running is False
|
|
||||||
|
|
||||||
def test_start_without_subscription(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.start()
|
|
||||||
|
|
||||||
assert (
|
|
||||||
manager.running is False
|
|
||||||
) # should not start without subscriptions
|
|
||||||
|
|
||||||
def test_get_latest_price(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
price = manager.get_latest_price("AAPL")
|
|
||||||
assert price == 100.0
|
|
||||||
|
|
||||||
def test_get_latest_price_unknown(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
price = manager.get_latest_price("UNKNOWN")
|
|
||||||
assert price is None
|
|
||||||
|
|
||||||
def test_get_all_latest_prices(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(
|
|
||||||
["AAPL", "MSFT"],
|
|
||||||
base_prices={"AAPL": 100.0, "MSFT": 200.0},
|
|
||||||
)
|
|
||||||
|
|
||||||
prices = manager.get_all_latest_prices()
|
|
||||||
assert prices["AAPL"] == 100.0
|
|
||||||
assert prices["MSFT"] == 200.0
|
|
||||||
|
|
||||||
def test_reset_open_prices(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
manager.latest_prices["AAPL"] = 105.0
|
|
||||||
|
|
||||||
manager.reset_open_prices()
|
|
||||||
|
|
||||||
# Open price should change (based on latest with small gap)
|
|
||||||
assert manager.open_prices["AAPL"] != 100.0
|
|
||||||
|
|
||||||
def test_set_base_price(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
manager.set_base_price("AAPL", 150.0)
|
|
||||||
|
|
||||||
assert manager.base_prices["AAPL"] == 150.0
|
|
||||||
assert manager.open_prices["AAPL"] == 150.0
|
|
||||||
assert manager.latest_prices["AAPL"] == 150.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestPollingPriceManager:
|
class TestPollingPriceManager:
|
||||||
@@ -231,37 +90,67 @@ class TestPollingPriceManager:
|
|||||||
|
|
||||||
assert len(manager.open_prices) == 0
|
assert len(manager.open_prices) == 0
|
||||||
|
|
||||||
|
def test_fetch_prices_suppresses_repeated_failures(self, caplog):
|
||||||
|
manager = PollingPriceManager(provider="yfinance", poll_interval=10)
|
||||||
|
manager.subscribe(["AAPL"])
|
||||||
|
|
||||||
|
with patch.object(manager, "_fetch_quote", side_effect=ValueError("empty quote")):
|
||||||
|
with caplog.at_level(logging.DEBUG):
|
||||||
|
for _ in range(3):
|
||||||
|
manager._fetch_prices()
|
||||||
|
|
||||||
|
assert manager._failure_counts["AAPL"] == 3
|
||||||
|
warning_messages = [record.message for record in caplog.records if record.levelno >= logging.WARNING]
|
||||||
|
assert any("Failed to fetch AAPL price: empty quote" in message for message in warning_messages)
|
||||||
|
|
||||||
|
def test_fetch_prices_logs_recovery_after_failure(self, caplog):
|
||||||
|
manager = PollingPriceManager(provider="yfinance", poll_interval=10)
|
||||||
|
manager.subscribe(["AAPL"])
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
manager,
|
||||||
|
"_fetch_quote",
|
||||||
|
side_effect=[
|
||||||
|
ValueError("temporary outage"),
|
||||||
|
{"c": 100.0, "o": 99.0, "h": 101.0, "l": 98.0, "pc": 99.5, "d": 0.5, "dp": 0.5, "t": 1},
|
||||||
|
],
|
||||||
|
):
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
manager._fetch_prices()
|
||||||
|
manager._fetch_prices()
|
||||||
|
|
||||||
|
assert "AAPL" not in manager._failure_counts
|
||||||
|
assert any("recovered after 1 consecutive failures" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryChatModel:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_retry_recovers_from_disconnect(self):
|
||||||
|
attempts = {"count": 0}
|
||||||
|
|
||||||
|
class FakeAsyncModel:
|
||||||
|
model_name = "fake-async-model"
|
||||||
|
|
||||||
|
async def __call__(self, *args, **kwargs):
|
||||||
|
attempts["count"] += 1
|
||||||
|
if attempts["count"] < 2:
|
||||||
|
raise RuntimeError("Server disconnected")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
wrapped = RetryChatModel(FakeAsyncModel(), max_retries=2, initial_delay=0.01)
|
||||||
|
result = await wrapped("hello")
|
||||||
|
|
||||||
|
assert result == {"ok": True}
|
||||||
|
assert attempts["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
class TestMarketService:
|
class TestMarketService:
|
||||||
def test_init_mock_mode(self):
|
@patch("backend.services.market.get_data_sources", return_value=["yfinance", "local_csv"])
|
||||||
service = MarketService(
|
|
||||||
tickers=["AAPL", "MSFT"],
|
|
||||||
poll_interval=10,
|
|
||||||
mock_mode=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert service.tickers == ["AAPL", "MSFT"]
|
|
||||||
assert service.poll_interval == 10
|
|
||||||
assert service.mock_mode is True
|
|
||||||
assert service.running is False
|
|
||||||
|
|
||||||
def test_init_real_mode(self):
|
|
||||||
service = MarketService(
|
|
||||||
tickers=["AAPL"],
|
|
||||||
mock_mode=False,
|
|
||||||
api_key="test_key",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert service.mock_mode is False
|
|
||||||
assert service.api_key == "test_key"
|
|
||||||
|
|
||||||
@patch("backend.services.market.get_data_source", return_value="yfinance")
|
|
||||||
@patch.object(PollingPriceManager, "start")
|
@patch.object(PollingPriceManager, "start")
|
||||||
def test_start_real_mode_with_yfinance(self, _mock_start, _mock_source):
|
def test_start_real_mode_with_yfinance(self, _mock_start, _mock_sources):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
poll_interval=10,
|
poll_interval=10,
|
||||||
mock_mode=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
service._start_real_mode()
|
service._start_real_mode()
|
||||||
@@ -269,30 +158,24 @@ class TestMarketService:
|
|||||||
assert isinstance(service._price_manager, PollingPriceManager)
|
assert isinstance(service._price_manager, PollingPriceManager)
|
||||||
assert service._price_manager.provider == "yfinance"
|
assert service._price_manager.provider == "yfinance"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@patch("backend.services.market.get_data_sources", return_value=["financial_datasets", "yfinance", "local_csv"])
|
||||||
async def test_start_mock_mode(self):
|
@patch.object(PollingPriceManager, "start")
|
||||||
|
def test_start_real_mode_uses_first_supported_live_provider(self, _mock_start, _mock_sources):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
poll_interval=10,
|
poll_interval=10,
|
||||||
mock_mode=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
broadcast_func = AsyncMock()
|
service._start_real_mode()
|
||||||
|
|
||||||
await service.start(broadcast_func)
|
assert isinstance(service._price_manager, PollingPriceManager)
|
||||||
|
assert service._price_manager.provider == "yfinance"
|
||||||
|
|
||||||
assert service.running is True
|
@patch("backend.services.market.get_data_sources", return_value=["finnhub", "yfinance"])
|
||||||
assert service._price_manager is not None
|
|
||||||
assert isinstance(service._price_manager, MockPriceManager)
|
|
||||||
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
@patch("backend.services.market.get_data_source", return_value="finnhub")
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_real_mode_without_api_key(self, _mock_source):
|
async def test_start_real_mode_without_api_key(self, _mock_sources):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=False,
|
|
||||||
api_key=None,
|
api_key=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -307,11 +190,12 @@ class TestMarketService:
|
|||||||
async def test_start_already_running(self):
|
async def test_start_already_running(self):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=True,
|
backtest_mode=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
broadcast_func = AsyncMock()
|
broadcast_func = AsyncMock()
|
||||||
|
|
||||||
|
# First start with backtest mode
|
||||||
await service.start(broadcast_func)
|
await service.start(broadcast_func)
|
||||||
assert service.running is True
|
assert service.running is True
|
||||||
|
|
||||||
@@ -323,7 +207,7 @@ class TestMarketService:
|
|||||||
def test_stop(self):
|
def test_stop(self):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=True,
|
backtest_mode=True,
|
||||||
)
|
)
|
||||||
service.running = True
|
service.running = True
|
||||||
service._price_manager = MagicMock()
|
service._price_manager = MagicMock()
|
||||||
@@ -336,7 +220,7 @@ class TestMarketService:
|
|||||||
def test_stop_when_not_running(self):
|
def test_stop_when_not_running(self):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=True,
|
backtest_mode=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should not raise
|
# Should not raise
|
||||||
@@ -344,20 +228,20 @@ class TestMarketService:
|
|||||||
assert service.running is False
|
assert service.running is False
|
||||||
|
|
||||||
def test_get_price_sync(self):
|
def test_get_price_sync(self):
|
||||||
service = MarketService(tickers=["AAPL"], mock_mode=True)
|
service = MarketService(tickers=["AAPL"], backtest_mode=True)
|
||||||
service.cache["AAPL"] = {"price": 150.0, "open": 148.0}
|
service.cache["AAPL"] = {"price": 150.0, "open": 148.0}
|
||||||
|
|
||||||
price = service.get_price_sync("AAPL")
|
price = service.get_price_sync("AAPL")
|
||||||
assert price == 150.0
|
assert price == 150.0
|
||||||
|
|
||||||
def test_get_price_sync_not_found(self):
|
def test_get_price_sync_not_found(self):
|
||||||
service = MarketService(tickers=["AAPL"], mock_mode=True)
|
service = MarketService(tickers=["AAPL"], backtest_mode=True)
|
||||||
|
|
||||||
price = service.get_price_sync("MSFT")
|
price = service.get_price_sync("MSFT")
|
||||||
assert price is None
|
assert price is None
|
||||||
|
|
||||||
def test_get_all_prices(self):
|
def test_get_all_prices(self):
|
||||||
service = MarketService(tickers=["AAPL", "MSFT"], mock_mode=True)
|
service = MarketService(tickers=["AAPL", "MSFT"], backtest_mode=True)
|
||||||
service.cache["AAPL"] = {"price": 150.0}
|
service.cache["AAPL"] = {"price": 150.0}
|
||||||
service.cache["MSFT"] = {"price": 400.0}
|
service.cache["MSFT"] = {"price": 400.0}
|
||||||
|
|
||||||
@@ -368,7 +252,7 @@ class TestMarketService:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_broadcast_price_update(self):
|
async def test_broadcast_price_update(self):
|
||||||
service = MarketService(tickers=["AAPL"], mock_mode=True)
|
service = MarketService(tickers=["AAPL"], backtest_mode=True)
|
||||||
service._broadcast_func = AsyncMock()
|
service._broadcast_func = AsyncMock()
|
||||||
|
|
||||||
price_data = {
|
price_data = {
|
||||||
@@ -388,7 +272,7 @@ class TestMarketService:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_broadcast_price_update_no_func(self):
|
async def test_broadcast_price_update_no_func(self):
|
||||||
service = MarketService(tickers=["AAPL"], mock_mode=True)
|
service = MarketService(tickers=["AAPL"], backtest_mode=True)
|
||||||
service._broadcast_func = None
|
service._broadcast_func = None
|
||||||
|
|
||||||
price_data = {"symbol": "AAPL", "price": 150.0, "open": 148.0}
|
price_data = {"symbol": "AAPL", "price": 150.0, "open": 148.0}
|
||||||
@@ -396,67 +280,6 @@ class TestMarketService:
|
|||||||
# Should not raise
|
# Should not raise
|
||||||
await service._broadcast_price_update(price_data)
|
await service._broadcast_price_update(price_data)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_price_callback_thread_safety(self):
|
|
||||||
service = MarketService(
|
|
||||||
tickers=["AAPL"],
|
|
||||||
poll_interval=1,
|
|
||||||
mock_mode=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
received_prices = []
|
|
||||||
|
|
||||||
async def capture_broadcast(msg):
|
|
||||||
received_prices.append(msg)
|
|
||||||
|
|
||||||
await service.start(capture_broadcast)
|
|
||||||
|
|
||||||
# Wait for at least one price update
|
|
||||||
await asyncio.sleep(1.5)
|
|
||||||
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
# Should have received at least one price update
|
|
||||||
assert len(received_prices) >= 1
|
|
||||||
assert received_prices[0]["type"] == "price_update"
|
|
||||||
|
|
||||||
|
|
||||||
class TestMarketServiceIntegration:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_full_mock_cycle(self):
|
|
||||||
service = MarketService(
|
|
||||||
tickers=["AAPL", "MSFT"],
|
|
||||||
poll_interval=1,
|
|
||||||
mock_mode=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
messages = []
|
|
||||||
|
|
||||||
async def collect_messages(msg):
|
|
||||||
messages.append(msg)
|
|
||||||
|
|
||||||
await service.start(collect_messages)
|
|
||||||
|
|
||||||
# Wait for price updates
|
|
||||||
await asyncio.sleep(2.5)
|
|
||||||
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
# Should have received multiple price updates
|
|
||||||
assert len(messages) >= 2
|
|
||||||
|
|
||||||
# Check message structure
|
|
||||||
symbols_seen = set()
|
|
||||||
for msg in messages:
|
|
||||||
assert msg["type"] == "price_update"
|
|
||||||
assert "symbol" in msg
|
|
||||||
assert "price" in msg
|
|
||||||
assert "ret" in msg
|
|
||||||
symbols_seen.add(msg["symbol"])
|
|
||||||
|
|
||||||
# Should have prices for both tickers
|
|
||||||
assert "AAPL" in symbols_seen or "MSFT" in symbols_seen
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def test_get_enriched_news_returns_rows_without_enrichment_when_present(monkeypa
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
news_domain,
|
news_domain,
|
||||||
"ensure_news_fresh",
|
"ensure_news_fresh",
|
||||||
lambda store, ticker, target_date=None: {
|
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"target_date": target_date,
|
"target_date": target_date,
|
||||||
"last_news_fetch": target_date,
|
"last_news_fetch": target_date,
|
||||||
@@ -109,7 +109,7 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
news_domain,
|
news_domain,
|
||||||
"ensure_news_fresh",
|
"ensure_news_fresh",
|
||||||
lambda store, ticker, target_date=None: {
|
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"target_date": target_date,
|
"target_date": target_date,
|
||||||
"last_news_fetch": target_date,
|
"last_news_fetch": target_date,
|
||||||
@@ -137,12 +137,38 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
|
|||||||
assert "freshness" in similar
|
assert "freshness" in similar
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_enriched_news_defaults_to_read_only_freshness(monkeypatch):
|
||||||
|
store = _FakeStore()
|
||||||
|
ensure_calls = []
|
||||||
|
|
||||||
|
def fake_ensure(store, ticker, target_date=None, refresh_if_stale=False):
|
||||||
|
ensure_calls.append(refresh_if_stale)
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"target_date": target_date,
|
||||||
|
"last_news_fetch": target_date,
|
||||||
|
"refreshed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(news_domain, "ensure_news_fresh", fake_ensure)
|
||||||
|
monkeypatch.setattr(news_domain, "news_rows_need_enrichment", lambda rows: False)
|
||||||
|
|
||||||
|
payload = news_domain.get_enriched_news(
|
||||||
|
store,
|
||||||
|
ticker="AAPL",
|
||||||
|
end_date="2026-03-16",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["ticker"] == "AAPL"
|
||||||
|
assert ensure_calls == [False]
|
||||||
|
|
||||||
|
|
||||||
def test_get_range_explain_payload_uses_article_ids(monkeypatch):
|
def test_get_range_explain_payload_uses_article_ids(monkeypatch):
|
||||||
store = _FakeStore()
|
store = _FakeStore()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
news_domain,
|
news_domain,
|
||||||
"ensure_news_fresh",
|
"ensure_news_fresh",
|
||||||
lambda store, ticker, target_date=None: {
|
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"target_date": target_date,
|
"target_date": target_date,
|
||||||
"last_news_fetch": target_date,
|
"last_news_fetch": target_date,
|
||||||
|
|||||||
60
backend/tests/test_openclaw_cli_service.py
Normal file
60
backend/tests/test_openclaw_cli_service.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for the OpenClaw CLI service wrapper."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService
|
||||||
|
|
||||||
|
|
||||||
|
class _Completed:
|
||||||
|
def __init__(self, *, returncode=0, stdout="", stderr=""):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_openclaw_cli_service_runs_json_command(monkeypatch, tmp_path):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _fake_run(command, **kwargs):
|
||||||
|
captured["command"] = command
|
||||||
|
captured["cwd"] = kwargs["cwd"]
|
||||||
|
return _Completed(stdout='{"sessions":[{"key":"main/session-1"}]}')
|
||||||
|
|
||||||
|
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
|
||||||
|
|
||||||
|
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
|
||||||
|
payload = service.list_sessions()
|
||||||
|
|
||||||
|
assert payload["sessions"][0]["key"] == "main/session-1"
|
||||||
|
assert captured["command"] == ["openclaw", "sessions", "--json"]
|
||||||
|
assert captured["cwd"] == tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_openclaw_cli_service_raises_on_failure(monkeypatch, tmp_path):
|
||||||
|
def _fake_run(command, **kwargs):
|
||||||
|
return _Completed(returncode=7, stdout="", stderr="boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
|
||||||
|
|
||||||
|
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
|
||||||
|
|
||||||
|
with pytest.raises(OpenClawCliError) as exc_info:
|
||||||
|
service.list_cron_jobs()
|
||||||
|
|
||||||
|
assert exc_info.value.exit_code == 7
|
||||||
|
assert exc_info.value.stderr == "boom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_openclaw_cli_service_can_extract_single_session(monkeypatch, tmp_path):
|
||||||
|
def _fake_run(command, **kwargs):
|
||||||
|
return _Completed(stdout='{"sessions":[{"key":"main/session-1","agentId":"main"}]}')
|
||||||
|
|
||||||
|
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
|
||||||
|
|
||||||
|
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
|
||||||
|
session = service.get_session("main/session-1")
|
||||||
|
|
||||||
|
assert session["agentId"] == "main"
|
||||||
110
backend/tests/test_openclaw_service_app.py
Normal file
110
backend/tests/test_openclaw_service_app.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for the extracted OpenClaw service app surface."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.apps.openclaw_service import create_app
|
||||||
|
from backend.api import openclaw as openclaw_module
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeOpenClawCliService:
|
||||||
|
def health(self):
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "openclaw-service",
|
||||||
|
"base_command": ["openclaw"],
|
||||||
|
"cwd": "/tmp/openclaw",
|
||||||
|
"binary_resolved": True,
|
||||||
|
"reference_entry_available": True,
|
||||||
|
"timeout_seconds": 15.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return {"runtimeVersion": "2026.3.24"}
|
||||||
|
|
||||||
|
def list_sessions(self):
|
||||||
|
return {
|
||||||
|
"sessions": [
|
||||||
|
{"key": "main/session-1", "agentId": "main"},
|
||||||
|
{"key": "analyst/session-2", "agentId": "analyst"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_session(self, session_key: str):
|
||||||
|
for session in self.list_sessions()["sessions"]:
|
||||||
|
if session["key"] == session_key:
|
||||||
|
return session
|
||||||
|
raise KeyError(session_key)
|
||||||
|
|
||||||
|
def get_session_history(self, session_key: str, *, limit: int = 20):
|
||||||
|
return {
|
||||||
|
"sessionKey": session_key,
|
||||||
|
"limit": limit,
|
||||||
|
"items": [{"role": "assistant", "text": "hello"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_cron_jobs(self):
|
||||||
|
return {"jobs": [{"id": "job-1", "name": "Daily sync"}]}
|
||||||
|
|
||||||
|
def list_approvals(self):
|
||||||
|
return {"approvals": [{"id": "ap-1", "status": "pending"}]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_openclaw_service_routes_are_exposed():
|
||||||
|
app = create_app()
|
||||||
|
paths = {route.path for route in app.routes}
|
||||||
|
|
||||||
|
assert "/health" in paths
|
||||||
|
assert "/api/status" in paths
|
||||||
|
assert "/api/openclaw/status" in paths
|
||||||
|
assert "/api/openclaw/sessions" in paths
|
||||||
|
assert "/api/openclaw/sessions/{session_key:path}" in paths
|
||||||
|
assert "/api/openclaw/sessions/{session_key:path}/history" in paths
|
||||||
|
assert "/api/openclaw/cron" in paths
|
||||||
|
assert "/api/openclaw/approvals" in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_openclaw_service_read_routes():
|
||||||
|
app = create_app()
|
||||||
|
app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = (
|
||||||
|
lambda: _FakeOpenClawCliService()
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
health = client.get("/health")
|
||||||
|
status = client.get("/api/status")
|
||||||
|
openclaw_status = client.get("/api/openclaw/status")
|
||||||
|
sessions = client.get("/api/openclaw/sessions")
|
||||||
|
session = client.get("/api/openclaw/sessions/main/session-1")
|
||||||
|
history = client.get("/api/openclaw/sessions/main/session-1/history", params={"limit": 5})
|
||||||
|
cron = client.get("/api/openclaw/cron")
|
||||||
|
approvals = client.get("/api/openclaw/approvals")
|
||||||
|
|
||||||
|
assert health.status_code == 200
|
||||||
|
assert health.json()["service"] == "openclaw-service"
|
||||||
|
assert status.status_code == 200
|
||||||
|
assert status.json()["status"] == "operational"
|
||||||
|
assert openclaw_status.status_code == 200
|
||||||
|
assert openclaw_status.json()["runtimeVersion"] == "2026.3.24"
|
||||||
|
assert sessions.status_code == 200
|
||||||
|
assert len(sessions.json()["sessions"]) == 2
|
||||||
|
assert session.status_code == 200
|
||||||
|
assert session.json()["session"]["agentId"] == "main"
|
||||||
|
assert history.status_code == 200
|
||||||
|
assert history.json()["limit"] == 5
|
||||||
|
assert cron.status_code == 200
|
||||||
|
assert cron.json()["jobs"][0]["id"] == "job-1"
|
||||||
|
assert approvals.status_code == 200
|
||||||
|
assert approvals.json()["approvals"][0]["id"] == "ap-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_openclaw_service_session_404():
|
||||||
|
app = create_app()
|
||||||
|
app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = (
|
||||||
|
lambda: _FakeOpenClawCliService()
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/openclaw/sessions/missing")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
74
backend/tests/test_openclaw_websocket_client.py
Normal file
74
backend/tests/test_openclaw_websocket_client.py
Normal 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}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Tests for the extracted runtime service app surface."""
|
"""Tests for the extracted runtime service app surface."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ def test_runtime_service_routes_are_exposed():
|
|||||||
assert "/api/status" in paths
|
assert "/api/status" in paths
|
||||||
assert "/api/runtime/start" in paths
|
assert "/api/runtime/start" in paths
|
||||||
assert "/api/runtime/stop" in paths
|
assert "/api/runtime/stop" in paths
|
||||||
|
assert "/api/runtime/cleanup" in paths
|
||||||
|
assert "/api/runtime/history" in paths
|
||||||
assert "/api/runtime/current" in paths
|
assert "/api/runtime/current" in paths
|
||||||
assert "/api/runtime/gateway/port" in paths
|
assert "/api/runtime/gateway/port" in paths
|
||||||
|
|
||||||
@@ -192,3 +195,170 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
|
|||||||
assert payload["bootstrap"]["schedule_mode"] == "intraday"
|
assert payload["bootstrap"]["schedule_mode"] == "intraday"
|
||||||
assert payload["resolved"]["interval_minutes"] == 15
|
assert payload["resolved"]["interval_minutes"] == 15
|
||||||
assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8")
|
assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_old_timestamped_runs_keeps_named_runs(monkeypatch, tmp_path):
|
||||||
|
runs_dir = tmp_path / "runs"
|
||||||
|
runs_dir.mkdir()
|
||||||
|
|
||||||
|
keep_dirs = ["20260324_110000", "20260324_120000"]
|
||||||
|
prune_dir = "20260324_100000"
|
||||||
|
named_dir = "smoke_fullstack"
|
||||||
|
|
||||||
|
for name in [*keep_dirs, prune_dir, named_dir]:
|
||||||
|
(runs_dir / name).mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
|
||||||
|
pruned = runtime_module._prune_old_timestamped_runs(keep=1, exclude_run_ids={"20260324_120000"})
|
||||||
|
|
||||||
|
assert prune_dir in pruned
|
||||||
|
assert (runs_dir / named_dir).exists()
|
||||||
|
assert (runs_dir / "20260324_120000").exists()
|
||||||
|
assert (runs_dir / "20260324_110000").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_cleanup_endpoint_prunes_old_runs(monkeypatch, tmp_path):
|
||||||
|
runs_dir = tmp_path / "runs"
|
||||||
|
runs_dir.mkdir()
|
||||||
|
|
||||||
|
for name in ["20260324_090000", "20260324_100000", "20260324_110000", "smoke_fullstack"]:
|
||||||
|
(runs_dir / name).mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(runtime_module, "_is_gateway_running", lambda: False)
|
||||||
|
|
||||||
|
with TestClient(create_app()) as client:
|
||||||
|
response = client.post("/api/runtime/cleanup?keep=1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["status"] == "ok"
|
||||||
|
assert sorted(payload["pruned_run_ids"]) == ["20260324_090000", "20260324_100000"]
|
||||||
|
assert (runs_dir / "20260324_110000").exists()
|
||||||
|
assert (runs_dir / "smoke_fullstack").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_history_lists_recent_runs(monkeypatch, tmp_path):
|
||||||
|
run_dir = tmp_path / "runs" / "20260324_120000"
|
||||||
|
(run_dir / "state").mkdir(parents=True)
|
||||||
|
(run_dir / "team_dashboard").mkdir(parents=True)
|
||||||
|
(run_dir / "state" / "runtime_state.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"context": {
|
||||||
|
"config_name": "20260324_120000",
|
||||||
|
"run_dir": str(run_dir),
|
||||||
|
"bootstrap_values": {"tickers": ["AAPL"]},
|
||||||
|
},
|
||||||
|
"events": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(run_dir / "team_dashboard" / "summary.json").write_text(
|
||||||
|
json.dumps({"totalTrades": 3, "totalAssetValue": 123456.0}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
|
||||||
|
with TestClient(create_app()) as client:
|
||||||
|
response = client.get("/api/runtime/history?limit=5")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["runs"][0]["run_id"] == "20260324_120000"
|
||||||
|
assert payload["runs"][0]["total_trades"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_run_assets_copies_state(monkeypatch, tmp_path):
|
||||||
|
source_run = tmp_path / "runs" / "20260324_100000"
|
||||||
|
(source_run / "team_dashboard").mkdir(parents=True)
|
||||||
|
(source_run / "state").mkdir(parents=True)
|
||||||
|
(source_run / "agents").mkdir(parents=True)
|
||||||
|
(source_run / "team_dashboard" / "_internal_state.json").write_text("{}", encoding="utf-8")
|
||||||
|
(source_run / "state" / "server_state.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
target_run = tmp_path / "runs" / "20260324_130000"
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
|
||||||
|
runtime_module._restore_run_assets("20260324_100000", target_run)
|
||||||
|
|
||||||
|
assert (target_run / "team_dashboard" / "_internal_state.json").exists()
|
||||||
|
assert (target_run / "state" / "server_state.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_runtime_restore_reuses_historical_run_id(monkeypatch, tmp_path):
|
||||||
|
run_dir = tmp_path / "runs" / "20260324_100000"
|
||||||
|
(run_dir / "state").mkdir(parents=True)
|
||||||
|
(run_dir / "state" / "runtime_state.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"context": {
|
||||||
|
"config_name": "20260324_100000",
|
||||||
|
"run_dir": str(run_dir),
|
||||||
|
"bootstrap_values": {
|
||||||
|
"tickers": ["AAPL"],
|
||||||
|
"schedule_mode": "intraday",
|
||||||
|
"interval_minutes": 30,
|
||||||
|
"trigger_time": "now",
|
||||||
|
"max_comm_cycles": 2,
|
||||||
|
"initial_cash": 100000.0,
|
||||||
|
"margin_requirement": 0.0,
|
||||||
|
"enable_memory": False,
|
||||||
|
"mode": "live",
|
||||||
|
"poll_interval": 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
class _DummyManager:
|
||||||
|
def __init__(self, config_name, run_dir, bootstrap):
|
||||||
|
self.config_name = config_name
|
||||||
|
self.run_dir = Path(run_dir)
|
||||||
|
self.bootstrap = bootstrap
|
||||||
|
self.context = None
|
||||||
|
|
||||||
|
def prepare_run(self):
|
||||||
|
self.context = type(
|
||||||
|
"Ctx",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"config_name": self.config_name,
|
||||||
|
"run_dir": self.run_dir,
|
||||||
|
"bootstrap_values": self.bootstrap,
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
return self.context
|
||||||
|
|
||||||
|
class _DummyProcess:
|
||||||
|
def poll(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(runtime_module, "_find_available_port", lambda start_port=8765, max_port=9000: 8765)
|
||||||
|
monkeypatch.setattr(runtime_module, "_start_gateway_process", lambda **kwargs: _DummyProcess())
|
||||||
|
monkeypatch.setattr(runtime_module, "_stop_gateway", lambda: True)
|
||||||
|
monkeypatch.setattr("backend.runtime.manager.TradingRuntimeManager", _DummyManager)
|
||||||
|
runtime_state = runtime_module.get_runtime_state()
|
||||||
|
runtime_state.gateway_process = None
|
||||||
|
|
||||||
|
with TestClient(create_app()) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/runtime/start",
|
||||||
|
json={
|
||||||
|
"launch_mode": "restore",
|
||||||
|
"restore_run_id": "20260324_100000",
|
||||||
|
"tickers": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["run_id"] == "20260324_100000"
|
||||||
|
assert payload["run_dir"] == str(run_dir)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from shared.client.control_client import ControlPlaneClient
|
from shared.client.control_client import ControlPlaneClient
|
||||||
|
from shared.client.openclaw_client import OpenClawServiceClient
|
||||||
from shared.client.runtime_client import RuntimeServiceClient
|
from shared.client.runtime_client import RuntimeServiceClient
|
||||||
|
|
||||||
|
|
||||||
@@ -105,3 +106,25 @@ async def test_runtime_service_client_hits_current_runtime_routes():
|
|||||||
("get", "/config", None),
|
("get", "/config", None),
|
||||||
("put", "/config", {"schedule_mode": "intraday"}),
|
("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),
|
||||||
|
]
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class TechnicalSignal:
|
|||||||
|
|
||||||
|
|
||||||
class StockTechnicalAnalyzer:
|
class StockTechnicalAnalyzer:
|
||||||
"""Lightweight technical analyzer adapted for EvoTraders tools."""
|
"""Lightweight technical analyzer adapted for 大时代 tools."""
|
||||||
|
|
||||||
def analyze(self, ticker: str, df: pd.DataFrame) -> TechnicalSignal:
|
def analyze(self, ticker: str, df: pd.DataFrame) -> TechnicalSignal:
|
||||||
"""Analyze one ticker from OHLC price history."""
|
"""Analyze one ticker from OHLC price history."""
|
||||||
|
|||||||
@@ -228,12 +228,12 @@ class SettlementCoordinator:
|
|||||||
|
|
||||||
all_evaluations = {**analyst_evaluations, **pm_evaluations}
|
all_evaluations = {**analyst_evaluations, **pm_evaluations}
|
||||||
|
|
||||||
leaderboard = self.storage.load_file("leaderboard") or []
|
leaderboard = self.storage.load_export_file("leaderboard") or []
|
||||||
updated_leaderboard = update_leaderboard_with_evaluations(
|
updated_leaderboard = update_leaderboard_with_evaluations(
|
||||||
leaderboard,
|
leaderboard,
|
||||||
all_evaluations,
|
all_evaluations,
|
||||||
)
|
)
|
||||||
self.storage.save_file("leaderboard", updated_leaderboard)
|
self.storage.save_export_file("leaderboard", updated_leaderboard)
|
||||||
|
|
||||||
self._update_summary_with_baselines(
|
self._update_summary_with_baselines(
|
||||||
date,
|
date,
|
||||||
|
|||||||
@@ -1,359 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Terminal Dashboard - Persistent unified panel using Rich Live
|
|
||||||
"""
|
|
||||||
# pylint: disable=R0915,R0912
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.live import Live
|
|
||||||
from rich.panel import Panel
|
|
||||||
from rich.table import Table
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalDashboard:
|
|
||||||
"""Unified persistent terminal dashboard"""
|
|
||||||
|
|
||||||
def __init__(self, console: Console = None):
|
|
||||||
self.console = console or Console()
|
|
||||||
self.live: Optional[Live] = None
|
|
||||||
|
|
||||||
# Config state
|
|
||||||
self.mode = "live"
|
|
||||||
self.config_name = ""
|
|
||||||
self.host = "0.0.0.0"
|
|
||||||
self.port = 8765
|
|
||||||
self.poll_interval = 10
|
|
||||||
self.trigger_time = "now"
|
|
||||||
self.mock = False
|
|
||||||
self.enable_memory = False
|
|
||||||
self.local_time = ""
|
|
||||||
self.nyse_time = ""
|
|
||||||
self.start_date = ""
|
|
||||||
self.end_date = ""
|
|
||||||
self.tickers: List[str] = []
|
|
||||||
self.initial_cash = 100000.0
|
|
||||||
self.data_sources: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
# Trading state
|
|
||||||
self.current_date = "-"
|
|
||||||
self.status = "Initializing"
|
|
||||||
self.total_value = 0.0
|
|
||||||
self.cash = 0.0
|
|
||||||
self.pnl_pct = 0.0
|
|
||||||
self.holdings: List[Dict] = []
|
|
||||||
self.trades: List[Dict] = []
|
|
||||||
self.days_completed = 0
|
|
||||||
self.days_total = 0
|
|
||||||
|
|
||||||
# Progress message (last line)
|
|
||||||
self.progress = ""
|
|
||||||
self._dots_index = 0
|
|
||||||
self._animator_running = False
|
|
||||||
self._animator_thread: Optional[threading.Thread] = None
|
|
||||||
|
|
||||||
def set_config(
|
|
||||||
self,
|
|
||||||
mode: str,
|
|
||||||
config_name: str,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
poll_interval: int,
|
|
||||||
trigger_time: str = "now",
|
|
||||||
mock: bool = False,
|
|
||||||
enable_memory: bool = False,
|
|
||||||
local_time: str = "",
|
|
||||||
nyse_time: str = "",
|
|
||||||
start_date: str = "",
|
|
||||||
end_date: str = "",
|
|
||||||
tickers: List[str] = None,
|
|
||||||
initial_cash: float = 100000.0,
|
|
||||||
data_sources: Dict[str, Any] = None,
|
|
||||||
):
|
|
||||||
"""Set configuration state"""
|
|
||||||
self.mode = mode
|
|
||||||
self.config_name = config_name
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.poll_interval = poll_interval
|
|
||||||
self.trigger_time = trigger_time
|
|
||||||
self.mock = mock
|
|
||||||
self.enable_memory = enable_memory
|
|
||||||
self.local_time = local_time
|
|
||||||
self.nyse_time = nyse_time
|
|
||||||
self.start_date = start_date
|
|
||||||
self.end_date = end_date
|
|
||||||
self.tickers = tickers or []
|
|
||||||
self.initial_cash = initial_cash
|
|
||||||
self.data_sources = data_sources or {}
|
|
||||||
self.total_value = initial_cash
|
|
||||||
self.cash = initial_cash
|
|
||||||
|
|
||||||
def _build_panel(self) -> Panel:
|
|
||||||
"""Build the unified dashboard panel"""
|
|
||||||
# Main grid
|
|
||||||
main_table = Table.grid(padding=(0, 2))
|
|
||||||
main_table.add_column(width=28)
|
|
||||||
main_table.add_column(width=22)
|
|
||||||
main_table.add_column(width=22)
|
|
||||||
|
|
||||||
# Left: Config + Status
|
|
||||||
left = Table.grid(padding=(0, 0))
|
|
||||||
left.add_column()
|
|
||||||
|
|
||||||
# Mode line
|
|
||||||
if self.mode == "backtest":
|
|
||||||
mode_str = "[cyan]Backtest[/cyan]"
|
|
||||||
elif self.mock:
|
|
||||||
mode_str = "[yellow]MOCK[/yellow]"
|
|
||||||
else:
|
|
||||||
mode_str = "[green]LIVE[/green]"
|
|
||||||
|
|
||||||
left.add_row(f"[bold]Mode:[/bold] {mode_str}")
|
|
||||||
left.add_row(f"[dim]Config:[/dim] {self.config_name}")
|
|
||||||
left.add_row(f"[dim]Server:[/dim] {self.host}:{self.port}")
|
|
||||||
preferred_sources = self.data_sources.get("preferred", [])
|
|
||||||
if preferred_sources:
|
|
||||||
left.add_row(
|
|
||||||
f"[dim]Data:[/dim] {' -> '.join(preferred_sources)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.mode == "live" and self.nyse_time:
|
|
||||||
left.add_row(f"[dim]NYSE:[/dim] {self.nyse_time[:19]}")
|
|
||||||
trigger_display = (
|
|
||||||
"[green]NOW[/green]"
|
|
||||||
if self.trigger_time == "now"
|
|
||||||
else self.trigger_time
|
|
||||||
)
|
|
||||||
left.add_row(f"[dim]Trigger:[/dim] {trigger_display}")
|
|
||||||
|
|
||||||
# Status
|
|
||||||
left.add_row("")
|
|
||||||
status_style = "green" if self.status == "Running" else "yellow"
|
|
||||||
left.add_row(
|
|
||||||
"[bold]Status:[/bold] "
|
|
||||||
f"[{status_style}]{self.status}[/{status_style}]",
|
|
||||||
)
|
|
||||||
if self.mode == "backtest":
|
|
||||||
left.add_row(
|
|
||||||
f"[dim]Backtesting Period:[/dim] {self.days_total} days\n"
|
|
||||||
f" {self.start_date} -> {self.end_date}",
|
|
||||||
)
|
|
||||||
left.add_row(f"[dim]Current Date:[/dim] {self.current_date}")
|
|
||||||
|
|
||||||
# Middle: Portfolio
|
|
||||||
mid = Table.grid(padding=(0, 0))
|
|
||||||
mid.add_column()
|
|
||||||
|
|
||||||
pnl_style = "green" if self.pnl_pct >= 0 else "red"
|
|
||||||
mid.add_row("[bold]Portfolio[/bold]")
|
|
||||||
mid.add_row(f"NAV: [bold]${self.total_value:,.0f}[/bold]")
|
|
||||||
mid.add_row(f"Cash: ${self.cash:,.0f}")
|
|
||||||
mid.add_row(f"P&L: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]")
|
|
||||||
|
|
||||||
# Positions
|
|
||||||
mid.add_row("")
|
|
||||||
mid.add_row("[bold]Positions[/bold]")
|
|
||||||
stock_holdings = [
|
|
||||||
h for h in self.holdings if h.get("ticker") != "CASH"
|
|
||||||
]
|
|
||||||
if stock_holdings:
|
|
||||||
for h in stock_holdings[:7]:
|
|
||||||
qty = h.get("quantity", 0)
|
|
||||||
ticker = h.get("ticker", "")[:5]
|
|
||||||
val = h.get("marketValue", 0)
|
|
||||||
qty_str = f"{qty:+d}" if qty != 0 else "0"
|
|
||||||
mid.add_row(
|
|
||||||
f"[cyan]{ticker:<5}[/cyan] {qty_str:>5} ${val:>7,.0f}",
|
|
||||||
)
|
|
||||||
if len(stock_holdings) > 7:
|
|
||||||
mid.add_row(f"[dim]+{len(stock_holdings) - 7} more[/dim]")
|
|
||||||
else:
|
|
||||||
mid.add_row("[dim]No positions[/dim]")
|
|
||||||
|
|
||||||
# Right: Recent Trades
|
|
||||||
right = Table.grid(padding=(0, 0))
|
|
||||||
right.add_column()
|
|
||||||
|
|
||||||
right.add_row("[bold]Recent Trades[/bold]")
|
|
||||||
if self.trades:
|
|
||||||
for t in self.trades[:10]:
|
|
||||||
side = t.get("side", "")
|
|
||||||
ticker = t.get("ticker", "")[:5]
|
|
||||||
qty = t.get("qty", 0)
|
|
||||||
if side == "LONG":
|
|
||||||
side_str = "[green]L[/green]"
|
|
||||||
elif side == "SHORT":
|
|
||||||
side_str = "[red]S[/red]"
|
|
||||||
else:
|
|
||||||
side_str = "[dim]H[/dim]"
|
|
||||||
right.add_row(f"{side_str} [cyan]{ticker:<5}[/cyan] {qty:>4}")
|
|
||||||
if len(self.trades) > 10:
|
|
||||||
right.add_row(f"[dim]+{len(self.trades) - 10} more[/dim]")
|
|
||||||
else:
|
|
||||||
right.add_row("[dim]No trades[/dim]")
|
|
||||||
|
|
||||||
main_table.add_row(left, mid, right)
|
|
||||||
|
|
||||||
# Outer table to add progress line at bottom
|
|
||||||
outer = Table.grid(padding=(0, 0))
|
|
||||||
outer.add_column()
|
|
||||||
outer.add_row(main_table)
|
|
||||||
|
|
||||||
# Progress line (last row) with animated dots
|
|
||||||
if self.progress:
|
|
||||||
DOTS_FRAMES = [" ", ". ", ".. ", "..."]
|
|
||||||
dots = DOTS_FRAMES[self._dots_index % len(DOTS_FRAMES)]
|
|
||||||
outer.add_row("")
|
|
||||||
outer.add_row(f"[dim]> {self.progress}{dots}[/dim]")
|
|
||||||
|
|
||||||
# Build panel
|
|
||||||
title = "[bold cyan]EvoTraders[/bold cyan]"
|
|
||||||
if self.mode == "backtest":
|
|
||||||
title += " [dim]Backtest[/dim]"
|
|
||||||
elif self.mock:
|
|
||||||
title += " [dim]Mock[/dim]"
|
|
||||||
else:
|
|
||||||
title += " [dim]Live[/dim]"
|
|
||||||
|
|
||||||
return Panel(
|
|
||||||
outer,
|
|
||||||
title=title,
|
|
||||||
border_style="cyan",
|
|
||||||
padding=(0, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _run_animator(self):
|
|
||||||
"""Background thread to animate the dots"""
|
|
||||||
while self._animator_running:
|
|
||||||
time.sleep(0.3)
|
|
||||||
if self.progress and self.live:
|
|
||||||
self._dots_index += 1
|
|
||||||
self.live.update(self._build_panel())
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""Start the live dashboard display"""
|
|
||||||
self.live = Live(
|
|
||||||
self._build_panel(),
|
|
||||||
console=self.console,
|
|
||||||
refresh_per_second=4,
|
|
||||||
vertical_overflow="visible",
|
|
||||||
)
|
|
||||||
self.live.start()
|
|
||||||
|
|
||||||
# Start animator thread
|
|
||||||
self._animator_running = True
|
|
||||||
self._animator_thread = threading.Thread(
|
|
||||||
target=self._run_animator,
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
self._animator_thread.start()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Stop the live dashboard"""
|
|
||||||
self._animator_running = False
|
|
||||||
if self._animator_thread:
|
|
||||||
self._animator_thread.join(timeout=0.5)
|
|
||||||
self._animator_thread = None
|
|
||||||
if self.live:
|
|
||||||
self.live.stop()
|
|
||||||
self.live = None
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
date: str = None,
|
|
||||||
status: str = None,
|
|
||||||
portfolio: Dict[str, Any] = None,
|
|
||||||
holdings: List[Dict] = None,
|
|
||||||
trades: List[Dict] = None,
|
|
||||||
days_completed: int = None,
|
|
||||||
days_total: int = None,
|
|
||||||
data_sources: Dict[str, Any] = None,
|
|
||||||
):
|
|
||||||
"""Update dashboard state and refresh display"""
|
|
||||||
if date:
|
|
||||||
self.current_date = date
|
|
||||||
if status:
|
|
||||||
self.status = status
|
|
||||||
if days_completed is not None:
|
|
||||||
self.days_completed = days_completed
|
|
||||||
if days_total is not None:
|
|
||||||
self.days_total = days_total
|
|
||||||
|
|
||||||
if portfolio:
|
|
||||||
self.total_value = portfolio.get(
|
|
||||||
"totalAssetValue",
|
|
||||||
0,
|
|
||||||
) or portfolio.get(
|
|
||||||
"total_value",
|
|
||||||
self.initial_cash,
|
|
||||||
)
|
|
||||||
self.cash = portfolio.get("cashPosition", 0) or portfolio.get(
|
|
||||||
"cash",
|
|
||||||
self.initial_cash,
|
|
||||||
)
|
|
||||||
if self.total_value > 0 and self.initial_cash > 0:
|
|
||||||
self.pnl_pct = (
|
|
||||||
(self.total_value - self.initial_cash) / self.initial_cash
|
|
||||||
) * 100
|
|
||||||
|
|
||||||
if holdings is not None:
|
|
||||||
self.holdings = holdings
|
|
||||||
if trades is not None:
|
|
||||||
self.trades = trades
|
|
||||||
if data_sources is not None:
|
|
||||||
self.data_sources = data_sources
|
|
||||||
|
|
||||||
if self.live:
|
|
||||||
self.live.update(self._build_panel())
|
|
||||||
|
|
||||||
def log(self, msg: str, also_log: bool = True):
|
|
||||||
"""
|
|
||||||
Update progress message and refresh panel
|
|
||||||
|
|
||||||
Args:
|
|
||||||
msg: Progress message to display
|
|
||||||
also_log: Whether to also write to logger (default True)
|
|
||||||
"""
|
|
||||||
self.progress = msg
|
|
||||||
if also_log:
|
|
||||||
logger.info(msg)
|
|
||||||
if self.live:
|
|
||||||
self.live.update(self._build_panel())
|
|
||||||
|
|
||||||
def print_final_summary(self):
|
|
||||||
"""Print final summary when dashboard stops"""
|
|
||||||
pnl_style = "green" if self.pnl_pct >= 0 else "red"
|
|
||||||
|
|
||||||
if self.mode == "backtest":
|
|
||||||
msg = (
|
|
||||||
f"[bold]Backtest Complete[/bold] | "
|
|
||||||
f"Days: {self.days_completed} | "
|
|
||||||
f"NAV: ${self.total_value:,.0f} | "
|
|
||||||
f"Return: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg = (
|
|
||||||
f"[bold]Session End[/bold] | "
|
|
||||||
f"NAV: ${self.total_value:,.0f} | "
|
|
||||||
f"P&L: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.console.print(Panel(msg, border_style="green"))
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
_dashboard: Optional[TerminalDashboard] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_dashboard() -> TerminalDashboard:
|
|
||||||
"""Get or create global dashboard instance"""
|
|
||||||
global _dashboard
|
|
||||||
if _dashboard is None:
|
|
||||||
_dashboard = TerminalDashboard()
|
|
||||||
return _dashboard
|
|
||||||
@@ -2626,6 +2626,5 @@
|
|||||||
"trading_days_completed": 5,
|
"trading_days_completed": 5,
|
||||||
"server_mode": "backtest",
|
"server_mode": "backtest",
|
||||||
"is_backtest": true,
|
"is_backtest": true,
|
||||||
"is_mock_mode": false,
|
|
||||||
"last_saved": "2026-03-12T23:07:31.098122"
|
"last_saved": "2026-03-12T23:07:31.098122"
|
||||||
}
|
}
|
||||||
135
deploy/README.md
Normal file
135
deploy/README.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Deployment Notes
|
||||||
|
|
||||||
|
This directory contains the current production-oriented deployment artifacts for
|
||||||
|
the 大时代 frontend site and the live gateway process.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [deploy/systemd/evotraders.service](./systemd/evotraders.service)
|
||||||
|
- systemd unit for the long-running 大时代 gateway process
|
||||||
|
- [scripts/run_prod.sh](../scripts/run_prod.sh)
|
||||||
|
- production launch script used by the systemd unit
|
||||||
|
- [deploy/nginx/bigtime.cillinn.com.conf](./nginx/bigtime.cillinn.com.conf)
|
||||||
|
- HTTPS nginx config with WebSocket proxying
|
||||||
|
- [deploy/nginx/bigtime.cillinn.com.http.conf](./nginx/bigtime.cillinn.com.http.conf)
|
||||||
|
- plain HTTP/static-site variant
|
||||||
|
|
||||||
|
## Current Production Shape
|
||||||
|
|
||||||
|
The checked-in production path is intentionally minimal:
|
||||||
|
|
||||||
|
- nginx serves the built frontend from `/var/www/bigtime/current`
|
||||||
|
- public domain examples use `bigtime.cillinn.com`
|
||||||
|
- nginx proxies `/ws` to `127.0.0.1:8765`
|
||||||
|
- systemd runs `scripts/run_prod.sh`
|
||||||
|
- `scripts/run_prod.sh` starts `python3 -m backend.main` in live mode on `127.0.0.1:8765`
|
||||||
|
|
||||||
|
This means the checked-in production example is centered on the gateway and
|
||||||
|
frontend, not on exposing the split FastAPI services directly.
|
||||||
|
|
||||||
|
## Important Paths And Ports
|
||||||
|
|
||||||
|
- frontend root: `/var/www/bigtime/current`
|
||||||
|
- gateway bind: `127.0.0.1:8765`
|
||||||
|
- public WebSocket path: `/ws`
|
||||||
|
- working directory expected by systemd: `/root/code/evotraders`
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
The current systemd unit:
|
||||||
|
|
||||||
|
- uses `WorkingDirectory=/root/code/evotraders`
|
||||||
|
- executes [scripts/run_prod.sh](../scripts/run_prod.sh)
|
||||||
|
- restarts automatically on failure
|
||||||
|
|
||||||
|
Enable and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/systemd/evotraders.service /etc/systemd/system/evotraders.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable evotraders
|
||||||
|
sudo systemctl start evotraders
|
||||||
|
```
|
||||||
|
|
||||||
|
Check status and logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status evotraders
|
||||||
|
journalctl -u evotraders -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## nginx
|
||||||
|
|
||||||
|
The HTTPS nginx config does two things:
|
||||||
|
|
||||||
|
- redirects `http://bigtime.cillinn.com` to HTTPS
|
||||||
|
- proxies `/ws` to the local gateway process with WebSocket upgrade headers
|
||||||
|
|
||||||
|
Typical install flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/nginx/bigtime.cillinn.com.conf /etc/nginx/sites-available/bigtime.cillinn.com.conf
|
||||||
|
sudo ln -s /etc/nginx/sites-available/bigtime.cillinn.com.conf /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
The checked-in TLS config expects Let's Encrypt assets at:
|
||||||
|
|
||||||
|
- `/etc/letsencrypt/live/bigtime.cillinn.com/fullchain.pem`
|
||||||
|
- `/etc/letsencrypt/live/bigtime.cillinn.com/privkey.pem`
|
||||||
|
|
||||||
|
## Environment Expectations
|
||||||
|
|
||||||
|
Before using the production scripts, ensure the runtime environment has:
|
||||||
|
|
||||||
|
- a usable Python environment
|
||||||
|
- backend dependencies installed from `requirements.txt`
|
||||||
|
- the package installed with `pip install -e .` or `uv pip install -e .`
|
||||||
|
- frontend dependencies installed with `npm ci`
|
||||||
|
- repo dependencies installed
|
||||||
|
- required market/model API keys
|
||||||
|
- any desired `TICKERS` override
|
||||||
|
|
||||||
|
Recommended production install sequence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -e .
|
||||||
|
cd frontend && npm ci && npm run build && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
The production script currently sets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/root/code/evotraders/.pydeps:.
|
||||||
|
TICKERS=${TICKERS:-AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN}
|
||||||
|
```
|
||||||
|
|
||||||
|
It then launches:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m backend.main \
|
||||||
|
--mode live \
|
||||||
|
--config-name production \
|
||||||
|
--host 127.0.0.1 \
|
||||||
|
--port 8765 \
|
||||||
|
--trigger-time now \
|
||||||
|
--poll-interval 15
|
||||||
|
```
|
||||||
|
|
||||||
|
## What This Deployment Does Not Yet Cover
|
||||||
|
|
||||||
|
The checked-in deployment artifacts do not currently document or automate:
|
||||||
|
|
||||||
|
- split FastAPI service deployment on `8000` to `8004`
|
||||||
|
- OpenClaw gateway deployment on `18789`
|
||||||
|
- database backup/retention workflows
|
||||||
|
- frontend build/publish steps
|
||||||
|
- secret management
|
||||||
|
|
||||||
|
If you move production fully to split-service mode, update this directory so it
|
||||||
|
documents the new service topology explicitly instead of relying on the gateway-
|
||||||
|
only path.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name evotraders.cillinn.com;
|
server_name bigtime.cillinn.com;
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
root /var/www/evotraders/current;
|
root /var/www/bigtime/current;
|
||||||
allow all;
|
allow all;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,13 +14,13 @@ server {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name evotraders.cillinn.com;
|
server_name bigtime.cillinn.com;
|
||||||
|
|
||||||
root /var/www/evotraders/current;
|
root /var/www/bigtime/current;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/evotraders.cillinn.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/bigtime.cillinn.com/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/evotraders.cillinn.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/bigtime.cillinn.com/privkey.pem;
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name evotraders.cillinn.com;
|
server_name bigtime.cillinn.com;
|
||||||
|
|
||||||
root /var/www/evotraders/current;
|
root /var/www/bigtime/current;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=EvoTraders Production Service
|
Description=大时代 Production Service
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
BIN
docs/assets/bigtime_demo.gif
Normal file
BIN
docs/assets/bigtime_demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1006 KiB |
BIN
docs/assets/bigtime_logo.jpg
Normal file
BIN
docs/assets/bigtime_logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -1,28 +1,116 @@
|
|||||||
# Compatibility Removal Plan
|
# Compatibility And Migration Status
|
||||||
|
|
||||||
This document tracks the remaining migration-only surfaces that still exist
|
This document tracks the remaining migration-related boundaries after the
|
||||||
after the move to split-first development.
|
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`
|
### `backend.app`
|
||||||
|
|
||||||
- Removed after compatibility startup switched to
|
- Removed after startup paths switched away from the legacy app wrapper.
|
||||||
`backend.apps.combined_service:app` directly.
|
|
||||||
|
### `backend.apps.combined_service`
|
||||||
|
|
||||||
|
- Removed after split-service startup became the only supported local dev mode.
|
||||||
|
|
||||||
### `shared.client.AgentServiceClient`
|
### `shared.client.AgentServiceClient`
|
||||||
|
|
||||||
- Removed after split-aware clients became the default import surface.
|
- Removed after split-aware clients became the default import surface.
|
||||||
- Replacement:
|
- Replaced by:
|
||||||
- `ControlPlaneClient`
|
- `ControlPlaneClient`
|
||||||
- `RuntimeServiceClient`
|
- `RuntimeServiceClient`
|
||||||
- `TradingServiceClient`
|
- `TradingServiceClient`
|
||||||
- `NewsServiceClient`
|
- `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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ================== General Configuration | 通用配置 ==================
|
# ================== General Configuration | 通用配置 ==================
|
||||||
# List of stock ticker symbols to analyze (comma-separated) | 想要分析的股票代码列表(用逗号分隔)
|
# List of stock ticker symbols to analyze (comma-separated) | 想要分析的股票代码列表(用逗号分隔)
|
||||||
TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN
|
TICKERS=AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN
|
||||||
|
|
||||||
# Financial Data API
|
# Financial Data API
|
||||||
# At least FINANCIAL_DATASETS_API_KEY is required, corresponding to FIN_DATA_SOURCE=financial_datasets; It's recommended to add FINNHUB_API_KEY, corresponding to FIN_DATA_SOURCE=finnhub; FINNHUB_API_KEY is mandatory for live mode
|
# At least FINANCIAL_DATASETS_API_KEY is required, corresponding to FIN_DATA_SOURCE=financial_datasets; It's recommended to add FINNHUB_API_KEY, corresponding to FIN_DATA_SOURCE=finnhub; FINNHUB_API_KEY is mandatory for live mode
|
||||||
@@ -20,6 +20,9 @@ MARKET_DB_PATH= #optional path for long-lived market_research.db | 长期市场
|
|||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
OPENAI_BASE_URL=
|
||||||
MODEL_NAME=qwen3-max-preview
|
MODEL_NAME=qwen3-max-preview
|
||||||
|
OPENCLAW_CMD=
|
||||||
|
OPENCLAW_CWD=
|
||||||
|
OPENCLAW_TIMEOUT_SECONDS=15
|
||||||
EXPLAIN_ENRICH_USE_LLM=false
|
EXPLAIN_ENRICH_USE_LLM=false
|
||||||
EXPLAIN_ENRICH_MODEL_PROVIDER=
|
EXPLAIN_ENRICH_MODEL_PROVIDER=
|
||||||
EXPLAIN_ENRICH_MODEL_NAME=
|
EXPLAIN_ENRICH_MODEL_NAME=
|
||||||
|
|||||||
@@ -1,31 +1,66 @@
|
|||||||
## QuickStart
|
## Frontend Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Optional Direct Service Calls
|
Default dev URL: `http://localhost:5173`
|
||||||
|
|
||||||
The frontend still works with the compatibility backend entrypoint by default.
|
The frontend expects the 大时代 gateway WebSocket on `ws://localhost:8765` unless overridden.
|
||||||
In the current test-stage setup, split services are the recommended default.
|
|
||||||
Point the frontend directly at those standalone services:
|
## 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
|
```bash
|
||||||
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
|
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
|
||||||
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
||||||
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
||||||
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
||||||
|
VITE_WS_URL=ws://localhost:8765
|
||||||
```
|
```
|
||||||
|
|
||||||
Current direct-call coverage:
|
There is also a starter template at [frontend/env.template](./env.template).
|
||||||
|
|
||||||
- runtime panel + gateway port discovery
|
For production deployments, prefer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the deployed frontend matches the checked-in `package-lock.json`.
|
||||||
|
|
||||||
|
## Direct-Service Coverage
|
||||||
|
|
||||||
|
Current direct-call coverage includes:
|
||||||
|
|
||||||
|
- runtime panel data loading
|
||||||
|
- gateway port/runtime discovery
|
||||||
- `story`
|
- `story`
|
||||||
- `similar days`
|
- `similar days`
|
||||||
- `range explain`
|
- `range explain`
|
||||||
- `news for date`
|
- `news for date`
|
||||||
- `news categories`
|
- `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
|
If these variables are not set, the frontend falls back to local defaults and compatibility paths where they still exist.
|
||||||
WebSocket-driven compatibility flow.
|
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
# Frontend Environment Variables Template
|
# Frontend Environment Variables Template
|
||||||
# 复制此文件为 .env 并修改配置
|
# 复制此文件为 .env 并修改配置
|
||||||
|
|
||||||
# WebSocket服务器地址
|
# 控制面 API(agent/workspaces/guard)
|
||||||
# 本地开发
|
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
|
||||||
|
|
||||||
|
# 运行时 API(start/stop/runtime info)
|
||||||
|
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
||||||
|
|
||||||
|
# 新闻服务(可选,未配置时走默认回退)
|
||||||
|
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
||||||
|
|
||||||
|
# 交易数据服务(可选,未配置时走默认回退)
|
||||||
|
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
||||||
|
|
||||||
|
# WebSocket Gateway
|
||||||
VITE_WS_URL=ws://localhost:8765
|
VITE_WS_URL=ws://localhost:8765
|
||||||
|
|
||||||
# 生产环境(替换为你的实际服务器地址)
|
# 生产环境示例
|
||||||
# VITE_WS_URL=wss://your-server.com:8765
|
# VITE_CONTROL_API_BASE_URL=https://your-domain.com/api
|
||||||
|
# VITE_RUNTIME_API_BASE_URL=https://your-domain.com/api/runtime
|
||||||
|
# VITE_NEWS_SERVICE_URL=https://your-domain.com/news
|
||||||
|
# VITE_TRADING_SERVICE_URL=https://your-domain.com/trading
|
||||||
|
# VITE_WS_URL=wss://your-domain.com/ws
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/trading_logo.png" />
|
<link rel="icon" type="image/png" href="/trading_logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>EvoTraders</title>
|
<title>大时代</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
14362
frontend/package-lock.json
generated
Normal file
14362
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20",
|
||||||
|
"npm": ">=10"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -13,6 +17,10 @@
|
|||||||
"preview:host": "vite preview --host"
|
"preview:host": "vite preview --host"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dicebear/collection": "^9.4.2",
|
||||||
|
"@dicebear/core": "^9.4.2",
|
||||||
|
"@lobehub/icons": "^5.2.0",
|
||||||
|
"@lobehub/ui": "^5.6.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
@@ -24,6 +32,7 @@
|
|||||||
"@react-three/drei": "^10.7.6",
|
"@react-three/drei": "^10.7.6",
|
||||||
"@react-three/fiber": "^9.3.0",
|
"@react-three/fiber": "^9.3.0",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
|
"antd": "^6.3.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.13",
|
"framer-motion": "^12.23.13",
|
||||||
@@ -31,6 +40,7 @@
|
|||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-is": "^19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
|||||||
3355
frontend/src/App.jsx
3355
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ASSETS } from '../config/constants';
|
import { ASSETS } from '../config/constants';
|
||||||
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
|
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
|
||||||
|
import LobeModelLogo from './LobeModelLogo.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get rank medal/trophy
|
* Get rank medal/trophy
|
||||||
@@ -57,7 +58,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
borderBottom: '2px solid #000000',
|
borderBottom: '2px solid #000000',
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||||
zIndex: 1000,
|
zIndex: 800,
|
||||||
animation: isClosing ? 'slideUp 0.2s ease-out forwards' : 'slideDown 0.25s ease-out'
|
animation: isClosing ? 'slideUp 0.2s ease-out forwards' : 'slideDown 0.25s ease-out'
|
||||||
}}>
|
}}>
|
||||||
{/* Horizontal scrollable content */}
|
{/* Horizontal scrollable content */}
|
||||||
@@ -207,14 +208,18 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginBottom: 4
|
marginBottom: 4
|
||||||
}}>
|
}}>
|
||||||
{modelInfo.logoPath ? (
|
{agent.modelName || modelInfo.logoPath ? (
|
||||||
<img
|
<LobeModelLogo
|
||||||
src={modelInfo.logoPath}
|
model={agent.modelName}
|
||||||
|
provider={agent.modelProvider}
|
||||||
|
fallbackSrc={modelInfo.logoPath}
|
||||||
alt={modelInfo.provider}
|
alt={modelInfo.provider}
|
||||||
|
size={36}
|
||||||
|
type="color"
|
||||||
|
shape="square"
|
||||||
style={{
|
style={{
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
objectFit: 'contain'
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { formatTime } from '../utils/formatters';
|
|||||||
import { MESSAGE_COLORS, getAgentColors, AGENTS, ASSETS } from '../config/constants';
|
import { MESSAGE_COLORS, getAgentColors, AGENTS, ASSETS } from '../config/constants';
|
||||||
import { getModelIcon } from '../utils/modelIcons';
|
import { getModelIcon } from '../utils/modelIcons';
|
||||||
import MarkdownModal from './MarkdownModal';
|
import MarkdownModal from './MarkdownModal';
|
||||||
|
import LobeModelLogo from './LobeModelLogo.jsx';
|
||||||
|
|
||||||
const isAnalyst = (agentId, agentName) => {
|
const isAnalyst = (agentId, agentName) => {
|
||||||
if (agentId && agentId.includes('analyst')) return true;
|
if (agentId && agentId.includes('analyst')) return true;
|
||||||
@@ -35,14 +36,22 @@ const stripMarkdown = (text) => {
|
|||||||
.replace(/^[-=]+$/gm, '');
|
.replace(/^[-=]+$/gm, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
|
const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) => {
|
||||||
const feedContentRef = useRef(null);
|
const feedContentRef = useRef(null);
|
||||||
const [highlightedId, setHighlightedId] = useState(null);
|
const [highlightedId, setHighlightedId] = useState(null);
|
||||||
const [selectedAgent, setSelectedAgent] = useState('all');
|
const [selectedAgent, setSelectedAgent] = useState('all');
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const getAgentModelInfo = (agentId) => {
|
const getAgentModelInfo = (agentId) => {
|
||||||
if (!leaderboard || !agentId) return { modelName: null, modelProvider: null };
|
if (!agentId) return { modelName: null, modelProvider: null };
|
||||||
|
const profile = agentProfilesByAgent?.[agentId];
|
||||||
|
if (profile?.model_name) {
|
||||||
|
return {
|
||||||
|
modelName: profile.model_name,
|
||||||
|
modelProvider: profile.model_provider
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!leaderboard) return { modelName: null, modelProvider: null };
|
||||||
const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId);
|
const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId);
|
||||||
return {
|
return {
|
||||||
modelName: agentData?.modelName,
|
modelName: agentData?.modelName,
|
||||||
@@ -52,7 +61,17 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
|
|||||||
|
|
||||||
// Get agent info by name
|
// Get agent info by name
|
||||||
const getAgentInfoByName = (agentName) => {
|
const getAgentInfoByName = (agentName) => {
|
||||||
if (!leaderboard || !agentName) return null;
|
if (!agentName) return null;
|
||||||
|
const agentConfig = AGENTS.find((agent) => agent.name === agentName);
|
||||||
|
const profile = agentConfig ? agentProfilesByAgent?.[agentConfig.id] : null;
|
||||||
|
if (agentConfig && profile?.model_name) {
|
||||||
|
return {
|
||||||
|
agentId: agentConfig.id,
|
||||||
|
modelName: profile.model_name,
|
||||||
|
modelProvider: profile.model_provider
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!leaderboard) return null;
|
||||||
const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName);
|
const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName);
|
||||||
if (!agentData) return null;
|
if (!agentData) return null;
|
||||||
return {
|
return {
|
||||||
@@ -149,11 +168,11 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
|
|||||||
// Get current selection display info
|
// Get current selection display info
|
||||||
const getCurrentSelectionInfo = () => {
|
const getCurrentSelectionInfo = () => {
|
||||||
if (selectedAgent === 'all') {
|
if (selectedAgent === 'all') {
|
||||||
return { label: '全部角色', modelInfo: null };
|
return { label: '全部角色', modelInfo: null, agentInfo: null };
|
||||||
}
|
}
|
||||||
const agentInfo = getAgentInfoByName(selectedAgent);
|
const agentInfo = getAgentInfoByName(selectedAgent);
|
||||||
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
|
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
|
||||||
return { label: selectedAgent, modelInfo };
|
return { label: selectedAgent, modelInfo, agentInfo };
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentSelection = getCurrentSelectionInfo();
|
const currentSelection = getCurrentSelectionInfo();
|
||||||
@@ -171,11 +190,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
|
|||||||
onBlur={() => setTimeout(() => setDropdownOpen(false), 200)}
|
onBlur={() => setTimeout(() => setDropdownOpen(false), 200)}
|
||||||
>
|
>
|
||||||
<div className="custom-select-value">
|
<div className="custom-select-value">
|
||||||
{currentSelection.modelInfo?.logoPath && (
|
{(currentSelection.agentInfo?.modelName || currentSelection.modelInfo?.logoPath) && (
|
||||||
<img
|
<LobeModelLogo
|
||||||
src={currentSelection.modelInfo.logoPath}
|
model={currentSelection.agentInfo?.modelName}
|
||||||
alt={currentSelection.modelInfo.provider}
|
provider={currentSelection.agentInfo?.modelProvider}
|
||||||
|
fallbackSrc={currentSelection.modelInfo?.logoPath}
|
||||||
|
alt={currentSelection.modelInfo?.provider}
|
||||||
|
size={18}
|
||||||
className="select-model-icon"
|
className="select-model-icon"
|
||||||
|
shape="square"
|
||||||
|
type="color"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{currentSelection.label}</span>
|
<span>{currentSelection.label}</span>
|
||||||
@@ -205,11 +229,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
|
|||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{modelInfo?.logoPath && (
|
{(agentInfo?.modelName || modelInfo?.logoPath) && (
|
||||||
<img
|
<LobeModelLogo
|
||||||
src={modelInfo.logoPath}
|
model={agentInfo?.modelName}
|
||||||
alt={modelInfo.provider}
|
provider={agentInfo?.modelProvider}
|
||||||
|
fallbackSrc={modelInfo?.logoPath}
|
||||||
|
alt={modelInfo?.provider}
|
||||||
|
size={18}
|
||||||
className="select-model-icon"
|
className="select-model-icon"
|
||||||
|
shape="square"
|
||||||
|
type="color"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{agent}</span>
|
<span>{agent}</span>
|
||||||
@@ -345,16 +374,16 @@ function ConferenceMessage({ message, getAgentModelInfo }) {
|
|||||||
return (
|
return (
|
||||||
<div className="conf-message-item">
|
<div className="conf-message-item">
|
||||||
<div className="conf-agent-name" style={{ color: agentColors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
<div className="conf-agent-name" style={{ color: agentColors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||||
{modelInfo.logoPath && (
|
{(agentModelData.modelName || modelInfo.logoPath) && (
|
||||||
<img
|
<LobeModelLogo
|
||||||
src={modelInfo.logoPath}
|
model={agentModelData.modelName}
|
||||||
|
provider={agentModelData.modelProvider}
|
||||||
|
fallbackSrc={modelInfo.logoPath}
|
||||||
alt={modelInfo.provider}
|
alt={modelInfo.provider}
|
||||||
style={{
|
size={20}
|
||||||
width: '20px',
|
shape="circle"
|
||||||
height: '20px',
|
type="color"
|
||||||
borderRadius: '50%',
|
style={{ borderRadius: '50%' }}
|
||||||
objectFit: 'contain'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{message.agent}
|
{message.agent}
|
||||||
@@ -573,16 +602,16 @@ function MessageItem({ message, itemId, isHighlighted, getAgentModelInfo }) {
|
|||||||
>
|
>
|
||||||
<div className="feed-item-header">
|
<div className="feed-item-header">
|
||||||
<span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
<span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||||
{modelInfo.logoPath && message.agent !== 'Memory' && (
|
{message.agent !== 'Memory' && (agentModelData.modelName || modelInfo.logoPath) && (
|
||||||
<img
|
<LobeModelLogo
|
||||||
src={modelInfo.logoPath}
|
model={agentModelData.modelName}
|
||||||
|
provider={agentModelData.modelProvider}
|
||||||
|
fallbackSrc={modelInfo.logoPath}
|
||||||
alt={modelInfo.provider}
|
alt={modelInfo.provider}
|
||||||
style={{
|
size={20}
|
||||||
width: '20px',
|
shape="circle"
|
||||||
height: '20px',
|
type="color"
|
||||||
borderRadius: '50%',
|
style={{ borderRadius: '50%' }}
|
||||||
objectFit: 'contain'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
511
frontend/src/components/AppShell.jsx
Normal file
511
frontend/src/components/AppShell.jsx
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
import React, { Suspense, lazy, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import GlobalStyles from '../styles/GlobalStyles';
|
||||||
|
import Header from './Header.jsx';
|
||||||
|
import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx';
|
||||||
|
import NetValueChart from './NetValueChart.jsx';
|
||||||
|
import { AGENTS } from '../config/constants';
|
||||||
|
import { useRuntimeStore } from '../store/runtimeStore';
|
||||||
|
import { useUIStore } from '../store/uiStore';
|
||||||
|
import { formatNumber, formatTickerPrice } from '../utils/formatters';
|
||||||
|
|
||||||
|
const RoomView = lazy(() => import('./RoomView'));
|
||||||
|
const AgentFeed = lazy(() => import('./AgentFeed'));
|
||||||
|
const StatisticsView = lazy(() => import('./StatisticsView'));
|
||||||
|
const StockExplainView = lazy(() => import('./StockExplainView.jsx'));
|
||||||
|
const TraderView = lazy(() => import('./TraderView.jsx'));
|
||||||
|
|
||||||
|
function ViewLoadingFallback({ label = '加载中...' }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: 240,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#ffffff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.4
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppShell - Layout shell containing Header, TickerBar, ViewNavBar, View container, and AgentFeed
|
||||||
|
*/
|
||||||
|
export default function AppShell({
|
||||||
|
// Connection & status
|
||||||
|
isConnected,
|
||||||
|
virtualTime,
|
||||||
|
now,
|
||||||
|
marketStatus,
|
||||||
|
serverMode,
|
||||||
|
marketStatusLabel,
|
||||||
|
dataSourceLabel,
|
||||||
|
runtimeSummaryLabel,
|
||||||
|
isUpdating,
|
||||||
|
// Handlers
|
||||||
|
onManualTrigger,
|
||||||
|
onOpenRuntimeLogs,
|
||||||
|
onRuntimeSettingsToggle,
|
||||||
|
// Runtime settings panel props
|
||||||
|
isRuntimeSettingsOpen,
|
||||||
|
isRuntimeConfigSaving,
|
||||||
|
isWatchlistSaving,
|
||||||
|
runtimeConfigFeedback,
|
||||||
|
watchlistFeedback,
|
||||||
|
launchModeDraft,
|
||||||
|
restoreRunIdDraft,
|
||||||
|
runtimeHistoryRuns,
|
||||||
|
scheduleModeDraft,
|
||||||
|
intervalMinutesDraft,
|
||||||
|
triggerTimeDraft,
|
||||||
|
maxCommCyclesDraft,
|
||||||
|
initialCashDraft,
|
||||||
|
marginRequirementDraft,
|
||||||
|
enableMemoryDraft,
|
||||||
|
modeDraft,
|
||||||
|
pollIntervalDraft,
|
||||||
|
startDateDraft,
|
||||||
|
endDateDraft,
|
||||||
|
watchlistDraftSymbols,
|
||||||
|
watchlistInputValue,
|
||||||
|
watchlistSuggestions,
|
||||||
|
onLaunchModeChange,
|
||||||
|
onRestoreRunIdChange,
|
||||||
|
onScheduleModeChange,
|
||||||
|
onIntervalMinutesChange,
|
||||||
|
onTriggerTimeChange,
|
||||||
|
onMaxCommCyclesChange,
|
||||||
|
onInitialCashChange,
|
||||||
|
onMarginRequirementChange,
|
||||||
|
onEnableMemoryChange,
|
||||||
|
onModeChange,
|
||||||
|
onPollIntervalChange,
|
||||||
|
onStartDateChange,
|
||||||
|
onEndDateChange,
|
||||||
|
onWatchlistInputChange,
|
||||||
|
onWatchlistInputKeyDown,
|
||||||
|
onWatchlistAdd,
|
||||||
|
onWatchlistRemove,
|
||||||
|
onWatchlistRestoreCurrent,
|
||||||
|
onWatchlistRestoreDefault,
|
||||||
|
onWatchlistSuggestionClick,
|
||||||
|
onLaunchConfigSave,
|
||||||
|
onRestoreDefaults,
|
||||||
|
// Ticker and portfolio data
|
||||||
|
displayTickers,
|
||||||
|
portfolioData,
|
||||||
|
rollingTickers,
|
||||||
|
// Feed data
|
||||||
|
feed,
|
||||||
|
bubbles,
|
||||||
|
bubbleFor,
|
||||||
|
leaderboard,
|
||||||
|
// Views data
|
||||||
|
currentView,
|
||||||
|
chartTab,
|
||||||
|
holdings,
|
||||||
|
trades,
|
||||||
|
stats,
|
||||||
|
priceHistoryByTicker,
|
||||||
|
ohlcHistoryByTicker,
|
||||||
|
selectedExplainSymbol,
|
||||||
|
onSelectedExplainSymbolChange,
|
||||||
|
historySourceByTicker,
|
||||||
|
explainEventsByTicker,
|
||||||
|
newsByTicker,
|
||||||
|
insiderTradesByTicker,
|
||||||
|
technicalIndicatorsByTicker,
|
||||||
|
currentDate,
|
||||||
|
// Stock request handlers
|
||||||
|
stockRequests,
|
||||||
|
// Agent request handlers
|
||||||
|
agentRequests,
|
||||||
|
agentProfilesByAgent,
|
||||||
|
// Layout
|
||||||
|
leftWidth,
|
||||||
|
isResizing,
|
||||||
|
onMouseDown,
|
||||||
|
agentFeedRef
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const { setIsRuntimeSettingsOpen, setIsWatchlistPanelOpen } = useRuntimeStore();
|
||||||
|
const { setChartTab, setCurrentView, setIsResizing, setLeftWidth } = useUIStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentView === 'openclaw') {
|
||||||
|
setCurrentView('statistics');
|
||||||
|
}
|
||||||
|
}, [currentView, setCurrentView]);
|
||||||
|
|
||||||
|
// Resize handler
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
||||||
|
if (newLeftWidth >= 30 && newLeftWidth <= 85) {
|
||||||
|
setLeftWidth(newLeftWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => setIsResizing(false);
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isResizing, setIsResizing, setLeftWidth]);
|
||||||
|
|
||||||
|
const handleJumpToMessage = (bubble) => {
|
||||||
|
if (agentFeedRef.current && agentFeedRef.current.scrollToMessage) {
|
||||||
|
agentFeedRef.current.scrollToMessage(bubble);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewClassName = useMemo(() => {
|
||||||
|
const base = `view-slider-five ${currentView === 'traders' ? 'show-traders' :
|
||||||
|
currentView === 'room' ? 'show-room' :
|
||||||
|
currentView === 'explain' ? 'show-explain' :
|
||||||
|
currentView === 'chart' ? 'show-chart' :
|
||||||
|
'show-statistics'}`;
|
||||||
|
return base;
|
||||||
|
}, [currentView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<GlobalStyles />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="header">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
|
||||||
|
{/* Unified Status Indicator */}
|
||||||
|
<div className="header-status-inline">
|
||||||
|
<span className={`status-dot ${isConnected ? (isUpdating ? 'updating' : 'live') : 'offline'}`} />
|
||||||
|
<span className={`status-text ${isConnected ? 'live' : 'offline'}`}>
|
||||||
|
{isConnected ? (isUpdating ? '同步中' : '在线') : '离线'}
|
||||||
|
</span>
|
||||||
|
{marketStatus && (
|
||||||
|
<>
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
|
||||||
|
{marketStatusLabel}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{dataSourceLabel && (
|
||||||
|
<>
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className="market-text backtest">{dataSourceLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{runtimeSummaryLabel && (
|
||||||
|
<>
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className="market-text backtest" title="当前运行配置">{runtimeSummaryLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{serverMode !== 'backtest' && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
{onOpenRuntimeLogs && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenRuntimeLogs}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #111111',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
letterSpacing: '0.4px',
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}
|
||||||
|
title="查看当前运行日志"
|
||||||
|
>
|
||||||
|
运行日志
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onManualTrigger}
|
||||||
|
disabled={!isConnected}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: isConnected ? '#111111' : '#8a8a8a',
|
||||||
|
border: '1px solid #111111',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: isConnected ? 'pointer' : 'not-allowed',
|
||||||
|
letterSpacing: '0.4px',
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}
|
||||||
|
title="手动触发一轮分析与交易决策"
|
||||||
|
>
|
||||||
|
手动运行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RuntimeSettingsPanel
|
||||||
|
showTrigger={false}
|
||||||
|
isOpen={isRuntimeSettingsOpen}
|
||||||
|
isConnected={isConnected}
|
||||||
|
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
|
||||||
|
feedback={runtimeConfigFeedback || watchlistFeedback}
|
||||||
|
launchMode={launchModeDraft}
|
||||||
|
restoreRunId={restoreRunIdDraft}
|
||||||
|
runtimeHistoryRuns={runtimeHistoryRuns}
|
||||||
|
scheduleMode={scheduleModeDraft}
|
||||||
|
intervalMinutes={intervalMinutesDraft}
|
||||||
|
triggerTime={triggerTimeDraft}
|
||||||
|
maxCommCycles={maxCommCyclesDraft}
|
||||||
|
initialCash={initialCashDraft}
|
||||||
|
marginRequirement={marginRequirementDraft}
|
||||||
|
enableMemory={enableMemoryDraft}
|
||||||
|
mode={modeDraft}
|
||||||
|
pollInterval={pollIntervalDraft}
|
||||||
|
startDate={startDateDraft}
|
||||||
|
endDate={endDateDraft}
|
||||||
|
watchlistSymbols={watchlistDraftSymbols}
|
||||||
|
watchlistInputValue={watchlistInputValue}
|
||||||
|
watchlistSuggestions={watchlistSuggestions}
|
||||||
|
onToggle={onRuntimeSettingsToggle}
|
||||||
|
onClose={() => setIsRuntimeSettingsOpen(false)}
|
||||||
|
onLaunchModeChange={onLaunchModeChange}
|
||||||
|
onRestoreRunIdChange={onRestoreRunIdChange}
|
||||||
|
onScheduleModeChange={onScheduleModeChange}
|
||||||
|
onIntervalMinutesChange={onIntervalMinutesChange}
|
||||||
|
onTriggerTimeChange={onTriggerTimeChange}
|
||||||
|
onMaxCommCyclesChange={onMaxCommCyclesChange}
|
||||||
|
onInitialCashChange={onInitialCashChange}
|
||||||
|
onMarginRequirementChange={onMarginRequirementChange}
|
||||||
|
onEnableMemoryChange={onEnableMemoryChange}
|
||||||
|
onModeChange={onModeChange}
|
||||||
|
onPollIntervalChange={onPollIntervalChange}
|
||||||
|
onStartDateChange={onStartDateChange}
|
||||||
|
onEndDateChange={onEndDateChange}
|
||||||
|
onWatchlistInputChange={onWatchlistInputChange}
|
||||||
|
onWatchlistInputKeyDown={onWatchlistInputKeyDown}
|
||||||
|
onWatchlistAdd={onWatchlistAdd}
|
||||||
|
onWatchlistRemove={onWatchlistRemove}
|
||||||
|
onWatchlistRestoreCurrent={onWatchlistRestoreCurrent}
|
||||||
|
onWatchlistRestoreDefault={onWatchlistRestoreDefault}
|
||||||
|
onWatchlistSuggestionClick={onWatchlistSuggestionClick}
|
||||||
|
onSave={onLaunchConfigSave}
|
||||||
|
onRestoreDefaults={onRestoreDefaults}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<>
|
||||||
|
{/* Ticker Bar */}
|
||||||
|
<div className="ticker-bar">
|
||||||
|
<div className="ticker-track">
|
||||||
|
{[0, 1].map((groupIdx) => (
|
||||||
|
<div key={groupIdx} className="ticker-group">
|
||||||
|
{displayTickers.map(ticker => (
|
||||||
|
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
|
||||||
|
<span className="ticker-symbol">{ticker.symbol}</span>
|
||||||
|
<span className="ticker-price">
|
||||||
|
<span className={`ticker-price-value ${rollingTickers[ticker.symbol] ? 'rolling' : ''}`}>
|
||||||
|
{ticker.price !== null && ticker.price !== undefined
|
||||||
|
? `$${formatTickerPrice(ticker.price)}` : '-'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`ticker-change ${
|
||||||
|
ticker.change === null || ticker.change === undefined
|
||||||
|
? '' : ticker.change >= 0 ? 'positive' : 'negative'
|
||||||
|
}`}>
|
||||||
|
{ticker.change !== null && ticker.change !== undefined
|
||||||
|
? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="portfolio-value">
|
||||||
|
<span className="portfolio-label">投资组合</span>
|
||||||
|
<span className="portfolio-amount">${formatNumber(portfolioData.netValue)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main-container" ref={containerRef}>
|
||||||
|
{/* Left Panel */}
|
||||||
|
<div className="left-panel" style={{ width: `${leftWidth}%` }}>
|
||||||
|
<div className="chart-section">
|
||||||
|
<div className="view-container">
|
||||||
|
<div className="view-nav-bar">
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'traders' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('traders')}
|
||||||
|
>
|
||||||
|
交易员
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('room')}
|
||||||
|
>
|
||||||
|
交易室
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('explain')}
|
||||||
|
>
|
||||||
|
个股分析
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('chart')}
|
||||||
|
>
|
||||||
|
业绩图表
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'statistics' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('statistics')}
|
||||||
|
>
|
||||||
|
统计
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={viewClassName}>
|
||||||
|
{/* Traders View */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
|
||||||
|
<TraderView {...agentRequests} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Room View Panel */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
|
||||||
|
<RoomView
|
||||||
|
bubbles={bubbles}
|
||||||
|
bubbleFor={bubbleFor}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
agentProfilesByAgent={agentProfilesByAgent}
|
||||||
|
feed={feed}
|
||||||
|
onJumpToMessage={handleJumpToMessage}
|
||||||
|
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock Explain View Panel */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载个股分析..." />}>
|
||||||
|
<StockExplainView
|
||||||
|
tickers={displayTickers}
|
||||||
|
holdings={holdings}
|
||||||
|
trades={trades}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
feed={feed}
|
||||||
|
priceHistoryByTicker={priceHistoryByTicker}
|
||||||
|
ohlcHistoryByTicker={ohlcHistoryByTicker}
|
||||||
|
selectedSymbol={selectedExplainSymbol}
|
||||||
|
onSelectedSymbolChange={onSelectedExplainSymbolChange}
|
||||||
|
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
||||||
|
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
||||||
|
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
|
||||||
|
insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null}
|
||||||
|
technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null}
|
||||||
|
onRequestHistory={stockRequests?.requestStockHistory}
|
||||||
|
onRequestExplainEvents={stockRequests?.requestStockExplainEvents}
|
||||||
|
onRequestNews={stockRequests?.requestStockNews}
|
||||||
|
onRequestRangeExplain={stockRequests?.requestStockRangeExplain}
|
||||||
|
onRequestNewsForDate={stockRequests?.requestStockNewsForDate}
|
||||||
|
onRequestStory={stockRequests?.requestStockStory}
|
||||||
|
onRequestInsiderTrades={stockRequests?.requestStockInsiderTrades}
|
||||||
|
onRequestTechnicalIndicators={stockRequests?.requestStockTechnicalIndicators}
|
||||||
|
currentDate={currentDate}
|
||||||
|
onRequestSimilarDays={stockRequests?.requestStockSimilarDays}
|
||||||
|
onRequestStockEnrich={stockRequests?.requestStockEnrich}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart View Panel */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<div className="chart-container">
|
||||||
|
<div className="chart-tabs-floating">
|
||||||
|
<button
|
||||||
|
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
|
||||||
|
onClick={() => setChartTab('all')}
|
||||||
|
>
|
||||||
|
日线
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{currentView === 'chart' ? (
|
||||||
|
<NetValueChart
|
||||||
|
equity={portfolioData.equity}
|
||||||
|
baseline={portfolioData.baseline}
|
||||||
|
baseline_vw={portfolioData.baseline_vw}
|
||||||
|
momentum={portfolioData.momentum}
|
||||||
|
strategies={portfolioData.strategies}
|
||||||
|
equity_return={portfolioData.equity_return}
|
||||||
|
baseline_return={portfolioData.baseline_return}
|
||||||
|
baseline_vw_return={portfolioData.baseline_vw_return}
|
||||||
|
momentum_return={portfolioData.momentum_return}
|
||||||
|
chartTab={chartTab}
|
||||||
|
virtualTime={virtualTime}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '100%', minHeight: 320 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics View Panel */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载统计视图..." />}>
|
||||||
|
<StatisticsView
|
||||||
|
trades={trades}
|
||||||
|
holdings={holdings}
|
||||||
|
stats={stats}
|
||||||
|
portfolioData={portfolioData}
|
||||||
|
baseline_vw={portfolioData.baseline_vw}
|
||||||
|
equity={portfolioData.equity}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resizer */}
|
||||||
|
<div className={`resizer ${isResizing ? 'resizing' : ''}`} onMouseDown={onMouseDown} />
|
||||||
|
|
||||||
|
{/* Right Panel: Agent Feed */}
|
||||||
|
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
|
||||||
|
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} agentProfilesByAgent={agentProfilesByAgent} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Header Component
|
* Header Component
|
||||||
* Reusable header brand for EvoTraders.
|
* Reusable header brand for 大时代.
|
||||||
*/
|
*/
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
return (
|
return (
|
||||||
@@ -19,10 +19,10 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/trading_logo.png"
|
src="/trading_logo.png"
|
||||||
alt="EvoTraders Logo"
|
alt="大时代 Logo"
|
||||||
style={{ height: '24px', width: 'auto' }}
|
style={{ height: '24px', width: 'auto' }}
|
||||||
/>
|
/>
|
||||||
EvoTraders
|
大时代
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
78
frontend/src/components/LobeModelLogo.jsx
Normal file
78
frontend/src/components/LobeModelLogo.jsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import { formatNumber, formatFullNumber } from '../utils/formatters';
|
|||||||
/**
|
/**
|
||||||
* Helper function to get the start time of the most recent trading session
|
* Helper function to get the start time of the most recent trading session
|
||||||
* Trading session: 22:30 - next day 05:00
|
* Trading session: 22:30 - next day 05:00
|
||||||
* @param {Date|null} virtualTime - Virtual time from server (for mock mode), or null to use real time
|
* @param {Date|null} virtualTime - Virtual time from server, or null to use real time
|
||||||
*/
|
*/
|
||||||
function getRecentTradingSessionStart(virtualTime = null) {
|
function getRecentTradingSessionStart(virtualTime = null) {
|
||||||
// Use virtual time if provided (for mock mode), otherwise use real time
|
// Use virtual time if provided, otherwise use real time
|
||||||
let now;
|
let now;
|
||||||
if (virtualTime) {
|
if (virtualTime) {
|
||||||
// Ensure virtualTime is a valid Date object
|
// Ensure virtualTime is a valid Date object
|
||||||
@@ -123,7 +123,7 @@ export default function NetValueChart({ equity, baseline, baseline_vw, momentum,
|
|||||||
|
|
||||||
// Legend descriptions
|
// Legend descriptions
|
||||||
const legendDescriptions = {
|
const legendDescriptions = {
|
||||||
'EvoTraders': 'EvoTraders is our agents investment strategy',
|
'大时代': '大时代 is our agents investment strategy',
|
||||||
'Buy & Hold (EW)': 'Equal Weight: Can be viewed as an equal-weighted index of all invested stocks',
|
'Buy & Hold (EW)': 'Equal Weight: Can be viewed as an equal-weighted index of all invested stocks',
|
||||||
'Buy & Hold (VW)': 'Value Weighted: Can be viewed as a market-cap weighted index of all invested stocks',
|
'Buy & Hold (VW)': 'Value Weighted: Can be viewed as a market-cap weighted index of all invested stocks',
|
||||||
'Momentum': 'Momentum Strategy: Buy stocks that have performed well in the past',
|
'Momentum': 'Momentum Strategy: Buy stocks that have performed well in the past',
|
||||||
@@ -758,7 +758,7 @@ export default function NetValueChart({ equity, baseline, baseline_vw, momentum,
|
|||||||
<Line
|
<Line
|
||||||
type="linear"
|
type="linear"
|
||||||
dataKey="portfolio"
|
dataKey="portfolio"
|
||||||
name="EvoTraders"
|
name="大时代"
|
||||||
stroke="#00C853"
|
stroke="#00C853"
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
dot={(props) => <CustomDot {...props} dataKey="portfolio" />}
|
dot={(props) => <CustomDot {...props} dataKey="portfolio" />}
|
||||||
|
|||||||
1086
frontend/src/components/OpenClawStatus.jsx
Normal file
1086
frontend/src/components/OpenClawStatus.jsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user