Compare commits
11 Commits
456748b01e
...
codex/chec
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,NVDA,TSLA,META,AMZN
|
||||
|
||||
# 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
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -54,10 +54,13 @@ outputs/
|
||||
/smoke_live_mock/
|
||||
|
||||
# Local tooling state
|
||||
/.omc/
|
||||
.omc/
|
||||
/.pydeps/
|
||||
/referance/
|
||||
|
||||
# Run outputs
|
||||
/runs/
|
||||
|
||||
# Data files
|
||||
backend/data/ret_data/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastScanned": 1773938154948,
|
||||
"lastScanned": 1774313111650,
|
||||
"projectRoot": "/Users/cillin/workspeace/evotraders",
|
||||
"techStack": {
|
||||
"languages": [
|
||||
@@ -11,14 +11,6 @@
|
||||
"markers": [
|
||||
"pyproject.toml"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "C/C++",
|
||||
"version": null,
|
||||
"confidence": "high",
|
||||
"markers": [
|
||||
"Makefile"
|
||||
]
|
||||
}
|
||||
],
|
||||
"frameworks": [
|
||||
@@ -32,8 +24,8 @@
|
||||
"runtime": null
|
||||
},
|
||||
"build": {
|
||||
"buildCommand": "make build",
|
||||
"testCommand": "make test",
|
||||
"buildCommand": null,
|
||||
"testCommand": "pytest",
|
||||
"lintCommand": "ruff check",
|
||||
"devCommand": null,
|
||||
"scripts": {}
|
||||
@@ -58,24 +50,13 @@
|
||||
},
|
||||
"customNotes": [],
|
||||
"directoryMap": {
|
||||
"agent-service": {
|
||||
"path": "agent-service",
|
||||
"purpose": null,
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1773938154941,
|
||||
"keyFiles": [
|
||||
"Dockerfile",
|
||||
"requirements.txt"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"path": "backend",
|
||||
"purpose": null,
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1773938154941,
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1774313111639,
|
||||
"keyFiles": [
|
||||
"__init__.py",
|
||||
"app.py",
|
||||
"cli.py",
|
||||
"gateway_server.py",
|
||||
"main.py"
|
||||
@@ -85,37 +66,41 @@
|
||||
"path": "backtest",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1773938154941,
|
||||
"lastAccessed": 1774313111640,
|
||||
"keyFiles": []
|
||||
},
|
||||
"data": {
|
||||
"path": "data",
|
||||
"purpose": "Data files",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1773938154941,
|
||||
"fileCount": 3,
|
||||
"lastAccessed": 1774313111640,
|
||||
"keyFiles": [
|
||||
"market_research.db"
|
||||
"market_research.db",
|
||||
"market_research.db-shm",
|
||||
"market_research.db-wal"
|
||||
]
|
||||
},
|
||||
"deploy": {
|
||||
"path": "deploy",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1773938154942,
|
||||
"lastAccessed": 1774313111640,
|
||||
"keyFiles": []
|
||||
},
|
||||
"docs": {
|
||||
"path": "docs",
|
||||
"purpose": "Documentation",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1773938154942,
|
||||
"keyFiles": []
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1774313111641,
|
||||
"keyFiles": [
|
||||
"compat-removal-plan.md"
|
||||
]
|
||||
},
|
||||
"evotraders.egg-info": {
|
||||
"path": "evotraders.egg-info",
|
||||
"purpose": null,
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1773938154942,
|
||||
"lastAccessed": 1774313111641,
|
||||
"keyFiles": [
|
||||
"PKG-INFO",
|
||||
"SOURCES.txt",
|
||||
@@ -128,7 +113,7 @@
|
||||
"path": "frontend",
|
||||
"purpose": null,
|
||||
"fileCount": 13,
|
||||
"lastAccessed": 1773938154942,
|
||||
"lastAccessed": 1774313111641,
|
||||
"keyFiles": [
|
||||
"README.md",
|
||||
"components.json",
|
||||
@@ -141,51 +126,41 @@
|
||||
"path": "live",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1773938154943,
|
||||
"lastAccessed": 1774313111642,
|
||||
"keyFiles": []
|
||||
},
|
||||
"logs": {
|
||||
"path": "logs",
|
||||
"purpose": null,
|
||||
"fileCount": 7,
|
||||
"lastAccessed": 1773938154943,
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1774313111642,
|
||||
"keyFiles": [
|
||||
"2026-03-16_00-48-03.log",
|
||||
"2026-03-18_23-17-29.log",
|
||||
"2026-03-18_23-17-30.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"
|
||||
"2026-03-19_00-18-04.log",
|
||||
"2026-03-19_00-34-21.log"
|
||||
]
|
||||
},
|
||||
"reference": {
|
||||
"path": "reference",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1773938154943,
|
||||
"lastAccessed": 1774313111643,
|
||||
"keyFiles": []
|
||||
},
|
||||
"runs": {
|
||||
"path": "runs",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1773938154944,
|
||||
"lastAccessed": 1774313111643,
|
||||
"keyFiles": []
|
||||
},
|
||||
"scripts": {
|
||||
"path": "scripts",
|
||||
"purpose": "Build/utility scripts",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1773938154944,
|
||||
"lastAccessed": 1774313111644,
|
||||
"keyFiles": [
|
||||
"run_prod.sh"
|
||||
]
|
||||
@@ -194,7 +169,7 @@
|
||||
"path": "services",
|
||||
"purpose": "Business logic services",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1773938154944,
|
||||
"lastAccessed": 1774313111644,
|
||||
"keyFiles": [
|
||||
"README.md"
|
||||
]
|
||||
@@ -203,43 +178,21 @@
|
||||
"path": "shared",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1773938154944,
|
||||
"lastAccessed": 1774313111644,
|
||||
"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,
|
||||
"lastAccessed": 1774313111645,
|
||||
"keyFiles": []
|
||||
},
|
||||
"agent-service/src": {
|
||||
"path": "agent-service/src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1773938154944,
|
||||
"keyFiles": [
|
||||
"__init__.py",
|
||||
"config.py",
|
||||
"main.py"
|
||||
]
|
||||
},
|
||||
"backend/api": {
|
||||
"path": "backend/api",
|
||||
"purpose": "API routes",
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1773938154944,
|
||||
"lastAccessed": 1774313111645,
|
||||
"keyFiles": [
|
||||
"__init__.py",
|
||||
"agents.py",
|
||||
@@ -250,7 +203,7 @@
|
||||
"path": "backend/config",
|
||||
"purpose": "Configuration files",
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1773938154944,
|
||||
"lastAccessed": 1774313111646,
|
||||
"keyFiles": [
|
||||
"__init__.py",
|
||||
"agent_profiles.yaml",
|
||||
@@ -261,7 +214,7 @@
|
||||
"path": "backend/data",
|
||||
"purpose": "Data files",
|
||||
"fileCount": 13,
|
||||
"lastAccessed": 1773938154944,
|
||||
"lastAccessed": 1774313111647,
|
||||
"keyFiles": [
|
||||
"__init__.py",
|
||||
"cache.py",
|
||||
@@ -272,7 +225,7 @@
|
||||
"path": "docs/assets",
|
||||
"purpose": "Static assets",
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1773938154944,
|
||||
"lastAccessed": 1774313111647,
|
||||
"keyFiles": [
|
||||
"dashboard.jpg",
|
||||
"evotraders_demo.gif",
|
||||
@@ -283,7 +236,7 @@
|
||||
"path": "frontend/dist",
|
||||
"purpose": "Distribution/build output",
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1773938154945,
|
||||
"lastAccessed": 1774313111647,
|
||||
"keyFiles": [
|
||||
"index.html",
|
||||
"trading_logo.png"
|
||||
@@ -293,331 +246,261 @@
|
||||
"path": "frontend/node_modules",
|
||||
"purpose": "Dependencies",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1773938154947,
|
||||
"lastAccessed": 1774313111650,
|
||||
"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": [
|
||||
{
|
||||
"path": "backend/agents/factory.py",
|
||||
"accessCount": 17,
|
||||
"lastAccessed": 1773939950376,
|
||||
"path": "CLAUDE.md",
|
||||
"accessCount": 15,
|
||||
"lastAccessed": 1774342728155,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/App.jsx",
|
||||
"accessCount": 10,
|
||||
"lastAccessed": 1774339397617,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend",
|
||||
"accessCount": 16,
|
||||
"lastAccessed": 1773940042371,
|
||||
"type": "directory"
|
||||
"path": "frontend/src/hooks/useWebsocketSessionSync.js",
|
||||
"accessCount": 4,
|
||||
"lastAccessed": 1774313470024,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "",
|
||||
"accessCount": 13,
|
||||
"lastAccessed": 1773939899611,
|
||||
"accessCount": 4,
|
||||
"lastAccessed": 1774339108220,
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/gateway.py",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1773939672930,
|
||||
"lastAccessed": 1774339389171,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/core/__init__.py",
|
||||
"path": "backend/main.py",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1773939963627,
|
||||
"lastAccessed": 1774342613364,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/trading/main.py",
|
||||
"path": "frontend/src/store/runtimeStore.js",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1773938360736,
|
||||
"lastAccessed": 1774317990919,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/agents/main.py",
|
||||
"path": "frontend/src/services/websocket.js",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1773938361040,
|
||||
"lastAccessed": 1774318009819,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/trading/data/__init__.py",
|
||||
"path": "backend/core/pipeline_runner.py",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1773938402496,
|
||||
"lastAccessed": 1774339367538,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/news/explain/__init__.py",
|
||||
"path": "backend/runtime/manager.py",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1773938460019,
|
||||
"lastAccessed": 1774339367572,
|
||||
"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",
|
||||
"path": "frontend/src/store/marketStore.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938226307,
|
||||
"lastAccessed": 1774313140483,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "docker-compose.yml",
|
||||
"path": "frontend/src/hooks/useFeedProcessor.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938226360,
|
||||
"lastAccessed": 1774313148279,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/news/shared/trading_client.py",
|
||||
"path": "frontend/src/components/Header.jsx",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938370618,
|
||||
"lastAccessed": 1774313156696,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/agents",
|
||||
"path": "frontend/src/components/TraderView.jsx",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938397772,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/trading",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938397823,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "backend/services",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938405541,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/news/config.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938638664,
|
||||
"lastAccessed": 1774313156753,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "shared/client/news_client.py",
|
||||
"path": "frontend/src/store/uiStore.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938638715,
|
||||
"lastAccessed": 1774313187460,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/portfolioStore.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313187511,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/agentStore.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313187573,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useWebSocketConnection.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313279414,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useStockDataRequests.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313319716,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useAgentDataRequests.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313347455,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/AppShell.jsx",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313396331,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "start-dev.sh",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774317979859,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/apps/agent_service.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774317984348,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "shared/client/trading_client.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938638770,
|
||||
"lastAccessed": 1774317984365,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/api",
|
||||
"path": "backend/apps/trading_service.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938669143,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "frontend",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938669195,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": ".env.example",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938849397,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/services/websocket.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938849448,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/services/runtimeApi.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773938849500,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/agents/routes/websocket.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773939001692,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/agents/routes/agents.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773939016291,
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"path": "backend/api/__init__.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773939658650,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/__init__.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773939658687,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/agents/base/evo_agent.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773939664916,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/agents/analyst.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773939664967,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/agents/base/hooks.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773939672727,
|
||||
"lastAccessed": 1774317984408,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "pyproject.toml",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1773939672778,
|
||||
"lastAccessed": 1774317990970,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/agents/factory.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774318009867,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/config/constants.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774318009922,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/api/__init__.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774318009973,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339107381,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/registry.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339380024,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/session.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339380084,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/context.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339380120,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/agent_runtime.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339380185,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/process/supervisor.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339389110,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/core/pipeline.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339389187,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/process/models.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339397557,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/process/registry.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339397577,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/config/env_config.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774342678236,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/config/data_config.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774342678253,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/env.template",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774342678290,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "env.template",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774342678310,
|
||||
"type": "file"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-03-19T16:36:52.471Z",
|
||||
"timestamp": "2026-03-24T07:58:12.123Z",
|
||||
"backgroundTasks": [],
|
||||
"sessionStartTimestamp": "2026-03-19T16:36:42.224Z",
|
||||
"sessionId": "ef02339a-1eec-4c7a-95ac-c8cfa0b5067d"
|
||||
"sessionStartTimestamp": "2026-03-24T07:58:09.417Z",
|
||||
"sessionId": "fda34772-7bd2-402e-86b2-d656296416f3"
|
||||
}
|
||||
@@ -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":"fda34772-7bd2-402e-86b2-d656296416f3","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/fda34772-7bd2-402e-86b2-d656296416f3.jsonl","cwd":"/Users/cillin/workspeace/evotraders","model":{"id":"MiniMax-M2.7-highspeed","display_name":"MiniMax-M2.7-highspeed"},"workspace":{"current_dir":"/Users/cillin/workspeace/evotraders","project_dir":"/Users/cillin/workspeace/evotraders","added_dirs":[]},"version":"2.1.78","output_style":{"name":"default"},"cost":{"total_cost_usd":36.63980749999998,"total_duration_ms":69778027,"total_api_duration_ms":2925118,"total_lines_added":3056,"total_lines_removed":4537},"context_window":{"total_input_tokens":910503,"total_output_tokens":145207,"context_window_size":200000,"current_usage":{"input_tokens":507,"output_tokens":247,"cache_creation_input_tokens":4132,"cache_read_input_tokens":96553},"used_percentage":51,"remaining_percentage":49},"exceeds_200k_tokens":false}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"lastSentAt": "2026-03-19T17:02:32.170Z"
|
||||
"lastSentAt": "2026-03-24T08:58:57.965Z"
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "a8305a91e192b2196",
|
||||
"agent_id": "abeaf609b74a2b7ee",
|
||||
"agent_type": "Explore",
|
||||
"started_at": "2026-03-19T17:00:33.284Z",
|
||||
"started_at": "2026-03-24T08:01:40.015Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-19T17:02:19.439Z",
|
||||
"duration_ms": 106155
|
||||
"completed_at": "2026-03-24T08:02:31.822Z",
|
||||
"duration_ms": 51807
|
||||
},
|
||||
{
|
||||
"agent_id": "afb6750eaae72bc72",
|
||||
"agent_type": "Explore",
|
||||
"started_at": "2026-03-24T08:56:21.471Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-24T08:57:27.856Z",
|
||||
"duration_ms": 66385
|
||||
}
|
||||
],
|
||||
"total_spawned": 1,
|
||||
"total_completed": 1,
|
||||
"total_spawned": 2,
|
||||
"total_completed": 2,
|
||||
"total_failed": 0,
|
||||
"last_updated": "2026-03-19T17:02:39.175Z"
|
||||
"last_updated": "2026-03-24T08:59:06.380Z"
|
||||
}
|
||||
426
CLAUDE.md
426
CLAUDE.md
@@ -1,5 +1,7 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
|
||||
|
||||
## 项目概述
|
||||
@@ -23,18 +25,20 @@ evotraders live -t 22:30 # 定时每日交易
|
||||
evotraders frontend # 启动可视化界面
|
||||
|
||||
# 开发服务器
|
||||
./start-dev.sh # 启动全部 4 个微服务
|
||||
./start-dev.sh # 启动全部 4 个微服务 (agent, runtime, trading, news)
|
||||
|
||||
# 单独启动某个服务
|
||||
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
|
||||
# Gateway WebSocket 服务器
|
||||
python backend/main.py --mode live --config-name mock --mock
|
||||
|
||||
# 单独启动微服务
|
||||
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.news_service:app --host 0.0.0.0 --port 8002 --reload
|
||||
|
||||
# 测试
|
||||
pytest backend/tests # 运行全部测试
|
||||
pytest backend/tests/test_news_service_app.py -v # 运行单个测试文件
|
||||
pytest backend/tests/test_news_service_app.py::test_news_service_routes_are_exposed -v # 运行单个测试
|
||||
pytest backend/tests/test_news_service_app.py -v # 运行单个测试
|
||||
```
|
||||
|
||||
### Frontend (React)
|
||||
@@ -46,142 +50,237 @@ npm run build # 生产构建
|
||||
npm run lint # ESLint 检查
|
||||
npm run lint:fix # ESLint 自动修复
|
||||
npm run test # Vitest 单元测试
|
||||
npm run test:watch # 监听模式
|
||||
```
|
||||
|
||||
## 架构概览
|
||||
|
||||
### 微服务架构 (`backend/apps/`)
|
||||
### 系统分层
|
||||
|
||||
项目采用 split-first 微服务架构,4 个独立的 FastAPI 服务:
|
||||
|
||||
| 服务 | 入口 | 端口 | 职责 |
|
||||
|------|------|------|------|
|
||||
| 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 | 新闻、新闻富化、解释功能 |
|
||||
|
||||
服务间通过环境变量通信(详见 `start-dev.sh`):
|
||||
```bash
|
||||
export TRADING_SERVICE_URL=http://localhost:8001
|
||||
export NEWS_SERVICE_URL=http://localhost:8002
|
||||
export RUNTIME_SERVICE_URL=http://localhost:8003
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ WebSocket ws://localhost:8765 连接 Gateway │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Gateway (backend/services/gateway.py) │
|
||||
│ WebSocket 服务器,编排 Pipeline,4 阶段启动 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ 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
|
||||
- `/trading/*` → trading_service
|
||||
- `/news/*` → news_service
|
||||
| 服务 | 端口 | 职责 |
|
||||
|------|------|------|
|
||||
| runtime_service | 8003 | 运行时配置、任务启动、Pipeline Runner |
|
||||
| agent_service | 8000 | Agent 生命周期、工作区管理 |
|
||||
| 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 服务通信 |
|
||||
| `RuntimeServiceClient` | 运行时服务通信 |
|
||||
| `TradingServiceClient` | 交易服务通信 |
|
||||
| `NewsServiceClient` | 新闻服务通信 |
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `manager.py` | TradingRuntimeManager - 全局运行时管理器,agent 注册、会话、事件快照 |
|
||||
| `agent_runtime.py` | AgentRuntimeState - 单 agent 状态(status、last_session) |
|
||||
| `context.py` | TradingRunContext - 运行上下文 |
|
||||
| `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/
|
||||
├── agents/ # 多智能体实现
|
||||
│ ├── base/ # 核心类、Hooks、评估
|
||||
│ │ ├── evo_agent.py # 基于 AgentScope 的核心实现
|
||||
│ │ ├── hooks.py # 生命周期 Hooks
|
||||
│ │ │ ├── BootstrapHook # 启动初始化
|
||||
│ │ │ ├── MemoryCompactionHook # 内存压缩(基于 CoPaw)
|
||||
│ │ │ ├── HeartbeatHook # 心跳检测
|
||||
│ │ │ └── WorkspaceWatchHook # 工作区监控
|
||||
│ │ ├── evaluation_hook.py # 执行后评估
|
||||
│ │ ├── skill_adaptation_hook.py # 动态技能适配
|
||||
│ │ └── tool_guard.py # 工具调用守卫
|
||||
│ ├── prompts/ # Agent 提示词和角色定义
|
||||
│ │ ├── analyst/personas.yaml # 分析师角色配置
|
||||
│ │ └── portfolio_manager/
|
||||
│ ├── team/ # 团队协作逻辑
|
||||
│ │ ├── registry.py # Agent 注册表
|
||||
│ │ ├── coordinator.py # 协作协调器
|
||||
│ │ ├── messenger.py # 消息传递
|
||||
│ │ └── task_delegator.py # 任务分发
|
||||
│ ├── factory.py # Agent 实例工厂
|
||||
│ ├── skills_manager.py # 技能加载管理(6 种作用域)
|
||||
│ └── toolkit_factory.py # 工具集工厂
|
||||
├── apps/ # 微服务入口(split-first)
|
||||
│ ├── agent_service.py
|
||||
│ ├── runtime_service.py
|
||||
│ ├── trading_service.py
|
||||
│ └── news_service.py
|
||||
├── domains/ # 领域业务逻辑
|
||||
├── agents/ # 多智能体实现
|
||||
│ ├── analyst.py # AnalystAgent 基类
|
||||
│ ├── portfolio_manager.py # PMAgent 投资经理
|
||||
│ ├── risk_manager.py # RiskAgent 风控经理
|
||||
│ ├── factory.py # Agent 实例工厂
|
||||
│ ├── toolkit_factory.py # 工具集工厂
|
||||
│ ├── skills_manager.py # 技能加载管理
|
||||
│ ├── workspace_manager.py # 工作区管理
|
||||
│ ├── skill_loader.py # 技能加载器
|
||||
│ ├── agent_workspace.py # Agent 工作区
|
||||
│ ├── prompt_loader.py # Prompt 加载器
|
||||
│ ├── prompt_factory.py # Prompt 工厂
|
||||
│ ├── skill_metadata.py # 技能元数据
|
||||
│ ├── registry.py # Agent 注册表
|
||||
│ ├── team_pipeline_config.py # 团队 Pipeline 配置
|
||||
│ ├── compat.py # 兼容性层
|
||||
│ ├── templates.py # 模板
|
||||
│ ├── workspace.py # 工作区
|
||||
│ ├── base/ # 核心类、Hooks
|
||||
│ │ ├── evo_agent.py # 基于 AgentScope 的核心实现
|
||||
│ │ └── hooks.py # 生命周期 Hooks
|
||||
│ └── prompts/ # Agent 提示词
|
||||
│ └── analyst/personas.yaml
|
||||
│
|
||||
├── apps/ # 微服务入口
|
||||
│ ├── runtime_service.py # 运行时服务(端口 8003)
|
||||
│ ├── agent_service.py # Agent 服务(端口 8000)
|
||||
│ ├── trading_service.py # 交易服务(端口 8001)
|
||||
│ ├── news_service.py # 新闻服务(端口 8002)
|
||||
│ └── cors.py
|
||||
│
|
||||
├── runtime/ # 运行时管理层
|
||||
│ ├── manager.py # TradingRuntimeManager
|
||||
│ ├── agent_runtime.py # AgentRuntimeState
|
||||
│ ├── context.py # TradingRunContext
|
||||
│ ├── session.py # TradingSessionKey
|
||||
│ └── registry.py # RuntimeRegistry
|
||||
│
|
||||
├── process/ # 进程监管层
|
||||
│ ├── supervisor.py # ProcessSupervisor
|
||||
│ ├── registry.py # RunRegistry
|
||||
│ └── models.py # ProcessRun、ProcessRunState
|
||||
│
|
||||
├── core/ # Pipeline 执行
|
||||
│ ├── pipeline.py # TradingPipeline(核心编排器)
|
||||
│ ├── pipeline_runner.py # 独立 Pipeline 执行
|
||||
│ ├── scheduler.py # 调度器
|
||||
│ └── state_sync.py # 状态同步
|
||||
│
|
||||
├── services/ # Gateway 和服务
|
||||
│ ├── gateway.py # WebSocket 网关
|
||||
│ ├── gateway_*.py # Gateway 子模块
|
||||
│ ├── market.py # 市场数据服务
|
||||
│ ├── storage.py # 存储服务
|
||||
│ ├── runtime_db.py # 运行时数据库
|
||||
│ └── research_db.py # 研究数据库
|
||||
│
|
||||
├── data/ # 市场数据处理
|
||||
│ ├── provider_router.py # 数据源路由
|
||||
│ ├── provider_utils.py # 数据源工具
|
||||
│ ├── market_store.py # 市场数据存储
|
||||
│ ├── market_ingest.py # 数据采集
|
||||
│ ├── cache.py # 缓存
|
||||
│ ├── schema.py # 数据 schema
|
||||
│ ├── historical_price_manager.py # 历史价格管理
|
||||
│ ├── polling_price_manager.py # 轮询价格管理
|
||||
│ ├── mock_price_manager.py # Mock 价格管理
|
||||
│ ├── 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
|
||||
│ └── trading.py
|
||||
├── services/ # Gateway 和辅助服务
|
||||
│ ├── gateway.py # 统一路由网关
|
||||
│ ├── gateway_*.py # Gateway 子模块
|
||||
│ └── market.py # 市场数据服务
|
||||
├── api/ # FastAPI 端点
|
||||
├── config/ # 常量和配置
|
||||
│ └── constants.py # Agent 配置、显示名称等
|
||||
├── core/ # Pipeline 执行逻辑
|
||||
├── data/ # 市场数据处理
|
||||
│ ├── provider_router.py # 数据源路由
|
||||
│ └── schema.py # 数据 schema
|
||||
├── enrich/ # LLM 响应富化
|
||||
├── explain/ # 交易决策解释
|
||||
├── llm/ # LLM 集成
|
||||
│ └── models.py # RetryChatModel、TokenRecordingModelWrapper
|
||||
├── skills/ # 技能定义(内置 + 自定义)
|
||||
├── tools/ # 交易和分析工具
|
||||
└── utils/ # 工具函数
|
||||
│
|
||||
├── llm/ # LLM 集成
|
||||
│ └── models.py # RetryChatModel、TokenRecordingModelWrapper
|
||||
│
|
||||
├── skills/ # 技能定义
|
||||
├── tools/ # 交易和分析工具
|
||||
├── enrich/ # LLM 响应富化
|
||||
├── explain/ # 交易决策解释
|
||||
├── utils/ # 工具函数
|
||||
│ ├── settlement.py # 结算协调器
|
||||
│ ├── trade_executor.py # 交易执行器
|
||||
│ ├── terminal_dashboard.py # 终端仪表板
|
||||
│ ├── analyst_tracker.py # 分析师追踪
|
||||
│ ├── baselines.py # 基准线
|
||||
│ ├── msg_adapter.py # 消息适配器
|
||||
│ └── progress.py # 进度追踪
|
||||
│
|
||||
├── api/ # FastAPI 端点
|
||||
│ └── runtime.py
|
||||
│
|
||||
└── main.py # 主入口点
|
||||
```
|
||||
|
||||
## 前端结构
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── App.jsx # React 主应用
|
||||
├── components/ # React 组件
|
||||
│ ├── RuntimeView.jsx # 交易运行时 UI
|
||||
│ ├── TraderView.jsx # 交易员界面
|
||||
│ ├── RoomView.jsx # 聊天室视图
|
||||
│ ├── StockExplainView.jsx # 股票解释视图
|
||||
├── App.jsx # 主应用(LiveTradingApp)
|
||||
├── AppShell.jsx # App 外壳(布局、侧边栏)
|
||||
├── components/
|
||||
│ ├── RuntimeView.jsx # 交易运行时 UI
|
||||
│ ├── TraderView.jsx # 交易员界面
|
||||
│ ├── RoomView.jsx # 聊天室视图
|
||||
│ ├── StockExplainView.jsx # 股票解释视图
|
||||
│ ├── RuntimeSettingsPanel.jsx # 运行时设置面板
|
||||
│ ├── WatchlistPanel.jsx # 关注列表
|
||||
│ ├── PerformanceView.jsx # 绩效视图
|
||||
│ ├── StatisticsView.jsx # 统计视图
|
||||
│ ├── NetValueChart.jsx # 净值曲线图
|
||||
│ ├── AgentCard.jsx # Agent 卡片
|
||||
│ ├── AgentFeed.jsx # Agent 动态
|
||||
│ └── explain/ # 解释相关组件
|
||||
│ ├── RuntimeLogsModal.jsx # 运行时日志弹窗
|
||||
│ ├── WatchlistPanel.jsx # 关注列表
|
||||
│ ├── PerformanceView.jsx # 绩效视图
|
||||
│ ├── StatisticsView.jsx # 统计视图
|
||||
│ ├── NetValueChart.jsx # 净值曲线图
|
||||
│ ├── AgentCard.jsx # Agent 卡片
|
||||
│ ├── AgentFeed.jsx # Agent 动态
|
||||
│ ├── Header.jsx # 头部
|
||||
│ ├── MarkdownModal.jsx # Markdown 弹窗
|
||||
│ ├── StockLogo.jsx # 股票 Logo
|
||||
│ └── explain/ # 解释组件
|
||||
│ ├── ExplainNewsSection.jsx
|
||||
│ ├── ExplainRangeSection.jsx
|
||||
│ ├── ExplainSimilarDaysSection.jsx
|
||||
│ ├── ExplainStorySection.jsx
|
||||
│ └── useExplainModel.js
|
||||
├── services/ # API 服务
|
||||
│ ├── runtimeApi.js # 运行时 API 调用
|
||||
│ ├── websocket.js # WebSocket 实时通信
|
||||
│ ├── newsApi.js # 新闻服务客户端
|
||||
│ └── tradingApi.js # 交易服务客户端
|
||||
├── config/
|
||||
│ └── constants.js # Agent 定义、配置
|
||||
└── hooks/ # React Hooks
|
||||
├── hooks/ # React Hooks
|
||||
│ ├── useWebSocketConnection.js # WebSocket 连接管理
|
||||
│ ├── useRuntimeControls.js # 运行时配置管理
|
||||
│ ├── useAgentDataRequests.js # Agent 数据请求
|
||||
│ ├── useStockDataRequests.js # 股票数据请求
|
||||
│ ├── useStockExplainData.js # 股票解释数据
|
||||
│ ├── useAgentWorkspacePanel.js # Agent 工作区面板
|
||||
│ ├── 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 系统
|
||||
@@ -193,110 +292,87 @@ frontend/src/
|
||||
| `fundamentals_analyst` | 基本面分析师 | 财务健康、盈利能力、成长质量 |
|
||||
| `technical_analyst` | 技术分析师 | 价格趋势、技术指标、动量分析 |
|
||||
| `sentiment_analyst` | 情绪分析师 | 市场情绪、新闻情绪、内幕交易 |
|
||||
| `valuation_analyst` | 估值分析师 | DCF、EV/EBITDA、 intrinsic value |
|
||||
| `valuation_analyst` | 估值分析师 | DCF、EV/EBITDA、intrinsic value |
|
||||
| `portfolio_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 条工具结果
|
||||
| `risk_manager` | 风控经理 | 实时价格/波动率监控、仓位限制 |
|
||||
|
||||
### 添加自定义分析师
|
||||
|
||||
1. 在 `backend/agents/prompts/analyst/personas.yaml` 注册
|
||||
2. 在 `backend/config/constants.py` 的 `ANALYST_TYPES` 字典中添加
|
||||
3. 可选:在 `frontend/src/config/constants.js` 中更新前端配置
|
||||
1. `backend/agents/prompts/analyst/personas.yaml` 注册
|
||||
2. `backend/config/constants.py` 的 `ANALYST_TYPES` 字典添加
|
||||
3. `frontend/src/config/constants.js` 可选更新
|
||||
|
||||
### LLM 模型封装 (`backend/llm/models.py`)
|
||||
|
||||
基于 CoPaw 的模型封装设计:
|
||||
|
||||
- **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)
|
||||
```
|
||||
- **RetryChatModel**: 自动重试瞬态 LLM 错误,指数退避
|
||||
- **TokenRecordingModelWrapper**: 追踪 token 消耗和成本
|
||||
|
||||
## 技能系统 (`backend/skills/`)
|
||||
|
||||
技能定义在 `SKILL.md` 文件中,包含:
|
||||
- `instructions` - 技能说明
|
||||
- `triggers` - 触发条件
|
||||
- `parameters` - 输入/输出 schema
|
||||
- `available_tools` - 技能可使用的工具
|
||||
|
||||
技能由 `skills_manager.py` 加载,通过 `skill_adaptation_hook.py` 绑定到 Agent。
|
||||
技能定义在 `SKILL.md`,包含 `instructions`、`triggers`、`parameters`、`available_tools`。
|
||||
|
||||
技能管理器支持 6 种作用域:builtin、customized、installed、active、disabled、local。
|
||||
|
||||
## Pipeline 执行 (`backend/core/`)
|
||||
## 运行时数据布局
|
||||
|
||||
每日交易流程:
|
||||
|
||||
1. **分析阶段** - 各 Agent 基于工具和历史经验独立分析
|
||||
2. **沟通阶段** - 通过私聊、通知、会议等方式交换观点(1v1/1vN/NvN)
|
||||
3. **决策阶段** - 投资经理综合判断,给出最终交易
|
||||
4. **评估阶段** - 绩效跟踪
|
||||
5. **复盘阶段** - Agent 根据当日实际收益反思总结,通过 ReMe 记忆框架更新经验
|
||||
|
||||
## 前端状态管理
|
||||
|
||||
项目正在向 Zustand 状态管理过渡,已创建的 store:
|
||||
- `data/market_research.db` - 持久研究数据
|
||||
- `runs/<run_id>/` - 每次任务运行的状态
|
||||
- `runs/<run_id>/team_dashboard/*.json` - 仪表板导出层(非权威源)
|
||||
- `runs/<run_id>/state/runtime_state.json` - 运行时快照
|
||||
- 运行时 API 优先使用 `server_state.json` 和 `runtime.db`
|
||||
|
||||
```bash
|
||||
frontend/src/store/
|
||||
├── index.js # 导出所有 store
|
||||
├── runtimeStore.js # 连接状态、运行时配置
|
||||
├── marketStore.js # 市场数据、股票价格
|
||||
├── portfolioStore.js # 组合、持仓、交易
|
||||
├── agentStore.js # Agent 技能、工作区
|
||||
└── uiStore.js # UI 状态、视图切换
|
||||
RUNS_RETENTION_COUNT=20 # 时间戳格式文件夹自动清理
|
||||
```
|
||||
|
||||
**迁移状态**:
|
||||
- Stores 已创建但尚未在 App.jsx 中使用
|
||||
- 计划:逐步迁移 60+ 个 useState 到对应 store
|
||||
|
||||
## 环境配置
|
||||
|
||||
`.env` 必需配置:
|
||||
### Backend (`env.template`)
|
||||
|
||||
```bash
|
||||
# 金融数据源
|
||||
FIN_DATA_SOURCE=finnhub|financial_datasets
|
||||
# 金融数据源(支持多源fallback)
|
||||
FIN_DATA_SOURCE=finnhub|financial_datasets|yfinance|local_csv
|
||||
ENABLED_DATA_SOURCES=financial_datasets,finnhub,yfinance,local_csv
|
||||
FINANCIAL_DATASETS_API_KEY= # 回测必需
|
||||
FINNHUB_API_KEY= # 实盘必需
|
||||
FINNHUB_API_KEY= # 实盘必需
|
||||
POLYGON_API_KEY= # Polygon市场库采集可选
|
||||
|
||||
# Agent LLM
|
||||
# LLM 配置
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
MODEL_NAME=qwen3-max-preview
|
||||
|
||||
# 可为不同 Agent 指定不同模型
|
||||
AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
|
||||
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=deepseek-chat
|
||||
# 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
|
||||
|
||||
# ReMe 记忆系统
|
||||
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** - 多智能体框架
|
||||
- **ReMe** - 持续学习记忆系统
|
||||
- **FastAPI** + **uvicorn** - 后端 API 服务器
|
||||
- **FastAPI** + **uvicorn** - 后端 API
|
||||
- **websockets** - 实时通信
|
||||
- **React 19** + **Vite** + **TailwindCSS** - 前端
|
||||
- **React Context** - 前端状态管理(App.jsx 中使用 useState + useCallback)
|
||||
- **Three.js** / **React-Three-Fiber** - 3D 可视化
|
||||
- **Zustand** - 状态管理
|
||||
|
||||
15
README.md
15
README.md
@@ -110,6 +110,21 @@ evotraders frontend # Default connects to port 8765, you can modi
|
||||
|
||||
Visit `http://localhost:5173/` to view the trading room, select a date and click Run/Replay to observe the decision-making process.
|
||||
|
||||
### Runtime Data Layout
|
||||
|
||||
- Long-lived research data is stored in `data/market_research.db`
|
||||
- Each task run writes run-scoped state under `runs/<run_id>/`
|
||||
- `runs/<run_id>/team_dashboard/*.json` is an export/compatibility layer for dashboard views, not the authoritative runtime source of truth
|
||||
- Runtime APIs prefer active runtime state, `server_state.json`, and `runtime.db`
|
||||
|
||||
Optional retention control:
|
||||
|
||||
```bash
|
||||
RUNS_RETENTION_COUNT=20
|
||||
```
|
||||
|
||||
Only timestamped run folders like `YYYYMMDD_HHMMSS` are pruned automatically when starting a new runtime. Named runs such as `smoke_fullstack` or `test_*` are preserved.
|
||||
|
||||
---
|
||||
|
||||
## System Architecture
|
||||
|
||||
@@ -41,6 +41,8 @@ class SkillsManager:
|
||||
)
|
||||
self.runs_root = self.project_root / "runs"
|
||||
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:
|
||||
return self.runs_root / config_name / "skills" / "active"
|
||||
@@ -739,7 +741,7 @@ class SkillsManager:
|
||||
if local_root.exists():
|
||||
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()
|
||||
for path in watched_paths:
|
||||
observer.schedule(handler, str(path), recursive=True)
|
||||
@@ -773,6 +775,7 @@ class SkillsManager:
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal change-tracking state (populated by _SkillsChangeHandler)
|
||||
# -------------------------------------------------------------------------
|
||||
# Legacy class-level reference kept for migration compatibility
|
||||
_pending_skill_changes: Dict[str, Set[Path]] = {}
|
||||
|
||||
def _resolve_disabled_skill_names(
|
||||
@@ -824,11 +827,13 @@ class _SkillsChangeHandler(FileSystemEventHandler):
|
||||
def __init__(
|
||||
self,
|
||||
watched_paths: List[Path],
|
||||
pending_changes: Dict[str, Set[Path]],
|
||||
callback: Optional[Any] = None,
|
||||
lock: Optional[Lock] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._watched_paths = watched_paths
|
||||
self._pending_changes = pending_changes
|
||||
self._callback = callback
|
||||
self._lock = lock
|
||||
|
||||
@@ -841,13 +846,9 @@ class _SkillsChangeHandler(FileSystemEventHandler):
|
||||
run_id = self._run_id_from_path(src_path)
|
||||
if self._lock:
|
||||
with self._lock:
|
||||
SkillsManager._pending_skill_changes.setdefault(
|
||||
run_id, set()
|
||||
).add(src_path)
|
||||
self._pending_changes.setdefault(run_id, set()).add(src_path)
|
||||
else:
|
||||
SkillsManager._pending_skill_changes.setdefault(
|
||||
run_id, set()
|
||||
).add(src_path)
|
||||
self._pending_changes.setdefault(run_id, set()).add(src_path)
|
||||
if self._callback:
|
||||
self._callback([src_path])
|
||||
break
|
||||
|
||||
@@ -129,6 +129,33 @@ class RunWorkspaceManager:
|
||||
)
|
||||
return asset_dir
|
||||
|
||||
def load_agent_file(
|
||||
self,
|
||||
*,
|
||||
config_name: str,
|
||||
agent_id: str,
|
||||
filename: str,
|
||||
) -> str:
|
||||
"""Load one run-scoped agent workspace file."""
|
||||
path = self.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.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(
|
||||
self,
|
||||
config_name: str,
|
||||
|
||||
@@ -13,8 +13,13 @@ from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body, UploadFile, File, Form
|
||||
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.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__)
|
||||
|
||||
@@ -47,6 +52,14 @@ class InstallExternalSkillRequest(BaseModel):
|
||||
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):
|
||||
"""Agent information response."""
|
||||
agent_id: str
|
||||
@@ -63,6 +76,24 @@ class AgentFileResponse(BaseModel):
|
||||
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
|
||||
def get_agent_factory():
|
||||
"""Get AgentFactory instance."""
|
||||
@@ -70,8 +101,8 @@ def get_agent_factory():
|
||||
|
||||
|
||||
def get_workspace_manager():
|
||||
"""Get WorkspaceManager instance."""
|
||||
return WorkspaceManager()
|
||||
"""Get run-scoped workspace manager instance."""
|
||||
return RunWorkspaceManager()
|
||||
|
||||
|
||||
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}")
|
||||
async def delete_agent(
|
||||
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")
|
||||
async def upload_external_skill(
|
||||
workspace_id: str,
|
||||
@@ -441,7 +653,7 @@ async def get_agent_file(
|
||||
workspace_id: str,
|
||||
agent_id: str,
|
||||
filename: str,
|
||||
workspace_manager: WorkspaceManager = Depends(get_workspace_manager),
|
||||
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager),
|
||||
):
|
||||
"""
|
||||
Read an agent's workspace file.
|
||||
@@ -471,7 +683,7 @@ async def update_agent_file(
|
||||
agent_id: str,
|
||||
filename: str,
|
||||
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.
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
@@ -38,12 +39,13 @@ class RuntimeState:
|
||||
"""
|
||||
|
||||
_instance: Optional["RuntimeState"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
_lock: "threading.Lock" = __import__("threading").Lock()
|
||||
|
||||
def __new__(cls) -> "RuntimeState":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -165,6 +167,8 @@ class RuntimeEventsResponse(BaseModel):
|
||||
|
||||
class LaunchConfig(BaseModel):
|
||||
"""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="股票池")
|
||||
schedule_mode: str = Field(default="daily", description="调度模式: daily, interval")
|
||||
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")
|
||||
end_date: Optional[str] = Field(default=None, description="回测结束日期 YYYY-MM-DD")
|
||||
poll_interval: int = Field(default=10, ge=1, le=300, description="市场数据轮询间隔(秒)")
|
||||
enable_mock: bool = Field(default=False, description="是否启用模拟模式(使用模拟价格数据)")
|
||||
|
||||
|
||||
class LaunchResponse(BaseModel):
|
||||
@@ -188,11 +191,30 @@ class LaunchResponse(BaseModel):
|
||||
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):
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
class CleanupResponse(BaseModel):
|
||||
status: str
|
||||
kept: int
|
||||
pruned_run_ids: List[str]
|
||||
|
||||
|
||||
class GatewayStatusResponse(BaseModel):
|
||||
is_running: bool
|
||||
port: int
|
||||
@@ -207,6 +229,13 @@ class RuntimeConfigResponse(BaseModel):
|
||||
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):
|
||||
schedule_mode: Optional[str] = None
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Find an available port for Gateway."""
|
||||
import socket
|
||||
@@ -288,29 +439,29 @@ def _start_gateway_process(
|
||||
"--bootstrap", json.dumps(bootstrap)
|
||||
]
|
||||
|
||||
# Start process
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=PROJECT_ROOT
|
||||
)
|
||||
log_path = run_dir / "logs" / "gateway.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log_file = log_path.open("ab")
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=PROJECT_ROOT
|
||||
)
|
||||
finally:
|
||||
log_file.close()
|
||||
|
||||
return process
|
||||
|
||||
|
||||
@router.get("/context", response_model=RunContextResponse)
|
||||
async def get_run_context() -> RunContextResponse:
|
||||
"""Return the most recent run 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 run context available")
|
||||
|
||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
||||
context = latest.get("context")
|
||||
"""Return active runtime context, or latest persisted context when stopped."""
|
||||
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
|
||||
context = snapshot.get("context")
|
||||
if context is None:
|
||||
raise HTTPException(status_code=404, detail="Run context is not ready")
|
||||
|
||||
@@ -323,15 +474,9 @@ async def get_run_context() -> RunContextResponse:
|
||||
|
||||
@router.get("/agents", response_model=RuntimeAgentsResponse)
|
||||
async def get_runtime_agents() -> RuntimeAgentsResponse:
|
||||
"""Return agent states from the most recent run."""
|
||||
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 state available")
|
||||
|
||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
||||
agents = latest.get("agents", [])
|
||||
"""Return agent states from the active runtime, or latest persisted run."""
|
||||
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
|
||||
agents = snapshot.get("agents", [])
|
||||
|
||||
return RuntimeAgentsResponse(
|
||||
agents=[RuntimeAgentState(**a) for a in agents]
|
||||
@@ -340,21 +485,21 @@ async def get_runtime_agents() -> RuntimeAgentsResponse:
|
||||
|
||||
@router.get("/events", response_model=RuntimeEventsResponse)
|
||||
async def get_runtime_events() -> RuntimeEventsResponse:
|
||||
"""Return events from the most recent run."""
|
||||
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 state available")
|
||||
|
||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
||||
events = latest.get("events", [])
|
||||
"""Return events from the active runtime, or latest persisted run."""
|
||||
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
|
||||
events = snapshot.get("events", [])
|
||||
|
||||
return RuntimeEventsResponse(
|
||||
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)
|
||||
async def get_gateway_status() -> GatewayStatusResponse:
|
||||
"""Get Gateway process status and port."""
|
||||
@@ -362,15 +507,10 @@ async def get_gateway_status() -> GatewayStatusResponse:
|
||||
run_id = None
|
||||
|
||||
if is_running:
|
||||
# Try to find run_id from runtime state
|
||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if snapshots:
|
||||
try:
|
||||
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}")
|
||||
try:
|
||||
run_id = _get_active_runtime_context().get("config_name")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to resolve active runtime context: {e}")
|
||||
|
||||
return GatewayStatusResponse(
|
||||
is_running=is_running,
|
||||
@@ -390,6 +530,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:
|
||||
"""Build a proxy-safe Gateway WebSocket URL."""
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip()
|
||||
@@ -416,10 +576,23 @@ def _load_latest_runtime_snapshot() -> Dict[str, Any]:
|
||||
return json.loads(snapshots[0].read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _get_current_runtime_context() -> Dict[str, Any]:
|
||||
"""Return the active runtime context from the latest snapshot."""
|
||||
def _get_active_runtime_snapshot() -> Dict[str, Any]:
|
||||
"""Return the active runtime snapshot, preferring in-memory manager state."""
|
||||
if not _is_gateway_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()
|
||||
context = latest.get("context") or {}
|
||||
if not context.get("config_name"):
|
||||
@@ -427,6 +600,35 @@ def _get_current_runtime_context() -> Dict[str, Any]:
|
||||
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:
|
||||
"""Build a normalized runtime config response for the active run."""
|
||||
context = _get_current_runtime_context()
|
||||
@@ -517,26 +719,51 @@ async def start_runtime(
|
||||
_stop_gateway()
|
||||
await asyncio.sleep(1) # Wait for port release
|
||||
|
||||
# 2. Generate run ID and directory
|
||||
run_id = _generate_run_id()
|
||||
run_dir = _get_run_dir(run_id)
|
||||
launch_mode = str(config.launch_mode or "fresh").strip().lower()
|
||||
if launch_mode not in {"fresh", "restore"}:
|
||||
raise HTTPException(status_code=400, detail="launch_mode must be 'fresh' or 'restore'")
|
||||
|
||||
# 3. Prepare bootstrap config
|
||||
bootstrap = {
|
||||
"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,
|
||||
"enable_mock": config.enable_mock,
|
||||
}
|
||||
# 2. Resolve run ID, directory, and bootstrap
|
||||
if launch_mode == "restore":
|
||||
restore_run_id = str(config.restore_run_id or "").strip()
|
||||
if not restore_run_id:
|
||||
raise HTTPException(status_code=400, detail="restore_run_id is required when launch_mode=restore")
|
||||
snapshot = _load_run_snapshot(restore_run_id)
|
||||
context = snapshot.get("context") or {}
|
||||
if not context.get("config_name"):
|
||||
raise HTTPException(status_code=404, detail=f"Run context not found: {restore_run_id}")
|
||||
run_id = restore_run_id
|
||||
run_dir = _get_run_dir(run_id)
|
||||
bootstrap = dict(context.get("bootstrap_values") or {})
|
||||
bootstrap["launch_mode"] = "restore"
|
||||
bootstrap["restore_run_id"] = restore_run_id
|
||||
else:
|
||||
run_id = _generate_run_id()
|
||||
run_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
|
||||
manager = TradingRuntimeManager(
|
||||
@@ -567,11 +794,12 @@ async def start_runtime(
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if not _is_gateway_running():
|
||||
stdout, stderr = process.communicate(timeout=1)
|
||||
_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(
|
||||
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:
|
||||
@@ -637,6 +865,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")
|
||||
async def restart_runtime(
|
||||
config: LaunchConfig,
|
||||
@@ -663,15 +910,7 @@ async def get_current_runtime():
|
||||
if not _is_gateway_running():
|
||||
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||
|
||||
# Find latest runtime state
|
||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
if 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", {})
|
||||
context = _get_active_runtime_context()
|
||||
|
||||
return {
|
||||
"run_id": context.get("config_name"),
|
||||
|
||||
@@ -9,6 +9,7 @@ from .runtime_service import app as runtime_app
|
||||
from .runtime_service import create_app as create_runtime_app
|
||||
from .trading_service import app as trading_app
|
||||
from .trading_service import create_app as create_trading_app
|
||||
from .cors import add_cors_middleware, get_cors_origins
|
||||
|
||||
app = agent_app
|
||||
create_app = create_agent_app
|
||||
@@ -24,4 +25,6 @@ __all__ = [
|
||||
"create_runtime_app",
|
||||
"trading_app",
|
||||
"create_trading_app",
|
||||
"add_cors_middleware",
|
||||
"get_cors_origins",
|
||||
]
|
||||
|
||||
@@ -8,7 +8,8 @@ from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
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.agents import AgentFactory, WorkspaceManager, get_registry
|
||||
@@ -47,13 +48,7 @@ def create_app(project_root: Path | None = None) -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
add_cors_middleware(app)
|
||||
|
||||
@app.get("/health")
|
||||
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,15 +6,15 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
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.domains import news as news_domain
|
||||
|
||||
|
||||
def get_market_store() -> MarketStore:
|
||||
"""Create a market store dependency."""
|
||||
return MarketStore()
|
||||
"""Get the MarketStore singleton dependency."""
|
||||
return MarketStore.get_instance()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -25,13 +25,7 @@ def create_app() -> FastAPI:
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
add_cors_middleware(app)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
@@ -51,6 +45,7 @@ def create_app() -> FastAPI:
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
|
||||
@app.get("/api/news-for-date")
|
||||
@@ -65,6 +60,7 @@ def create_app() -> FastAPI:
|
||||
ticker=ticker,
|
||||
date=date,
|
||||
limit=limit,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
|
||||
@app.get("/api/news-timeline")
|
||||
@@ -79,6 +75,7 @@ def create_app() -> FastAPI:
|
||||
ticker=ticker,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
|
||||
@app.get("/api/categories")
|
||||
@@ -95,6 +92,7 @@ def create_app() -> FastAPI:
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
|
||||
@app.get("/api/similar-days")
|
||||
@@ -109,6 +107,7 @@ def create_app() -> FastAPI:
|
||||
ticker=ticker,
|
||||
date=date,
|
||||
n_similar=n_similar,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
|
||||
@app.get("/api/stories/{ticker}")
|
||||
@@ -121,6 +120,7 @@ def create_app() -> FastAPI:
|
||||
store,
|
||||
ticker=ticker,
|
||||
as_of_date=as_of_date,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
|
||||
@app.get("/api/range-explain")
|
||||
@@ -139,6 +139,7 @@ def create_app() -> FastAPI:
|
||||
end_date=end_date,
|
||||
article_ids=article_ids,
|
||||
limit=limit,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from backend.api import runtime_router
|
||||
from backend.api.runtime import get_runtime_state
|
||||
from backend.apps.cors import add_cors_middleware
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -18,13 +18,7 @@ def create_app() -> FastAPI:
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
add_cors_middleware(app)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict[str, object]:
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
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 shared.schema import (
|
||||
@@ -26,13 +26,7 @@ def create_app() -> FastAPI:
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
add_cors_middleware(app)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
|
||||
@@ -1019,11 +1019,6 @@ def backtest(
|
||||
|
||||
@app.command()
|
||||
def live(
|
||||
mock: bool = typer.Option(
|
||||
False,
|
||||
"--mock",
|
||||
help="Use mock mode with simulated prices (for testing)",
|
||||
),
|
||||
config_name: str = typer.Option(
|
||||
"live",
|
||||
"--config-name",
|
||||
@@ -1078,7 +1073,6 @@ def live(
|
||||
|
||||
Example:
|
||||
evotraders live # Run immediately (default)
|
||||
evotraders live --mock # Mock mode
|
||||
evotraders live -t 22:30 # Run at 22:30 local time daily
|
||||
evotraders live --schedule-mode intraday --interval-minutes 60
|
||||
evotraders live --trigger-time now # Run immediately
|
||||
@@ -1086,33 +1080,31 @@ def live(
|
||||
"""
|
||||
schedule_mode = str(_normalize_typer_value(schedule_mode, "daily"))
|
||||
interval_minutes = int(_normalize_typer_value(interval_minutes, 60))
|
||||
mode_name = "MOCK" if mock else "LIVE"
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[bold cyan]EvoTraders {mode_name} Mode[/bold cyan]",
|
||||
"[bold cyan]EvoTraders LIVE Mode[/bold cyan]",
|
||||
border_style="cyan",
|
||||
),
|
||||
)
|
||||
|
||||
# Check for required API key in live mode
|
||||
if not mock:
|
||||
env_file = get_project_root() / ".env"
|
||||
if not env_file.exists():
|
||||
console.print("\n[yellow]Warning: .env file not found[/yellow]")
|
||||
console.print("Creating from template...\n")
|
||||
template = get_project_root() / "env.template"
|
||||
if template.exists():
|
||||
shutil.copy(template, env_file)
|
||||
console.print("[green].env file created[/green]")
|
||||
console.print(
|
||||
"\n[red]Error: Please edit .env and set FINNHUB_API_KEY[/red]",
|
||||
)
|
||||
console.print(
|
||||
"Get your free API key at: https://finnhub.io/register\n",
|
||||
)
|
||||
else:
|
||||
console.print("[red]Error: env.template not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
env_file = get_project_root() / ".env"
|
||||
if not env_file.exists():
|
||||
console.print("\n[yellow]Warning: .env file not found[/yellow]")
|
||||
console.print("Creating from template...\n")
|
||||
template = get_project_root() / "env.template"
|
||||
if template.exists():
|
||||
shutil.copy(template, env_file)
|
||||
console.print("[green].env file created[/green]")
|
||||
console.print(
|
||||
"\n[red]Error: Please edit .env and set FINNHUB_API_KEY[/red]",
|
||||
)
|
||||
console.print(
|
||||
"Get your free API key at: https://finnhub.io/register\n",
|
||||
)
|
||||
else:
|
||||
console.print("[red]Error: env.template not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Handle historical data cleanup
|
||||
handle_history_cleanup(config_name, auto_clean=clean)
|
||||
@@ -1168,12 +1160,9 @@ def live(
|
||||
|
||||
# Display configuration
|
||||
console.print("\n[bold]Configuration:[/bold]")
|
||||
if mock:
|
||||
console.print(" Mode: [yellow]MOCK[/yellow] (Simulated prices)")
|
||||
else:
|
||||
console.print(
|
||||
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
||||
)
|
||||
console.print(
|
||||
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
||||
)
|
||||
console.print(f" Config: {config_name}")
|
||||
console.print(f" Server: {host}:{port}")
|
||||
console.print(f" Poll Interval: {poll_interval}s")
|
||||
@@ -1188,22 +1177,17 @@ def live(
|
||||
project_root = get_project_root()
|
||||
os.chdir(project_root)
|
||||
|
||||
# Data update (if not mock mode)
|
||||
if not mock:
|
||||
run_data_updater(project_root)
|
||||
auto_update_market_store(
|
||||
config_name,
|
||||
end_date=nyse_now.date().isoformat(),
|
||||
)
|
||||
auto_enrich_market_store(
|
||||
config_name,
|
||||
end_date=nyse_now.date().isoformat(),
|
||||
force=False,
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"\n[dim]Mock mode enabled - skipping data update[/dim]\n",
|
||||
)
|
||||
# Data update
|
||||
run_data_updater(project_root)
|
||||
auto_update_market_store(
|
||||
config_name,
|
||||
end_date=nyse_now.date().isoformat(),
|
||||
)
|
||||
auto_enrich_market_store(
|
||||
config_name,
|
||||
end_date=nyse_now.date().isoformat(),
|
||||
force=False,
|
||||
)
|
||||
|
||||
# Build command using backend.main
|
||||
cmd = [
|
||||
@@ -1229,8 +1213,6 @@ def live(
|
||||
str(interval_minutes),
|
||||
]
|
||||
|
||||
if mock:
|
||||
cmd.append("--mock")
|
||||
if enable_memory:
|
||||
cmd.append("--enable-memory")
|
||||
|
||||
|
||||
@@ -76,27 +76,19 @@ def _resolve_config() -> DataSourceConfig:
|
||||
"""
|
||||
Resolve data source configuration based on available API keys.
|
||||
|
||||
Priority:
|
||||
1. FINNHUB_API_KEY (if set)
|
||||
2. FINANCIAL_DATASETS_API_KEY (if set)
|
||||
3. Raises error if neither is available
|
||||
The effective source should always match the first item in the resolved
|
||||
ordered source list.
|
||||
"""
|
||||
sources = _ordered_sources()
|
||||
if "finnhub" in sources:
|
||||
return DataSourceConfig(
|
||||
source="finnhub",
|
||||
api_key=os.getenv("FINNHUB_API_KEY", "").strip(),
|
||||
sources=sources,
|
||||
)
|
||||
if "financial_datasets" in sources:
|
||||
return DataSourceConfig(
|
||||
source="financial_datasets",
|
||||
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)
|
||||
source = sources[0] if sources else "local_csv"
|
||||
|
||||
api_key = ""
|
||||
if source == "finnhub":
|
||||
api_key = os.getenv("FINNHUB_API_KEY", "").strip()
|
||||
elif source == "financial_datasets":
|
||||
api_key = os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip()
|
||||
|
||||
return DataSourceConfig(source=source, api_key=api_key, sources=sources)
|
||||
|
||||
|
||||
def get_config() -> DataSourceConfig:
|
||||
|
||||
@@ -244,10 +244,8 @@ async def run_pipeline(
|
||||
start_date = bootstrap.get("start_date")
|
||||
end_date = bootstrap.get("end_date")
|
||||
enable_memory = bootstrap.get("enable_memory", False)
|
||||
enable_mock = bootstrap.get("enable_mock", False)
|
||||
|
||||
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
|
||||
@@ -266,10 +264,6 @@ async def run_pipeline(
|
||||
|
||||
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)
|
||||
# These will be started by Gateway in the correct order
|
||||
@@ -292,9 +286,8 @@ async def run_pipeline(
|
||||
market_service = MarketService(
|
||||
tickers=tickers,
|
||||
poll_interval=10,
|
||||
mock_mode=is_mock and not 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_end_date=end_date if is_backtest else None,
|
||||
)
|
||||
@@ -391,7 +384,6 @@ async def run_pipeline(
|
||||
scheduler_callback=scheduler_callback,
|
||||
config={
|
||||
"mode": mode,
|
||||
"mock_mode": is_mock,
|
||||
"backtest_mode": is_backtest,
|
||||
"tickers": tickers,
|
||||
"config_name": run_id,
|
||||
|
||||
@@ -465,7 +465,6 @@ class StateSync:
|
||||
|
||||
payload = {
|
||||
"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),
|
||||
"tickers": self._state.get("tickers"),
|
||||
"runtime_config": self._state.get("runtime_config"),
|
||||
@@ -488,12 +487,13 @@ class StateSync:
|
||||
}
|
||||
|
||||
if include_dashboard:
|
||||
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self._state)
|
||||
payload["dashboard"] = {
|
||||
"summary": self.storage.load_file("summary"),
|
||||
"holdings": self.storage.load_file("holdings"),
|
||||
"stats": self.storage.load_file("stats"),
|
||||
"trades": self.storage.load_file("trades"),
|
||||
"leaderboard": self.storage.load_file("leaderboard"),
|
||||
"summary": dashboard_snapshot.get("summary"),
|
||||
"holdings": dashboard_snapshot.get("holdings"),
|
||||
"stats": dashboard_snapshot.get("stats"),
|
||||
"trades": dashboard_snapshot.get("trades"),
|
||||
"leaderboard": dashboard_snapshot.get("leaderboard"),
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from backend.data.historical_price_manager import HistoricalPriceManager
|
||||
from backend.data.mock_price_manager import MockPriceManager
|
||||
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.news_alignment import align_news_for_symbol
|
||||
from backend.data.provider_router import DataProviderRouter
|
||||
from backend.data.polygon_client import (
|
||||
fetch_news,
|
||||
fetch_ohlc,
|
||||
@@ -24,6 +25,35 @@ def _default_start(years: int = 2) -> str:
|
||||
return (datetime.now(timezone.utc).date() - timedelta(days=years * 366)).isoformat()
|
||||
|
||||
|
||||
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(
|
||||
symbol: str,
|
||||
*,
|
||||
@@ -114,6 +144,80 @@ 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()
|
||||
start_news = (
|
||||
(datetime.fromisoformat(watermarks["last_news_fetch"]) + timedelta(days=1)).date().isoformat()
|
||||
if watermarks.get("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=end if news_rows or watermarks.get("last_news_fetch") else None,
|
||||
)
|
||||
|
||||
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(
|
||||
symbols: Iterable[str],
|
||||
*,
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
@@ -147,12 +147,30 @@ def _utc_timestamp() -> str:
|
||||
|
||||
|
||||
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):
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
self.db_path = Path(db_path or get_market_db_path())
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
_SUPPRESSED_LOG_EVERY = 20
|
||||
|
||||
|
||||
class PollingPriceManager:
|
||||
"""Polling-based price manager using Finnhub or yfinance."""
|
||||
|
||||
@@ -43,6 +46,7 @@ class PollingPriceManager:
|
||||
self.latest_prices: Dict[str, float] = {}
|
||||
self.open_prices: Dict[str, float] = {}
|
||||
self.price_callbacks: List[Callable] = []
|
||||
self._failure_counts: Dict[str, int] = {}
|
||||
|
||||
self.running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
@@ -77,6 +81,8 @@ class PollingPriceManager:
|
||||
for symbol in self.subscribed_symbols:
|
||||
try:
|
||||
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")
|
||||
open_price = quote_data.get("o")
|
||||
@@ -103,6 +109,13 @@ class PollingPriceManager:
|
||||
)
|
||||
|
||||
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 = {
|
||||
"symbol": symbol,
|
||||
@@ -128,7 +141,20 @@ class PollingPriceManager:
|
||||
)
|
||||
|
||||
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]:
|
||||
"""Fetch a normalized quote payload from the configured provider."""
|
||||
@@ -136,7 +162,10 @@ class PollingPriceManager:
|
||||
return self._fetch_yfinance_quote(symbol)
|
||||
if not self.finnhub_client:
|
||||
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]:
|
||||
"""Fetch quote data from yfinance and normalize to Finnhub-like keys."""
|
||||
@@ -162,6 +191,8 @@ class PollingPriceManager:
|
||||
|
||||
if current_price is None:
|
||||
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:
|
||||
raise ValueError(f"{symbol}: No yfinance quote data")
|
||||
latest = history.iloc[-1]
|
||||
|
||||
@@ -30,6 +30,7 @@ def ensure_news_fresh(
|
||||
*,
|
||||
ticker: str,
|
||||
target_date: str | None = None,
|
||||
refresh_if_stale: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Refresh raw news incrementally when stored watermarks are stale."""
|
||||
normalized_target = str(target_date or "").strip()[:10]
|
||||
@@ -44,7 +45,7 @@ def ensure_news_fresh(
|
||||
watermarks = store.get_ticker_watermarks(ticker)
|
||||
last_news_fetch = str(watermarks.get("last_news_fetch") or "").strip()[:10]
|
||||
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(
|
||||
ticker,
|
||||
end_date=normalized_target,
|
||||
@@ -69,8 +70,14 @@ def get_enriched_news(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
limit: int = 100,
|
||||
refresh_if_stale: bool = False,
|
||||
) -> 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(
|
||||
ticker,
|
||||
start_date=start_date,
|
||||
@@ -100,8 +107,14 @@ def get_news_for_date(
|
||||
ticker: str,
|
||||
date: str,
|
||||
limit: int = 20,
|
||||
refresh_if_stale: bool = False,
|
||||
) -> 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(
|
||||
ticker,
|
||||
trade_date=date,
|
||||
@@ -129,8 +142,14 @@ def get_news_timeline(
|
||||
ticker: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
refresh_if_stale: bool = False,
|
||||
) -> 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(
|
||||
ticker,
|
||||
start_date=start_date,
|
||||
@@ -165,8 +184,14 @@ def get_news_categories(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
limit: int = 200,
|
||||
refresh_if_stale: bool = False,
|
||||
) -> 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(
|
||||
ticker,
|
||||
start_date=start_date,
|
||||
@@ -196,8 +221,14 @@ def get_similar_days_payload(
|
||||
ticker: str,
|
||||
date: str,
|
||||
n_similar: int = 5,
|
||||
refresh_if_stale: bool = False,
|
||||
) -> 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(
|
||||
store,
|
||||
symbol=ticker,
|
||||
@@ -213,8 +244,14 @@ def get_story_payload(
|
||||
*,
|
||||
ticker: str,
|
||||
as_of_date: str,
|
||||
refresh_if_stale: bool = False,
|
||||
) -> 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(
|
||||
store,
|
||||
ticker,
|
||||
@@ -238,8 +275,14 @@ def get_range_explain_payload(
|
||||
end_date: str,
|
||||
article_ids: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
refresh_if_stale: bool = False,
|
||||
) -> 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 = []
|
||||
if article_ids:
|
||||
news_rows = store.get_news_by_ids_enriched(ticker, article_ids)
|
||||
|
||||
@@ -43,6 +43,71 @@ logger = logging.getLogger(__name__)
|
||||
_prompt_loader = get_prompt_loader()
|
||||
|
||||
|
||||
INFO_LOGGER_PREFIXES = (
|
||||
"backend.agents",
|
||||
"backend.core.pipeline",
|
||||
"backend.core.scheduler",
|
||||
"backend.services.gateway_cycle_support",
|
||||
"backend.utils.terminal_dashboard",
|
||||
)
|
||||
|
||||
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:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return True
|
||||
|
||||
message = record.getMessage()
|
||||
if record.name == "httpx" and message.startswith("HTTP Request:"):
|
||||
return False
|
||||
if record.name.startswith("websockets") and "connection open" in message:
|
||||
return False
|
||||
|
||||
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(
|
||||
run_id: str,
|
||||
run_dir: Path,
|
||||
@@ -65,10 +130,8 @@ async def run_gateway(
|
||||
end_date = bootstrap.get("end_date")
|
||||
enable_memory = bootstrap.get("enable_memory", False)
|
||||
poll_interval = int(bootstrap.get("poll_interval", 10))
|
||||
enable_mock = bootstrap.get("enable_mock", False)
|
||||
|
||||
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}")
|
||||
|
||||
@@ -87,9 +150,8 @@ async def run_gateway(
|
||||
market_service = MarketService(
|
||||
tickers=tickers,
|
||||
poll_interval=poll_interval,
|
||||
mock_mode=is_mock and not 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_end_date=end_date if is_backtest else None,
|
||||
)
|
||||
@@ -182,7 +244,6 @@ async def run_gateway(
|
||||
scheduler_callback=scheduler_callback,
|
||||
config={
|
||||
"mode": mode,
|
||||
"mock_mode": is_mock,
|
||||
"backtest_mode": is_backtest,
|
||||
"tickers": tickers,
|
||||
"config_name": run_id,
|
||||
@@ -222,11 +283,7 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
level = logging.DEBUG if args.verbose else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s | %(levelname)-7s | %(name)s:%(lineno)d - %(message)s",
|
||||
)
|
||||
configure_gateway_logging(verbose=args.verbose)
|
||||
|
||||
# Parse bootstrap
|
||||
bootstrap = json.loads(args.bootstrap)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
AgentScope Native Model Factory
|
||||
Uses native AgentScope model classes for LLM calls
|
||||
"""
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
@@ -34,6 +36,27 @@ logger = logging.getLogger(__name__)
|
||||
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:
|
||||
"""Wraps an AgentScope model with automatic retry for transient errors.
|
||||
|
||||
@@ -55,6 +78,7 @@ class RetryChatModel:
|
||||
"502",
|
||||
"504",
|
||||
"connection",
|
||||
"disconnected",
|
||||
"temporary",
|
||||
"overloaded",
|
||||
"too_many_requests",
|
||||
@@ -150,8 +174,8 @@ class RetryChatModel:
|
||||
# Track usage if available
|
||||
if hasattr(result, "usage") and result.usage:
|
||||
usage = result.usage
|
||||
self._total_tokens_used += getattr(usage, "total_tokens", 0)
|
||||
self._total_cost += getattr(usage, "cost", 0.0)
|
||||
self._total_tokens_used += _usage_total_tokens(usage)
|
||||
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
|
||||
|
||||
return result
|
||||
|
||||
@@ -192,9 +216,66 @@ class RetryChatModel:
|
||||
raise last_error
|
||||
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:
|
||||
"""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:
|
||||
"""Proxy attribute access to the wrapped model."""
|
||||
@@ -248,10 +329,18 @@ class TokenRecordingModelWrapper:
|
||||
if usage is None:
|
||||
return
|
||||
|
||||
self._prompt_tokens += getattr(usage, "prompt_tokens", 0)
|
||||
self._completion_tokens += getattr(usage, "completion_tokens", 0)
|
||||
self._total_tokens += getattr(usage, "total_tokens", 0)
|
||||
self._total_cost += getattr(usage, "cost", 0.0)
|
||||
prompt_tokens = _usage_value(usage, "prompt_tokens", None)
|
||||
completion_tokens = _usage_value(usage, "completion_tokens", None)
|
||||
|
||||
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:
|
||||
"""Forward calls and record usage."""
|
||||
@@ -401,7 +490,8 @@ def create_model(
|
||||
if 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):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Main Entry Point
|
||||
Supports: backtest, live, mock modes
|
||||
Supports: backtest, live modes
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
@@ -226,17 +226,13 @@ async def run_with_gateway(args):
|
||||
)
|
||||
runtime_manager.prepare_run()
|
||||
set_global_runtime_manager(runtime_manager)
|
||||
register_runtime_manager(runtime_manager)
|
||||
|
||||
# Create market service
|
||||
market_service = MarketService(
|
||||
tickers=tickers,
|
||||
poll_interval=args.poll_interval,
|
||||
mock_mode=args.mock and not is_backtest,
|
||||
backtest_mode=is_backtest,
|
||||
api_key=os.getenv("FINNHUB_API_KEY")
|
||||
if not args.mock and not is_backtest
|
||||
else None,
|
||||
api_key=os.getenv("FINNHUB_API_KEY") if not 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,
|
||||
)
|
||||
@@ -321,7 +317,6 @@ async def run_with_gateway(args):
|
||||
scheduler_callback=scheduler_callback,
|
||||
config={
|
||||
"mode": args.mode,
|
||||
"mock_mode": args.mock,
|
||||
"backtest_mode": is_backtest,
|
||||
"tickers": tickers,
|
||||
"config_name": config_name,
|
||||
@@ -354,8 +349,7 @@ def main():
|
||||
"""Main entry point"""
|
||||
parser = argparse.ArgumentParser(description="Trading System")
|
||||
parser.add_argument("--mode", choices=["live", "backtest"], default="live")
|
||||
parser.add_argument("--mock", action="store_true")
|
||||
parser.add_argument("--config-name", default="mock")
|
||||
parser.add_argument("--config-name", default="live")
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument(
|
||||
|
||||
@@ -13,15 +13,30 @@ from .registry import RuntimeRegistry
|
||||
_global_runtime_manager: Optional["TradingRuntimeManager"] = 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:
|
||||
global _global_runtime_manager
|
||||
_global_runtime_manager = manager
|
||||
# Sync to RuntimeState for consistency
|
||||
_get_api_runtime().register_runtime_manager(manager)
|
||||
|
||||
|
||||
def clear_global_runtime_manager() -> None:
|
||||
global _global_runtime_manager
|
||||
_global_runtime_manager = None
|
||||
# Sync to RuntimeState for consistency
|
||||
_get_api_runtime().unregister_runtime_manager()
|
||||
|
||||
|
||||
def get_global_runtime_manager() -> Optional["TradingRuntimeManager"]:
|
||||
|
||||
@@ -111,7 +111,6 @@ class Gateway:
|
||||
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 "",
|
||||
@@ -125,10 +124,6 @@ class Gateway:
|
||||
self.state_sync.update_state("status", "initializing")
|
||||
self.state_sync.update_state("server_mode", self.mode)
|
||||
self.state_sync.update_state("is_backtest", self.is_backtest)
|
||||
self.state_sync.update_state(
|
||||
"is_mock_mode",
|
||||
self.config.get("mock_mode", False),
|
||||
)
|
||||
self.state_sync.update_state("tickers", self.config.get("tickers", []))
|
||||
self.state_sync.update_state(
|
||||
"runtime_config",
|
||||
@@ -152,10 +147,11 @@ class Gateway:
|
||||
)
|
||||
|
||||
# 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:
|
||||
holdings = self.storage.load_file("holdings") or []
|
||||
trades = self.storage.load_file("trades") or []
|
||||
holdings = dashboard_snapshot.get("holdings") or []
|
||||
trades = dashboard_snapshot.get("trades") or []
|
||||
current_date = self.state_sync.state.get("current_date")
|
||||
self._dashboard.update(
|
||||
date=current_date or "-",
|
||||
@@ -544,13 +540,13 @@ class Gateway:
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Run one live/mock trading cycle on demand."""
|
||||
"""Run one live trading cycle on demand."""
|
||||
if self.is_backtest:
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Manual trigger is only available in live/mock mode.",
|
||||
"message": "Manual trigger is only available in live mode.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import asyncio
|
||||
import logging
|
||||
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.utils.msg_adapter import FrontendAdapter
|
||||
|
||||
@@ -61,7 +61,7 @@ async def market_status_monitor(gateway: Any) -> None:
|
||||
status = gateway.market_service.get_market_status()
|
||||
if status["status"] == "open" and not gateway.storage.is_live_session_active:
|
||||
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(
|
||||
"totalAssetValue",
|
||||
gateway.storage.initial_cash,
|
||||
@@ -200,6 +200,23 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
|
||||
trading_date = gateway.market_service.get_live_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)
|
||||
gateway._dashboard.update(date=trading_date, status="Analyzing...")
|
||||
|
||||
@@ -240,14 +257,15 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[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:
|
||||
summary.update(gateway.storage.get_live_returns())
|
||||
|
||||
await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary)
|
||||
holdings = gateway.storage.load_file("holdings") or []
|
||||
trades = gateway.storage.load_file("trades") or []
|
||||
leaderboard = gateway.storage.load_file("leaderboard") or []
|
||||
holdings = dashboard_snapshot.get("holdings") or []
|
||||
trades = dashboard_snapshot.get("trades") or []
|
||||
leaderboard = dashboard_snapshot.get("leaderboard") or []
|
||||
if leaderboard:
|
||||
await gateway.state_sync.on_leaderboard_update(leaderboard)
|
||||
gateway._dashboard.update(date=date, status="Running", portfolio=summary, holdings=holdings, trades=trades)
|
||||
@@ -319,7 +337,7 @@ async def run_backtest_dates(gateway: Any, dates: list[str]) -> None:
|
||||
await gateway.on_strategy_trigger(date=date)
|
||||
await asyncio.sleep(0.1)
|
||||
await gateway.state_sync.on_system_message(f"Backtest complete - {len(dates)} days")
|
||||
summary = gateway.storage.load_file("summary") or {}
|
||||
summary = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state).get("summary") or {}
|
||||
gateway._dashboard.update(status="Complete", portfolio=summary, days_completed=len(dates))
|
||||
gateway._dashboard.stop()
|
||||
gateway._dashboard.print_final_summary()
|
||||
|
||||
@@ -164,9 +164,10 @@ def sync_runtime_state(gateway: Any) -> None:
|
||||
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 []
|
||||
dashboard_snapshot = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state)
|
||||
summary = dashboard_snapshot.get("summary") or {}
|
||||
holdings = dashboard_snapshot.get("holdings") or []
|
||||
trades = dashboard_snapshot.get("trades") or []
|
||||
gateway._dashboard.update(
|
||||
portfolio=summary,
|
||||
holdings=holdings,
|
||||
|
||||
@@ -152,6 +152,7 @@ async def handle_get_stock_news(gateway: Any, websocket: Any, data: dict[str, An
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=max(limit, 50),
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
news_rows = (payload.get("news") or [])[-limit:]
|
||||
source = "market_store"
|
||||
@@ -202,6 +203,7 @@ async def handle_get_stock_news_for_date(gateway: Any, websocket: Any, data: dic
|
||||
ticker=ticker,
|
||||
date=trade_date,
|
||||
limit=limit,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
news_rows = payload.get("news") or []
|
||||
source = "market_store"
|
||||
@@ -255,6 +257,7 @@ async def handle_get_stock_news_timeline(gateway: Any, websocket: Any, data: dic
|
||||
ticker=ticker,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
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,
|
||||
end_date=end_date,
|
||||
limit=200,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
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,
|
||||
article_ids=article_ids if isinstance(article_ids, list) else None,
|
||||
limit=100,
|
||||
refresh_if_stale=False,
|
||||
)
|
||||
result = payload.get("result")
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Market Data Service
|
||||
Supports live, mock, and backtest modes
|
||||
Supports live and backtest modes
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,7 +36,6 @@ class MarketService:
|
||||
self,
|
||||
tickers: List[str],
|
||||
poll_interval: int = 10,
|
||||
mock_mode: bool = False,
|
||||
backtest_mode: bool = False,
|
||||
api_key: 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.poll_interval = poll_interval
|
||||
self.mock_mode = mock_mode
|
||||
self.backtest_mode = backtest_mode
|
||||
self.api_key = api_key
|
||||
self.backtest_start_date = backtest_start_date
|
||||
@@ -69,8 +67,6 @@ class MarketService:
|
||||
"""Return the active live quote provider for UI/debugging."""
|
||||
if self.backtest_mode:
|
||||
return "backtest"
|
||||
if self.mock_mode:
|
||||
return "mock"
|
||||
if self._price_manager and hasattr(self._price_manager, "provider"):
|
||||
provider = getattr(self._price_manager, "provider", None)
|
||||
if isinstance(provider, str) and provider.strip():
|
||||
@@ -81,8 +77,6 @@ class MarketService:
|
||||
def mode_name(self) -> str:
|
||||
if self.backtest_mode:
|
||||
return "BACKTEST"
|
||||
elif self.mock_mode:
|
||||
return "MOCK"
|
||||
return "LIVE"
|
||||
|
||||
async def start(self, broadcast_func: Callable):
|
||||
@@ -96,8 +90,6 @@ class MarketService:
|
||||
|
||||
if self.backtest_mode:
|
||||
self._start_backtest_mode()
|
||||
elif self.mock_mode:
|
||||
self._start_mock_mode()
|
||||
else:
|
||||
self._start_real_mode()
|
||||
|
||||
@@ -125,26 +117,10 @@ class MarketService:
|
||||
|
||||
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):
|
||||
from backend.data.polling_price_manager import PollingPriceManager
|
||||
|
||||
provider = get_data_source()
|
||||
if provider == "local_csv":
|
||||
provider = "yfinance"
|
||||
provider = self._resolve_live_quote_provider()
|
||||
|
||||
if provider == "finnhub" and not self.api_key:
|
||||
raise ValueError("API key required for live mode")
|
||||
@@ -157,6 +133,13 @@ class MarketService:
|
||||
self._price_manager.subscribe(self.tickers)
|
||||
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):
|
||||
from backend.data.historical_price_manager import (
|
||||
HistoricalPriceManager,
|
||||
@@ -257,13 +240,7 @@ class MarketService:
|
||||
if removed:
|
||||
self._price_manager.unsubscribe(removed)
|
||||
if added:
|
||||
if self.mock_mode:
|
||||
self._price_manager.subscribe(
|
||||
added,
|
||||
base_prices={ticker: 100.0 for ticker in added},
|
||||
)
|
||||
else:
|
||||
self._price_manager.subscribe(added)
|
||||
self._price_manager.subscribe(added)
|
||||
|
||||
if self.backtest_mode and self._current_date:
|
||||
self._price_manager.set_date(self._current_date)
|
||||
|
||||
@@ -11,7 +11,6 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from backend.data.market_store import MarketStore
|
||||
from .research_db import ResearchDb
|
||||
from .runtime_db import RuntimeDb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,12 +21,18 @@ class StorageService:
|
||||
Storage service for data persistence
|
||||
|
||||
Responsibilities:
|
||||
1. Load/save dashboard JSON files
|
||||
1. Export dashboard JSON files
|
||||
(summary, holdings, stats, trades, leaderboard)
|
||||
2. Load/save internal state (_internal_state.json)
|
||||
3. Load/save server state (server_state.json) with feed history
|
||||
4. Manage portfolio state persistence
|
||||
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__(
|
||||
@@ -49,7 +54,7 @@ class StorageService:
|
||||
self.initial_cash = initial_cash
|
||||
self.config_name = config_name
|
||||
|
||||
# Dashboard file paths
|
||||
# Dashboard export file paths
|
||||
self.files = {
|
||||
"summary": self.dashboard_dir / "summary.json",
|
||||
"holdings": self.dashboard_dir / "holdings.json",
|
||||
@@ -66,7 +71,6 @@ class StorageService:
|
||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.server_state_file = self.state_dir / "server_state.json"
|
||||
self.runtime_db = RuntimeDb(self.state_dir / "runtime.db")
|
||||
self.research_db = ResearchDb(self.state_dir / "research.db")
|
||||
self.market_store = MarketStore()
|
||||
|
||||
# Feed history (for agent messages)
|
||||
@@ -84,16 +88,8 @@ class StorageService:
|
||||
|
||||
logger.info(f"Storage service initialized: {self.dashboard_dir}")
|
||||
|
||||
def load_file(self, file_type: str) -> Optional[Any]:
|
||||
"""
|
||||
Load dashboard JSON file
|
||||
|
||||
Args:
|
||||
file_type: One of: summary, holdings, stats, trades, leaderboard
|
||||
|
||||
Returns:
|
||||
Loaded data or None if file doesn't exist
|
||||
"""
|
||||
def load_export_file(self, file_type: str) -> Optional[Any]:
|
||||
"""Load dashboard export JSON file."""
|
||||
file_path = self.files.get(file_type)
|
||||
if not file_path or not file_path.exists():
|
||||
return None
|
||||
@@ -105,14 +101,12 @@ class StorageService:
|
||||
logger.error(f"Failed to load {file_type}.json: {e}")
|
||||
return None
|
||||
|
||||
def save_file(self, file_type: str, data: Any):
|
||||
"""
|
||||
Save dashboard JSON file
|
||||
def load_file(self, file_type: str) -> Optional[Any]:
|
||||
"""Backward-compatible alias for export-layer JSON reads."""
|
||||
return self.load_export_file(file_type)
|
||||
|
||||
Args:
|
||||
file_type: One of: summary, holdings, stats, trades, leaderboard
|
||||
data: Data to save
|
||||
"""
|
||||
def save_export_file(self, file_type: str, data: Any):
|
||||
"""Save dashboard export JSON file."""
|
||||
file_path = self.files.get(file_type)
|
||||
if not file_path:
|
||||
logger.error(f"Unknown file type: {file_type}")
|
||||
@@ -129,6 +123,48 @@ class StorageService:
|
||||
except Exception as 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]:
|
||||
"""
|
||||
Check which dashboard files have been updated since last check
|
||||
@@ -297,7 +333,7 @@ class StorageService:
|
||||
def initialize_empty_dashboard(self):
|
||||
"""Initialize empty dashboard files with default values"""
|
||||
# Summary
|
||||
self.save_file(
|
||||
self.save_export_file(
|
||||
"summary",
|
||||
{
|
||||
"totalAssetValue": self.initial_cash,
|
||||
@@ -315,10 +351,10 @@ class StorageService:
|
||||
)
|
||||
|
||||
# Holdings
|
||||
self.save_file("holdings", [])
|
||||
self.save_export_file("holdings", [])
|
||||
|
||||
# Stats
|
||||
self.save_file(
|
||||
self.save_export_file(
|
||||
"stats",
|
||||
{
|
||||
"totalAssetValue": self.initial_cash,
|
||||
@@ -335,7 +371,7 @@ class StorageService:
|
||||
)
|
||||
|
||||
# Trades
|
||||
self.save_file("trades", [])
|
||||
self.save_export_file("trades", [])
|
||||
|
||||
# Leaderboard with model info
|
||||
self.generate_leaderboard()
|
||||
@@ -375,7 +411,7 @@ class StorageService:
|
||||
ranking_entries.append(entry)
|
||||
|
||||
leaderboard = team_entries + ranking_entries
|
||||
self.save_file("leaderboard", leaderboard)
|
||||
self.save_export_file("leaderboard", leaderboard)
|
||||
logger.info("Leaderboard generated with model info")
|
||||
|
||||
def update_leaderboard_model_info(self):
|
||||
@@ -398,7 +434,7 @@ class StorageService:
|
||||
entry["modelName"] = model_name
|
||||
entry["modelProvider"] = model_provider
|
||||
|
||||
self.save_file("leaderboard", existing)
|
||||
self.save_export_file("leaderboard", existing)
|
||||
logger.info("Leaderboard model info updated")
|
||||
|
||||
def get_current_timestamp_ms(self, date: str = None) -> int:
|
||||
@@ -653,7 +689,7 @@ class StorageService:
|
||||
"momentum": state.get("momentum_history", []),
|
||||
}
|
||||
|
||||
self.save_file("summary", summary)
|
||||
self.save_export_file("summary", summary)
|
||||
|
||||
def _generate_holdings(
|
||||
self,
|
||||
@@ -715,7 +751,7 @@ class StorageService:
|
||||
# Sort by weight
|
||||
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):
|
||||
"""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]):
|
||||
"""Generate trades.json"""
|
||||
@@ -764,7 +800,7 @@ class StorageService:
|
||||
},
|
||||
)
|
||||
|
||||
self.save_file("trades", trades)
|
||||
self.save_export_file("trades", trades)
|
||||
|
||||
# Server State Management Methods
|
||||
|
||||
@@ -1001,12 +1037,12 @@ class StorageService:
|
||||
Args:
|
||||
state: Server state dictionary to update
|
||||
"""
|
||||
# Load dashboard data
|
||||
summary = self.load_file("summary") or {}
|
||||
holdings = self.load_file("holdings") or []
|
||||
stats = self.load_file("stats") or self._get_default_stats()
|
||||
trades = self.load_file("trades") or []
|
||||
leaderboard = self.load_file("leaderboard") or []
|
||||
dashboard_snapshot = self.build_dashboard_snapshot_from_state(state)
|
||||
summary = dashboard_snapshot.get("summary") or {}
|
||||
holdings = dashboard_snapshot.get("holdings") or []
|
||||
stats = dashboard_snapshot.get("stats") or self._get_default_stats()
|
||||
trades = dashboard_snapshot.get("trades") or []
|
||||
leaderboard = dashboard_snapshot.get("leaderboard") or []
|
||||
internal_state = self.load_internal_state()
|
||||
|
||||
# Update state
|
||||
@@ -1040,7 +1076,6 @@ class StorageService:
|
||||
Start tracking live returns for current trading session.
|
||||
Captures current values as session start baseline.
|
||||
"""
|
||||
summary = self.load_file("summary") or {}
|
||||
state = self.load_internal_state()
|
||||
|
||||
# Capture current values as session start
|
||||
@@ -1052,7 +1087,7 @@ class StorageService:
|
||||
self._session_start_equity = (
|
||||
equity_history[-1]["v"]
|
||||
if equity_history
|
||||
else summary.get("totalAssetValue", self.initial_cash)
|
||||
else self.initial_cash
|
||||
)
|
||||
self._session_start_baseline = (
|
||||
baseline_history[-1]["v"]
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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):
|
||||
@@ -25,3 +26,79 @@ def test_agent_service_excludes_runtime_routes(tmp_path):
|
||||
|
||||
assert "/api/runtime/start" 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"
|
||||
|
||||
@@ -34,7 +34,6 @@ def test_live_runs_incremental_market_store_update_before_start(monkeypatch, tmp
|
||||
monkeypatch.setattr(cli.subprocess, "run", fake_run)
|
||||
|
||||
cli.live(
|
||||
mock=False,
|
||||
config_name="smoke_fullstack",
|
||||
host="0.0.0.0",
|
||||
port=8765,
|
||||
|
||||
@@ -77,6 +77,15 @@ class _DummyStorage:
|
||||
return {"totalAssetValue": self.initial_cash}
|
||||
return []
|
||||
|
||||
def build_dashboard_snapshot_from_state(self, state):
|
||||
return {
|
||||
"summary": {"totalAssetValue": self.initial_cash},
|
||||
"holdings": [],
|
||||
"stats": {},
|
||||
"trades": [],
|
||||
"leaderboard": [],
|
||||
}
|
||||
|
||||
|
||||
class _DummyPM:
|
||||
def __init__(self):
|
||||
|
||||
@@ -2,153 +2,12 @@
|
||||
# pylint: disable=W0212
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
import pytest
|
||||
from backend.services.market import MarketService
|
||||
from backend.data.mock_price_manager import MockPriceManager
|
||||
from backend.data.polling_price_manager import PollingPriceManager
|
||||
|
||||
|
||||
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
|
||||
from backend.llm.models import RetryChatModel
|
||||
|
||||
|
||||
class TestPollingPriceManager:
|
||||
@@ -231,37 +90,67 @@ class TestPollingPriceManager:
|
||||
|
||||
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:
|
||||
def test_init_mock_mode(self):
|
||||
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("backend.services.market.get_data_sources", return_value=["yfinance", "local_csv"])
|
||||
@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(
|
||||
tickers=["AAPL"],
|
||||
poll_interval=10,
|
||||
mock_mode=False,
|
||||
)
|
||||
|
||||
service._start_real_mode()
|
||||
@@ -269,30 +158,24 @@ class TestMarketService:
|
||||
assert isinstance(service._price_manager, PollingPriceManager)
|
||||
assert service._price_manager.provider == "yfinance"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_mock_mode(self):
|
||||
@patch("backend.services.market.get_data_sources", return_value=["financial_datasets", "yfinance", "local_csv"])
|
||||
@patch.object(PollingPriceManager, "start")
|
||||
def test_start_real_mode_uses_first_supported_live_provider(self, _mock_start, _mock_sources):
|
||||
service = MarketService(
|
||||
tickers=["AAPL"],
|
||||
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
|
||||
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")
|
||||
@patch("backend.services.market.get_data_sources", return_value=["finnhub", "yfinance"])
|
||||
@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(
|
||||
tickers=["AAPL"],
|
||||
mock_mode=False,
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
@@ -307,11 +190,12 @@ class TestMarketService:
|
||||
async def test_start_already_running(self):
|
||||
service = MarketService(
|
||||
tickers=["AAPL"],
|
||||
mock_mode=True,
|
||||
backtest_mode=True,
|
||||
)
|
||||
|
||||
broadcast_func = AsyncMock()
|
||||
|
||||
# First start with backtest mode
|
||||
await service.start(broadcast_func)
|
||||
assert service.running is True
|
||||
|
||||
@@ -323,7 +207,7 @@ class TestMarketService:
|
||||
def test_stop(self):
|
||||
service = MarketService(
|
||||
tickers=["AAPL"],
|
||||
mock_mode=True,
|
||||
backtest_mode=True,
|
||||
)
|
||||
service.running = True
|
||||
service._price_manager = MagicMock()
|
||||
@@ -336,7 +220,7 @@ class TestMarketService:
|
||||
def test_stop_when_not_running(self):
|
||||
service = MarketService(
|
||||
tickers=["AAPL"],
|
||||
mock_mode=True,
|
||||
backtest_mode=True,
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
@@ -344,20 +228,20 @@ class TestMarketService:
|
||||
assert service.running is False
|
||||
|
||||
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}
|
||||
|
||||
price = service.get_price_sync("AAPL")
|
||||
assert price == 150.0
|
||||
|
||||
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")
|
||||
assert price is None
|
||||
|
||||
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["MSFT"] = {"price": 400.0}
|
||||
|
||||
@@ -368,7 +252,7 @@ class TestMarketService:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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()
|
||||
|
||||
price_data = {
|
||||
@@ -388,7 +272,7 @@ class TestMarketService:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
price_data = {"symbol": "AAPL", "price": 150.0, "open": 148.0}
|
||||
@@ -396,67 +280,6 @@ class TestMarketService:
|
||||
# Should not raise
|
||||
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__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -80,7 +80,7 @@ def test_get_enriched_news_returns_rows_without_enrichment_when_present(monkeypa
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"ensure_news_fresh",
|
||||
lambda store, ticker, target_date=None: {
|
||||
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||
"ticker": ticker,
|
||||
"target_date": target_date,
|
||||
"last_news_fetch": target_date,
|
||||
@@ -109,7 +109,7 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"ensure_news_fresh",
|
||||
lambda store, ticker, target_date=None: {
|
||||
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||
"ticker": ticker,
|
||||
"target_date": target_date,
|
||||
"last_news_fetch": target_date,
|
||||
@@ -137,12 +137,38 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
|
||||
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):
|
||||
store = _FakeStore()
|
||||
monkeypatch.setattr(
|
||||
news_domain,
|
||||
"ensure_news_fresh",
|
||||
lambda store, ticker, target_date=None: {
|
||||
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||
"ticker": ticker,
|
||||
"target_date": target_date,
|
||||
"last_news_fetch": target_date,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Tests for the extracted runtime service app surface."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -17,6 +18,8 @@ def test_runtime_service_routes_are_exposed():
|
||||
assert "/api/status" in paths
|
||||
assert "/api/runtime/start" 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/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["resolved"]["interval_minutes"] == 15
|
||||
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)
|
||||
|
||||
@@ -228,12 +228,12 @@ class SettlementCoordinator:
|
||||
|
||||
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(
|
||||
leaderboard,
|
||||
all_evaluations,
|
||||
)
|
||||
self.storage.save_file("leaderboard", updated_leaderboard)
|
||||
self.storage.save_export_file("leaderboard", updated_leaderboard)
|
||||
|
||||
self._update_summary_with_baselines(
|
||||
date,
|
||||
|
||||
@@ -30,7 +30,6 @@ class TerminalDashboard:
|
||||
self.port = 8765
|
||||
self.poll_interval = 10
|
||||
self.trigger_time = "now"
|
||||
self.mock = False
|
||||
self.enable_memory = False
|
||||
self.local_time = ""
|
||||
self.nyse_time = ""
|
||||
@@ -65,7 +64,6 @@ class TerminalDashboard:
|
||||
port: int,
|
||||
poll_interval: int,
|
||||
trigger_time: str = "now",
|
||||
mock: bool = False,
|
||||
enable_memory: bool = False,
|
||||
local_time: str = "",
|
||||
nyse_time: str = "",
|
||||
@@ -82,7 +80,6 @@ class TerminalDashboard:
|
||||
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
|
||||
@@ -109,8 +106,6 @@ class TerminalDashboard:
|
||||
# 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]"
|
||||
|
||||
@@ -216,8 +211,6 @@ class TerminalDashboard:
|
||||
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]"
|
||||
|
||||
|
||||
3372
frontend/src/App.jsx
3372
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
||||
background: '#ffffff',
|
||||
borderBottom: '2px solid #000000',
|
||||
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'
|
||||
}}>
|
||||
{/* Horizontal scrollable content */}
|
||||
|
||||
@@ -35,14 +35,22 @@ const stripMarkdown = (text) => {
|
||||
.replace(/^[-=]+$/gm, '');
|
||||
};
|
||||
|
||||
const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
|
||||
const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) => {
|
||||
const feedContentRef = useRef(null);
|
||||
const [highlightedId, setHighlightedId] = useState(null);
|
||||
const [selectedAgent, setSelectedAgent] = useState('all');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
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);
|
||||
return {
|
||||
modelName: agentData?.modelName,
|
||||
@@ -52,7 +60,17 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
|
||||
|
||||
// Get agent info by name
|
||||
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);
|
||||
if (!agentData) return null;
|
||||
return {
|
||||
|
||||
506
frontend/src/components/AppShell.jsx
Normal file
506
frontend/src/components/AppShell.jsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import React, { Suspense, lazy, useRef, useEffect, useMemo } from 'react';
|
||||
import GlobalStyles from '../styles/GlobalStyles';
|
||||
import Header from './Header.jsx';
|
||||
import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx';
|
||||
import StockLogo from './StockLogo.jsx';
|
||||
import NetValueChart from './NetValueChart.jsx';
|
||||
import { AGENTS } from '../config/constants';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import { useUIStore } from '../store/uiStore';
|
||||
import { formatNumber, formatTickerPrice } from '../utils/formatters';
|
||||
|
||||
const RoomView = lazy(() => import('./RoomView'));
|
||||
const AgentFeed = lazy(() => import('./AgentFeed'));
|
||||
const StatisticsView = lazy(() => import('./StatisticsView'));
|
||||
const StockExplainView = lazy(() => import('./StockExplainView.jsx'));
|
||||
const TraderView = lazy(() => import('./TraderView.jsx'));
|
||||
|
||||
function ViewLoadingFallback({ label = '加载中...' }) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: 240,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.4
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AppShell - Layout shell containing Header, TickerBar, ViewNavBar, View container, and AgentFeed
|
||||
*/
|
||||
export default function AppShell({
|
||||
// Connection & status
|
||||
isConnected,
|
||||
virtualTime,
|
||||
now,
|
||||
marketStatus,
|
||||
serverMode,
|
||||
marketStatusLabel,
|
||||
dataSourceLabel,
|
||||
runtimeSummaryLabel,
|
||||
isUpdating,
|
||||
// Handlers
|
||||
onManualTrigger,
|
||||
onOpenRuntimeLogs,
|
||||
onRuntimeSettingsToggle,
|
||||
// Runtime settings panel props
|
||||
isRuntimeSettingsOpen,
|
||||
isRuntimeConfigSaving,
|
||||
isWatchlistSaving,
|
||||
runtimeConfigFeedback,
|
||||
watchlistFeedback,
|
||||
launchModeDraft,
|
||||
restoreRunIdDraft,
|
||||
runtimeHistoryRuns,
|
||||
scheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
marginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
startDateDraft,
|
||||
endDateDraft,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistSuggestions,
|
||||
onLaunchModeChange,
|
||||
onRestoreRunIdChange,
|
||||
onScheduleModeChange,
|
||||
onIntervalMinutesChange,
|
||||
onTriggerTimeChange,
|
||||
onMaxCommCyclesChange,
|
||||
onInitialCashChange,
|
||||
onMarginRequirementChange,
|
||||
onEnableMemoryChange,
|
||||
onModeChange,
|
||||
onPollIntervalChange,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onWatchlistInputChange,
|
||||
onWatchlistInputKeyDown,
|
||||
onWatchlistAdd,
|
||||
onWatchlistRemove,
|
||||
onWatchlistRestoreCurrent,
|
||||
onWatchlistRestoreDefault,
|
||||
onWatchlistSuggestionClick,
|
||||
onLaunchConfigSave,
|
||||
onRestoreDefaults,
|
||||
// Ticker and portfolio data
|
||||
displayTickers,
|
||||
portfolioData,
|
||||
rollingTickers,
|
||||
// Feed data
|
||||
feed,
|
||||
bubbles,
|
||||
bubbleFor,
|
||||
leaderboard,
|
||||
// Views data
|
||||
currentView,
|
||||
chartTab,
|
||||
holdings,
|
||||
trades,
|
||||
stats,
|
||||
priceHistoryByTicker,
|
||||
ohlcHistoryByTicker,
|
||||
selectedExplainSymbol,
|
||||
onSelectedExplainSymbolChange,
|
||||
historySourceByTicker,
|
||||
explainEventsByTicker,
|
||||
newsByTicker,
|
||||
insiderTradesByTicker,
|
||||
technicalIndicatorsByTicker,
|
||||
currentDate,
|
||||
// Stock request handlers
|
||||
stockRequests,
|
||||
// Agent request handlers
|
||||
agentRequests,
|
||||
agentProfilesByAgent,
|
||||
// Layout
|
||||
leftWidth,
|
||||
isResizing,
|
||||
onMouseDown,
|
||||
agentFeedRef
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const { setIsRuntimeSettingsOpen, setIsWatchlistPanelOpen } = useRuntimeStore();
|
||||
const { setChartTab, setCurrentView, setIsResizing, setLeftWidth } = useUIStore();
|
||||
|
||||
// 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 === 'statistics' ? 'show-statistics' : 'show-chart'}`;
|
||||
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">
|
||||
<StockLogo ticker={ticker.symbol} size={16} />
|
||||
<span className="ticker-symbol">{ticker.symbol}</span>
|
||||
<span className="ticker-price">
|
||||
<span className={`ticker-price-value ${rollingTickers[ticker.symbol] ? 'rolling' : ''}`}>
|
||||
{ticker.price !== null && ticker.price !== undefined
|
||||
? `$${formatTickerPrice(ticker.price)}` : '-'}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`ticker-change ${
|
||||
ticker.change === null || ticker.change === undefined
|
||||
? '' : ticker.change >= 0 ? 'positive' : 'negative'
|
||||
}`}>
|
||||
{ticker.change !== null && ticker.change !== undefined
|
||||
? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="portfolio-value">
|
||||
<span className="portfolio-label">投资组合</span>
|
||||
<span className="portfolio-amount">${formatNumber(portfolioData.netValue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-container" ref={containerRef}>
|
||||
{/* Left Panel */}
|
||||
<div className="left-panel" style={{ width: `${leftWidth}%` }}>
|
||||
<div className="chart-section">
|
||||
<div className="view-container">
|
||||
<div className="view-nav-bar">
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'traders' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('traders')}
|
||||
>
|
||||
交易员
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('room')}
|
||||
>
|
||||
交易室
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('explain')}
|
||||
>
|
||||
个股分析
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('chart')}
|
||||
>
|
||||
业绩图表
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'statistics' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('statistics')}
|
||||
>
|
||||
统计
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={viewClassName}>
|
||||
{/* Traders View */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
|
||||
<TraderView {...agentRequests} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Room View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
|
||||
<RoomView
|
||||
bubbles={bubbles}
|
||||
bubbleFor={bubbleFor}
|
||||
leaderboard={leaderboard}
|
||||
agentProfilesByAgent={agentProfilesByAgent}
|
||||
feed={feed}
|
||||
onJumpToMessage={handleJumpToMessage}
|
||||
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Stock Explain View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载个股分析..." />}>
|
||||
<StockExplainView
|
||||
tickers={displayTickers}
|
||||
holdings={holdings}
|
||||
trades={trades}
|
||||
leaderboard={leaderboard}
|
||||
feed={feed}
|
||||
priceHistoryByTicker={priceHistoryByTicker}
|
||||
ohlcHistoryByTicker={ohlcHistoryByTicker}
|
||||
selectedSymbol={selectedExplainSymbol}
|
||||
onSelectedSymbolChange={onSelectedExplainSymbolChange}
|
||||
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
||||
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
||||
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
|
||||
insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null}
|
||||
technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null}
|
||||
onRequestHistory={stockRequests?.requestStockHistory}
|
||||
onRequestExplainEvents={stockRequests?.requestStockExplainEvents}
|
||||
onRequestNews={stockRequests?.requestStockNews}
|
||||
onRequestRangeExplain={stockRequests?.requestStockRangeExplain}
|
||||
onRequestNewsForDate={stockRequests?.requestStockNewsForDate}
|
||||
onRequestStory={stockRequests?.requestStockStory}
|
||||
onRequestInsiderTrades={stockRequests?.requestStockInsiderTrades}
|
||||
onRequestTechnicalIndicators={stockRequests?.requestStockTechnicalIndicators}
|
||||
currentDate={currentDate}
|
||||
onRequestSimilarDays={stockRequests?.requestStockSimilarDays}
|
||||
onRequestStockEnrich={stockRequests?.requestStockEnrich}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Chart View Panel */}
|
||||
<div className="view-panel">
|
||||
<div className="chart-container">
|
||||
<div className="chart-tabs-floating">
|
||||
<button
|
||||
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setChartTab('all')}
|
||||
>
|
||||
日线
|
||||
</button>
|
||||
</div>
|
||||
{currentView === 'chart' ? (
|
||||
<NetValueChart
|
||||
equity={portfolioData.equity}
|
||||
baseline={portfolioData.baseline}
|
||||
baseline_vw={portfolioData.baseline_vw}
|
||||
momentum={portfolioData.momentum}
|
||||
strategies={portfolioData.strategies}
|
||||
equity_return={portfolioData.equity_return}
|
||||
baseline_return={portfolioData.baseline_return}
|
||||
baseline_vw_return={portfolioData.baseline_vw_return}
|
||||
momentum_return={portfolioData.momentum_return}
|
||||
chartTab={chartTab}
|
||||
virtualTime={virtualTime}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ height: '100%', minHeight: 320 }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载统计视图..." />}>
|
||||
<StatisticsView
|
||||
trades={trades}
|
||||
holdings={holdings}
|
||||
stats={stats}
|
||||
portfolioData={portfolioData}
|
||||
baseline_vw={portfolioData.baseline_vw}
|
||||
equity={portfolioData.equity}
|
||||
leaderboard={leaderboard}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizer */}
|
||||
<div className={`resizer ${isResizing ? 'resizing' : ''}`} onMouseDown={onMouseDown} />
|
||||
|
||||
{/* Right Panel: Agent Feed */}
|
||||
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
|
||||
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} agentProfilesByAgent={agentProfilesByAgent} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import { formatNumber, formatFullNumber } from '../utils/formatters';
|
||||
/**
|
||||
* Helper function to get the start time of the most recent trading session
|
||||
* 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) {
|
||||
// Use virtual time if provided (for mock mode), otherwise use real time
|
||||
// Use virtual time if provided, otherwise use real time
|
||||
let now;
|
||||
if (virtualTime) {
|
||||
// Ensure virtualTime is a valid Date object
|
||||
|
||||
@@ -47,7 +47,7 @@ function getRankMedal(rank) {
|
||||
* Supports click and hover (1.5s) to show agent performance cards
|
||||
* Supports replay mode - completely independent from live mode
|
||||
*/
|
||||
export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJumpToMessage, onOpenLaunchConfig }) {
|
||||
export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfilesByAgent, feed, onJumpToMessage, onOpenLaunchConfig }) {
|
||||
const canvasRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
@@ -162,11 +162,14 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
||||
const getAgentData = (agentId) => {
|
||||
const agent = AGENTS.find(a => a.id === agentId);
|
||||
if (!agent) return null;
|
||||
const profile = agentProfilesByAgent?.[agentId] || null;
|
||||
|
||||
// If no leaderboard data, return agent with default stats
|
||||
if (!leaderboard || !Array.isArray(leaderboard)) {
|
||||
return {
|
||||
...agent,
|
||||
modelName: profile?.model_name || null,
|
||||
modelProvider: profile?.model_provider || null,
|
||||
bull: { n: 0, win: 0, unknown: 0 },
|
||||
bear: { n: 0, win: 0, unknown: 0 },
|
||||
winRate: null,
|
||||
@@ -181,6 +184,8 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
||||
if (!leaderboardData) {
|
||||
return {
|
||||
...agent,
|
||||
modelName: profile?.model_name || null,
|
||||
modelProvider: profile?.model_provider || null,
|
||||
bull: { n: 0, win: 0, unknown: 0 },
|
||||
bear: { n: 0, win: 0, unknown: 0 },
|
||||
winRate: null,
|
||||
@@ -193,6 +198,8 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
||||
return {
|
||||
...agent,
|
||||
...leaderboardData,
|
||||
modelName: profile?.model_name || leaderboardData.modelName || null,
|
||||
modelProvider: profile?.model_provider || leaderboardData.modelProvider || null,
|
||||
avatar: agent.avatar // Always use the frontend's avatar URL
|
||||
};
|
||||
};
|
||||
|
||||
190
frontend/src/components/RuntimeLogsModal.jsx
Normal file
190
frontend/src/components/RuntimeLogsModal.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export default function RuntimeLogsModal({
|
||||
isOpen,
|
||||
isLoading,
|
||||
logPayload,
|
||||
error,
|
||||
onClose,
|
||||
onRefresh
|
||||
}) {
|
||||
const logRef = useRef(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [followTail, setFollowTail] = useState(true);
|
||||
|
||||
const refreshIntervalMs = useMemo(() => 2000, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !autoRefresh) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timerId = window.setInterval(() => {
|
||||
onRefresh();
|
||||
}, refreshIntervalMs);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [autoRefresh, isOpen, onRefresh, refreshIntervalMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !followTail || !logRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}, [followTail, isOpen, logPayload?.content]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.32)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
zIndex: 10000
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(980px, 94vw)',
|
||||
maxHeight: '82vh',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 16,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto auto minmax(0, 1fr)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '18px 20px 10px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>运行日志</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
{logPayload?.run_id ? `任务 ${logPayload.run_id}` : '当前运行任务'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #111111',
|
||||
background: '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '0 20px 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
|
||||
{logPayload?.log_path || '未找到日志文件'}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div style={{ fontSize: 11, color: '#2563EB', fontWeight: 700 }}>加载中...</div>
|
||||
) : error ? (
|
||||
<div style={{ fontSize: 11, color: '#B91C1C', fontWeight: 700 }}>{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '0 20px 12px',
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(event) => setAutoRefresh(event.target.checked)}
|
||||
/>
|
||||
实时刷新
|
||||
</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={followTail}
|
||||
onChange={(event) => setFollowTail(event.target.checked)}
|
||||
/>
|
||||
自动滚底
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 20px 20px', minHeight: 0 }}>
|
||||
<pre
|
||||
ref={logRef}
|
||||
style={{
|
||||
margin: 0,
|
||||
height: '100%',
|
||||
minHeight: 320,
|
||||
maxHeight: 'calc(82vh - 140px)',
|
||||
overflow: 'auto',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#0F172A',
|
||||
color: '#E2E8F0',
|
||||
padding: 16,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: '"SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{logPayload?.content || '暂无日志输出'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const formatHistorySummary = (run) => {
|
||||
const updatedAt = run?.updated_at ? String(run.updated_at).replace("T", " ").slice(0, 16) : "未知时间";
|
||||
const mode = run?.bootstrap?.mode ? String(run.bootstrap.mode).toUpperCase() : "LIVE";
|
||||
const tickers = Array.isArray(run?.bootstrap?.tickers) ? run.bootstrap.tickers.length : 0;
|
||||
const assetValue = Number(run?.total_asset_value ?? 0).toFixed(2);
|
||||
const trades = Number(run?.total_trades ?? 0);
|
||||
return `${run.run_id} · ${updatedAt} · ${mode} · ${tickers}标的 · ${trades}笔交易 · $${assetValue}`;
|
||||
};
|
||||
|
||||
export default function RuntimeSettingsPanel({
|
||||
showTrigger = true,
|
||||
isOpen,
|
||||
isConnected,
|
||||
isSaving,
|
||||
feedback,
|
||||
launchMode,
|
||||
restoreRunId,
|
||||
runtimeHistoryRuns,
|
||||
scheduleMode,
|
||||
intervalMinutes,
|
||||
triggerTime,
|
||||
@@ -18,13 +30,14 @@ export default function RuntimeSettingsPanel({
|
||||
pollInterval,
|
||||
startDate,
|
||||
endDate,
|
||||
enableMock,
|
||||
watchlistSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistSuggestions,
|
||||
onToggle,
|
||||
onClose,
|
||||
onScheduleModeChange,
|
||||
onLaunchModeChange,
|
||||
onRestoreRunIdChange,
|
||||
onIntervalMinutesChange,
|
||||
onTriggerTimeChange,
|
||||
onMaxCommCyclesChange,
|
||||
@@ -35,7 +48,6 @@ export default function RuntimeSettingsPanel({
|
||||
onPollIntervalChange,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onEnableMockChange,
|
||||
onWatchlistInputChange,
|
||||
onWatchlistInputKeyDown,
|
||||
onWatchlistAdd,
|
||||
@@ -142,6 +154,75 @@ export default function RuntimeSettingsPanel({
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>启动形式</div>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>任务模式</span>
|
||||
<select
|
||||
value={launchMode}
|
||||
onChange={(e) => onLaunchModeChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="fresh">重新启动</option>
|
||||
<option value="restore">从历史任务恢复</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{launchMode === 'restore' && (
|
||||
<>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>历史任务</span>
|
||||
<select
|
||||
value={restoreRunId}
|
||||
onChange={(e) => onRestoreRunIdChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="">请选择历史任务</option>
|
||||
{runtimeHistoryRuns.map((run) => (
|
||||
<option key={run.run_id} value={run.run_id}>
|
||||
{formatHistorySummary(run)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#6B7280',
|
||||
lineHeight: 1.6,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
background: '#FFFFFF',
|
||||
border: '1px dashed #D0D7DE'
|
||||
}}>
|
||||
恢复启动会从所选历史任务复制运行状态、组合、交易记录和 Agent 工作区资产,并以新的任务 ID 继续运行。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{launchMode === 'fresh' && (
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>自选股</div>
|
||||
|
||||
<div style={{
|
||||
@@ -272,16 +353,18 @@ export default function RuntimeSettingsPanel({
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
{launchMode === 'fresh' && (
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>调度参数</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
@@ -495,22 +578,8 @@ export default function RuntimeSettingsPanel({
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableMock}
|
||||
onChange={(e) => onEnableMockChange(e.target.checked)}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
accentColor: '#0D47A1',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用模拟数据 (Mock)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
|
||||
@@ -34,6 +34,18 @@ const EVENT_FILTER_OPTIONS = [
|
||||
{ value: 'approval', label: '审批事件' }
|
||||
];
|
||||
|
||||
const SR_ONLY_STYLE = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
function metricCard(label, value, accent, helper = null) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
@@ -722,6 +734,9 @@ export default function RuntimeView() {
|
||||
{sectionTitle(
|
||||
'近期事件',
|
||||
<select
|
||||
id="runtime-event-filter"
|
||||
name="runtime_event_filter"
|
||||
aria-label="筛选近期事件"
|
||||
value={eventFilter}
|
||||
onChange={(event) => setEventFilter(event.target.value)}
|
||||
style={{
|
||||
@@ -739,6 +754,9 @@ export default function RuntimeView() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label htmlFor="runtime-event-filter" style={SR_ONLY_STYLE}>
|
||||
筛选近期事件
|
||||
</label>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
|
||||
@@ -8,12 +8,36 @@ import { formatNumber, formatDateTime } from '../utils/formatters';
|
||||
* Left: Performance Overview (35%) | Right: Holdings + Trades (65%)
|
||||
* No scrolling - content fits within viewport with pagination
|
||||
*/
|
||||
export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard }) {
|
||||
export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard, portfolioData }) {
|
||||
const [holdingsPage, setHoldingsPage] = useState(1);
|
||||
const [tradesPage, setTradesPage] = useState(1);
|
||||
const holdingsPerPage = 5;
|
||||
const tradesPerPage = 8;
|
||||
|
||||
const effectiveStats = React.useMemo(() => {
|
||||
const base = stats && typeof stats === 'object' ? stats : {};
|
||||
const netValue = Number(portfolioData?.netValue ?? 0);
|
||||
const pnl = Number(portfolioData?.pnl ?? 0);
|
||||
const hasPortfolioValue = Number.isFinite(netValue) && netValue > 0;
|
||||
const hasMeaningfulStats = Number(base?.totalAssetValue ?? 0) > 0;
|
||||
|
||||
if (hasMeaningfulStats || !hasPortfolioValue) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const cashHolding = Array.isArray(holdings)
|
||||
? holdings.find((item) => String(item?.ticker || '').toUpperCase() === 'CASH')
|
||||
: null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
totalAssetValue: netValue,
|
||||
totalReturn: pnl,
|
||||
cashPosition: Number(cashHolding?.marketValue ?? cashHolding?.currentPrice ?? 0),
|
||||
totalTrades: Array.isArray(trades) ? trades.length : 0,
|
||||
};
|
||||
}, [holdings, portfolioData, stats, trades]);
|
||||
|
||||
// Calculate pagination for holdings
|
||||
const totalHoldingsPages = Math.ceil(holdings.length / holdingsPerPage);
|
||||
const holdingsStartIndex = (holdingsPage - 1) * holdingsPerPage;
|
||||
@@ -28,12 +52,12 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
||||
|
||||
// Calculate excess return (Evatraders return - benchmark value-weighted return)
|
||||
const calculateExcessReturn = () => {
|
||||
if (!stats || !baseline_vw || baseline_vw.length === 0) {
|
||||
if (!effectiveStats || !baseline_vw || baseline_vw.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get Evatraders return from stats
|
||||
const evatradersReturn = stats.totalReturn || 0; // Already in percentage
|
||||
const evatradersReturn = effectiveStats.totalReturn || 0; // Already in percentage
|
||||
|
||||
// Calculate benchmark return from baseline_vw
|
||||
// baseline_vw format: [{t: timestamp, v: value}, ...] or [value, ...]
|
||||
@@ -130,7 +154,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
||||
borderRight: '2px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{stats ? (
|
||||
{effectiveStats ? (
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
@@ -179,7 +203,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
||||
fontFamily: '"Courier New", monospace',
|
||||
lineHeight: 1
|
||||
}}>
|
||||
${formatNumber(stats.totalAssetValue || 0)}
|
||||
${formatNumber(effectiveStats.totalAssetValue || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -272,10 +296,10 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
||||
<div style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: (stats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
|
||||
color: (effectiveStats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{(stats.totalReturn || 0) >= 0 ? '+' : ''}{(stats.totalReturn || 0).toFixed(2)}%
|
||||
{(effectiveStats.totalReturn || 0) >= 0 ? '+' : ''}{(effectiveStats.totalReturn || 0).toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,7 +328,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
||||
color: '#000000',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
${formatNumber(stats.cashPosition || 0)}
|
||||
${formatNumber(effectiveStats.cashPosition || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -330,13 +354,13 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
||||
color: '#000000',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{stats.totalTrades || 0}
|
||||
{effectiveStats.totalTrades || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticker Weights - Compact */}
|
||||
{stats.tickerWeights && Object.keys(stats.tickerWeights).length > 0 && (
|
||||
{effectiveStats?.tickerWeights && Object.keys(effectiveStats.tickerWeights).length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
paddingTop: 20,
|
||||
@@ -358,7 +382,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
||||
gap: 8,
|
||||
maxHeight: 120
|
||||
}}>
|
||||
{Object.entries(stats.tickerWeights).map(([ticker, weight]) => {
|
||||
{Object.entries(effectiveStats.tickerWeights).map(([ticker, weight]) => {
|
||||
const weightValue = Number(weight);
|
||||
const isNegative = weightValue < 0;
|
||||
const displayWeight = (weightValue * 100).toFixed(1);
|
||||
|
||||
@@ -33,6 +33,9 @@ export default function StockExplainView({
|
||||
insiderTradesSnapshot,
|
||||
technicalIndicatorsSnapshot,
|
||||
onRequestRangeExplain,
|
||||
onRequestHistory,
|
||||
onRequestExplainEvents,
|
||||
onRequestNews,
|
||||
onRequestNewsForDate,
|
||||
onRequestStory,
|
||||
onRequestInsiderTrades,
|
||||
@@ -142,11 +145,37 @@ export default function StockExplainView({
|
||||
setActiveNewsSentiment('all');
|
||||
}, [selectedSymbol, selectedEventDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSymbol) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onRequestHistory && (!Array.isArray(ohlcHistoryByTicker?.[selectedSymbol]) || ohlcHistoryByTicker[selectedSymbol].length === 0)) {
|
||||
onRequestHistory(selectedSymbol);
|
||||
}
|
||||
|
||||
if (onRequestExplainEvents && !explainEventsSnapshot) {
|
||||
onRequestExplainEvents(selectedSymbol);
|
||||
}
|
||||
|
||||
if (onRequestNews && (!Array.isArray(newsSnapshot?.items) || newsSnapshot.items.length === 0)) {
|
||||
onRequestNews(selectedSymbol);
|
||||
}
|
||||
}, [
|
||||
explainEventsSnapshot,
|
||||
newsSnapshot,
|
||||
ohlcHistoryByTicker,
|
||||
onRequestExplainEvents,
|
||||
onRequestHistory,
|
||||
onRequestNews,
|
||||
selectedSymbol,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSymbol || !selectedEventDate || !onRequestNewsForDate) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(newsSnapshot?.byDate?.[selectedEventDate]) && newsSnapshot.byDate[selectedEventDate].length > 0) {
|
||||
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.byDate || {}, selectedEventDate)) {
|
||||
return;
|
||||
}
|
||||
onRequestNewsForDate(selectedSymbol, selectedEventDate);
|
||||
@@ -156,21 +185,21 @@ export default function StockExplainView({
|
||||
if (!selectedSymbol || !onRequestStory || !currentDate) {
|
||||
return;
|
||||
}
|
||||
if (selectedStory?.story) {
|
||||
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.storyCache || {}, currentDate)) {
|
||||
return;
|
||||
}
|
||||
onRequestStory(selectedSymbol, currentDate);
|
||||
}, [currentDate, onRequestStory, selectedStory, selectedSymbol]);
|
||||
}, [currentDate, newsSnapshot, onRequestStory, selectedStory, selectedSymbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSymbol || !selectedEventDate || !onRequestSimilarDays) {
|
||||
return;
|
||||
}
|
||||
if (selectedSimilarDays?.items?.length) {
|
||||
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.similarDaysCache || {}, selectedEventDate)) {
|
||||
return;
|
||||
}
|
||||
onRequestSimilarDays(selectedSymbol, selectedEventDate);
|
||||
}, [onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
|
||||
}, [newsSnapshot, onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSymbol || !onRequestTechnicalIndicators) {
|
||||
|
||||
@@ -38,6 +38,18 @@ export default function TraderView({
|
||||
onWorkspaceFileSave,
|
||||
onUploadExternalSkill
|
||||
}) {
|
||||
const srOnlyStyle = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
const [expandedSkillKey, setExpandedSkillKey] = useState(null);
|
||||
const [newLocalSkillName, setNewLocalSkillName] = useState('');
|
||||
const [externalSkillFile, setExternalSkillFile] = useState(null);
|
||||
@@ -460,6 +472,9 @@ export default function TraderView({
|
||||
本地技能 SKILL.md
|
||||
</div>
|
||||
<textarea
|
||||
id={`local-skill-${selectedAgentId}-${skill.skill_name}`}
|
||||
name={`local_skill_${selectedAgentId}_${skill.skill_name}`}
|
||||
aria-label={`${skill.skill_name} 本地技能内容`}
|
||||
value={skillDraft}
|
||||
onChange={(e) => onLocalSkillDraftChange(skill.skill_name, e.target.value)}
|
||||
style={{
|
||||
@@ -557,6 +572,9 @@ export default function TraderView({
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={`workspace-editor-${selectedAgentId}-${selectedWorkspaceFile || 'file'}`}
|
||||
name={`workspace_editor_${selectedAgentId}_${selectedWorkspaceFile || 'file'}`}
|
||||
aria-label={`编辑 ${selectedWorkspaceFile || '工作区文件'} 内容`}
|
||||
value={workspaceDraftContent}
|
||||
onChange={(e) => onWorkspaceDraftChange(e.target.value)}
|
||||
placeholder={isWorkspaceFileLoading ? '加载中...' : '输入 markdown 内容'}
|
||||
@@ -687,7 +705,13 @@ export default function TraderView({
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>创建本地技能</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label htmlFor="new-local-skill-name" style={srOnlyStyle}>
|
||||
输入本地技能名称
|
||||
</label>
|
||||
<input
|
||||
id="new-local-skill-name"
|
||||
name="new_local_skill_name"
|
||||
aria-label="输入本地技能名称"
|
||||
value={newLocalSkillName}
|
||||
onChange={(e) => setNewLocalSkillName(e.target.value)}
|
||||
placeholder="输入技能名,例如 event_playbook"
|
||||
@@ -741,7 +765,13 @@ export default function TraderView({
|
||||
支持上传 .zip(包内需包含一个技能目录及 SKILL.md)
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="external-skill-zip" style={srOnlyStyle}>
|
||||
上传外部技能 zip 包
|
||||
</label>
|
||||
<input
|
||||
id="external-skill-zip"
|
||||
name="external_skill_zip"
|
||||
aria-label="上传外部技能 zip 包"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={async (e) => {
|
||||
|
||||
@@ -19,6 +19,18 @@ export default function WatchlistPanel({
|
||||
onSuggestionClick,
|
||||
onSave
|
||||
}) {
|
||||
const srOnlyStyle = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
|
||||
<button
|
||||
@@ -117,7 +129,13 @@ export default function WatchlistPanel({
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<label htmlFor="watchlist-symbol-input" style={srOnlyStyle}>
|
||||
输入股票代码
|
||||
</label>
|
||||
<input
|
||||
id="watchlist-symbol-input"
|
||||
name="watchlist_symbol"
|
||||
aria-label="输入股票代码"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
|
||||
@@ -11,6 +11,37 @@ export default function ExplainPriceSection({
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
const timeTicks = (() => {
|
||||
const candles = Array.isArray(chartModel?.candles) ? chartModel.candles : [];
|
||||
if (!candles.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetCount = Math.min(4, candles.length);
|
||||
const step = Math.max(1, Math.floor((candles.length - 1) / Math.max(targetCount - 1, 1)));
|
||||
const ticks = [];
|
||||
|
||||
for (let index = 0; index < candles.length; index += step) {
|
||||
const candle = candles[index];
|
||||
const rawLabel = candle.startLabel || candle.time || candle.date || '';
|
||||
ticks.push({
|
||||
x: candle.centerX,
|
||||
label: String(rawLabel).slice(5, 16).replace('T', ' '),
|
||||
});
|
||||
}
|
||||
|
||||
const lastCandle = candles[candles.length - 1];
|
||||
const lastLabel = String(lastCandle.endLabel || lastCandle.time || lastCandle.date || '').slice(5, 16).replace('T', ' ');
|
||||
if (ticks.length === 0 || ticks[ticks.length - 1]?.x !== lastCandle.centerX) {
|
||||
ticks.push({
|
||||
x: lastCandle.centerX,
|
||||
label: lastLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return ticks;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
@@ -66,12 +97,35 @@ export default function ExplainPriceSection({
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{timeTicks.map((tick) => (
|
||||
<g key={`${tick.x}-${tick.label}`}>
|
||||
<line
|
||||
x1={tick.x}
|
||||
y1={chartModel.height - chartModel.padding}
|
||||
x2={tick.x}
|
||||
y2={chartModel.height - chartModel.padding + 4}
|
||||
stroke="#666666"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={tick.x}
|
||||
y={chartModel.height - chartModel.padding + 16}
|
||||
fontSize="10"
|
||||
fill="#666666"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{chartModel.candles.length > 1 ? chartModel.candles.map((candle) => {
|
||||
const rising = candle.close >= candle.open;
|
||||
const stroke = rising ? '#00C853' : '#FF1744';
|
||||
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
|
||||
return (
|
||||
<g key={candle.id}>
|
||||
<title>{`${candle.startLabel || candle.time || candle.date || ''} → ${candle.endLabel || candle.time || candle.date || ''}`}</title>
|
||||
<line
|
||||
x1={candle.centerX}
|
||||
y1={candle.highY}
|
||||
@@ -123,7 +177,7 @@ export default function ExplainPriceSection({
|
||||
stroke={marker.isSelected ? '#111111' : '#ffffff'}
|
||||
strokeWidth={marker.isSelected ? '2.5' : '2'}
|
||||
/>
|
||||
<title>{`${marker.title} · ${marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||
<title>{`${marker.title} · ${marker.timestamp || marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
388
frontend/src/hooks/useAgentDataRequests.js
Normal file
388
frontend/src/hooks/useAgentDataRequests.js
Normal file
@@ -0,0 +1,388 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
createAgentLocalSkill,
|
||||
deleteAgentLocalSkill,
|
||||
disableAgentSkill,
|
||||
enableAgentSkill,
|
||||
fetchAgentProfile,
|
||||
fetchAgentSkillDetail,
|
||||
fetchAgentSkills,
|
||||
fetchAgentWorkspaceFile,
|
||||
fetchCurrentRuntime,
|
||||
updateAgentLocalSkill,
|
||||
updateAgentWorkspaceFile,
|
||||
uploadAgentSkillZip
|
||||
} from '../services/runtimeApi';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
|
||||
/**
|
||||
* Custom hook for agent operation callbacks.
|
||||
* Takes clientRef, uses agentStore.
|
||||
*/
|
||||
export function useAgentDataRequests(clientRef) {
|
||||
const {
|
||||
selectedSkillAgentId,
|
||||
setSelectedSkillAgentId,
|
||||
setAgentProfilesByAgent,
|
||||
setIsAgentSkillsLoading,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey,
|
||||
setSkillDetailLoadingKey,
|
||||
setAgentSkillsByAgent,
|
||||
setSkillDetailsByName,
|
||||
localSkillDraftsByKey,
|
||||
selectedWorkspaceFile,
|
||||
setWorkspaceFilesByAgent,
|
||||
setWorkspaceDraftContent,
|
||||
workspaceDraftContent,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey,
|
||||
setIsWorkspaceFileLoading
|
||||
} = useAgentStore();
|
||||
|
||||
const resolveWorkspaceId = useCallback(async () => {
|
||||
const runtime = await fetchCurrentRuntime();
|
||||
const workspaceId = runtime?.run_id;
|
||||
if (!workspaceId) {
|
||||
throw new Error('未检测到正在运行的任务');
|
||||
}
|
||||
return workspaceId;
|
||||
}, []);
|
||||
|
||||
const requestAgentSkills = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
if (!normalized) return false;
|
||||
setIsAgentSkillsLoading(true);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => fetchAgentSkills(workspaceId, normalized))
|
||||
.then((payload) => {
|
||||
setAgentSkillsByAgent((prev) => ({ ...prev, [normalized]: Array.isArray(payload?.skills) ? payload.skills : [] }));
|
||||
setIsAgentSkillsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setIsAgentSkillsLoading(false);
|
||||
return;
|
||||
}
|
||||
console.debug('REST agent skills request failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized });
|
||||
if (!success) {
|
||||
setIsAgentSkillsLoading(false);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}, [clientRef, resolveWorkspaceId, setAgentSkillsByAgent, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
|
||||
|
||||
const requestAgentProfile = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
if (!normalized) return false;
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => fetchAgentProfile(workspaceId, normalized))
|
||||
.then((payload) => {
|
||||
setAgentProfilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[normalized]: payload?.profile && typeof payload.profile === 'object' ? payload.profile : {}
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (clientRef.current) {
|
||||
console.debug('REST agent profile request failed, falling back to websocket compatibility path');
|
||||
clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}, [clientRef, resolveWorkspaceId, setAgentProfilesByAgent]);
|
||||
|
||||
const requestSkillDetail = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
|
||||
if (!normalized) return false;
|
||||
const detailKey = `${selectedSkillAgentId}:${normalized}`;
|
||||
setSkillDetailLoadingKey(detailKey);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => fetchAgentSkillDetail(workspaceId, selectedSkillAgentId, normalized))
|
||||
.then((payload) => {
|
||||
setSkillDetailsByName((prev) => ({ ...prev, [detailKey]: payload?.skill || null }));
|
||||
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({
|
||||
...prev,
|
||||
[detailKey]: typeof payload?.skill?.content === 'string' ? payload.skill.content : ''
|
||||
}));
|
||||
setSkillDetailLoadingKey(null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setSkillDetailLoadingKey(null);
|
||||
return;
|
||||
}
|
||||
console.debug('REST skill detail request failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized });
|
||||
if (!success) {
|
||||
setSkillDetailLoadingKey(null);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}, [clientRef, resolveWorkspaceId, selectedSkillAgentId, setSkillDetailLoadingKey, setSkillDetailsByName]);
|
||||
|
||||
const handleCreateLocalSkill = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
|
||||
if (!normalized) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => createAgentLocalSkill(workspaceId, selectedSkillAgentId, normalized))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `已创建本地技能 ${normalized}` });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
requestSkillDetail(normalized);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST local skill create failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, requestAgentSkills, requestSkillDetail, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({ ...prev, [detailKey]: content }));
|
||||
}, [selectedSkillAgentId]);
|
||||
|
||||
const handleLocalSkillSave = useCallback((skillName) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
const content = localSkillDraftsByKey[detailKey];
|
||||
if (typeof content !== 'string') return;
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => updateAgentLocalSkill(workspaceId, selectedSkillAgentId, skillName, content))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 的本地技能 ${skillName} 已保存` });
|
||||
requestSkillDetail(skillName);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST local skill save failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'update_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName, content });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, localSkillDraftsByKey, requestSkillDetail, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDelete = useCallback((skillName) => {
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => deleteAgentLocalSkill(workspaceId, selectedSkillAgentId, skillName))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 的本地技能 ${skillName} 已删除` });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST local skill delete failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleRemoveSharedSkill = useCallback((skillName) => {
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => disableAgentSkill(workspaceId, selectedSkillAgentId, skillName))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 已移除共享技能 ${skillName}` });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST shared skill remove failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
|
||||
const agentId = selectedSkillAgentId;
|
||||
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => enabled
|
||||
? enableAgentSkill(workspaceId, agentId, skillName)
|
||||
: disableAgentSkill(workspaceId, agentId, skillName))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} ${enabled ? '已启用' : '已禁用'} ${skillName}` });
|
||||
requestAgentSkills(agentId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST skill toggle failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleSkillAgentChange = useCallback((agentId) => {
|
||||
setSelectedSkillAgentId(agentId);
|
||||
requestAgentProfile(agentId);
|
||||
requestAgentSkills(agentId);
|
||||
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||
}, [requestAgentProfile, requestAgentSkills, setSelectedSkillAgentId, selectedWorkspaceFile]);
|
||||
|
||||
const requestWorkspaceFile = useCallback((agentId, filename) => {
|
||||
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
const normalizedFilename = typeof filename === 'string' ? filename.trim() : '';
|
||||
if (!normalizedAgentId || !normalizedFilename) return false;
|
||||
setIsWorkspaceFileLoading(true);
|
||||
setWorkspaceFileFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => fetchAgentWorkspaceFile(workspaceId, normalizedAgentId, normalizedFilename))
|
||||
.then((payload) => {
|
||||
setWorkspaceFilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[normalizedAgentId]: {
|
||||
...(prev[normalizedAgentId] || {}),
|
||||
[normalizedFilename]: typeof payload?.content === 'string' ? payload.content : ''
|
||||
}
|
||||
}));
|
||||
setWorkspaceDraftContent(typeof payload?.content === 'string' ? payload.content : '');
|
||||
setIsWorkspaceFileLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setIsWorkspaceFileLoading(false);
|
||||
return;
|
||||
}
|
||||
console.debug('REST workspace file read failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename });
|
||||
if (!success) {
|
||||
setIsWorkspaceFileLoading(false);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}, [clientRef, resolveWorkspaceId, setIsWorkspaceFileLoading, setWorkspaceDraftContent, setWorkspaceFileFeedback, setWorkspaceFilesByAgent]);
|
||||
|
||||
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||
useAgentStore.getState().setSelectedWorkspaceFile(filename);
|
||||
requestWorkspaceFile(selectedSkillAgentId, filename);
|
||||
}, [requestWorkspaceFile, selectedSkillAgentId]);
|
||||
|
||||
const handleWorkspaceFileSave = useCallback(() => {
|
||||
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
|
||||
setWorkspaceFileSavingKey(key);
|
||||
setWorkspaceFileFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => updateAgentWorkspaceFile(workspaceId, selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent))
|
||||
.then((payload) => {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: 'success', text: `${selectedSkillAgentId} 的 ${selectedWorkspaceFile} 已保存` });
|
||||
setWorkspaceFilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[selectedSkillAgentId]: {
|
||||
...(prev[selectedSkillAgentId] || {}),
|
||||
[selectedWorkspaceFile]: typeof payload?.content === 'string' ? payload.content : workspaceDraftContent
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST workspace file save failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({
|
||||
type: 'update_agent_workspace_file',
|
||||
agent_id: selectedSkillAgentId,
|
||||
filename: selectedWorkspaceFile,
|
||||
content: workspaceDraftContent
|
||||
});
|
||||
if (!success) {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, resolveWorkspaceId, selectedSkillAgentId, selectedWorkspaceFile, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, workspaceDraftContent]);
|
||||
|
||||
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '请选择 zip 文件后再上传' });
|
||||
return;
|
||||
}
|
||||
if (!selectedSkillAgentId) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '未选择目标 Agent' });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||
setAgentSkillsFeedback(null);
|
||||
try {
|
||||
const result = await uploadAgentSkillZip({ agentId: selectedSkillAgentId, file, activate: true });
|
||||
setAgentSkillsFeedback({ type: 'success', text: `已上传并安装技能 ${result.skill_name || ''}`.trim() });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
} catch (error) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: `上传失败: ${error.message || '未知错误'}` });
|
||||
} finally {
|
||||
setAgentSkillsSavingKey(null);
|
||||
}
|
||||
}, [requestAgentSkills, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
return {
|
||||
requestAgentSkills,
|
||||
requestAgentProfile,
|
||||
requestSkillDetail,
|
||||
handleCreateLocalSkill,
|
||||
handleLocalSkillDraftChange,
|
||||
handleLocalSkillSave,
|
||||
handleLocalSkillDelete,
|
||||
handleRemoveSharedSkill,
|
||||
handleAgentSkillToggle,
|
||||
handleSkillAgentChange,
|
||||
requestWorkspaceFile,
|
||||
handleWorkspaceFileChange,
|
||||
handleWorkspaceFileSave,
|
||||
handleUploadExternalSkill
|
||||
};
|
||||
}
|
||||
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { AGENTS } from "../config/constants";
|
||||
import { uploadAgentSkillZip } from "../services/runtimeApi";
|
||||
|
||||
export function useAgentWorkspacePanel({
|
||||
clientRef,
|
||||
currentView,
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
selectedWorkspaceContent,
|
||||
localSkillDraftsByKey,
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
workspaceFilesByAgent,
|
||||
workspaceDraftContent,
|
||||
setSelectedSkillAgentId,
|
||||
setSelectedWorkspaceFile,
|
||||
setWorkspaceDraftContent,
|
||||
setIsAgentSkillsLoading,
|
||||
setAgentSkillsFeedback,
|
||||
setSkillDetailLoadingKey,
|
||||
setAgentSkillsSavingKey,
|
||||
setLocalSkillDraftsByKey,
|
||||
setIsWorkspaceFileLoading,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey
|
||||
}) {
|
||||
const sendWithRetry = useCallback((payload, retries = 3, delayMs = 250) => {
|
||||
const attemptSend = (remaining) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
const sent = client.send(payload);
|
||||
if (sent || remaining <= 0) {
|
||||
return sent;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
attemptSend(remaining - 1);
|
||||
}, delayMs);
|
||||
return false;
|
||||
};
|
||||
|
||||
return attemptSend(retries);
|
||||
}, [clientRef]);
|
||||
|
||||
const requestAgentSkills = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
setIsAgentSkillsLoading(true);
|
||||
setAgentSkillsFeedback(null);
|
||||
return sendWithRetry({
|
||||
type: "get_agent_skills",
|
||||
agent_id: normalized
|
||||
});
|
||||
}, [clientRef, sendWithRetry, setAgentSkillsFeedback, setIsAgentSkillsLoading]);
|
||||
|
||||
const requestAgentProfile = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return sendWithRetry({
|
||||
type: "get_agent_profile",
|
||||
agent_id: normalized
|
||||
});
|
||||
}, [clientRef, sendWithRetry]);
|
||||
|
||||
const requestSkillDetail = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === "string" ? skillName.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
const detailKey = `${selectedSkillAgentId}:${normalized}`;
|
||||
setSkillDetailLoadingKey(detailKey);
|
||||
return sendWithRetry({
|
||||
type: "get_skill_detail",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: normalized
|
||||
});
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setSkillDetailLoadingKey]);
|
||||
|
||||
const handleCreateLocalSkill = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === "string" ? skillName.trim() : "";
|
||||
if (!normalized) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "技能名称不能为空" });
|
||||
return;
|
||||
}
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "create_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: normalized
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
setLocalSkillDraftsByKey((prev) => ({
|
||||
...prev,
|
||||
[detailKey]: content
|
||||
}));
|
||||
}, [selectedSkillAgentId, setLocalSkillDraftsByKey]);
|
||||
|
||||
const handleLocalSkillSave = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
const content = localSkillDraftsByKey[detailKey];
|
||||
if (typeof content !== "string") {
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName,
|
||||
content
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
localSkillDraftsByKey,
|
||||
selectedSkillAgentId,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey
|
||||
]);
|
||||
|
||||
const handleLocalSkillDelete = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "delete_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleRemoveSharedSkill = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "remove_agent_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const requestWorkspaceFile = useCallback((agentId, filename) => {
|
||||
const normalizedAgentId = typeof agentId === "string" ? agentId.trim() : "";
|
||||
const normalizedFilename = typeof filename === "string" ? filename.trim() : "";
|
||||
if (!normalizedAgentId || !normalizedFilename || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
setIsWorkspaceFileLoading(true);
|
||||
setWorkspaceFileFeedback(null);
|
||||
return sendWithRetry({
|
||||
type: "get_agent_workspace_file",
|
||||
agent_id: normalizedAgentId,
|
||||
filename: normalizedFilename
|
||||
});
|
||||
}, [clientRef, sendWithRetry, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
|
||||
|
||||
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = selectedSkillAgentId;
|
||||
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_skill",
|
||||
agent_id: agentId,
|
||||
skill_name: skillName,
|
||||
enabled
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleSkillAgentChange = useCallback((agentId) => {
|
||||
setSelectedSkillAgentId(agentId);
|
||||
requestAgentProfile(agentId);
|
||||
requestAgentSkills(agentId);
|
||||
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||
}, [
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
selectedWorkspaceFile,
|
||||
setSelectedSkillAgentId
|
||||
]);
|
||||
|
||||
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||
setSelectedWorkspaceFile(filename);
|
||||
requestWorkspaceFile(selectedSkillAgentId, filename);
|
||||
}, [requestWorkspaceFile, selectedSkillAgentId, setSelectedWorkspaceFile]);
|
||||
|
||||
const handleWorkspaceFileSave = useCallback(() => {
|
||||
if (!clientRef.current) {
|
||||
setWorkspaceFileFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
|
||||
setWorkspaceFileSavingKey(key);
|
||||
setWorkspaceFileFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_workspace_file",
|
||||
agent_id: selectedSkillAgentId,
|
||||
filename: selectedWorkspaceFile,
|
||||
content: workspaceDraftContent
|
||||
});
|
||||
if (!success) {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
sendWithRetry,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey,
|
||||
workspaceDraftContent
|
||||
]);
|
||||
|
||||
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "请选择 zip 文件后再上传" });
|
||||
return;
|
||||
}
|
||||
if (!selectedSkillAgentId) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "未选择目标 Agent" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||
setAgentSkillsFeedback(null);
|
||||
try {
|
||||
const result = await uploadAgentSkillZip({
|
||||
agentId: selectedSkillAgentId,
|
||||
file,
|
||||
activate: true
|
||||
});
|
||||
setAgentSkillsFeedback({
|
||||
type: "success",
|
||||
text: `已上传并安装技能 ${result.skill_name || ""}`.trim()
|
||||
});
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
} catch (error) {
|
||||
setAgentSkillsFeedback({
|
||||
type: "error",
|
||||
text: `上传失败: ${error.message || "未知错误"}`
|
||||
});
|
||||
} finally {
|
||||
setAgentSkillsSavingKey(null);
|
||||
}
|
||||
}, [
|
||||
requestAgentSkills,
|
||||
selectedSkillAgentId,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaceDraftContent(selectedWorkspaceContent);
|
||||
}, [selectedWorkspaceContent, setWorkspaceDraftContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "traders") {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
AGENTS.forEach((agent) => {
|
||||
if (!agentProfilesByAgent[agent.id]) {
|
||||
requestAgentProfile(agent.id);
|
||||
}
|
||||
if (!agentSkillsByAgent[agent.id]) {
|
||||
requestAgentSkills(agent.id);
|
||||
}
|
||||
if (!workspaceFilesByAgent[agent.id]?.["MEMORY.md"]) {
|
||||
requestWorkspaceFile(agent.id, "MEMORY.md");
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
connectionStatus,
|
||||
currentView,
|
||||
isConnected,
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
workspaceFilesByAgent
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "traders" || !selectedSkillAgentId) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!agentProfilesByAgent[selectedSkillAgentId]) {
|
||||
requestAgentProfile(selectedSkillAgentId);
|
||||
}
|
||||
if (!agentSkillsByAgent[selectedSkillAgentId]) {
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
}
|
||||
if (selectedWorkspaceFile && !workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile]) {
|
||||
requestWorkspaceFile(selectedSkillAgentId, selectedWorkspaceFile);
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
connectionStatus,
|
||||
currentView,
|
||||
isConnected,
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
workspaceFilesByAgent
|
||||
]);
|
||||
|
||||
return {
|
||||
requestAgentSkills,
|
||||
requestAgentProfile,
|
||||
requestSkillDetail,
|
||||
requestWorkspaceFile,
|
||||
handleCreateLocalSkill,
|
||||
handleLocalSkillDraftChange,
|
||||
handleLocalSkillSave,
|
||||
handleLocalSkillDelete,
|
||||
handleRemoveSharedSkill,
|
||||
handleAgentSkillToggle,
|
||||
handleSkillAgentChange,
|
||||
handleWorkspaceFileChange,
|
||||
handleWorkspaceFileSave,
|
||||
handleUploadExternalSkill
|
||||
};
|
||||
}
|
||||
581
frontend/src/hooks/useRuntimeControls.js
Normal file
581
frontend/src/hooks/useRuntimeControls.js
Normal file
@@ -0,0 +1,581 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { INITIAL_TICKERS } from "../config/constants";
|
||||
import { fetchRuntimeHistory, startRuntime } from "../services/runtimeApi";
|
||||
import {
|
||||
buildRuntimeSummaryLabel,
|
||||
normalizeTickerSymbols,
|
||||
normalizeRuntimeWatchlistSymbols,
|
||||
parseWatchlistInput
|
||||
} from "../services/runtimeControls";
|
||||
import { useAgentStore } from "../store/agentStore";
|
||||
import { useRuntimeStore } from "../store/runtimeStore";
|
||||
|
||||
const DEFAULT_SCHEDULE_MODE = "daily";
|
||||
const DEFAULT_INTERVAL_MINUTES = "60";
|
||||
const DEFAULT_TRIGGER_TIME = "now";
|
||||
const DEFAULT_MAX_COMM_CYCLES = "2";
|
||||
const DEFAULT_INITIAL_CASH = "100000";
|
||||
const DEFAULT_MARGIN_REQUIREMENT = "0";
|
||||
const DEFAULT_MODE = "live";
|
||||
const DEFAULT_POLL_INTERVAL = "10";
|
||||
|
||||
export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage, onRuntimeStarted }) {
|
||||
const {
|
||||
runtimeConfig,
|
||||
setRuntimeConfig,
|
||||
isWatchlistPanelOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
setIsRuntimeSettingsOpen,
|
||||
watchlistDraftSymbols,
|
||||
setWatchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
setWatchlistInputValue,
|
||||
watchlistFeedback,
|
||||
setWatchlistFeedback,
|
||||
isWatchlistSaving,
|
||||
setIsWatchlistSaving,
|
||||
launchModeDraft,
|
||||
setLaunchModeDraft,
|
||||
restoreRunIdDraft,
|
||||
setRestoreRunIdDraft,
|
||||
runtimeHistoryRuns,
|
||||
setRuntimeHistoryRuns,
|
||||
scheduleModeDraft,
|
||||
setScheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
setIntervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
setTriggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
setInitialCashDraft,
|
||||
marginRequirementDraft,
|
||||
setMarginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
setEnableMemoryDraft,
|
||||
modeDraft,
|
||||
setModeDraft,
|
||||
pollIntervalDraft,
|
||||
setPollIntervalDraft,
|
||||
startDateDraft,
|
||||
setStartDateDraft,
|
||||
endDateDraft,
|
||||
setEndDateDraft,
|
||||
runtimeConfigFeedback,
|
||||
setRuntimeConfigFeedback,
|
||||
isRuntimeConfigSaving,
|
||||
setIsRuntimeConfigSaving
|
||||
} = useRuntimeStore();
|
||||
|
||||
const {
|
||||
setAgentSkillsFeedback,
|
||||
setWorkspaceFileFeedback
|
||||
} = useAgentStore();
|
||||
|
||||
const isWatchlistSavingRef = useRef(false);
|
||||
const isRuntimeConfigSavingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
}, [isWatchlistSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
}, [isRuntimeConfigSaving]);
|
||||
|
||||
const displayTickers = useMemo(
|
||||
() => normalizeTickerSymbols(runtimeConfig?.tickers, currentTickers),
|
||||
[currentTickers, runtimeConfig]
|
||||
);
|
||||
|
||||
const runtimeWatchlistSymbols = useMemo(
|
||||
() => normalizeRuntimeWatchlistSymbols(runtimeConfig, currentTickers),
|
||||
[currentTickers, runtimeConfig]
|
||||
);
|
||||
|
||||
const runtimeSummaryLabel = useMemo(
|
||||
() => buildRuntimeSummaryLabel(runtimeConfig),
|
||||
[runtimeConfig]
|
||||
);
|
||||
|
||||
const watchlistSuggestions = useMemo(
|
||||
() => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
|
||||
[]
|
||||
);
|
||||
|
||||
const isWatchlistDraftDirty = useMemo(() => {
|
||||
if (watchlistInputValue.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]);
|
||||
}, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!isWatchlistPanelOpen && !isRuntimeSettingsOpen) || !isWatchlistDraftDirty) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
if (!isWatchlistPanelOpen && !isRuntimeSettingsOpen) {
|
||||
setWatchlistInputValue("");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isWatchlistDraftDirty,
|
||||
isWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
runtimeWatchlistSymbols,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistInputValue
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScheduleModeDraft(String(runtimeConfig.schedule_mode || DEFAULT_SCHEDULE_MODE));
|
||||
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || DEFAULT_INTERVAL_MINUTES));
|
||||
setTriggerTimeDraft(String(runtimeConfig.trigger_time || DEFAULT_TRIGGER_TIME));
|
||||
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || DEFAULT_MAX_COMM_CYCLES));
|
||||
setInitialCashDraft(String(runtimeConfig.initial_cash ?? DEFAULT_INITIAL_CASH));
|
||||
setMarginRequirementDraft(String(runtimeConfig.margin_requirement ?? DEFAULT_MARGIN_REQUIREMENT));
|
||||
setEnableMemoryDraft(Boolean(runtimeConfig.enable_memory ?? false));
|
||||
}, [
|
||||
runtimeConfig,
|
||||
setEnableMemoryDraft,
|
||||
setInitialCashDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setMarginRequirementDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setScheduleModeDraft,
|
||||
setTriggerTimeDraft
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRuntimeSettingsOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void fetchRuntimeHistory(20)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
const runs = Array.isArray(payload?.runs) ? payload.runs : [];
|
||||
setRuntimeHistoryRuns(runs);
|
||||
if (!restoreRunIdDraft && runs.length > 0) {
|
||||
setRestoreRunIdDraft(runs[0].run_id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRuntimeHistoryRuns([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isRuntimeSettingsOpen, restoreRunIdDraft, setRestoreRunIdDraft, setRuntimeHistoryRuns]);
|
||||
|
||||
const commitWatchlistInput = useCallback((value) => {
|
||||
const parsed = parseWatchlistInput(value);
|
||||
if (parsed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed])));
|
||||
setWatchlistInputValue("");
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return parsed;
|
||||
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistRemove = useCallback((symbolToRemove) => {
|
||||
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistPanelToggle = useCallback(() => {
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
setIsWatchlistPanelOpen((open) => {
|
||||
const nextOpen = !open;
|
||||
if (nextOpen) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return nextOpen;
|
||||
});
|
||||
}, [
|
||||
runtimeWatchlistSymbols,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue
|
||||
]);
|
||||
|
||||
const handleWatchlistInputChange = useCallback((value) => {
|
||||
setWatchlistInputValue(value);
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistInputKeyDown = useCallback((event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
commitWatchlistInput(watchlistInputValue);
|
||||
}
|
||||
}, [commitWatchlistInput, watchlistInputValue]);
|
||||
|
||||
const handleWatchlistSuggestionClick = useCallback((symbol) => {
|
||||
if (watchlistDraftSymbols.includes(symbol)) {
|
||||
return;
|
||||
}
|
||||
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistDraftSymbols, watchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistRestoreCurrent = useCallback(() => {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}, [runtimeWatchlistSymbols, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback]);
|
||||
|
||||
const handleWatchlistRestoreDefault = useCallback(() => {
|
||||
setWatchlistDraftSymbols(watchlistSuggestions);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistSuggestions]);
|
||||
|
||||
const handleWatchlistSave = useCallback(() => {
|
||||
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||
if (nextTickers.length === 0) {
|
||||
setWatchlistFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
setWatchlistFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsWatchlistSaving(true);
|
||||
setWatchlistFeedback(null);
|
||||
setWatchlistDraftSymbols(nextTickers);
|
||||
setWatchlistInputValue("");
|
||||
const success = clientRef.current.send({
|
||||
type: "update_watchlist",
|
||||
tickers: nextTickers
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setIsWatchlistSaving(false);
|
||||
setWatchlistFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
setIsWatchlistSaving,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue
|
||||
]);
|
||||
|
||||
const handleRuntimeConfigSave = useCallback(() => {
|
||||
if (!clientRef.current) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = Number(intervalMinutesDraft);
|
||||
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRuntimeConfigSaving(true);
|
||||
setRuntimeConfigFeedback(null);
|
||||
const success = clientRef.current.send({
|
||||
type: "update_runtime_config",
|
||||
schedule_mode: scheduleModeDraft,
|
||||
interval_minutes: interval,
|
||||
trigger_time: triggerTimeDraft,
|
||||
max_comm_cycles: maxCommCycles,
|
||||
initial_cash: Number(initialCashDraft),
|
||||
margin_requirement: Number(marginRequirementDraft),
|
||||
enable_memory: Boolean(enableMemoryDraft)
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setRuntimeConfigFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
enableMemoryDraft,
|
||||
initialCashDraft,
|
||||
intervalMinutesDraft,
|
||||
marginRequirementDraft,
|
||||
maxCommCyclesDraft,
|
||||
scheduleModeDraft,
|
||||
setIsRuntimeConfigSaving,
|
||||
setRuntimeConfigFeedback,
|
||||
triggerTimeDraft
|
||||
]);
|
||||
|
||||
const handleLaunchConfigSave = useCallback(async () => {
|
||||
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||
if (nextTickers.length === 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = Number(intervalMinutesDraft);
|
||||
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||
const initialCash = Number(initialCashDraft);
|
||||
const marginRequirement = Number(marginRequirementDraft);
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(initialCash) || initialCash <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "初始资金必须是正数" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(marginRequirement) || marginRequirement < 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "保证金要求不能为负数" });
|
||||
return;
|
||||
}
|
||||
if (launchModeDraft === "restore" && !restoreRunIdDraft) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "请选择一个历史任务用于恢复启动" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRuntimeConfigSaving(true);
|
||||
setIsWatchlistSaving(true);
|
||||
setRuntimeConfigFeedback(null);
|
||||
setWatchlistFeedback(null);
|
||||
setWatchlistDraftSymbols(nextTickers);
|
||||
setWatchlistInputValue("");
|
||||
|
||||
try {
|
||||
const result = await startRuntime({
|
||||
launch_mode: launchModeDraft,
|
||||
restore_run_id: launchModeDraft === "restore" ? restoreRunIdDraft : null,
|
||||
tickers: nextTickers,
|
||||
schedule_mode: scheduleModeDraft,
|
||||
interval_minutes: interval,
|
||||
trigger_time: triggerTimeDraft,
|
||||
max_comm_cycles: maxCommCycles,
|
||||
initial_cash: initialCash,
|
||||
margin_requirement: marginRequirement,
|
||||
enable_memory: Boolean(enableMemoryDraft),
|
||||
mode: modeDraft || DEFAULT_MODE,
|
||||
poll_interval: Number(pollIntervalDraft) || Number(DEFAULT_POLL_INTERVAL),
|
||||
start_date: startDateDraft || null,
|
||||
end_date: endDateDraft || null,
|
||||
});
|
||||
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setIsWatchlistSaving(false);
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
setRuntimeConfigFeedback({
|
||||
type: "success",
|
||||
text: `任务已启动: ${result.run_id}`
|
||||
});
|
||||
addSystemMessage(`新任务已启动: ${result.run_id}`);
|
||||
onRuntimeStarted?.(result);
|
||||
} catch (error) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setIsWatchlistSaving(false);
|
||||
setRuntimeConfigFeedback({
|
||||
type: "error",
|
||||
text: `启动失败: ${error.message}`
|
||||
});
|
||||
}
|
||||
}, [
|
||||
addSystemMessage,
|
||||
clientRef,
|
||||
enableMemoryDraft,
|
||||
endDateDraft,
|
||||
initialCashDraft,
|
||||
intervalMinutesDraft,
|
||||
launchModeDraft,
|
||||
marginRequirementDraft,
|
||||
maxCommCyclesDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
restoreRunIdDraft,
|
||||
scheduleModeDraft,
|
||||
setIsRuntimeConfigSaving,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistSaving,
|
||||
setRuntimeConfigFeedback,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
startDateDraft,
|
||||
onRuntimeStarted,
|
||||
triggerTimeDraft,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue
|
||||
]);
|
||||
|
||||
const handleRuntimeDefaultsRestore = useCallback(() => {
|
||||
setScheduleModeDraft(DEFAULT_SCHEDULE_MODE);
|
||||
setIntervalMinutesDraft(DEFAULT_INTERVAL_MINUTES);
|
||||
setTriggerTimeDraft(DEFAULT_TRIGGER_TIME);
|
||||
setMaxCommCyclesDraft(DEFAULT_MAX_COMM_CYCLES);
|
||||
setInitialCashDraft(DEFAULT_INITIAL_CASH);
|
||||
setMarginRequirementDraft(DEFAULT_MARGIN_REQUIREMENT);
|
||||
setEnableMemoryDraft(false);
|
||||
setLaunchModeDraft("fresh");
|
||||
setRestoreRunIdDraft("");
|
||||
setModeDraft(DEFAULT_MODE);
|
||||
setPollIntervalDraft(DEFAULT_POLL_INTERVAL);
|
||||
setStartDateDraft("");
|
||||
setEndDateDraft("");
|
||||
setRuntimeConfigFeedback(null);
|
||||
}, [
|
||||
setEnableMemoryDraft,
|
||||
setEndDateDraft,
|
||||
setInitialCashDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setLaunchModeDraft,
|
||||
setMarginRequirementDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setModeDraft,
|
||||
setPollIntervalDraft,
|
||||
setRestoreRunIdDraft,
|
||||
setRuntimeConfigFeedback,
|
||||
setScheduleModeDraft,
|
||||
setStartDateDraft,
|
||||
setTriggerTimeDraft
|
||||
]);
|
||||
|
||||
const handleRuntimeSettingsToggle = useCallback(() => {
|
||||
setRuntimeConfigFeedback(null);
|
||||
setAgentSkillsFeedback(null);
|
||||
setWorkspaceFileFeedback(null);
|
||||
setIsRuntimeSettingsOpen((prev) => {
|
||||
const nextOpen = !prev;
|
||||
if (nextOpen) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return nextOpen;
|
||||
});
|
||||
setIsWatchlistPanelOpen(false);
|
||||
}, [
|
||||
runtimeWatchlistSymbols,
|
||||
setAgentSkillsFeedback,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
setRuntimeConfigFeedback,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
setWorkspaceFileFeedback
|
||||
]);
|
||||
|
||||
const handleRuntimeSettingsClose = useCallback(() => {
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
}, [setIsRuntimeSettingsOpen]);
|
||||
|
||||
const handleWatchlistAdd = useCallback(() => commitWatchlistInput(watchlistInputValue), [commitWatchlistInput, watchlistInputValue]);
|
||||
|
||||
return {
|
||||
runtimeConfig,
|
||||
displayTickers,
|
||||
runtimeWatchlistSymbols,
|
||||
runtimeSummaryLabel,
|
||||
watchlistSuggestions,
|
||||
isWatchlistDraftDirty,
|
||||
isWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistFeedback,
|
||||
isWatchlistSaving,
|
||||
launchModeDraft,
|
||||
restoreRunIdDraft,
|
||||
runtimeHistoryRuns,
|
||||
scheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
marginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
startDateDraft,
|
||||
endDateDraft,
|
||||
runtimeConfigFeedback,
|
||||
isRuntimeConfigSaving,
|
||||
isWatchlistSavingRef,
|
||||
isRuntimeConfigSavingRef,
|
||||
commitWatchlistInput,
|
||||
handleWatchlistRemove,
|
||||
handleWatchlistPanelToggle,
|
||||
handleWatchlistInputChange,
|
||||
handleWatchlistInputKeyDown,
|
||||
handleWatchlistSuggestionClick,
|
||||
handleWatchlistRestoreCurrent,
|
||||
handleWatchlistRestoreDefault,
|
||||
handleWatchlistSave,
|
||||
handleWatchlistAdd,
|
||||
handleRuntimeConfigSave,
|
||||
handleLaunchConfigSave,
|
||||
handleRuntimeDefaultsRestore,
|
||||
handleRuntimeSettingsToggle,
|
||||
handleRuntimeSettingsClose,
|
||||
setRuntimeConfig,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistInputValue,
|
||||
setWatchlistFeedback,
|
||||
setRuntimeConfigFeedback,
|
||||
setIsWatchlistPanelOpen,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setScheduleModeDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setTriggerTimeDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setInitialCashDraft,
|
||||
setMarginRequirementDraft,
|
||||
setEnableMemoryDraft,
|
||||
setLaunchModeDraft,
|
||||
setRestoreRunIdDraft,
|
||||
setModeDraft,
|
||||
setPollIntervalDraft,
|
||||
setStartDateDraft,
|
||||
setEndDateDraft,
|
||||
setIsWatchlistSaving,
|
||||
setIsRuntimeConfigSaving
|
||||
};
|
||||
}
|
||||
352
frontend/src/hooks/useStockDataRequests.js
Normal file
352
frontend/src/hooks/useStockDataRequests.js
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useMarketStore } from '../store/marketStore';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import {
|
||||
fetchNewsCategoriesDirect,
|
||||
fetchNewsForDateDirect,
|
||||
fetchRangeExplainDirect,
|
||||
fetchSimilarDaysDirect,
|
||||
fetchStockStoryDirect,
|
||||
hasDirectNewsService
|
||||
} from '../services/newsApi';
|
||||
import {
|
||||
fetchInsiderTradesDirect,
|
||||
fetchStockHistoryDirect,
|
||||
hasDirectTradingService
|
||||
} from '../services/tradingApi';
|
||||
|
||||
/**
|
||||
* Custom hook for stock data request callbacks.
|
||||
* Takes clientRef, calls store setters directly.
|
||||
*/
|
||||
export function useStockDataRequests(clientRef, { setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories }) {
|
||||
const requestedStockHistoryRef = useRef(new Set());
|
||||
|
||||
const { currentDate } = useRuntimeStore();
|
||||
const { setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker,
|
||||
setNewsByTicker, setInsiderTradesByTicker } = useMarketStore();
|
||||
|
||||
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (!force && requestedStockHistoryRef.current.has(normalized)) return false;
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 120);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||
.then((payload) => {
|
||||
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||
setPriceHistoryByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: prices
|
||||
.map((point) => {
|
||||
const price = Number(point?.close);
|
||||
const timestamp = point?.time;
|
||||
if (!timestamp || !Number.isFinite(price)) return null;
|
||||
return { timestamp: String(timestamp), label: String(timestamp), price };
|
||||
})
|
||||
.filter(Boolean)
|
||||
}));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: 'trading_service' }));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct stock-history fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
const success = clientRef.current.send({
|
||||
type: 'get_stock_history',
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
});
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
const success = clientRef.current.send({ type: 'get_stock_history', ticker: normalized, lookback_days: 120 });
|
||||
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||
return success;
|
||||
}, [clientRef, currentDate, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]);
|
||||
|
||||
const requestStockExplainEvents = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_explain_events', ticker: normalized });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNews = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news', ticker: normalized, lookback_days: 45, limit: 12 });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !date) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsForDateDirect(normalized, date, 20)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.date === 'string' ? payload.date.trim() : date;
|
||||
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
byDate: { ...((prev[normalized] && prev[normalized].byDate) || {}), [targetDate]: news },
|
||||
byDateFreshness: { ...((prev[normalized] && prev[normalized].byDateFreshness) || {}), [targetDate]: freshness }
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct news-for-date fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockNewsTimeline = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_timeline', ticker: normalized, lookback_days: 90 });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsCategories = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 90);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||
.then((payload) => {
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
categories: payload?.categories || {},
|
||||
categoriesStartDate: startDate,
|
||||
categoriesEndDate: endDate,
|
||||
categoriesFreshness: freshness
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct news-categories fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||
}, [clientRef, currentDate, setNewsByTicker]);
|
||||
|
||||
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||
.then((payload) => {
|
||||
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: { ticker: normalized, startDate, endDate, trades: rows }
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct insider-trades fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||
}, [clientRef, setInsiderTradesByTicker]);
|
||||
|
||||
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_technical_indicators', ticker: normalized });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !startDate || !endDate) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||
.then((payload) => {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : null;
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!result?.start_date || !result?.end_date) return;
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||
[cacheKey]: { ...result, freshness }
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct range explain fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchStockStoryDirect(normalized, asOfDate)
|
||||
.then((payload) => {
|
||||
const storyDate = typeof payload?.as_of_date === 'string' ? payload.as_of_date.trim() : '';
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!storyDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
storyCache: {
|
||||
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||
[storyDate]: { story: payload.story || '', source: payload.source || 'news_service', asOfDate: storyDate, freshness }
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct story fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !date) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.target_date === 'string' ? payload.target_date.trim() : date;
|
||||
if (!targetDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||
[targetDate]: payload
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct similar-days fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockEnrich = useCallback((symbol, options = {}) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : '';
|
||||
const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : '';
|
||||
if (!startDate || !endDate) return false;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
maintenanceStatus: { running: true, error: null, updatedAt: new Date().toISOString(), stats: null }
|
||||
}
|
||||
}));
|
||||
return clientRef.current.send({
|
||||
type: 'run_stock_enrich',
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
force: Boolean(options.force),
|
||||
only_local_to_llm: Boolean(options.onlyLocalToLlm),
|
||||
rebuild_story: Boolean(options.rebuildStory),
|
||||
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
|
||||
story_date: options.storyDate || null,
|
||||
target_date: options.targetDate || null
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
// Register request functions with WebSocket connection hook
|
||||
if (setRequestStockHistory) setRequestStockHistory(requestStockHistory);
|
||||
if (setRequestStockNewsTimeline) setRequestStockNewsTimeline(requestStockNewsTimeline);
|
||||
if (setRequestStockNewsCategories) setRequestStockNewsCategories(requestStockNewsCategories);
|
||||
|
||||
return {
|
||||
requestStockHistory,
|
||||
requestStockExplainEvents,
|
||||
requestStockNews,
|
||||
requestStockNewsForDate,
|
||||
requestStockNewsTimeline,
|
||||
requestStockNewsCategories,
|
||||
requestStockInsiderTrades,
|
||||
requestStockTechnicalIndicators,
|
||||
requestStockRangeExplain,
|
||||
requestStockStory,
|
||||
requestStockSimilarDays,
|
||||
requestStockEnrich
|
||||
};
|
||||
}
|
||||
546
frontend/src/hooks/useStockExplainData.js
Normal file
546
frontend/src/hooks/useStockExplainData.js
Normal file
@@ -0,0 +1,546 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import {
|
||||
fetchNewsCategoriesDirect,
|
||||
fetchNewsForDateDirect,
|
||||
fetchRangeExplainDirect,
|
||||
fetchSimilarDaysDirect,
|
||||
fetchStockStoryDirect,
|
||||
hasDirectNewsService
|
||||
} from "../services/newsApi";
|
||||
import {
|
||||
fetchInsiderTradesDirect,
|
||||
fetchStockHistoryDirect,
|
||||
hasDirectTradingService
|
||||
} from "../services/tradingApi";
|
||||
|
||||
export function useStockExplainData({
|
||||
clientRef,
|
||||
currentDate,
|
||||
currentView,
|
||||
selectedExplainSymbol,
|
||||
requestedStockHistoryRef,
|
||||
setOhlcHistoryByTicker,
|
||||
setPriceHistoryByTicker,
|
||||
setHistorySourceByTicker,
|
||||
setNewsByTicker,
|
||||
setInsiderTradesByTicker
|
||||
}) {
|
||||
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!force && requestedStockHistoryRef.current.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 120);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||
.then((payload) => {
|
||||
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||
setPriceHistoryByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: prices
|
||||
.map((point) => {
|
||||
const price = Number(point?.close);
|
||||
const timestamp = point?.time;
|
||||
if (!timestamp || !Number.isFinite(price)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
timestamp: String(timestamp),
|
||||
label: String(timestamp),
|
||||
price
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
}));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: "trading_service" }));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct stock-history fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
const success = clientRef.current.send({
|
||||
type: "get_stock_history",
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
if (success) {
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
}
|
||||
});
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = clientRef.current.send({
|
||||
type: "get_stock_history",
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
|
||||
if (success) {
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
|
||||
return success;
|
||||
}, [
|
||||
clientRef,
|
||||
currentDate,
|
||||
requestedStockHistoryRef,
|
||||
setHistorySourceByTicker,
|
||||
setOhlcHistoryByTicker,
|
||||
setPriceHistoryByTicker
|
||||
]);
|
||||
|
||||
const requestStockExplainEvents = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_explain_events",
|
||||
ticker: normalized
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNews = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news",
|
||||
ticker: normalized,
|
||||
lookback_days: 45,
|
||||
limit: 12
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsForDateDirect(normalized, date, 20)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.date === "string" ? payload.date.trim() : date;
|
||||
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
byDate: {
|
||||
...((prev[normalized] && prev[normalized].byDate) || {}),
|
||||
[targetDate]: news
|
||||
},
|
||||
byDateFreshness: {
|
||||
...((prev[normalized] && prev[normalized].byDateFreshness) || {}),
|
||||
[targetDate]: freshness
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct news-for-date fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_news_for_date",
|
||||
ticker: normalized,
|
||||
date,
|
||||
limit: 20
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_for_date",
|
||||
ticker: normalized,
|
||||
date,
|
||||
limit: 20
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockNewsTimeline = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_timeline",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsCategories = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 90);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||
.then((payload) => {
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
categories: payload?.categories || {},
|
||||
categoriesStartDate: startDate,
|
||||
categoriesEndDate: endDate,
|
||||
categoriesFreshness: freshness
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct news-categories fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_news_categories",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_categories",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}, [clientRef, currentDate, setNewsByTicker]);
|
||||
|
||||
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||
.then((payload) => {
|
||||
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
ticker: normalized,
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
trades: rows
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct insider-trades fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_insider_trades",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 50
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_insider_trades",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 50
|
||||
});
|
||||
}, [clientRef, setInsiderTradesByTicker]);
|
||||
|
||||
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_technical_indicators",
|
||||
ticker: normalized
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !startDate || !endDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||
.then((payload) => {
|
||||
const result = payload?.result && typeof payload.result === "object" ? payload.result : null;
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!result?.start_date || !result?.end_date) {
|
||||
return;
|
||||
}
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||
[cacheKey]: {
|
||||
...result,
|
||||
freshness
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct range explain fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_range_explain",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
article_ids: Array.isArray(articleIds) ? articleIds : []
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_range_explain",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
article_ids: Array.isArray(articleIds) ? articleIds : []
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchStockStoryDirect(normalized, asOfDate)
|
||||
.then((payload) => {
|
||||
const storyDate = typeof payload?.as_of_date === "string" ? payload.as_of_date.trim() : "";
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!storyDate) {
|
||||
return;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
storyCache: {
|
||||
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||
[storyDate]: {
|
||||
story: payload.story || "",
|
||||
source: payload.source || "news_service",
|
||||
asOfDate: storyDate,
|
||||
freshness
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct story fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_story",
|
||||
ticker: normalized,
|
||||
as_of_date: asOfDate
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_story",
|
||||
ticker: normalized,
|
||||
as_of_date: asOfDate
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.target_date === "string" ? payload.target_date.trim() : date;
|
||||
if (!targetDate) {
|
||||
return;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||
[targetDate]: payload
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct similar-days fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_similar_days",
|
||||
ticker: normalized,
|
||||
date,
|
||||
top_k: topK
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_similar_days",
|
||||
ticker: normalized,
|
||||
date,
|
||||
top_k: topK
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockEnrich = useCallback((symbol, options = {}) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
const startDate = typeof options.startDate === "string" ? options.startDate.trim() : "";
|
||||
const endDate = typeof options.endDate === "string" ? options.endDate.trim() : "";
|
||||
if (!startDate || !endDate) {
|
||||
return false;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
maintenanceStatus: {
|
||||
running: true,
|
||||
error: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
stats: null
|
||||
}
|
||||
}
|
||||
}));
|
||||
return clientRef.current.send({
|
||||
type: "run_stock_enrich",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
force: Boolean(options.force),
|
||||
only_local_to_llm: Boolean(options.onlyLocalToLlm),
|
||||
rebuild_story: Boolean(options.rebuildStory),
|
||||
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
|
||||
story_date: options.storyDate || null,
|
||||
target_date: options.targetDate || null
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "explain" || !selectedExplainSymbol) {
|
||||
return;
|
||||
}
|
||||
requestStockHistory(selectedExplainSymbol);
|
||||
requestStockExplainEvents(selectedExplainSymbol);
|
||||
requestStockNews(selectedExplainSymbol);
|
||||
requestStockNewsTimeline(selectedExplainSymbol);
|
||||
requestStockNewsCategories(selectedExplainSymbol);
|
||||
requestStockStory(selectedExplainSymbol, currentDate);
|
||||
}, [
|
||||
currentDate,
|
||||
currentView,
|
||||
requestStockExplainEvents,
|
||||
requestStockHistory,
|
||||
requestStockNews,
|
||||
requestStockNewsCategories,
|
||||
requestStockNewsTimeline,
|
||||
requestStockStory,
|
||||
selectedExplainSymbol
|
||||
]);
|
||||
|
||||
return {
|
||||
requestStockHistory,
|
||||
requestStockExplainEvents,
|
||||
requestStockNews,
|
||||
requestStockNewsForDate,
|
||||
requestStockNewsTimeline,
|
||||
requestStockNewsCategories,
|
||||
requestStockInsiderTrades,
|
||||
requestStockTechnicalIndicators,
|
||||
requestStockRangeExplain,
|
||||
requestStockStory,
|
||||
requestStockSimilarDays,
|
||||
requestStockEnrich
|
||||
};
|
||||
}
|
||||
861
frontend/src/hooks/useWebSocketConnection.js
Normal file
861
frontend/src/hooks/useWebSocketConnection.js
Normal file
@@ -0,0 +1,861 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { AGENTS } from '../config/constants';
|
||||
import { ReadOnlyClient } from '../services/websocket';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import { useMarketStore } from '../store/marketStore';
|
||||
import { usePortfolioStore } from '../store/portfolioStore';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
import { useUIStore } from '../store/uiStore';
|
||||
import { normalizeTickerSymbols } from '../services/runtimeControls';
|
||||
|
||||
/**
|
||||
* Normalize price history from server format
|
||||
*/
|
||||
function normalizePriceHistory(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
Object.entries(payload).forEach(([symbol, points]) => {
|
||||
const ticker = String(symbol || '').trim().toUpperCase();
|
||||
if (!ticker || !Array.isArray(points)) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalized[ticker] = points
|
||||
.map((point) => {
|
||||
if (Array.isArray(point) && point.length >= 2) {
|
||||
const [label, value] = point;
|
||||
const price = Number(value);
|
||||
if (!label || !Number.isFinite(price)) return null;
|
||||
return { timestamp: String(label), label: String(label), price };
|
||||
}
|
||||
|
||||
if (point && typeof point === 'object') {
|
||||
const rawTimestamp = point.timestamp ?? point.t ?? point.date ?? point.label;
|
||||
const price = Number(point.price ?? point.v ?? point.value ?? point.close);
|
||||
if (!rawTimestamp || !Number.isFinite(price)) return null;
|
||||
return { timestamp: String(rawTimestamp), label: String(rawTimestamp), price };
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(-120);
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tickers from symbols array
|
||||
*/
|
||||
function buildTickersFromSymbols(symbols, previousTickers = []) {
|
||||
if (!Array.isArray(symbols) || symbols.length === 0) {
|
||||
return previousTickers;
|
||||
}
|
||||
|
||||
return symbols
|
||||
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
|
||||
.map((symbol) => {
|
||||
const normalized = symbol.trim().toUpperCase();
|
||||
const existing = previousTickers.find((ticker) => ticker.symbol === normalized);
|
||||
return existing || { symbol: normalized, price: null, change: null };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for WebSocket connection lifecycle and event handling.
|
||||
* Manages clientRef, connection, and ALL event handlers.
|
||||
* Feeds directly into stores (no props drilling).
|
||||
*/
|
||||
export function useWebSocketConnection({
|
||||
processHistoricalFeed,
|
||||
processFeedEvent,
|
||||
addSystemMessage
|
||||
}) {
|
||||
const clientRef = useRef(null);
|
||||
const isWatchlistSavingRef = useRef(false);
|
||||
const isRuntimeConfigSavingRef = useRef(false);
|
||||
const selectedSkillAgentIdRef = useRef(null);
|
||||
const requestedStockHistoryRef = useRef(new Set());
|
||||
|
||||
// Store state
|
||||
const { setIsConnected, setConnectionStatus, setSystemStatus, setCurrentDate,
|
||||
setServerMode, setDataSources, setRuntimeConfig, setMarketStatus,
|
||||
setVirtualTime, setProgress, watchlistDraftSymbols, setWatchlistInputValue,
|
||||
setIsWatchlistSaving, setWatchlistFeedback, setIsRuntimeConfigSaving,
|
||||
setRuntimeConfigFeedback, isWatchlistSaving, isRuntimeConfigSaving,
|
||||
setLastDayHistory } = useRuntimeStore();
|
||||
|
||||
const { tickers, setTickers, setRollingTickers, setPriceHistoryByTicker,
|
||||
setExplainEventsByTicker, setNewsByTicker, setInsiderTradesByTicker,
|
||||
setTechnicalIndicatorsByTicker, setHistorySourceByTicker,
|
||||
setOhlcHistoryByTicker } = useMarketStore();
|
||||
|
||||
const { setPortfolioData, setHoldings, setTrades, setStats, setLeaderboard } = usePortfolioStore();
|
||||
|
||||
const { setAgentSkillsByAgent, setAgentProfilesByAgent, setSkillDetailsByName,
|
||||
setLocalSkillDraftsByKey, setIsAgentSkillsLoading, setSkillDetailLoadingKey,
|
||||
setAgentSkillsSavingKey, setAgentSkillsFeedback, setIsWorkspaceFileLoading,
|
||||
setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, setWorkspaceFileFeedback,
|
||||
selectedSkillAgentId } = useAgentStore();
|
||||
|
||||
const { setBubbles } = useUIStore();
|
||||
|
||||
// Helper: Update tickers from realtime prices
|
||||
const updateTickersFromPrices = useCallback((realtimePrices) => {
|
||||
try {
|
||||
setTickers((prevTickers) => prevTickers.map((ticker) => {
|
||||
const realtimeData = realtimePrices[ticker.symbol];
|
||||
if (realtimeData && realtimeData.price !== null && realtimeData.price !== undefined) {
|
||||
const newChange = (realtimeData.ret !== null && realtimeData.ret !== undefined)
|
||||
? realtimeData.ret
|
||||
: (ticker.change !== null && ticker.change !== undefined ? ticker.change : 0);
|
||||
|
||||
return {
|
||||
...ticker,
|
||||
price: realtimeData.price,
|
||||
change: newChange,
|
||||
open: realtimeData.open || ticker.open
|
||||
};
|
||||
}
|
||||
return ticker;
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error updating tickers from prices:', error);
|
||||
}
|
||||
}, [setTickers]);
|
||||
|
||||
// Stock request callbacks (these will be provided by useStockDataRequests)
|
||||
const requestStockHistoryRef = useRef(null);
|
||||
const requestStockNewsTimelineRef = useRef(null);
|
||||
const requestStockNewsCategoriesRef = useRef(null);
|
||||
|
||||
const setRequestStockHistory = useCallback((fn) => {
|
||||
requestStockHistoryRef.current = fn;
|
||||
}, []);
|
||||
|
||||
const setRequestStockNewsTimeline = useCallback((fn) => {
|
||||
requestStockNewsTimelineRef.current = fn;
|
||||
}, []);
|
||||
|
||||
const setRequestStockNewsCategories = useCallback((fn) => {
|
||||
requestStockNewsCategoriesRef.current = fn;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePushEvent = (evt) => {
|
||||
if (!evt) return;
|
||||
|
||||
try {
|
||||
handleEventInternal(evt);
|
||||
} catch (error) {
|
||||
console.error('[Event Handler] Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventInternal = (evt) => {
|
||||
if (evt?.type && evt.type !== 'pong') {
|
||||
setConnectionStatus('connected');
|
||||
setIsConnected(true);
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
error: (e) => {
|
||||
const message = typeof e.message === 'string' ? e.message : '请求失败';
|
||||
console.error('[Error]', message);
|
||||
setIsAgentSkillsLoading(false);
|
||||
setSkillDetailLoadingKey(null);
|
||||
setAgentSkillsSavingKey(null);
|
||||
setIsWorkspaceFileLoading(false);
|
||||
setWorkspaceFileSavingKey(null);
|
||||
if (isWatchlistSavingRef.current) {
|
||||
setIsWatchlistSaving(false);
|
||||
setWatchlistFeedback({ type: 'error', text: message || '更新 watchlist 失败' });
|
||||
}
|
||||
if (isRuntimeConfigSavingRef.current) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setRuntimeConfigFeedback({ type: 'error', text: message });
|
||||
}
|
||||
if (message.includes('skill') || message.includes('agent_id')) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: message || '更新技能失败' });
|
||||
}
|
||||
if (message.includes('workspace_file') || message.includes('filename')) {
|
||||
setWorkspaceFileFeedback({ type: 'error', text: message || '更新工作区文件失败' });
|
||||
}
|
||||
if (message.includes('fast forward')) {
|
||||
console.warn(`⚠️ ${message}`);
|
||||
handlePushEvent({ type: 'system', content: `⚠️ ${message}`, timestamp: Date.now() });
|
||||
}
|
||||
addSystemMessage(message);
|
||||
},
|
||||
|
||||
system: (e) => {
|
||||
console.log('[System]', e.content);
|
||||
if (e.content.includes('Connected') || e.content.includes('已连接')) {
|
||||
setConnectionStatus('connected');
|
||||
setIsConnected(true);
|
||||
} else if (e.content.includes('Disconnected') || e.content.includes('断开')) {
|
||||
setConnectionStatus('disconnected');
|
||||
setIsConnected(false);
|
||||
}
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
pong: () => {
|
||||
console.log('[Heartbeat] Pong received');
|
||||
},
|
||||
|
||||
initial_state: (e) => {
|
||||
try {
|
||||
const state = e.state;
|
||||
if (!state) return;
|
||||
|
||||
setConnectionStatus('connected');
|
||||
setIsConnected(true);
|
||||
setSystemStatus(state.status || 'initializing');
|
||||
setCurrentDate(state.current_date);
|
||||
|
||||
if (state.server_mode) setServerMode(state.server_mode);
|
||||
if (state.data_sources) setDataSources(state.data_sources);
|
||||
if (state.runtime_config) setRuntimeConfig(state.runtime_config);
|
||||
if (Array.isArray(state.tickers) && state.tickers.length > 0) {
|
||||
setTickers((prevTickers) => buildTickersFromSymbols(state.tickers, prevTickers));
|
||||
}
|
||||
|
||||
if (state.market_status) {
|
||||
setMarketStatus(state.market_status);
|
||||
setVirtualTime(null);
|
||||
}
|
||||
|
||||
if (state.trading_days_total) {
|
||||
setProgress({
|
||||
current: state.trading_days_completed || 0,
|
||||
total: state.trading_days_total
|
||||
});
|
||||
}
|
||||
|
||||
if (state.portfolio) {
|
||||
setPortfolioData((prev) => ({
|
||||
...prev,
|
||||
netValue: state.portfolio.total_value || prev.netValue,
|
||||
pnl: state.portfolio.pnl_percent || 0,
|
||||
equity: state.portfolio.equity || prev.equity,
|
||||
baseline: state.portfolio.baseline || prev.baseline,
|
||||
baseline_vw: state.portfolio.baseline_vw || prev.baseline_vw,
|
||||
momentum: state.portfolio.momentum || prev.momentum,
|
||||
strategies: state.portfolio.strategies || prev.strategies,
|
||||
equity_return: state.portfolio.equity_return || prev.equity_return,
|
||||
baseline_return: state.portfolio.baseline_return || prev.baseline_return,
|
||||
baseline_vw_return: state.portfolio.baseline_vw_return || prev.baseline_vw_return,
|
||||
momentum_return: state.portfolio.momentum_return || prev.momentum_return
|
||||
}));
|
||||
}
|
||||
|
||||
if (state.dashboard) {
|
||||
if (state.dashboard.holdings) setHoldings(state.dashboard.holdings);
|
||||
if (state.dashboard.trades) setTrades(state.dashboard.trades);
|
||||
if (state.dashboard.stats) setStats(state.dashboard.stats);
|
||||
if (state.dashboard.leaderboard) setLeaderboard(state.dashboard.leaderboard);
|
||||
}
|
||||
if (state.realtime_prices) updateTickersFromPrices(state.realtime_prices);
|
||||
if (state.price_history) {
|
||||
setPriceHistoryByTicker(normalizePriceHistory(state.price_history));
|
||||
}
|
||||
|
||||
if (state.feed_history && Array.isArray(state.feed_history)) {
|
||||
console.log(`✅ Loading ${state.feed_history.length} historical events`);
|
||||
processHistoricalFeed(state.feed_history);
|
||||
}
|
||||
|
||||
if (state.last_day_history && Array.isArray(state.last_day_history)) {
|
||||
setLastDayHistory(state.last_day_history);
|
||||
console.log(`✅ Loaded ${state.last_day_history.length} last day events for replay`);
|
||||
}
|
||||
|
||||
console.log('Initial state loaded');
|
||||
} catch (error) {
|
||||
console.error('Error loading initial state:', error);
|
||||
}
|
||||
},
|
||||
|
||||
market_status_update: (e) => {
|
||||
if (e.market_status) setMarketStatus(e.market_status);
|
||||
},
|
||||
|
||||
data_sources_update: (e) => {
|
||||
if (e.data_sources) setDataSources(e.data_sources);
|
||||
},
|
||||
|
||||
runtime_assets_reloaded: (e) => {
|
||||
if (e.runtime_config_applied) setRuntimeConfig(e.runtime_config_applied);
|
||||
if (Array.isArray(e.runtime_config_applied?.tickers)) {
|
||||
setTickers((prevTickers) =>
|
||||
buildTickersFromSymbols(e.runtime_config_applied.tickers, prevTickers)
|
||||
);
|
||||
setWatchlistInputValue('');
|
||||
}
|
||||
if (isWatchlistSavingRef.current) setIsWatchlistSaving(false);
|
||||
if (isRuntimeConfigSavingRef.current) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setRuntimeConfigFeedback({ type: 'success', text: '运行配置已保存并生效' });
|
||||
}
|
||||
const warnings = Array.isArray(e.runtime_config_warnings) ? e.runtime_config_warnings : [];
|
||||
warnings.forEach((warning) => addSystemMessage(warning));
|
||||
addSystemMessage('运行时配置已热更新');
|
||||
},
|
||||
|
||||
agent_skills_loaded: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
if (!agentId) {
|
||||
setIsAgentSkillsLoading(false);
|
||||
return;
|
||||
}
|
||||
setAgentSkillsByAgent((prev) => ({ ...prev, [agentId]: Array.isArray(e.skills) ? e.skills : [] }));
|
||||
setIsAgentSkillsLoading(false);
|
||||
setAgentSkillsSavingKey(null);
|
||||
},
|
||||
|
||||
agent_profile_loaded: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
if (!agentId) return;
|
||||
setAgentProfilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[agentId]: e.profile && typeof e.profile === 'object' ? e.profile : {}
|
||||
}));
|
||||
},
|
||||
|
||||
skill_detail_loaded: (e) => {
|
||||
const skillName = typeof e.skill?.skill_name === 'string' ? e.skill.skill_name.trim() : '';
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : selectedSkillAgentIdRef.current;
|
||||
if (!skillName) {
|
||||
setSkillDetailLoadingKey(null);
|
||||
return;
|
||||
}
|
||||
const detailKey = `${agentId}:${skillName}`;
|
||||
setSkillDetailsByName((prev) => ({ ...prev, [detailKey]: e.skill }));
|
||||
setLocalSkillDraftsByKey((prev) => ({
|
||||
...prev,
|
||||
[detailKey]: typeof e.skill?.content === 'string' ? e.skill.content : ''
|
||||
}));
|
||||
setSkillDetailLoadingKey(null);
|
||||
},
|
||||
|
||||
agent_skill_updated: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
if (!agentId || !skillName) return;
|
||||
setAgentSkillsFeedback({
|
||||
type: 'success',
|
||||
text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}`
|
||||
});
|
||||
},
|
||||
|
||||
agent_local_skill_created: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
setAgentSkillsSavingKey(null);
|
||||
if (!agentId || !skillName) return;
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已创建本地技能 ${skillName}` });
|
||||
},
|
||||
|
||||
agent_local_skill_updated: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
setAgentSkillsSavingKey(null);
|
||||
if (!agentId || !skillName) return;
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已保存` });
|
||||
},
|
||||
|
||||
agent_local_skill_deleted: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
setAgentSkillsSavingKey(null);
|
||||
if (!agentId || !skillName) return;
|
||||
setSkillDetailsByName((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`${agentId}:${skillName}`];
|
||||
return next;
|
||||
});
|
||||
setLocalSkillDraftsByKey((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`${agentId}:${skillName}`];
|
||||
return next;
|
||||
});
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已删除` });
|
||||
},
|
||||
|
||||
agent_skill_removed: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||
setAgentSkillsSavingKey(null);
|
||||
if (!agentId || !skillName) return;
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已移除共享技能 ${skillName}` });
|
||||
},
|
||||
|
||||
agent_workspace_file_loaded: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
|
||||
if (!agentId || !filename) {
|
||||
setIsWorkspaceFileLoading(false);
|
||||
return;
|
||||
}
|
||||
setWorkspaceFilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[agentId]: { ...(prev[agentId] || {}), [filename]: typeof e.content === 'string' ? e.content : '' }
|
||||
}));
|
||||
setIsWorkspaceFileLoading(false);
|
||||
setWorkspaceFileSavingKey(null);
|
||||
},
|
||||
|
||||
agent_workspace_file_updated: (e) => {
|
||||
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
|
||||
if (!agentId || !filename) return;
|
||||
setWorkspaceFileFeedback({ type: 'success', text: `${agentId} 的 ${filename} 已保存` });
|
||||
},
|
||||
|
||||
watchlist_updated: (e) => {
|
||||
if (Array.isArray(e.tickers)) {
|
||||
const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase());
|
||||
setRuntimeConfig((prev) => ({ ...(prev || {}), tickers: normalizedTickers }));
|
||||
setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers));
|
||||
}
|
||||
setIsWatchlistSaving(false);
|
||||
setWatchlistFeedback({
|
||||
type: 'success',
|
||||
text: `已更新为 ${Array.isArray(e.tickers) ? e.tickers.join(', ') : '最新列表'}`
|
||||
});
|
||||
},
|
||||
|
||||
stock_history_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
if (Array.isArray(e.prices)) {
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [symbol]: e.prices }));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [symbol]: e.source || null }));
|
||||
}
|
||||
},
|
||||
|
||||
stock_explain_events_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setExplainEventsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
events: Array.isArray(e.events) ? e.events : [],
|
||||
signals: Array.isArray(e.signals) ? e.signals : [],
|
||||
trades: Array.isArray(e.trades) ? e.trades : []
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_news_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
items: Array.isArray(e.news) ? e.news : [],
|
||||
source: e.source || null,
|
||||
startDate: e.start_date || null,
|
||||
endDate: e.end_date || null,
|
||||
freshness: e.freshness || null
|
||||
}
|
||||
}));
|
||||
if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(symbol);
|
||||
},
|
||||
|
||||
stock_news_for_date_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
const date = typeof e.date === 'string' ? e.date.trim() : '';
|
||||
if (!symbol || !date) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
byDate: { ...((prev[symbol] && prev[symbol].byDate) || {}), [date]: Array.isArray(e.news) ? e.news : [] },
|
||||
byDateFreshness: { ...((prev[symbol] && prev[symbol].byDateFreshness) || {}), [date]: e.freshness || null }
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_news_timeline_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
timeline: Array.isArray(e.timeline) ? e.timeline : [],
|
||||
timelineStartDate: e.start_date || null,
|
||||
timelineEndDate: e.end_date || null,
|
||||
timelineFreshness: e.freshness || null
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_news_categories_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
categories: e.categories || {},
|
||||
categoriesStartDate: e.start_date || null,
|
||||
categoriesEndDate: e.end_date || null,
|
||||
categoriesFreshness: e.freshness || null
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_insider_trades_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: { trades: Array.isArray(e.trades) ? e.trades : [], startDate: e.start_date || null, endDate: e.end_date || null }
|
||||
}));
|
||||
},
|
||||
|
||||
stock_technical_indicators_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setTechnicalIndicatorsByTicker((prev) => ({ ...prev, [symbol]: e.indicators || null }));
|
||||
},
|
||||
|
||||
stock_range_explain_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
const result = e.result && typeof e.result === 'object' ? e.result : null;
|
||||
if (!result?.start_date || !result?.end_date) return;
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[symbol] && prev[symbol].rangeExplainCache) || {}),
|
||||
[cacheKey]: { ...result, freshness: e.freshness || null }
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_story_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
const asOfDate = typeof e.as_of_date === 'string' ? e.as_of_date.trim() : '';
|
||||
if (!symbol || !asOfDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
storyCache: {
|
||||
...((prev[symbol] && prev[symbol].storyCache) || {}),
|
||||
[asOfDate]: { story: e.story || '', source: e.source || null, asOfDate, freshness: e.freshness || null }
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_similar_days_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
const date = typeof e.target_date === 'string' ? e.target_date.trim() : typeof e.date === 'string' ? e.date.trim() : '';
|
||||
if (!symbol || !date) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[symbol] && prev[symbol].similarDaysCache) || {}),
|
||||
[date]: {
|
||||
target_features: e.target_features || {},
|
||||
items: Array.isArray(e.items) ? e.items : [],
|
||||
error: e.error || null,
|
||||
freshness: e.freshness || null
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_enrich_completed: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
const completedAt = new Date().toISOString();
|
||||
const historyEntry = {
|
||||
timestamp: completedAt,
|
||||
startDate: e.start_date || '',
|
||||
endDate: e.end_date || '',
|
||||
force: Boolean(e.force),
|
||||
onlyLocalToLlm: Boolean(e.only_local_to_llm),
|
||||
error: e.error || null,
|
||||
stats: e.stats || null,
|
||||
storyStatus: e.story_status || null,
|
||||
similarStatus: e.similar_status || null
|
||||
};
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: {
|
||||
...(prev[symbol] || {}),
|
||||
items: [], byDate: {}, timeline: [], categories: {},
|
||||
rangeExplainCache: {}, storyCache: {}, similarDaysCache: {},
|
||||
maintenanceStatus: { running: false, error: e.error || null, updatedAt: completedAt, stats: e.stats || null, storyStatus: e.story_status || null, similarStatus: e.similar_status || null },
|
||||
maintenanceHistory: [historyEntry, ...(((prev[symbol] && prev[symbol].maintenanceHistory) || []).slice(0, 7))]
|
||||
}
|
||||
}));
|
||||
if (!e.error) {
|
||||
if (requestStockHistoryRef.current) requestStockHistoryRef.current(symbol);
|
||||
if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(symbol);
|
||||
if (requestStockNewsCategoriesRef.current) requestStockNewsCategoriesRef.current(symbol);
|
||||
}
|
||||
},
|
||||
|
||||
price_update: (e) => {
|
||||
try {
|
||||
const { symbol, price, ret, open, portfolio, realtime_prices } = e;
|
||||
if (!symbol || !price) {
|
||||
console.warn('[Price Update] Missing symbol or price:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionStatus('connected');
|
||||
setIsConnected(true);
|
||||
console.log(`[Price Update] ${symbol}: $${price} (ret: ${ret !== undefined ? ret.toFixed(2) : 'N/A'}%)`);
|
||||
|
||||
setPriceHistoryByTicker((prev) => {
|
||||
const ticker = String(symbol).trim().toUpperCase();
|
||||
const nextPoint = { timestamp: new Date().toISOString(), label: new Date().toISOString(), price: Number(price) };
|
||||
const existing = Array.isArray(prev[ticker]) ? prev[ticker] : [];
|
||||
const lastPoint = existing[existing.length - 1];
|
||||
if (lastPoint && Number(lastPoint.price) === Number(nextPoint.price)) return prev;
|
||||
return { ...prev, [ticker]: [...existing, nextPoint].slice(-120) };
|
||||
});
|
||||
|
||||
const normalizedSymbol = String(symbol).trim().toUpperCase();
|
||||
let shouldAnimateTicker = false;
|
||||
setTickers((prevTickers) => prevTickers.map((ticker) => {
|
||||
if (ticker.symbol === symbol) {
|
||||
const oldPrice = ticker.price;
|
||||
let newChange = ticker.change;
|
||||
if (ret !== null && ret !== undefined) {
|
||||
newChange = ret;
|
||||
} else if (oldPrice !== null && oldPrice !== undefined && isFinite(oldPrice)) {
|
||||
const priceChange = ((price - oldPrice) / oldPrice) * 100;
|
||||
newChange = (newChange !== null && newChange !== undefined) ? newChange + priceChange : priceChange;
|
||||
} else {
|
||||
newChange = 0;
|
||||
}
|
||||
if (oldPrice !== price) shouldAnimateTicker = true;
|
||||
return { ...ticker, price, change: newChange, open: open || ticker.open };
|
||||
}
|
||||
return ticker;
|
||||
}));
|
||||
|
||||
if (shouldAnimateTicker) {
|
||||
setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: true }));
|
||||
setTimeout(() => setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: false })), 500);
|
||||
}
|
||||
|
||||
if (realtime_prices) updateTickersFromPrices(realtime_prices);
|
||||
if (portfolio && portfolio.total_value) {
|
||||
setPortfolioData((prev) => ({
|
||||
...prev,
|
||||
netValue: portfolio.total_value,
|
||||
pnl: portfolio.pnl_percent || 0,
|
||||
equity: portfolio.equity || prev.equity
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Price Update] Error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
day_start: (e) => {
|
||||
setCurrentDate(e.date);
|
||||
if (e.progress !== undefined) {
|
||||
setProgress((prev) => ({ ...prev, current: Math.floor(e.progress * (prev.total || 1)) }));
|
||||
}
|
||||
setSystemStatus('running');
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
day_complete: (e) => {
|
||||
const result = e.result;
|
||||
if (result && typeof result === 'object') {
|
||||
if (result.portfolio_summary) {
|
||||
const summary = result.portfolio_summary;
|
||||
setPortfolioData((prev) => {
|
||||
const newEquity = [...prev.equity];
|
||||
const dateObj = new Date(e.date);
|
||||
newEquity.push({ t: dateObj.getTime(), v: summary.total_value || summary.cash || prev.netValue });
|
||||
return { ...prev, netValue: summary.total_value || summary.cash || prev.netValue, pnl: summary.pnl_percent || 0, equity: newEquity };
|
||||
});
|
||||
}
|
||||
}
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
day_error: (e) => {
|
||||
console.error('Day error:', e.date, e.error);
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
conference_start: (e) => processFeedEvent(e),
|
||||
conference_end: (e) => processFeedEvent(e),
|
||||
|
||||
agent_message: (e) => {
|
||||
const agent = AGENTS.find((item) => item.id === e.agentId);
|
||||
setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || e.agentId } });
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
conference_message: (e) => {
|
||||
const agent = AGENTS.find((item) => item.id === e.agentId);
|
||||
setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || e.agentId } });
|
||||
processFeedEvent(e);
|
||||
},
|
||||
|
||||
memory: (e) => processFeedEvent(e),
|
||||
|
||||
team_summary: (e) => {
|
||||
setPortfolioData((prev) => ({
|
||||
...prev,
|
||||
netValue: e.balance || prev.netValue,
|
||||
pnl: e.pnlPct || 0,
|
||||
equity: e.equity || prev.equity,
|
||||
baseline: e.baseline || prev.baseline,
|
||||
baseline_vw: e.baseline_vw || prev.baseline_vw,
|
||||
momentum: e.momentum || prev.momentum,
|
||||
equity_return: e.equity_return || prev.equity_return,
|
||||
baseline_return: e.baseline_return || prev.baseline_return,
|
||||
baseline_vw_return: e.baseline_vw_return || prev.baseline_vw_return,
|
||||
momentum_return: e.momentum_return || prev.momentum_return
|
||||
}));
|
||||
},
|
||||
|
||||
team_portfolio: (e) => {
|
||||
if (e.holdings) setHoldings(e.holdings);
|
||||
},
|
||||
|
||||
team_holdings: (e) => {
|
||||
if (e.data && Array.isArray(e.data)) {
|
||||
setHoldings(e.data);
|
||||
console.log(`✅ Holdings updated: ${e.data.length} positions`);
|
||||
}
|
||||
},
|
||||
|
||||
team_trades: (e) => {
|
||||
if (e.mode === 'full' && e.data && Array.isArray(e.data)) {
|
||||
setTrades(e.data);
|
||||
} else if (Array.isArray(e.trades)) {
|
||||
setTrades(e.trades);
|
||||
} else if (e.trade) {
|
||||
setTrades((prev) => [e.trade, ...prev].slice(0, 100));
|
||||
}
|
||||
},
|
||||
|
||||
team_stats: (e) => {
|
||||
if (e.data) setStats(e.data);
|
||||
else if (e.stats) setStats(e.stats);
|
||||
},
|
||||
|
||||
team_leaderboard: (e) => {
|
||||
if (Array.isArray(e.data)) setLeaderboard(e.data);
|
||||
else if (Array.isArray(e.rows)) setLeaderboard(e.rows);
|
||||
else if (Array.isArray(e.leaderboard)) setLeaderboard(e.leaderboard);
|
||||
},
|
||||
|
||||
time_update: (e) => {
|
||||
if (e.beijing_time_str) {
|
||||
const statusEmoji = { market_open: '📊', off_market: '⏸️', non_trading_day: '📅', trade_execution: '💼' };
|
||||
const emoji = statusEmoji[e.status] || '⏰';
|
||||
let logMessage = `${emoji} 时间: ${e.beijing_time_str} | 状态: ${e.status}`;
|
||||
if (e.hours_to_open !== undefined) logMessage += ` | 距离开盘: ${e.hours_to_open}小时`;
|
||||
if (e.hours_to_trade !== undefined) logMessage += ` | 距离交易: ${e.hours_to_trade}小时`;
|
||||
if (e.trading_date) logMessage += ` | 交易日: ${e.trading_date}`;
|
||||
console.log(logMessage);
|
||||
|
||||
setVirtualTime(null);
|
||||
}
|
||||
if (e.market_status) setMarketStatus(e.market_status);
|
||||
},
|
||||
|
||||
time_fast_forwarded: (e) => {
|
||||
console.log(`⏩ 时间已快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`);
|
||||
if (e.new_time) {
|
||||
try {
|
||||
setVirtualTime(new Date(e.new_time));
|
||||
handlePushEvent({ type: 'system', content: `⏩ 时间快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`, timestamp: Date.now() });
|
||||
} catch (error) { console.error('Error parsing fast forwarded time:', error); }
|
||||
}
|
||||
},
|
||||
|
||||
fast_forward_success: (e) => {
|
||||
console.log(`✅ ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const handler = handlers[evt.type];
|
||||
if (handler) handler(evt);
|
||||
else console.log('[handleEvent] Unknown event type:', evt.type);
|
||||
} catch (error) {
|
||||
console.error('[handleEvent] Error handling event:', evt.type, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create and connect WebSocket client
|
||||
const client = new ReadOnlyClient(handlePushEvent);
|
||||
clientRef.current = client;
|
||||
client.connect();
|
||||
setConnectionStatus('connecting');
|
||||
|
||||
// Sync refs with store state
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
selectedSkillAgentIdRef.current = selectedSkillAgentId;
|
||||
|
||||
return () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
addSystemMessage, processFeedEvent,
|
||||
processHistoricalFeed, setAgentProfilesByAgent,
|
||||
setAgentSkillsByAgent, setAgentSkillsFeedback, setAgentSkillsSavingKey,
|
||||
setBubbles, setConnectionStatus, setCurrentDate, setDataSources,
|
||||
setExplainEventsByTicker, setHistorySourceByTicker, setHoldings,
|
||||
setInsiderTradesByTicker, setIsAgentSkillsLoading, setIsConnected,
|
||||
setIsRuntimeConfigSaving, setIsWatchlistSaving, setIsWorkspaceFileLoading,
|
||||
setLastDayHistory, setLeaderboard, setLocalSkillDraftsByKey,
|
||||
setMarketStatus, setNewsByTicker, setOhlcHistoryByTicker,
|
||||
setPortfolioData, setPriceHistoryByTicker, setProgress,
|
||||
setRollingTickers, setRuntimeConfig, setRuntimeConfigFeedback,
|
||||
setServerMode, setSkillDetailLoadingKey, setSkillDetailsByName,
|
||||
setStats, setSystemStatus, setTechnicalIndicatorsByTicker,
|
||||
setTickers, setTrades, setVirtualTime, setWatchlistFeedback,
|
||||
setWatchlistInputValue, setWorkspaceFileFeedback, setWorkspaceFileSavingKey,
|
||||
setWorkspaceFilesByAgent, updateTickersFromPrices
|
||||
]);
|
||||
|
||||
// Sync refs
|
||||
useEffect(() => {
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
}, [isWatchlistSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
}, [isRuntimeConfigSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedSkillAgentIdRef.current = selectedSkillAgentId;
|
||||
}, [selectedSkillAgentId]);
|
||||
|
||||
return { clientRef, setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories };
|
||||
}
|
||||
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* useWebsocketSessionSync - DEPRECATED
|
||||
*
|
||||
* This hook is deprecated. WebSocket connection and event handling is now managed
|
||||
* by useWebSocketConnection.js. This file is kept for backwards compatibility
|
||||
* but will be removed in a future version.
|
||||
*
|
||||
* All functionality has been consolidated into:
|
||||
* - useWebSocketConnection.js: WebSocket lifecycle and event handlers
|
||||
* - useStockDataRequests.js: Stock data request callbacks
|
||||
* - useAgentDataRequests.js: Agent operation callbacks
|
||||
*/
|
||||
|
||||
import { useWebSocketConnection } from './useWebSocketConnection';
|
||||
|
||||
/**
|
||||
* @deprecated Use useWebSocketConnection directly instead.
|
||||
* This hook is a thin wrapper that delegates to useWebSocketConnection
|
||||
* for backwards compatibility.
|
||||
*/
|
||||
export function useWebsocketSessionSync(props) {
|
||||
// Delegate to useWebSocketConnection
|
||||
const { clientRef } = useWebSocketConnection();
|
||||
|
||||
// Return clientRef so existing code can still access it
|
||||
return { clientRef };
|
||||
}
|
||||
|
||||
export default useWebsocketSessionSync;
|
||||
@@ -38,6 +38,10 @@ export function fetchRuntimeEvents() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/events');
|
||||
}
|
||||
|
||||
export function fetchRuntimeHistory(limit = 20) {
|
||||
return safeFetch(RUNTIME_API_BASE, `/history?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function fetchPendingApprovals() {
|
||||
return safeFetch(CONTROL_API_BASE, '/guard/pending');
|
||||
}
|
||||
@@ -121,6 +125,73 @@ export function fetchCurrentRuntime() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/current');
|
||||
}
|
||||
|
||||
export function fetchRuntimeLogs() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/logs');
|
||||
}
|
||||
|
||||
export function fetchAgentProfile(workspaceId, agentId) {
|
||||
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/profile`);
|
||||
}
|
||||
|
||||
export function fetchAgentSkills(workspaceId, agentId) {
|
||||
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills`);
|
||||
}
|
||||
|
||||
export function fetchAgentSkillDetail(workspaceId, agentId, skillName) {
|
||||
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent(skillName)}`);
|
||||
}
|
||||
|
||||
export function fetchAgentWorkspaceFile(workspaceId, agentId, filename) {
|
||||
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/files/${encodeURIComponent(filename)}`);
|
||||
}
|
||||
|
||||
export function createAgentLocalSkill(workspaceId, agentId, skillName) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/local`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ skill_name: skillName })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAgentLocalSkill(workspaceId, agentId, skillName, content) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/local/${encodeURIComponent(skillName)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAgentLocalSkill(workspaceId, agentId, skillName) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/local/${encodeURIComponent(skillName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export function enableAgentSkill(workspaceId, agentId, skillName) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent(skillName)}/enable`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function disableAgentSkill(workspaceId, agentId, skillName) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent(skillName)}/disable`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAgentWorkspaceFile(workspaceId, agentId, filename, content) {
|
||||
return fetch(`${CONTROL_API_BASE}/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/files/${encodeURIComponent(filename)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain'
|
||||
},
|
||||
body: content
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadAgentSkillZip({
|
||||
agentId,
|
||||
file,
|
||||
|
||||
81
frontend/src/services/runtimeControls.js
Normal file
81
frontend/src/services/runtimeControls.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const normalizeSymbol = (symbol) => {
|
||||
if (typeof symbol !== "string") {
|
||||
return "";
|
||||
}
|
||||
return symbol.trim().toUpperCase();
|
||||
};
|
||||
|
||||
export const normalizeTickerSymbols = (symbols, previousTickers = []) => {
|
||||
if (!Array.isArray(symbols) || symbols.length === 0) {
|
||||
return previousTickers;
|
||||
}
|
||||
|
||||
return symbols
|
||||
.map(normalizeSymbol)
|
||||
.filter(Boolean)
|
||||
.reduce((acc, symbol) => {
|
||||
const existing = acc.find((ticker) => ticker.symbol === symbol);
|
||||
if (existing) {
|
||||
return acc;
|
||||
}
|
||||
const prior = previousTickers.find((ticker) => ticker.symbol === symbol);
|
||||
acc.push(
|
||||
prior || {
|
||||
symbol,
|
||||
price: null,
|
||||
change: null
|
||||
}
|
||||
);
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const normalizeRuntimeWatchlistSymbols = (runtimeConfig, fallbackTickers = []) => {
|
||||
const runtimeSymbols = Array.isArray(runtimeConfig?.tickers)
|
||||
? runtimeConfig.tickers.map(normalizeSymbol).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (runtimeSymbols.length > 0) {
|
||||
return runtimeSymbols;
|
||||
}
|
||||
|
||||
return fallbackTickers
|
||||
.map((ticker) => normalizeSymbol(ticker?.symbol))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const parseWatchlistInput = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(/[\s,]+/)
|
||||
.map(normalizeSymbol)
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const buildRuntimeSummaryLabel = (runtimeConfig) => {
|
||||
if (!runtimeConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduleMode = String(runtimeConfig.schedule_mode || "daily");
|
||||
const intervalMinutes = Number(runtimeConfig.interval_minutes || 60);
|
||||
const triggerTime = String(runtimeConfig.trigger_time || "now");
|
||||
const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2);
|
||||
|
||||
if (scheduleMode === "intraday") {
|
||||
return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles} 轮`;
|
||||
}
|
||||
|
||||
if (triggerTime.toLowerCase() === "now") {
|
||||
return `调度 daily / 立即执行 / 讨论 ${maxCommCycles} 轮`;
|
||||
}
|
||||
|
||||
return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles} 轮`;
|
||||
};
|
||||
59
frontend/src/services/runtimeControls.test.js
Normal file
59
frontend/src/services/runtimeControls.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRuntimeSummaryLabel,
|
||||
normalizeRuntimeWatchlistSymbols,
|
||||
normalizeTickerSymbols,
|
||||
parseWatchlistInput
|
||||
} from "./runtimeControls";
|
||||
|
||||
describe("runtimeControls", () => {
|
||||
it("normalizes ticker symbols while preserving existing entries", () => {
|
||||
const previous = [
|
||||
{ symbol: "AAPL", price: 10, change: 1 },
|
||||
{ symbol: "MSFT", price: 20, change: 2 }
|
||||
];
|
||||
|
||||
expect(normalizeTickerSymbols(["aapl", "nvda", "MSFT"], previous)).toEqual([
|
||||
{ symbol: "AAPL", price: 10, change: 1 },
|
||||
{ symbol: "NVDA", price: null, change: null },
|
||||
{ symbol: "MSFT", price: 20, change: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives runtime watchlist symbols from runtime config or fallback tickers", () => {
|
||||
const runtimeConfig = { tickers: ["tsla", "meta", "tsla"] };
|
||||
const fallbackTickers = [{ symbol: "AAPL" }, { symbol: "MSFT" }];
|
||||
|
||||
expect(normalizeRuntimeWatchlistSymbols(runtimeConfig, fallbackTickers)).toEqual([
|
||||
"TSLA",
|
||||
"META",
|
||||
"TSLA"
|
||||
]);
|
||||
expect(normalizeRuntimeWatchlistSymbols({}, fallbackTickers)).toEqual([
|
||||
"AAPL",
|
||||
"MSFT"
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses watchlist input tokens and removes duplicates", () => {
|
||||
expect(parseWatchlistInput(" aapl, msft nvda\nNVDA ")).toEqual([
|
||||
"AAPL",
|
||||
"MSFT",
|
||||
"NVDA"
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds runtime summary labels", () => {
|
||||
expect(buildRuntimeSummaryLabel({
|
||||
schedule_mode: "daily",
|
||||
trigger_time: "09:30",
|
||||
max_comm_cycles: 3
|
||||
})).toBe("调度 daily / 09:30 ET / 讨论 3 轮");
|
||||
|
||||
expect(buildRuntimeSummaryLabel({
|
||||
schedule_mode: "intraday",
|
||||
interval_minutes: 15,
|
||||
max_comm_cycles: 2
|
||||
})).toBe("调度 intraday / 15m / 讨论 2 轮");
|
||||
});
|
||||
});
|
||||
@@ -24,13 +24,13 @@ export async function fetchGatewayPort() {
|
||||
if (data.is_running && data.port) {
|
||||
cachedGatewayPort = data.port;
|
||||
cachedWsUrl = data.ws_url;
|
||||
return { port: data.port, wsUrl: data.ws_url };
|
||||
return { status: "running", port: data.port, wsUrl: data.ws_url };
|
||||
}
|
||||
|
||||
return null;
|
||||
return { status: "stopped", port: data.port || null, wsUrl: data.ws_url || null };
|
||||
} catch (error) {
|
||||
console.warn('[Gateway] Failed to fetch port:', error);
|
||||
return null;
|
||||
return { status: "unavailable", port: null, wsUrl: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,15 +86,29 @@ export class ReadOnlyClient {
|
||||
// Resolve WebSocket URL if not set
|
||||
let targetUrl = this.wsUrl;
|
||||
if (!targetUrl) {
|
||||
// Try to fetch from API first
|
||||
const gatewayInfo = await fetchGatewayPort();
|
||||
if (gatewayInfo) {
|
||||
if (gatewayInfo?.status === "running" && gatewayInfo.wsUrl) {
|
||||
targetUrl = gatewayInfo.wsUrl;
|
||||
console.log(`[WebSocket] Resolved Gateway port: ${gatewayInfo.port}`);
|
||||
} else {
|
||||
// Fallback to default
|
||||
} else if (gatewayInfo?.status === "unavailable") {
|
||||
targetUrl = WS_URL;
|
||||
console.log(`[WebSocket] Using default URL: ${targetUrl}`);
|
||||
} else {
|
||||
this.isConnecting = false;
|
||||
this._safeEmit({
|
||||
type: "system",
|
||||
content: "运行任务尚未启动,等待数据服务上线..."
|
||||
});
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this._connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +1,62 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Agent Store - Agent skills, profiles, workspaces
|
||||
*/
|
||||
export const useAgentStore = create((set) => ({
|
||||
// Selected agent for skill/workspace editing
|
||||
selectedSkillAgentId: null,
|
||||
setSelectedSkillAgentId: (selectedSkillAgentId) => set({ selectedSkillAgentId }),
|
||||
setSelectedSkillAgentId: (selectedSkillAgentId) => set((state) => ({ selectedSkillAgentId: resolveValue(selectedSkillAgentId, state.selectedSkillAgentId) })),
|
||||
|
||||
// Agent profiles
|
||||
agentProfilesByAgent: {},
|
||||
setAgentProfilesByAgent: (agentProfilesByAgent) => set({ agentProfilesByAgent }),
|
||||
setAgentProfilesByAgent: (agentProfilesByAgent) => set((state) => ({ agentProfilesByAgent: resolveValue(agentProfilesByAgent, state.agentProfilesByAgent) })),
|
||||
|
||||
// Agent skills
|
||||
agentSkillsByAgent: {},
|
||||
setAgentSkillsByAgent: (agentSkillsByAgent) => set({ agentSkillsByAgent }),
|
||||
setAgentSkillsByAgent: (agentSkillsByAgent) => set((state) => ({ agentSkillsByAgent: resolveValue(agentSkillsByAgent, state.agentSkillsByAgent) })),
|
||||
|
||||
// Skill details
|
||||
skillDetailsByName: {},
|
||||
setSkillDetailsByName: (skillDetailsByName) => set({ skillDetailsByName }),
|
||||
setSkillDetailsByName: (skillDetailsByName) => set((state) => ({ skillDetailsByName: resolveValue(skillDetailsByName, state.skillDetailsByName) })),
|
||||
|
||||
// Local skill drafts
|
||||
localSkillDraftsByKey: {},
|
||||
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set({ localSkillDraftsByKey }),
|
||||
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set((state) => ({ localSkillDraftsByKey: resolveValue(localSkillDraftsByKey, state.localSkillDraftsByKey) })),
|
||||
|
||||
// Loading states
|
||||
isAgentSkillsLoading: false,
|
||||
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set({ isAgentSkillsLoading }),
|
||||
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set((state) => ({ isAgentSkillsLoading: resolveValue(isAgentSkillsLoading, state.isAgentSkillsLoading) })),
|
||||
|
||||
skillDetailLoadingKey: null,
|
||||
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set({ skillDetailLoadingKey }),
|
||||
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set((state) => ({ skillDetailLoadingKey: resolveValue(skillDetailLoadingKey, state.skillDetailLoadingKey) })),
|
||||
|
||||
agentSkillsSavingKey: null,
|
||||
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set({ agentSkillsSavingKey }),
|
||||
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set((state) => ({ agentSkillsSavingKey: resolveValue(agentSkillsSavingKey, state.agentSkillsSavingKey) })),
|
||||
|
||||
agentSkillsFeedback: null,
|
||||
setAgentSkillsFeedback: (agentSkillsFeedback) => set({ agentSkillsFeedback }),
|
||||
setAgentSkillsFeedback: (agentSkillsFeedback) => set((state) => ({ agentSkillsFeedback: resolveValue(agentSkillsFeedback, state.agentSkillsFeedback) })),
|
||||
|
||||
// Workspace files
|
||||
selectedWorkspaceFile: null,
|
||||
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set({ selectedWorkspaceFile }),
|
||||
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set((state) => ({ selectedWorkspaceFile: resolveValue(selectedWorkspaceFile, state.selectedWorkspaceFile) })),
|
||||
|
||||
workspaceFilesByAgent: {},
|
||||
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set({ workspaceFilesByAgent }),
|
||||
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set((state) => ({ workspaceFilesByAgent: resolveValue(workspaceFilesByAgent, state.workspaceFilesByAgent) })),
|
||||
|
||||
workspaceDraftContent: '',
|
||||
setWorkspaceDraftContent: (workspaceDraftContent) => set({ workspaceDraftContent }),
|
||||
setWorkspaceDraftContent: (workspaceDraftContent) => set((state) => ({ workspaceDraftContent: resolveValue(workspaceDraftContent, state.workspaceDraftContent) })),
|
||||
|
||||
isWorkspaceFileLoading: false,
|
||||
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set({ isWorkspaceFileLoading }),
|
||||
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set((state) => ({ isWorkspaceFileLoading: resolveValue(isWorkspaceFileLoading, state.isWorkspaceFileLoading) })),
|
||||
|
||||
workspaceFileSavingKey: null,
|
||||
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set({ workspaceFileSavingKey }),
|
||||
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set((state) => ({ workspaceFileSavingKey: resolveValue(workspaceFileSavingKey, state.workspaceFileSavingKey) })),
|
||||
|
||||
workspaceFileFeedback: null,
|
||||
setWorkspaceFileFeedback: (workspaceFileFeedback) => set({ workspaceFileFeedback }),
|
||||
setWorkspaceFileFeedback: (workspaceFileFeedback) => set((state) => ({ workspaceFileFeedback: resolveValue(workspaceFileFeedback, state.workspaceFileFeedback) })),
|
||||
}));
|
||||
|
||||
@@ -1,44 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Market Store - Market data, stock prices, news
|
||||
*/
|
||||
export const useMarketStore = create((set) => ({
|
||||
// Ticker prices
|
||||
tickers: [],
|
||||
setTickers: (tickers) => set({ tickers }),
|
||||
setTickers: (tickers) => set((state) => ({ tickers: resolveValue(tickers, state.tickers) })),
|
||||
rollingTickers: {},
|
||||
setRollingTickers: (rollingTickers) => set({ rollingTickers }),
|
||||
setRollingTickers: (rollingTickers) => set((state) => ({ rollingTickers: resolveValue(rollingTickers, state.rollingTickers) })),
|
||||
|
||||
// Price history
|
||||
priceHistoryByTicker: {},
|
||||
setPriceHistoryByTicker: (priceHistoryByTicker) => set({ priceHistoryByTicker }),
|
||||
setPriceHistoryByTicker: (priceHistoryByTicker) => set((state) => ({ priceHistoryByTicker: resolveValue(priceHistoryByTicker, state.priceHistoryByTicker) })),
|
||||
|
||||
// OHLC history
|
||||
ohlcHistoryByTicker: {},
|
||||
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set({ ohlcHistoryByTicker }),
|
||||
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set((state) => ({ ohlcHistoryByTicker: resolveValue(ohlcHistoryByTicker, state.ohlcHistoryByTicker) })),
|
||||
|
||||
// History source tracking
|
||||
historySourceByTicker: {},
|
||||
setHistorySourceByTicker: (historySourceByTicker) => set({ historySourceByTicker }),
|
||||
setHistorySourceByTicker: (historySourceByTicker) => set((state) => ({ historySourceByTicker: resolveValue(historySourceByTicker, state.historySourceByTicker) })),
|
||||
|
||||
// Explain events
|
||||
explainEventsByTicker: {},
|
||||
setExplainEventsByTicker: (explainEventsByTicker) => set({ explainEventsByTicker }),
|
||||
setExplainEventsByTicker: (explainEventsByTicker) => set((state) => ({ explainEventsByTicker: resolveValue(explainEventsByTicker, state.explainEventsByTicker) })),
|
||||
|
||||
// Selected explain symbol
|
||||
selectedExplainSymbol: '',
|
||||
setSelectedExplainSymbol: (selectedExplainSymbol) => set({ selectedExplainSymbol }),
|
||||
setSelectedExplainSymbol: (selectedExplainSymbol) => set((state) => ({ selectedExplainSymbol: resolveValue(selectedExplainSymbol, state.selectedExplainSymbol) })),
|
||||
|
||||
// News by ticker
|
||||
newsByTicker: {},
|
||||
setNewsByTicker: (newsByTicker) => set({ newsByTicker }),
|
||||
setNewsByTicker: (newsByTicker) => set((state) => ({ newsByTicker: resolveValue(newsByTicker, state.newsByTicker) })),
|
||||
|
||||
// Insider trades
|
||||
insiderTradesByTicker: {},
|
||||
setInsiderTradesByTicker: (insiderTradesByTicker) => set({ insiderTradesByTicker }),
|
||||
setInsiderTradesByTicker: (insiderTradesByTicker) => set((state) => ({ insiderTradesByTicker: resolveValue(insiderTradesByTicker, state.insiderTradesByTicker) })),
|
||||
|
||||
// Technical indicators
|
||||
technicalIndicatorsByTicker: {},
|
||||
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set({ technicalIndicatorsByTicker }),
|
||||
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set((state) => ({ technicalIndicatorsByTicker: resolveValue(technicalIndicatorsByTicker, state.technicalIndicatorsByTicker) })),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Portfolio Store - Portfolio data, holdings, trades, statistics
|
||||
*/
|
||||
@@ -18,21 +22,21 @@ export const usePortfolioStore = create((set) => ({
|
||||
baseline_vw_return: 0,
|
||||
momentum_return: 0,
|
||||
},
|
||||
setPortfolioData: (portfolioData) => set({ portfolioData }),
|
||||
setPortfolioData: (portfolioData) => set((state) => ({ portfolioData: resolveValue(portfolioData, state.portfolioData) })),
|
||||
|
||||
// Holdings
|
||||
holdings: [],
|
||||
setHoldings: (holdings) => set({ holdings }),
|
||||
setHoldings: (holdings) => set((state) => ({ holdings: resolveValue(holdings, state.holdings) })),
|
||||
|
||||
// Trades
|
||||
trades: [],
|
||||
setTrades: (trades) => set({ trades }),
|
||||
setTrades: (trades) => set((state) => ({ trades: resolveValue(trades, state.trades) })),
|
||||
|
||||
// Statistics
|
||||
stats: null,
|
||||
setStats: (stats) => set({ stats }),
|
||||
setStats: (stats) => set((state) => ({ stats: resolveValue(stats, state.stats) })),
|
||||
|
||||
// Leaderboard
|
||||
leaderboard: [],
|
||||
setLeaderboard: (leaderboard) => set({ leaderboard }),
|
||||
setLeaderboard: (leaderboard) => set((state) => ({ leaderboard: resolveValue(leaderboard, state.leaderboard) })),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Runtime Store - Connection state and runtime configuration
|
||||
*/
|
||||
@@ -7,59 +11,62 @@ export const useRuntimeStore = create((set) => ({
|
||||
// Connection state
|
||||
isConnected: false,
|
||||
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
|
||||
setIsConnected: (isConnected) => set({ isConnected }),
|
||||
setConnectionStatus: (connectionStatus) => set({ connectionStatus }),
|
||||
setIsConnected: (isConnected) => set((state) => ({ isConnected: resolveValue(isConnected, state.isConnected) })),
|
||||
setConnectionStatus: (connectionStatus) => set((state) => ({ connectionStatus: resolveValue(connectionStatus, state.connectionStatus) })),
|
||||
|
||||
// System state
|
||||
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
|
||||
currentDate: null,
|
||||
setSystemStatus: (systemStatus) => set({ systemStatus }),
|
||||
setCurrentDate: (currentDate) => set({ currentDate }),
|
||||
setSystemStatus: (systemStatus) => set((state) => ({ systemStatus: resolveValue(systemStatus, state.systemStatus) })),
|
||||
setCurrentDate: (currentDate) => set((state) => ({ currentDate: resolveValue(currentDate, state.currentDate) })),
|
||||
|
||||
// Progress
|
||||
progress: { current: 0, total: 0 },
|
||||
setProgress: (progress) => set({ progress }),
|
||||
setProgress: (progress) => set((state) => ({ progress: resolveValue(progress, state.progress) })),
|
||||
|
||||
// Server mode
|
||||
serverMode: null, // 'live' | 'backtest' | null
|
||||
setServerMode: (serverMode) => set({ serverMode }),
|
||||
setServerMode: (serverMode) => set((state) => ({ serverMode: resolveValue(serverMode, state.serverMode) })),
|
||||
|
||||
// Market status
|
||||
marketStatus: null,
|
||||
virtualTime: null,
|
||||
setMarketStatus: (marketStatus) => set({ marketStatus }),
|
||||
setVirtualTime: (virtualTime) => set({ virtualTime }),
|
||||
setMarketStatus: (marketStatus) => set((state) => ({ marketStatus: resolveValue(marketStatus, state.marketStatus) })),
|
||||
setVirtualTime: (virtualTime) => set((state) => ({ virtualTime: resolveValue(virtualTime, state.virtualTime) })),
|
||||
|
||||
// Data sources
|
||||
dataSources: null,
|
||||
setDataSources: (dataSources) => set({ dataSources }),
|
||||
setDataSources: (dataSources) => set((state) => ({ dataSources: resolveValue(dataSources, state.dataSources) })),
|
||||
|
||||
// Runtime config
|
||||
runtimeConfig: null,
|
||||
setRuntimeConfig: (runtimeConfig) => set({ runtimeConfig }),
|
||||
setRuntimeConfig: (runtimeConfig) => set((state) => ({ runtimeConfig: resolveValue(runtimeConfig, state.runtimeConfig) })),
|
||||
|
||||
// Watchlist panel
|
||||
isWatchlistPanelOpen: false,
|
||||
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set({ isWatchlistPanelOpen }),
|
||||
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set((state) => ({ isWatchlistPanelOpen: resolveValue(isWatchlistPanelOpen, state.isWatchlistPanelOpen) })),
|
||||
|
||||
// Watchlist draft
|
||||
watchlistDraftSymbols: [],
|
||||
watchlistInputValue: '',
|
||||
watchlistFeedback: null,
|
||||
isWatchlistSaving: false,
|
||||
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set({ watchlistDraftSymbols }),
|
||||
setWatchlistInputValue: (watchlistInputValue) => set({ watchlistInputValue }),
|
||||
setWatchlistFeedback: (watchlistFeedback) => set({ watchlistFeedback }),
|
||||
setIsWatchlistSaving: (isWatchlistSaving) => set({ isWatchlistSaving }),
|
||||
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set((state) => ({ watchlistDraftSymbols: resolveValue(watchlistDraftSymbols, state.watchlistDraftSymbols) })),
|
||||
setWatchlistInputValue: (watchlistInputValue) => set((state) => ({ watchlistInputValue: resolveValue(watchlistInputValue, state.watchlistInputValue) })),
|
||||
setWatchlistFeedback: (watchlistFeedback) => set((state) => ({ watchlistFeedback: resolveValue(watchlistFeedback, state.watchlistFeedback) })),
|
||||
setIsWatchlistSaving: (isWatchlistSaving) => set((state) => ({ isWatchlistSaving: resolveValue(isWatchlistSaving, state.isWatchlistSaving) })),
|
||||
|
||||
// Runtime settings panel
|
||||
isRuntimeSettingsOpen: false,
|
||||
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set({ isRuntimeSettingsOpen }),
|
||||
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })),
|
||||
|
||||
// Runtime config drafts
|
||||
launchModeDraft: 'fresh',
|
||||
restoreRunIdDraft: '',
|
||||
runtimeHistoryRuns: [],
|
||||
scheduleModeDraft: 'daily',
|
||||
intervalMinutesDraft: '60',
|
||||
triggerTimeDraft: '09:30',
|
||||
triggerTimeDraft: 'now',
|
||||
maxCommCyclesDraft: '2',
|
||||
initialCashDraft: '100000',
|
||||
marginRequirementDraft: '0',
|
||||
@@ -68,23 +75,28 @@ export const useRuntimeStore = create((set) => ({
|
||||
pollIntervalDraft: '10',
|
||||
startDateDraft: '',
|
||||
endDateDraft: '',
|
||||
enableMockDraft: false,
|
||||
setScheduleModeDraft: (scheduleModeDraft) => set({ scheduleModeDraft }),
|
||||
setIntervalMinutesDraft: (intervalMinutesDraft) => set({ intervalMinutesDraft }),
|
||||
setTriggerTimeDraft: (triggerTimeDraft) => set({ triggerTimeDraft }),
|
||||
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set({ maxCommCyclesDraft }),
|
||||
setInitialCashDraft: (initialCashDraft) => set({ initialCashDraft }),
|
||||
setMarginRequirementDraft: (marginRequirementDraft) => set({ marginRequirementDraft }),
|
||||
setEnableMemoryDraft: (enableMemoryDraft) => set({ enableMemoryDraft }),
|
||||
setModeDraft: (modeDraft) => set({ modeDraft }),
|
||||
setPollIntervalDraft: (pollIntervalDraft) => set({ pollIntervalDraft }),
|
||||
setStartDateDraft: (startDateDraft) => set({ startDateDraft }),
|
||||
setEndDateDraft: (endDateDraft) => set({ endDateDraft }),
|
||||
setEnableMockDraft: (enableMockDraft) => set({ enableMockDraft }),
|
||||
setLaunchModeDraft: (launchModeDraft) => set((state) => ({ launchModeDraft: resolveValue(launchModeDraft, state.launchModeDraft) })),
|
||||
setRestoreRunIdDraft: (restoreRunIdDraft) => set((state) => ({ restoreRunIdDraft: resolveValue(restoreRunIdDraft, state.restoreRunIdDraft) })),
|
||||
setRuntimeHistoryRuns: (runtimeHistoryRuns) => set((state) => ({ runtimeHistoryRuns: resolveValue(runtimeHistoryRuns, state.runtimeHistoryRuns) })),
|
||||
setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })),
|
||||
setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })),
|
||||
setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })),
|
||||
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set((state) => ({ maxCommCyclesDraft: resolveValue(maxCommCyclesDraft, state.maxCommCyclesDraft) })),
|
||||
setInitialCashDraft: (initialCashDraft) => set((state) => ({ initialCashDraft: resolveValue(initialCashDraft, state.initialCashDraft) })),
|
||||
setMarginRequirementDraft: (marginRequirementDraft) => set((state) => ({ marginRequirementDraft: resolveValue(marginRequirementDraft, state.marginRequirementDraft) })),
|
||||
setEnableMemoryDraft: (enableMemoryDraft) => set((state) => ({ enableMemoryDraft: resolveValue(enableMemoryDraft, state.enableMemoryDraft) })),
|
||||
setModeDraft: (modeDraft) => set((state) => ({ modeDraft: resolveValue(modeDraft, state.modeDraft) })),
|
||||
setPollIntervalDraft: (pollIntervalDraft) => set((state) => ({ pollIntervalDraft: resolveValue(pollIntervalDraft, state.pollIntervalDraft) })),
|
||||
setStartDateDraft: (startDateDraft) => set((state) => ({ startDateDraft: resolveValue(startDateDraft, state.startDateDraft) })),
|
||||
setEndDateDraft: (endDateDraft) => set((state) => ({ endDateDraft: resolveValue(endDateDraft, state.endDateDraft) })),
|
||||
|
||||
// Runtime config feedback
|
||||
runtimeConfigFeedback: null,
|
||||
isRuntimeConfigSaving: false,
|
||||
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set({ runtimeConfigFeedback }),
|
||||
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set({ isRuntimeConfigSaving }),
|
||||
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set((state) => ({ runtimeConfigFeedback: resolveValue(runtimeConfigFeedback, state.runtimeConfigFeedback) })),
|
||||
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set((state) => ({ isRuntimeConfigSaving: resolveValue(isRuntimeConfigSaving, state.isRuntimeConfigSaving) })),
|
||||
|
||||
// Last day history (for replay)
|
||||
lastDayHistory: [],
|
||||
setLastDayHistory: (lastDayHistory) => set((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })),
|
||||
}));
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* UI Store - UI state, view management, layout
|
||||
*/
|
||||
export const useUIStore = create((set) => ({
|
||||
// Current view
|
||||
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
|
||||
setCurrentView: (currentView) => set({ currentView }),
|
||||
setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
|
||||
|
||||
// Chart tab
|
||||
chartTab: 'all',
|
||||
setChartTab: (chartTab) => set({ chartTab }),
|
||||
setChartTab: (chartTab) => set((state) => ({ chartTab: resolveValue(chartTab, state.chartTab) })),
|
||||
|
||||
// Initial animation
|
||||
isInitialAnimating: true,
|
||||
setIsInitialAnimating: (isInitialAnimating) => set({ isInitialAnimating }),
|
||||
setIsInitialAnimating: (isInitialAnimating) => set((state) => ({ isInitialAnimating: resolveValue(isInitialAnimating, state.isInitialAnimating) })),
|
||||
|
||||
// Last update timestamp
|
||||
lastUpdate: new Date(),
|
||||
setLastUpdate: (lastUpdate) => set({ lastUpdate }),
|
||||
setLastUpdate: (lastUpdate) => set((state) => ({ lastUpdate: resolveValue(lastUpdate, state.lastUpdate) })),
|
||||
|
||||
// Is updating
|
||||
isUpdating: false,
|
||||
setIsUpdating: (isUpdating) => set({ isUpdating }),
|
||||
setIsUpdating: (isUpdating) => set((state) => ({ isUpdating: resolveValue(isUpdating, state.isUpdating) })),
|
||||
|
||||
// Room bubbles
|
||||
bubbles: {},
|
||||
setBubbles: (bubbles) => set({ bubbles }),
|
||||
setBubbles: (bubbles) => set((state) => ({ bubbles: resolveValue(bubbles, state.bubbles) })),
|
||||
|
||||
// Resizable panels
|
||||
leftWidth: 70,
|
||||
setLeftWidth: (leftWidth) => set({ leftWidth }),
|
||||
setLeftWidth: (leftWidth) => set((state) => ({ leftWidth: resolveValue(leftWidth, state.leftWidth) })),
|
||||
isResizing: false,
|
||||
setIsResizing: (isResizing) => set({ isResizing }),
|
||||
setIsResizing: (isResizing) => set((state) => ({ isResizing: resolveValue(isResizing, state.isResizing) })),
|
||||
|
||||
// Now timestamp (for current time display)
|
||||
now: new Date(),
|
||||
setNow: (now) => set({ now }),
|
||||
setNow: (now) => set((state) => ({ now: resolveValue(now, state.now) })),
|
||||
}));
|
||||
|
||||
@@ -478,7 +478,7 @@ export default function GlobalStyles() {
|
||||
background: #ffffff;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.agent-indicator {
|
||||
@@ -578,11 +578,12 @@ export default function GlobalStyles() {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
z-index: 700;
|
||||
}
|
||||
|
||||
.room-scene-wrapper {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -646,7 +647,7 @@ export default function GlobalStyles() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
@@ -656,6 +657,7 @@ export default function GlobalStyles() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.room-canvas {
|
||||
@@ -666,7 +668,8 @@ export default function GlobalStyles() {
|
||||
|
||||
.room-bubble {
|
||||
position: absolute;
|
||||
max-width: 300px;
|
||||
max-width: 320px;
|
||||
max-height: 260px;
|
||||
font-size: 11px;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
@@ -676,6 +679,8 @@ export default function GlobalStyles() {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
line-height: 1.5;
|
||||
animation: bubbleAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
overflow: hidden;
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
@keyframes bubbleAppear {
|
||||
@@ -708,7 +713,7 @@ export default function GlobalStyles() {
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
z-index: 1510;
|
||||
}
|
||||
|
||||
.bubble-jump-btn,
|
||||
@@ -786,6 +791,9 @@ export default function GlobalStyles() {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
position: relative;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.bubble-expand-btn {
|
||||
|
||||
4
frontend/test-results/.last-run.json
Normal file
4
frontend/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
1
reference/CoPaw
Submodule
1
reference/CoPaw
Submodule
Submodule reference/CoPaw added at 934cfce0a7
1
reference/Hyper-Alpha-Arena
Submodule
1
reference/Hyper-Alpha-Arena
Submodule
Submodule reference/Hyper-Alpha-Arena added at f137cff476
1
reference/openclaw
Submodule
1
reference/openclaw
Submodule
Submodule reference/openclaw added at 7b151afeeb
10
start-dev.sh
10
start-dev.sh
@@ -29,13 +29,6 @@ else
|
||||
echo -e "${YELLOW}Warning: .env file not found${NC}"
|
||||
fi
|
||||
|
||||
# Check required environment variables
|
||||
if [ -z "$OPENAI_API_KEY" ]; then
|
||||
echo -e "${RED}Error: OPENAI_API_KEY not set${NC}"
|
||||
echo "Please set it in .env file or environment"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd /Users/cillin/workspeace/evotraders
|
||||
PIDS=()
|
||||
|
||||
@@ -50,7 +43,8 @@ start_service() {
|
||||
--port "${port}" \
|
||||
--reload \
|
||||
--reload-dir backend \
|
||||
--log-level info &
|
||||
--log-level warning \
|
||||
--no-access-log &
|
||||
PIDS+=($!)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user