Compare commits
15 Commits
3926a6bd07
...
codex/work
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
428
CLAUDE.md
428
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 # 启动初始化
|
│ ├── factory.py # Agent 实例工厂
|
||||||
│ │ │ ├── MemoryCompactionHook # 内存压缩(基于 CoPaw)
|
│ ├── toolkit_factory.py # 工具集工厂
|
||||||
│ │ │ ├── HeartbeatHook # 心跳检测
|
│ ├── skills_manager.py # 技能加载管理
|
||||||
│ │ │ └── WorkspaceWatchHook # 工作区监控
|
│ ├── workspace_manager.py # 工作区管理
|
||||||
│ │ ├── evaluation_hook.py # 执行后评估
|
│ ├── skill_loader.py # 技能加载器
|
||||||
│ │ ├── skill_adaptation_hook.py # 动态技能适配
|
│ ├── agent_workspace.py # Agent 工作区
|
||||||
│ │ └── tool_guard.py # 工具调用守卫
|
│ ├── prompt_loader.py # Prompt 加载器
|
||||||
│ ├── prompts/ # Agent 提示词和角色定义
|
│ ├── prompt_factory.py # Prompt 工厂
|
||||||
│ │ ├── analyst/personas.yaml # 分析师角色配置
|
│ ├── skill_metadata.py # 技能元数据
|
||||||
│ │ └── portfolio_manager/
|
│ ├── registry.py # Agent 注册表
|
||||||
│ ├── team/ # 团队协作逻辑
|
│ ├── team_pipeline_config.py # 团队 Pipeline 配置
|
||||||
│ │ ├── registry.py # Agent 注册表
|
│ ├── compat.py # 兼容性层
|
||||||
│ │ ├── coordinator.py # 协作协调器
|
│ ├── templates.py # 模板
|
||||||
│ │ ├── messenger.py # 消息传递
|
│ ├── workspace.py # 工作区
|
||||||
│ │ └── task_delegator.py # 任务分发
|
│ ├── base/ # 核心类、Hooks
|
||||||
│ ├── factory.py # Agent 实例工厂
|
│ │ ├── evo_agent.py # 基于 AgentScope 的核心实现
|
||||||
│ ├── skills_manager.py # 技能加载管理(6 种作用域)
|
│ │ └── hooks.py # 生命周期 Hooks
|
||||||
│ └── toolkit_factory.py # 工具集工厂
|
│ └── prompts/ # Agent 提示词
|
||||||
├── apps/ # 微服务入口(split-first)
|
│ └── analyst/personas.yaml
|
||||||
│ ├── agent_service.py
|
│
|
||||||
│ ├── runtime_service.py
|
├── apps/ # 微服务入口
|
||||||
│ ├── trading_service.py
|
│ ├── runtime_service.py # 运行时服务(端口 8003)
|
||||||
│ └── news_service.py
|
│ ├── agent_service.py # Agent 服务(端口 8000)
|
||||||
├── domains/ # 领域业务逻辑
|
│ ├── 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/ # 领域业务逻辑
|
||||||
│ ├── news.py
|
│ ├── news.py
|
||||||
│ └── trading.py
|
│ └── trading.py
|
||||||
├── services/ # Gateway 和辅助服务
|
│
|
||||||
│ ├── gateway.py # 统一路由网关
|
├── llm/ # LLM 集成
|
||||||
│ ├── gateway_*.py # Gateway 子模块
|
│ └── models.py # RetryChatModel、TokenRecordingModelWrapper
|
||||||
│ └── market.py # 市场数据服务
|
│
|
||||||
├── api/ # FastAPI 端点
|
├── skills/ # 技能定义
|
||||||
├── config/ # 常量和配置
|
├── tools/ # 交易和分析工具
|
||||||
│ └── constants.py # Agent 配置、显示名称等
|
├── enrich/ # LLM 响应富化
|
||||||
├── core/ # Pipeline 执行逻辑
|
├── explain/ # 交易决策解释
|
||||||
├── data/ # 市场数据处理
|
├── utils/ # 工具函数
|
||||||
│ ├── provider_router.py # 数据源路由
|
│ ├── settlement.py # 结算协调器
|
||||||
│ └── schema.py # 数据 schema
|
│ ├── trade_executor.py # 交易执行器
|
||||||
├── enrich/ # LLM 响应富化
|
│ ├── terminal_dashboard.py # 终端仪表板
|
||||||
├── explain/ # 交易决策解释
|
│ ├── analyst_tracker.py # 分析师追踪
|
||||||
├── llm/ # LLM 集成
|
│ ├── baselines.py # 基准线
|
||||||
│ └── models.py # RetryChatModel、TokenRecordingModelWrapper
|
│ ├── msg_adapter.py # 消息适配器
|
||||||
├── skills/ # 技能定义(内置 + 自定义)
|
│ └── progress.py # 进度追踪
|
||||||
├── tools/ # 交易和分析工具
|
│
|
||||||
└── utils/ # 工具函数
|
├── api/ # FastAPI 端点
|
||||||
|
│ └── runtime.py
|
||||||
|
│
|
||||||
|
└── main.py # 主入口点
|
||||||
```
|
```
|
||||||
|
|
||||||
## 前端结构
|
## 前端结构
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/
|
frontend/src/
|
||||||
├── App.jsx # React 主应用
|
├── App.jsx # 主应用(LiveTradingApp)
|
||||||
├── components/ # React 组件
|
├── AppShell.jsx # App 外壳(布局、侧边栏)
|
||||||
│ ├── RuntimeView.jsx # 交易运行时 UI
|
├── components/
|
||||||
│ ├── TraderView.jsx # 交易员界面
|
│ ├── RuntimeView.jsx # 交易运行时 UI
|
||||||
│ ├── RoomView.jsx # 聊天室视图
|
│ ├── TraderView.jsx # 交易员界面
|
||||||
│ ├── StockExplainView.jsx # 股票解释视图
|
│ ├── RoomView.jsx # 聊天室视图
|
||||||
|
│ ├── StockExplainView.jsx # 股票解释视图
|
||||||
│ ├── RuntimeSettingsPanel.jsx # 运行时设置面板
|
│ ├── RuntimeSettingsPanel.jsx # 运行时设置面板
|
||||||
│ ├── WatchlistPanel.jsx # 关注列表
|
│ ├── RuntimeLogsModal.jsx # 运行时日志弹窗
|
||||||
│ ├── PerformanceView.jsx # 绩效视图
|
│ ├── WatchlistPanel.jsx # 关注列表
|
||||||
│ ├── StatisticsView.jsx # 统计视图
|
│ ├── PerformanceView.jsx # 绩效视图
|
||||||
│ ├── NetValueChart.jsx # 净值曲线图
|
│ ├── StatisticsView.jsx # 统计视图
|
||||||
│ ├── AgentCard.jsx # Agent 卡片
|
│ ├── NetValueChart.jsx # 净值曲线图
|
||||||
│ ├── AgentFeed.jsx # Agent 动态
|
│ ├── AgentCard.jsx # Agent 卡片
|
||||||
│ └── explain/ # 解释相关组件
|
│ ├── AgentFeed.jsx # Agent 动态
|
||||||
|
│ ├── 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 系统
|
||||||
@@ -193,110 +290,87 @@ frontend/src/
|
|||||||
| `fundamentals_analyst` | 基本面分析师 | 财务健康、盈利能力、成长质量 |
|
| `fundamentals_analyst` | 基本面分析师 | 财务健康、盈利能力、成长质量 |
|
||||||
| `technical_analyst` | 技术分析师 | 价格趋势、技术指标、动量分析 |
|
| `technical_analyst` | 技术分析师 | 价格趋势、技术指标、动量分析 |
|
||||||
| `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(
|
self._ensure_file(asset_dir / filename, content, legacy_contents=legacy_contents)
|
||||||
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_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)
|
||||||
agent_id=agent_id,
|
(asset_dir / "skills" / "disabled").mkdir(parents=True, exist_ok=True)
|
||||||
role_seed=role_seed,
|
(asset_dir / "skills" / "local").mkdir(parents=True, exist_ok=True)
|
||||||
style_seed=style_seed,
|
for filename, content in file_contents.items():
|
||||||
policy_seed=policy_seed,
|
self._ensure_file(
|
||||||
)
|
asset_dir / filename,
|
||||||
|
content,
|
||||||
|
legacy_contents=self.build_legacy_agent_file_variants(
|
||||||
|
agent_id=agent_id,
|
||||||
|
filename=filename,
|
||||||
|
persona=persona,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._ensure_agent_yaml(asset_dir / "agent.yaml", agent_id=agent_id)
|
||||||
|
|
||||||
@staticmethod
|
@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,12 +39,13 @@ 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":
|
||||||
if cls._instance is None:
|
with cls._lock:
|
||||||
cls._instance = super().__new__(cls)
|
if cls._instance is None:
|
||||||
cls._instance._initialized = False
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -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"
|
||||||
process = subprocess.Popen(
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cmd,
|
|
||||||
env=env,
|
log_file = log_path.open("ab")
|
||||||
stdout=subprocess.PIPE,
|
try:
|
||||||
stderr=subprocess.PIPE,
|
process = subprocess.Popen(
|
||||||
cwd=PROJECT_ROOT
|
cmd,
|
||||||
)
|
env=env,
|
||||||
|
stdout=log_file,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
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
|
try:
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
run_id = _get_active_runtime_context().get("config_name")
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
except Exception as e:
|
||||||
if snapshots:
|
logger.warning(f"Failed to resolve active runtime context: {e}")
|
||||||
try:
|
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
|
||||||
run_id = latest.get("context", {}).get("config_name")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to parse latest snapshot: {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,26 +729,51 @@ 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()
|
||||||
run_id = _generate_run_id()
|
if launch_mode not in {"fresh", "restore"}:
|
||||||
run_dir = _get_run_dir(run_id)
|
raise HTTPException(status_code=400, detail="launch_mode must be 'fresh' or 'restore'")
|
||||||
|
|
||||||
# 3. Prepare bootstrap config
|
# 2. Resolve run ID, directory, and bootstrap
|
||||||
bootstrap = {
|
if launch_mode == "restore":
|
||||||
"tickers": config.tickers,
|
restore_run_id = str(config.restore_run_id or "").strip()
|
||||||
"schedule_mode": config.schedule_mode,
|
if not restore_run_id:
|
||||||
"interval_minutes": config.interval_minutes,
|
raise HTTPException(status_code=400, detail="restore_run_id is required when launch_mode=restore")
|
||||||
"trigger_time": config.trigger_time,
|
snapshot = _load_run_snapshot(restore_run_id)
|
||||||
"max_comm_cycles": config.max_comm_cycles,
|
context = snapshot.get("context") or {}
|
||||||
"initial_cash": config.initial_cash,
|
if not context.get("config_name"):
|
||||||
"margin_requirement": config.margin_requirement,
|
raise HTTPException(status_code=404, detail=f"Run context not found: {restore_run_id}")
|
||||||
"enable_memory": config.enable_memory,
|
run_id = restore_run_id
|
||||||
"mode": config.mode,
|
run_dir = _get_run_dir(run_id)
|
||||||
"start_date": config.start_date,
|
bootstrap = dict(context.get("bootstrap_values") or {})
|
||||||
"end_date": config.end_date,
|
bootstrap["launch_mode"] = "restore"
|
||||||
"poll_interval": config.poll_interval,
|
bootstrap["restore_run_id"] = restore_run_id
|
||||||
"enable_mock": config.enable_mock,
|
else:
|
||||||
}
|
run_id = _generate_run_id()
|
||||||
|
run_dir = _get_run_dir(run_id)
|
||||||
|
bootstrap = {
|
||||||
|
"launch_mode": "fresh",
|
||||||
|
"restore_run_id": None,
|
||||||
|
"tickers": config.tickers,
|
||||||
|
"schedule_mode": config.schedule_mode,
|
||||||
|
"interval_minutes": config.interval_minutes,
|
||||||
|
"trigger_time": config.trigger_time,
|
||||||
|
"max_comm_cycles": config.max_comm_cycles,
|
||||||
|
"initial_cash": config.initial_cash,
|
||||||
|
"margin_requirement": config.margin_requirement,
|
||||||
|
"enable_memory": config.enable_memory,
|
||||||
|
"mode": config.mode,
|
||||||
|
"start_date": config.start_date,
|
||||||
|
"end_date": config.end_date,
|
||||||
|
"poll_interval": config.poll_interval,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -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,33 +1080,31 @@ 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]")
|
console.print("Creating from template...\n")
|
||||||
console.print("Creating from template...\n")
|
template = get_project_root() / "env.template"
|
||||||
template = get_project_root() / "env.template"
|
if template.exists():
|
||||||
if template.exists():
|
shutil.copy(template, env_file)
|
||||||
shutil.copy(template, env_file)
|
console.print("[green].env file created[/green]")
|
||||||
console.print("[green].env file created[/green]")
|
console.print(
|
||||||
console.print(
|
"\n[red]Error: Please edit .env and set FINNHUB_API_KEY[/red]",
|
||||||
"\n[red]Error: Please edit .env and set FINNHUB_API_KEY[/red]",
|
)
|
||||||
)
|
console.print(
|
||||||
console.print(
|
"Get your free API key at: https://finnhub.io/register\n",
|
||||||
"Get your free API key at: https://finnhub.io/register\n",
|
)
|
||||||
)
|
else:
|
||||||
else:
|
console.print("[red]Error: env.template not found[/red]")
|
||||||
console.print("[red]Error: env.template not found[/red]")
|
raise typer.Exit(1)
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
# Handle historical data cleanup
|
# Handle historical data cleanup
|
||||||
handle_history_cleanup(config_name, auto_clean=clean)
|
handle_history_cleanup(config_name, auto_clean=clean)
|
||||||
@@ -1168,12 +1160,9 @@ def live(
|
|||||||
|
|
||||||
# Display configuration
|
# Display configuration
|
||||||
console.print("\n[bold]Configuration:[/bold]")
|
console.print("\n[bold]Configuration:[/bold]")
|
||||||
if mock:
|
console.print(
|
||||||
console.print(" Mode: [yellow]MOCK[/yellow] (Simulated prices)")
|
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
||||||
else:
|
)
|
||||||
console.print(
|
|
||||||
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
|
||||||
)
|
|
||||||
console.print(f" Config: {config_name}")
|
console.print(f" Config: {config_name}")
|
||||||
console.print(f" Server: {host}:{port}")
|
console.print(f" Server: {host}:{port}")
|
||||||
console.print(f" Poll Interval: {poll_interval}s")
|
console.print(f" Poll Interval: {poll_interval}s")
|
||||||
@@ -1188,22 +1177,17 @@ 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,
|
end_date=nyse_now.date().isoformat(),
|
||||||
end_date=nyse_now.date().isoformat(),
|
)
|
||||||
)
|
auto_enrich_market_store(
|
||||||
auto_enrich_market_store(
|
config_name,
|
||||||
config_name,
|
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,13 +47,9 @@ 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()
|
logger.info(msg)
|
||||||
if dashboard.live:
|
|
||||||
dashboard.log(msg)
|
|
||||||
else:
|
|
||||||
logger.info(msg)
|
|
||||||
|
|
||||||
|
|
||||||
class TradingPipeline:
|
class TradingPipeline:
|
||||||
@@ -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,13 +240,7 @@ 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)
|
||||||
self._price_manager.subscribe(
|
|
||||||
added,
|
|
||||||
base_prices={ticker: 100.0 for ticker in added},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._price_manager.subscribe(added)
|
|
||||||
|
|
||||||
if self.backtest_mode and self._current_date:
|
if self.backtest_mode and self._current_date:
|
||||||
self._price_manager.set_date(self._current_date)
|
self._price_manager.set_date(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>
|
||||||
|
|||||||
@@ -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,9 @@
|
|||||||
"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",
|
||||||
"@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",
|
||||||
|
|||||||
3393
frontend/src/App.jsx
3393
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}
|
||||||
|
|||||||
519
frontend/src/components/AppShell.jsx
Normal file
519
frontend/src/components/AppShell.jsx
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
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'));
|
||||||
|
const OpenClawView = lazy(() => import('./OpenClawView.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();
|
||||||
|
|
||||||
|
// 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' :
|
||||||
|
currentView === 'statistics' ? 'show-statistics' : 'show-openclaw'}`;
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'openclaw' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('openclaw')}
|
||||||
|
>
|
||||||
|
OpenClaw
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* OpenClaw View Panel */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载 OpenClaw 视图..." />}>
|
||||||
|
<OpenClawView />
|
||||||
|
</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
5
frontend/src/components/OpenClawView.jsx
Normal file
5
frontend/src/components/OpenClawView.jsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { OpenClawStatus } from './OpenClawStatus';
|
||||||
|
|
||||||
|
export default function OpenClawView() {
|
||||||
|
return <OpenClawStatus />;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user