2 Commits

Author SHA1 Message Date
3926a6bd07 feat: 架构修复 - P0/P1 问题全面修复
P0 修复:
- runtimeStore: 添加缺失的 lastDayHistory 字段
- Gateway/RuntimeService: 状态同步改为内存优先,消除 glob 竞态
- App.jsx: 从 3075 行重构到 ~500 行,提取 8 个独立文件

P1 修复:
- CORS: 4 个服务改为从环境变量读取允许 origins
- MarketStore: 改为模块级单例模式
- Domain 层: 删除 trading thin wrapper,保留 news 真实逻辑
- 测试: 补齐 77 个 gateway/runtime 测试

新增文件:
- backend/tests/test_gateway.py (43 tests)
- frontend/src/hooks/useWebSocketHandler.js
- frontend/src/hooks/useStockRequestCallbacks.js
- frontend/src/hooks/useAgentCallbacks.js
- frontend/src/hooks/useRuntimeCallbacks.js
- frontend/src/hooks/useWatchlistCallbacks.js
- frontend/src/components/TickerBar.jsx
- frontend/src/components/HeaderRight.jsx
- frontend/src/components/ChartTabs.jsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:45:57 +08:00
80256a4079 fix(frontend): 添加缺失的 lastDayHistory 字段到 runtimeStore
修复 App.jsx 中使用不存在的 store 字段导致的潜在运行时错误。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:20:29 +08:00
95 changed files with 5776 additions and 6789 deletions

View File

@@ -1,41 +0,0 @@
# 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
View File

@@ -54,13 +54,10 @@ outputs/
/smoke_live_mock/ /smoke_live_mock/
# Local tooling state # Local tooling state
.omc/ /.omc/
/.pydeps/ /.pydeps/
/referance/ /referance/
# Run outputs
/runs/
# Data files # Data files
backend/data/ret_data/ backend/data/ret_data/

View File

@@ -1,6 +1,6 @@
{ {
"version": "1.0.0", "version": "1.0.0",
"lastScanned": 1774313111650, "lastScanned": 1773938154948,
"projectRoot": "/Users/cillin/workspeace/evotraders", "projectRoot": "/Users/cillin/workspeace/evotraders",
"techStack": { "techStack": {
"languages": [ "languages": [
@@ -11,6 +11,14 @@
"markers": [ "markers": [
"pyproject.toml" "pyproject.toml"
] ]
},
{
"name": "C/C++",
"version": null,
"confidence": "high",
"markers": [
"Makefile"
]
} }
], ],
"frameworks": [ "frameworks": [
@@ -24,8 +32,8 @@
"runtime": null "runtime": null
}, },
"build": { "build": {
"buildCommand": null, "buildCommand": "make build",
"testCommand": "pytest", "testCommand": "make test",
"lintCommand": "ruff check", "lintCommand": "ruff check",
"devCommand": null, "devCommand": null,
"scripts": {} "scripts": {}
@@ -50,13 +58,24 @@
}, },
"customNotes": [], "customNotes": [],
"directoryMap": { "directoryMap": {
"agent-service": {
"path": "agent-service",
"purpose": null,
"fileCount": 2,
"lastAccessed": 1773938154941,
"keyFiles": [
"Dockerfile",
"requirements.txt"
]
},
"backend": { "backend": {
"path": "backend", "path": "backend",
"purpose": null, "purpose": null,
"fileCount": 4, "fileCount": 5,
"lastAccessed": 1774313111639, "lastAccessed": 1773938154941,
"keyFiles": [ "keyFiles": [
"__init__.py", "__init__.py",
"app.py",
"cli.py", "cli.py",
"gateway_server.py", "gateway_server.py",
"main.py" "main.py"
@@ -66,41 +85,37 @@
"path": "backtest", "path": "backtest",
"purpose": null, "purpose": null,
"fileCount": 0, "fileCount": 0,
"lastAccessed": 1774313111640, "lastAccessed": 1773938154941,
"keyFiles": [] "keyFiles": []
}, },
"data": { "data": {
"path": "data", "path": "data",
"purpose": "Data files", "purpose": "Data files",
"fileCount": 3, "fileCount": 1,
"lastAccessed": 1774313111640, "lastAccessed": 1773938154941,
"keyFiles": [ "keyFiles": [
"market_research.db", "market_research.db"
"market_research.db-shm",
"market_research.db-wal"
] ]
}, },
"deploy": { "deploy": {
"path": "deploy", "path": "deploy",
"purpose": null, "purpose": null,
"fileCount": 0, "fileCount": 0,
"lastAccessed": 1774313111640, "lastAccessed": 1773938154942,
"keyFiles": [] "keyFiles": []
}, },
"docs": { "docs": {
"path": "docs", "path": "docs",
"purpose": "Documentation", "purpose": "Documentation",
"fileCount": 1, "fileCount": 0,
"lastAccessed": 1774313111641, "lastAccessed": 1773938154942,
"keyFiles": [ "keyFiles": []
"compat-removal-plan.md"
]
}, },
"evotraders.egg-info": { "evotraders.egg-info": {
"path": "evotraders.egg-info", "path": "evotraders.egg-info",
"purpose": null, "purpose": null,
"fileCount": 6, "fileCount": 6,
"lastAccessed": 1774313111641, "lastAccessed": 1773938154942,
"keyFiles": [ "keyFiles": [
"PKG-INFO", "PKG-INFO",
"SOURCES.txt", "SOURCES.txt",
@@ -113,7 +128,7 @@
"path": "frontend", "path": "frontend",
"purpose": null, "purpose": null,
"fileCount": 13, "fileCount": 13,
"lastAccessed": 1774313111641, "lastAccessed": 1773938154942,
"keyFiles": [ "keyFiles": [
"README.md", "README.md",
"components.json", "components.json",
@@ -126,41 +141,51 @@
"path": "live", "path": "live",
"purpose": null, "purpose": null,
"fileCount": 0, "fileCount": 0,
"lastAccessed": 1774313111642, "lastAccessed": 1773938154943,
"keyFiles": [] "keyFiles": []
}, },
"logs": { "logs": {
"path": "logs", "path": "logs",
"purpose": null, "purpose": null,
"fileCount": 6, "fileCount": 7,
"lastAccessed": 1774313111642, "lastAccessed": 1773938154943,
"keyFiles": [ "keyFiles": [
"2026-03-16_00-48-03.log", "2026-03-16_00-48-03.log",
"2026-03-18_23-17-29.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-18_23-17-30.log",
"2026-03-19_00-18-04.log", "2026-03-19_00-18-04.log"
"2026-03-19_00-34-21.log" ]
},
"news-service": {
"path": "news-service",
"purpose": null,
"fileCount": 3,
"lastAccessed": 1773938154943,
"keyFiles": [
"Dockerfile",
"requirements.txt"
] ]
}, },
"reference": { "reference": {
"path": "reference", "path": "reference",
"purpose": null, "purpose": null,
"fileCount": 0, "fileCount": 0,
"lastAccessed": 1774313111643, "lastAccessed": 1773938154943,
"keyFiles": [] "keyFiles": []
}, },
"runs": { "runs": {
"path": "runs", "path": "runs",
"purpose": null, "purpose": null,
"fileCount": 0, "fileCount": 0,
"lastAccessed": 1774313111643, "lastAccessed": 1773938154944,
"keyFiles": [] "keyFiles": []
}, },
"scripts": { "scripts": {
"path": "scripts", "path": "scripts",
"purpose": "Build/utility scripts", "purpose": "Build/utility scripts",
"fileCount": 1, "fileCount": 1,
"lastAccessed": 1774313111644, "lastAccessed": 1773938154944,
"keyFiles": [ "keyFiles": [
"run_prod.sh" "run_prod.sh"
] ]
@@ -169,7 +194,7 @@
"path": "services", "path": "services",
"purpose": "Business logic services", "purpose": "Business logic services",
"fileCount": 1, "fileCount": 1,
"lastAccessed": 1774313111644, "lastAccessed": 1773938154944,
"keyFiles": [ "keyFiles": [
"README.md" "README.md"
] ]
@@ -178,21 +203,43 @@
"path": "shared", "path": "shared",
"purpose": null, "purpose": null,
"fileCount": 0, "fileCount": 0,
"lastAccessed": 1774313111644, "lastAccessed": 1773938154944,
"keyFiles": [] "keyFiles": []
}, },
"trading-service": {
"path": "trading-service",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1773938154944,
"keyFiles": [
"Dockerfile",
"README.md",
"requirements.txt"
]
},
"workspaces": { "workspaces": {
"path": "workspaces", "path": "workspaces",
"purpose": null, "purpose": null,
"fileCount": 0, "fileCount": 0,
"lastAccessed": 1774313111645, "lastAccessed": 1773938154944,
"keyFiles": [] "keyFiles": []
}, },
"agent-service/src": {
"path": "agent-service/src",
"purpose": "Source code",
"fileCount": 5,
"lastAccessed": 1773938154944,
"keyFiles": [
"__init__.py",
"config.py",
"main.py"
]
},
"backend/api": { "backend/api": {
"path": "backend/api", "path": "backend/api",
"purpose": "API routes", "purpose": "API routes",
"fileCount": 5, "fileCount": 5,
"lastAccessed": 1774313111645, "lastAccessed": 1773938154944,
"keyFiles": [ "keyFiles": [
"__init__.py", "__init__.py",
"agents.py", "agents.py",
@@ -203,7 +250,7 @@
"path": "backend/config", "path": "backend/config",
"purpose": "Configuration files", "purpose": "Configuration files",
"fileCount": 6, "fileCount": 6,
"lastAccessed": 1774313111646, "lastAccessed": 1773938154944,
"keyFiles": [ "keyFiles": [
"__init__.py", "__init__.py",
"agent_profiles.yaml", "agent_profiles.yaml",
@@ -214,7 +261,7 @@
"path": "backend/data", "path": "backend/data",
"purpose": "Data files", "purpose": "Data files",
"fileCount": 13, "fileCount": 13,
"lastAccessed": 1774313111647, "lastAccessed": 1773938154944,
"keyFiles": [ "keyFiles": [
"__init__.py", "__init__.py",
"cache.py", "cache.py",
@@ -225,7 +272,7 @@
"path": "docs/assets", "path": "docs/assets",
"purpose": "Static assets", "purpose": "Static assets",
"fileCount": 5, "fileCount": 5,
"lastAccessed": 1774313111647, "lastAccessed": 1773938154944,
"keyFiles": [ "keyFiles": [
"dashboard.jpg", "dashboard.jpg",
"evotraders_demo.gif", "evotraders_demo.gif",
@@ -236,7 +283,7 @@
"path": "frontend/dist", "path": "frontend/dist",
"purpose": "Distribution/build output", "purpose": "Distribution/build output",
"fileCount": 2, "fileCount": 2,
"lastAccessed": 1774313111647, "lastAccessed": 1773938154945,
"keyFiles": [ "keyFiles": [
"index.html", "index.html",
"trading_logo.png" "trading_logo.png"
@@ -246,261 +293,331 @@
"path": "frontend/node_modules", "path": "frontend/node_modules",
"purpose": "Dependencies", "purpose": "Dependencies",
"fileCount": 1, "fileCount": 1,
"lastAccessed": 1774313111650, "lastAccessed": 1773938154947,
"keyFiles": [] "keyFiles": []
},
"news-service/src": {
"path": "news-service/src",
"purpose": "Source code",
"fileCount": 3,
"lastAccessed": 1773938154948,
"keyFiles": [
"__init__.py",
"config.py",
"main.py"
]
},
"trading-service/src": {
"path": "trading-service/src",
"purpose": "Source code",
"fileCount": 8,
"lastAccessed": 1773938154948,
"keyFiles": [
"__init__.py",
"config.py",
"main.py"
]
} }
}, },
"hotPaths": [ "hotPaths": [
{ {
"path": "CLAUDE.md", "path": "backend/agents/factory.py",
"accessCount": 15, "accessCount": 17,
"lastAccessed": 1774342728155, "lastAccessed": 1773939950376,
"type": "file"
},
{
"path": "backend",
"accessCount": 16,
"lastAccessed": 1773940042371,
"type": "directory" "type": "directory"
}, },
{
"path": "frontend/src/App.jsx",
"accessCount": 10,
"lastAccessed": 1774339397617,
"type": "file"
},
{
"path": "frontend/src/hooks/useWebsocketSessionSync.js",
"accessCount": 4,
"lastAccessed": 1774313470024,
"type": "file"
},
{ {
"path": "", "path": "",
"accessCount": 4, "accessCount": 13,
"lastAccessed": 1774339108220, "lastAccessed": 1773939899611,
"type": "directory" "type": "directory"
}, },
{
"path": "backend/services/gateway.py",
"accessCount": 3,
"lastAccessed": 1774339389171,
"type": "file"
},
{ {
"path": "backend/main.py", "path": "backend/main.py",
"accessCount": 3, "accessCount": 7,
"lastAccessed": 1774342613364, "lastAccessed": 1773939993951,
"type": "file" "type": "file"
}, },
{ {
"path": "frontend/src/store/runtimeStore.js", "path": "backend/gateway_server.py",
"accessCount": 2, "accessCount": 7,
"lastAccessed": 1774317990919, "lastAccessed": 1773940004402,
"type": "file" "type": "file"
}, },
{ {
"path": "frontend/src/services/websocket.js", "path": "backend/services/news/main.py",
"accessCount": 2, "accessCount": 5,
"lastAccessed": 1774318009819, "lastAccessed": 1773938385662,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/core/pipeline_runner.py", "path": "backend/core/pipeline.py",
"accessCount": 2, "accessCount": 5,
"lastAccessed": 1774339367538, "lastAccessed": 1773940024933,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/runtime/manager.py", "path": "backend/services/news/enrich/news_enricher.py",
"accessCount": 2, "accessCount": 4,
"lastAccessed": 1774339367572, "lastAccessed": 1773938508417,
"type": "file"
},
{
"path": "frontend/src/store/marketStore.js",
"accessCount": 1,
"lastAccessed": 1774313140483,
"type": "file"
},
{
"path": "frontend/src/hooks/useFeedProcessor.js",
"accessCount": 1,
"lastAccessed": 1774313148279,
"type": "file"
},
{
"path": "frontend/src/components/Header.jsx",
"accessCount": 1,
"lastAccessed": 1774313156696,
"type": "file"
},
{
"path": "frontend/src/components/TraderView.jsx",
"accessCount": 1,
"lastAccessed": 1774313156753,
"type": "file"
},
{
"path": "frontend/src/store/uiStore.js",
"accessCount": 1,
"lastAccessed": 1774313187460,
"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" "type": "file"
}, },
{ {
"path": "start-dev.sh", "path": "start-dev.sh",
"accessCount": 1, "accessCount": 4,
"lastAccessed": 1774317979859, "lastAccessed": 1773939259381,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/apps/agent_service.py", "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,
"type": "file"
},
{
"path": "backend/core/__init__.py",
"accessCount": 3,
"lastAccessed": 1773939963627,
"type": "file"
},
{
"path": "backend/services/trading/main.py",
"accessCount": 2,
"lastAccessed": 1773938360736,
"type": "file"
},
{
"path": "backend/services/agents/main.py",
"accessCount": 2,
"lastAccessed": 1773938361040,
"type": "file"
},
{
"path": "backend/services/trading/data/__init__.py",
"accessCount": 2,
"lastAccessed": 1773938402496,
"type": "file"
},
{
"path": "backend/services/news/explain/__init__.py",
"accessCount": 2,
"lastAccessed": 1773938460019,
"type": "file"
},
{
"path": "backend/services/news/enrich/__init__.py",
"accessCount": 2,
"lastAccessed": 1773938465216,
"type": "file"
},
{
"path": "backend/services/news/explain/range_explainer.py",
"accessCount": 2,
"lastAccessed": 1773938481152,
"type": "file"
},
{
"path": "backend/services/news/enrich/llm_enricher.py",
"accessCount": 2,
"lastAccessed": 1773938499885,
"type": "file"
},
{
"path": "CLAUDE.md",
"accessCount": 2,
"lastAccessed": 1773939273598,
"type": "file"
},
{
"path": "backend/agents/__init__.py",
"accessCount": 2,
"lastAccessed": 1773939883015,
"type": "file"
},
{
"path": "backend/agents/agent_core.py",
"accessCount": 2,
"lastAccessed": 1773939886997,
"type": "file"
},
{
"path": "Makefile",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774317984348, "lastAccessed": 1773938226307,
"type": "file"
},
{
"path": "docker-compose.yml",
"accessCount": 1,
"lastAccessed": 1773938226360,
"type": "file"
},
{
"path": "backend/services/news/shared/trading_client.py",
"accessCount": 1,
"lastAccessed": 1773938370618,
"type": "file"
},
{
"path": "backend/services/agents",
"accessCount": 1,
"lastAccessed": 1773938397772,
"type": "directory"
},
{
"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,
"type": "file"
},
{
"path": "shared/client/news_client.py",
"accessCount": 1,
"lastAccessed": 1773938638715,
"type": "file" "type": "file"
}, },
{ {
"path": "shared/client/trading_client.py", "path": "shared/client/trading_client.py",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774317984365, "lastAccessed": 1773938638770,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/apps/trading_service.py", "path": "backend/api",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774317984408, "lastAccessed": 1773938669143,
"type": "directory"
},
{
"path": "frontend",
"accessCount": 1,
"lastAccessed": 1773938669195,
"type": "directory"
},
{
"path": ".env.example",
"accessCount": 1,
"lastAccessed": 1773938849397,
"type": "file" "type": "file"
}, },
{ {
"path": "pyproject.toml", "path": "frontend/src/services/websocket.js",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774317990970, "lastAccessed": 1773938849448,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/agents/factory.py", "path": "frontend/src/services/runtimeApi.js",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774318009867, "lastAccessed": 1773938849500,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/config/constants.py", "path": "backend/services/agents/routes/websocket.py",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774318009922, "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" "type": "file"
}, },
{ {
"path": "backend/api/__init__.py", "path": "backend/api/__init__.py",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774318009973, "lastAccessed": 1773939658650,
"type": "file" "type": "file"
}, },
{ {
"path": "README.md", "path": "backend/runtime/__init__.py",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774339107381, "lastAccessed": 1773939658687,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/runtime/registry.py", "path": "backend/agents/base/evo_agent.py",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774339380024, "lastAccessed": 1773939664916,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/runtime/session.py", "path": "backend/agents/analyst.py",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774339380084, "lastAccessed": 1773939664967,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/runtime/context.py", "path": "backend/agents/base/hooks.py",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774339380120, "lastAccessed": 1773939672727,
"type": "file" "type": "file"
}, },
{ {
"path": "backend/runtime/agent_runtime.py", "path": "pyproject.toml",
"accessCount": 1, "accessCount": 1,
"lastAccessed": 1774339380185, "lastAccessed": 1773939672778,
"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" "type": "file"
} }
], ],

View File

@@ -1,6 +1,6 @@
{ {
"timestamp": "2026-03-24T07:58:12.123Z", "timestamp": "2026-03-19T16:36:52.471Z",
"backgroundTasks": [], "backgroundTasks": [],
"sessionStartTimestamp": "2026-03-24T07:58:09.417Z", "sessionStartTimestamp": "2026-03-19T16:36:42.224Z",
"sessionId": "fda34772-7bd2-402e-86b2-d656296416f3" "sessionId": "ef02339a-1eec-4c7a-95ac-c8cfa0b5067d"
} }

View File

@@ -1 +1 @@
{"session_id":"fda34772-7bd2-402e-86b2-d656296416f3","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/fda34772-7bd2-402e-86b2-d656296416f3.jsonl","cwd":"/Users/cillin/workspeace/evotraders","model":{"id":"MiniMax-M2.7-highspeed","display_name":"MiniMax-M2.7-highspeed"},"workspace":{"current_dir":"/Users/cillin/workspeace/evotraders","project_dir":"/Users/cillin/workspeace/evotraders","added_dirs":[]},"version":"2.1.78","output_style":{"name":"default"},"cost":{"total_cost_usd":36.63980749999998,"total_duration_ms":69778027,"total_api_duration_ms":2925118,"total_lines_added":3056,"total_lines_removed":4537},"context_window":{"total_input_tokens":910503,"total_output_tokens":145207,"context_window_size":200000,"current_usage":{"input_tokens":507,"output_tokens":247,"cache_creation_input_tokens":4132,"cache_read_input_tokens":96553},"used_percentage":51,"remaining_percentage":49},"exceeds_200k_tokens":false} {"session_id":"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}

View File

@@ -1,3 +1,3 @@
{ {
"lastSentAt": "2026-03-24T08:58:57.965Z" "lastSentAt": "2026-03-19T17:02:32.170Z"
} }

View File

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

430
CLAUDE.md
View File

@@ -1,7 +1,5 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。 本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
## 项目概述 ## 项目概述
@@ -25,20 +23,18 @@ evotraders live -t 22:30 # 定时每日交易
evotraders frontend # 启动可视化界面 evotraders frontend # 启动可视化界面
# 开发服务器 # 开发服务器
./start-dev.sh # 启动全部 4 个微服务 (agent, runtime, trading, news) ./start-dev.sh # 启动全部 4 个微服务
# 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.agent_service:app --host 0.0.0.0 --port 8000 --reload
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
# 测试 # 测试
pytest backend/tests # 运行全部测试 pytest backend/tests # 运行全部测试
pytest backend/tests/test_news_service_app.py -v # 运行单个测试 pytest backend/tests/test_news_service_app.py -v # 运行单个测试文件
pytest backend/tests/test_news_service_app.py::test_news_service_routes_are_exposed -v # 运行单个测试
``` ```
### Frontend (React) ### Frontend (React)
@@ -50,237 +46,142 @@ npm run build # 生产构建
npm run lint # ESLint 检查 npm run lint # ESLint 检查
npm run lint:fix # ESLint 自动修复 npm run lint:fix # ESLint 自动修复
npm run test # Vitest 单元测试 npm run test # Vitest 单元测试
npm run test:watch # 监听模式
``` ```
## 架构概览 ## 架构概览
### 系统分层
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ WebSocket ws://localhost:8765 连接 Gateway │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Gateway (backend/services/gateway.py) │
│ WebSocket 服务器,编排 Pipeline4 阶段启动 │
└─────────────────────────────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ Market │ │ Storage │ │ Pipeline │ │ Scheduler │
│ Service │ │ Service │ │ │ │ │
└────────────┘ └────────────┘ └────────────┘ └────────────┘
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Analysts │ │ PM │ │ Risk │
│ (4 个) │ │ │ │ Manager │
└──────────┘ └──────────┘ └──────────┘
```
### 微服务架构 (`backend/apps/`) ### 微服务架构 (`backend/apps/`)
| 服务 | 端口 | 职责 | 项目采用 split-first 微服务架构4 个独立的 FastAPI 服务:
|------|------|------|
| runtime_service | 8003 | 运行时配置、任务启动、Pipeline Runner |
| agent_service | 8000 | Agent 生命周期、工作区管理 |
| trading_service | 8001 | 市场数据、交易操作 |
| news_service | 8002 | 新闻、新闻富化、解释功能 |
### Gateway 4 阶段启动 (`backend/services/gateway.py`) | 服务 | 入口 | 端口 | 职责 |
|------|------|------|------|
| 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 | 新闻、新闻富化、解释功能 |
1. **WebSocket Server** - 前端立即可连接 服务间通过环境变量通信(详见 `start-dev.sh`
2. **Market Service** - 价格数据开始推送 ```bash
3. **Market Status Monitor** - 市场状态监控 export TRADING_SERVICE_URL=http://localhost:8001
4. **Scheduler** - 交易周期开始 export NEWS_SERVICE_URL=http://localhost:8002
export RUNTIME_SERVICE_URL=http://localhost:8003
```
### 运行时管理层 (`backend/runtime/`) ### Gateway 网关 (`backend/services/gateway.py`)
| 文件 | 职责 | Gateway 是统一的请求路由器,根据路径前缀将请求转发到对应的微服务:
|------|------| - `/control/*` → agent_service
| `manager.py` | TradingRuntimeManager - 全局运行时管理器agent 注册、会话、事件快照 | - `/runtime/*` → runtime_service
| `agent_runtime.py` | AgentRuntimeState - 单 agent 状态status、last_session | - `/trading/*` → trading_service
| `context.py` | TradingRunContext - 运行上下文 | - `/news/*` → news_service
| `session.py` | TradingSessionKey - 交易日会话键 |
| `registry.py` | RuntimeRegistry - agent 状态注册表 |
快照持久化到 `runs/<run_id>/state/runtime_state.json` 新增接口时应注册到对应的 service app而非直接添加到 gateway
### Pipeline 执行 (`backend/core/`) ### 共享客户端 (`shared/client/`)
| 文件 | 职责 | 统一的服务客户端库,所有前端和后端服务间通信都使用此处定义的客户端:
|------|------|
| `pipeline.py` | TradingPipeline - 核心编排器(分析→沟通→决策→执行→评估) | | 客户端 | 用途 |
| `pipeline_runner.py` | REST API 触发的独立执行5 阶段启动 | |--------|------|
| `scheduler.py` | BacktestScheduler、Scheduler - 回测/实盘调度 | | `ControlPlaneClient` | Agent 服务通信 |
| `state_sync.py` | StateSync - 状态同步和广播 | | `RuntimeServiceClient` | 运行时服务通信 |
| `TradingServiceClient` | 交易服务通信 |
| `NewsServiceClient` | 新闻服务通信 |
### 领域层 (`backend/domains/`)
业务逻辑按领域分离:
- `news.py` - 新闻领域操作
- `trading.py` - 交易领域操作
## 后端结构 ## 后端结构
``` ```
backend/ backend/
├── agents/ # 多智能体实现 ├── agents/ # 多智能体实现
│ ├── analyst.py # AnalystAgent 基类 │ ├── base/ # 核心类、Hooks、评估
│ ├── portfolio_manager.py # PMAgent 投资经理 │ ├── evo_agent.py # 基于 AgentScope 的核心实现
│ ├── risk_manager.py # RiskAgent 风控经理 │ ├── hooks.py # 生命周期 Hooks
├── factory.py # Agent 实例工厂 │ │ ├── BootstrapHook # 启动初始化
├── toolkit_factory.py # 工具集工厂 │ │ ├── MemoryCompactionHook # 内存压缩(基于 CoPaw
├── skills_manager.py # 技能加载管理 │ │ ├── HeartbeatHook # 心跳检测
── workspace_manager.py # 工作区管理 │ │ └── WorkspaceWatchHook # 工作区监控
│ ├── skill_loader.py # 技能加载器 │ ├── evaluation_hook.py # 执行后评估
│ ├── agent_workspace.py # Agent 工作区 │ ├── skill_adaptation_hook.py # 动态技能适配
├── prompt_loader.py # Prompt 加载器 │ └── tool_guard.py # 工具调用守卫
│ ├── prompt_factory.py # Prompt 工厂 │ ├── prompts/ # Agent 提示词和角色定义
│ ├── skill_metadata.py # 技能元数据 │ ├── analyst/personas.yaml # 分析师角色配置
├── registry.py # Agent 注册表 │ └── portfolio_manager/
│ ├── team_pipeline_config.py # 团队 Pipeline 配置 │ ├── team/ # 团队协作逻辑
│ ├── compat.py # 兼容性层 │ ├── registry.py # Agent 注册表
│ ├── templates.py # 模板 │ ├── coordinator.py # 协作协调器
│ ├── workspace.py # 工作区 │ ├── messenger.py # 消息传递
── base/ # 核心类、Hooks │ └── task_delegator.py # 任务分发
│ ├── evo_agent.py # 基于 AgentScope 的核心实现 │ ├── factory.py # Agent 实例工厂
│ └── hooks.py # 生命周期 Hooks ├── skills_manager.py # 技能加载管理6 种作用域)
│ └── prompts/ # Agent 提示词 │ └── toolkit_factory.py # 工具集工厂
│ └── analyst/personas.yaml ├── apps/ # 微服务入口split-first
├── agent_service.py
├── apps/ # 微服务入口 │ ├── runtime_service.py
│ ├── runtime_service.py # 运行时服务(端口 8003 │ ├── trading_service.py
── agent_service.py # Agent 服务(端口 8000 ── news_service.py
│ ├── trading_service.py # 交易服务(端口 8001 ├── domains/ # 领域业务逻辑
│ ├── 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 │ ├── news.py
│ └── trading.py │ └── trading.py
├── services/ # Gateway 和辅助服务
├── llm/ # LLM 集成 │ ├── gateway.py # 统一路由网关
── models.py # RetryChatModel、TokenRecordingModelWrapper ── gateway_*.py # Gateway 子模块
└── market.py # 市场数据服务
├── skills/ # 技能定义 ├── api/ # FastAPI 端点
├── tools/ # 交易和分析工具 ├── config/ # 常量和配置
├── enrich/ # LLM 响应富化 │ └── constants.py # Agent 配置、显示名称等
├── explain/ # 交易决策解释 ├── core/ # Pipeline 执行逻辑
├── utils/ # 工具函数 ├── data/ # 市场数据处理
│ ├── settlement.py # 结算协调器 │ ├── provider_router.py # 数据源路由
── trade_executor.py # 交易执行器 ── schema.py # 数据 schema
│ ├── terminal_dashboard.py # 终端仪表板 ├── enrich/ # LLM 响应富化
│ ├── analyst_tracker.py # 分析师追踪 ├── explain/ # 交易决策解释
│ ├── baselines.py # 基准线 ├── llm/ # LLM 集成
── msg_adapter.py # 消息适配器 ── models.py # RetryChatModel、TokenRecordingModelWrapper
│ └── progress.py # 进度追踪 ├── skills/ # 技能定义(内置 + 自定义)
├── tools/ # 交易和分析工具
── api/ # FastAPI 端点 ── utils/ # 工具函数
│ └── runtime.py
└── main.py # 主入口点
``` ```
## 前端结构 ## 前端结构
``` ```
frontend/src/ frontend/src/
├── App.jsx # 主应用LiveTradingApp ├── App.jsx # React 主应用
├── AppShell.jsx # App 外壳(布局、侧边栏) ├── components/ # React 组件
├── components/ │ ├── RuntimeView.jsx # 交易运行时 UI
│ ├── RuntimeView.jsx # 交易运行时 UI │ ├── TraderView.jsx # 交易员界面
│ ├── TraderView.jsx # 交易员界面 │ ├── RoomView.jsx # 聊天室视图
│ ├── RoomView.jsx # 聊天室视图 │ ├── StockExplainView.jsx # 股票解释视图
│ ├── StockExplainView.jsx # 股票解释视图
│ ├── RuntimeSettingsPanel.jsx # 运行时设置面板 │ ├── RuntimeSettingsPanel.jsx # 运行时设置面板
│ ├── RuntimeLogsModal.jsx # 运行时日志弹窗 │ ├── WatchlistPanel.jsx # 关注列表
│ ├── WatchlistPanel.jsx # 关注列表 │ ├── PerformanceView.jsx # 绩效视图
│ ├── PerformanceView.jsx # 绩效视图 │ ├── StatisticsView.jsx # 统计视图
│ ├── StatisticsView.jsx # 统计视 │ ├── NetValueChart.jsx # 净值曲线
│ ├── NetValueChart.jsx # 净值曲线图 │ ├── AgentCard.jsx # Agent 卡片
│ ├── AgentCard.jsx # Agent 卡片 │ ├── AgentFeed.jsx # Agent 动态
── AgentFeed.jsx # Agent 动态 ── explain/ # 解释相关组件
│ ├── Header.jsx # 头部
│ ├── MarkdownModal.jsx # Markdown 弹窗
│ ├── StockLogo.jsx # 股票 Logo
│ └── explain/ # 解释组件
│ ├── ExplainNewsSection.jsx │ ├── ExplainNewsSection.jsx
│ ├── ExplainRangeSection.jsx │ ├── ExplainRangeSection.jsx
│ ├── ExplainSimilarDaysSection.jsx │ ├── ExplainSimilarDaysSection.jsx
│ ├── ExplainStorySection.jsx │ ├── ExplainStorySection.jsx
│ └── useExplainModel.js │ └── useExplainModel.js
├── hooks/ # React Hooks ├── services/ # API 服务
│ ├── useWebSocketConnection.js # WebSocket 连接管理 │ ├── runtimeApi.js # 运行时 API 调用
│ ├── useRuntimeControls.js # 运行时配置管理 │ ├── websocket.js # WebSocket 实时通信
│ ├── useAgentDataRequests.js # Agent 数据请求 │ ├── newsApi.js # 新闻服务客户端
── useStockDataRequests.js # 股票数据请求 ── tradingApi.js # 交易服务客户端
│ ├── useStockExplainData.js # 股票解释数据 ├── config/
── useAgentWorkspacePanel.js # Agent 工作区面板 ── constants.js # Agent 定义、配置
│ ├── useWebsocketSessionSync.js # WebSocket 会话同步 └── hooks/ # React Hooks
│ └── useFeedProcessor.js # Feed 事件处理
├── store/ # Zustand 状态管理
│ ├── runtimeStore.js # 连接状态、运行时配置
│ ├── marketStore.js # 市场数据、股票价格
│ ├── portfolioStore.js # 组合、持仓、交易
│ ├── agentStore.js # Agent 技能、工作区
│ └── uiStore.js # UI 状态、视图切换
├── services/
│ ├── websocket.js # WebSocket 客户端
│ ├── runtimeApi.js # 运行时 API
│ ├── runtimeControls.js # 运行时控制
│ ├── newsApi.js # 新闻 API
│ └── tradingApi.js # 交易 API
├── utils/
│ ├── formatters.js # 格式化工具
│ └── modelIcons.js # 模型图标
└── config/
└── constants.js # Agent 定义、配置
``` ```
## Agent 系统 ## Agent 系统
@@ -292,87 +193,110 @@ frontend/src/
| `fundamentals_analyst` | 基本面分析师 | 财务健康、盈利能力、成长质量 | | `fundamentals_analyst` | 基本面分析师 | 财务健康、盈利能力、成长质量 |
| `technical_analyst` | 技术分析师 | 价格趋势、技术指标、动量分析 | | `technical_analyst` | 技术分析师 | 价格趋势、技术指标、动量分析 |
| `sentiment_analyst` | 情绪分析师 | 市场情绪、新闻情绪、内幕交易 | | `sentiment_analyst` | 情绪分析师 | 市场情绪、新闻情绪、内幕交易 |
| `valuation_analyst` | 估值分析师 | DCF、EV/EBITDA、intrinsic value | | `valuation_analyst` | 估值分析师 | DCF、EV/EBITDA、 intrinsic value |
| `portfolio_manager` | 投资经理 | 决策执行、交易协调 | | `portfolio_manager` | 投资经理 | 决策执行、交易协调 |
| `risk_manager` | 风控经理 | 实时价格/波动率监控、仓位限制 | | `risk_manager` | 风控经理 | 实时价格/波动率监控、仓位限制、多层风险预警 |
### Hook 系统 (`base/hooks.py`)
- **MemoryCompactionHook**: 基于 CoPaw 的内存压缩
- `memory_compact_ratio`: 压缩目标比例(默认 0.75
- `memory_reserve_ratio`: 保留比例(默认 0.1
- `enable_tool_result_compact`: 工具结果压缩
- `tool_result_compact_keep_n`: 保留最近 N 条工具结果
### 添加自定义分析师 ### 添加自定义分析师
1. `backend/agents/prompts/analyst/personas.yaml` 注册 1. `backend/agents/prompts/analyst/personas.yaml` 注册
2. `backend/config/constants.py``ANALYST_TYPES` 字典添加 2. `backend/config/constants.py``ANALYST_TYPES` 字典添加
3. `frontend/src/config/constants.js` 可选更新 3. 可选:在 `frontend/src/config/constants.js` 中更新前端配置
### LLM 模型封装 (`backend/llm/models.py`) ### LLM 模型封装 (`backend/llm/models.py`)
- **RetryChatModel**: 自动重试瞬态 LLM 错误,指数退避 基于 CoPaw 的模型封装设计:
- **TokenRecordingModelWrapper**: 追踪 token 消耗和成本
- **RetryChatModel**: 自动重试瞬态 LLM 错误rate limit、timeout、502/503 等),指数退避
- `max_retries`: 最大重试次数(默认 3
- `initial_delay`: 初始延迟秒数(默认 1.0
- `backoff_multiplier`: 退避倍数(默认 2.0
- **TokenRecordingModelWrapper**: 追踪每个 provider 的 token 消耗和成本
```python
from backend.llm.models import create_model, RetryChatModel
model = RetryChatModel(create_model("gpt-4o", "OPENAI"), max_retries=3)
```
## 技能系统 (`backend/skills/`) ## 技能系统 (`backend/skills/`)
技能定义在 `SKILL.md`,包含 `instructions``triggers``parameters``available_tools` 技能定义在 `SKILL.md` 文件中,包含:
- `instructions` - 技能说明
- `triggers` - 触发条件
- `parameters` - 输入/输出 schema
- `available_tools` - 技能可使用的工具
技能由 `skills_manager.py` 加载,通过 `skill_adaptation_hook.py` 绑定到 Agent。
技能管理器支持 6 种作用域builtin、customized、installed、active、disabled、local。 技能管理器支持 6 种作用域builtin、customized、installed、active、disabled、local。
## 运行时数据布局 ## Pipeline 执行 (`backend/core/`)
- `data/market_research.db` - 持久研究数据 每日交易流程:
- `runs/<run_id>/` - 每次任务运行的状态
- `runs/<run_id>/team_dashboard/*.json` - 仪表板导出层(非权威源) 1. **分析阶段** - 各 Agent 基于工具和历史经验独立分析
- `runs/<run_id>/state/runtime_state.json` - 运行时快照 2. **沟通阶段** - 通过私聊、通知、会议等方式交换观点1v1/1vN/NvN
- 运行时 API 优先使用 `server_state.json``runtime.db` 3. **决策阶段** - 投资经理综合判断,给出最终交易
4. **评估阶段** - 绩效跟踪
5. **复盘阶段** - Agent 根据当日实际收益反思总结,通过 ReMe 记忆框架更新经验
## 前端状态管理
项目正在向 Zustand 状态管理过渡,已创建的 store
```bash ```bash
RUNS_RETENTION_COUNT=20 # 时间戳格式文件夹自动清理 frontend/src/store/
├── index.js # 导出所有 store
├── runtimeStore.js # 连接状态、运行时配置
├── marketStore.js # 市场数据、股票价格
├── portfolioStore.js # 组合、持仓、交易
├── agentStore.js # Agent 技能、工作区
└── uiStore.js # UI 状态、视图切换
``` ```
**迁移状态**
- Stores 已创建但尚未在 App.jsx 中使用
- 计划:逐步迁移 60+ 个 useState 到对应 store
## 环境配置 ## 环境配置
### Backend (`env.template`) `.env` 必需配置:
```bash ```bash
# 金融数据源支持多源fallback # 金融数据源
FIN_DATA_SOURCE=finnhub|financial_datasets|yfinance|local_csv FIN_DATA_SOURCE=finnhub|financial_datasets
ENABLED_DATA_SOURCES=financial_datasets,finnhub,yfinance,local_csv
FINANCIAL_DATASETS_API_KEY= # 回测必需 FINANCIAL_DATASETS_API_KEY= # 回测必需
FINNHUB_API_KEY= # 实盘必需 FINNHUB_API_KEY= # 实盘必需
POLYGON_API_KEY= # Polygon市场库采集可选
# LLM 配置 # Agent LLM
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_BASE_URL= OPENAI_BASE_URL=
MODEL_NAME=qwen3-max-preview MODEL_NAME=qwen3-max-preview
# Agent 特定模型 # 可为不同 Agent 指定不同模型
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6 AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=deepseek-chat
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview
AGENT_VALUATION_ANALYST_MODEL_NAME=Moonshot-Kimi-K2-Instruct
AGENT_RISK_MANAGER_MODEL_NAME=qwen3-max-preview
AGENT_PORTFOLIO_MANAGER_MODEL_NAME=qwen3-max-preview
# ReMe 记忆系统 # ReMe 记忆系统
MEMORY_API_KEY= MEMORY_API_KEY=
MEMORY_MODEL_NAME=qwen3-max
MEMORY_EMBEDDING_MODEL=text-embedding-v4
# 交易参数
MAX_COMM_CYCLES=2
MARGIN_REQUIREMENT=0.5
DATA_START_DATE=2022-01-01
AUTO_UPDATE_DATA=true
```
### Frontend (`frontend/env.template`)
```bash
VITE_WS_URL=ws://localhost:8765
``` ```
## 关键依赖 ## 关键依赖
- **AgentScope** - 多智能体框架 - **AgentScope** - 多智能体框架
- **ReMe** - 持续学习记忆系统 - **ReMe** - 持续学习记忆系统
- **FastAPI** + **uvicorn** - 后端 API - **FastAPI** + **uvicorn** - 后端 API 服务器
- **websockets** - 实时通信 - **websockets** - 实时通信
- **React 19** + **Vite** + **TailwindCSS** - 前端 - **React 19** + **Vite** + **TailwindCSS** - 前端
- **Zustand** - 状态管理 - **React Context** - 前端状态管理App.jsx 中使用 useState + useCallback
- **Three.js** / **React-Three-Fiber** - 3D 可视化

View File

@@ -110,21 +110,6 @@ 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. 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 ## System Architecture

View File

@@ -41,8 +41,6 @@ class SkillsManager:
) )
self.runs_root = self.project_root / "runs" self.runs_root = self.project_root / "runs"
self._lock = Lock() self._lock = Lock()
# Instance-level pending skill changes (thread-safe via self._lock)
self._pending_skill_changes: Dict[str, Set[Path]] = {}
def get_active_root(self, config_name: str) -> Path: def get_active_root(self, config_name: str) -> Path:
return self.runs_root / config_name / "skills" / "active" return self.runs_root / config_name / "skills" / "active"
@@ -741,7 +739,7 @@ class SkillsManager:
if local_root.exists(): if local_root.exists():
watched_paths.append(local_root) watched_paths.append(local_root)
handler = _SkillsChangeHandler(watched_paths, self._pending_skill_changes, callback, self._lock) handler = _SkillsChangeHandler(watched_paths, callback, self._lock)
observer = Observer() observer = Observer()
for path in watched_paths: for path in watched_paths:
observer.schedule(handler, str(path), recursive=True) observer.schedule(handler, str(path), recursive=True)
@@ -775,7 +773,6 @@ class SkillsManager:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Internal change-tracking state (populated by _SkillsChangeHandler) # Internal change-tracking state (populated by _SkillsChangeHandler)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Legacy class-level reference kept for migration compatibility
_pending_skill_changes: Dict[str, Set[Path]] = {} _pending_skill_changes: Dict[str, Set[Path]] = {}
def _resolve_disabled_skill_names( def _resolve_disabled_skill_names(
@@ -827,13 +824,11 @@ class _SkillsChangeHandler(FileSystemEventHandler):
def __init__( def __init__(
self, self,
watched_paths: List[Path], watched_paths: List[Path],
pending_changes: Dict[str, Set[Path]],
callback: Optional[Any] = None, callback: Optional[Any] = None,
lock: Optional[Lock] = None, lock: Optional[Lock] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self._watched_paths = watched_paths self._watched_paths = watched_paths
self._pending_changes = pending_changes
self._callback = callback self._callback = callback
self._lock = lock self._lock = lock
@@ -846,9 +841,13 @@ class _SkillsChangeHandler(FileSystemEventHandler):
run_id = self._run_id_from_path(src_path) run_id = self._run_id_from_path(src_path)
if self._lock: if self._lock:
with self._lock: with self._lock:
self._pending_changes.setdefault(run_id, set()).add(src_path) SkillsManager._pending_skill_changes.setdefault(
run_id, set()
).add(src_path)
else: else:
self._pending_changes.setdefault(run_id, set()).add(src_path) SkillsManager._pending_skill_changes.setdefault(
run_id, set()
).add(src_path)
if self._callback: if self._callback:
self._callback([src_path]) self._callback([src_path])
break break

View File

@@ -129,33 +129,6 @@ class RunWorkspaceManager:
) )
return asset_dir 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( def initialize_default_assets(
self, self,
config_name: str, config_name: str,

View File

@@ -13,13 +13,8 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Body, UploadFile, File, Form from fastapi import APIRouter, HTTPException, Depends, Body, UploadFile, File, Form
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from backend.agents import AgentFactory, get_registry from backend.agents import AgentFactory, WorkspaceManager, get_registry
from backend.agents.workspace_manager import RunWorkspaceManager
from backend.agents.agent_workspace import load_agent_workspace_config
from backend.agents.skills_manager import SkillsManager from backend.agents.skills_manager import SkillsManager
from backend.agents.toolkit_factory import load_agent_profiles
from backend.config.bootstrap_config import get_bootstrap_config_for_run
from backend.llm.models import get_agent_model_info
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,14 +47,6 @@ class InstallExternalSkillRequest(BaseModel):
activate: bool = Field(True, description="Whether to enable skill immediately") activate: bool = Field(True, description="Whether to enable skill immediately")
class LocalSkillRequest(BaseModel):
skill_name: str = Field(..., description="Local skill name")
class LocalSkillContentRequest(BaseModel):
content: str = Field(..., description="Updated SKILL.md content")
class AgentResponse(BaseModel): class AgentResponse(BaseModel):
"""Agent information response.""" """Agent information response."""
agent_id: str agent_id: str
@@ -76,24 +63,6 @@ class AgentFileResponse(BaseModel):
content: str content: str
class AgentProfileResponse(BaseModel):
agent_id: str
workspace_id: str
profile: Dict[str, Any]
class AgentSkillsResponse(BaseModel):
agent_id: str
workspace_id: str
skills: List[Dict[str, Any]]
class SkillDetailResponse(BaseModel):
agent_id: str
workspace_id: str
skill: Dict[str, Any]
# Dependencies # Dependencies
def get_agent_factory(): def get_agent_factory():
"""Get AgentFactory instance.""" """Get AgentFactory instance."""
@@ -101,8 +70,8 @@ def get_agent_factory():
def get_workspace_manager(): def get_workspace_manager():
"""Get run-scoped workspace manager instance.""" """Get WorkspaceManager instance."""
return RunWorkspaceManager() return WorkspaceManager()
def get_skills_manager(): def get_skills_manager():
@@ -230,108 +199,6 @@ async def get_agent(
) )
@router.get("/{agent_id}/profile", response_model=AgentProfileResponse)
async def get_agent_profile(
workspace_id: str,
agent_id: str,
skills_manager: SkillsManager = Depends(get_skills_manager),
):
asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id)
agent_config = load_agent_workspace_config(asset_dir / "agent.yaml")
profiles = load_agent_profiles()
profile = profiles.get(agent_id, {})
bootstrap = get_bootstrap_config_for_run(skills_manager.project_root, workspace_id)
override = bootstrap.agent_override(agent_id)
active_tool_groups = override.get("active_tool_groups", agent_config.active_tool_groups or profile.get("active_tool_groups", []))
if not isinstance(active_tool_groups, list):
active_tool_groups = []
disabled_tool_groups = agent_config.disabled_tool_groups
if disabled_tool_groups:
disabled_set = set(disabled_tool_groups)
active_tool_groups = [group_name for group_name in active_tool_groups if group_name not in disabled_set]
default_skills = profile.get("skills", [])
if not isinstance(default_skills, list):
default_skills = []
resolved_skills = skills_manager.resolve_agent_skill_names(
config_name=workspace_id,
agent_id=agent_id,
default_skills=default_skills,
)
prompt_files = agent_config.prompt_files or ["SOUL.md", "PROFILE.md", "AGENTS.md", "POLICY.md", "MEMORY.md"]
model_name, model_provider = get_agent_model_info(agent_id)
return AgentProfileResponse(
agent_id=agent_id,
workspace_id=workspace_id,
profile={
"model_name": model_name,
"model_provider": model_provider,
"prompt_files": prompt_files,
"default_skills": default_skills,
"resolved_skills": resolved_skills,
"active_tool_groups": active_tool_groups,
"disabled_tool_groups": disabled_tool_groups,
"enabled_skills": agent_config.enabled_skills,
"disabled_skills": agent_config.disabled_skills,
},
)
@router.get("/{agent_id}/skills", response_model=AgentSkillsResponse)
async def get_agent_skills(
workspace_id: str,
agent_id: str,
skills_manager: SkillsManager = Depends(get_skills_manager),
):
agent_asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id)
agent_config = load_agent_workspace_config(agent_asset_dir / "agent.yaml")
resolved_skills = set(skills_manager.resolve_agent_skill_names(config_name=workspace_id, agent_id=agent_id, default_skills=[]))
enabled = set(agent_config.enabled_skills)
disabled = set(agent_config.disabled_skills)
payload = []
for item in skills_manager.list_agent_skill_catalog(workspace_id, agent_id):
if item.skill_name in disabled:
status = "disabled"
elif item.skill_name in enabled:
status = "enabled"
elif item.skill_name in resolved_skills:
status = "active"
else:
status = "available"
payload.append({
"skill_name": item.skill_name,
"name": item.name,
"description": item.description,
"version": item.version,
"source": item.source,
"tools": item.tools,
"status": status,
})
return AgentSkillsResponse(agent_id=agent_id, workspace_id=workspace_id, skills=payload)
@router.get("/{agent_id}/skills/{skill_name}", response_model=SkillDetailResponse)
async def get_agent_skill_detail(
workspace_id: str,
agent_id: str,
skill_name: str,
skills_manager: SkillsManager = Depends(get_skills_manager),
):
try:
detail = skills_manager.load_agent_skill_document(
config_name=workspace_id,
agent_id=agent_id,
skill_name=skill_name,
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Unknown skill: {skill_name}")
return SkillDetailResponse(agent_id=agent_id, workspace_id=workspace_id, skill=detail)
@router.delete("/{agent_id}") @router.delete("/{agent_id}")
async def delete_agent( async def delete_agent(
workspace_id: str, workspace_id: str,
@@ -519,85 +386,6 @@ async def install_external_skill(
} }
@router.post("/{agent_id}/skills/local")
async def create_local_skill(
workspace_id: str,
agent_id: str,
request: LocalSkillRequest,
registry=Depends(get_registry),
):
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
skills_manager = SkillsManager()
try:
skills_manager.create_agent_local_skill(
config_name=workspace_id,
agent_id=agent_id,
skill_name=request.skill_name,
)
except (ValueError, FileExistsError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"message": f"Created local skill '{request.skill_name}' for '{agent_id}'"}
@router.put("/{agent_id}/skills/local/{skill_name}")
async def update_local_skill(
workspace_id: str,
agent_id: str,
skill_name: str,
request: LocalSkillContentRequest,
registry=Depends(get_registry),
):
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
skills_manager = SkillsManager()
try:
skills_manager.update_agent_local_skill(
config_name=workspace_id,
agent_id=agent_id,
skill_name=skill_name,
content=request.content,
)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"message": f"Updated local skill '{skill_name}' for '{agent_id}'"}
@router.delete("/{agent_id}/skills/local/{skill_name}")
async def delete_local_skill(
workspace_id: str,
agent_id: str,
skill_name: str,
registry=Depends(get_registry),
):
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
skills_manager = SkillsManager()
try:
skills_manager.delete_agent_local_skill(
config_name=workspace_id,
agent_id=agent_id,
skill_name=skill_name,
)
skills_manager.forget_agent_skill_overrides(
config_name=workspace_id,
agent_id=agent_id,
skill_names=[skill_name],
)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"message": f"Deleted local skill '{skill_name}' for '{agent_id}'"}
@router.post("/{agent_id}/skills/upload") @router.post("/{agent_id}/skills/upload")
async def upload_external_skill( async def upload_external_skill(
workspace_id: str, workspace_id: str,
@@ -653,7 +441,7 @@ async def get_agent_file(
workspace_id: str, workspace_id: str,
agent_id: str, agent_id: str,
filename: str, filename: str,
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager), workspace_manager: WorkspaceManager = Depends(get_workspace_manager),
): ):
""" """
Read an agent's workspace file. Read an agent's workspace file.
@@ -683,7 +471,7 @@ async def update_agent_file(
agent_id: str, agent_id: str,
filename: str, filename: str,
content: str = Body(..., media_type="text/plain"), content: str = Body(..., media_type="text/plain"),
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager), workspace_manager: WorkspaceManager = Depends(get_workspace_manager),
): ):
""" """
Update an agent's workspace file. Update an agent's workspace file.

View File

@@ -8,7 +8,6 @@ import json
import logging import logging
import os import os
import signal import signal
import shutil
import subprocess import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
@@ -39,13 +38,12 @@ class RuntimeState:
""" """
_instance: Optional["RuntimeState"] = None _instance: Optional["RuntimeState"] = None
_lock: "threading.Lock" = __import__("threading").Lock() _lock: asyncio.Lock = asyncio.Lock()
def __new__(cls) -> "RuntimeState": def __new__(cls) -> "RuntimeState":
with cls._lock: if cls._instance is None:
if cls._instance is None: cls._instance = super().__new__(cls)
cls._instance = super().__new__(cls) cls._instance._initialized = False
cls._instance._initialized = False
return cls._instance return cls._instance
def __init__(self) -> None: def __init__(self) -> None:
@@ -167,8 +165,6 @@ class RuntimeEventsResponse(BaseModel):
class LaunchConfig(BaseModel): class LaunchConfig(BaseModel):
"""Configuration for launching a new trading task.""" """Configuration for launching a new trading task."""
launch_mode: str = Field(default="fresh", description="启动形式: fresh, restore")
restore_run_id: Optional[str] = Field(default=None, description="历史任务 run_id用于恢复启动")
tickers: List[str] = Field(default_factory=list, description="股票池") tickers: List[str] = Field(default_factory=list, description="股票池")
schedule_mode: str = Field(default="daily", description="调度模式: daily, interval") schedule_mode: str = Field(default="daily", description="调度模式: daily, interval")
interval_minutes: int = Field(default=60, ge=1, description="间隔分钟数") interval_minutes: int = Field(default=60, ge=1, description="间隔分钟数")
@@ -181,6 +177,7 @@ class LaunchConfig(BaseModel):
start_date: Optional[str] = Field(default=None, description="回测开始日期 YYYY-MM-DD") start_date: Optional[str] = Field(default=None, description="回测开始日期 YYYY-MM-DD")
end_date: Optional[str] = Field(default=None, description="回测结束日期 YYYY-MM-DD") end_date: Optional[str] = Field(default=None, description="回测结束日期 YYYY-MM-DD")
poll_interval: int = Field(default=10, ge=1, le=300, description="市场数据轮询间隔(秒)") poll_interval: int = Field(default=10, ge=1, le=300, description="市场数据轮询间隔(秒)")
enable_mock: bool = Field(default=False, description="是否启用模拟模式(使用模拟价格数据)")
class LaunchResponse(BaseModel): class LaunchResponse(BaseModel):
@@ -191,30 +188,11 @@ class LaunchResponse(BaseModel):
message: str message: str
class RuntimeHistoryItem(BaseModel):
run_id: str
run_dir: str
updated_at: Optional[str] = None
total_trades: int = 0
total_asset_value: Optional[float] = None
bootstrap: Dict[str, Any] = Field(default_factory=dict)
class RuntimeHistoryResponse(BaseModel):
runs: List[RuntimeHistoryItem]
class StopResponse(BaseModel): class StopResponse(BaseModel):
status: str status: str
message: str message: str
class CleanupResponse(BaseModel):
status: str
kept: int
pruned_run_ids: List[str]
class GatewayStatusResponse(BaseModel): class GatewayStatusResponse(BaseModel):
is_running: bool is_running: bool
port: int port: int
@@ -229,13 +207,6 @@ class RuntimeConfigResponse(BaseModel):
resolved: Dict[str, Any] resolved: Dict[str, Any]
class RuntimeLogResponse(BaseModel):
run_id: Optional[str] = None
is_running: bool
log_path: Optional[str] = None
content: str = ""
class UpdateRuntimeConfigRequest(BaseModel): class UpdateRuntimeConfigRequest(BaseModel):
schedule_mode: Optional[str] = None schedule_mode: Optional[str] = None
interval_minutes: Optional[int] = Field(default=None, ge=1) interval_minutes: Optional[int] = Field(default=None, ge=1)
@@ -256,128 +227,6 @@ def _get_run_dir(run_id: str) -> Path:
return PROJECT_ROOT / "runs" / run_id return PROJECT_ROOT / "runs" / run_id
def _load_run_snapshot(run_id: str) -> Dict[str, Any]:
"""Load a specific run snapshot by run_id."""
snapshot_path = _get_run_dir(run_id) / "state" / "runtime_state.json"
if not snapshot_path.exists():
raise HTTPException(status_code=404, detail=f"Run snapshot not found: {run_id}")
return json.loads(snapshot_path.read_text(encoding="utf-8"))
def _copy_path_if_exists(src: Path, dst: Path) -> None:
if not src.exists():
return
if src.is_dir():
shutil.copytree(src, dst, dirs_exist_ok=True)
else:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def _restore_run_assets(source_run_id: str, target_run_dir: Path) -> None:
"""Seed a fresh run directory from a historical run snapshot."""
source_run_dir = _get_run_dir(source_run_id)
if not source_run_dir.exists():
raise HTTPException(status_code=404, detail=f"Source run not found: {source_run_id}")
for relative in [
"team_dashboard",
"agents",
"skills",
"memory",
"state/server_state.json",
"state/runtime.db",
"state/research.db",
]:
_copy_path_if_exists(source_run_dir / relative, target_run_dir / relative)
def _list_runs(limit: int = 50) -> list[RuntimeHistoryItem]:
runs_root = PROJECT_ROOT / "runs"
if not runs_root.exists():
return []
items: list[RuntimeHistoryItem] = []
run_dirs = sorted(
[path for path in runs_root.iterdir() if path.is_dir()],
key=lambda path: path.stat().st_mtime,
reverse=True,
)
for run_dir in run_dirs[: max(1, int(limit))]:
run_id = run_dir.name
runtime_state_path = run_dir / "state" / "runtime_state.json"
summary_path = run_dir / "team_dashboard" / "summary.json"
bootstrap: Dict[str, Any] = {}
updated_at: Optional[str] = None
total_trades = 0
total_asset_value: Optional[float] = None
if runtime_state_path.exists():
try:
snapshot = json.loads(runtime_state_path.read_text(encoding="utf-8"))
context = snapshot.get("context") or {}
bootstrap = dict(context.get("bootstrap_values") or {})
updated_at = snapshot.get("events", [{}])[-1].get("timestamp") if snapshot.get("events") else None
except Exception:
bootstrap = {}
if summary_path.exists():
try:
summary = json.loads(summary_path.read_text(encoding="utf-8"))
total_trades = int(summary.get("totalTrades") or 0)
total_asset_value = float(summary.get("totalAssetValue")) if summary.get("totalAssetValue") is not None else None
except Exception:
total_trades = 0
total_asset_value = None
items.append(
RuntimeHistoryItem(
run_id=run_id,
run_dir=str(run_dir),
updated_at=updated_at,
total_trades=total_trades,
total_asset_value=total_asset_value,
bootstrap=bootstrap,
)
)
return items
def _is_timestamped_run_dir(path: Path) -> bool:
try:
datetime.strptime(path.name, "%Y%m%d_%H%M%S")
return True
except ValueError:
return False
def _prune_old_timestamped_runs(*, keep: int = 20, exclude_run_ids: Optional[set[str]] = None) -> list[str]:
"""Prune old timestamped run directories, preserving the newest N and excluded ids."""
exclude = exclude_run_ids or set()
runs_root = PROJECT_ROOT / "runs"
if not runs_root.exists():
return []
candidates = sorted(
[
path
for path in runs_root.iterdir()
if path.is_dir() and _is_timestamped_run_dir(path) and path.name not in exclude
],
key=lambda path: path.name,
reverse=True,
)
pruned: list[str] = []
for path in candidates[max(0, keep):]:
shutil.rmtree(path, ignore_errors=True)
pruned.append(path.name)
return pruned
def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int: def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int:
"""Find an available port for Gateway.""" """Find an available port for Gateway."""
import socket import socket
@@ -439,43 +288,41 @@ def _start_gateway_process(
"--bootstrap", json.dumps(bootstrap) "--bootstrap", json.dumps(bootstrap)
] ]
log_path = run_dir / "logs" / "gateway.log" # Start process
log_path.parent.mkdir(parents=True, exist_ok=True) process = subprocess.Popen(
cmd,
log_file = log_path.open("ab") env=env,
try: stdout=subprocess.PIPE,
process = subprocess.Popen( stderr=subprocess.PIPE,
cmd, cwd=PROJECT_ROOT
env=env, )
stdout=log_file,
stderr=subprocess.STDOUT,
cwd=PROJECT_ROOT
)
finally:
log_file.close()
return process return process
@router.get("/context", response_model=RunContextResponse) @router.get("/context", response_model=RunContextResponse)
async def get_run_context() -> RunContextResponse: async def get_run_context() -> RunContextResponse:
"""Return active runtime context, or latest persisted context when stopped.""" """Return the current run context from in-memory state (avoids glob race condition)."""
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot() manager = _runtime_state.runtime_manager
context = snapshot.get("context") if manager is None or manager.context is None:
if context is None: raise HTTPException(status_code=404, detail="No run context available")
raise HTTPException(status_code=404, detail="Run context is not ready")
context = manager.context
return RunContextResponse( return RunContextResponse(
config_name=context["config_name"], config_name=context.config_name,
run_dir=context["run_dir"], run_dir=str(context.run_dir),
bootstrap_values=context["bootstrap_values"], bootstrap_values=context.bootstrap_values,
) )
@router.get("/agents", response_model=RuntimeAgentsResponse) @router.get("/agents", response_model=RuntimeAgentsResponse)
async def get_runtime_agents() -> RuntimeAgentsResponse: async def get_runtime_agents() -> RuntimeAgentsResponse:
"""Return agent states from the active runtime, or latest persisted run.""" """Return agent states from the in-memory runtime manager (avoids glob race condition)."""
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot() manager = _runtime_state.runtime_manager
if manager is None:
raise HTTPException(status_code=404, detail="No runtime state available")
snapshot = manager.build_snapshot()
agents = snapshot.get("agents", []) agents = snapshot.get("agents", [])
return RuntimeAgentsResponse( return RuntimeAgentsResponse(
@@ -485,8 +332,12 @@ async def get_runtime_agents() -> RuntimeAgentsResponse:
@router.get("/events", response_model=RuntimeEventsResponse) @router.get("/events", response_model=RuntimeEventsResponse)
async def get_runtime_events() -> RuntimeEventsResponse: async def get_runtime_events() -> RuntimeEventsResponse:
"""Return events from the active runtime, or latest persisted run.""" """Return events from the in-memory runtime manager (avoids glob race condition)."""
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot() manager = _runtime_state.runtime_manager
if manager is None:
raise HTTPException(status_code=404, detail="No runtime state available")
snapshot = manager.build_snapshot()
events = snapshot.get("events", []) events = snapshot.get("events", [])
return RuntimeEventsResponse( return RuntimeEventsResponse(
@@ -494,12 +345,6 @@ async def get_runtime_events() -> RuntimeEventsResponse:
) )
@router.get("/history", response_model=RuntimeHistoryResponse)
async def get_runtime_history(limit: int = 20) -> RuntimeHistoryResponse:
"""List recent historical runs for restore/start selection."""
return RuntimeHistoryResponse(runs=_list_runs(limit=limit))
@router.get("/gateway/status", response_model=GatewayStatusResponse) @router.get("/gateway/status", response_model=GatewayStatusResponse)
async def get_gateway_status() -> GatewayStatusResponse: async def get_gateway_status() -> GatewayStatusResponse:
"""Get Gateway process status and port.""" """Get Gateway process status and port."""
@@ -507,10 +352,10 @@ async def get_gateway_status() -> GatewayStatusResponse:
run_id = None run_id = None
if is_running: if is_running:
try: # Get run_id from in-memory runtime manager (avoids glob race condition)
run_id = _get_active_runtime_context().get("config_name") manager = _runtime_state.runtime_manager
except Exception as e: if manager is not None and manager.context is not None:
logger.warning(f"Failed to resolve active runtime context: {e}") run_id = manager.context.config_name
return GatewayStatusResponse( return GatewayStatusResponse(
is_running=is_running, is_running=is_running,
@@ -530,26 +375,6 @@ async def get_gateway_port(request: Request) -> Dict[str, Any]:
} }
@router.get("/logs", response_model=RuntimeLogResponse)
async def get_runtime_logs() -> RuntimeLogResponse:
"""Return current runtime log tail, or the latest run log if runtime is stopped."""
try:
context = _get_active_runtime_context() if _is_gateway_running() else _get_runtime_context_from_latest_snapshot()
except HTTPException:
return RuntimeLogResponse(is_running=False, content="")
run_id = str(context.get("config_name") or "").strip() or None
log_path = _get_gateway_log_path_for_run(run_id) if run_id else None
content = _read_log_tail(log_path) if log_path else ""
return RuntimeLogResponse(
run_id=run_id,
is_running=_is_gateway_running(),
log_path=str(log_path) if log_path else None,
content=content,
)
def _build_gateway_ws_url(request: Request, port: int) -> str: def _build_gateway_ws_url(request: Request, port: int) -> str:
"""Build a proxy-safe Gateway WebSocket URL.""" """Build a proxy-safe Gateway WebSocket URL."""
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip() forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip()
@@ -564,8 +389,28 @@ def _build_gateway_ws_url(request: Request, port: int) -> str:
return f"{ws_scheme}://{host}:{port}" return f"{ws_scheme}://{host}:{port}"
def _load_latest_runtime_snapshot() -> Dict[str, Any]: def _get_current_runtime_context() -> Dict[str, Any]:
"""Load the latest persisted runtime snapshot.""" """Return the active runtime context from the in-memory manager (avoids glob race condition).
Falls back to file-based lookup only when the in-memory manager is not available
(e.g., after a service restart). File-based lookup is deprecated and exists
only for backward compatibility.
"""
if not _is_gateway_running():
raise HTTPException(status_code=404, detail="No runtime is currently running")
# Primary: use in-memory manager (always correct for current process)
manager = _runtime_state.runtime_manager
if manager is not None and manager.context is not None:
ctx = manager.context
return {
"config_name": ctx.config_name,
"run_dir": str(ctx.run_dir),
"bootstrap_values": ctx.bootstrap_values,
}
# Deprecated fallback: scan filesystem (only for backward compatibility
# after service restart without a restart of the runtime itself)
snapshots = sorted( snapshots = sorted(
PROJECT_ROOT.glob("runs/*/state/runtime_state.json"), PROJECT_ROOT.glob("runs/*/state/runtime_state.json"),
key=lambda p: p.stat().st_mtime, key=lambda p: p.stat().st_mtime,
@@ -573,62 +418,13 @@ def _load_latest_runtime_snapshot() -> Dict[str, Any]:
) )
if not snapshots: if not snapshots:
raise HTTPException(status_code=404, detail="No runtime information available") raise HTTPException(status_code=404, detail="No runtime information available")
return json.loads(snapshots[0].read_text(encoding="utf-8")) latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
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 {} context = latest.get("context") or {}
if not context.get("config_name"): if not context.get("config_name"):
raise HTTPException(status_code=404, detail="No runtime context available") raise HTTPException(status_code=404, detail="No runtime context available")
return context return context
def _get_gateway_log_path_for_run(run_id: str) -> Path:
return _get_run_dir(run_id) / "logs" / "gateway.log"
def _read_log_tail(path: Path, max_chars: int = 120_000) -> str:
if not path.exists() or not path.is_file():
return ""
text = path.read_text(encoding="utf-8", errors="replace")
if len(text) <= max_chars:
return text
return text[-max_chars:]
def _get_current_runtime_context() -> Dict[str, Any]:
"""Return the active runtime context from the latest snapshot."""
if not _is_gateway_running():
raise HTTPException(status_code=404, detail="No runtime is currently running")
snapshot = _get_active_runtime_snapshot()
context = snapshot.get("context") or {}
if not context.get("config_name"):
raise HTTPException(status_code=404, detail="No runtime context available")
return context
def _get_active_runtime_context() -> Dict[str, Any]:
"""Return the active runtime context, preferring in-memory runtime manager state."""
return _get_current_runtime_context()
def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse: def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse:
"""Build a normalized runtime config response for the active run.""" """Build a normalized runtime config response for the active run."""
context = _get_current_runtime_context() context = _get_current_runtime_context()
@@ -719,51 +515,26 @@ async def start_runtime(
_stop_gateway() _stop_gateway()
await asyncio.sleep(1) # Wait for port release await asyncio.sleep(1) # Wait for port release
launch_mode = str(config.launch_mode or "fresh").strip().lower() # 2. Generate run ID and directory
if launch_mode not in {"fresh", "restore"}: run_id = _generate_run_id()
raise HTTPException(status_code=400, detail="launch_mode must be 'fresh' or 'restore'") run_dir = _get_run_dir(run_id)
# 2. Resolve run ID, directory, and bootstrap # 3. Prepare bootstrap config
if launch_mode == "restore": bootstrap = {
restore_run_id = str(config.restore_run_id or "").strip() "tickers": config.tickers,
if not restore_run_id: "schedule_mode": config.schedule_mode,
raise HTTPException(status_code=400, detail="restore_run_id is required when launch_mode=restore") "interval_minutes": config.interval_minutes,
snapshot = _load_run_snapshot(restore_run_id) "trigger_time": config.trigger_time,
context = snapshot.get("context") or {} "max_comm_cycles": config.max_comm_cycles,
if not context.get("config_name"): "initial_cash": config.initial_cash,
raise HTTPException(status_code=404, detail=f"Run context not found: {restore_run_id}") "margin_requirement": config.margin_requirement,
run_id = restore_run_id "enable_memory": config.enable_memory,
run_dir = _get_run_dir(run_id) "mode": config.mode,
bootstrap = dict(context.get("bootstrap_values") or {}) "start_date": config.start_date,
bootstrap["launch_mode"] = "restore" "end_date": config.end_date,
bootstrap["restore_run_id"] = restore_run_id "poll_interval": config.poll_interval,
else: "enable_mock": config.enable_mock,
run_id = _generate_run_id() }
run_dir = _get_run_dir(run_id)
bootstrap = {
"launch_mode": "fresh",
"restore_run_id": None,
"tickers": config.tickers,
"schedule_mode": config.schedule_mode,
"interval_minutes": config.interval_minutes,
"trigger_time": config.trigger_time,
"max_comm_cycles": config.max_comm_cycles,
"initial_cash": config.initial_cash,
"margin_requirement": config.margin_requirement,
"enable_memory": config.enable_memory,
"mode": config.mode,
"start_date": config.start_date,
"end_date": config.end_date,
"poll_interval": config.poll_interval,
}
retention_keep = max(1, int(os.getenv("RUNS_RETENTION_COUNT", "20") or "20"))
pruned_run_ids = _prune_old_timestamped_runs(
keep=retention_keep,
exclude_run_ids={run_id},
)
if pruned_run_ids:
logger.info("Pruned old run directories: %s", ", ".join(pruned_run_ids))
# 4. Create runtime manager # 4. Create runtime manager
manager = TradingRuntimeManager( manager = TradingRuntimeManager(
@@ -794,12 +565,11 @@ async def start_runtime(
await asyncio.sleep(2) await asyncio.sleep(2)
if not _is_gateway_running(): if not _is_gateway_running():
stdout, stderr = process.communicate(timeout=1)
_runtime_state.gateway_process = None _runtime_state.gateway_process = None
log_path = _get_gateway_log_path_for_run(run_id)
log_tail = _read_log_tail(log_path, max_chars=4000)
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Gateway failed to start: {log_tail or 'Unknown error'}" detail=f"Gateway failed to start: {stderr.decode() if stderr else 'Unknown error'}"
) )
except Exception as e: except Exception as e:
@@ -865,25 +635,6 @@ async def stop_runtime(force: bool = True) -> StopResponse:
) )
@router.post("/cleanup", response_model=CleanupResponse)
async def cleanup_old_runs(keep: int = 20) -> CleanupResponse:
"""Prune old timestamped run directories while preserving named runs."""
keep_count = max(1, int(keep))
exclude: set[str] = set()
if _is_gateway_running():
try:
active_context = _get_active_runtime_context()
active_run_id = str(active_context.get("config_name") or "").strip()
if active_run_id:
exclude.add(active_run_id)
except HTTPException:
pass
pruned = _prune_old_timestamped_runs(keep=keep_count, exclude_run_ids=exclude)
return CleanupResponse(status="ok", kept=keep_count, pruned_run_ids=pruned)
@router.post("/restart") @router.post("/restart")
async def restart_runtime( async def restart_runtime(
config: LaunchConfig, config: LaunchConfig,
@@ -910,7 +661,8 @@ async def get_current_runtime():
if not _is_gateway_running(): if not _is_gateway_running():
raise HTTPException(status_code=404, detail="No runtime is currently running") raise HTTPException(status_code=404, detail="No runtime is currently running")
context = _get_active_runtime_context() # Get context from in-memory manager (avoids glob race condition)
context = _get_current_runtime_context()
return { return {
"run_id": context.get("config_name"), "run_id": context.get("config_name"),

View File

@@ -9,7 +9,6 @@ from .runtime_service import app as runtime_app
from .runtime_service import create_app as create_runtime_app from .runtime_service import create_app as create_runtime_app
from .trading_service import app as trading_app from .trading_service import app as trading_app
from .trading_service import create_app as create_trading_app from .trading_service import create_app as create_trading_app
from .cors import add_cors_middleware, get_cors_origins
app = agent_app app = agent_app
create_app = create_agent_app create_app = create_agent_app
@@ -25,6 +24,4 @@ __all__ = [
"create_runtime_app", "create_runtime_app",
"trading_app", "trading_app",
"create_trading_app", "create_trading_app",
"add_cors_middleware",
"get_cors_origins",
] ]

View File

@@ -8,11 +8,11 @@ from pathlib import Path
from typing import AsyncGenerator from typing import AsyncGenerator
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.apps.cors import add_cors_middleware
from backend.api import agents_router, guard_router, workspaces_router from backend.api import agents_router, guard_router, workspaces_router
from backend.agents import AgentFactory, WorkspaceManager, get_registry from backend.agents import AgentFactory, WorkspaceManager, get_registry
from backend.config.env_config import get_cors_origins
# Global instances (initialized on startup) # Global instances (initialized on startup)
agent_factory: AgentFactory | None = None agent_factory: AgentFactory | None = None
@@ -48,7 +48,13 @@ def create_app(project_root: Path | None = None) -> FastAPI:
lifespan=lifespan, lifespan=lifespan,
) )
add_cors_middleware(app) app.add_middleware(
CORSMiddleware,
allow_origins=get_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health") @app.get("/health")
async def health_check() -> dict[str, object]: async def health_check() -> dict[str, object]:

View File

@@ -1,30 +0,0 @@
# -*- 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=["*"],
)

View File

@@ -6,15 +6,16 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import Depends, FastAPI, Query from fastapi import Depends, FastAPI, Query
from backend.apps.cors import add_cors_middleware from fastapi.middleware.cors import CORSMiddleware
from backend.data.market_store import MarketStore from backend.data.market_store import MarketStore
from backend.domains import news as news_domain from backend.domains import news as news_domain
from backend.config.env_config import get_cors_origins
def get_market_store() -> MarketStore: def get_market_store() -> MarketStore:
"""Get the MarketStore singleton dependency.""" """Create a market store dependency."""
return MarketStore.get_instance() return MarketStore()
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -25,7 +26,13 @@ def create_app() -> FastAPI:
version="0.1.0", version="0.1.0",
) )
add_cors_middleware(app) app.add_middleware(
CORSMiddleware,
allow_origins=get_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health") @app.get("/health")
async def health_check() -> dict[str, str]: async def health_check() -> dict[str, str]:
@@ -45,7 +52,6 @@ def create_app() -> FastAPI:
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
limit=limit, limit=limit,
refresh_if_stale=False,
) )
@app.get("/api/news-for-date") @app.get("/api/news-for-date")
@@ -60,7 +66,6 @@ def create_app() -> FastAPI:
ticker=ticker, ticker=ticker,
date=date, date=date,
limit=limit, limit=limit,
refresh_if_stale=False,
) )
@app.get("/api/news-timeline") @app.get("/api/news-timeline")
@@ -75,7 +80,6 @@ def create_app() -> FastAPI:
ticker=ticker, ticker=ticker,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
refresh_if_stale=False,
) )
@app.get("/api/categories") @app.get("/api/categories")
@@ -92,7 +96,6 @@ def create_app() -> FastAPI:
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
limit=limit, limit=limit,
refresh_if_stale=False,
) )
@app.get("/api/similar-days") @app.get("/api/similar-days")
@@ -107,7 +110,6 @@ def create_app() -> FastAPI:
ticker=ticker, ticker=ticker,
date=date, date=date,
n_similar=n_similar, n_similar=n_similar,
refresh_if_stale=False,
) )
@app.get("/api/stories/{ticker}") @app.get("/api/stories/{ticker}")
@@ -120,7 +122,6 @@ def create_app() -> FastAPI:
store, store,
ticker=ticker, ticker=ticker,
as_of_date=as_of_date, as_of_date=as_of_date,
refresh_if_stale=False,
) )
@app.get("/api/range-explain") @app.get("/api/range-explain")
@@ -139,7 +140,6 @@ def create_app() -> FastAPI:
end_date=end_date, end_date=end_date,
article_ids=article_ids, article_ids=article_ids,
limit=limit, limit=limit,
refresh_if_stale=False,
) )
return app return app

View File

@@ -4,10 +4,11 @@
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.api import runtime_router from backend.api import runtime_router
from backend.api.runtime import get_runtime_state from backend.api.runtime import get_runtime_state
from backend.apps.cors import add_cors_middleware from backend.config.env_config import get_cors_origins
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -18,7 +19,13 @@ def create_app() -> FastAPI:
version="0.1.0", version="0.1.0",
) )
add_cors_middleware(app) app.add_middleware(
CORSMiddleware,
allow_origins=get_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health") @app.get("/health")
async def health_check() -> dict[str, object]: async def health_check() -> dict[str, object]:

View File

@@ -6,9 +6,18 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import FastAPI, Query from fastapi import FastAPI, Query
from backend.apps.cors import add_cors_middleware from fastapi.middleware.cors import CORSMiddleware
from backend.domains import trading as trading_domain from backend.config.env_config import get_cors_origins
from backend.services.market import MarketService
from backend.tools.data_tools import (
get_company_news,
get_financial_metrics,
get_insider_trades,
get_market_cap,
get_prices,
search_line_items,
)
from shared.schema import ( from shared.schema import (
CompanyNewsResponse, CompanyNewsResponse,
FinancialMetricsResponse, FinancialMetricsResponse,
@@ -26,7 +35,13 @@ def create_app() -> FastAPI:
version="0.1.0", version="0.1.0",
) )
add_cors_middleware(app) app.add_middleware(
CORSMiddleware,
allow_origins=get_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health") @app.get("/health")
async def health_check() -> dict[str, str]: async def health_check() -> dict[str, str]:
@@ -39,12 +54,8 @@ def create_app() -> FastAPI:
start_date: str = Query(...), start_date: str = Query(...),
end_date: str = Query(...), end_date: str = Query(...),
) -> PriceResponse: ) -> PriceResponse:
payload = trading_domain.get_prices_payload( prices = get_prices(ticker=ticker, start_date=start_date, end_date=end_date)
ticker=ticker, return PriceResponse(ticker=ticker, prices=prices)
start_date=start_date,
end_date=end_date,
)
return PriceResponse(ticker=payload["ticker"], prices=payload["prices"])
@app.get("/api/financials", response_model=FinancialMetricsResponse) @app.get("/api/financials", response_model=FinancialMetricsResponse)
async def api_get_financials( async def api_get_financials(
@@ -53,13 +64,13 @@ def create_app() -> FastAPI:
period: str = Query("ttm"), period: str = Query("ttm"),
limit: int = Query(10, ge=1, le=100), limit: int = Query(10, ge=1, le=100),
) -> FinancialMetricsResponse: ) -> FinancialMetricsResponse:
payload = trading_domain.get_financials_payload( metrics = get_financial_metrics(
ticker=ticker, ticker=ticker,
end_date=end_date, end_date=end_date,
period=period, period=period,
limit=limit, limit=limit,
) )
return FinancialMetricsResponse(financial_metrics=payload["financial_metrics"]) return FinancialMetricsResponse(financial_metrics=metrics)
@app.get("/api/news", response_model=CompanyNewsResponse) @app.get("/api/news", response_model=CompanyNewsResponse)
async def api_get_news( async def api_get_news(
@@ -68,13 +79,13 @@ def create_app() -> FastAPI:
start_date: str | None = Query(None), start_date: str | None = Query(None),
limit: int = Query(1000, ge=1, le=5000), limit: int = Query(1000, ge=1, le=5000),
) -> CompanyNewsResponse: ) -> CompanyNewsResponse:
payload = trading_domain.get_news_payload( news = get_company_news(
ticker=ticker, ticker=ticker,
end_date=end_date, end_date=end_date,
start_date=start_date, start_date=start_date,
limit=limit, limit=limit,
) )
return CompanyNewsResponse(news=payload["news"]) return CompanyNewsResponse(news=news)
@app.get("/api/insider-trades", response_model=InsiderTradeResponse) @app.get("/api/insider-trades", response_model=InsiderTradeResponse)
async def api_get_insider_trades( async def api_get_insider_trades(
@@ -83,18 +94,19 @@ def create_app() -> FastAPI:
start_date: str | None = Query(None), start_date: str | None = Query(None),
limit: int = Query(1000, ge=1, le=5000), limit: int = Query(1000, ge=1, le=5000),
) -> InsiderTradeResponse: ) -> InsiderTradeResponse:
payload = trading_domain.get_insider_trades_payload( trades = get_insider_trades(
ticker=ticker, ticker=ticker,
end_date=end_date, end_date=end_date,
start_date=start_date, start_date=start_date,
limit=limit, limit=limit,
) )
return InsiderTradeResponse(insider_trades=payload["insider_trades"]) return InsiderTradeResponse(insider_trades=trades)
@app.get("/api/market/status") @app.get("/api/market/status")
async def api_get_market_status() -> dict[str, Any]: async def api_get_market_status() -> dict[str, Any]:
"""Return current market status using the existing market service logic.""" """Return current market status using the existing market service logic."""
return trading_domain.get_market_status_payload() service = MarketService(tickers=[])
return service.get_market_status()
@app.get("/api/market-cap") @app.get("/api/market-cap")
async def api_get_market_cap( async def api_get_market_cap(
@@ -102,10 +114,12 @@ def create_app() -> FastAPI:
end_date: str = Query(...), end_date: str = Query(...),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return market cap for one ticker/date.""" """Return market cap for one ticker/date."""
return trading_domain.get_market_cap_payload( market_cap = get_market_cap(ticker=ticker, end_date=end_date)
ticker=ticker, return {
end_date=end_date, "ticker": ticker,
) "end_date": end_date,
"market_cap": market_cap,
}
@app.get("/api/line-items", response_model=LineItemResponse) @app.get("/api/line-items", response_model=LineItemResponse)
async def api_get_line_items( async def api_get_line_items(
@@ -115,14 +129,14 @@ def create_app() -> FastAPI:
period: str = Query("ttm"), period: str = Query("ttm"),
limit: int = Query(10, ge=1, le=100), limit: int = Query(10, ge=1, le=100),
) -> LineItemResponse: ) -> LineItemResponse:
payload = trading_domain.get_line_items_payload( items = search_line_items(
ticker=ticker, ticker=ticker,
line_items=line_items, line_items=line_items,
end_date=end_date, end_date=end_date,
period=period, period=period,
limit=limit, limit=limit,
) )
return LineItemResponse(search_results=payload["search_results"]) return LineItemResponse(search_results=items)
return app return app

View File

@@ -1019,6 +1019,11 @@ def backtest(
@app.command() @app.command()
def live( def live(
mock: bool = typer.Option(
False,
"--mock",
help="Use mock mode with simulated prices (for testing)",
),
config_name: str = typer.Option( config_name: str = typer.Option(
"live", "live",
"--config-name", "--config-name",
@@ -1073,6 +1078,7 @@ def live(
Example: Example:
evotraders live # Run immediately (default) evotraders live # Run immediately (default)
evotraders live --mock # Mock mode
evotraders live -t 22:30 # Run at 22:30 local time daily evotraders live -t 22:30 # Run at 22:30 local time daily
evotraders live --schedule-mode intraday --interval-minutes 60 evotraders live --schedule-mode intraday --interval-minutes 60
evotraders live --trigger-time now # Run immediately evotraders live --trigger-time now # Run immediately
@@ -1080,31 +1086,33 @@ def live(
""" """
schedule_mode = str(_normalize_typer_value(schedule_mode, "daily")) schedule_mode = str(_normalize_typer_value(schedule_mode, "daily"))
interval_minutes = int(_normalize_typer_value(interval_minutes, 60)) interval_minutes = int(_normalize_typer_value(interval_minutes, 60))
mode_name = "MOCK" if mock else "LIVE"
console.print( console.print(
Panel.fit( Panel.fit(
"[bold cyan]EvoTraders LIVE Mode[/bold cyan]", f"[bold cyan]EvoTraders {mode_name} Mode[/bold cyan]",
border_style="cyan", border_style="cyan",
), ),
) )
# Check for required API key in live mode # Check for required API key in live mode
env_file = get_project_root() / ".env" if not mock:
if not env_file.exists(): env_file = get_project_root() / ".env"
console.print("\n[yellow]Warning: .env file not found[/yellow]") if not env_file.exists():
console.print("Creating from template...\n") console.print("\n[yellow]Warning: .env file not found[/yellow]")
template = get_project_root() / "env.template" console.print("Creating from template...\n")
if template.exists(): template = get_project_root() / "env.template"
shutil.copy(template, env_file) if template.exists():
console.print("[green].env file created[/green]") shutil.copy(template, env_file)
console.print( console.print("[green].env file created[/green]")
"\n[red]Error: Please edit .env and set FINNHUB_API_KEY[/red]", 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", console.print(
) "Get your free API key at: https://finnhub.io/register\n",
else: )
console.print("[red]Error: env.template not found[/red]") else:
raise typer.Exit(1) console.print("[red]Error: env.template not found[/red]")
raise typer.Exit(1)
# Handle historical data cleanup # Handle historical data cleanup
handle_history_cleanup(config_name, auto_clean=clean) handle_history_cleanup(config_name, auto_clean=clean)
@@ -1160,9 +1168,12 @@ def live(
# Display configuration # Display configuration
console.print("\n[bold]Configuration:[/bold]") console.print("\n[bold]Configuration:[/bold]")
console.print( if mock:
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)", console.print(" Mode: [yellow]MOCK[/yellow] (Simulated prices)")
) else:
console.print(
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
)
console.print(f" Config: {config_name}") console.print(f" Config: {config_name}")
console.print(f" Server: {host}:{port}") console.print(f" Server: {host}:{port}")
console.print(f" Poll Interval: {poll_interval}s") console.print(f" Poll Interval: {poll_interval}s")
@@ -1177,17 +1188,22 @@ def live(
project_root = get_project_root() project_root = get_project_root()
os.chdir(project_root) os.chdir(project_root)
# Data update # Data update (if not mock mode)
run_data_updater(project_root) if not mock:
auto_update_market_store( run_data_updater(project_root)
config_name, auto_update_market_store(
end_date=nyse_now.date().isoformat(), config_name,
) end_date=nyse_now.date().isoformat(),
auto_enrich_market_store( )
config_name, auto_enrich_market_store(
end_date=nyse_now.date().isoformat(), config_name,
force=False, end_date=nyse_now.date().isoformat(),
) force=False,
)
else:
console.print(
"\n[dim]Mock mode enabled - skipping data update[/dim]\n",
)
# Build command using backend.main # Build command using backend.main
cmd = [ cmd = [
@@ -1213,6 +1229,8 @@ def live(
str(interval_minutes), str(interval_minutes),
] ]
if mock:
cmd.append("--mock")
if enable_memory: if enable_memory:
cmd.append("--enable-memory") cmd.append("--enable-memory")

View File

@@ -76,19 +76,27 @@ def _resolve_config() -> DataSourceConfig:
""" """
Resolve data source configuration based on available API keys. Resolve data source configuration based on available API keys.
The effective source should always match the first item in the resolved Priority:
ordered source list. 1. FINNHUB_API_KEY (if set)
2. FINANCIAL_DATASETS_API_KEY (if set)
3. Raises error if neither is available
""" """
sources = _ordered_sources() sources = _ordered_sources()
source = sources[0] if sources else "local_csv" if "finnhub" in sources:
return DataSourceConfig(
api_key = "" source="finnhub",
if source == "finnhub": api_key=os.getenv("FINNHUB_API_KEY", "").strip(),
api_key = os.getenv("FINNHUB_API_KEY", "").strip() sources=sources,
elif source == "financial_datasets": )
api_key = os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip() if "financial_datasets" in sources:
return DataSourceConfig(
return DataSourceConfig(source=source, api_key=api_key, sources=sources) 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)
def get_config() -> DataSourceConfig: def get_config() -> DataSourceConfig:

View File

@@ -3,6 +3,7 @@
"""Environment config helpers with light validation and normalization.""" """Environment config helpers with light validation and normalization."""
import os import os
import warnings
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@@ -16,6 +17,36 @@ PROVIDER_ALIASES = {
"vertexai": "GEMINI", "vertexai": "GEMINI",
} }
# Default dev CORS origins (localhost variants used by common dev servers)
_LOCALHOST_ORIGINS = [
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:8000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:8000",
]
def get_cors_origins() -> list[str]:
"""Get CORS allowed origins from environment.
Reads CORS_ALLOWED_ORIGINS env var (comma-separated).
Falls back to localhost dev origins if not set.
Warns if "*" is configured (only acceptable for local dev).
"""
origins = get_env_list("CORS_ALLOWED_ORIGINS", default=[])
if origins:
if "*" in origins:
warnings.warn(
"CORS_ALLOWED_ORIGINS contains '*' — this allows any origin. "
"Only use in local development, never in production.",
UserWarning,
)
return origins
# Fallback: local dev only
return _LOCALHOST_ORIGINS
@dataclass(frozen=True) @dataclass(frozen=True)
class AgentModelConfig: class AgentModelConfig:

View File

@@ -244,8 +244,10 @@ async def run_pipeline(
start_date = bootstrap.get("start_date") start_date = bootstrap.get("start_date")
end_date = bootstrap.get("end_date") end_date = bootstrap.get("end_date")
enable_memory = bootstrap.get("enable_memory", False) enable_memory = bootstrap.get("enable_memory", False)
enable_mock = bootstrap.get("enable_mock", False)
is_backtest = mode == "backtest" is_backtest = mode == "backtest"
is_mock = enable_mock or mode == "mock" or (not is_backtest and os.getenv("MOCK_MODE", "false").lower() == "true")
# ====================================================================== # ======================================================================
# PHASE 0: Initialize runtime manager # PHASE 0: Initialize runtime manager
@@ -264,6 +266,10 @@ async def run_pipeline(
set_global_runtime_manager(runtime_manager) set_global_runtime_manager(runtime_manager)
# Register runtime manager with API
from backend.api.runtime import register_runtime_manager
register_runtime_manager(runtime_manager)
# ====================================================================== # ======================================================================
# PHASE 1 & 2: Create infrastructure services (Market, Storage) # PHASE 1 & 2: Create infrastructure services (Market, Storage)
# These will be started by Gateway in the correct order # These will be started by Gateway in the correct order
@@ -286,8 +292,9 @@ async def run_pipeline(
market_service = MarketService( market_service = MarketService(
tickers=tickers, tickers=tickers,
poll_interval=10, poll_interval=10,
mock_mode=is_mock and not is_backtest,
backtest_mode=is_backtest, backtest_mode=is_backtest,
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None, api_key=os.getenv("FINNHUB_API_KEY") if not is_mock and not is_backtest else None,
backtest_start_date=start_date if is_backtest else None, backtest_start_date=start_date if is_backtest else None,
backtest_end_date=end_date if is_backtest else None, backtest_end_date=end_date if is_backtest else None,
) )
@@ -384,6 +391,7 @@ async def run_pipeline(
scheduler_callback=scheduler_callback, scheduler_callback=scheduler_callback,
config={ config={
"mode": mode, "mode": mode,
"mock_mode": is_mock,
"backtest_mode": is_backtest, "backtest_mode": is_backtest,
"tickers": tickers, "tickers": tickers,
"config_name": run_id, "config_name": run_id,

View File

@@ -465,6 +465,7 @@ class StateSync:
payload = { payload = {
"server_mode": self._state.get("server_mode", "live"), "server_mode": self._state.get("server_mode", "live"),
"is_mock_mode": self._state.get("is_mock_mode", False),
"is_backtest": self._state.get("is_backtest", False), "is_backtest": self._state.get("is_backtest", False),
"tickers": self._state.get("tickers"), "tickers": self._state.get("tickers"),
"runtime_config": self._state.get("runtime_config"), "runtime_config": self._state.get("runtime_config"),
@@ -487,13 +488,12 @@ class StateSync:
} }
if include_dashboard: if include_dashboard:
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self._state)
payload["dashboard"] = { payload["dashboard"] = {
"summary": dashboard_snapshot.get("summary"), "summary": self.storage.load_file("summary"),
"holdings": dashboard_snapshot.get("holdings"), "holdings": self.storage.load_file("holdings"),
"stats": dashboard_snapshot.get("stats"), "stats": self.storage.load_file("stats"),
"trades": dashboard_snapshot.get("trades"), "trades": self.storage.load_file("trades"),
"leaderboard": dashboard_snapshot.get("leaderboard"), "leaderboard": self.storage.load_file("leaderboard"),
} }
return payload return payload

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from backend.data.historical_price_manager import HistoricalPriceManager from backend.data.historical_price_manager import HistoricalPriceManager
from backend.data.mock_price_manager import MockPriceManager
from backend.data.polling_price_manager import PollingPriceManager from backend.data.polling_price_manager import PollingPriceManager
__all__ = ["PollingPriceManager", "HistoricalPriceManager"] __all__ = ["MockPriceManager", "PollingPriceManager", "HistoricalPriceManager"]

View File

@@ -8,7 +8,6 @@ from typing import Iterable
from backend.data.market_store import MarketStore from backend.data.market_store import MarketStore
from backend.data.news_alignment import align_news_for_symbol from backend.data.news_alignment import align_news_for_symbol
from backend.data.provider_router import DataProviderRouter
from backend.data.polygon_client import ( from backend.data.polygon_client import (
fetch_news, fetch_news,
fetch_ohlc, fetch_ohlc,
@@ -25,35 +24,6 @@ def _default_start(years: int = 2) -> str:
return (datetime.now(timezone.utc).date() - timedelta(days=years * 366)).isoformat() return (datetime.now(timezone.utc).date() - timedelta(days=years * 366)).isoformat()
def _normalize_provider_news_rows(ticker: str, news_items: Iterable[Any]) -> list[dict]:
rows: list[dict] = []
for item in news_items:
payload = item.model_dump() if hasattr(item, "model_dump") else dict(item or {})
related = payload.get("related")
if isinstance(related, str):
related_list = [value.strip().upper() for value in related.split(",") if value.strip()]
elif isinstance(related, list):
related_list = [str(value).strip().upper() for value in related if str(value).strip()]
else:
related_list = []
if ticker not in related_list:
related_list.append(ticker)
rows.append(
{
"title": payload.get("title"),
"description": payload.get("summary"),
"summary": payload.get("summary"),
"article_url": payload.get("url"),
"published_utc": payload.get("date"),
"publisher": payload.get("source"),
"tickers": related_list,
"category": payload.get("category"),
"raw_json": payload,
}
)
return rows
def ingest_ticker_history( def ingest_ticker_history(
symbol: str, symbol: str,
*, *,
@@ -144,80 +114,6 @@ 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( def ingest_symbols(
symbols: Iterable[str], symbols: Iterable[str],
*, *,

View File

@@ -9,7 +9,7 @@ import os
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable, Optional from typing import Any, Iterable
SCHEMA = """ SCHEMA = """
@@ -147,30 +147,12 @@ def _utc_timestamp() -> str:
class MarketStore: class MarketStore:
"""SQLite-backed market research warehouse. Use get_instance() for the singleton.""" """SQLite-backed market research warehouse."""
_instance: Optional["MarketStore"] = None
def __new__(cls, db_path: Path | None = None) -> "MarketStore":
if cls._instance is not None:
if db_path is None or cls._instance.db_path == Path(db_path or get_market_db_path()):
return cls._instance
instance = super().__new__(cls)
cls._instance = instance
return instance
def __init__(self, db_path: Path | None = None): def __init__(self, db_path: Path | None = None):
if getattr(self, "_initialized", False):
return
self.db_path = Path(db_path or get_market_db_path()) self.db_path = Path(db_path or get_market_db_path())
self.db_path.parent.mkdir(parents=True, exist_ok=True) self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db() self._init_db()
self._initialized = True
@classmethod
def get_instance(cls, db_path: Path | None = None) -> "MarketStore":
"""Get the MarketStore singleton instance."""
return cls(db_path)
def _connect(self) -> sqlite3.Connection: def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)

View File

@@ -0,0 +1,244 @@
# -*- 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")

View File

@@ -15,9 +15,6 @@ from backend.data.provider_utils import normalize_symbol
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SUPPRESSED_LOG_EVERY = 20
class PollingPriceManager: class PollingPriceManager:
"""Polling-based price manager using Finnhub or yfinance.""" """Polling-based price manager using Finnhub or yfinance."""
@@ -46,7 +43,6 @@ class PollingPriceManager:
self.latest_prices: Dict[str, float] = {} self.latest_prices: Dict[str, float] = {}
self.open_prices: Dict[str, float] = {} self.open_prices: Dict[str, float] = {}
self.price_callbacks: List[Callable] = [] self.price_callbacks: List[Callable] = []
self._failure_counts: Dict[str, int] = {}
self.running = False self.running = False
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
@@ -81,8 +77,6 @@ class PollingPriceManager:
for symbol in self.subscribed_symbols: for symbol in self.subscribed_symbols:
try: try:
quote_data = self._fetch_quote(symbol) quote_data = self._fetch_quote(symbol)
if not isinstance(quote_data, dict):
raise ValueError(f"{symbol}: Empty quote payload")
current_price = quote_data.get("c") current_price = quote_data.get("c")
open_price = quote_data.get("o") open_price = quote_data.get("o")
@@ -109,13 +103,6 @@ class PollingPriceManager:
) )
self.latest_prices[symbol] = current_price self.latest_prices[symbol] = current_price
previous_failures = self._failure_counts.pop(symbol, 0)
if previous_failures > 0:
logger.info(
"%s quote polling recovered after %d consecutive failures",
symbol,
previous_failures,
)
price_data = { price_data = {
"symbol": symbol, "symbol": symbol,
@@ -141,20 +128,7 @@ class PollingPriceManager:
) )
except Exception as e: except Exception as e:
failure_count = self._failure_counts.get(symbol, 0) + 1 logger.error(f"Failed to fetch {symbol} price: {e}")
self._failure_counts[symbol] = failure_count
message = f"Failed to fetch {symbol} price: {e}"
if failure_count == 1:
logger.warning(message)
elif failure_count % _SUPPRESSED_LOG_EVERY == 0:
logger.warning(
"%s (repeated %d times; suppressing intermediate failures)",
message,
failure_count,
)
else:
logger.debug(message)
def _fetch_quote(self, symbol: str) -> Dict[str, float]: def _fetch_quote(self, symbol: str) -> Dict[str, float]:
"""Fetch a normalized quote payload from the configured provider.""" """Fetch a normalized quote payload from the configured provider."""
@@ -162,10 +136,7 @@ class PollingPriceManager:
return self._fetch_yfinance_quote(symbol) return self._fetch_yfinance_quote(symbol)
if not self.finnhub_client: if not self.finnhub_client:
raise ValueError("Finnhub API key required for finnhub polling") raise ValueError("Finnhub API key required for finnhub polling")
quote = self.finnhub_client.quote(symbol) return self.finnhub_client.quote(symbol)
if not isinstance(quote, dict):
raise ValueError(f"{symbol}: Invalid Finnhub quote payload")
return quote
def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]: def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]:
"""Fetch quote data from yfinance and normalize to Finnhub-like keys.""" """Fetch quote data from yfinance and normalize to Finnhub-like keys."""
@@ -191,8 +162,6 @@ class PollingPriceManager:
if current_price is None: if current_price is None:
history = ticker.history(period="1d", interval="1m", auto_adjust=False) history = ticker.history(period="1d", interval="1m", auto_adjust=False)
if history is None:
raise ValueError(f"{symbol}: yfinance returned no history frame")
if history.empty: if history.empty:
raise ValueError(f"{symbol}: No yfinance quote data") raise ValueError(f"{symbol}: No yfinance quote data")
latest = history.iloc[-1] latest = history.iloc[-1]

View File

@@ -30,7 +30,6 @@ def ensure_news_fresh(
*, *,
ticker: str, ticker: str,
target_date: str | None = None, target_date: str | None = None,
refresh_if_stale: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Refresh raw news incrementally when stored watermarks are stale.""" """Refresh raw news incrementally when stored watermarks are stale."""
normalized_target = str(target_date or "").strip()[:10] normalized_target = str(target_date or "").strip()[:10]
@@ -45,7 +44,7 @@ def ensure_news_fresh(
watermarks = store.get_ticker_watermarks(ticker) watermarks = store.get_ticker_watermarks(ticker)
last_news_fetch = str(watermarks.get("last_news_fetch") or "").strip()[:10] last_news_fetch = str(watermarks.get("last_news_fetch") or "").strip()[:10]
refreshed = False refreshed = False
if refresh_if_stale and (not last_news_fetch or last_news_fetch < normalized_target): if not last_news_fetch or last_news_fetch < normalized_target:
update_ticker_incremental( update_ticker_incremental(
ticker, ticker,
end_date=normalized_target, end_date=normalized_target,
@@ -70,14 +69,8 @@ def get_enriched_news(
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
limit: int = 100, limit: int = 100,
refresh_if_stale: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
freshness = ensure_news_fresh( freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
store,
ticker=ticker,
target_date=end_date,
refresh_if_stale=refresh_if_stale,
)
rows = store.get_news_items_enriched( rows = store.get_news_items_enriched(
ticker, ticker,
start_date=start_date, start_date=start_date,
@@ -107,14 +100,8 @@ def get_news_for_date(
ticker: str, ticker: str,
date: str, date: str,
limit: int = 20, limit: int = 20,
refresh_if_stale: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
freshness = ensure_news_fresh( freshness = ensure_news_fresh(store, ticker=ticker, target_date=date)
store,
ticker=ticker,
target_date=date,
refresh_if_stale=refresh_if_stale,
)
rows = store.get_news_items_enriched( rows = store.get_news_items_enriched(
ticker, ticker,
trade_date=date, trade_date=date,
@@ -142,14 +129,8 @@ def get_news_timeline(
ticker: str, ticker: str,
start_date: str, start_date: str,
end_date: str, end_date: str,
refresh_if_stale: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
freshness = ensure_news_fresh( freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
store,
ticker=ticker,
target_date=end_date,
refresh_if_stale=refresh_if_stale,
)
timeline = store.get_news_timeline_enriched( timeline = store.get_news_timeline_enriched(
ticker, ticker,
start_date=start_date, start_date=start_date,
@@ -184,14 +165,8 @@ def get_news_categories(
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
limit: int = 200, limit: int = 200,
refresh_if_stale: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
freshness = ensure_news_fresh( freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
store,
ticker=ticker,
target_date=end_date,
refresh_if_stale=refresh_if_stale,
)
rows = store.get_news_items_enriched( rows = store.get_news_items_enriched(
ticker, ticker,
start_date=start_date, start_date=start_date,
@@ -221,14 +196,8 @@ def get_similar_days_payload(
ticker: str, ticker: str,
date: str, date: str,
n_similar: int = 5, n_similar: int = 5,
refresh_if_stale: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
freshness = ensure_news_fresh( freshness = ensure_news_fresh(store, ticker=ticker, target_date=date)
store,
ticker=ticker,
target_date=date,
refresh_if_stale=refresh_if_stale,
)
result = find_similar_days( result = find_similar_days(
store, store,
symbol=ticker, symbol=ticker,
@@ -244,14 +213,8 @@ def get_story_payload(
*, *,
ticker: str, ticker: str,
as_of_date: str, as_of_date: str,
refresh_if_stale: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
freshness = ensure_news_fresh( freshness = ensure_news_fresh(store, ticker=ticker, target_date=as_of_date)
store,
ticker=ticker,
target_date=as_of_date,
refresh_if_stale=refresh_if_stale,
)
enrich_news_for_symbol( enrich_news_for_symbol(
store, store,
ticker, ticker,
@@ -275,14 +238,8 @@ def get_range_explain_payload(
end_date: str, end_date: str,
article_ids: list[str] | None = None, article_ids: list[str] | None = None,
limit: int = 100, limit: int = 100,
refresh_if_stale: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
freshness = ensure_news_fresh( freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
store,
ticker=ticker,
target_date=end_date,
refresh_if_stale=refresh_if_stale,
)
news_rows = [] news_rows = []
if article_ids: if article_ids:
news_rows = store.get_news_by_ids_enriched(ticker, article_ids) news_rows = store.get_news_by_ids_enriched(ticker, article_ids)

View File

@@ -43,71 +43,6 @@ logger = logging.getLogger(__name__)
_prompt_loader = get_prompt_loader() _prompt_loader = get_prompt_loader()
INFO_LOGGER_PREFIXES = (
"backend.agents",
"backend.core.pipeline",
"backend.core.scheduler",
"backend.services.gateway_cycle_support",
"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( async def run_gateway(
run_id: str, run_id: str,
run_dir: Path, run_dir: Path,
@@ -130,8 +65,10 @@ async def run_gateway(
end_date = bootstrap.get("end_date") end_date = bootstrap.get("end_date")
enable_memory = bootstrap.get("enable_memory", False) enable_memory = bootstrap.get("enable_memory", False)
poll_interval = int(bootstrap.get("poll_interval", 10)) poll_interval = int(bootstrap.get("poll_interval", 10))
enable_mock = bootstrap.get("enable_mock", False)
is_backtest = mode == "backtest" is_backtest = mode == "backtest"
is_mock = enable_mock or mode == "mock" or (not is_backtest and os.getenv("MOCK_MODE", "false").lower() == "true")
logger.info(f"[Gateway Server] Starting run {run_id} on port {port}") logger.info(f"[Gateway Server] Starting run {run_id} on port {port}")
@@ -150,8 +87,9 @@ async def run_gateway(
market_service = MarketService( market_service = MarketService(
tickers=tickers, tickers=tickers,
poll_interval=poll_interval, poll_interval=poll_interval,
mock_mode=is_mock and not is_backtest,
backtest_mode=is_backtest, backtest_mode=is_backtest,
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None, api_key=os.getenv("FINNHUB_API_KEY") if not is_mock and not is_backtest else None,
backtest_start_date=start_date if is_backtest else None, backtest_start_date=start_date if is_backtest else None,
backtest_end_date=end_date if is_backtest else None, backtest_end_date=end_date if is_backtest else None,
) )
@@ -244,6 +182,7 @@ async def run_gateway(
scheduler_callback=scheduler_callback, scheduler_callback=scheduler_callback,
config={ config={
"mode": mode, "mode": mode,
"mock_mode": is_mock,
"backtest_mode": is_backtest, "backtest_mode": is_backtest,
"tickers": tickers, "tickers": tickers,
"config_name": run_id, "config_name": run_id,
@@ -283,7 +222,11 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Setup logging # Setup logging
configure_gateway_logging(verbose=args.verbose) level = logging.DEBUG if args.verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s | %(levelname)-7s | %(name)s:%(lineno)d - %(message)s",
)
# Parse bootstrap # Parse bootstrap
bootstrap = json.loads(args.bootstrap) bootstrap = json.loads(args.bootstrap)

View File

@@ -3,8 +3,6 @@
AgentScope Native Model Factory AgentScope Native Model Factory
Uses native AgentScope model classes for LLM calls Uses native AgentScope model classes for LLM calls
""" """
import asyncio
import inspect
import os import os
import time import time
import logging import logging
@@ -36,27 +34,6 @@ logger = logging.getLogger(__name__)
T = TypeVar("T") T = TypeVar("T")
def _usage_value(usage: Any, key: str, default: Any = 0) -> Any:
"""Read usage fields from both object-style and dict-style usage payloads."""
if usage is None:
return default
if isinstance(usage, dict):
return usage.get(key, default)
try:
return getattr(usage, key)
except (AttributeError, KeyError):
return default
def _usage_total_tokens(usage: Any) -> int:
total = _usage_value(usage, "total_tokens", None)
if total is not None:
return int(total or 0)
input_tokens = _usage_value(usage, "input_tokens", 0)
output_tokens = _usage_value(usage, "output_tokens", 0)
return int((input_tokens or 0) + (output_tokens or 0))
class RetryChatModel: class RetryChatModel:
"""Wraps an AgentScope model with automatic retry for transient errors. """Wraps an AgentScope model with automatic retry for transient errors.
@@ -78,7 +55,6 @@ class RetryChatModel:
"502", "502",
"504", "504",
"connection", "connection",
"disconnected",
"temporary", "temporary",
"overloaded", "overloaded",
"too_many_requests", "too_many_requests",
@@ -174,8 +150,8 @@ class RetryChatModel:
# Track usage if available # Track usage if available
if hasattr(result, "usage") and result.usage: if hasattr(result, "usage") and result.usage:
usage = result.usage usage = result.usage
self._total_tokens_used += _usage_total_tokens(usage) self._total_tokens_used += getattr(usage, "total_tokens", 0)
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0) self._total_cost += getattr(usage, "cost", 0.0)
return result return result
@@ -216,66 +192,9 @@ class RetryChatModel:
raise last_error raise last_error
raise RuntimeError("RetryChatModel: Unexpected state, no error but no result") raise RuntimeError("RetryChatModel: Unexpected state, no error but no result")
async def _call_with_retry_async(self, func: Callable[..., T], *args, **kwargs) -> T:
"""Call an async function with retry logic for transient errors."""
last_error: Optional[Exception] = None
for attempt in range(1, self._max_retries + 1):
try:
result = await func(*args, **kwargs)
if hasattr(result, "usage") and result.usage:
usage = result.usage
self._total_tokens_used += _usage_total_tokens(usage)
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
return result
except Exception as e:
last_error = e
if attempt >= self._max_retries:
logger.error(
"RetryChatModel: Max retries (%d) exhausted for %s",
self._max_retries,
self.model_name,
)
break
if not self._is_transient_error(e):
logger.warning(
"RetryChatModel: Non-transient error, not retrying: %s",
str(e),
)
break
delay = self._calculate_delay(attempt)
logger.warning(
"RetryChatModel: Transient async error on attempt %d/%d, "
"retrying in %.1fs: %s",
attempt,
self._max_retries,
delay,
str(e)[:200],
)
if self._on_retry:
self._on_retry(attempt, e, delay)
await asyncio.sleep(delay)
if last_error is not None:
raise last_error
raise RuntimeError("RetryChatModel: Unexpected async state, no error but no result")
def __call__(self, *args, **kwargs) -> Any: def __call__(self, *args, **kwargs) -> Any:
"""Forward calls to the wrapped model with retry logic.""" """Forward calls to the wrapped model with retry logic."""
model_call = getattr(self._model, "__call__", None) return self._call_with_retry(self._model, *args, **kwargs)
if inspect.iscoroutinefunction(self._model) or inspect.iscoroutinefunction(model_call):
return self._call_with_retry_async(self._model, *args, **kwargs)
result = self._model(*args, **kwargs)
return result
def __getattr__(self, name: str) -> Any: def __getattr__(self, name: str) -> Any:
"""Proxy attribute access to the wrapped model.""" """Proxy attribute access to the wrapped model."""
@@ -329,18 +248,10 @@ class TokenRecordingModelWrapper:
if usage is None: if usage is None:
return return
prompt_tokens = _usage_value(usage, "prompt_tokens", None) self._prompt_tokens += getattr(usage, "prompt_tokens", 0)
completion_tokens = _usage_value(usage, "completion_tokens", None) self._completion_tokens += getattr(usage, "completion_tokens", 0)
self._total_tokens += getattr(usage, "total_tokens", 0)
if prompt_tokens is None: self._total_cost += getattr(usage, "cost", 0.0)
prompt_tokens = _usage_value(usage, "input_tokens", 0)
if completion_tokens is None:
completion_tokens = _usage_value(usage, "output_tokens", 0)
self._prompt_tokens += int(prompt_tokens or 0)
self._completion_tokens += int(completion_tokens or 0)
self._total_tokens += _usage_total_tokens(usage)
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
def __call__(self, *args, **kwargs) -> Any: def __call__(self, *args, **kwargs) -> Any:
"""Forward calls and record usage.""" """Forward calls and record usage."""
@@ -490,8 +401,7 @@ def create_model(
if host: if host:
model_kwargs["host"] = host model_kwargs["host"] = host
model = model_class(**model_kwargs) return model_class(**model_kwargs)
return RetryChatModel(model)
def get_agent_model(agent_id: str, stream: bool = False): def get_agent_model(agent_id: str, stream: bool = False):

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Main Entry Point Main Entry Point
Supports: backtest, live modes Supports: backtest, live, mock modes
""" """
import argparse import argparse
import asyncio import asyncio
@@ -226,13 +226,17 @@ async def run_with_gateway(args):
) )
runtime_manager.prepare_run() runtime_manager.prepare_run()
set_global_runtime_manager(runtime_manager) set_global_runtime_manager(runtime_manager)
register_runtime_manager(runtime_manager)
# Create market service # Create market service
market_service = MarketService( market_service = MarketService(
tickers=tickers, tickers=tickers,
poll_interval=args.poll_interval, poll_interval=args.poll_interval,
mock_mode=args.mock and not is_backtest,
backtest_mode=is_backtest, backtest_mode=is_backtest,
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None, api_key=os.getenv("FINNHUB_API_KEY")
if not args.mock and not is_backtest
else None,
backtest_start_date=args.start_date if is_backtest else None, backtest_start_date=args.start_date if is_backtest else None,
backtest_end_date=args.end_date if is_backtest else None, backtest_end_date=args.end_date if is_backtest else None,
) )
@@ -317,6 +321,7 @@ async def run_with_gateway(args):
scheduler_callback=scheduler_callback, scheduler_callback=scheduler_callback,
config={ config={
"mode": args.mode, "mode": args.mode,
"mock_mode": args.mock,
"backtest_mode": is_backtest, "backtest_mode": is_backtest,
"tickers": tickers, "tickers": tickers,
"config_name": config_name, "config_name": config_name,
@@ -349,7 +354,8 @@ def main():
"""Main entry point""" """Main entry point"""
parser = argparse.ArgumentParser(description="Trading System") parser = argparse.ArgumentParser(description="Trading System")
parser.add_argument("--mode", choices=["live", "backtest"], default="live") parser.add_argument("--mode", choices=["live", "backtest"], default="live")
parser.add_argument("--config-name", default="live") parser.add_argument("--mock", action="store_true")
parser.add_argument("--config-name", default="mock")
parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8765) parser.add_argument("--port", type=int, default=8765)
parser.add_argument( parser.add_argument(

View File

@@ -13,30 +13,15 @@ from .registry import RuntimeRegistry
_global_runtime_manager: Optional["TradingRuntimeManager"] = None _global_runtime_manager: Optional["TradingRuntimeManager"] = None
_shutdown_event: Optional[asyncio.Event] = None _shutdown_event: Optional[asyncio.Event] = None
# Lazy import to avoid circular dependency
_api_runtime = None
def _get_api_runtime():
global _api_runtime
if _api_runtime is None:
from backend.api import runtime as api_runtime_module
_api_runtime = api_runtime_module
return _api_runtime
def set_global_runtime_manager(manager: "TradingRuntimeManager") -> None: def set_global_runtime_manager(manager: "TradingRuntimeManager") -> None:
global _global_runtime_manager global _global_runtime_manager
_global_runtime_manager = manager _global_runtime_manager = manager
# Sync to RuntimeState for consistency
_get_api_runtime().register_runtime_manager(manager)
def clear_global_runtime_manager() -> None: def clear_global_runtime_manager() -> None:
global _global_runtime_manager global _global_runtime_manager
_global_runtime_manager = None _global_runtime_manager = None
# Sync to RuntimeState for consistency
_get_api_runtime().unregister_runtime_manager()
def get_global_runtime_manager() -> Optional["TradingRuntimeManager"]: def get_global_runtime_manager() -> Optional["TradingRuntimeManager"]:

View File

@@ -111,6 +111,7 @@ class Gateway:
host=host, host=host,
port=port, port=port,
poll_interval=self.config.get("poll_interval", 10), poll_interval=self.config.get("poll_interval", 10),
mock=self.config.get("mock_mode", False),
tickers=self.config.get("tickers", []), tickers=self.config.get("tickers", []),
initial_cash=self.storage.initial_cash, initial_cash=self.storage.initial_cash,
start_date=self._backtest_start_date or "", start_date=self._backtest_start_date or "",
@@ -124,6 +125,10 @@ class Gateway:
self.state_sync.update_state("status", "initializing") self.state_sync.update_state("status", "initializing")
self.state_sync.update_state("server_mode", self.mode) self.state_sync.update_state("server_mode", self.mode)
self.state_sync.update_state("is_backtest", self.is_backtest) self.state_sync.update_state("is_backtest", self.is_backtest)
self.state_sync.update_state(
"is_mock_mode",
self.config.get("mock_mode", False),
)
self.state_sync.update_state("tickers", self.config.get("tickers", [])) self.state_sync.update_state("tickers", self.config.get("tickers", []))
self.state_sync.update_state( self.state_sync.update_state(
"runtime_config", "runtime_config",
@@ -147,11 +152,10 @@ class Gateway:
) )
# Load and display existing portfolio state if available # Load and display existing portfolio state if available
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self.state_sync.state) summary = self.storage.load_file("summary")
summary = dashboard_snapshot.get("summary")
if summary: if summary:
holdings = dashboard_snapshot.get("holdings") or [] holdings = self.storage.load_file("holdings") or []
trades = dashboard_snapshot.get("trades") or [] trades = self.storage.load_file("trades") or []
current_date = self.state_sync.state.get("current_date") current_date = self.state_sync.state.get("current_date")
self._dashboard.update( self._dashboard.update(
date=current_date or "-", date=current_date or "-",
@@ -540,13 +544,13 @@ class Gateway:
websocket: ServerConnection, websocket: ServerConnection,
data: Dict[str, Any], data: Dict[str, Any],
) -> None: ) -> None:
"""Run one live trading cycle on demand.""" """Run one live/mock trading cycle on demand."""
if self.is_backtest: if self.is_backtest:
await websocket.send( await websocket.send(
json.dumps( json.dumps(
{ {
"type": "error", "type": "error",
"message": "Manual trigger is only available in live mode.", "message": "Manual trigger is only available in live/mock mode.",
}, },
ensure_ascii=False, ensure_ascii=False,
), ),

View File

@@ -1,12 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Runtime/workspace/skills handlers extracted from the main Gateway module. """Runtime/workspace/skills handlers extracted from the main Gateway module."""
Deprecated note:
Agent/workspace/skill read-write operations are being migrated to
agent_service REST endpoints. These websocket handlers remain as a
compatibility fallback and should not be considered the primary control
plane path for frontend reads/writes.
"""
from __future__ import annotations from __future__ import annotations

View File

@@ -7,8 +7,8 @@ import asyncio
import logging import logging
from typing import Any from typing import Any
from backend.data.market_ingest import ingest_symbols, refresh_news_for_symbols from backend.data.market_ingest import ingest_symbols
from backend.domains import trading as trading_domain from backend.tools.data_tools import get_market_cap
from backend.utils.msg_adapter import FrontendAdapter from backend.utils.msg_adapter import FrontendAdapter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,7 +61,7 @@ async def market_status_monitor(gateway: Any) -> None:
status = gateway.market_service.get_market_status() status = gateway.market_service.get_market_status()
if status["status"] == "open" and not gateway.storage.is_live_session_active: if status["status"] == "open" and not gateway.storage.is_live_session_active:
gateway.storage.start_live_session() gateway.storage.start_live_session()
summary = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state).get("summary") or {} summary = gateway.storage.load_file("summary") or {}
gateway._session_start_portfolio_value = summary.get( gateway._session_start_portfolio_value = summary.get(
"totalAssetValue", "totalAssetValue",
gateway.storage.initial_cash, gateway.storage.initial_cash,
@@ -200,23 +200,6 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
trading_date = gateway.market_service.get_live_trading_date() trading_date = gateway.market_service.get_live_trading_date()
logger.info("Live cycle: triggered=%s, trading_date=%s", date, trading_date) logger.info("Live cycle: triggered=%s, trading_date=%s", date, trading_date)
try:
news_refresh = await asyncio.to_thread(
refresh_news_for_symbols,
tickers,
end_date=trading_date,
store=gateway.storage.market_store,
)
logger.info(
"News refresh complete: %s",
", ".join(
f"{item['symbol']} news={item['news']}"
for item in news_refresh
) or "no symbols",
)
except Exception as exc:
logger.warning("Live cycle news refresh failed: %s", exc)
await gateway.state_sync.on_cycle_start(trading_date) await gateway.state_sync.on_cycle_start(trading_date)
gateway._dashboard.update(date=trading_date, status="Analyzing...") gateway._dashboard.update(date=trading_date, status="Analyzing...")
@@ -257,15 +240,14 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
async def finalize_cycle(gateway: Any, date: str) -> None: async def finalize_cycle(gateway: Any, date: str) -> None:
dashboard_snapshot = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state) summary = gateway.storage.load_file("summary") or {}
summary = dashboard_snapshot.get("summary") or {}
if gateway.storage.is_live_session_active: if gateway.storage.is_live_session_active:
summary.update(gateway.storage.get_live_returns()) summary.update(gateway.storage.get_live_returns())
await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary) await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary)
holdings = dashboard_snapshot.get("holdings") or [] holdings = gateway.storage.load_file("holdings") or []
trades = dashboard_snapshot.get("trades") or [] trades = gateway.storage.load_file("trades") or []
leaderboard = dashboard_snapshot.get("leaderboard") or [] leaderboard = gateway.storage.load_file("leaderboard") or []
if leaderboard: if leaderboard:
await gateway.state_sync.on_leaderboard_update(leaderboard) await gateway.state_sync.on_leaderboard_update(leaderboard)
gateway._dashboard.update(date=date, status="Running", portfolio=summary, holdings=holdings, trades=trades) gateway._dashboard.update(date=date, status="Running", portfolio=summary, holdings=holdings, trades=trades)
@@ -283,8 +265,7 @@ async def get_market_caps(gateway: Any, tickers: list[str], date: str) -> dict[s
if response is not None: if response is not None:
market_cap = response.get("market_cap") market_cap = response.get("market_cap")
if market_cap is None: if market_cap is None:
payload = trading_domain.get_market_cap_payload(ticker=ticker, end_date=date) market_cap = get_market_cap(ticker=ticker, end_date=date)
market_cap = payload.get("market_cap")
market_caps[ticker] = market_cap if market_cap else 1e9 market_caps[ticker] = market_cap if market_cap else 1e9
except Exception as exc: except Exception as exc:
logger.warning("Failed to get market cap for %s, using default 1e9: %s", ticker, exc) logger.warning("Failed to get market cap for %s, using default 1e9: %s", ticker, exc)
@@ -337,7 +318,7 @@ async def run_backtest_dates(gateway: Any, dates: list[str]) -> None:
await gateway.on_strategy_trigger(date=date) await gateway.on_strategy_trigger(date=date)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
await gateway.state_sync.on_system_message(f"Backtest complete - {len(dates)} days") await gateway.state_sync.on_system_message(f"Backtest complete - {len(dates)} days")
summary = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state).get("summary") or {} summary = gateway.storage.load_file("summary") or {}
gateway._dashboard.update(status="Complete", portfolio=summary, days_completed=len(dates)) gateway._dashboard.update(status="Complete", portfolio=summary, days_completed=len(dates))
gateway._dashboard.stop() gateway._dashboard.stop()
gateway._dashboard.print_final_summary() gateway._dashboard.print_final_summary()

View File

@@ -164,10 +164,9 @@ def sync_runtime_state(gateway: Any) -> None:
gateway._dashboard.initial_cash = gateway.storage.initial_cash gateway._dashboard.initial_cash = gateway.storage.initial_cash
gateway._dashboard.enable_memory = bool(gateway.config.get("enable_memory", False)) gateway._dashboard.enable_memory = bool(gateway.config.get("enable_memory", False))
dashboard_snapshot = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state) summary = gateway.storage.load_file("summary") or {}
summary = dashboard_snapshot.get("summary") or {} holdings = gateway.storage.load_file("holdings") or []
holdings = dashboard_snapshot.get("holdings") or [] trades = gateway.storage.load_file("trades") or []
trades = dashboard_snapshot.get("trades") or []
gateway._dashboard.update( gateway._dashboard.update(
portfolio=summary, portfolio=summary,
holdings=holdings, holdings=holdings,

View File

@@ -11,10 +11,9 @@ from typing import Any
from backend.data.provider_utils import normalize_symbol from backend.data.provider_utils import normalize_symbol
from backend.domains import news as news_domain from backend.domains import news as news_domain
from backend.domains import trading as trading_domain
from backend.enrich.news_enricher import enrich_news_for_symbol from backend.enrich.news_enricher import enrich_news_for_symbol
from backend.enrich.llm_enricher import llm_enrichment_enabled from backend.enrich.llm_enricher import llm_enrichment_enabled
from backend.tools.data_tools import prices_to_df from backend.tools.data_tools import get_insider_trades, get_prices, prices_to_df
from shared.client import NewsServiceClient, TradingServiceClient from shared.client import NewsServiceClient, TradingServiceClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,13 +58,12 @@ async def handle_get_stock_history(gateway: Any, websocket: Any, data: dict[str,
if not prices: if not prices:
prices = await asyncio.to_thread(gateway.storage.market_store.get_ohlc, ticker, start_date, end_date) prices = await asyncio.to_thread(gateway.storage.market_store.get_ohlc, ticker, start_date, end_date)
if not prices: if not prices:
payload = await asyncio.to_thread( prices = await asyncio.to_thread(
trading_domain.get_prices_payload, get_prices,
ticker=ticker, ticker=ticker,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
) )
prices = payload.get("prices") or []
usage_snapshot = gateway._provider_router.get_usage_snapshot() usage_snapshot = gateway._provider_router.get_usage_snapshot()
source = usage_snapshot.get("last_success", {}).get("prices") source = usage_snapshot.get("last_success", {}).get("prices")
if prices: if prices:
@@ -152,7 +150,6 @@ async def handle_get_stock_news(gateway: Any, websocket: Any, data: dict[str, An
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
limit=max(limit, 50), limit=max(limit, 50),
refresh_if_stale=False,
) )
news_rows = (payload.get("news") or [])[-limit:] news_rows = (payload.get("news") or [])[-limit:]
source = "market_store" source = "market_store"
@@ -203,7 +200,6 @@ async def handle_get_stock_news_for_date(gateway: Any, websocket: Any, data: dic
ticker=ticker, ticker=ticker,
date=trade_date, date=trade_date,
limit=limit, limit=limit,
refresh_if_stale=False,
) )
news_rows = payload.get("news") or [] news_rows = payload.get("news") or []
source = "market_store" source = "market_store"
@@ -257,7 +253,6 @@ async def handle_get_stock_news_timeline(gateway: Any, websocket: Any, data: dic
ticker=ticker, ticker=ticker,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
refresh_if_stale=False,
) )
timeline = payload.get("timeline") or [] timeline = payload.get("timeline") or []
@@ -316,7 +311,6 @@ async def handle_get_stock_news_categories(gateway: Any, websocket: Any, data: d
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
limit=200, limit=200,
refresh_if_stale=False,
) )
categories = payload.get("categories") or {} categories = payload.get("categories") or {}
@@ -365,7 +359,6 @@ async def handle_get_stock_range_explain(gateway: Any, websocket: Any, data: dic
end_date=end_date, end_date=end_date,
article_ids=article_ids if isinstance(article_ids, list) else None, article_ids=article_ids if isinstance(article_ids, list) else None,
limit=100, limit=100,
refresh_if_stale=False,
) )
result = payload.get("result") result = payload.get("result")
@@ -405,14 +398,13 @@ async def handle_get_stock_insider_trades(gateway: Any, websocket: Any, data: di
trades = response.insider_trades trades = response.insider_trades
if not trades: if not trades:
payload = await asyncio.to_thread( trades = await asyncio.to_thread(
trading_domain.get_insider_trades_payload, get_insider_trades,
ticker=ticker, ticker=ticker,
end_date=end_date, end_date=end_date,
start_date=start_date if start_date else None, start_date=start_date if start_date else None,
limit=limit, limit=limit,
) )
trades = payload.get("insider_trades") or []
sorted_trades = sorted(trades, key=lambda t: t.transaction_date or "", reverse=True) sorted_trades = sorted(trades, key=lambda t: t.transaction_date or "", reverse=True)
formatted_trades = [{ formatted_trades = [{
@@ -545,12 +537,11 @@ async def handle_get_stock_technical_indicators(gateway: Any, websocket: Any, da
prices = response.prices prices = response.prices
if prices is None: if prices is None:
payload = trading_domain.get_prices_payload( prices = get_prices(
ticker=ticker, ticker=ticker,
start_date=start_date.strftime("%Y-%m-%d"), start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"), end_date=end_date.strftime("%Y-%m-%d"),
) )
prices = payload.get("prices") or []
if not prices or len(prices) < 20: if not prices or len(prices) < 20:
await websocket.send(json.dumps({ await websocket.send(json.dumps({

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Market Data Service Market Data Service
Supports live and backtest modes Supports live, mock, and backtest modes
""" """
import asyncio import asyncio
import logging import logging
@@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List, Optional
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pandas_market_calendars as mcal import pandas_market_calendars as mcal
from backend.config.data_config import get_data_sources from backend.config.data_config import get_data_source
from backend.data.provider_utils import normalize_symbol from backend.data.provider_utils import normalize_symbol
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,6 +36,7 @@ class MarketService:
self, self,
tickers: List[str], tickers: List[str],
poll_interval: int = 10, poll_interval: int = 10,
mock_mode: bool = False,
backtest_mode: bool = False, backtest_mode: bool = False,
api_key: Optional[str] = None, api_key: Optional[str] = None,
backtest_start_date: Optional[str] = None, backtest_start_date: Optional[str] = None,
@@ -43,6 +44,7 @@ class MarketService:
): ):
self.tickers = [normalize_symbol(ticker) for ticker in tickers] self.tickers = [normalize_symbol(ticker) for ticker in tickers]
self.poll_interval = poll_interval self.poll_interval = poll_interval
self.mock_mode = mock_mode
self.backtest_mode = backtest_mode self.backtest_mode = backtest_mode
self.api_key = api_key self.api_key = api_key
self.backtest_start_date = backtest_start_date self.backtest_start_date = backtest_start_date
@@ -67,6 +69,8 @@ class MarketService:
"""Return the active live quote provider for UI/debugging.""" """Return the active live quote provider for UI/debugging."""
if self.backtest_mode: if self.backtest_mode:
return "backtest" return "backtest"
if self.mock_mode:
return "mock"
if self._price_manager and hasattr(self._price_manager, "provider"): if self._price_manager and hasattr(self._price_manager, "provider"):
provider = getattr(self._price_manager, "provider", None) provider = getattr(self._price_manager, "provider", None)
if isinstance(provider, str) and provider.strip(): if isinstance(provider, str) and provider.strip():
@@ -77,6 +81,8 @@ class MarketService:
def mode_name(self) -> str: def mode_name(self) -> str:
if self.backtest_mode: if self.backtest_mode:
return "BACKTEST" return "BACKTEST"
elif self.mock_mode:
return "MOCK"
return "LIVE" return "LIVE"
async def start(self, broadcast_func: Callable): async def start(self, broadcast_func: Callable):
@@ -90,6 +96,8 @@ class MarketService:
if self.backtest_mode: if self.backtest_mode:
self._start_backtest_mode() self._start_backtest_mode()
elif self.mock_mode:
self._start_mock_mode()
else: else:
self._start_real_mode() self._start_real_mode()
@@ -117,10 +125,26 @@ class MarketService:
return callback return callback
def _start_mock_mode(self):
from backend.data.mock_price_manager import MockPriceManager
self._price_manager = MockPriceManager(
poll_interval=self.poll_interval,
volatility=0.5,
)
self._price_manager.add_price_callback(self._make_price_callback())
self._price_manager.subscribe(
self.tickers,
base_prices={t: 100.0 for t in self.tickers},
)
self._price_manager.start()
def _start_real_mode(self): def _start_real_mode(self):
from backend.data.polling_price_manager import PollingPriceManager from backend.data.polling_price_manager import PollingPriceManager
provider = self._resolve_live_quote_provider() provider = get_data_source()
if provider == "local_csv":
provider = "yfinance"
if provider == "finnhub" and not self.api_key: if provider == "finnhub" and not self.api_key:
raise ValueError("API key required for live mode") raise ValueError("API key required for live mode")
@@ -133,13 +157,6 @@ class MarketService:
self._price_manager.subscribe(self.tickers) self._price_manager.subscribe(self.tickers)
self._price_manager.start() self._price_manager.start()
def _resolve_live_quote_provider(self) -> str:
"""Pick the first configured provider that supports live quote polling."""
for provider in get_data_sources():
if provider in {"finnhub", "yfinance"}:
return provider
return "yfinance"
def _start_backtest_mode(self): def _start_backtest_mode(self):
from backend.data.historical_price_manager import ( from backend.data.historical_price_manager import (
HistoricalPriceManager, HistoricalPriceManager,
@@ -240,7 +257,13 @@ class MarketService:
if removed: if removed:
self._price_manager.unsubscribe(removed) self._price_manager.unsubscribe(removed)
if added: if added:
self._price_manager.subscribe(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)
if self.backtest_mode and self._current_date: if self.backtest_mode and self._current_date:
self._price_manager.set_date(self._current_date) self._price_manager.set_date(self._current_date)

View File

@@ -11,6 +11,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from backend.data.market_store import MarketStore from backend.data.market_store import MarketStore
from .research_db import ResearchDb
from .runtime_db import RuntimeDb from .runtime_db import RuntimeDb
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,18 +22,12 @@ class StorageService:
Storage service for data persistence Storage service for data persistence
Responsibilities: Responsibilities:
1. Export dashboard JSON files 1. Load/save dashboard JSON files
(summary, holdings, stats, trades, leaderboard) (summary, holdings, stats, trades, leaderboard)
2. Load/save internal state (_internal_state.json) 2. Load/save internal state (_internal_state.json)
3. Load/save server state (server_state.json) with feed history 3. Load/save server state (server_state.json) with feed history
4. Manage portfolio state persistence 4. Manage portfolio state persistence
5. Support loading from saved state to resume execution 5. Support loading from saved state to resume execution
Notes:
- team_dashboard/*.json is treated as an export/compatibility layer
rather than the authoritative runtime source of truth.
- authoritative runtime reads should prefer in-memory state, server_state,
runtime.db, and market_research.db.
""" """
def __init__( def __init__(
@@ -54,7 +49,7 @@ class StorageService:
self.initial_cash = initial_cash self.initial_cash = initial_cash
self.config_name = config_name self.config_name = config_name
# Dashboard export file paths # Dashboard file paths
self.files = { self.files = {
"summary": self.dashboard_dir / "summary.json", "summary": self.dashboard_dir / "summary.json",
"holdings": self.dashboard_dir / "holdings.json", "holdings": self.dashboard_dir / "holdings.json",
@@ -71,6 +66,7 @@ class StorageService:
self.state_dir.mkdir(parents=True, exist_ok=True) self.state_dir.mkdir(parents=True, exist_ok=True)
self.server_state_file = self.state_dir / "server_state.json" self.server_state_file = self.state_dir / "server_state.json"
self.runtime_db = RuntimeDb(self.state_dir / "runtime.db") self.runtime_db = RuntimeDb(self.state_dir / "runtime.db")
self.research_db = ResearchDb(self.state_dir / "research.db")
self.market_store = MarketStore() self.market_store = MarketStore()
# Feed history (for agent messages) # Feed history (for agent messages)
@@ -88,8 +84,16 @@ class StorageService:
logger.info(f"Storage service initialized: {self.dashboard_dir}") logger.info(f"Storage service initialized: {self.dashboard_dir}")
def load_export_file(self, file_type: str) -> Optional[Any]: def load_file(self, file_type: str) -> Optional[Any]:
"""Load dashboard export JSON file.""" """
Load dashboard JSON file
Args:
file_type: One of: summary, holdings, stats, trades, leaderboard
Returns:
Loaded data or None if file doesn't exist
"""
file_path = self.files.get(file_type) file_path = self.files.get(file_type)
if not file_path or not file_path.exists(): if not file_path or not file_path.exists():
return None return None
@@ -101,12 +105,14 @@ class StorageService:
logger.error(f"Failed to load {file_type}.json: {e}") logger.error(f"Failed to load {file_type}.json: {e}")
return None return None
def load_file(self, file_type: str) -> Optional[Any]: def save_file(self, file_type: str, data: Any):
"""Backward-compatible alias for export-layer JSON reads.""" """
return self.load_export_file(file_type) Save dashboard JSON file
def save_export_file(self, file_type: str, data: Any): Args:
"""Save dashboard export JSON file.""" file_type: One of: summary, holdings, stats, trades, leaderboard
data: Data to save
"""
file_path = self.files.get(file_type) file_path = self.files.get(file_type)
if not file_path: if not file_path:
logger.error(f"Unknown file type: {file_type}") logger.error(f"Unknown file type: {file_type}")
@@ -123,48 +129,6 @@ class StorageService:
except Exception as e: except Exception as e:
logger.error(f"Failed to save {file_type}.json: {e}") logger.error(f"Failed to save {file_type}.json: {e}")
def save_file(self, file_type: str, data: Any):
"""Backward-compatible alias for export-layer JSON writes."""
self.save_export_file(file_type, data)
def build_dashboard_snapshot_from_state(
self,
state: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build dashboard view data from runtime state instead of JSON exports."""
runtime_state = state or self.load_server_state()
portfolio = dict(runtime_state.get("portfolio") or {})
holdings = list(runtime_state.get("holdings") or [])
stats = runtime_state.get("stats") or self._get_default_stats()
trades = list(runtime_state.get("trades") or [])
leaderboard = list(runtime_state.get("leaderboard") or [])
summary = {
"totalAssetValue": portfolio.get("total_value", self.initial_cash),
"totalReturn": portfolio.get("pnl_percent", 0.0),
"cashPosition": portfolio.get("cash", self.initial_cash),
"tickerWeights": stats.get("tickerWeights", {}),
"totalTrades": len(trades),
"pnlPct": portfolio.get("pnl_percent", 0.0),
"balance": portfolio.get("total_value", self.initial_cash),
"equity": portfolio.get("equity", []),
"baseline": portfolio.get("baseline", []),
"baseline_vw": portfolio.get("baseline_vw", []),
"momentum": portfolio.get("momentum", []),
"equity_return": portfolio.get("equity_return", []),
"baseline_return": portfolio.get("baseline_return", []),
"baseline_vw_return": portfolio.get("baseline_vw_return", []),
"momentum_return": portfolio.get("momentum_return", []),
}
return {
"summary": summary,
"holdings": holdings,
"stats": stats,
"trades": trades,
"leaderboard": leaderboard,
}
def check_file_updates(self) -> Dict[str, bool]: def check_file_updates(self) -> Dict[str, bool]:
""" """
Check which dashboard files have been updated since last check Check which dashboard files have been updated since last check
@@ -333,7 +297,7 @@ class StorageService:
def initialize_empty_dashboard(self): def initialize_empty_dashboard(self):
"""Initialize empty dashboard files with default values""" """Initialize empty dashboard files with default values"""
# Summary # Summary
self.save_export_file( self.save_file(
"summary", "summary",
{ {
"totalAssetValue": self.initial_cash, "totalAssetValue": self.initial_cash,
@@ -351,10 +315,10 @@ class StorageService:
) )
# Holdings # Holdings
self.save_export_file("holdings", []) self.save_file("holdings", [])
# Stats # Stats
self.save_export_file( self.save_file(
"stats", "stats",
{ {
"totalAssetValue": self.initial_cash, "totalAssetValue": self.initial_cash,
@@ -371,7 +335,7 @@ class StorageService:
) )
# Trades # Trades
self.save_export_file("trades", []) self.save_file("trades", [])
# Leaderboard with model info # Leaderboard with model info
self.generate_leaderboard() self.generate_leaderboard()
@@ -411,7 +375,7 @@ class StorageService:
ranking_entries.append(entry) ranking_entries.append(entry)
leaderboard = team_entries + ranking_entries leaderboard = team_entries + ranking_entries
self.save_export_file("leaderboard", leaderboard) self.save_file("leaderboard", leaderboard)
logger.info("Leaderboard generated with model info") logger.info("Leaderboard generated with model info")
def update_leaderboard_model_info(self): def update_leaderboard_model_info(self):
@@ -434,7 +398,7 @@ class StorageService:
entry["modelName"] = model_name entry["modelName"] = model_name
entry["modelProvider"] = model_provider entry["modelProvider"] = model_provider
self.save_export_file("leaderboard", existing) self.save_file("leaderboard", existing)
logger.info("Leaderboard model info updated") logger.info("Leaderboard model info updated")
def get_current_timestamp_ms(self, date: str = None) -> int: def get_current_timestamp_ms(self, date: str = None) -> int:
@@ -689,7 +653,7 @@ class StorageService:
"momentum": state.get("momentum_history", []), "momentum": state.get("momentum_history", []),
} }
self.save_export_file("summary", summary) self.save_file("summary", summary)
def _generate_holdings( def _generate_holdings(
self, self,
@@ -751,7 +715,7 @@ class StorageService:
# Sort by weight # Sort by weight
holdings.sort(key=lambda x: abs(x["weight"]), reverse=True) holdings.sort(key=lambda x: abs(x["weight"]), reverse=True)
self.save_export_file("holdings", holdings) self.save_file("holdings", holdings)
def _generate_stats(self, state: Dict[str, Any], net_value: float): def _generate_stats(self, state: Dict[str, Any], net_value: float):
"""Generate stats.json""" """Generate stats.json"""
@@ -774,7 +738,7 @@ class StorageService:
}, },
} }
self.save_export_file("stats", stats) self.save_file("stats", stats)
def _generate_trades(self, state: Dict[str, Any]): def _generate_trades(self, state: Dict[str, Any]):
"""Generate trades.json""" """Generate trades.json"""
@@ -800,7 +764,7 @@ class StorageService:
}, },
) )
self.save_export_file("trades", trades) self.save_file("trades", trades)
# Server State Management Methods # Server State Management Methods
@@ -1037,12 +1001,12 @@ class StorageService:
Args: Args:
state: Server state dictionary to update state: Server state dictionary to update
""" """
dashboard_snapshot = self.build_dashboard_snapshot_from_state(state) # Load dashboard data
summary = dashboard_snapshot.get("summary") or {} summary = self.load_file("summary") or {}
holdings = dashboard_snapshot.get("holdings") or [] holdings = self.load_file("holdings") or []
stats = dashboard_snapshot.get("stats") or self._get_default_stats() stats = self.load_file("stats") or self._get_default_stats()
trades = dashboard_snapshot.get("trades") or [] trades = self.load_file("trades") or []
leaderboard = dashboard_snapshot.get("leaderboard") or [] leaderboard = self.load_file("leaderboard") or []
internal_state = self.load_internal_state() internal_state = self.load_internal_state()
# Update state # Update state
@@ -1076,6 +1040,7 @@ class StorageService:
Start tracking live returns for current trading session. Start tracking live returns for current trading session.
Captures current values as session start baseline. Captures current values as session start baseline.
""" """
summary = self.load_file("summary") or {}
state = self.load_internal_state() state = self.load_internal_state()
# Capture current values as session start # Capture current values as session start
@@ -1087,7 +1052,7 @@ class StorageService:
self._session_start_equity = ( self._session_start_equity = (
equity_history[-1]["v"] equity_history[-1]["v"]
if equity_history if equity_history
else self.initial_cash else summary.get("totalAssetValue", self.initial_cash)
) )
self._session_start_baseline = ( self._session_start_baseline = (
baseline_history[-1]["v"] baseline_history[-1]["v"]

View File

@@ -6,7 +6,6 @@ from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from backend.apps.agent_service import create_app from backend.apps.agent_service import create_app
from backend.api import agents as agents_module
def test_agent_service_routes_include_control_plane_endpoints(tmp_path): def test_agent_service_routes_include_control_plane_endpoints(tmp_path):
@@ -26,79 +25,3 @@ def test_agent_service_excludes_runtime_routes(tmp_path):
assert "/api/runtime/start" not in paths assert "/api/runtime/start" not in paths
assert "/api/runtime/gateway/port" not in paths assert "/api/runtime/gateway/port" not in paths
def test_agent_service_read_routes(monkeypatch, tmp_path):
class _FakeSkillsManager:
project_root = tmp_path
def get_agent_asset_dir(self, config_name, agent_id):
return tmp_path / "runs" / config_name / "agents" / agent_id
def resolve_agent_skill_names(self, config_name, agent_id, default_skills=None):
return ["demo_skill"]
def list_agent_skill_catalog(self, config_name, agent_id):
return [
type(
"Skill",
(),
{
"skill_name": "demo_skill",
"name": "Demo Skill",
"description": "demo",
"version": "1.0.0",
"source": "builtin",
"tools": [],
},
)()
]
def load_agent_skill_document(self, config_name, agent_id, skill_name):
return {"skill_name": skill_name, "content": "# demo"}
class _FakeWorkspaceManager:
def load_agent_file(self, config_name, agent_id, filename):
return f"{config_name}:{agent_id}:{filename}"
monkeypatch.setattr(agents_module, "load_agent_profiles", lambda: {"portfolio_manager": {"skills": ["demo_skill"]}})
monkeypatch.setattr(agents_module, "get_agent_model_info", lambda agent_id: ("deepseek-v3.2", "DASHSCOPE"))
monkeypatch.setattr(
agents_module,
"load_agent_workspace_config",
lambda path: type(
"Cfg",
(),
{
"active_tool_groups": ["portfolio_ops"],
"disabled_tool_groups": [],
"enabled_skills": [],
"disabled_skills": [],
"prompt_files": ["SOUL.md", "MEMORY.md"],
},
)(),
)
monkeypatch.setattr(
agents_module,
"get_bootstrap_config_for_run",
lambda project_root, config_name: type("Bootstrap", (), {"agent_override": lambda self, agent_id: {}})(),
)
app = create_app(project_root=tmp_path)
app.dependency_overrides[agents_module.get_skills_manager] = lambda: _FakeSkillsManager()
app.dependency_overrides[agents_module.get_workspace_manager] = lambda: _FakeWorkspaceManager()
with TestClient(app) as client:
profile = client.get("/api/workspaces/demo/agents/portfolio_manager/profile")
skills = client.get("/api/workspaces/demo/agents/portfolio_manager/skills")
detail = client.get("/api/workspaces/demo/agents/portfolio_manager/skills/demo_skill")
workspace_file = client.get("/api/workspaces/demo/agents/portfolio_manager/files/MEMORY.md")
assert profile.status_code == 200
assert profile.json()["profile"]["model_name"] == "deepseek-v3.2"
assert skills.status_code == 200
assert skills.json()["skills"][0]["skill_name"] == "demo_skill"
assert detail.status_code == 200
assert detail.json()["skill"]["content"] == "# demo"
assert workspace_file.status_code == 200
assert workspace_file.json()["content"] == "demo:portfolio_manager:MEMORY.md"

View File

@@ -34,6 +34,7 @@ def test_live_runs_incremental_market_store_update_before_start(monkeypatch, tmp
monkeypatch.setattr(cli.subprocess, "run", fake_run) monkeypatch.setattr(cli.subprocess, "run", fake_run)
cli.live( cli.live(
mock=False,
config_name="smoke_fullstack", config_name="smoke_fullstack",
host="0.0.0.0", host="0.0.0.0",
port=8765, port=8765,

View File

@@ -0,0 +1,549 @@
# -*- coding: utf-8 -*-
"""Tests for the Gateway main class - core behavior and fallback paths."""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.services.gateway import Gateway
import backend.services.gateway as gateway_module
class DummyWebSocket:
def __init__(self):
self.messages = []
self.closed = False
self._queue = []
def queue(self, data: str):
"""Queue a raw message string to be yielded by the async iterator."""
self._queue.append(data)
def __aiter__(self):
return self
async def __anext__(self):
if not self._queue:
raise StopAsyncIteration
return self._queue.pop(0)
async def send(self, payload: str):
self.messages.append(json.loads(payload))
async def close(self):
self.closed = True
class DummyStateSync:
def __init__(self, current_date="2026-03-16"):
self.state = {"current_date": current_date}
self.system_messages = []
self.saved = False
self.initial_state_payload = {}
def set_broadcast_fn(self, _fn):
return None
def update_state(self, key, value):
self.state[key] = value
def save_state(self):
self.saved = True
async def on_system_message(self, message):
self.system_messages.append(message)
def get_initial_state_payload(self, include_dashboard=True):
return {
"status": "running",
"current_date": self.state.get("current_date", ""),
"portfolio": {},
"holdings": [],
"trades": [],
}
class DummyMarketService:
def __init__(self):
self.broadcast_func = None
self.market_status = {"is_open": True, "session": "regular"}
def set_price_recorder(self, _fn):
return None
async def start(self, broadcast_func=None):
self.broadcast_func = broadcast_func
def get_market_status(self):
return self.market_status
class DummyStorage:
def __init__(self, initial_cash=100000.0, live_session=False):
self.initial_cash = initial_cash
self.is_live_session_active = live_session
self._market_store = SimpleNamespace()
@property
def market_store(self):
return self._market_store
def load_file(self, name):
if name == "summary":
return {"totalAssetValue": self.initial_cash}
if name in ("holdings", "trades"):
return []
return None
def get_live_returns(self):
return {"session_pnl": 0.0, "session_return": 0.0}
def make_gateway(market_service=None, storage=None, state_sync=None, config=None):
storage = storage or DummyStorage()
state_sync = state_sync or DummyStateSync()
market_service = market_service or DummyMarketService()
pipeline = SimpleNamespace(state_sync=state_sync, max_comm_cycles=0, pm=SimpleNamespace(portfolio={"margin_requirement": 0.0}))
return Gateway(
market_service=market_service,
storage_service=storage,
pipeline=pipeline,
state_sync=state_sync,
config=config or {"mode": "live"},
)
# =============================================================================
# Gateway initialization and core properties
# =============================================================================
def test_gateway_init_sets_live_mode():
gateway = make_gateway(config={"mode": "live"})
assert gateway.mode == "live"
assert gateway.is_backtest is False
def test_gateway_init_sets_backtest_mode_from_config():
gateway = make_gateway(config={"mode": "backtest"})
assert gateway.mode == "backtest"
assert gateway.is_backtest is True
def test_gateway_init_sets_backtest_mode_from_flag():
gateway = make_gateway(config={"backtest_mode": True, "mode": "live"})
assert gateway.is_backtest is True
def test_gateway_init_defaults_to_live_mode():
gateway = make_gateway(config={})
assert gateway.mode == "live"
assert gateway.is_backtest is False
def test_gateway_state_property_returns_state_sync_state():
state_sync = DummyStateSync()
state_sync.state["foo"] = "bar"
gateway = make_gateway(state_sync=state_sync)
assert gateway.state["foo"] == "bar"
def test_gateway_news_rows_need_enrichment_delegates_to_news_domain():
rows = [{"id": "1"}, {"id": "2"}]
with patch.object(gateway_module.news_domain, "news_rows_need_enrichment", return_value=True) as mock:
result = Gateway._news_rows_need_enrichment(rows)
mock.assert_called_once_with(rows)
assert result is True
# =============================================================================
# Service URL helpers and fallback paths
# =============================================================================
def test_news_service_url_returns_config_value(monkeypatch):
gateway = make_gateway(config={"news_service_url": "http://custom-news:9000"})
assert gateway._news_service_url() == "http://custom-news:9000"
def test_news_service_url_falls_back_to_env(monkeypatch):
monkeypatch.setenv("NEWS_SERVICE_URL", "http://env-news:9001")
gateway = make_gateway(config={})
assert gateway._news_service_url() == "http://env-news:9001"
def test_news_service_url_returns_none_when_unset(monkeypatch):
monkeypatch.delenv("NEWS_SERVICE_URL", raising=False)
gateway = make_gateway(config={})
assert gateway._news_service_url() is None
def test_news_service_url_strips_whitespace(monkeypatch):
gateway = make_gateway(config={"news_service_url": " http://whitespace-news:9000 "})
assert gateway._news_service_url() == "http://whitespace-news:9000"
def test_trading_service_url_returns_config_value(monkeypatch):
gateway = make_gateway(config={"trading_service_url": "http://custom-trading:9000"})
assert gateway._trading_service_url() == "http://custom-trading:9000"
def test_trading_service_url_falls_back_to_env(monkeypatch):
monkeypatch.setenv("TRADING_SERVICE_URL", "http://env-trading:9001")
gateway = make_gateway(config={})
assert gateway._trading_service_url() == "http://env-trading:9001"
def test_trading_service_url_returns_none_when_unset(monkeypatch):
monkeypatch.delenv("TRADING_SERVICE_URL", raising=False)
gateway = make_gateway(config={})
assert gateway._trading_service_url() is None
def test_trading_service_url_strips_whitespace(monkeypatch):
gateway = make_gateway(config={"trading_service_url": " http://whitespace-trading:9000 "})
assert gateway._trading_service_url() == "http://whitespace-trading:9000"
@pytest.mark.asyncio
async def test_call_news_service_returns_none_when_url_not_set(monkeypatch):
monkeypatch.delenv("NEWS_SERVICE_URL", raising=False)
gateway = make_gateway(config={})
async def dummy_callback(client):
return "should not be called"
result = await gateway._call_news_service("test_action", dummy_callback)
assert result is None
@pytest.mark.asyncio
async def test_call_news_service_calls_callback_and_returns():
gateway = make_gateway(config={"news_service_url": "http://news:9000"})
async def callback(client):
return {"result": "ok"}
result = await gateway._call_news_service("test_action", callback)
assert result == {"result": "ok"}
@pytest.mark.asyncio
async def test_call_news_service_returns_none_on_exception():
gateway = make_gateway(config={"news_service_url": "http://news:9000"})
async def failing_callback(client):
raise RuntimeError("connection failed")
result = await gateway._call_news_service("test_action", failing_callback)
assert result is None
@pytest.mark.asyncio
async def test_call_trading_service_returns_none_when_url_not_set(monkeypatch):
monkeypatch.delenv("TRADING_SERVICE_URL", raising=False)
gateway = make_gateway(config={})
result = await gateway._call_trading_service("test_action", lambda c: None)
assert result is None
@pytest.mark.asyncio
async def test_call_trading_service_calls_callback_and_returns():
gateway = make_gateway(config={"trading_service_url": "http://trading:9000"})
async def callback(client):
return {"result": "ok"}
result = await gateway._call_trading_service("test_action", callback)
assert result == {"result": "ok"}
@pytest.mark.asyncio
async def test_call_trading_service_returns_none_on_exception():
gateway = make_gateway(config={"trading_service_url": "http://trading:9000"})
async def failing_callback(client):
raise RuntimeError("connection failed")
result = await gateway._call_trading_service("test_action", failing_callback)
assert result is None
# =============================================================================
# WebSocket message handlers
# =============================================================================
@pytest.mark.asyncio
async def test_handle_client_messages_ping_returns_pong():
"""Ping message type results in a pong response."""
gateway = make_gateway()
ws = DummyWebSocket()
ws.queue(json.dumps({"type": "ping"}))
await gateway._handle_client_messages(ws)
assert ws.messages[-1]["type"] == "pong"
assert "timestamp" in ws.messages[-1]
@pytest.mark.asyncio
async def test_handle_client_messages_get_state_sends_initial_state():
"""get_state message type triggers _send_initial_state."""
gateway = make_gateway()
ws = DummyWebSocket()
ws.queue(json.dumps({"type": "get_state"}))
with patch.object(gateway, "_send_initial_state", AsyncMock()) as mock_send:
await gateway._handle_client_messages(ws)
mock_send.assert_called_once_with(ws)
@pytest.mark.asyncio
async def test_handle_client_messages_unknown_type_is_silently_ignored():
"""Unknown message types are silently ignored without error."""
gateway = make_gateway()
ws = DummyWebSocket()
ws.queue(json.dumps({"type": "unknown_type"}))
# Should not raise
await gateway._handle_client_messages(ws)
assert len(ws.messages) == 0
@pytest.mark.asyncio
async def test_handle_client_messages_json_decode_error_is_silently_ignored():
"""Invalid JSON messages are caught by the handler's except block."""
gateway = make_gateway()
ws = DummyWebSocket()
ws.queue("not valid json")
# Should not raise
await gateway._handle_client_messages(ws)
assert len(ws.messages) == 0
# =============================================================================
# Backtest handling
# =============================================================================
@pytest.mark.asyncio
async def test_handle_start_backtest_ignored_when_not_backtest_mode():
gateway = make_gateway(config={"mode": "live"})
# Should not raise - backtest is ignored in live mode
await gateway._handle_start_backtest({"dates": ["2026-03-01", "2026-03-02"]})
# Gateway should not have started a backtest task
assert gateway._backtest_task is None
@pytest.mark.asyncio
async def test_handle_start_backtest_ignored_when_task_already_running():
gateway = make_gateway(config={"mode": "backtest"})
# Pre-set a backtest task
dummy_task = MagicMock()
dummy_task.done.return_value = False
gateway._backtest_task = dummy_task
# Should not start a new task
await gateway._handle_start_backtest({"dates": ["2026-03-01"]})
assert gateway._backtest_task is dummy_task # unchanged
# =============================================================================
# Manual trigger (live/mock mode)
# =============================================================================
@pytest.mark.asyncio
async def test_handle_manual_trigger_rejected_in_backtest_mode():
gateway = make_gateway(config={"mode": "backtest"})
ws = DummyWebSocket()
await gateway._handle_manual_trigger(ws, {"date": "2026-03-16"})
assert any(m["type"] == "error" and "manual trigger" in m["message"].lower() for m in ws.messages)
@pytest.mark.asyncio
async def test_handle_manual_trigger_rejected_when_cycle_already_running():
gateway = make_gateway(config={"mode": "live"})
ws = DummyWebSocket()
# Simulate a running cycle task
dummy_task = MagicMock()
dummy_task.done.return_value = False
gateway._manual_cycle_task = dummy_task
await gateway._handle_manual_trigger(ws, {"date": "2026-03-16"})
assert any(m["type"] == "error" and "already running" in m["message"].lower() for m in ws.messages)
# =============================================================================
# Normalization helpers
# =============================================================================
def test_normalize_watchlist_filters_empty_and_dedupes():
result = Gateway._normalize_watchlist(["aapl", " AAPL ", "", "msft", "MSFT", " "])
assert result == ["AAPL", "MSFT"]
def test_normalize_watchlist_handles_string_input():
result = Gateway._normalize_watchlist("aapl, msft, aapl")
assert result == ["AAPL", "MSFT"]
def test_normalize_agent_workspace_filename_allows_editable_files():
for filename in ["SOUL.md", "PROFILE.md", "AGENTS.md", "MEMORY.md", "POLICY.md"]:
result = Gateway._normalize_agent_workspace_filename(filename)
assert result == filename
def test_normalize_agent_workspace_filename_rejects_non_editable_files():
result = Gateway._normalize_agent_workspace_filename("README.md")
assert result is None
def test_normalize_agent_workspace_filename_rejects_arbitrary_paths():
result = Gateway._normalize_agent_workspace_filename("../etc/passwd")
assert result is None
# =============================================================================
# Broadcast
# =============================================================================
@pytest.mark.asyncio
async def test_broadcast_skips_when_no_clients():
gateway = make_gateway()
gateway.connected_clients = set()
# Should not raise
await gateway.broadcast({"type": "test"})
@pytest.mark.asyncio
async def test_broadcast_sends_to_all_connected_clients():
gateway = make_gateway()
ws1 = DummyWebSocket()
ws2 = DummyWebSocket()
gateway.connected_clients = {ws1, ws2}
await gateway.broadcast({"type": "market_update", "data": "test"})
assert all(m["type"] == "market_update" for m in ws1.messages + ws2.messages)
assert ws1.messages[0]["data"] == "test"
assert ws2.messages[0]["data"] == "test"
@pytest.mark.asyncio
async def test_broadcast_removes_closed_connections():
"""Verify closed connections are removed from connected_clients set.
The broadcast method's _send_to_client helper removes a client
when it raises websockets.ConnectionClosed.
"""
gateway = make_gateway()
closed_ws = DummyWebSocket()
open_ws = DummyWebSocket()
gateway.connected_clients = {closed_ws, open_ws}
# Make closed_ws.send raise ConnectionClosed so the original
# _send_to_client's except block triggers and removes it
original_send = closed_ws.send
async def raising_send(payload):
raise gateway_module.websockets.ConnectionClosed(None, None)
closed_ws.send = raising_send
try:
await gateway.broadcast({"type": "test"})
except gateway_module.websockets.ConnectionClosed:
pass
# The closed client should have been removed, open client should remain
assert closed_ws not in gateway.connected_clients
assert open_ws in gateway.connected_clients
@pytest.mark.asyncio
async def test_broadcast_sends_to_all_connected_clients():
"""Verify broadcast sends to all connected clients and collects results."""
gateway = make_gateway()
ws1 = DummyWebSocket()
ws2 = DummyWebSocket()
gateway.connected_clients = {ws1, ws2}
await gateway.broadcast({"type": "market_update", "data": "test"})
assert all(m["type"] == "market_update" for m in ws1.messages + ws2.messages)
assert ws1.messages[0]["data"] == "test"
assert ws2.messages[0]["data"] == "test"
# =============================================================================
# Stop
# =============================================================================
def test_stop_gateway_calls_cycle_support():
gateway = make_gateway()
with patch.object(gateway_module.gateway_cycle_support, "stop_gateway") as mock:
gateway.stop()
mock.assert_called_once_with(gateway)
# =============================================================================
# set_backtest_dates
# =============================================================================
def test_set_backtest_dates_delegates_to_cycle_support():
gateway = make_gateway()
with patch.object(gateway_module.gateway_cycle_support, "set_backtest_dates") as mock:
gateway.set_backtest_dates(["2026-03-01", "2026-03-02"])
mock.assert_called_once_with(gateway, ["2026-03-01", "2026-03-02"])
# =============================================================================
# Provider usage change callback
# =============================================================================
def test_on_provider_usage_changed_updates_state_sync():
"""_on_provider_usage_changed updates state_sync with the provider snapshot."""
gateway = make_gateway()
gateway._loop = None # no loop set
snapshot = {"provider": "finnhub", "calls": 10}
gateway._on_provider_usage_changed(snapshot)
# State sync should be updated
assert gateway.state_sync.state.get("data_sources") == snapshot
# =============================================================================
# handle_client lifecycle
# =============================================================================
@pytest.mark.asyncio
async def test_handle_client_adds_and_removes_client_from_connected_set():
gateway = make_gateway()
ws = DummyWebSocket()
with patch.object(gateway, "_send_initial_state", AsyncMock()):
with patch.object(gateway, "_handle_client_messages", AsyncMock()):
await gateway.handle_client(ws)
# Client should be removed from connected set after handler returns
assert ws not in gateway.connected_clients
@pytest.mark.asyncio
async def test_handle_client_adds_client_before_handler():
gateway = make_gateway()
ws = DummyWebSocket()
with patch.object(gateway, "_send_initial_state", AsyncMock()):
with patch.object(gateway, "_handle_client_messages", AsyncMock()):
await gateway.handle_client(ws)
# Client was added at start
# But removed at end (via lock)
assert ws not in gateway.connected_clients

View File

@@ -77,15 +77,6 @@ class _DummyStorage:
return {"totalAssetValue": self.initial_cash} return {"totalAssetValue": self.initial_cash}
return [] return []
def build_dashboard_snapshot_from_state(self, state):
return {
"summary": {"totalAssetValue": self.initial_cash},
"holdings": [],
"stats": {},
"trades": [],
"leaderboard": [],
}
class _DummyPM: class _DummyPM:
def __init__(self): def __init__(self):

View File

@@ -2,12 +2,153 @@
# pylint: disable=W0212 # pylint: disable=W0212
import asyncio import asyncio
import time import time
import logging
from unittest.mock import MagicMock, AsyncMock, patch from unittest.mock import MagicMock, AsyncMock, patch
import pytest import pytest
from backend.services.market import MarketService from backend.services.market import MarketService
from backend.data.mock_price_manager import MockPriceManager
from backend.data.polling_price_manager import PollingPriceManager from backend.data.polling_price_manager import PollingPriceManager
from backend.llm.models import RetryChatModel
class TestMockPriceManager:
def test_init_default(self):
manager = MockPriceManager()
assert manager.poll_interval == 10
assert manager.volatility == 0.5
assert manager.running is False
assert len(manager.subscribed_symbols) == 0
def test_init_custom(self):
manager = MockPriceManager(poll_interval=5, volatility=1.0)
assert manager.poll_interval == 5
assert manager.volatility == 1.0
def test_subscribe(self):
manager = MockPriceManager()
manager.subscribe(["AAPL", "MSFT"])
assert "AAPL" in manager.subscribed_symbols
assert "MSFT" in manager.subscribed_symbols
assert manager.base_prices["AAPL"] == 237.50 # default price
assert manager.base_prices["MSFT"] == 425.30 # default price
def test_subscribe_with_base_prices(self):
manager = MockPriceManager()
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
assert manager.base_prices["AAPL"] == 100.0
assert manager.open_prices["AAPL"] == 100.0
assert manager.latest_prices["AAPL"] == 100.0
def test_subscribe_unknown_symbol(self):
manager = MockPriceManager()
manager.subscribe(["UNKNOWN"])
assert "UNKNOWN" in manager.subscribed_symbols
assert manager.base_prices["UNKNOWN"] > 0 # random price generated
def test_unsubscribe(self):
manager = MockPriceManager()
manager.subscribe(["AAPL", "MSFT"])
manager.unsubscribe(["AAPL"])
assert "AAPL" not in manager.subscribed_symbols
assert "MSFT" in manager.subscribed_symbols
def test_add_price_callback(self):
manager = MockPriceManager()
callback = MagicMock()
manager.add_price_callback(callback)
assert callback in manager.price_callbacks
def test_generate_price_update_within_bounds(self):
manager = MockPriceManager(volatility=0.5)
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
for _ in range(100):
new_price = manager._generate_price_update("AAPL")
# Should be within +/-10% of open
assert 90.0 <= new_price <= 110.0
def test_update_prices_triggers_callback(self):
manager = MockPriceManager()
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
callback = MagicMock()
manager.add_price_callback(callback)
manager._update_prices()
callback.assert_called_once()
call_args = callback.call_args[0][0]
assert call_args["symbol"] == "AAPL"
assert "price" in call_args
assert "timestamp" in call_args
def test_start_stop(self):
manager = MockPriceManager(poll_interval=1)
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
manager.start()
assert manager.running is True
time.sleep(0.1) # let thread start
manager.stop()
assert manager.running is False
def test_start_without_subscription(self):
manager = MockPriceManager()
manager.start()
assert (
manager.running is False
) # should not start without subscriptions
def test_get_latest_price(self):
manager = MockPriceManager()
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
price = manager.get_latest_price("AAPL")
assert price == 100.0
def test_get_latest_price_unknown(self):
manager = MockPriceManager()
price = manager.get_latest_price("UNKNOWN")
assert price is None
def test_get_all_latest_prices(self):
manager = MockPriceManager()
manager.subscribe(
["AAPL", "MSFT"],
base_prices={"AAPL": 100.0, "MSFT": 200.0},
)
prices = manager.get_all_latest_prices()
assert prices["AAPL"] == 100.0
assert prices["MSFT"] == 200.0
def test_reset_open_prices(self):
manager = MockPriceManager()
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
manager.latest_prices["AAPL"] = 105.0
manager.reset_open_prices()
# Open price should change (based on latest with small gap)
assert manager.open_prices["AAPL"] != 100.0
def test_set_base_price(self):
manager = MockPriceManager()
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
manager.set_base_price("AAPL", 150.0)
assert manager.base_prices["AAPL"] == 150.0
assert manager.open_prices["AAPL"] == 150.0
assert manager.latest_prices["AAPL"] == 150.0
class TestPollingPriceManager: class TestPollingPriceManager:
@@ -90,67 +231,37 @@ class TestPollingPriceManager:
assert len(manager.open_prices) == 0 assert len(manager.open_prices) == 0
def test_fetch_prices_suppresses_repeated_failures(self, caplog):
manager = PollingPriceManager(provider="yfinance", poll_interval=10)
manager.subscribe(["AAPL"])
with patch.object(manager, "_fetch_quote", side_effect=ValueError("empty quote")):
with caplog.at_level(logging.DEBUG):
for _ in range(3):
manager._fetch_prices()
assert manager._failure_counts["AAPL"] == 3
warning_messages = [record.message for record in caplog.records if record.levelno >= logging.WARNING]
assert any("Failed to fetch AAPL price: empty quote" in message for message in warning_messages)
def test_fetch_prices_logs_recovery_after_failure(self, caplog):
manager = PollingPriceManager(provider="yfinance", poll_interval=10)
manager.subscribe(["AAPL"])
with patch.object(
manager,
"_fetch_quote",
side_effect=[
ValueError("temporary outage"),
{"c": 100.0, "o": 99.0, "h": 101.0, "l": 98.0, "pc": 99.5, "d": 0.5, "dp": 0.5, "t": 1},
],
):
with caplog.at_level(logging.INFO):
manager._fetch_prices()
manager._fetch_prices()
assert "AAPL" not in manager._failure_counts
assert any("recovered after 1 consecutive failures" in record.message for record in caplog.records)
class TestRetryChatModel:
@pytest.mark.asyncio
async def test_async_retry_recovers_from_disconnect(self):
attempts = {"count": 0}
class FakeAsyncModel:
model_name = "fake-async-model"
async def __call__(self, *args, **kwargs):
attempts["count"] += 1
if attempts["count"] < 2:
raise RuntimeError("Server disconnected")
return {"ok": True}
wrapped = RetryChatModel(FakeAsyncModel(), max_retries=2, initial_delay=0.01)
result = await wrapped("hello")
assert result == {"ok": True}
assert attempts["count"] == 2
class TestMarketService: class TestMarketService:
@patch("backend.services.market.get_data_sources", return_value=["yfinance", "local_csv"]) 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.object(PollingPriceManager, "start") @patch.object(PollingPriceManager, "start")
def test_start_real_mode_with_yfinance(self, _mock_start, _mock_sources): def test_start_real_mode_with_yfinance(self, _mock_start, _mock_source):
service = MarketService( service = MarketService(
tickers=["AAPL"], tickers=["AAPL"],
poll_interval=10, poll_interval=10,
mock_mode=False,
) )
service._start_real_mode() service._start_real_mode()
@@ -158,24 +269,30 @@ class TestMarketService:
assert isinstance(service._price_manager, PollingPriceManager) assert isinstance(service._price_manager, PollingPriceManager)
assert service._price_manager.provider == "yfinance" assert service._price_manager.provider == "yfinance"
@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,
)
service._start_real_mode()
assert isinstance(service._price_manager, PollingPriceManager)
assert service._price_manager.provider == "yfinance"
@patch("backend.services.market.get_data_sources", return_value=["finnhub", "yfinance"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_real_mode_without_api_key(self, _mock_sources): async def test_start_mock_mode(self):
service = MarketService( service = MarketService(
tickers=["AAPL"], tickers=["AAPL"],
poll_interval=10,
mock_mode=True,
)
broadcast_func = AsyncMock()
await service.start(broadcast_func)
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")
@pytest.mark.asyncio
async def test_start_real_mode_without_api_key(self, _mock_source):
service = MarketService(
tickers=["AAPL"],
mock_mode=False,
api_key=None, api_key=None,
) )
@@ -190,12 +307,11 @@ class TestMarketService:
async def test_start_already_running(self): async def test_start_already_running(self):
service = MarketService( service = MarketService(
tickers=["AAPL"], tickers=["AAPL"],
backtest_mode=True, mock_mode=True,
) )
broadcast_func = AsyncMock() broadcast_func = AsyncMock()
# First start with backtest mode
await service.start(broadcast_func) await service.start(broadcast_func)
assert service.running is True assert service.running is True
@@ -207,7 +323,7 @@ class TestMarketService:
def test_stop(self): def test_stop(self):
service = MarketService( service = MarketService(
tickers=["AAPL"], tickers=["AAPL"],
backtest_mode=True, mock_mode=True,
) )
service.running = True service.running = True
service._price_manager = MagicMock() service._price_manager = MagicMock()
@@ -220,7 +336,7 @@ class TestMarketService:
def test_stop_when_not_running(self): def test_stop_when_not_running(self):
service = MarketService( service = MarketService(
tickers=["AAPL"], tickers=["AAPL"],
backtest_mode=True, mock_mode=True,
) )
# Should not raise # Should not raise
@@ -228,20 +344,20 @@ class TestMarketService:
assert service.running is False assert service.running is False
def test_get_price_sync(self): def test_get_price_sync(self):
service = MarketService(tickers=["AAPL"], backtest_mode=True) service = MarketService(tickers=["AAPL"], mock_mode=True)
service.cache["AAPL"] = {"price": 150.0, "open": 148.0} service.cache["AAPL"] = {"price": 150.0, "open": 148.0}
price = service.get_price_sync("AAPL") price = service.get_price_sync("AAPL")
assert price == 150.0 assert price == 150.0
def test_get_price_sync_not_found(self): def test_get_price_sync_not_found(self):
service = MarketService(tickers=["AAPL"], backtest_mode=True) service = MarketService(tickers=["AAPL"], mock_mode=True)
price = service.get_price_sync("MSFT") price = service.get_price_sync("MSFT")
assert price is None assert price is None
def test_get_all_prices(self): def test_get_all_prices(self):
service = MarketService(tickers=["AAPL", "MSFT"], backtest_mode=True) service = MarketService(tickers=["AAPL", "MSFT"], mock_mode=True)
service.cache["AAPL"] = {"price": 150.0} service.cache["AAPL"] = {"price": 150.0}
service.cache["MSFT"] = {"price": 400.0} service.cache["MSFT"] = {"price": 400.0}
@@ -252,7 +368,7 @@ class TestMarketService:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_broadcast_price_update(self): async def test_broadcast_price_update(self):
service = MarketService(tickers=["AAPL"], backtest_mode=True) service = MarketService(tickers=["AAPL"], mock_mode=True)
service._broadcast_func = AsyncMock() service._broadcast_func = AsyncMock()
price_data = { price_data = {
@@ -272,7 +388,7 @@ class TestMarketService:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_broadcast_price_update_no_func(self): async def test_broadcast_price_update_no_func(self):
service = MarketService(tickers=["AAPL"], backtest_mode=True) service = MarketService(tickers=["AAPL"], mock_mode=True)
service._broadcast_func = None service._broadcast_func = None
price_data = {"symbol": "AAPL", "price": 150.0, "open": 148.0} price_data = {"symbol": "AAPL", "price": 150.0, "open": 148.0}
@@ -280,6 +396,67 @@ class TestMarketService:
# Should not raise # Should not raise
await service._broadcast_price_update(price_data) await service._broadcast_price_update(price_data)
@pytest.mark.asyncio
async def test_price_callback_thread_safety(self):
service = MarketService(
tickers=["AAPL"],
poll_interval=1,
mock_mode=True,
)
received_prices = []
async def capture_broadcast(msg):
received_prices.append(msg)
await service.start(capture_broadcast)
# Wait for at least one price update
await asyncio.sleep(1.5)
service.stop()
# Should have received at least one price update
assert len(received_prices) >= 1
assert received_prices[0]["type"] == "price_update"
class TestMarketServiceIntegration:
@pytest.mark.asyncio
async def test_full_mock_cycle(self):
service = MarketService(
tickers=["AAPL", "MSFT"],
poll_interval=1,
mock_mode=True,
)
messages = []
async def collect_messages(msg):
messages.append(msg)
await service.start(collect_messages)
# Wait for price updates
await asyncio.sleep(2.5)
service.stop()
# Should have received multiple price updates
assert len(messages) >= 2
# Check message structure
symbols_seen = set()
for msg in messages:
assert msg["type"] == "price_update"
assert "symbol" in msg
assert "price" in msg
assert "ret" in msg
symbols_seen.add(msg["symbol"])
# Should have prices for both tickers
assert "AAPL" in symbols_seen or "MSFT" in symbols_seen
if __name__ == "__main__": if __name__ == "__main__":
pytest.main([__file__, "-v"]) pytest.main([__file__, "-v"])

View File

@@ -80,7 +80,7 @@ def test_get_enriched_news_returns_rows_without_enrichment_when_present(monkeypa
monkeypatch.setattr( monkeypatch.setattr(
news_domain, news_domain,
"ensure_news_fresh", "ensure_news_fresh",
lambda store, ticker, target_date=None, refresh_if_stale=False: { lambda store, ticker, target_date=None: {
"ticker": ticker, "ticker": ticker,
"target_date": target_date, "target_date": target_date,
"last_news_fetch": target_date, "last_news_fetch": target_date,
@@ -109,7 +109,7 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
news_domain, news_domain,
"ensure_news_fresh", "ensure_news_fresh",
lambda store, ticker, target_date=None, refresh_if_stale=False: { lambda store, ticker, target_date=None: {
"ticker": ticker, "ticker": ticker,
"target_date": target_date, "target_date": target_date,
"last_news_fetch": target_date, "last_news_fetch": target_date,
@@ -137,38 +137,12 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
assert "freshness" in similar assert "freshness" in similar
def test_get_enriched_news_defaults_to_read_only_freshness(monkeypatch):
store = _FakeStore()
ensure_calls = []
def fake_ensure(store, ticker, target_date=None, refresh_if_stale=False):
ensure_calls.append(refresh_if_stale)
return {
"ticker": ticker,
"target_date": target_date,
"last_news_fetch": target_date,
"refreshed": False,
}
monkeypatch.setattr(news_domain, "ensure_news_fresh", fake_ensure)
monkeypatch.setattr(news_domain, "news_rows_need_enrichment", lambda rows: False)
payload = news_domain.get_enriched_news(
store,
ticker="AAPL",
end_date="2026-03-16",
)
assert payload["ticker"] == "AAPL"
assert ensure_calls == [False]
def test_get_range_explain_payload_uses_article_ids(monkeypatch): def test_get_range_explain_payload_uses_article_ids(monkeypatch):
store = _FakeStore() store = _FakeStore()
monkeypatch.setattr( monkeypatch.setattr(
news_domain, news_domain,
"ensure_news_fresh", "ensure_news_fresh",
lambda store, ticker, target_date=None, refresh_if_stale=False: { lambda store, ticker, target_date=None: {
"ticker": ticker, "ticker": ticker,
"target_date": target_date, "target_date": target_date,
"last_news_fetch": target_date, "last_news_fetch": target_date,

View File

@@ -1,15 +1,31 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Tests for the extracted runtime service app surface.""" """Tests for the extracted runtime service app surface."""
import asyncio
import json import json
from pathlib import Path from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from backend.api import runtime as runtime_module from backend.api import runtime as runtime_module
from backend.apps.runtime_service import create_app from backend.apps.runtime_service import create_app
@pytest.fixture(autouse=True)
def reset_runtime_module_state():
"""Reset module-level runtime_manager before each test."""
runtime_module.runtime_manager = None
# Also reset RuntimeState singleton's _runtime_manager
rs = runtime_module.get_runtime_state()
rs._runtime_manager = None
yield
runtime_module.runtime_manager = None
rs = runtime_module.get_runtime_state()
rs._runtime_manager = None
def test_runtime_service_routes_are_exposed(): def test_runtime_service_routes_are_exposed():
app = create_app() app = create_app()
paths = {route.path for route in app.routes} paths = {route.path for route in app.routes}
@@ -18,8 +34,6 @@ def test_runtime_service_routes_are_exposed():
assert "/api/status" in paths assert "/api/status" in paths
assert "/api/runtime/start" in paths assert "/api/runtime/start" in paths
assert "/api/runtime/stop" in paths assert "/api/runtime/stop" in paths
assert "/api/runtime/cleanup" in paths
assert "/api/runtime/history" in paths
assert "/api/runtime/current" in paths assert "/api/runtime/current" in paths
assert "/api/runtime/gateway/port" in paths assert "/api/runtime/gateway/port" in paths
@@ -156,7 +170,9 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
) )
class _DummyContext: class _DummyContext:
def __init__(self): def __init__(self, run_dir):
self.config_name = "demo"
self.run_dir = run_dir
self.bootstrap_values = { self.bootstrap_values = {
"tickers": ["AAPL"], "tickers": ["AAPL"],
"schedule_mode": "daily", "schedule_mode": "daily",
@@ -168,8 +184,17 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
class _DummyManager: class _DummyManager:
def __init__(self): def __init__(self):
self.config_name = "demo" self.config_name = "demo"
self.bootstrap = dict(_DummyContext().bootstrap_values) self.bootstrap = dict(_DummyContext(run_dir).bootstrap_values)
self.context = _DummyContext() self.context = _DummyContext(run_dir)
def build_snapshot(self):
return {
"context": {
"config_name": self.context.config_name,
"run_dir": str(self.context.run_dir),
"bootstrap_values": self.context.bootstrap_values,
}
}
def _persist_snapshot(self): def _persist_snapshot(self):
return None return None
@@ -197,168 +222,383 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8") assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8")
def test_prune_old_timestamped_runs_keeps_named_runs(monkeypatch, tmp_path): # =============================================================================
runs_dir = tmp_path / "runs" # RuntimeState singleton unit tests
runs_dir.mkdir() # =============================================================================
keep_dirs = ["20260324_110000", "20260324_120000"] def test_runtime_state_is_singleton():
prune_dir = "20260324_100000" """RuntimeState.__new__ returns the same instance across calls."""
named_dir = "smoke_fullstack" state1 = runtime_module.RuntimeState()
state2 = runtime_module.RuntimeState()
for name in [*keep_dirs, prune_dir, named_dir]: assert state1 is state2
(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): def test_runtime_state_get_runtime_state_returns_same_instance():
runs_dir = tmp_path / "runs" """get_runtime_state() returns the module singleton."""
runs_dir.mkdir() instance = runtime_module.get_runtime_state()
assert instance is runtime_module._runtime_state
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): def test_runtime_state_default_values():
run_dir = tmp_path / "runs" / "20260324_120000" """RuntimeState initializes with sensible defaults on first instantiation."""
(run_dir / "state").mkdir(parents=True) # Reset singleton to get fresh __init__ values
(run_dir / "team_dashboard").mkdir(parents=True) runtime_module.RuntimeState._instance = None
(run_dir / "state" / "runtime_state.json").write_text( runtime_module.RuntimeState._lock = asyncio.Lock()
json.dumps( state = runtime_module.RuntimeState()
{ assert state._runtime_manager is None
"context": { assert state._gateway_process is None
"config_name": "20260324_120000", assert state._gateway_port == 8765
"run_dir": str(run_dir),
"bootstrap_values": {"tickers": ["AAPL"]},
}, def test_runtime_state_gateway_port_property():
"events": [], """gateway_port property getter and setter work correctly."""
} runtime_module.RuntimeState._instance = None
), runtime_module.RuntimeState._lock = asyncio.Lock()
encoding="utf-8", state = runtime_module.RuntimeState()
state.gateway_port = 9999
assert state.gateway_port == 9999
state.gateway_port = 1234
assert state.gateway_port == 1234
def test_runtime_state_gateway_process_property():
"""gateway_process property getter and setter work correctly."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
assert state.gateway_process is None
fake_process = object()
state.gateway_process = fake_process
assert state.gateway_process is fake_process
state.gateway_process = None
assert state.gateway_process is None
def test_runtime_state_runtime_manager_property():
"""runtime_manager property getter and setter work correctly."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
assert state.runtime_manager is None
fake_manager = object()
state.runtime_manager = fake_manager
assert state.runtime_manager is fake_manager
state.runtime_manager = None
assert state.runtime_manager is None
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
def test_runtime_state_lock_property_is_async():
"""lock is an async property that returns a coroutine producing an asyncio.Lock."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
lock_coro = state.lock
assert asyncio.iscoroutine(lock_coro)
@pytest.mark.asyncio
async def test_runtime_state_async_set_get_gateway_port():
"""Async setters and getters for gateway_port with lock protection."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
await state.set_gateway_port(8888)
assert await state.get_gateway_port() == 8888
await state.set_gateway_port(7777)
assert await state.get_gateway_port() == 7777
@pytest.mark.asyncio
async def test_runtime_state_async_set_get_gateway_process():
"""Async setters and getters for gateway_process with lock protection."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
await state.set_gateway_process(None)
assert await state.get_gateway_process() is None
fake_process = object()
await state.set_gateway_process(fake_process)
assert await state.get_gateway_process() is fake_process
@pytest.mark.asyncio
async def test_runtime_state_async_set_get_runtime_manager():
"""Async setters and getters for runtime_manager with lock protection."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
await state.set_runtime_manager(None)
assert await state.get_runtime_manager() is None
fake_manager = object()
await state.set_runtime_manager(fake_manager)
assert await state.get_runtime_manager() is fake_manager
# =============================================================================
# _is_gateway_running helper tests
# =============================================================================
def test_is_gateway_running_returns_false_when_process_is_none():
"""_is_gateway_running returns False when gateway_process is None."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
new_state = runtime_module.RuntimeState()
new_state._gateway_process = None
runtime_module._runtime_state = new_state
assert runtime_module._is_gateway_running() is False
def test_is_gateway_running_returns_false_when_process_exited():
"""_is_gateway_running returns False when process has terminated."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
runtime_module._runtime_state = state
mock_process = MagicMock()
mock_process.poll.return_value = 1 # non-None = process has exited
state._gateway_process = mock_process
assert runtime_module._is_gateway_running() is False
def test_is_gateway_running_returns_true_when_process_running():
"""_is_gateway_running returns True when process is alive."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
runtime_module._runtime_state = state
mock_process = MagicMock()
mock_process.poll.return_value = None # None = still running
state._gateway_process = mock_process
assert runtime_module._is_gateway_running() is True
# =============================================================================
# _stop_gateway helper tests
# =============================================================================
def test_stop_gateway_returns_false_when_no_process():
"""_stop_gateway returns False if no gateway process exists."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
new_state = runtime_module.RuntimeState()
new_state._gateway_process = None
runtime_module._runtime_state = new_state
result = runtime_module._stop_gateway()
assert result is False
def test_stop_gateway_sets_process_to_none_after_stop():
"""_stop_gateway sets _gateway_process to None after stopping."""
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
runtime_module._runtime_state = state
mock_process = MagicMock()
mock_process.poll.return_value = None
mock_process.wait.return_value = 0
state._gateway_process = mock_process
result = runtime_module._stop_gateway()
assert result is True
assert state._gateway_process is None
mock_process.terminate.assert_called_once()
mock_process.wait.assert_called_once()
def test_stop_gateway_kills_when_terminate_times_out():
"""_stop_gateway kills the process if terminate times out."""
import subprocess
runtime_module.RuntimeState._instance = None
runtime_module.RuntimeState._lock = asyncio.Lock()
state = runtime_module.RuntimeState()
runtime_module._runtime_state = state
mock_process = MagicMock()
mock_process.poll.return_value = None
mock_process.wait.side_effect = subprocess.TimeoutExpired("cmd", 5)
mock_process.kill.return_value = None
state._gateway_process = mock_process
result = runtime_module._stop_gateway()
assert result is True
assert state._gateway_process is None
mock_process.kill.assert_called_once()
# =============================================================================
# _build_gateway_ws_url helper tests
# =============================================================================
def test_build_gateway_ws_url_defaults_to_ws():
from fastapi import Request
mock_request = MagicMock(spec=Request)
mock_request.headers.get.side_effect = lambda k, d="": d
mock_request.url.scheme = "http"
mock_request.url.hostname = "localhost"
url = runtime_module._build_gateway_ws_url(mock_request, 8765)
assert url == "ws://localhost:8765"
def test_build_gateway_ws_url_uses_wss_for_https():
from fastapi import Request
mock_request = MagicMock(spec=Request)
mock_request.headers.get.side_effect = lambda k, d="": d
mock_request.url.scheme = "https"
mock_request.url.hostname = "example.com"
url = runtime_module._build_gateway_ws_url(mock_request, 8765)
assert url == "wss://example.com:8765"
def test_build_gateway_ws_url_respects_forwarded_proto():
from fastapi import Request
mock_request = MagicMock(spec=Request)
def header_get(key, default=""):
if key == "x-forwarded-proto":
return "https"
return default
mock_request.headers.get.side_effect = header_get
mock_request.url.scheme = "http"
mock_request.url.hostname = "internal.example"
url = runtime_module._build_gateway_ws_url(mock_request, 8765)
assert url == "wss://internal.example:8765"
def test_build_gateway_ws_url_respects_forwarded_host():
from fastapi import Request
mock_request = MagicMock(spec=Request)
mock_request.headers.get.side_effect = lambda k, d="": {
"x-forwarded-host": "external.example.com"
}.get(k, d)
mock_request.url.scheme = "http"
mock_request.url.hostname = "internal.example"
url = runtime_module._build_gateway_ws_url(mock_request, 8765)
assert url == "ws://external.example.com:8765"
# =============================================================================
# _normalize_runtime_config_updates tests
# =============================================================================
def test_normalize_runtime_config_updates_validates_schedule_mode():
req = runtime_module.UpdateRuntimeConfigRequest(schedule_mode="invalid")
with pytest.raises(HTTPException) as exc_info:
runtime_module._normalize_runtime_config_updates(req)
assert "schedule_mode" in str(exc_info.value.detail).lower()
def test_normalize_runtime_config_updates_validates_schedule_mode_values():
for invalid in ["weekly", "monthly", "once"]:
req = runtime_module.UpdateRuntimeConfigRequest(schedule_mode=invalid)
with pytest.raises(HTTPException):
runtime_module._normalize_runtime_config_updates(req)
def test_normalize_runtime_config_updates_accepts_daily_and_intraday():
for valid in ["daily", "intraday", "DAILY", "IntraDay"]:
req = runtime_module.UpdateRuntimeConfigRequest(schedule_mode=valid)
result = runtime_module._normalize_runtime_config_updates(req)
assert "schedule_mode" in result
def test_normalize_runtime_config_updates_validates_trigger_time_format():
req = runtime_module.UpdateRuntimeConfigRequest(trigger_time="25:99")
with pytest.raises(HTTPException) as exc_info:
runtime_module._normalize_runtime_config_updates(req)
assert "trigger_time" in str(exc_info.value.detail).lower()
def test_normalize_runtime_config_updates_accepts_now_trigger_time():
req = runtime_module.UpdateRuntimeConfigRequest(trigger_time="now")
result = runtime_module._normalize_runtime_config_updates(req)
assert result["trigger_time"] == "now"
def test_normalize_runtime_config_updates_defaults_empty_trigger_time():
req = runtime_module.UpdateRuntimeConfigRequest(trigger_time=" ")
result = runtime_module._normalize_runtime_config_updates(req)
assert result["trigger_time"] == "09:30"
def test_normalize_runtime_config_updates_rejects_no_updates():
req = runtime_module.UpdateRuntimeConfigRequest()
with pytest.raises(HTTPException) as exc_info:
runtime_module._normalize_runtime_config_updates(req)
assert "no runtime config updates" in str(exc_info.value.detail).lower()
def test_normalize_runtime_config_updates_coerces_types():
req = runtime_module.UpdateRuntimeConfigRequest(
schedule_mode="intraday",
interval_minutes="30", # string from JSON
initial_cash="50000.0", # string from JSON
margin_requirement="0.25",
) )
(run_dir / "team_dashboard" / "summary.json").write_text( result = runtime_module._normalize_runtime_config_updates(req)
json.dumps({"totalTrades": 3, "totalAssetValue": 123456.0}), assert result["schedule_mode"] == "intraday"
encoding="utf-8", assert result["interval_minutes"] == 30
) assert result["initial_cash"] == 50000.0
assert result["margin_requirement"] == 0.25
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" # register_runtime_manager / unregister_runtime_manager tests
(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" def test_register_runtime_manager_sets_module_and_singleton():
runtime_module._runtime_state._initialized = True # prevent re-init
fake_manager = object()
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path) runtime_module.register_runtime_manager(fake_manager)
runtime_module._restore_run_assets("20260324_100000", target_run) assert runtime_module.runtime_manager is fake_manager
assert runtime_module._runtime_state.runtime_manager is fake_manager
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): def test_unregister_runtime_manager_clears_module_and_singleton():
run_dir = tmp_path / "runs" / "20260324_100000" runtime_module._runtime_state._initialized = True # prevent re-init
(run_dir / "state").mkdir(parents=True) runtime_module._runtime_state.runtime_manager = object()
(run_dir / "state" / "runtime_state.json").write_text( runtime_module.runtime_manager = object()
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: runtime_module.unregister_runtime_manager()
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): assert runtime_module.runtime_manager is None
self.context = type( assert runtime_module._runtime_state.runtime_manager is None
"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) # _generate_run_id tests
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: def test_generate_run_id_returns_timestamp_format():
response = client.post( run_id = runtime_module._generate_run_id()
"/api/runtime/start", # Format: YYYYMMDD_HHMMSS - length is 15
json={ assert len(run_id) == 15
"launch_mode": "restore", assert run_id[8] == "_" # separator between date and time
"restore_run_id": "20260324_100000", assert run_id[:8].isdigit() # YYYYMMDD
"tickers": [], assert run_id[9:].isdigit() # HHMMSS
},
)
assert response.status_code == 200
payload = response.json()
assert payload["run_id"] == "20260324_100000"
assert payload["run_dir"] == str(run_dir)

View File

@@ -1,47 +1,21 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Unit tests for the trading domain helpers.""" """Unit tests for data_tools functions (replaces the deleted trading_domain)."""
from backend.domains import trading as trading_domain from backend.tools.data_tools import (
get_company_news,
get_financial_metrics,
get_insider_trades,
get_market_cap,
get_prices,
search_line_items,
)
def test_trading_domain_payload_wrappers(monkeypatch): def test_data_tools_functions_exist():
monkeypatch.setattr(trading_domain, "get_prices", lambda ticker, start_date, end_date: [{"close": 1}]) """Verify that all data_tools functions are importable and callable."""
monkeypatch.setattr(trading_domain, "get_financial_metrics", lambda ticker, end_date, period, limit: [{"ticker": ticker}]) assert callable(get_prices)
monkeypatch.setattr(trading_domain, "get_company_news", lambda ticker, end_date, start_date=None, limit=1000: [{"ticker": ticker}]) assert callable(get_financial_metrics)
monkeypatch.setattr(trading_domain, "get_insider_trades", lambda ticker, end_date, start_date=None, limit=1000: [{"ticker": ticker}]) assert callable(get_company_news)
monkeypatch.setattr(trading_domain, "get_market_cap", lambda ticker, end_date: 2.5e12) assert callable(get_insider_trades)
assert callable(get_market_cap)
assert trading_domain.get_prices_payload(ticker="AAPL", start_date="2026-03-01", end_date="2026-03-16") == { assert callable(search_line_items)
"ticker": "AAPL",
"prices": [{"close": 1}],
}
assert trading_domain.get_financials_payload(ticker="AAPL", end_date="2026-03-16") == {
"financial_metrics": [{"ticker": "AAPL"}],
}
assert trading_domain.get_news_payload(ticker="AAPL", end_date="2026-03-16") == {
"news": [{"ticker": "AAPL"}],
}
assert trading_domain.get_insider_trades_payload(ticker="AAPL", end_date="2026-03-16") == {
"insider_trades": [{"ticker": "AAPL"}],
}
assert trading_domain.get_market_cap_payload(ticker="AAPL", end_date="2026-03-16") == {
"ticker": "AAPL",
"end_date": "2026-03-16",
"market_cap": 2.5e12,
}
def test_get_market_status_payload_uses_market_service(monkeypatch):
class _FakeMarketService:
def __init__(self, tickers):
self.tickers = tickers
def get_market_status(self):
return {"status": "open", "status_text": "Open"}
monkeypatch.setattr(trading_domain, "MarketService", _FakeMarketService)
assert trading_domain.get_market_status_payload() == {
"status": "open",
"status_text": "Open",
}

View File

@@ -24,20 +24,17 @@ def test_trading_service_routes_are_exposed():
def test_trading_service_prices_endpoint(monkeypatch): def test_trading_service_prices_endpoint(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"backend.domains.trading.get_prices_payload", "backend.apps.trading_service.get_prices",
lambda ticker, start_date, end_date: { lambda ticker, start_date, end_date: [
"ticker": ticker, Price(
"prices": [ open=1.0,
Price( close=2.0,
open=1.0, high=2.5,
close=2.0, low=0.5,
high=2.5, volume=100,
low=0.5, time="2026-03-20",
volume=100, )
time="2026-03-20", ],
)
],
},
) )
with TestClient(create_app()) as client: with TestClient(create_app()) as client:
@@ -57,56 +54,54 @@ def test_trading_service_prices_endpoint(monkeypatch):
def test_trading_service_financials_endpoint(monkeypatch): def test_trading_service_financials_endpoint(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"backend.domains.trading.get_financials_payload", "backend.apps.trading_service.get_financial_metrics",
lambda ticker, end_date, period, limit: { lambda ticker, end_date, period, limit: [
"financial_metrics": [ FinancialMetrics(
FinancialMetrics( ticker=ticker,
ticker=ticker, report_period=end_date,
report_period=end_date, period=period,
period=period, currency="USD",
currency="USD", market_cap=123.0,
market_cap=123.0, enterprise_value=None,
enterprise_value=None, price_to_earnings_ratio=None,
price_to_earnings_ratio=None, price_to_book_ratio=None,
price_to_book_ratio=None, price_to_sales_ratio=None,
price_to_sales_ratio=None, enterprise_value_to_ebitda_ratio=None,
enterprise_value_to_ebitda_ratio=None, enterprise_value_to_revenue_ratio=None,
enterprise_value_to_revenue_ratio=None, free_cash_flow_yield=None,
free_cash_flow_yield=None, peg_ratio=None,
peg_ratio=None, gross_margin=None,
gross_margin=None, operating_margin=None,
operating_margin=None, net_margin=None,
net_margin=None, return_on_equity=None,
return_on_equity=None, return_on_assets=None,
return_on_assets=None, return_on_invested_capital=None,
return_on_invested_capital=None, asset_turnover=None,
asset_turnover=None, inventory_turnover=None,
inventory_turnover=None, receivables_turnover=None,
receivables_turnover=None, days_sales_outstanding=None,
days_sales_outstanding=None, operating_cycle=None,
operating_cycle=None, working_capital_turnover=None,
working_capital_turnover=None, current_ratio=None,
current_ratio=None, quick_ratio=None,
quick_ratio=None, cash_ratio=None,
cash_ratio=None, operating_cash_flow_ratio=None,
operating_cash_flow_ratio=None, debt_to_equity=None,
debt_to_equity=None, debt_to_assets=None,
debt_to_assets=None, interest_coverage=None,
interest_coverage=None, revenue_growth=None,
revenue_growth=None, earnings_growth=None,
earnings_growth=None, book_value_growth=None,
book_value_growth=None, earnings_per_share_growth=None,
earnings_per_share_growth=None, free_cash_flow_growth=None,
free_cash_flow_growth=None, operating_income_growth=None,
operating_income_growth=None, ebitda_growth=None,
ebitda_growth=None, payout_ratio=None,
payout_ratio=None, earnings_per_share=None,
earnings_per_share=None, book_value_per_share=None,
book_value_per_share=None, free_cash_flow_per_share=None,
free_cash_flow_per_share=None, )
) ],
]
},
) )
with TestClient(create_app()) as client: with TestClient(create_app()) as client:
@@ -121,26 +116,22 @@ def test_trading_service_financials_endpoint(monkeypatch):
def test_trading_service_news_and_insider_endpoints(monkeypatch): def test_trading_service_news_and_insider_endpoints(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"backend.domains.trading.get_news_payload", "backend.apps.trading_service.get_company_news",
lambda ticker, end_date, start_date=None, limit=1000: { lambda ticker, end_date, start_date=None, limit=1000: [
"news": [ CompanyNews(
CompanyNews( ticker=ticker,
ticker=ticker, title="News title",
title="News title", source="polygon",
source="polygon", url="https://example.com/news",
url="https://example.com/news", date=end_date,
date=end_date, )
) ],
]
},
) )
monkeypatch.setattr( monkeypatch.setattr(
"backend.domains.trading.get_insider_trades_payload", "backend.apps.trading_service.get_insider_trades",
lambda ticker, end_date, start_date=None, limit=1000: { lambda ticker, end_date, start_date=None, limit=1000: [
"insider_trades": [ InsiderTrade(ticker=ticker, filing_date=end_date)
InsiderTrade(ticker=ticker, filing_date=end_date) ],
]
},
) )
with TestClient(create_app()) as client: with TestClient(create_app()) as client:
@@ -165,8 +156,8 @@ def test_trading_service_market_status_endpoint(monkeypatch):
return {"status": "open", "status_text": "Open"} return {"status": "open", "status_text": "Open"}
monkeypatch.setattr( monkeypatch.setattr(
"backend.domains.trading.get_market_status_payload", "backend.apps.trading_service.MarketService",
lambda: _FakeMarketService().get_market_status(), lambda tickers: _FakeMarketService(),
) )
with TestClient(create_app()) as client: with TestClient(create_app()) as client:
@@ -178,12 +169,8 @@ def test_trading_service_market_status_endpoint(monkeypatch):
def test_trading_service_market_cap_endpoint(monkeypatch): def test_trading_service_market_cap_endpoint(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"backend.domains.trading.get_market_cap_payload", "backend.apps.trading_service.get_market_cap",
lambda ticker, end_date: { lambda ticker, end_date: 3.5e12,
"ticker": ticker,
"end_date": end_date,
"market_cap": 3.5e12,
},
) )
with TestClient(create_app()) as client: with TestClient(create_app()) as client:
@@ -202,18 +189,16 @@ def test_trading_service_market_cap_endpoint(monkeypatch):
def test_trading_service_line_items_endpoint(monkeypatch): def test_trading_service_line_items_endpoint(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"backend.domains.trading.get_line_items_payload", "backend.apps.trading_service.search_line_items",
lambda ticker, line_items, end_date, period, limit: { lambda ticker, line_items, end_date, period, limit: [
"search_results": [ LineItem(
LineItem( ticker=ticker,
ticker=ticker, report_period=end_date,
report_period=end_date, period=period,
period=period, currency="USD",
currency="USD", free_cash_flow=123.0,
free_cash_flow=123.0, )
) ],
]
},
) )
with TestClient(create_app()) as client: with TestClient(create_app()) as client:

View File

@@ -228,12 +228,12 @@ class SettlementCoordinator:
all_evaluations = {**analyst_evaluations, **pm_evaluations} all_evaluations = {**analyst_evaluations, **pm_evaluations}
leaderboard = self.storage.load_export_file("leaderboard") or [] leaderboard = self.storage.load_file("leaderboard") or []
updated_leaderboard = update_leaderboard_with_evaluations( updated_leaderboard = update_leaderboard_with_evaluations(
leaderboard, leaderboard,
all_evaluations, all_evaluations,
) )
self.storage.save_export_file("leaderboard", updated_leaderboard) self.storage.save_file("leaderboard", updated_leaderboard)
self._update_summary_with_baselines( self._update_summary_with_baselines(
date, date,

View File

@@ -30,6 +30,7 @@ class TerminalDashboard:
self.port = 8765 self.port = 8765
self.poll_interval = 10 self.poll_interval = 10
self.trigger_time = "now" self.trigger_time = "now"
self.mock = False
self.enable_memory = False self.enable_memory = False
self.local_time = "" self.local_time = ""
self.nyse_time = "" self.nyse_time = ""
@@ -64,6 +65,7 @@ class TerminalDashboard:
port: int, port: int,
poll_interval: int, poll_interval: int,
trigger_time: str = "now", trigger_time: str = "now",
mock: bool = False,
enable_memory: bool = False, enable_memory: bool = False,
local_time: str = "", local_time: str = "",
nyse_time: str = "", nyse_time: str = "",
@@ -80,6 +82,7 @@ class TerminalDashboard:
self.port = port self.port = port
self.poll_interval = poll_interval self.poll_interval = poll_interval
self.trigger_time = trigger_time self.trigger_time = trigger_time
self.mock = mock
self.enable_memory = enable_memory self.enable_memory = enable_memory
self.local_time = local_time self.local_time = local_time
self.nyse_time = nyse_time self.nyse_time = nyse_time
@@ -106,6 +109,8 @@ class TerminalDashboard:
# Mode line # Mode line
if self.mode == "backtest": if self.mode == "backtest":
mode_str = "[cyan]Backtest[/cyan]" mode_str = "[cyan]Backtest[/cyan]"
elif self.mock:
mode_str = "[yellow]MOCK[/yellow]"
else: else:
mode_str = "[green]LIVE[/green]" mode_str = "[green]LIVE[/green]"
@@ -211,6 +216,8 @@ class TerminalDashboard:
title = "[bold cyan]EvoTraders[/bold cyan]" title = "[bold cyan]EvoTraders[/bold cyan]"
if self.mode == "backtest": if self.mode == "backtest":
title += " [dim]Backtest[/dim]" title += " [dim]Backtest[/dim]"
elif self.mock:
title += " [dim]Mock[/dim]"
else: else:
title += " [dim]Live[/dim]" title += " [dim]Live[/dim]"

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
background: '#ffffff', background: '#ffffff',
borderBottom: '2px solid #000000', borderBottom: '2px solid #000000',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
zIndex: 800, zIndex: 1000,
animation: isClosing ? 'slideUp 0.2s ease-out forwards' : 'slideDown 0.25s ease-out' animation: isClosing ? 'slideUp 0.2s ease-out forwards' : 'slideDown 0.25s ease-out'
}}> }}>
{/* Horizontal scrollable content */} {/* Horizontal scrollable content */}

View File

@@ -35,22 +35,14 @@ const stripMarkdown = (text) => {
.replace(/^[-=]+$/gm, ''); .replace(/^[-=]+$/gm, '');
}; };
const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) => { const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
const feedContentRef = useRef(null); const feedContentRef = useRef(null);
const [highlightedId, setHighlightedId] = useState(null); const [highlightedId, setHighlightedId] = useState(null);
const [selectedAgent, setSelectedAgent] = useState('all'); const [selectedAgent, setSelectedAgent] = useState('all');
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const getAgentModelInfo = (agentId) => { const getAgentModelInfo = (agentId) => {
if (!agentId) return { modelName: null, modelProvider: null }; if (!leaderboard || !agentId) return { modelName: null, modelProvider: null };
const profile = agentProfilesByAgent?.[agentId];
if (profile?.model_name) {
return {
modelName: profile.model_name,
modelProvider: profile.model_provider
};
}
if (!leaderboard) return { modelName: null, modelProvider: null };
const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId); const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId);
return { return {
modelName: agentData?.modelName, modelName: agentData?.modelName,
@@ -60,17 +52,7 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
// Get agent info by name // Get agent info by name
const getAgentInfoByName = (agentName) => { const getAgentInfoByName = (agentName) => {
if (!agentName) return null; if (!leaderboard || !agentName) return null;
const agentConfig = AGENTS.find((agent) => agent.name === agentName);
const profile = agentConfig ? agentProfilesByAgent?.[agentConfig.id] : null;
if (agentConfig && profile?.model_name) {
return {
agentId: agentConfig.id,
modelName: profile.model_name,
modelProvider: profile.model_provider
};
}
if (!leaderboard) return null;
const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName); const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName);
if (!agentData) return null; if (!agentData) return null;
return { return {

View File

@@ -1,506 +0,0 @@
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>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function ChartTabs({
chartTab,
setChartTab,
isLiveEnabled
}) {
return (
<div className="chart-tabs-floating">
<button
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
onClick={() => setChartTab('all')}
>
日线
</button>
</div>
);
}

View File

@@ -0,0 +1,293 @@
import React from 'react';
import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx';
export default function HeaderRight({
// Connection state
isConnected,
// Virtual time
virtualTime,
now,
// Market & server
marketStatus,
marketStatusLabel,
serverMode,
// Labels
runtimeSummaryLabel,
livePriceSourceLabel,
historicalPriceSourceLabel,
// Settings state
isRuntimeSettingsOpen,
isRuntimeConfigSaving,
isWatchlistSaving,
runtimeConfigFeedback,
watchlistFeedback,
// Settings panel props
scheduleModeDraft,
intervalMinutesDraft,
triggerTimeDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
enableMockDraft,
watchlistDraftSymbols,
watchlistInputValue,
watchlistSuggestions,
// Callbacks
onRuntimeSettingsToggle,
onCloseSettings,
onScheduleModeChange,
onIntervalMinutesChange,
onTriggerTimeChange,
onMaxCommCyclesChange,
onInitialCashChange,
onMarginRequirementChange,
onEnableMemoryChange,
onModeChange,
onPollIntervalChange,
onStartDateChange,
onEndDateChange,
onEnableMockChange,
onWatchlistInputChange,
onWatchlistInputKeyDown,
onWatchlistAdd,
onWatchlistRemove,
onWatchlistRestoreCurrent,
onWatchlistRestoreDefault,
onWatchlistSuggestionClick,
onLaunchConfigSave,
onRestoreDefaults,
onManualTrigger,
clientRef
}) {
return (
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
{/* Mock Mode Indicator */}
{virtualTime && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: 4,
background: '#FF9800',
border: '1px solid #FFB74D'
}}>
<span style={{
fontSize: '11px',
fontWeight: 600,
color: '#FFFFFF',
fontFamily: '"Courier New", monospace',
letterSpacing: '0.5px'
}}>
模拟模式
</span>
</div>
)}
{/* Clock Display (only in Mock mode) */}
{virtualTime && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 2,
padding: '4px 12px',
borderRadius: 4,
background: '#1A237E',
border: '1px solid #3F51B5'
}}>
<span style={{
fontSize: '11px',
color: '#999',
fontFamily: '"Courier New", monospace',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
虚拟时间
</span>
<span style={{
fontSize: '14px',
fontWeight: 700,
color: '#FFFFFF',
fontFamily: '"Courier New", monospace',
letterSpacing: '1px'
}}>
{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}
</span>
<span style={{
fontSize: '10px',
color: '#999',
fontFamily: '"Courier New", monospace'
}}>
{now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
{/* Fast Forward Button (only in Mock mode) */}
<button
onClick={() => {
if (clientRef.current) {
const success = clientRef.current.send({
type: 'fast_forward_time',
minutes: 30
});
if (!success) {
console.error('Failed to send fast forward request');
}
}
}}
style={{
padding: '6px 12px',
borderRadius: 4,
background: '#3F51B5',
border: '1px solid #5C6BC0',
color: '#FFFFFF',
fontSize: '12px',
fontFamily: '"Courier New", monospace',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: 4,
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}
onMouseEnter={(e) => {
e.target.style.background = '#5C6BC0';
e.target.style.borderColor = '#7986CB';
}}
onMouseLeave={(e) => {
e.target.style.background = '#3F51B5';
e.target.style.borderColor = '#5C6BC0';
}}
title="快进30分钟 (Mock模式)"
>
+30min
</button>
</div>
)}
{/* Unified Status Indicator */}
<div className="header-status-inline">
<span className={`status-dot ${isConnected ? 'live' : 'offline'}`} />
<span className={`status-text ${isConnected ? 'live' : 'offline'}`}>
{isConnected ? '在线' : '离线'}
</span>
{marketStatus && (
<>
<span className="status-sep">·</span>
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
{marketStatusLabel}
</span>
</>
)}
{livePriceSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
{livePriceSourceLabel}
</span>
</>
)}
{historicalPriceSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
{historicalPriceSourceLabel}
</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' && (
<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>
)}
<RuntimeSettingsPanel
showTrigger={false}
isOpen={isRuntimeSettingsOpen}
isConnected={isConnected}
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
feedback={runtimeConfigFeedback || watchlistFeedback}
scheduleMode={scheduleModeDraft}
intervalMinutes={intervalMinutesDraft}
triggerTime={triggerTimeDraft}
maxCommCycles={maxCommCyclesDraft}
initialCash={initialCashDraft}
marginRequirement={marginRequirementDraft}
enableMemory={enableMemoryDraft}
mode={modeDraft}
pollInterval={pollIntervalDraft}
startDate={startDateDraft}
endDate={endDateDraft}
enableMock={enableMockDraft}
watchlistSymbols={watchlistDraftSymbols}
watchlistInputValue={watchlistInputValue}
watchlistSuggestions={watchlistSuggestions}
onToggle={onRuntimeSettingsToggle}
onClose={onCloseSettings}
onScheduleModeChange={onScheduleModeChange}
onIntervalMinutesChange={onIntervalMinutesChange}
onTriggerTimeChange={onTriggerTimeChange}
onMaxCommCyclesChange={onMaxCommCyclesChange}
onInitialCashChange={onInitialCashChange}
onMarginRequirementChange={onMarginRequirementChange}
onEnableMemoryChange={onEnableMemoryChange}
onModeChange={onModeChange}
onPollIntervalChange={onPollIntervalChange}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
onEnableMockChange={onEnableMockChange}
onWatchlistInputChange={onWatchlistInputChange}
onWatchlistInputKeyDown={onWatchlistInputKeyDown}
onWatchlistAdd={onWatchlistAdd}
onWatchlistRemove={onWatchlistRemove}
onWatchlistRestoreCurrent={onWatchlistRestoreCurrent}
onWatchlistRestoreDefault={onWatchlistRestoreDefault}
onWatchlistSuggestionClick={onWatchlistSuggestionClick}
onSave={onLaunchConfigSave}
onRestoreDefaults={onRestoreDefaults}
/>
</div>
);
}

View File

@@ -5,10 +5,10 @@ import { formatNumber, formatFullNumber } from '../utils/formatters';
/** /**
* Helper function to get the start time of the most recent trading session * Helper function to get the start time of the most recent trading session
* Trading session: 22:30 - next day 05:00 * Trading session: 22:30 - next day 05:00
* @param {Date|null} virtualTime - Virtual time from server, or null to use real time * @param {Date|null} virtualTime - Virtual time from server (for mock mode), or null to use real time
*/ */
function getRecentTradingSessionStart(virtualTime = null) { function getRecentTradingSessionStart(virtualTime = null) {
// Use virtual time if provided, otherwise use real time // Use virtual time if provided (for mock mode), otherwise use real time
let now; let now;
if (virtualTime) { if (virtualTime) {
// Ensure virtualTime is a valid Date object // Ensure virtualTime is a valid Date object

View File

@@ -47,7 +47,7 @@ function getRankMedal(rank) {
* Supports click and hover (1.5s) to show agent performance cards * Supports click and hover (1.5s) to show agent performance cards
* Supports replay mode - completely independent from live mode * Supports replay mode - completely independent from live mode
*/ */
export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfilesByAgent, feed, onJumpToMessage, onOpenLaunchConfig }) { export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJumpToMessage, onOpenLaunchConfig }) {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const containerRef = useRef(null); const containerRef = useRef(null);
@@ -162,14 +162,11 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile
const getAgentData = (agentId) => { const getAgentData = (agentId) => {
const agent = AGENTS.find(a => a.id === agentId); const agent = AGENTS.find(a => a.id === agentId);
if (!agent) return null; if (!agent) return null;
const profile = agentProfilesByAgent?.[agentId] || null;
// If no leaderboard data, return agent with default stats // If no leaderboard data, return agent with default stats
if (!leaderboard || !Array.isArray(leaderboard)) { if (!leaderboard || !Array.isArray(leaderboard)) {
return { return {
...agent, ...agent,
modelName: profile?.model_name || null,
modelProvider: profile?.model_provider || null,
bull: { n: 0, win: 0, unknown: 0 }, bull: { n: 0, win: 0, unknown: 0 },
bear: { n: 0, win: 0, unknown: 0 }, bear: { n: 0, win: 0, unknown: 0 },
winRate: null, winRate: null,
@@ -184,8 +181,6 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile
if (!leaderboardData) { if (!leaderboardData) {
return { return {
...agent, ...agent,
modelName: profile?.model_name || null,
modelProvider: profile?.model_provider || null,
bull: { n: 0, win: 0, unknown: 0 }, bull: { n: 0, win: 0, unknown: 0 },
bear: { n: 0, win: 0, unknown: 0 }, bear: { n: 0, win: 0, unknown: 0 },
winRate: null, winRate: null,
@@ -198,8 +193,6 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile
return { return {
...agent, ...agent,
...leaderboardData, ...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 avatar: agent.avatar // Always use the frontend's avatar URL
}; };
}; };

View File

@@ -1,190 +0,0 @@
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
);
}

View File

@@ -1,24 +1,12 @@
import React from 'react'; import React from 'react';
import { createPortal } from 'react-dom'; 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({ export default function RuntimeSettingsPanel({
showTrigger = true, showTrigger = true,
isOpen, isOpen,
isConnected, isConnected,
isSaving, isSaving,
feedback, feedback,
launchMode,
restoreRunId,
runtimeHistoryRuns,
scheduleMode, scheduleMode,
intervalMinutes, intervalMinutes,
triggerTime, triggerTime,
@@ -30,14 +18,13 @@ export default function RuntimeSettingsPanel({
pollInterval, pollInterval,
startDate, startDate,
endDate, endDate,
enableMock,
watchlistSymbols, watchlistSymbols,
watchlistInputValue, watchlistInputValue,
watchlistSuggestions, watchlistSuggestions,
onToggle, onToggle,
onClose, onClose,
onScheduleModeChange, onScheduleModeChange,
onLaunchModeChange,
onRestoreRunIdChange,
onIntervalMinutesChange, onIntervalMinutesChange,
onTriggerTimeChange, onTriggerTimeChange,
onMaxCommCyclesChange, onMaxCommCyclesChange,
@@ -48,6 +35,7 @@ export default function RuntimeSettingsPanel({
onPollIntervalChange, onPollIntervalChange,
onStartDateChange, onStartDateChange,
onEndDateChange, onEndDateChange,
onEnableMockChange,
onWatchlistInputChange, onWatchlistInputChange,
onWatchlistInputKeyDown, onWatchlistInputKeyDown,
onWatchlistAdd, onWatchlistAdd,
@@ -154,75 +142,6 @@ export default function RuntimeSettingsPanel({
display: 'grid', display: 'grid',
gap: 12 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={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>自选股</div>
<div style={{ <div style={{
@@ -353,18 +272,16 @@ export default function RuntimeSettingsPanel({
恢复默认 恢复默认
</button> </button>
</div> </div>
</div> </div>
)}
{launchMode === 'fresh' && ( <div style={{
<div style={{ border: '1px solid #E5EAF1',
border: '1px solid #E5EAF1', borderRadius: 12,
borderRadius: 12, background: '#FCFDFE',
background: '#FCFDFE', padding: 14,
padding: 14, display: 'grid',
display: 'grid', gap: 12
gap: 12 }}>
}}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>调度参数</div> <div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>调度参数</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<label style={{ display: 'grid', gap: 4 }}> <label style={{ display: 'grid', gap: 4 }}>
@@ -578,8 +495,22 @@ export default function RuntimeSettingsPanel({
}} }}
/> />
</label> </label>
</div>
)} <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 style={{ <div style={{
border: '1px solid #E5EAF1', border: '1px solid #E5EAF1',

View File

@@ -34,18 +34,6 @@ const EVENT_FILTER_OPTIONS = [
{ value: 'approval', label: '审批事件' } { 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) { function metricCard(label, value, accent, helper = null) {
return ( return (
<div className="stat-card"> <div className="stat-card">
@@ -734,9 +722,6 @@ export default function RuntimeView() {
{sectionTitle( {sectionTitle(
'近期事件', '近期事件',
<select <select
id="runtime-event-filter"
name="runtime_event_filter"
aria-label="筛选近期事件"
value={eventFilter} value={eventFilter}
onChange={(event) => setEventFilter(event.target.value)} onChange={(event) => setEventFilter(event.target.value)}
style={{ style={{
@@ -754,9 +739,6 @@ export default function RuntimeView() {
))} ))}
</select> </select>
)} )}
<label htmlFor="runtime-event-filter" style={SR_ONLY_STYLE}>
筛选近期事件
</label>
<div style={{ <div style={{
display: 'grid', display: 'grid',
gap: 8, gap: 8,

View File

@@ -8,36 +8,12 @@ import { formatNumber, formatDateTime } from '../utils/formatters';
* Left: Performance Overview (35%) | Right: Holdings + Trades (65%) * Left: Performance Overview (35%) | Right: Holdings + Trades (65%)
* No scrolling - content fits within viewport with pagination * No scrolling - content fits within viewport with pagination
*/ */
export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard, portfolioData }) { export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard }) {
const [holdingsPage, setHoldingsPage] = useState(1); const [holdingsPage, setHoldingsPage] = useState(1);
const [tradesPage, setTradesPage] = useState(1); const [tradesPage, setTradesPage] = useState(1);
const holdingsPerPage = 5; const holdingsPerPage = 5;
const tradesPerPage = 8; 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 // Calculate pagination for holdings
const totalHoldingsPages = Math.ceil(holdings.length / holdingsPerPage); const totalHoldingsPages = Math.ceil(holdings.length / holdingsPerPage);
const holdingsStartIndex = (holdingsPage - 1) * holdingsPerPage; const holdingsStartIndex = (holdingsPage - 1) * holdingsPerPage;
@@ -52,12 +28,12 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
// Calculate excess return (Evatraders return - benchmark value-weighted return) // Calculate excess return (Evatraders return - benchmark value-weighted return)
const calculateExcessReturn = () => { const calculateExcessReturn = () => {
if (!effectiveStats || !baseline_vw || baseline_vw.length === 0) { if (!stats || !baseline_vw || baseline_vw.length === 0) {
return null; return null;
} }
// Get Evatraders return from stats // Get Evatraders return from stats
const evatradersReturn = effectiveStats.totalReturn || 0; // Already in percentage const evatradersReturn = stats.totalReturn || 0; // Already in percentage
// Calculate benchmark return from baseline_vw // Calculate benchmark return from baseline_vw
// baseline_vw format: [{t: timestamp, v: value}, ...] or [value, ...] // baseline_vw format: [{t: timestamp, v: value}, ...] or [value, ...]
@@ -154,7 +130,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
borderRight: '2px solid #e0e0e0', borderRight: '2px solid #e0e0e0',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{effectiveStats ? ( {stats ? (
<div style={{ <div style={{
padding: '24px', padding: '24px',
display: 'flex', display: 'flex',
@@ -203,7 +179,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
fontFamily: '"Courier New", monospace', fontFamily: '"Courier New", monospace',
lineHeight: 1 lineHeight: 1
}}> }}>
${formatNumber(effectiveStats.totalAssetValue || 0)} ${formatNumber(stats.totalAssetValue || 0)}
</div> </div>
</div> </div>
@@ -296,10 +272,10 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
<div style={{ <div style={{
fontSize: 28, fontSize: 28,
fontWeight: 700, fontWeight: 700,
color: (effectiveStats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744', color: (stats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
fontFamily: '"Courier New", monospace' fontFamily: '"Courier New", monospace'
}}> }}>
{(effectiveStats.totalReturn || 0) >= 0 ? '+' : ''}{(effectiveStats.totalReturn || 0).toFixed(2)}% {(stats.totalReturn || 0) >= 0 ? '+' : ''}{(stats.totalReturn || 0).toFixed(2)}%
</div> </div>
</div> </div>
</div> </div>
@@ -328,7 +304,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
color: '#000000', color: '#000000',
fontFamily: '"Courier New", monospace' fontFamily: '"Courier New", monospace'
}}> }}>
${formatNumber(effectiveStats.cashPosition || 0)} ${formatNumber(stats.cashPosition || 0)}
</div> </div>
</div> </div>
@@ -354,13 +330,13 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
color: '#000000', color: '#000000',
fontFamily: '"Courier New", monospace' fontFamily: '"Courier New", monospace'
}}> }}>
{effectiveStats.totalTrades || 0} {stats.totalTrades || 0}
</div> </div>
</div> </div>
</div> </div>
{/* Ticker Weights - Compact */} {/* Ticker Weights - Compact */}
{effectiveStats?.tickerWeights && Object.keys(effectiveStats.tickerWeights).length > 0 && ( {stats.tickerWeights && Object.keys(stats.tickerWeights).length > 0 && (
<div style={{ <div style={{
marginTop: 'auto', marginTop: 'auto',
paddingTop: 20, paddingTop: 20,
@@ -382,7 +358,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
gap: 8, gap: 8,
maxHeight: 120 maxHeight: 120
}}> }}>
{Object.entries(effectiveStats.tickerWeights).map(([ticker, weight]) => { {Object.entries(stats.tickerWeights).map(([ticker, weight]) => {
const weightValue = Number(weight); const weightValue = Number(weight);
const isNegative = weightValue < 0; const isNegative = weightValue < 0;
const displayWeight = (weightValue * 100).toFixed(1); const displayWeight = (weightValue * 100).toFixed(1);

View File

@@ -33,9 +33,6 @@ export default function StockExplainView({
insiderTradesSnapshot, insiderTradesSnapshot,
technicalIndicatorsSnapshot, technicalIndicatorsSnapshot,
onRequestRangeExplain, onRequestRangeExplain,
onRequestHistory,
onRequestExplainEvents,
onRequestNews,
onRequestNewsForDate, onRequestNewsForDate,
onRequestStory, onRequestStory,
onRequestInsiderTrades, onRequestInsiderTrades,
@@ -145,37 +142,11 @@ export default function StockExplainView({
setActiveNewsSentiment('all'); setActiveNewsSentiment('all');
}, [selectedSymbol, selectedEventDate]); }, [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(() => { useEffect(() => {
if (!selectedSymbol || !selectedEventDate || !onRequestNewsForDate) { if (!selectedSymbol || !selectedEventDate || !onRequestNewsForDate) {
return; return;
} }
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.byDate || {}, selectedEventDate)) { if (Array.isArray(newsSnapshot?.byDate?.[selectedEventDate]) && newsSnapshot.byDate[selectedEventDate].length > 0) {
return; return;
} }
onRequestNewsForDate(selectedSymbol, selectedEventDate); onRequestNewsForDate(selectedSymbol, selectedEventDate);
@@ -185,21 +156,21 @@ export default function StockExplainView({
if (!selectedSymbol || !onRequestStory || !currentDate) { if (!selectedSymbol || !onRequestStory || !currentDate) {
return; return;
} }
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.storyCache || {}, currentDate)) { if (selectedStory?.story) {
return; return;
} }
onRequestStory(selectedSymbol, currentDate); onRequestStory(selectedSymbol, currentDate);
}, [currentDate, newsSnapshot, onRequestStory, selectedStory, selectedSymbol]); }, [currentDate, onRequestStory, selectedStory, selectedSymbol]);
useEffect(() => { useEffect(() => {
if (!selectedSymbol || !selectedEventDate || !onRequestSimilarDays) { if (!selectedSymbol || !selectedEventDate || !onRequestSimilarDays) {
return; return;
} }
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.similarDaysCache || {}, selectedEventDate)) { if (selectedSimilarDays?.items?.length) {
return; return;
} }
onRequestSimilarDays(selectedSymbol, selectedEventDate); onRequestSimilarDays(selectedSymbol, selectedEventDate);
}, [newsSnapshot, onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]); }, [onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
useEffect(() => { useEffect(() => {
if (!selectedSymbol || !onRequestTechnicalIndicators) { if (!selectedSymbol || !onRequestTechnicalIndicators) {

View File

@@ -0,0 +1,52 @@
import React from 'react';
import StockLogo from './StockLogo';
import { formatNumber, formatTickerPrice } from '../utils/formatters';
export default function TickerBar({
displayTickers,
rollingTickers,
portfolioData,
onTickerSelect
}) {
return (
<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"
onClick={() => onTickerSelect && onTickerSelect(ticker.symbol)}
style={{ cursor: onTickerSelect ? 'pointer' : 'default' }}
>
<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>
);
}

View File

@@ -38,18 +38,6 @@ export default function TraderView({
onWorkspaceFileSave, onWorkspaceFileSave,
onUploadExternalSkill 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 [expandedSkillKey, setExpandedSkillKey] = useState(null);
const [newLocalSkillName, setNewLocalSkillName] = useState(''); const [newLocalSkillName, setNewLocalSkillName] = useState('');
const [externalSkillFile, setExternalSkillFile] = useState(null); const [externalSkillFile, setExternalSkillFile] = useState(null);
@@ -472,9 +460,6 @@ export default function TraderView({
本地技能 SKILL.md 本地技能 SKILL.md
</div> </div>
<textarea <textarea
id={`local-skill-${selectedAgentId}-${skill.skill_name}`}
name={`local_skill_${selectedAgentId}_${skill.skill_name}`}
aria-label={`${skill.skill_name} 本地技能内容`}
value={skillDraft} value={skillDraft}
onChange={(e) => onLocalSkillDraftChange(skill.skill_name, e.target.value)} onChange={(e) => onLocalSkillDraftChange(skill.skill_name, e.target.value)}
style={{ style={{
@@ -572,9 +557,6 @@ export default function TraderView({
</div> </div>
<textarea <textarea
id={`workspace-editor-${selectedAgentId}-${selectedWorkspaceFile || 'file'}`}
name={`workspace_editor_${selectedAgentId}_${selectedWorkspaceFile || 'file'}`}
aria-label={`编辑 ${selectedWorkspaceFile || '工作区文件'} 内容`}
value={workspaceDraftContent} value={workspaceDraftContent}
onChange={(e) => onWorkspaceDraftChange(e.target.value)} onChange={(e) => onWorkspaceDraftChange(e.target.value)}
placeholder={isWorkspaceFileLoading ? '加载中...' : '输入 markdown 内容'} placeholder={isWorkspaceFileLoading ? '加载中...' : '输入 markdown 内容'}
@@ -705,13 +687,7 @@ export default function TraderView({
}}> }}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>创建本地技能</div> <div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>创建本地技能</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label htmlFor="new-local-skill-name" style={srOnlyStyle}>
输入本地技能名称
</label>
<input <input
id="new-local-skill-name"
name="new_local_skill_name"
aria-label="输入本地技能名称"
value={newLocalSkillName} value={newLocalSkillName}
onChange={(e) => setNewLocalSkillName(e.target.value)} onChange={(e) => setNewLocalSkillName(e.target.value)}
placeholder="输入技能名,例如 event_playbook" placeholder="输入技能名,例如 event_playbook"
@@ -765,13 +741,7 @@ export default function TraderView({
支持上传 .zip包内需包含一个技能目录及 SKILL.md 支持上传 .zip包内需包含一个技能目录及 SKILL.md
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<label htmlFor="external-skill-zip" style={srOnlyStyle}>
上传外部技能 zip
</label>
<input <input
id="external-skill-zip"
name="external_skill_zip"
aria-label="上传外部技能 zip 包"
type="file" type="file"
accept=".zip,application/zip" accept=".zip,application/zip"
onChange={async (e) => { onChange={async (e) => {

View File

@@ -19,18 +19,6 @@ export default function WatchlistPanel({
onSuggestionClick, onSuggestionClick,
onSave 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 ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
<button <button
@@ -129,13 +117,7 @@ export default function WatchlistPanel({
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<label htmlFor="watchlist-symbol-input" style={srOnlyStyle}>
输入股票代码
</label>
<input <input
id="watchlist-symbol-input"
name="watchlist_symbol"
aria-label="输入股票代码"
value={inputValue} value={inputValue}
onChange={(e) => onInputChange(e.target.value)} onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onInputKeyDown} onKeyDown={onInputKeyDown}

View File

@@ -11,37 +11,6 @@ export default function ExplainPriceSection({
isOpen, isOpen,
onToggle, 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 ( return (
<div className="section"> <div className="section">
<div className="section-header"> <div className="section-header">
@@ -97,35 +66,12 @@ export default function ExplainPriceSection({
strokeWidth="1" 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) => { {chartModel.candles.length > 1 ? chartModel.candles.map((candle) => {
const rising = candle.close >= candle.open; const rising = candle.close >= candle.open;
const stroke = rising ? '#00C853' : '#FF1744'; const stroke = rising ? '#00C853' : '#FF1744';
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)'; const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
return ( return (
<g key={candle.id}> <g key={candle.id}>
<title>{`${candle.startLabel || candle.time || candle.date || ''}${candle.endLabel || candle.time || candle.date || ''}`}</title>
<line <line
x1={candle.centerX} x1={candle.centerX}
y1={candle.highY} y1={candle.highY}
@@ -177,7 +123,7 @@ export default function ExplainPriceSection({
stroke={marker.isSelected ? '#111111' : '#ffffff'} stroke={marker.isSelected ? '#111111' : '#ffffff'}
strokeWidth={marker.isSelected ? '2.5' : '2'} strokeWidth={marker.isSelected ? '2.5' : '2'}
/> />
<title>{`${marker.title} · ${marker.timestamp || marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title> <title>{`${marker.title} · ${marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
</g> </g>
); );
})} })}

View File

@@ -0,0 +1,308 @@
import { useCallback, useEffect } from 'react';
import { uploadAgentSkillZip } from '../services/runtimeApi';
/**
* Extracts agent/skill-related callbacks from App.jsx into a single hook.
*/
export function useAgentCallbacks({
clientRef,
selectedSkillAgentId,
selectedWorkspaceFile,
workspaceDraftContent,
agentProfilesByAgent,
agentSkillsByAgent,
workspaceFilesByAgent,
AGENTS,
setters
}) {
const {
setIsAgentSkillsLoading,
setAgentSkillsFeedback,
setSkillDetailLoadingKey,
setAgentSkillsSavingKey,
setIsWorkspaceFileLoading,
setWorkspaceFileSavingKey,
setWorkspaceFileFeedback,
setLocalSkillDraftsByKey,
setAgentSkillsByAgent,
setAgentProfilesByAgent,
setSkillDetailsByName,
setWorkspaceFilesByAgent,
setSelectedSkillAgentId,
setSelectedWorkspaceFile,
setWorkspaceDraftContent
} = setters;
const requestAgentSkills = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
setIsAgentSkillsLoading(true);
setAgentSkillsFeedback(null);
return clientRef.current.send({
type: 'get_agent_skills',
agent_id: normalized
});
}, [clientRef, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
const requestAgentProfile = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_agent_profile',
agent_id: normalized
});
}, [clientRef]);
const requestSkillDetail = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
const detailKey = `${selectedSkillAgentId}:${normalized}`;
setSkillDetailLoadingKey(detailKey);
return clientRef.current.send({
type: 'get_skill_detail',
agent_id: selectedSkillAgentId,
skill_name: normalized
});
}, [clientRef, selectedSkillAgentId, setSkillDetailLoadingKey]);
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 clientRef.current.send({
type: 'get_agent_workspace_file',
agent_id: normalizedAgentId,
filename: normalizedFilename
});
}, [clientRef, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
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 = clientRef.current.send({
type: 'create_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: normalized
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
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 = setters.localSkillDraftsByKey[detailKey];
if (typeof content !== 'string') {
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
setAgentSkillsFeedback(null);
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, selectedSkillAgentId, setters.localSkillDraftsByKey, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
const handleLocalSkillDelete = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
setAgentSkillsFeedback(null);
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, selectedSkillAgentId, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
const handleRemoveSharedSkill = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'remove_agent_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName,
enabled
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
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 = clientRef.current.send({
type: 'update_agent_workspace_file',
agent_id: selectedSkillAgentId,
filename: selectedWorkspaceFile,
content: workspaceDraftContent
});
if (!success) {
setWorkspaceFileSavingKey(null);
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent, setWorkspaceFileSavingKey, setWorkspaceFileFeedback]);
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);
}
}, [selectedSkillAgentId, requestAgentSkills, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
// Sync workspace draft content when selected content changes
useEffect(() => {
const selectedWorkspaceContent = workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile] || '';
setWorkspaceDraftContent(selectedWorkspaceContent);
}, [selectedWorkspaceFile, selectedSkillAgentId, workspaceFilesByAgent, setWorkspaceDraftContent]);
// Load agent profiles and skills when view changes
const currentView = setters.currentView;
const isConnected = setters.isConnected;
useEffect(() => {
if (currentView !== 'traders' || !isConnected) {
return;
}
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');
}
});
}, [agentProfilesByAgent, agentSkillsByAgent, currentView, isConnected, requestAgentProfile, requestAgentSkills, requestWorkspaceFile, workspaceFilesByAgent, AGENTS]);
return {
requestAgentSkills,
requestAgentProfile,
requestSkillDetail,
requestWorkspaceFile,
handleCreateLocalSkill,
handleLocalSkillDraftChange,
handleLocalSkillSave,
handleLocalSkillDelete,
handleRemoveSharedSkill,
handleAgentSkillToggle,
handleSkillAgentChange,
handleWorkspaceFileChange,
handleWorkspaceFileSave,
handleUploadExternalSkill
};
}

View File

@@ -1,388 +0,0 @@
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
};
}

View File

@@ -1,385 +0,0 @@
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
};
}

View File

@@ -0,0 +1,257 @@
import { useCallback } from 'react';
import { startRuntime } from '../services/runtimeApi';
/**
* Extracts runtime config callbacks from App.jsx into a single hook.
*/
export function useRuntimeCallbacks({
clientRef,
addSystemMessage,
parseWatchlistInput,
setters
}) {
const {
setScheduleModeDraft,
setIntervalMinutesDraft,
setTriggerTimeDraft,
setMaxCommCyclesDraft,
setInitialCashDraft,
setMarginRequirementDraft,
setEnableMemoryDraft,
setModeDraft,
setPollIntervalDraft,
setStartDateDraft,
setEndDateDraft,
setEnableMockDraft,
setRuntimeConfigFeedback,
setIsRuntimeConfigSaving,
setIsWatchlistSaving,
setIsRuntimeSettingsOpen,
watchlistDraftSymbols,
watchlistInputValue,
scheduleModeDraft,
intervalMinutesDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
enableMockDraft
} = setters;
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,
intervalMinutesDraft,
maxCommCyclesDraft,
scheduleModeDraft,
triggerTimeDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
setIsRuntimeConfigSaving,
setRuntimeConfigFeedback
]);
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;
}
setIsRuntimeConfigSaving(true);
setIsWatchlistSaving(true);
setRuntimeConfigFeedback(null);
setters.setWatchlistFeedback(null);
setters.setWatchlistDraftSymbols(nextTickers);
setters.setWatchlistInputValue('');
try {
const result = await startRuntime({
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 || 'live',
poll_interval: Number(pollIntervalDraft) || 10,
start_date: startDateDraft || null,
end_date: endDateDraft || null,
enable_mock: Boolean(enableMockDraft)
});
setIsRuntimeConfigSaving(false);
setIsWatchlistSaving(false);
setIsRuntimeSettingsOpen(false);
setRuntimeConfigFeedback({
type: 'success',
text: `任务已启动: ${result.run_id}`
});
addSystemMessage(`新任务已启动: ${result.run_id}`);
} catch (error) {
setIsRuntimeConfigSaving(false);
setIsWatchlistSaving(false);
setRuntimeConfigFeedback({
type: 'error',
text: `启动失败: ${error.message}`
});
}
}, [
parseWatchlistInput,
watchlistInputValue,
watchlistDraftSymbols,
intervalMinutesDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
scheduleModeDraft,
triggerTimeDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
enableMockDraft,
setters,
setIsRuntimeConfigSaving,
setIsWatchlistSaving,
setRuntimeConfigFeedback,
setIsRuntimeSettingsOpen,
addSystemMessage
]);
const handleRuntimeDefaultsRestore = useCallback(() => {
setScheduleModeDraft('daily');
setIntervalMinutesDraft('60');
setTriggerTimeDraft('09:30');
setMaxCommCyclesDraft('2');
setInitialCashDraft('100000');
setMarginRequirementDraft('0');
setEnableMemoryDraft(false);
setModeDraft('live');
setPollIntervalDraft('10');
setStartDateDraft('');
setEndDateDraft('');
setEnableMockDraft(false);
setRuntimeConfigFeedback(null);
}, [
setScheduleModeDraft,
setIntervalMinutesDraft,
setTriggerTimeDraft,
setMaxCommCyclesDraft,
setInitialCashDraft,
setMarginRequirementDraft,
setEnableMemoryDraft,
setModeDraft,
setPollIntervalDraft,
setStartDateDraft,
setEndDateDraft,
setEnableMockDraft,
setRuntimeConfigFeedback
]);
const handleRuntimeSettingsToggle = useCallback(() => {
setRuntimeConfigFeedback(null);
setters.setAgentSkillsFeedback(null);
setters.setWorkspaceFileFeedback(null);
setIsRuntimeSettingsOpen((prev) => {
const nextOpen = !prev;
if (nextOpen) {
// Initialize watchlist draft when opening settings
setters.setWatchlistDraftSymbols(settlers.runtimeWatchlistSymbols);
setters.setWatchlistInputValue('');
setters.setWatchlistFeedback(null);
}
return nextOpen;
});
setters.setIsWatchlistPanelOpen(false);
}, [setRuntimeConfigFeedback, setters, setIsRuntimeSettingsOpen]);
const handleManualTrigger = useCallback(() => {
if (!clientRef.current) {
addSystemMessage('连接未就绪,无法手动触发');
return;
}
const success = clientRef.current.send({
type: 'trigger_strategy'
});
if (!success) {
addSystemMessage('手动触发发送失败,请检查连接状态');
return;
}
addSystemMessage('已发送手动触发请求');
}, [clientRef, addSystemMessage]);
return {
handleRuntimeConfigSave,
handleLaunchConfigSave,
handleRuntimeDefaultsRestore,
handleRuntimeSettingsToggle,
handleManualTrigger
};
}

View File

@@ -1,581 +0,0 @@
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
};
}

View File

@@ -1,352 +0,0 @@
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
};
}

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect } from "react"; import { useCallback } from 'react';
import { import {
fetchNewsCategoriesDirect, fetchNewsCategoriesDirect,
fetchNewsForDateDirect, fetchNewsForDateDirect,
@@ -7,27 +6,110 @@ import {
fetchSimilarDaysDirect, fetchSimilarDaysDirect,
fetchStockStoryDirect, fetchStockStoryDirect,
hasDirectNewsService hasDirectNewsService
} from "../services/newsApi"; } from '../services/newsApi';
import { import {
fetchInsiderTradesDirect, fetchInsiderTradesDirect,
fetchStockHistoryDirect, fetchStockHistoryDirect,
hasDirectTradingService hasDirectTradingService
} from "../services/tradingApi"; } from '../services/tradingApi';
export function useStockExplainData({ /**
* Extracts all requestStock* callbacks from App.jsx into a single hook.
*/
export function useStockRequestCallbacks({
clientRef, clientRef,
currentDate, currentDate,
currentView,
selectedExplainSymbol,
requestedStockHistoryRef, requestedStockHistoryRef,
setOhlcHistoryByTicker, setters,
setPriceHistoryByTicker, apiHelpers
setHistorySourceByTicker,
setNewsByTicker,
setInsiderTradesByTicker
}) { }) {
const {
setOhlcHistoryByTicker,
setHistorySourceByTicker,
setExplainEventsByTicker,
setNewsByTicker,
setInsiderTradesByTicker,
setTechnicalIndicatorsByTicker,
setPriceHistoryByTicker
} = setters;
const {
hasDirectTradingService: _hasDirectTradingService,
fetchStockHistoryDirect: _fetchStockHistoryDirect,
hasDirectNewsService: _hasDirectNewsService,
fetchNewsForDateDirect: _fetchNewsForDateDirect,
fetchNewsCategoriesDirect: _fetchNewsCategoriesDirect,
fetchInsiderTradesDirect: _fetchInsiderTradesDirect,
fetchRangeExplainDirect: _fetchRangeExplainDirect,
fetchStockStoryDirect: _fetchStockStoryDirect,
fetchSimilarDaysDirect: _fetchSimilarDaysDirect
} = apiHelpers;
const buildTickersFromSymbols = useCallback((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
};
});
}, []);
const normalizePriceHistory = useCallback((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;
}, []);
const requestStockHistory = useCallback((symbol, { force = false } = {}) => { const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) { if (!normalized) {
return false; return false;
} }
@@ -44,11 +126,14 @@ export function useStockExplainData({
start.setDate(start.getDate() - 120); start.setDate(start.getDate() - 120);
const startDate = start.toISOString().slice(0, 10); const startDate = start.toISOString().slice(0, 10);
if (hasDirectTradingService()) { if (_hasDirectTradingService()) {
void fetchStockHistoryDirect(normalized, startDate, endDate) void _fetchStockHistoryDirect(normalized, startDate, endDate)
.then((payload) => { .then((payload) => {
const prices = Array.isArray(payload?.prices) ? payload.prices : []; const prices = Array.isArray(payload?.prices) ? payload.prices : [];
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices })); setOhlcHistoryByTicker((prev) => ({
...prev,
[normalized]: prices
}));
setPriceHistoryByTicker((prev) => ({ setPriceHistoryByTicker((prev) => ({
...prev, ...prev,
[normalized]: prices [normalized]: prices
@@ -66,13 +151,16 @@ export function useStockExplainData({
}) })
.filter(Boolean) .filter(Boolean)
})); }));
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: "trading_service" })); setHistorySourceByTicker((prev) => ({
...prev,
[normalized]: 'trading_service'
}));
}) })
.catch((error) => { .catch((error) => {
console.error("Direct stock-history fetch failed, falling back to websocket:", error); console.error('Direct stock-history fetch failed, falling back to websocket:', error);
if (clientRef.current) { if (clientRef.current) {
const success = clientRef.current.send({ const success = clientRef.current.send({
type: "get_stock_history", type: 'get_stock_history',
ticker: normalized, ticker: normalized,
lookback_days: 120 lookback_days: 120
}); });
@@ -90,7 +178,7 @@ export function useStockExplainData({
} }
const success = clientRef.current.send({ const success = clientRef.current.send({
type: "get_stock_history", type: 'get_stock_history',
ticker: normalized, ticker: normalized,
lookback_days: 120 lookback_days: 120
}); });
@@ -100,33 +188,26 @@ export function useStockExplainData({
} }
return success; return success;
}, [ }, [currentDate, _hasDirectTradingService, _fetchStockHistoryDirect, clientRef, requestedStockHistoryRef, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]);
clientRef,
currentDate,
requestedStockHistoryRef,
setHistorySourceByTicker,
setOhlcHistoryByTicker,
setPriceHistoryByTicker
]);
const requestStockExplainEvents = useCallback((symbol) => { const requestStockExplainEvents = useCallback((symbol) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) { if (!normalized || !clientRef.current) {
return false; return false;
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_explain_events", type: 'get_stock_explain_events',
ticker: normalized ticker: normalized
}); });
}, [clientRef]); }, [clientRef]);
const requestStockNews = useCallback((symbol) => { const requestStockNews = useCallback((symbol) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) { if (!normalized || !clientRef.current) {
return false; return false;
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_news", type: 'get_stock_news',
ticker: normalized, ticker: normalized,
lookback_days: 45, lookback_days: 45,
limit: 12 limit: 12
@@ -134,15 +215,15 @@ export function useStockExplainData({
}, [clientRef]); }, [clientRef]);
const requestStockNewsForDate = useCallback((symbol, date) => { const requestStockNewsForDate = useCallback((symbol, date) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !date) { if (!normalized || !date) {
return false; return false;
} }
if (hasDirectNewsService()) { if (_hasDirectNewsService()) {
void fetchNewsForDateDirect(normalized, date, 20) void _fetchNewsForDateDirect(normalized, date, 20)
.then((payload) => { .then((payload) => {
const targetDate = typeof payload?.date === "string" ? payload.date.trim() : date; const targetDate = typeof payload?.date === 'string' ? payload.date.trim() : date;
const news = Array.isArray(payload?.news) ? payload.news : []; const news = Array.isArray(payload?.news) ? payload.news : [];
const freshness = payload?.freshness || null; const freshness = payload?.freshness || null;
setNewsByTicker((prev) => ({ setNewsByTicker((prev) => ({
@@ -161,10 +242,10 @@ export function useStockExplainData({
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error("Direct news-for-date fetch failed, falling back to websocket:", error); console.error('Direct news-for-date fetch failed, falling back to websocket:', error);
if (clientRef.current) { if (clientRef.current) {
clientRef.current.send({ clientRef.current.send({
type: "get_stock_news_for_date", type: 'get_stock_news_for_date',
ticker: normalized, ticker: normalized,
date, date,
limit: 20 limit: 20
@@ -179,27 +260,27 @@ export function useStockExplainData({
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_news_for_date", type: 'get_stock_news_for_date',
ticker: normalized, ticker: normalized,
date, date,
limit: 20 limit: 20
}); });
}, [clientRef, setNewsByTicker]); }, [clientRef, _hasDirectNewsService, _fetchNewsForDateDirect, setNewsByTicker]);
const requestStockNewsTimeline = useCallback((symbol) => { const requestStockNewsTimeline = useCallback((symbol) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) { if (!normalized || !clientRef.current) {
return false; return false;
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_news_timeline", type: 'get_stock_news_timeline',
ticker: normalized, ticker: normalized,
lookback_days: 90 lookback_days: 90
}); });
}, [clientRef]); }, [clientRef]);
const requestStockNewsCategories = useCallback((symbol) => { const requestStockNewsCategories = useCallback((symbol) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) { if (!normalized) {
return false; return false;
} }
@@ -212,8 +293,8 @@ export function useStockExplainData({
start.setDate(start.getDate() - 90); start.setDate(start.getDate() - 90);
const startDate = start.toISOString().slice(0, 10); const startDate = start.toISOString().slice(0, 10);
if (hasDirectNewsService()) { if (_hasDirectNewsService()) {
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200) void _fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
.then((payload) => { .then((payload) => {
const freshness = payload?.freshness || null; const freshness = payload?.freshness || null;
setNewsByTicker((prev) => ({ setNewsByTicker((prev) => ({
@@ -228,10 +309,10 @@ export function useStockExplainData({
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error("Direct news-categories fetch failed, falling back to websocket:", error); console.error('Direct news-categories fetch failed, falling back to websocket:', error);
if (clientRef.current) { if (clientRef.current) {
clientRef.current.send({ clientRef.current.send({
type: "get_stock_news_categories", type: 'get_stock_news_categories',
ticker: normalized, ticker: normalized,
lookback_days: 90 lookback_days: 90
}); });
@@ -245,20 +326,20 @@ export function useStockExplainData({
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_news_categories", type: 'get_stock_news_categories',
ticker: normalized, ticker: normalized,
lookback_days: 90 lookback_days: 90
}); });
}, [clientRef, currentDate, setNewsByTicker]); }, [currentDate, clientRef, _hasDirectNewsService, _fetchNewsCategoriesDirect, setNewsByTicker]);
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => { const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) { if (!normalized) {
return false; return false;
} }
if (hasDirectTradingService()) { if (_hasDirectTradingService()) {
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50) void _fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
.then((payload) => { .then((payload) => {
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : []; const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
setInsiderTradesByTicker((prev) => ({ setInsiderTradesByTicker((prev) => ({
@@ -272,10 +353,10 @@ export function useStockExplainData({
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error("Direct insider-trades fetch failed, falling back to websocket:", error); console.error('Direct insider-trades fetch failed, falling back to websocket:', error);
if (clientRef.current) { if (clientRef.current) {
clientRef.current.send({ clientRef.current.send({
type: "get_stock_insider_trades", type: 'get_stock_insider_trades',
ticker: normalized, ticker: normalized,
start_date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
@@ -291,35 +372,35 @@ export function useStockExplainData({
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_insider_trades", type: 'get_stock_insider_trades',
ticker: normalized, ticker: normalized,
start_date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
limit: 50 limit: 50
}); });
}, [clientRef, setInsiderTradesByTicker]); }, [clientRef, _hasDirectTradingService, _fetchInsiderTradesDirect, setInsiderTradesByTicker]);
const requestStockTechnicalIndicators = useCallback((symbol) => { const requestStockTechnicalIndicators = useCallback((symbol) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) { if (!normalized || !clientRef.current) {
return false; return false;
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_technical_indicators", type: 'get_stock_technical_indicators',
ticker: normalized ticker: normalized
}); });
}, [clientRef]); }, [clientRef]);
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => { const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !startDate || !endDate) { if (!normalized || !startDate || !endDate) {
return false; return false;
} }
if (hasDirectNewsService()) { if (_hasDirectNewsService()) {
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds) void _fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
.then((payload) => { .then((payload) => {
const result = payload?.result && typeof payload.result === "object" ? payload.result : null; const result = payload?.result && typeof payload.result === 'object' ? payload.result : null;
const freshness = payload?.freshness || null; const freshness = payload?.freshness || null;
if (!result?.start_date || !result?.end_date) { if (!result?.start_date || !result?.end_date) {
return; return;
@@ -340,10 +421,10 @@ export function useStockExplainData({
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error("Direct range explain fetch failed, falling back to websocket:", error); console.error('Direct range explain fetch failed, falling back to websocket:', error);
if (clientRef.current) { if (clientRef.current) {
clientRef.current.send({ clientRef.current.send({
type: "get_stock_range_explain", type: 'get_stock_range_explain',
ticker: normalized, ticker: normalized,
start_date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
@@ -359,51 +440,47 @@ export function useStockExplainData({
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_range_explain", type: 'get_stock_range_explain',
ticker: normalized, ticker: normalized,
start_date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
article_ids: Array.isArray(articleIds) ? articleIds : [] article_ids: Array.isArray(articleIds) ? articleIds : []
}); });
}, [clientRef, setNewsByTicker]); }, [clientRef, _hasDirectNewsService, _fetchRangeExplainDirect, setNewsByTicker]);
const requestStockStory = useCallback((symbol, asOfDate = null) => { const requestStockStory = useCallback((symbol, asOfDate) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) { const date = typeof asOfDate === 'string' ? asOfDate.trim() : '';
if (!normalized || !date) {
return false; return false;
} }
if (hasDirectNewsService()) { if (_hasDirectNewsService()) {
void fetchStockStoryDirect(normalized, asOfDate) void _fetchStockStoryDirect(normalized, date)
.then((payload) => { .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) => ({ setNewsByTicker((prev) => ({
...prev, ...prev,
[normalized]: { [normalized]: {
...(prev[normalized] || {}), ...(prev[normalized] || {}),
storyCache: { storyCache: {
...((prev[normalized] && prev[normalized].storyCache) || {}), ...((prev[normalized] && prev[normalized].storyCache) || {}),
[storyDate]: { [date]: {
story: payload.story || "", story: payload?.story || '',
source: payload.source || "news_service", source: payload?.source || null,
asOfDate: storyDate, asOfDate: date,
freshness freshness: payload?.freshness || null
} }
} }
} }
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error("Direct story fetch failed, falling back to websocket:", error); console.error('Direct story fetch failed, falling back to websocket:', error);
if (clientRef.current) { if (clientRef.current) {
clientRef.current.send({ clientRef.current.send({
type: "get_stock_story", type: 'get_stock_story',
ticker: normalized, ticker: normalized,
as_of_date: asOfDate as_of_date: date
}); });
} }
}); });
@@ -415,44 +492,46 @@ export function useStockExplainData({
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_story", type: 'get_stock_story',
ticker: normalized, ticker: normalized,
as_of_date: asOfDate as_of_date: date
}); });
}, [clientRef, setNewsByTicker]); }, [clientRef, _hasDirectNewsService, _fetchStockStoryDirect, setNewsByTicker]);
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => { const requestStockSimilarDays = useCallback((symbol, targetDate, lookbackDays = 365) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
const date = typeof targetDate === 'string' ? targetDate.trim() : '';
if (!normalized || !date) { if (!normalized || !date) {
return false; return false;
} }
if (hasDirectNewsService()) { if (_hasDirectNewsService()) {
void fetchSimilarDaysDirect(normalized, date, topK) void _fetchSimilarDaysDirect(normalized, date, lookbackDays)
.then((payload) => { .then((payload) => {
const targetDate = typeof payload?.target_date === "string" ? payload.target_date.trim() : date;
if (!targetDate) {
return;
}
setNewsByTicker((prev) => ({ setNewsByTicker((prev) => ({
...prev, ...prev,
[normalized]: { [normalized]: {
...(prev[normalized] || {}), ...(prev[normalized] || {}),
similarDaysCache: { similarDaysCache: {
...((prev[normalized] && prev[normalized].similarDaysCache) || {}), ...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
[targetDate]: payload [date]: {
target_features: payload?.target_features || {},
items: Array.isArray(payload?.items) ? payload?.items : [],
error: payload?.error || null,
freshness: payload?.freshness || null
}
} }
} }
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error("Direct similar-days fetch failed, falling back to websocket:", error); console.error('Direct similar-days fetch failed, falling back to websocket:', error);
if (clientRef.current) { if (clientRef.current) {
clientRef.current.send({ clientRef.current.send({
type: "get_stock_similar_days", type: 'get_stock_similar_days',
ticker: normalized, ticker: normalized,
date, target_date: date,
top_k: topK lookback_days: lookbackDays
}); });
} }
}); });
@@ -464,72 +543,31 @@ export function useStockExplainData({
} }
return clientRef.current.send({ return clientRef.current.send({
type: "get_stock_similar_days", type: 'get_stock_similar_days',
ticker: normalized, ticker: normalized,
date, target_date: date,
top_k: topK lookback_days: lookbackDays
}); });
}, [clientRef, setNewsByTicker]); }, [clientRef, _hasDirectNewsService, _fetchSimilarDaysDirect, setNewsByTicker]);
const requestStockEnrich = useCallback((symbol, options = {}) => { const requestStockEnrich = useCallback((symbol, startDate, endDate, { force = false, onlyLocalToLlm = false } = {}) => {
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : ""; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) { if (!normalized || !clientRef.current) {
return false; 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({ return clientRef.current.send({
type: "run_stock_enrich", type: 'enrich_stock_news',
ticker: normalized, ticker: normalized,
start_date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
force: Boolean(options.force), force: Boolean(force),
only_local_to_llm: Boolean(options.onlyLocalToLlm), only_local_to_llm: Boolean(onlyLocalToLlm)
rebuild_story: Boolean(options.rebuildStory),
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
story_date: options.storyDate || null,
target_date: options.targetDate || null
}); });
}, [clientRef, setNewsByTicker]); }, [clientRef]);
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 { return {
buildTickersFromSymbols,
normalizePriceHistory,
requestStockHistory, requestStockHistory,
requestStockExplainEvents, requestStockExplainEvents,
requestStockNews, requestStockNews,

View File

@@ -0,0 +1,144 @@
import { useCallback, useMemo } from 'react';
import { INITIAL_TICKERS } from '../config/constants';
/**
* Extracts watchlist-related callbacks from App.jsx into a single hook.
*/
export function useWatchlistCallbacks({
clientRef,
runtimeWatchlistSymbols,
watchlistDraftSymbols,
watchlistInputValue,
watchlistFeedback,
setters
}) {
const {
setWatchlistDraftSymbols,
setWatchlistInputValue,
setWatchlistFeedback
} = setters;
const parseWatchlistInput = useCallback((value) => {
if (typeof value !== 'string') {
return [];
}
return Array.from(
new Set(
value
.split(/[\s,]+/)
.map((symbol) => symbol.trim().toUpperCase())
.filter(Boolean)
)
);
}, []);
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;
}, [parseWatchlistInput, watchlistFeedback, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, setters]);
const handleWatchlistRemove = useCallback((symbolToRemove) => {
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback, setWatchlistDraftSymbols, setWatchlistFeedback]);
const handleWatchlistInputChange = useCallback((value) => {
setWatchlistInputValue(value);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback, setWatchlistInputValue, setWatchlistFeedback]);
const handleWatchlistInputKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitWatchlistInput(watchlistInputValue);
}
}, [commitWatchlistInput, watchlistInputValue]);
const handleWatchlistSuggestionClick = useCallback((symbol) => {
if (watchlistDraftSymbols.includes(symbol)) {
return;
}
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistDraftSymbols, watchlistFeedback, setWatchlistDraftSymbols, setWatchlistFeedback]);
const handleWatchlistRestoreCurrent = useCallback(() => {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}, [runtimeWatchlistSymbols, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback]);
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;
}
setters.setIsWatchlistSaving(true);
setWatchlistFeedback(null);
setWatchlistDraftSymbols(nextTickers);
setWatchlistInputValue('');
const success = clientRef.current.send({
type: 'update_watchlist',
tickers: nextTickers
});
if (!success) {
setters.setIsWatchlistSaving(false);
setWatchlistFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [parseWatchlistInput, watchlistDraftSymbols, watchlistInputValue, clientRef, setters.setIsWatchlistSaving, setWatchlistFeedback, setWatchlistDraftSymbols, setWatchlistInputValue]);
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]);
return {
parseWatchlistInput,
commitWatchlistInput,
handleWatchlistRemove,
handleWatchlistInputChange,
handleWatchlistInputKeyDown,
handleWatchlistSuggestionClick,
handleWatchlistRestoreCurrent,
handleWatchlistSave,
watchlistSuggestions,
isWatchlistDraftDirty
};
}

View File

@@ -1,861 +0,0 @@
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 };
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
/**
* 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;

View File

@@ -38,10 +38,6 @@ export function fetchRuntimeEvents() {
return safeFetch(RUNTIME_API_BASE, '/events'); return safeFetch(RUNTIME_API_BASE, '/events');
} }
export function fetchRuntimeHistory(limit = 20) {
return safeFetch(RUNTIME_API_BASE, `/history?limit=${limit}`);
}
export function fetchPendingApprovals() { export function fetchPendingApprovals() {
return safeFetch(CONTROL_API_BASE, '/guard/pending'); return safeFetch(CONTROL_API_BASE, '/guard/pending');
} }
@@ -125,73 +121,6 @@ export function fetchCurrentRuntime() {
return safeFetch(RUNTIME_API_BASE, '/current'); 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({ export async function uploadAgentSkillZip({
agentId, agentId,
file, file,

View File

@@ -1,81 +0,0 @@
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}`;
};

View File

@@ -1,59 +0,0 @@
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 轮");
});
});

View File

@@ -24,13 +24,13 @@ export async function fetchGatewayPort() {
if (data.is_running && data.port) { if (data.is_running && data.port) {
cachedGatewayPort = data.port; cachedGatewayPort = data.port;
cachedWsUrl = data.ws_url; cachedWsUrl = data.ws_url;
return { status: "running", port: data.port, wsUrl: data.ws_url }; return { port: data.port, wsUrl: data.ws_url };
} }
return { status: "stopped", port: data.port || null, wsUrl: data.ws_url || null }; return null;
} catch (error) { } catch (error) {
console.warn('[Gateway] Failed to fetch port:', error); console.warn('[Gateway] Failed to fetch port:', error);
return { status: "unavailable", port: null, wsUrl: null }; return null;
} }
} }
@@ -86,29 +86,15 @@ export class ReadOnlyClient {
// Resolve WebSocket URL if not set // Resolve WebSocket URL if not set
let targetUrl = this.wsUrl; let targetUrl = this.wsUrl;
if (!targetUrl) { if (!targetUrl) {
// Try to fetch from API first
const gatewayInfo = await fetchGatewayPort(); const gatewayInfo = await fetchGatewayPort();
if (gatewayInfo?.status === "running" && gatewayInfo.wsUrl) { if (gatewayInfo) {
targetUrl = gatewayInfo.wsUrl; targetUrl = gatewayInfo.wsUrl;
console.log(`[WebSocket] Resolved Gateway port: ${gatewayInfo.port}`); console.log(`[WebSocket] Resolved Gateway port: ${gatewayInfo.port}`);
} else if (gatewayInfo?.status === "unavailable") { } else {
// Fallback to default
targetUrl = WS_URL; targetUrl = WS_URL;
console.log(`[WebSocket] Using default URL: ${targetUrl}`); 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;
} }
} }

View File

@@ -1,62 +1,58 @@
import { create } from 'zustand'; import { create } from 'zustand';
const resolveValue = (updater, currentValue) => (
typeof updater === 'function' ? updater(currentValue) : updater
);
/** /**
* Agent Store - Agent skills, profiles, workspaces * Agent Store - Agent skills, profiles, workspaces
*/ */
export const useAgentStore = create((set) => ({ export const useAgentStore = create((set) => ({
// Selected agent for skill/workspace editing // Selected agent for skill/workspace editing
selectedSkillAgentId: null, selectedSkillAgentId: null,
setSelectedSkillAgentId: (selectedSkillAgentId) => set((state) => ({ selectedSkillAgentId: resolveValue(selectedSkillAgentId, state.selectedSkillAgentId) })), setSelectedSkillAgentId: (selectedSkillAgentId) => set({ selectedSkillAgentId }),
// Agent profiles // Agent profiles
agentProfilesByAgent: {}, agentProfilesByAgent: {},
setAgentProfilesByAgent: (agentProfilesByAgent) => set((state) => ({ agentProfilesByAgent: resolveValue(agentProfilesByAgent, state.agentProfilesByAgent) })), setAgentProfilesByAgent: (agentProfilesByAgent) => set({ agentProfilesByAgent }),
// Agent skills // Agent skills
agentSkillsByAgent: {}, agentSkillsByAgent: {},
setAgentSkillsByAgent: (agentSkillsByAgent) => set((state) => ({ agentSkillsByAgent: resolveValue(agentSkillsByAgent, state.agentSkillsByAgent) })), setAgentSkillsByAgent: (agentSkillsByAgent) => set({ agentSkillsByAgent }),
// Skill details // Skill details
skillDetailsByName: {}, skillDetailsByName: {},
setSkillDetailsByName: (skillDetailsByName) => set((state) => ({ skillDetailsByName: resolveValue(skillDetailsByName, state.skillDetailsByName) })), setSkillDetailsByName: (skillDetailsByName) => set({ skillDetailsByName }),
// Local skill drafts // Local skill drafts
localSkillDraftsByKey: {}, localSkillDraftsByKey: {},
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set((state) => ({ localSkillDraftsByKey: resolveValue(localSkillDraftsByKey, state.localSkillDraftsByKey) })), setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set({ localSkillDraftsByKey }),
// Loading states // Loading states
isAgentSkillsLoading: false, isAgentSkillsLoading: false,
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set((state) => ({ isAgentSkillsLoading: resolveValue(isAgentSkillsLoading, state.isAgentSkillsLoading) })), setIsAgentSkillsLoading: (isAgentSkillsLoading) => set({ isAgentSkillsLoading }),
skillDetailLoadingKey: null, skillDetailLoadingKey: null,
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set((state) => ({ skillDetailLoadingKey: resolveValue(skillDetailLoadingKey, state.skillDetailLoadingKey) })), setSkillDetailLoadingKey: (skillDetailLoadingKey) => set({ skillDetailLoadingKey }),
agentSkillsSavingKey: null, agentSkillsSavingKey: null,
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set((state) => ({ agentSkillsSavingKey: resolveValue(agentSkillsSavingKey, state.agentSkillsSavingKey) })), setAgentSkillsSavingKey: (agentSkillsSavingKey) => set({ agentSkillsSavingKey }),
agentSkillsFeedback: null, agentSkillsFeedback: null,
setAgentSkillsFeedback: (agentSkillsFeedback) => set((state) => ({ agentSkillsFeedback: resolveValue(agentSkillsFeedback, state.agentSkillsFeedback) })), setAgentSkillsFeedback: (agentSkillsFeedback) => set({ agentSkillsFeedback }),
// Workspace files // Workspace files
selectedWorkspaceFile: null, selectedWorkspaceFile: null,
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set((state) => ({ selectedWorkspaceFile: resolveValue(selectedWorkspaceFile, state.selectedWorkspaceFile) })), setSelectedWorkspaceFile: (selectedWorkspaceFile) => set({ selectedWorkspaceFile }),
workspaceFilesByAgent: {}, workspaceFilesByAgent: {},
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set((state) => ({ workspaceFilesByAgent: resolveValue(workspaceFilesByAgent, state.workspaceFilesByAgent) })), setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set({ workspaceFilesByAgent }),
workspaceDraftContent: '', workspaceDraftContent: '',
setWorkspaceDraftContent: (workspaceDraftContent) => set((state) => ({ workspaceDraftContent: resolveValue(workspaceDraftContent, state.workspaceDraftContent) })), setWorkspaceDraftContent: (workspaceDraftContent) => set({ workspaceDraftContent }),
isWorkspaceFileLoading: false, isWorkspaceFileLoading: false,
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set((state) => ({ isWorkspaceFileLoading: resolveValue(isWorkspaceFileLoading, state.isWorkspaceFileLoading) })), setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set({ isWorkspaceFileLoading }),
workspaceFileSavingKey: null, workspaceFileSavingKey: null,
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set((state) => ({ workspaceFileSavingKey: resolveValue(workspaceFileSavingKey, state.workspaceFileSavingKey) })), setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set({ workspaceFileSavingKey }),
workspaceFileFeedback: null, workspaceFileFeedback: null,
setWorkspaceFileFeedback: (workspaceFileFeedback) => set((state) => ({ workspaceFileFeedback: resolveValue(workspaceFileFeedback, state.workspaceFileFeedback) })), setWorkspaceFileFeedback: (workspaceFileFeedback) => set({ workspaceFileFeedback }),
})); }));

View File

@@ -1,48 +1,44 @@
import { create } from 'zustand'; import { create } from 'zustand';
const resolveValue = (updater, currentValue) => (
typeof updater === 'function' ? updater(currentValue) : updater
);
/** /**
* Market Store - Market data, stock prices, news * Market Store - Market data, stock prices, news
*/ */
export const useMarketStore = create((set) => ({ export const useMarketStore = create((set) => ({
// Ticker prices // Ticker prices
tickers: [], tickers: [],
setTickers: (tickers) => set((state) => ({ tickers: resolveValue(tickers, state.tickers) })), setTickers: (tickers) => set({ tickers }),
rollingTickers: {}, rollingTickers: {},
setRollingTickers: (rollingTickers) => set((state) => ({ rollingTickers: resolveValue(rollingTickers, state.rollingTickers) })), setRollingTickers: (rollingTickers) => set({ rollingTickers }),
// Price history // Price history
priceHistoryByTicker: {}, priceHistoryByTicker: {},
setPriceHistoryByTicker: (priceHistoryByTicker) => set((state) => ({ priceHistoryByTicker: resolveValue(priceHistoryByTicker, state.priceHistoryByTicker) })), setPriceHistoryByTicker: (priceHistoryByTicker) => set({ priceHistoryByTicker }),
// OHLC history // OHLC history
ohlcHistoryByTicker: {}, ohlcHistoryByTicker: {},
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set((state) => ({ ohlcHistoryByTicker: resolveValue(ohlcHistoryByTicker, state.ohlcHistoryByTicker) })), setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set({ ohlcHistoryByTicker }),
// History source tracking // History source tracking
historySourceByTicker: {}, historySourceByTicker: {},
setHistorySourceByTicker: (historySourceByTicker) => set((state) => ({ historySourceByTicker: resolveValue(historySourceByTicker, state.historySourceByTicker) })), setHistorySourceByTicker: (historySourceByTicker) => set({ historySourceByTicker }),
// Explain events // Explain events
explainEventsByTicker: {}, explainEventsByTicker: {},
setExplainEventsByTicker: (explainEventsByTicker) => set((state) => ({ explainEventsByTicker: resolveValue(explainEventsByTicker, state.explainEventsByTicker) })), setExplainEventsByTicker: (explainEventsByTicker) => set({ explainEventsByTicker }),
// Selected explain symbol // Selected explain symbol
selectedExplainSymbol: '', selectedExplainSymbol: '',
setSelectedExplainSymbol: (selectedExplainSymbol) => set((state) => ({ selectedExplainSymbol: resolveValue(selectedExplainSymbol, state.selectedExplainSymbol) })), setSelectedExplainSymbol: (selectedExplainSymbol) => set({ selectedExplainSymbol }),
// News by ticker // News by ticker
newsByTicker: {}, newsByTicker: {},
setNewsByTicker: (newsByTicker) => set((state) => ({ newsByTicker: resolveValue(newsByTicker, state.newsByTicker) })), setNewsByTicker: (newsByTicker) => set({ newsByTicker }),
// Insider trades // Insider trades
insiderTradesByTicker: {}, insiderTradesByTicker: {},
setInsiderTradesByTicker: (insiderTradesByTicker) => set((state) => ({ insiderTradesByTicker: resolveValue(insiderTradesByTicker, state.insiderTradesByTicker) })), setInsiderTradesByTicker: (insiderTradesByTicker) => set({ insiderTradesByTicker }),
// Technical indicators // Technical indicators
technicalIndicatorsByTicker: {}, technicalIndicatorsByTicker: {},
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set((state) => ({ technicalIndicatorsByTicker: resolveValue(technicalIndicatorsByTicker, state.technicalIndicatorsByTicker) })), setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set({ technicalIndicatorsByTicker }),
})); }));

View File

@@ -1,9 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
const resolveValue = (updater, currentValue) => (
typeof updater === 'function' ? updater(currentValue) : updater
);
/** /**
* Portfolio Store - Portfolio data, holdings, trades, statistics * Portfolio Store - Portfolio data, holdings, trades, statistics
*/ */
@@ -22,21 +18,21 @@ export const usePortfolioStore = create((set) => ({
baseline_vw_return: 0, baseline_vw_return: 0,
momentum_return: 0, momentum_return: 0,
}, },
setPortfolioData: (portfolioData) => set((state) => ({ portfolioData: resolveValue(portfolioData, state.portfolioData) })), setPortfolioData: (portfolioData) => set({ portfolioData }),
// Holdings // Holdings
holdings: [], holdings: [],
setHoldings: (holdings) => set((state) => ({ holdings: resolveValue(holdings, state.holdings) })), setHoldings: (holdings) => set({ holdings }),
// Trades // Trades
trades: [], trades: [],
setTrades: (trades) => set((state) => ({ trades: resolveValue(trades, state.trades) })), setTrades: (trades) => set({ trades }),
// Statistics // Statistics
stats: null, stats: null,
setStats: (stats) => set((state) => ({ stats: resolveValue(stats, state.stats) })), setStats: (stats) => set({ stats }),
// Leaderboard // Leaderboard
leaderboard: [], leaderboard: [],
setLeaderboard: (leaderboard) => set((state) => ({ leaderboard: resolveValue(leaderboard, state.leaderboard) })), setLeaderboard: (leaderboard) => set({ leaderboard }),
})); }));

View File

@@ -1,9 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
const resolveValue = (updater, currentValue) => (
typeof updater === 'function' ? updater(currentValue) : updater
);
/** /**
* Runtime Store - Connection state and runtime configuration * Runtime Store - Connection state and runtime configuration
*/ */
@@ -11,62 +7,59 @@ export const useRuntimeStore = create((set) => ({
// Connection state // Connection state
isConnected: false, isConnected: false,
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected' connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
setIsConnected: (isConnected) => set((state) => ({ isConnected: resolveValue(isConnected, state.isConnected) })), setIsConnected: (isConnected) => set({ isConnected }),
setConnectionStatus: (connectionStatus) => set((state) => ({ connectionStatus: resolveValue(connectionStatus, state.connectionStatus) })), setConnectionStatus: (connectionStatus) => set({ connectionStatus }),
// System state // System state
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed' systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
currentDate: null, currentDate: null,
setSystemStatus: (systemStatus) => set((state) => ({ systemStatus: resolveValue(systemStatus, state.systemStatus) })), setSystemStatus: (systemStatus) => set({ systemStatus }),
setCurrentDate: (currentDate) => set((state) => ({ currentDate: resolveValue(currentDate, state.currentDate) })), setCurrentDate: (currentDate) => set({ currentDate }),
// Progress // Progress
progress: { current: 0, total: 0 }, progress: { current: 0, total: 0 },
setProgress: (progress) => set((state) => ({ progress: resolveValue(progress, state.progress) })), setProgress: (progress) => set({ progress }),
// Server mode // Server mode
serverMode: null, // 'live' | 'backtest' | null serverMode: null, // 'live' | 'backtest' | null
setServerMode: (serverMode) => set((state) => ({ serverMode: resolveValue(serverMode, state.serverMode) })), setServerMode: (serverMode) => set({ serverMode }),
// Market status // Market status
marketStatus: null, marketStatus: null,
virtualTime: null, virtualTime: null,
setMarketStatus: (marketStatus) => set((state) => ({ marketStatus: resolveValue(marketStatus, state.marketStatus) })), setMarketStatus: (marketStatus) => set({ marketStatus }),
setVirtualTime: (virtualTime) => set((state) => ({ virtualTime: resolveValue(virtualTime, state.virtualTime) })), setVirtualTime: (virtualTime) => set({ virtualTime }),
// Data sources // Data sources
dataSources: null, dataSources: null,
setDataSources: (dataSources) => set((state) => ({ dataSources: resolveValue(dataSources, state.dataSources) })), setDataSources: (dataSources) => set({ dataSources }),
// Runtime config // Runtime config
runtimeConfig: null, runtimeConfig: null,
setRuntimeConfig: (runtimeConfig) => set((state) => ({ runtimeConfig: resolveValue(runtimeConfig, state.runtimeConfig) })), setRuntimeConfig: (runtimeConfig) => set({ runtimeConfig }),
// Watchlist panel // Watchlist panel
isWatchlistPanelOpen: false, isWatchlistPanelOpen: false,
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set((state) => ({ isWatchlistPanelOpen: resolveValue(isWatchlistPanelOpen, state.isWatchlistPanelOpen) })), setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set({ isWatchlistPanelOpen }),
// Watchlist draft // Watchlist draft
watchlistDraftSymbols: [], watchlistDraftSymbols: [],
watchlistInputValue: '', watchlistInputValue: '',
watchlistFeedback: null, watchlistFeedback: null,
isWatchlistSaving: false, isWatchlistSaving: false,
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set((state) => ({ watchlistDraftSymbols: resolveValue(watchlistDraftSymbols, state.watchlistDraftSymbols) })), setWatchlistDraftSymbols: (watchlistDraftSymbols) => set({ watchlistDraftSymbols }),
setWatchlistInputValue: (watchlistInputValue) => set((state) => ({ watchlistInputValue: resolveValue(watchlistInputValue, state.watchlistInputValue) })), setWatchlistInputValue: (watchlistInputValue) => set({ watchlistInputValue }),
setWatchlistFeedback: (watchlistFeedback) => set((state) => ({ watchlistFeedback: resolveValue(watchlistFeedback, state.watchlistFeedback) })), setWatchlistFeedback: (watchlistFeedback) => set({ watchlistFeedback }),
setIsWatchlistSaving: (isWatchlistSaving) => set((state) => ({ isWatchlistSaving: resolveValue(isWatchlistSaving, state.isWatchlistSaving) })), setIsWatchlistSaving: (isWatchlistSaving) => set({ isWatchlistSaving }),
// Runtime settings panel // Runtime settings panel
isRuntimeSettingsOpen: false, isRuntimeSettingsOpen: false,
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })), setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set({ isRuntimeSettingsOpen }),
// Runtime config drafts // Runtime config drafts
launchModeDraft: 'fresh',
restoreRunIdDraft: '',
runtimeHistoryRuns: [],
scheduleModeDraft: 'daily', scheduleModeDraft: 'daily',
intervalMinutesDraft: '60', intervalMinutesDraft: '60',
triggerTimeDraft: 'now', triggerTimeDraft: '09:30',
maxCommCyclesDraft: '2', maxCommCyclesDraft: '2',
initialCashDraft: '100000', initialCashDraft: '100000',
marginRequirementDraft: '0', marginRequirementDraft: '0',
@@ -75,28 +68,27 @@ export const useRuntimeStore = create((set) => ({
pollIntervalDraft: '10', pollIntervalDraft: '10',
startDateDraft: '', startDateDraft: '',
endDateDraft: '', endDateDraft: '',
setLaunchModeDraft: (launchModeDraft) => set((state) => ({ launchModeDraft: resolveValue(launchModeDraft, state.launchModeDraft) })), enableMockDraft: false,
setRestoreRunIdDraft: (restoreRunIdDraft) => set((state) => ({ restoreRunIdDraft: resolveValue(restoreRunIdDraft, state.restoreRunIdDraft) })), setScheduleModeDraft: (scheduleModeDraft) => set({ scheduleModeDraft }),
setRuntimeHistoryRuns: (runtimeHistoryRuns) => set((state) => ({ runtimeHistoryRuns: resolveValue(runtimeHistoryRuns, state.runtimeHistoryRuns) })), setIntervalMinutesDraft: (intervalMinutesDraft) => set({ intervalMinutesDraft }),
setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })), setTriggerTimeDraft: (triggerTimeDraft) => set({ triggerTimeDraft }),
setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })), setMaxCommCyclesDraft: (maxCommCyclesDraft) => set({ maxCommCyclesDraft }),
setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })), setInitialCashDraft: (initialCashDraft) => set({ initialCashDraft }),
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set((state) => ({ maxCommCyclesDraft: resolveValue(maxCommCyclesDraft, state.maxCommCyclesDraft) })), setMarginRequirementDraft: (marginRequirementDraft) => set({ marginRequirementDraft }),
setInitialCashDraft: (initialCashDraft) => set((state) => ({ initialCashDraft: resolveValue(initialCashDraft, state.initialCashDraft) })), setEnableMemoryDraft: (enableMemoryDraft) => set({ enableMemoryDraft }),
setMarginRequirementDraft: (marginRequirementDraft) => set((state) => ({ marginRequirementDraft: resolveValue(marginRequirementDraft, state.marginRequirementDraft) })), setModeDraft: (modeDraft) => set({ modeDraft }),
setEnableMemoryDraft: (enableMemoryDraft) => set((state) => ({ enableMemoryDraft: resolveValue(enableMemoryDraft, state.enableMemoryDraft) })), setPollIntervalDraft: (pollIntervalDraft) => set({ pollIntervalDraft }),
setModeDraft: (modeDraft) => set((state) => ({ modeDraft: resolveValue(modeDraft, state.modeDraft) })), setStartDateDraft: (startDateDraft) => set({ startDateDraft }),
setPollIntervalDraft: (pollIntervalDraft) => set((state) => ({ pollIntervalDraft: resolveValue(pollIntervalDraft, state.pollIntervalDraft) })), setEndDateDraft: (endDateDraft) => set({ endDateDraft }),
setStartDateDraft: (startDateDraft) => set((state) => ({ startDateDraft: resolveValue(startDateDraft, state.startDateDraft) })), setEnableMockDraft: (enableMockDraft) => set({ enableMockDraft }),
setEndDateDraft: (endDateDraft) => set((state) => ({ endDateDraft: resolveValue(endDateDraft, state.endDateDraft) })),
// Runtime config feedback // Runtime config feedback
runtimeConfigFeedback: null, runtimeConfigFeedback: null,
isRuntimeConfigSaving: false, isRuntimeConfigSaving: false,
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set((state) => ({ runtimeConfigFeedback: resolveValue(runtimeConfigFeedback, state.runtimeConfigFeedback) })), setRuntimeConfigFeedback: (runtimeConfigFeedback) => set({ runtimeConfigFeedback }),
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set((state) => ({ isRuntimeConfigSaving: resolveValue(isRuntimeConfigSaving, state.isRuntimeConfigSaving) })), setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set({ isRuntimeConfigSaving }),
// Last day history (for replay) // Last day history (for replay)
lastDayHistory: [], lastDayHistory: [],
setLastDayHistory: (lastDayHistory) => set((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })), setLastDayHistory: (lastDayHistory) => set({ lastDayHistory }),
})); }));

View File

@@ -1,44 +1,40 @@
import { create } from 'zustand'; import { create } from 'zustand';
const resolveValue = (updater, currentValue) => (
typeof updater === 'function' ? updater(currentValue) : updater
);
/** /**
* UI Store - UI state, view management, layout * UI Store - UI state, view management, layout
*/ */
export const useUIStore = create((set) => ({ export const useUIStore = create((set) => ({
// Current view // Current view
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime' currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })), setCurrentView: (currentView) => set({ currentView }),
// Chart tab // Chart tab
chartTab: 'all', chartTab: 'all',
setChartTab: (chartTab) => set((state) => ({ chartTab: resolveValue(chartTab, state.chartTab) })), setChartTab: (chartTab) => set({ chartTab }),
// Initial animation // Initial animation
isInitialAnimating: true, isInitialAnimating: true,
setIsInitialAnimating: (isInitialAnimating) => set((state) => ({ isInitialAnimating: resolveValue(isInitialAnimating, state.isInitialAnimating) })), setIsInitialAnimating: (isInitialAnimating) => set({ isInitialAnimating }),
// Last update timestamp // Last update timestamp
lastUpdate: new Date(), lastUpdate: new Date(),
setLastUpdate: (lastUpdate) => set((state) => ({ lastUpdate: resolveValue(lastUpdate, state.lastUpdate) })), setLastUpdate: (lastUpdate) => set({ lastUpdate }),
// Is updating // Is updating
isUpdating: false, isUpdating: false,
setIsUpdating: (isUpdating) => set((state) => ({ isUpdating: resolveValue(isUpdating, state.isUpdating) })), setIsUpdating: (isUpdating) => set({ isUpdating }),
// Room bubbles // Room bubbles
bubbles: {}, bubbles: {},
setBubbles: (bubbles) => set((state) => ({ bubbles: resolveValue(bubbles, state.bubbles) })), setBubbles: (bubbles) => set({ bubbles }),
// Resizable panels // Resizable panels
leftWidth: 70, leftWidth: 70,
setLeftWidth: (leftWidth) => set((state) => ({ leftWidth: resolveValue(leftWidth, state.leftWidth) })), setLeftWidth: (leftWidth) => set({ leftWidth }),
isResizing: false, isResizing: false,
setIsResizing: (isResizing) => set((state) => ({ isResizing: resolveValue(isResizing, state.isResizing) })), setIsResizing: (isResizing) => set({ isResizing }),
// Now timestamp (for current time display) // Now timestamp (for current time display)
now: new Date(), now: new Date(),
setNow: (now) => set((state) => ({ now: resolveValue(now, state.now) })), setNow: (now) => set({ now }),
})); }));

View File

@@ -478,7 +478,7 @@ export default function GlobalStyles() {
background: #ffffff; background: #ffffff;
flex-wrap: wrap; flex-wrap: wrap;
position: relative; position: relative;
z-index: 10; z-index: 1000;
} }
.agent-indicator { .agent-indicator {
@@ -578,12 +578,11 @@ export default function GlobalStyles() {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 700; z-index: 999;
} }
.room-scene-wrapper { .room-scene-wrapper {
position: relative; position: relative;
overflow: visible;
} }
@keyframes pulse { @keyframes pulse {
@@ -647,7 +646,7 @@ export default function GlobalStyles() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: visible; overflow: hidden;
padding: 24px; padding: 24px;
position: relative; position: relative;
} }
@@ -657,7 +656,6 @@ export default function GlobalStyles() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: visible;
} }
.room-canvas { .room-canvas {
@@ -668,8 +666,7 @@ export default function GlobalStyles() {
.room-bubble { .room-bubble {
position: absolute; position: absolute;
max-width: 320px; max-width: 300px;
max-height: 260px;
font-size: 11px; font-size: 11px;
background: #ffffff; background: #ffffff;
color: #000000; color: #000000;
@@ -679,8 +676,6 @@ export default function GlobalStyles() {
font-family: 'IBM Plex Mono', monospace; font-family: 'IBM Plex Mono', monospace;
line-height: 1.5; line-height: 1.5;
animation: bubbleAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); animation: bubbleAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
z-index: 1500;
} }
@keyframes bubbleAppear { @keyframes bubbleAppear {
@@ -713,7 +708,7 @@ export default function GlobalStyles() {
right: 8px; right: 8px;
display: flex; display: flex;
gap: 4px; gap: 4px;
z-index: 1510; z-index: 10;
} }
.bubble-jump-btn, .bubble-jump-btn,
@@ -791,9 +786,6 @@ export default function GlobalStyles() {
word-wrap: break-word; word-wrap: break-word;
white-space: pre-wrap; white-space: pre-wrap;
position: relative; position: relative;
max-height: 180px;
overflow-y: auto;
padding-right: 4px;
} }
.bubble-expand-btn { .bubble-expand-btn {

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}

Submodule reference/CoPaw deleted from 934cfce0a7

Submodule reference/Hyper-Alpha-Arena deleted from f137cff476

Submodule reference/openclaw deleted from 7b151afeeb

View File

@@ -29,6 +29,13 @@ else
echo -e "${YELLOW}Warning: .env file not found${NC}" echo -e "${YELLOW}Warning: .env file not found${NC}"
fi 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 cd /Users/cillin/workspeace/evotraders
PIDS=() PIDS=()
@@ -43,8 +50,7 @@ start_service() {
--port "${port}" \ --port "${port}" \
--reload \ --reload \
--reload-dir backend \ --reload-dir backend \
--log-level warning \ --log-level info &
--no-access-log &
PIDS+=($!) PIDS+=($!)
} }