Compare commits
11 Commits
3926a6bd07
...
codex/chec
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bcc4221a4 | |||
| fecf8a9466 | |||
| 86eb8c37a9 | |||
| 1f9063edad | |||
| 7e7a58769a | |||
| 16bb3c4211 | |||
| da6d642aaa | |||
| 8d6c3c5647 | |||
| 6413edf8c9 | |||
| c5eaf2b5ad | |||
| 032c37538f |
41
.env.example
Normal file
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Copy this file to `.env` for local development.
|
||||||
|
# Keep `.env` untracked and never paste real secrets into tracked files.
|
||||||
|
|
||||||
|
# ================== General Configuration | 通用配置 ==================
|
||||||
|
TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN
|
||||||
|
|
||||||
|
# Financial Data API
|
||||||
|
# At least `FINANCIAL_DATASETS_API_KEY` is required when using `FIN_DATA_SOURCE=financial_datasets`.
|
||||||
|
# `FINNHUB_API_KEY` is recommended for `FIN_DATA_SOURCE=finnhub` and required for live mode.
|
||||||
|
FIN_DATA_SOURCE=finnhub
|
||||||
|
ENABLED_DATA_SOURCES=financial_datasets,finnhub,yfinance,local_csv
|
||||||
|
FINANCIAL_DATASETS_API_KEY=
|
||||||
|
FINNHUB_API_KEY=
|
||||||
|
POLYGON_API_KEY=
|
||||||
|
MARKET_DB_PATH=
|
||||||
|
|
||||||
|
# Model API
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_BASE_URL=
|
||||||
|
MODEL_NAME=qwen3-max-preview
|
||||||
|
EXPLAIN_ENRICH_USE_LLM=false
|
||||||
|
EXPLAIN_ENRICH_MODEL_PROVIDER=
|
||||||
|
EXPLAIN_ENRICH_MODEL_NAME=
|
||||||
|
EXPLAIN_RANGE_USE_LLM=
|
||||||
|
|
||||||
|
# Memory module
|
||||||
|
MEMORY_API_KEY=
|
||||||
|
|
||||||
|
# ================== Agent-Specific Model Configuration | Agent特定模型配置 ==================
|
||||||
|
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp
|
||||||
|
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6
|
||||||
|
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview
|
||||||
|
AGENT_VALUATION_ANALYST_MODEL_NAME=Moonshot-Kimi-K2-Instruct
|
||||||
|
AGENT_RISK_MANAGER_MODEL_NAME=qwen3-max-preview
|
||||||
|
AGENT_PORTFOLIO_MANAGER_MODEL_NAME=qwen3-max-preview
|
||||||
|
|
||||||
|
# ================== Advanced Configuration | 高阶配置 ==================
|
||||||
|
MAX_COMM_CYCLES=2
|
||||||
|
MARGIN_REQUIREMENT=0.5
|
||||||
|
DATA_START_DATE=2022-01-01
|
||||||
|
AUTO_UPDATE_DATA=true
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -54,10 +54,13 @@ outputs/
|
|||||||
/smoke_live_mock/
|
/smoke_live_mock/
|
||||||
|
|
||||||
# Local tooling state
|
# Local tooling state
|
||||||
/.omc/
|
.omc/
|
||||||
/.pydeps/
|
/.pydeps/
|
||||||
/referance/
|
/referance/
|
||||||
|
|
||||||
|
# Run outputs
|
||||||
|
/runs/
|
||||||
|
|
||||||
# Data files
|
# Data files
|
||||||
backend/data/ret_data/
|
backend/data/ret_data/
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastScanned": 1773938154948,
|
"lastScanned": 1774313111650,
|
||||||
"projectRoot": "/Users/cillin/workspeace/evotraders",
|
"projectRoot": "/Users/cillin/workspeace/evotraders",
|
||||||
"techStack": {
|
"techStack": {
|
||||||
"languages": [
|
"languages": [
|
||||||
@@ -11,14 +11,6 @@
|
|||||||
"markers": [
|
"markers": [
|
||||||
"pyproject.toml"
|
"pyproject.toml"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "C/C++",
|
|
||||||
"version": null,
|
|
||||||
"confidence": "high",
|
|
||||||
"markers": [
|
|
||||||
"Makefile"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"frameworks": [
|
"frameworks": [
|
||||||
@@ -32,8 +24,8 @@
|
|||||||
"runtime": null
|
"runtime": null
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"buildCommand": "make build",
|
"buildCommand": null,
|
||||||
"testCommand": "make test",
|
"testCommand": "pytest",
|
||||||
"lintCommand": "ruff check",
|
"lintCommand": "ruff check",
|
||||||
"devCommand": null,
|
"devCommand": null,
|
||||||
"scripts": {}
|
"scripts": {}
|
||||||
@@ -58,24 +50,13 @@
|
|||||||
},
|
},
|
||||||
"customNotes": [],
|
"customNotes": [],
|
||||||
"directoryMap": {
|
"directoryMap": {
|
||||||
"agent-service": {
|
|
||||||
"path": "agent-service",
|
|
||||||
"purpose": null,
|
|
||||||
"fileCount": 2,
|
|
||||||
"lastAccessed": 1773938154941,
|
|
||||||
"keyFiles": [
|
|
||||||
"Dockerfile",
|
|
||||||
"requirements.txt"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"backend": {
|
"backend": {
|
||||||
"path": "backend",
|
"path": "backend",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 5,
|
"fileCount": 4,
|
||||||
"lastAccessed": 1773938154941,
|
"lastAccessed": 1774313111639,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"app.py",
|
|
||||||
"cli.py",
|
"cli.py",
|
||||||
"gateway_server.py",
|
"gateway_server.py",
|
||||||
"main.py"
|
"main.py"
|
||||||
@@ -85,37 +66,41 @@
|
|||||||
"path": "backtest",
|
"path": "backtest",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154941,
|
"lastAccessed": 1774313111640,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"path": "data",
|
"path": "data",
|
||||||
"purpose": "Data files",
|
"purpose": "Data files",
|
||||||
"fileCount": 1,
|
"fileCount": 3,
|
||||||
"lastAccessed": 1773938154941,
|
"lastAccessed": 1774313111640,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"market_research.db"
|
"market_research.db",
|
||||||
|
"market_research.db-shm",
|
||||||
|
"market_research.db-wal"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"deploy": {
|
"deploy": {
|
||||||
"path": "deploy",
|
"path": "deploy",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154942,
|
"lastAccessed": 1774313111640,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"path": "docs",
|
"path": "docs",
|
||||||
"purpose": "Documentation",
|
"purpose": "Documentation",
|
||||||
"fileCount": 0,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1773938154942,
|
"lastAccessed": 1774313111641,
|
||||||
"keyFiles": []
|
"keyFiles": [
|
||||||
|
"compat-removal-plan.md"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"evotraders.egg-info": {
|
"evotraders.egg-info": {
|
||||||
"path": "evotraders.egg-info",
|
"path": "evotraders.egg-info",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 6,
|
"fileCount": 6,
|
||||||
"lastAccessed": 1773938154942,
|
"lastAccessed": 1774313111641,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"PKG-INFO",
|
"PKG-INFO",
|
||||||
"SOURCES.txt",
|
"SOURCES.txt",
|
||||||
@@ -128,7 +113,7 @@
|
|||||||
"path": "frontend",
|
"path": "frontend",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 13,
|
"fileCount": 13,
|
||||||
"lastAccessed": 1773938154942,
|
"lastAccessed": 1774313111641,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"README.md",
|
"README.md",
|
||||||
"components.json",
|
"components.json",
|
||||||
@@ -141,51 +126,41 @@
|
|||||||
"path": "live",
|
"path": "live",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154943,
|
"lastAccessed": 1774313111642,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"path": "logs",
|
"path": "logs",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 7,
|
"fileCount": 6,
|
||||||
"lastAccessed": 1773938154943,
|
"lastAccessed": 1774313111642,
|
||||||
"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": 1773938154943,
|
"lastAccessed": 1774313111643,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"runs": {
|
"runs": {
|
||||||
"path": "runs",
|
"path": "runs",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774313111643,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"path": "scripts",
|
"path": "scripts",
|
||||||
"purpose": "Build/utility scripts",
|
"purpose": "Build/utility scripts",
|
||||||
"fileCount": 1,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774313111644,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"run_prod.sh"
|
"run_prod.sh"
|
||||||
]
|
]
|
||||||
@@ -194,7 +169,7 @@
|
|||||||
"path": "services",
|
"path": "services",
|
||||||
"purpose": "Business logic services",
|
"purpose": "Business logic services",
|
||||||
"fileCount": 1,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774313111644,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"README.md"
|
"README.md"
|
||||||
]
|
]
|
||||||
@@ -203,43 +178,21 @@
|
|||||||
"path": "shared",
|
"path": "shared",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774313111644,
|
||||||
"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": 1773938154944,
|
"lastAccessed": 1774313111645,
|
||||||
"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": 1773938154944,
|
"lastAccessed": 1774313111645,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"agents.py",
|
"agents.py",
|
||||||
@@ -250,7 +203,7 @@
|
|||||||
"path": "backend/config",
|
"path": "backend/config",
|
||||||
"purpose": "Configuration files",
|
"purpose": "Configuration files",
|
||||||
"fileCount": 6,
|
"fileCount": 6,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774313111646,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"agent_profiles.yaml",
|
"agent_profiles.yaml",
|
||||||
@@ -261,7 +214,7 @@
|
|||||||
"path": "backend/data",
|
"path": "backend/data",
|
||||||
"purpose": "Data files",
|
"purpose": "Data files",
|
||||||
"fileCount": 13,
|
"fileCount": 13,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774313111647,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"cache.py",
|
"cache.py",
|
||||||
@@ -272,7 +225,7 @@
|
|||||||
"path": "docs/assets",
|
"path": "docs/assets",
|
||||||
"purpose": "Static assets",
|
"purpose": "Static assets",
|
||||||
"fileCount": 5,
|
"fileCount": 5,
|
||||||
"lastAccessed": 1773938154944,
|
"lastAccessed": 1774313111647,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"dashboard.jpg",
|
"dashboard.jpg",
|
||||||
"evotraders_demo.gif",
|
"evotraders_demo.gif",
|
||||||
@@ -283,7 +236,7 @@
|
|||||||
"path": "frontend/dist",
|
"path": "frontend/dist",
|
||||||
"purpose": "Distribution/build output",
|
"purpose": "Distribution/build output",
|
||||||
"fileCount": 2,
|
"fileCount": 2,
|
||||||
"lastAccessed": 1773938154945,
|
"lastAccessed": 1774313111647,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"index.html",
|
"index.html",
|
||||||
"trading_logo.png"
|
"trading_logo.png"
|
||||||
@@ -293,331 +246,261 @@
|
|||||||
"path": "frontend/node_modules",
|
"path": "frontend/node_modules",
|
||||||
"purpose": "Dependencies",
|
"purpose": "Dependencies",
|
||||||
"fileCount": 1,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1773938154947,
|
"lastAccessed": 1774313111650,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
|
||||||
"news-service/src": {
|
|
||||||
"path": "news-service/src",
|
|
||||||
"purpose": "Source code",
|
|
||||||
"fileCount": 3,
|
|
||||||
"lastAccessed": 1773938154948,
|
|
||||||
"keyFiles": [
|
|
||||||
"__init__.py",
|
|
||||||
"config.py",
|
|
||||||
"main.py"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"trading-service/src": {
|
|
||||||
"path": "trading-service/src",
|
|
||||||
"purpose": "Source code",
|
|
||||||
"fileCount": 8,
|
|
||||||
"lastAccessed": 1773938154948,
|
|
||||||
"keyFiles": [
|
|
||||||
"__init__.py",
|
|
||||||
"config.py",
|
|
||||||
"main.py"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotPaths": [
|
"hotPaths": [
|
||||||
{
|
{
|
||||||
"path": "backend/agents/factory.py",
|
"path": "CLAUDE.md",
|
||||||
"accessCount": 17,
|
"accessCount": 15,
|
||||||
"lastAccessed": 1773939950376,
|
"lastAccessed": 1774342728155,
|
||||||
|
"type": "directory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/App.jsx",
|
||||||
|
"accessCount": 10,
|
||||||
|
"lastAccessed": 1774339397617,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend",
|
"path": "frontend/src/hooks/useWebsocketSessionSync.js",
|
||||||
"accessCount": 16,
|
"accessCount": 4,
|
||||||
"lastAccessed": 1773940042371,
|
"lastAccessed": 1774313470024,
|
||||||
"type": "directory"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "",
|
"path": "",
|
||||||
"accessCount": 13,
|
"accessCount": 4,
|
||||||
"lastAccessed": 1773939899611,
|
"lastAccessed": 1774339108220,
|
||||||
"type": "directory"
|
"type": "directory"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/main.py",
|
|
||||||
"accessCount": 7,
|
|
||||||
"lastAccessed": 1773939993951,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/gateway_server.py",
|
|
||||||
"accessCount": 7,
|
|
||||||
"lastAccessed": 1773940004402,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/main.py",
|
|
||||||
"accessCount": 5,
|
|
||||||
"lastAccessed": 1773938385662,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/core/pipeline.py",
|
|
||||||
"accessCount": 5,
|
|
||||||
"lastAccessed": 1773940024933,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/enrich/news_enricher.py",
|
|
||||||
"accessCount": 4,
|
|
||||||
"lastAccessed": 1773938508417,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "start-dev.sh",
|
|
||||||
"accessCount": 4,
|
|
||||||
"lastAccessed": 1773939259381,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "services/README.md",
|
|
||||||
"accessCount": 4,
|
|
||||||
"lastAccessed": 1773939281935,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/app.py",
|
|
||||||
"accessCount": 4,
|
|
||||||
"lastAccessed": 1773939648215,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news/routes/news.py",
|
|
||||||
"accessCount": 3,
|
|
||||||
"lastAccessed": 1773938438928,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/news",
|
|
||||||
"accessCount": 3,
|
|
||||||
"lastAccessed": 1773938468730,
|
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "frontend/src/config/constants.js",
|
|
||||||
"accessCount": 3,
|
|
||||||
"lastAccessed": 1773939204395,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/services/gateway.py",
|
"path": "backend/services/gateway.py",
|
||||||
"accessCount": 3,
|
"accessCount": 3,
|
||||||
"lastAccessed": 1773939672930,
|
"lastAccessed": 1774339389171,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/core/__init__.py",
|
"path": "backend/main.py",
|
||||||
"accessCount": 3,
|
"accessCount": 3,
|
||||||
"lastAccessed": 1773939963627,
|
"lastAccessed": 1774342613364,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/trading/main.py",
|
"path": "frontend/src/store/runtimeStore.js",
|
||||||
"accessCount": 2,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1773938360736,
|
"lastAccessed": 1774317990919,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/agents/main.py",
|
"path": "frontend/src/services/websocket.js",
|
||||||
"accessCount": 2,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1773938361040,
|
"lastAccessed": 1774318009819,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/trading/data/__init__.py",
|
"path": "backend/core/pipeline_runner.py",
|
||||||
"accessCount": 2,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1773938402496,
|
"lastAccessed": 1774339367538,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/news/explain/__init__.py",
|
"path": "backend/runtime/manager.py",
|
||||||
"accessCount": 2,
|
"accessCount": 2,
|
||||||
"lastAccessed": 1773938460019,
|
"lastAccessed": 1774339367572,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/news/enrich/__init__.py",
|
"path": "frontend/src/store/marketStore.js",
|
||||||
"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": 1773938226307,
|
"lastAccessed": 1774313140483,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "docker-compose.yml",
|
"path": "frontend/src/hooks/useFeedProcessor.js",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773938226360,
|
"lastAccessed": 1774313148279,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/news/shared/trading_client.py",
|
"path": "frontend/src/components/Header.jsx",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773938370618,
|
"lastAccessed": 1774313156696,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/services/agents",
|
"path": "frontend/src/components/TraderView.jsx",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773938397772,
|
"lastAccessed": 1774313156753,
|
||||||
"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"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "shared/client/news_client.py",
|
"path": "frontend/src/store/uiStore.js",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773938638715,
|
"lastAccessed": 1774313187460,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/store/portfolioStore.js",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313187511,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/store/agentStore.js",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313187573,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/hooks/useWebSocketConnection.js",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313279414,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/hooks/useStockDataRequests.js",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313319716,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/hooks/useAgentDataRequests.js",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313347455,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/src/components/AppShell.jsx",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313396331,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "start-dev.sh",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774317979859,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/apps/agent_service.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774317984348,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "shared/client/trading_client.py",
|
"path": "shared/client/trading_client.py",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773938638770,
|
"lastAccessed": 1774317984365,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/api",
|
"path": "backend/apps/trading_service.py",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773938669143,
|
"lastAccessed": 1774317984408,
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "frontend",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938669195,
|
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": ".env.example",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938849397,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "frontend/src/services/websocket.js",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938849448,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "frontend/src/services/runtimeApi.js",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773938849500,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/agents/routes/websocket.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939001692,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/agents/routes/agents.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939016291,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/services/agents/routes/run.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939016343,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/__init__.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939648323,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/api/__init__.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939658650,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/runtime/__init__.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939658687,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/agents/base/evo_agent.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939664916,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/agents/analyst.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939664967,
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/agents/base/hooks.py",
|
|
||||||
"accessCount": 1,
|
|
||||||
"lastAccessed": 1773939672727,
|
|
||||||
"type": "file"
|
"type": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pyproject.toml",
|
"path": "pyproject.toml",
|
||||||
"accessCount": 1,
|
"accessCount": 1,
|
||||||
"lastAccessed": 1773939672778,
|
"lastAccessed": 1774317990970,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/agents/factory.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774318009867,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/config/constants.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774318009922,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/api/__init__.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774318009973,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774339107381,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/runtime/registry.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774339380024,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/runtime/session.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774339380084,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/runtime/context.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774339380120,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/runtime/agent_runtime.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774339380185,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/process/supervisor.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774339389110,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/core/pipeline.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774339389187,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/process/models.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774339397557,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/process/registry.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774339397577,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/config/env_config.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774342678236,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/config/data_config.py",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774342678253,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/env.template",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774342678290,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "env.template",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774342678310,
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"timestamp": "2026-03-19T16:36:52.471Z",
|
"timestamp": "2026-03-24T07:58:12.123Z",
|
||||||
"backgroundTasks": [],
|
"backgroundTasks": [],
|
||||||
"sessionStartTimestamp": "2026-03-19T16:36:42.224Z",
|
"sessionStartTimestamp": "2026-03-24T07:58:09.417Z",
|
||||||
"sessionId": "ef02339a-1eec-4c7a-95ac-c8cfa0b5067d"
|
"sessionId": "fda34772-7bd2-402e-86b2-d656296416f3"
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"session_id":"ef02339a-1eec-4c7a-95ac-c8cfa0b5067d","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/ef02339a-1eec-4c7a-95ac-c8cfa0b5067d.jsonl","cwd":"/Users/cillin/workspeace/evotraders","model":{"id":"MiniMax-M2.7-highspeed","display_name":"MiniMax-M2.7-highspeed"},"workspace":{"current_dir":"/Users/cillin/workspeace/evotraders","project_dir":"/Users/cillin/workspeace/evotraders","added_dirs":[]},"version":"2.1.78","output_style":{"name":"default"},"cost":{"total_cost_usd":17.458779250000003,"total_duration_ms":1866224,"total_api_duration_ms":1188013,"total_lines_added":257,"total_lines_removed":290},"context_window":{"total_input_tokens":195204,"total_output_tokens":48917,"context_window_size":200000,"current_usage":{"input_tokens":481,"output_tokens":0,"cache_creation_input_tokens":149,"cache_read_input_tokens":163286},"used_percentage":82,"remaining_percentage":18},"exceeds_200k_tokens":false}
|
{"session_id":"fda34772-7bd2-402e-86b2-d656296416f3","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/fda34772-7bd2-402e-86b2-d656296416f3.jsonl","cwd":"/Users/cillin/workspeace/evotraders","model":{"id":"MiniMax-M2.7-highspeed","display_name":"MiniMax-M2.7-highspeed"},"workspace":{"current_dir":"/Users/cillin/workspeace/evotraders","project_dir":"/Users/cillin/workspeace/evotraders","added_dirs":[]},"version":"2.1.78","output_style":{"name":"default"},"cost":{"total_cost_usd":36.63980749999998,"total_duration_ms":69778027,"total_api_duration_ms":2925118,"total_lines_added":3056,"total_lines_removed":4537},"context_window":{"total_input_tokens":910503,"total_output_tokens":145207,"context_window_size":200000,"current_usage":{"input_tokens":507,"output_tokens":247,"cache_creation_input_tokens":4132,"cache_read_input_tokens":96553},"used_percentage":51,"remaining_percentage":49},"exceeds_200k_tokens":false}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"lastSentAt": "2026-03-19T17:02:32.170Z"
|
"lastSentAt": "2026-03-24T08:58:57.965Z"
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
{
|
{
|
||||||
"agents": [
|
"agents": [
|
||||||
{
|
{
|
||||||
"agent_id": "a8305a91e192b2196",
|
"agent_id": "abeaf609b74a2b7ee",
|
||||||
"agent_type": "Explore",
|
"agent_type": "Explore",
|
||||||
"started_at": "2026-03-19T17:00:33.284Z",
|
"started_at": "2026-03-24T08:01:40.015Z",
|
||||||
"parent_mode": "none",
|
"parent_mode": "none",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"completed_at": "2026-03-19T17:02:19.439Z",
|
"completed_at": "2026-03-24T08:02:31.822Z",
|
||||||
"duration_ms": 106155
|
"duration_ms": 51807
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "afb6750eaae72bc72",
|
||||||
|
"agent_type": "Explore",
|
||||||
|
"started_at": "2026-03-24T08:56:21.471Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-24T08:57:27.856Z",
|
||||||
|
"duration_ms": 66385
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"total_spawned": 1,
|
"total_spawned": 2,
|
||||||
"total_completed": 1,
|
"total_completed": 2,
|
||||||
"total_failed": 0,
|
"total_failed": 0,
|
||||||
"last_updated": "2026-03-19T17:02:39.175Z"
|
"last_updated": "2026-03-24T08:59:06.380Z"
|
||||||
}
|
}
|
||||||
390
CLAUDE.md
390
CLAUDE.md
@@ -1,5 +1,7 @@
|
|||||||
# 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) 在此代码库中工作时提供指导。
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
@@ -23,18 +25,20 @@ evotraders live -t 22:30 # 定时每日交易
|
|||||||
evotraders frontend # 启动可视化界面
|
evotraders frontend # 启动可视化界面
|
||||||
|
|
||||||
# 开发服务器
|
# 开发服务器
|
||||||
./start-dev.sh # 启动全部 4 个微服务
|
./start-dev.sh # 启动全部 4 个微服务 (agent, runtime, trading, news)
|
||||||
|
|
||||||
# 单独启动某个服务
|
# Gateway WebSocket 服务器
|
||||||
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
|
python backend/main.py --mode live --config-name mock --mock
|
||||||
|
|
||||||
|
# 单独启动微服务
|
||||||
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
|
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
|
||||||
|
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
|
||||||
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
|
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
|
||||||
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
|
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
|
||||||
|
|
||||||
# 测试
|
# 测试
|
||||||
pytest backend/tests # 运行全部测试
|
pytest backend/tests # 运行全部测试
|
||||||
pytest backend/tests/test_news_service_app.py -v # 运行单个测试文件
|
pytest backend/tests/test_news_service_app.py -v # 运行单个测试
|
||||||
pytest backend/tests/test_news_service_app.py::test_news_service_routes_are_exposed -v # 运行单个测试
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (React)
|
### Frontend (React)
|
||||||
@@ -46,142 +50,237 @@ npm run build # 生产构建
|
|||||||
npm run lint # ESLint 检查
|
npm run lint # ESLint 检查
|
||||||
npm run lint:fix # ESLint 自动修复
|
npm run lint:fix # ESLint 自动修复
|
||||||
npm run test # Vitest 单元测试
|
npm run test # Vitest 单元测试
|
||||||
npm run test:watch # 监听模式
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 架构概览
|
## 架构概览
|
||||||
|
|
||||||
### 微服务架构 (`backend/apps/`)
|
### 系统分层
|
||||||
|
|
||||||
项目采用 split-first 微服务架构,4 个独立的 FastAPI 服务:
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
| 服务 | 入口 | 端口 | 职责 |
|
│ Frontend (React) │
|
||||||
|------|------|------|------|
|
│ WebSocket ws://localhost:8765 连接 Gateway │
|
||||||
| agent_service | `backend.apps.agent_service:app` | 8000 | Agent 生命周期、工作区管理 |
|
└─────────────────────────────────────────────────────────────┘
|
||||||
| runtime_service | `backend.apps.runtime_service:app` | 8003 | 运行时配置、任务启动 |
|
│
|
||||||
| trading_service | `backend.apps.trading_service:app` | 8001 | 市场数据、交易操作 |
|
▼
|
||||||
| news_service | `backend.apps.news_service:app` | 8002 | 新闻、新闻富化、解释功能 |
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gateway (backend/services/gateway.py) │
|
||||||
服务间通过环境变量通信(详见 `start-dev.sh`):
|
│ WebSocket 服务器,编排 Pipeline,4 阶段启动 │
|
||||||
```bash
|
└─────────────────────────────────────────────────────────────┘
|
||||||
export TRADING_SERVICE_URL=http://localhost:8001
|
│ │ │ │
|
||||||
export NEWS_SERVICE_URL=http://localhost:8002
|
▼ ▼ ▼ ▼
|
||||||
export RUNTIME_SERVICE_URL=http://localhost:8003
|
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||||
|
│ Market │ │ Storage │ │ Pipeline │ │ Scheduler │
|
||||||
|
│ Service │ │ Service │ │ │ │ │
|
||||||
|
└────────────┘ └────────────┘ └────────────┘ └────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────┼──────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ Analysts │ │ PM │ │ Risk │
|
||||||
|
│ (4 个) │ │ │ │ Manager │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gateway 网关 (`backend/services/gateway.py`)
|
### 微服务架构 (`backend/apps/`)
|
||||||
|
|
||||||
Gateway 是统一的请求路由器,根据路径前缀将请求转发到对应的微服务:
|
| 服务 | 端口 | 职责 |
|
||||||
- `/control/*` → agent_service
|
|------|------|------|
|
||||||
- `/runtime/*` → runtime_service
|
| runtime_service | 8003 | 运行时配置、任务启动、Pipeline Runner |
|
||||||
- `/trading/*` → trading_service
|
| agent_service | 8000 | Agent 生命周期、工作区管理 |
|
||||||
- `/news/*` → news_service
|
| trading_service | 8001 | 市场数据、交易操作 |
|
||||||
|
| news_service | 8002 | 新闻、新闻富化、解释功能 |
|
||||||
|
|
||||||
新增接口时应注册到对应的 service app,而非直接添加到 gateway。
|
### Gateway 4 阶段启动 (`backend/services/gateway.py`)
|
||||||
|
|
||||||
### 共享客户端 (`shared/client/`)
|
1. **WebSocket Server** - 前端立即可连接
|
||||||
|
2. **Market Service** - 价格数据开始推送
|
||||||
|
3. **Market Status Monitor** - 市场状态监控
|
||||||
|
4. **Scheduler** - 交易周期开始
|
||||||
|
|
||||||
统一的服务客户端库,所有前端和后端服务间通信都使用此处定义的客户端:
|
### 运行时管理层 (`backend/runtime/`)
|
||||||
|
|
||||||
| 客户端 | 用途 |
|
| 文件 | 职责 |
|
||||||
|--------|------|
|
|------|------|
|
||||||
| `ControlPlaneClient` | Agent 服务通信 |
|
| `manager.py` | TradingRuntimeManager - 全局运行时管理器,agent 注册、会话、事件快照 |
|
||||||
| `RuntimeServiceClient` | 运行时服务通信 |
|
| `agent_runtime.py` | AgentRuntimeState - 单 agent 状态(status、last_session) |
|
||||||
| `TradingServiceClient` | 交易服务通信 |
|
| `context.py` | TradingRunContext - 运行上下文 |
|
||||||
| `NewsServiceClient` | 新闻服务通信 |
|
| `session.py` | TradingSessionKey - 交易日会话键 |
|
||||||
|
| `registry.py` | RuntimeRegistry - agent 状态注册表 |
|
||||||
|
|
||||||
### 领域层 (`backend/domains/`)
|
快照持久化到 `runs/<run_id>/state/runtime_state.json`。
|
||||||
|
|
||||||
业务逻辑按领域分离:
|
### Pipeline 执行 (`backend/core/`)
|
||||||
|
|
||||||
- `news.py` - 新闻领域操作
|
| 文件 | 职责 |
|
||||||
- `trading.py` - 交易领域操作
|
|------|------|
|
||||||
|
| `pipeline.py` | TradingPipeline - 核心编排器(分析→沟通→决策→执行→评估) |
|
||||||
|
| `pipeline_runner.py` | REST API 触发的独立执行,5 阶段启动 |
|
||||||
|
| `scheduler.py` | BacktestScheduler、Scheduler - 回测/实盘调度 |
|
||||||
|
| `state_sync.py` | StateSync - 状态同步和广播 |
|
||||||
|
|
||||||
## 后端结构
|
## 后端结构
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── agents/ # 多智能体实现
|
├── agents/ # 多智能体实现
|
||||||
│ ├── base/ # 核心类、Hooks、评估
|
│ ├── analyst.py # AnalystAgent 基类
|
||||||
│ │ ├── evo_agent.py # 基于 AgentScope 的核心实现
|
│ ├── portfolio_manager.py # PMAgent 投资经理
|
||||||
│ │ ├── hooks.py # 生命周期 Hooks
|
│ ├── risk_manager.py # RiskAgent 风控经理
|
||||||
│ │ │ ├── BootstrapHook # 启动初始化
|
|
||||||
│ │ │ ├── MemoryCompactionHook # 内存压缩(基于 CoPaw)
|
|
||||||
│ │ │ ├── HeartbeatHook # 心跳检测
|
|
||||||
│ │ │ └── WorkspaceWatchHook # 工作区监控
|
|
||||||
│ │ ├── evaluation_hook.py # 执行后评估
|
|
||||||
│ │ ├── skill_adaptation_hook.py # 动态技能适配
|
|
||||||
│ │ └── tool_guard.py # 工具调用守卫
|
|
||||||
│ ├── prompts/ # Agent 提示词和角色定义
|
|
||||||
│ │ ├── analyst/personas.yaml # 分析师角色配置
|
|
||||||
│ │ └── portfolio_manager/
|
|
||||||
│ ├── team/ # 团队协作逻辑
|
|
||||||
│ │ ├── registry.py # Agent 注册表
|
|
||||||
│ │ ├── coordinator.py # 协作协调器
|
|
||||||
│ │ ├── messenger.py # 消息传递
|
|
||||||
│ │ └── task_delegator.py # 任务分发
|
|
||||||
│ ├── factory.py # Agent 实例工厂
|
│ ├── factory.py # Agent 实例工厂
|
||||||
│ ├── skills_manager.py # 技能加载管理(6 种作用域)
|
│ ├── toolkit_factory.py # 工具集工厂
|
||||||
│ └── toolkit_factory.py # 工具集工厂
|
│ ├── skills_manager.py # 技能加载管理
|
||||||
├── apps/ # 微服务入口(split-first)
|
│ ├── workspace_manager.py # 工作区管理
|
||||||
│ ├── agent_service.py
|
│ ├── skill_loader.py # 技能加载器
|
||||||
│ ├── runtime_service.py
|
│ ├── agent_workspace.py # Agent 工作区
|
||||||
│ ├── trading_service.py
|
│ ├── prompt_loader.py # Prompt 加载器
|
||||||
│ └── news_service.py
|
│ ├── prompt_factory.py # Prompt 工厂
|
||||||
|
│ ├── skill_metadata.py # 技能元数据
|
||||||
|
│ ├── registry.py # Agent 注册表
|
||||||
|
│ ├── team_pipeline_config.py # 团队 Pipeline 配置
|
||||||
|
│ ├── compat.py # 兼容性层
|
||||||
|
│ ├── templates.py # 模板
|
||||||
|
│ ├── workspace.py # 工作区
|
||||||
|
│ ├── base/ # 核心类、Hooks
|
||||||
|
│ │ ├── evo_agent.py # 基于 AgentScope 的核心实现
|
||||||
|
│ │ └── hooks.py # 生命周期 Hooks
|
||||||
|
│ └── prompts/ # Agent 提示词
|
||||||
|
│ └── analyst/personas.yaml
|
||||||
|
│
|
||||||
|
├── apps/ # 微服务入口
|
||||||
|
│ ├── runtime_service.py # 运行时服务(端口 8003)
|
||||||
|
│ ├── agent_service.py # Agent 服务(端口 8000)
|
||||||
|
│ ├── trading_service.py # 交易服务(端口 8001)
|
||||||
|
│ ├── news_service.py # 新闻服务(端口 8002)
|
||||||
|
│ └── cors.py
|
||||||
|
│
|
||||||
|
├── runtime/ # 运行时管理层
|
||||||
|
│ ├── manager.py # TradingRuntimeManager
|
||||||
|
│ ├── agent_runtime.py # AgentRuntimeState
|
||||||
|
│ ├── context.py # TradingRunContext
|
||||||
|
│ ├── session.py # TradingSessionKey
|
||||||
|
│ └── registry.py # RuntimeRegistry
|
||||||
|
│
|
||||||
|
├── process/ # 进程监管层
|
||||||
|
│ ├── supervisor.py # ProcessSupervisor
|
||||||
|
│ ├── registry.py # RunRegistry
|
||||||
|
│ └── models.py # ProcessRun、ProcessRunState
|
||||||
|
│
|
||||||
|
├── core/ # Pipeline 执行
|
||||||
|
│ ├── pipeline.py # TradingPipeline(核心编排器)
|
||||||
|
│ ├── pipeline_runner.py # 独立 Pipeline 执行
|
||||||
|
│ ├── scheduler.py # 调度器
|
||||||
|
│ └── state_sync.py # 状态同步
|
||||||
|
│
|
||||||
|
├── services/ # Gateway 和服务
|
||||||
|
│ ├── gateway.py # WebSocket 网关
|
||||||
|
│ ├── gateway_*.py # Gateway 子模块
|
||||||
|
│ ├── market.py # 市场数据服务
|
||||||
|
│ ├── storage.py # 存储服务
|
||||||
|
│ ├── runtime_db.py # 运行时数据库
|
||||||
|
│ └── research_db.py # 研究数据库
|
||||||
|
│
|
||||||
|
├── data/ # 市场数据处理
|
||||||
|
│ ├── provider_router.py # 数据源路由
|
||||||
|
│ ├── provider_utils.py # 数据源工具
|
||||||
|
│ ├── market_store.py # 市场数据存储
|
||||||
|
│ ├── market_ingest.py # 数据采集
|
||||||
|
│ ├── cache.py # 缓存
|
||||||
|
│ ├── schema.py # 数据 schema
|
||||||
|
│ ├── historical_price_manager.py # 历史价格管理
|
||||||
|
│ ├── polling_price_manager.py # 轮询价格管理
|
||||||
|
│ ├── 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/ # 领域业务逻辑
|
├── domains/ # 领域业务逻辑
|
||||||
│ ├── news.py
|
│ ├── news.py
|
||||||
│ └── trading.py
|
│ └── trading.py
|
||||||
├── services/ # Gateway 和辅助服务
|
│
|
||||||
│ ├── gateway.py # 统一路由网关
|
|
||||||
│ ├── gateway_*.py # Gateway 子模块
|
|
||||||
│ └── market.py # 市场数据服务
|
|
||||||
├── api/ # FastAPI 端点
|
|
||||||
├── config/ # 常量和配置
|
|
||||||
│ └── constants.py # Agent 配置、显示名称等
|
|
||||||
├── core/ # Pipeline 执行逻辑
|
|
||||||
├── data/ # 市场数据处理
|
|
||||||
│ ├── provider_router.py # 数据源路由
|
|
||||||
│ └── schema.py # 数据 schema
|
|
||||||
├── enrich/ # LLM 响应富化
|
|
||||||
├── explain/ # 交易决策解释
|
|
||||||
├── llm/ # LLM 集成
|
├── llm/ # LLM 集成
|
||||||
│ └── models.py # RetryChatModel、TokenRecordingModelWrapper
|
│ └── models.py # RetryChatModel、TokenRecordingModelWrapper
|
||||||
├── skills/ # 技能定义(内置 + 自定义)
|
│
|
||||||
|
├── skills/ # 技能定义
|
||||||
├── tools/ # 交易和分析工具
|
├── tools/ # 交易和分析工具
|
||||||
└── utils/ # 工具函数
|
├── enrich/ # LLM 响应富化
|
||||||
|
├── explain/ # 交易决策解释
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
│ ├── settlement.py # 结算协调器
|
||||||
|
│ ├── trade_executor.py # 交易执行器
|
||||||
|
│ ├── terminal_dashboard.py # 终端仪表板
|
||||||
|
│ ├── analyst_tracker.py # 分析师追踪
|
||||||
|
│ ├── baselines.py # 基准线
|
||||||
|
│ ├── msg_adapter.py # 消息适配器
|
||||||
|
│ └── progress.py # 进度追踪
|
||||||
|
│
|
||||||
|
├── api/ # FastAPI 端点
|
||||||
|
│ └── runtime.py
|
||||||
|
│
|
||||||
|
└── main.py # 主入口点
|
||||||
```
|
```
|
||||||
|
|
||||||
## 前端结构
|
## 前端结构
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/
|
frontend/src/
|
||||||
├── App.jsx # React 主应用
|
├── App.jsx # 主应用(LiveTradingApp)
|
||||||
├── components/ # React 组件
|
├── AppShell.jsx # App 外壳(布局、侧边栏)
|
||||||
|
├── components/
|
||||||
│ ├── RuntimeView.jsx # 交易运行时 UI
|
│ ├── RuntimeView.jsx # 交易运行时 UI
|
||||||
│ ├── TraderView.jsx # 交易员界面
|
│ ├── TraderView.jsx # 交易员界面
|
||||||
│ ├── RoomView.jsx # 聊天室视图
|
│ ├── RoomView.jsx # 聊天室视图
|
||||||
│ ├── StockExplainView.jsx # 股票解释视图
|
│ ├── StockExplainView.jsx # 股票解释视图
|
||||||
│ ├── RuntimeSettingsPanel.jsx # 运行时设置面板
|
│ ├── RuntimeSettingsPanel.jsx # 运行时设置面板
|
||||||
|
│ ├── RuntimeLogsModal.jsx # 运行时日志弹窗
|
||||||
│ ├── WatchlistPanel.jsx # 关注列表
|
│ ├── WatchlistPanel.jsx # 关注列表
|
||||||
│ ├── PerformanceView.jsx # 绩效视图
|
│ ├── PerformanceView.jsx # 绩效视图
|
||||||
│ ├── StatisticsView.jsx # 统计视图
|
│ ├── StatisticsView.jsx # 统计视图
|
||||||
│ ├── NetValueChart.jsx # 净值曲线图
|
│ ├── NetValueChart.jsx # 净值曲线图
|
||||||
│ ├── AgentCard.jsx # Agent 卡片
|
│ ├── AgentCard.jsx # Agent 卡片
|
||||||
│ ├── AgentFeed.jsx # Agent 动态
|
│ ├── AgentFeed.jsx # Agent 动态
|
||||||
│ └── explain/ # 解释相关组件
|
│ ├── Header.jsx # 头部
|
||||||
|
│ ├── MarkdownModal.jsx # Markdown 弹窗
|
||||||
|
│ ├── StockLogo.jsx # 股票 Logo
|
||||||
|
│ └── explain/ # 解释组件
|
||||||
│ ├── ExplainNewsSection.jsx
|
│ ├── ExplainNewsSection.jsx
|
||||||
│ ├── ExplainRangeSection.jsx
|
│ ├── ExplainRangeSection.jsx
|
||||||
│ ├── ExplainSimilarDaysSection.jsx
|
│ ├── ExplainSimilarDaysSection.jsx
|
||||||
│ ├── ExplainStorySection.jsx
|
│ ├── ExplainStorySection.jsx
|
||||||
│ └── useExplainModel.js
|
│ └── useExplainModel.js
|
||||||
├── services/ # API 服务
|
├── hooks/ # React Hooks
|
||||||
│ ├── runtimeApi.js # 运行时 API 调用
|
│ ├── useWebSocketConnection.js # WebSocket 连接管理
|
||||||
│ ├── websocket.js # WebSocket 实时通信
|
│ ├── useRuntimeControls.js # 运行时配置管理
|
||||||
│ ├── newsApi.js # 新闻服务客户端
|
│ ├── useAgentDataRequests.js # Agent 数据请求
|
||||||
│ └── tradingApi.js # 交易服务客户端
|
│ ├── useStockDataRequests.js # 股票数据请求
|
||||||
├── config/
|
│ ├── useStockExplainData.js # 股票解释数据
|
||||||
│ └── constants.js # Agent 定义、配置
|
│ ├── useAgentWorkspacePanel.js # Agent 工作区面板
|
||||||
└── hooks/ # React Hooks
|
│ ├── useWebsocketSessionSync.js # WebSocket 会话同步
|
||||||
|
│ └── useFeedProcessor.js # Feed 事件处理
|
||||||
|
├── store/ # Zustand 状态管理
|
||||||
|
│ ├── runtimeStore.js # 连接状态、运行时配置
|
||||||
|
│ ├── marketStore.js # 市场数据、股票价格
|
||||||
|
│ ├── portfolioStore.js # 组合、持仓、交易
|
||||||
|
│ ├── agentStore.js # Agent 技能、工作区
|
||||||
|
│ └── uiStore.js # UI 状态、视图切换
|
||||||
|
├── services/
|
||||||
|
│ ├── websocket.js # WebSocket 客户端
|
||||||
|
│ ├── runtimeApi.js # 运行时 API
|
||||||
|
│ ├── runtimeControls.js # 运行时控制
|
||||||
|
│ ├── newsApi.js # 新闻 API
|
||||||
|
│ └── tradingApi.js # 交易 API
|
||||||
|
├── utils/
|
||||||
|
│ ├── formatters.js # 格式化工具
|
||||||
|
│ └── modelIcons.js # 模型图标
|
||||||
|
└── config/
|
||||||
|
└── constants.js # Agent 定义、配置
|
||||||
```
|
```
|
||||||
|
|
||||||
## Agent 系统
|
## Agent 系统
|
||||||
@@ -195,108 +294,85 @@ frontend/src/
|
|||||||
| `sentiment_analyst` | 情绪分析师 | 市场情绪、新闻情绪、内幕交易 |
|
| `sentiment_analyst` | 情绪分析师 | 市场情绪、新闻情绪、内幕交易 |
|
||||||
| `valuation_analyst` | 估值分析师 | DCF、EV/EBITDA、intrinsic value |
|
| `valuation_analyst` | 估值分析师 | DCF、EV/EBITDA、intrinsic value |
|
||||||
| `portfolio_manager` | 投资经理 | 决策执行、交易协调 |
|
| `portfolio_manager` | 投资经理 | 决策执行、交易协调 |
|
||||||
| `risk_manager` | 风控经理 | 实时价格/波动率监控、仓位限制、多层风险预警 |
|
| `risk_manager` | 风控经理 | 实时价格/波动率监控、仓位限制 |
|
||||||
|
|
||||||
### Hook 系统 (`base/hooks.py`)
|
|
||||||
|
|
||||||
- **MemoryCompactionHook**: 基于 CoPaw 的内存压缩
|
|
||||||
- `memory_compact_ratio`: 压缩目标比例(默认 0.75)
|
|
||||||
- `memory_reserve_ratio`: 保留比例(默认 0.1)
|
|
||||||
- `enable_tool_result_compact`: 工具结果压缩
|
|
||||||
- `tool_result_compact_keep_n`: 保留最近 N 条工具结果
|
|
||||||
|
|
||||||
### 添加自定义分析师
|
### 添加自定义分析师
|
||||||
|
|
||||||
1. 在 `backend/agents/prompts/analyst/personas.yaml` 注册
|
1. `backend/agents/prompts/analyst/personas.yaml` 注册
|
||||||
2. 在 `backend/config/constants.py` 的 `ANALYST_TYPES` 字典中添加
|
2. `backend/config/constants.py` 的 `ANALYST_TYPES` 字典添加
|
||||||
3. 可选:在 `frontend/src/config/constants.js` 中更新前端配置
|
3. `frontend/src/config/constants.js` 可选更新
|
||||||
|
|
||||||
### LLM 模型封装 (`backend/llm/models.py`)
|
### LLM 模型封装 (`backend/llm/models.py`)
|
||||||
|
|
||||||
基于 CoPaw 的模型封装设计:
|
- **RetryChatModel**: 自动重试瞬态 LLM 错误,指数退避
|
||||||
|
- **TokenRecordingModelWrapper**: 追踪 token 消耗和成本
|
||||||
- **RetryChatModel**: 自动重试瞬态 LLM 错误(rate limit、timeout、502/503 等),指数退避
|
|
||||||
- `max_retries`: 最大重试次数(默认 3)
|
|
||||||
- `initial_delay`: 初始延迟秒数(默认 1.0)
|
|
||||||
- `backoff_multiplier`: 退避倍数(默认 2.0)
|
|
||||||
|
|
||||||
- **TokenRecordingModelWrapper**: 追踪每个 provider 的 token 消耗和成本
|
|
||||||
|
|
||||||
```python
|
|
||||||
from backend.llm.models import create_model, RetryChatModel
|
|
||||||
|
|
||||||
model = RetryChatModel(create_model("gpt-4o", "OPENAI"), max_retries=3)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技能系统 (`backend/skills/`)
|
## 技能系统 (`backend/skills/`)
|
||||||
|
|
||||||
技能定义在 `SKILL.md` 文件中,包含:
|
技能定义在 `SKILL.md`,包含 `instructions`、`triggers`、`parameters`、`available_tools`。
|
||||||
- `instructions` - 技能说明
|
|
||||||
- `triggers` - 触发条件
|
|
||||||
- `parameters` - 输入/输出 schema
|
|
||||||
- `available_tools` - 技能可使用的工具
|
|
||||||
|
|
||||||
技能由 `skills_manager.py` 加载,通过 `skill_adaptation_hook.py` 绑定到 Agent。
|
|
||||||
|
|
||||||
技能管理器支持 6 种作用域:builtin、customized、installed、active、disabled、local。
|
技能管理器支持 6 种作用域:builtin、customized、installed、active、disabled、local。
|
||||||
|
|
||||||
## Pipeline 执行 (`backend/core/`)
|
## 运行时数据布局
|
||||||
|
|
||||||
每日交易流程:
|
- `data/market_research.db` - 持久研究数据
|
||||||
|
- `runs/<run_id>/` - 每次任务运行的状态
|
||||||
1. **分析阶段** - 各 Agent 基于工具和历史经验独立分析
|
- `runs/<run_id>/team_dashboard/*.json` - 仪表板导出层(非权威源)
|
||||||
2. **沟通阶段** - 通过私聊、通知、会议等方式交换观点(1v1/1vN/NvN)
|
- `runs/<run_id>/state/runtime_state.json` - 运行时快照
|
||||||
3. **决策阶段** - 投资经理综合判断,给出最终交易
|
- 运行时 API 优先使用 `server_state.json` 和 `runtime.db`
|
||||||
4. **评估阶段** - 绩效跟踪
|
|
||||||
5. **复盘阶段** - Agent 根据当日实际收益反思总结,通过 ReMe 记忆框架更新经验
|
|
||||||
|
|
||||||
## 前端状态管理
|
|
||||||
|
|
||||||
项目正在向 Zustand 状态管理过渡,已创建的 store:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
frontend/src/store/
|
RUNS_RETENTION_COUNT=20 # 时间戳格式文件夹自动清理
|
||||||
├── index.js # 导出所有 store
|
|
||||||
├── runtimeStore.js # 连接状态、运行时配置
|
|
||||||
├── marketStore.js # 市场数据、股票价格
|
|
||||||
├── portfolioStore.js # 组合、持仓、交易
|
|
||||||
├── agentStore.js # Agent 技能、工作区
|
|
||||||
└── uiStore.js # UI 状态、视图切换
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**迁移状态**:
|
|
||||||
- Stores 已创建但尚未在 App.jsx 中使用
|
|
||||||
- 计划:逐步迁移 60+ 个 useState 到对应 store
|
|
||||||
|
|
||||||
## 环境配置
|
## 环境配置
|
||||||
|
|
||||||
`.env` 必需配置:
|
### Backend (`env.template`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 金融数据源
|
# 金融数据源(支持多源fallback)
|
||||||
FIN_DATA_SOURCE=finnhub|financial_datasets
|
FIN_DATA_SOURCE=finnhub|financial_datasets|yfinance|local_csv
|
||||||
|
ENABLED_DATA_SOURCES=financial_datasets,finnhub,yfinance,local_csv
|
||||||
FINANCIAL_DATASETS_API_KEY= # 回测必需
|
FINANCIAL_DATASETS_API_KEY= # 回测必需
|
||||||
FINNHUB_API_KEY= # 实盘必需
|
FINNHUB_API_KEY= # 实盘必需
|
||||||
|
POLYGON_API_KEY= # Polygon市场库采集可选
|
||||||
|
|
||||||
# Agent LLM
|
# LLM 配置
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
OPENAI_BASE_URL=
|
||||||
MODEL_NAME=qwen3-max-preview
|
MODEL_NAME=qwen3-max-preview
|
||||||
|
|
||||||
# 可为不同 Agent 指定不同模型
|
# Agent 特定模型
|
||||||
AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
|
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp
|
||||||
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=deepseek-chat
|
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6
|
||||||
|
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview
|
||||||
|
AGENT_VALUATION_ANALYST_MODEL_NAME=Moonshot-Kimi-K2-Instruct
|
||||||
|
AGENT_RISK_MANAGER_MODEL_NAME=qwen3-max-preview
|
||||||
|
AGENT_PORTFOLIO_MANAGER_MODEL_NAME=qwen3-max-preview
|
||||||
|
|
||||||
# ReMe 记忆系统
|
# ReMe 记忆系统
|
||||||
MEMORY_API_KEY=
|
MEMORY_API_KEY=
|
||||||
|
MEMORY_MODEL_NAME=qwen3-max
|
||||||
|
MEMORY_EMBEDDING_MODEL=text-embedding-v4
|
||||||
|
|
||||||
|
# 交易参数
|
||||||
|
MAX_COMM_CYCLES=2
|
||||||
|
MARGIN_REQUIREMENT=0.5
|
||||||
|
DATA_START_DATE=2022-01-01
|
||||||
|
AUTO_UPDATE_DATA=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (`frontend/env.template`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_WS_URL=ws://localhost:8765
|
||||||
```
|
```
|
||||||
|
|
||||||
## 关键依赖
|
## 关键依赖
|
||||||
|
|
||||||
- **AgentScope** - 多智能体框架
|
- **AgentScope** - 多智能体框架
|
||||||
- **ReMe** - 持续学习记忆系统
|
- **ReMe** - 持续学习记忆系统
|
||||||
- **FastAPI** + **uvicorn** - 后端 API 服务器
|
- **FastAPI** + **uvicorn** - 后端 API
|
||||||
- **websockets** - 实时通信
|
- **websockets** - 实时通信
|
||||||
- **React 19** + **Vite** + **TailwindCSS** - 前端
|
- **React 19** + **Vite** + **TailwindCSS** - 前端
|
||||||
- **React Context** - 前端状态管理(App.jsx 中使用 useState + useCallback)
|
- **Zustand** - 状态管理
|
||||||
- **Three.js** / **React-Three-Fiber** - 3D 可视化
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -110,6 +110,21 @@ evotraders frontend # Default connects to port 8765, you can modi
|
|||||||
|
|
||||||
Visit `http://localhost:5173/` to view the trading room, select a date and click Run/Replay to observe the decision-making process.
|
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
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class SkillsManager:
|
|||||||
)
|
)
|
||||||
self.runs_root = self.project_root / "runs"
|
self.runs_root = self.project_root / "runs"
|
||||||
self._lock = Lock()
|
self._lock = Lock()
|
||||||
|
# Instance-level pending skill changes (thread-safe via self._lock)
|
||||||
|
self._pending_skill_changes: Dict[str, Set[Path]] = {}
|
||||||
|
|
||||||
def get_active_root(self, config_name: str) -> Path:
|
def get_active_root(self, config_name: str) -> Path:
|
||||||
return self.runs_root / config_name / "skills" / "active"
|
return self.runs_root / config_name / "skills" / "active"
|
||||||
@@ -739,7 +741,7 @@ class SkillsManager:
|
|||||||
if local_root.exists():
|
if local_root.exists():
|
||||||
watched_paths.append(local_root)
|
watched_paths.append(local_root)
|
||||||
|
|
||||||
handler = _SkillsChangeHandler(watched_paths, callback, self._lock)
|
handler = _SkillsChangeHandler(watched_paths, self._pending_skill_changes, callback, self._lock)
|
||||||
observer = Observer()
|
observer = Observer()
|
||||||
for path in watched_paths:
|
for path in watched_paths:
|
||||||
observer.schedule(handler, str(path), recursive=True)
|
observer.schedule(handler, str(path), recursive=True)
|
||||||
@@ -773,6 +775,7 @@ class SkillsManager:
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Internal change-tracking state (populated by _SkillsChangeHandler)
|
# Internal change-tracking state (populated by _SkillsChangeHandler)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
# Legacy class-level reference kept for migration compatibility
|
||||||
_pending_skill_changes: Dict[str, Set[Path]] = {}
|
_pending_skill_changes: Dict[str, Set[Path]] = {}
|
||||||
|
|
||||||
def _resolve_disabled_skill_names(
|
def _resolve_disabled_skill_names(
|
||||||
@@ -824,11 +827,13 @@ class _SkillsChangeHandler(FileSystemEventHandler):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
watched_paths: List[Path],
|
watched_paths: List[Path],
|
||||||
|
pending_changes: Dict[str, Set[Path]],
|
||||||
callback: Optional[Any] = None,
|
callback: Optional[Any] = None,
|
||||||
lock: Optional[Lock] = None,
|
lock: Optional[Lock] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._watched_paths = watched_paths
|
self._watched_paths = watched_paths
|
||||||
|
self._pending_changes = pending_changes
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
self._lock = lock
|
self._lock = lock
|
||||||
|
|
||||||
@@ -841,13 +846,9 @@ class _SkillsChangeHandler(FileSystemEventHandler):
|
|||||||
run_id = self._run_id_from_path(src_path)
|
run_id = self._run_id_from_path(src_path)
|
||||||
if self._lock:
|
if self._lock:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
SkillsManager._pending_skill_changes.setdefault(
|
self._pending_changes.setdefault(run_id, set()).add(src_path)
|
||||||
run_id, set()
|
|
||||||
).add(src_path)
|
|
||||||
else:
|
else:
|
||||||
SkillsManager._pending_skill_changes.setdefault(
|
self._pending_changes.setdefault(run_id, set()).add(src_path)
|
||||||
run_id, set()
|
|
||||||
).add(src_path)
|
|
||||||
if self._callback:
|
if self._callback:
|
||||||
self._callback([src_path])
|
self._callback([src_path])
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -129,6 +129,33 @@ 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,
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ from typing import Any, Dict, List, Optional
|
|||||||
from fastapi import APIRouter, HTTPException, Depends, Body, UploadFile, File, Form
|
from fastapi import APIRouter, HTTPException, Depends, Body, UploadFile, File, Form
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from backend.agents import AgentFactory, WorkspaceManager, get_registry
|
from backend.agents import AgentFactory, get_registry
|
||||||
|
from backend.agents.workspace_manager import RunWorkspaceManager
|
||||||
|
from backend.agents.agent_workspace import load_agent_workspace_config
|
||||||
from backend.agents.skills_manager import SkillsManager
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
from backend.agents.toolkit_factory import load_agent_profiles
|
||||||
|
from backend.config.bootstrap_config import get_bootstrap_config_for_run
|
||||||
|
from backend.llm.models import get_agent_model_info
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -47,6 +52,14 @@ class InstallExternalSkillRequest(BaseModel):
|
|||||||
activate: bool = Field(True, description="Whether to enable skill immediately")
|
activate: bool = Field(True, description="Whether to enable skill immediately")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSkillRequest(BaseModel):
|
||||||
|
skill_name: str = Field(..., description="Local skill name")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSkillContentRequest(BaseModel):
|
||||||
|
content: str = Field(..., description="Updated SKILL.md content")
|
||||||
|
|
||||||
|
|
||||||
class AgentResponse(BaseModel):
|
class AgentResponse(BaseModel):
|
||||||
"""Agent information response."""
|
"""Agent information response."""
|
||||||
agent_id: str
|
agent_id: str
|
||||||
@@ -63,6 +76,24 @@ class AgentFileResponse(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentProfileResponse(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
workspace_id: str
|
||||||
|
profile: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSkillsResponse(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
workspace_id: str
|
||||||
|
skills: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDetailResponse(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
workspace_id: str
|
||||||
|
skill: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
def get_agent_factory():
|
def get_agent_factory():
|
||||||
"""Get AgentFactory instance."""
|
"""Get AgentFactory instance."""
|
||||||
@@ -70,8 +101,8 @@ def get_agent_factory():
|
|||||||
|
|
||||||
|
|
||||||
def get_workspace_manager():
|
def get_workspace_manager():
|
||||||
"""Get WorkspaceManager instance."""
|
"""Get run-scoped workspace manager instance."""
|
||||||
return WorkspaceManager()
|
return RunWorkspaceManager()
|
||||||
|
|
||||||
|
|
||||||
def get_skills_manager():
|
def get_skills_manager():
|
||||||
@@ -199,6 +230,108 @@ async def get_agent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/profile", response_model=AgentProfileResponse)
|
||||||
|
async def get_agent_profile(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skills_manager: SkillsManager = Depends(get_skills_manager),
|
||||||
|
):
|
||||||
|
asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id)
|
||||||
|
agent_config = load_agent_workspace_config(asset_dir / "agent.yaml")
|
||||||
|
profiles = load_agent_profiles()
|
||||||
|
profile = profiles.get(agent_id, {})
|
||||||
|
bootstrap = get_bootstrap_config_for_run(skills_manager.project_root, workspace_id)
|
||||||
|
override = bootstrap.agent_override(agent_id)
|
||||||
|
active_tool_groups = override.get("active_tool_groups", agent_config.active_tool_groups or profile.get("active_tool_groups", []))
|
||||||
|
if not isinstance(active_tool_groups, list):
|
||||||
|
active_tool_groups = []
|
||||||
|
disabled_tool_groups = agent_config.disabled_tool_groups
|
||||||
|
if disabled_tool_groups:
|
||||||
|
disabled_set = set(disabled_tool_groups)
|
||||||
|
active_tool_groups = [group_name for group_name in active_tool_groups if group_name not in disabled_set]
|
||||||
|
|
||||||
|
default_skills = profile.get("skills", [])
|
||||||
|
if not isinstance(default_skills, list):
|
||||||
|
default_skills = []
|
||||||
|
resolved_skills = skills_manager.resolve_agent_skill_names(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
default_skills=default_skills,
|
||||||
|
)
|
||||||
|
prompt_files = agent_config.prompt_files or ["SOUL.md", "PROFILE.md", "AGENTS.md", "POLICY.md", "MEMORY.md"]
|
||||||
|
model_name, model_provider = get_agent_model_info(agent_id)
|
||||||
|
|
||||||
|
return AgentProfileResponse(
|
||||||
|
agent_id=agent_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
profile={
|
||||||
|
"model_name": model_name,
|
||||||
|
"model_provider": model_provider,
|
||||||
|
"prompt_files": prompt_files,
|
||||||
|
"default_skills": default_skills,
|
||||||
|
"resolved_skills": resolved_skills,
|
||||||
|
"active_tool_groups": active_tool_groups,
|
||||||
|
"disabled_tool_groups": disabled_tool_groups,
|
||||||
|
"enabled_skills": agent_config.enabled_skills,
|
||||||
|
"disabled_skills": agent_config.disabled_skills,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/skills", response_model=AgentSkillsResponse)
|
||||||
|
async def get_agent_skills(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skills_manager: SkillsManager = Depends(get_skills_manager),
|
||||||
|
):
|
||||||
|
agent_asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id)
|
||||||
|
agent_config = load_agent_workspace_config(agent_asset_dir / "agent.yaml")
|
||||||
|
resolved_skills = set(skills_manager.resolve_agent_skill_names(config_name=workspace_id, agent_id=agent_id, default_skills=[]))
|
||||||
|
enabled = set(agent_config.enabled_skills)
|
||||||
|
disabled = set(agent_config.disabled_skills)
|
||||||
|
|
||||||
|
payload = []
|
||||||
|
for item in skills_manager.list_agent_skill_catalog(workspace_id, agent_id):
|
||||||
|
if item.skill_name in disabled:
|
||||||
|
status = "disabled"
|
||||||
|
elif item.skill_name in enabled:
|
||||||
|
status = "enabled"
|
||||||
|
elif item.skill_name in resolved_skills:
|
||||||
|
status = "active"
|
||||||
|
else:
|
||||||
|
status = "available"
|
||||||
|
payload.append({
|
||||||
|
"skill_name": item.skill_name,
|
||||||
|
"name": item.name,
|
||||||
|
"description": item.description,
|
||||||
|
"version": item.version,
|
||||||
|
"source": item.source,
|
||||||
|
"tools": item.tools,
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
|
||||||
|
return AgentSkillsResponse(agent_id=agent_id, workspace_id=workspace_id, skills=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/skills/{skill_name}", response_model=SkillDetailResponse)
|
||||||
|
async def get_agent_skill_detail(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skill_name: str,
|
||||||
|
skills_manager: SkillsManager = Depends(get_skills_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
detail = skills_manager.load_agent_skill_document(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown skill: {skill_name}")
|
||||||
|
|
||||||
|
return SkillDetailResponse(agent_id=agent_id, workspace_id=workspace_id, skill=detail)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{agent_id}")
|
@router.delete("/{agent_id}")
|
||||||
async def delete_agent(
|
async def delete_agent(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -386,6 +519,85 @@ async def install_external_skill(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{agent_id}/skills/local")
|
||||||
|
async def create_local_skill(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
request: LocalSkillRequest,
|
||||||
|
registry=Depends(get_registry),
|
||||||
|
):
|
||||||
|
agent_info = registry.get(agent_id)
|
||||||
|
if not agent_info or agent_info.workspace_id != workspace_id:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
skills_manager = SkillsManager()
|
||||||
|
try:
|
||||||
|
skills_manager.create_agent_local_skill(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_name=request.skill_name,
|
||||||
|
)
|
||||||
|
except (ValueError, FileExistsError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
return {"message": f"Created local skill '{request.skill_name}' for '{agent_id}'"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{agent_id}/skills/local/{skill_name}")
|
||||||
|
async def update_local_skill(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skill_name: str,
|
||||||
|
request: LocalSkillContentRequest,
|
||||||
|
registry=Depends(get_registry),
|
||||||
|
):
|
||||||
|
agent_info = registry.get(agent_id)
|
||||||
|
if not agent_info or agent_info.workspace_id != workspace_id:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
skills_manager = SkillsManager()
|
||||||
|
try:
|
||||||
|
skills_manager.update_agent_local_skill(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
content=request.content,
|
||||||
|
)
|
||||||
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
return {"message": f"Updated local skill '{skill_name}' for '{agent_id}'"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{agent_id}/skills/local/{skill_name}")
|
||||||
|
async def delete_local_skill(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
skill_name: str,
|
||||||
|
registry=Depends(get_registry),
|
||||||
|
):
|
||||||
|
agent_info = registry.get(agent_id)
|
||||||
|
if not agent_info or agent_info.workspace_id != workspace_id:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
skills_manager = SkillsManager()
|
||||||
|
try:
|
||||||
|
skills_manager.delete_agent_local_skill(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
)
|
||||||
|
skills_manager.forget_agent_skill_overrides(
|
||||||
|
config_name=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
skill_names=[skill_name],
|
||||||
|
)
|
||||||
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
return {"message": f"Deleted local skill '{skill_name}' for '{agent_id}'"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{agent_id}/skills/upload")
|
@router.post("/{agent_id}/skills/upload")
|
||||||
async def upload_external_skill(
|
async def upload_external_skill(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -441,7 +653,7 @@ async def get_agent_file(
|
|||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
workspace_manager: WorkspaceManager = Depends(get_workspace_manager),
|
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Read an agent's workspace file.
|
Read an agent's workspace file.
|
||||||
@@ -471,7 +683,7 @@ async def update_agent_file(
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
content: str = Body(..., media_type="text/plain"),
|
content: str = Body(..., media_type="text/plain"),
|
||||||
workspace_manager: WorkspaceManager = Depends(get_workspace_manager),
|
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update an agent's workspace file.
|
Update an agent's workspace file.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -38,9 +39,10 @@ class RuntimeState:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_instance: Optional["RuntimeState"] = None
|
_instance: Optional["RuntimeState"] = None
|
||||||
_lock: asyncio.Lock = asyncio.Lock()
|
_lock: "threading.Lock" = __import__("threading").Lock()
|
||||||
|
|
||||||
def __new__(cls) -> "RuntimeState":
|
def __new__(cls) -> "RuntimeState":
|
||||||
|
with cls._lock:
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super().__new__(cls)
|
cls._instance = super().__new__(cls)
|
||||||
cls._instance._initialized = False
|
cls._instance._initialized = False
|
||||||
@@ -165,6 +167,8 @@ class RuntimeEventsResponse(BaseModel):
|
|||||||
|
|
||||||
class LaunchConfig(BaseModel):
|
class LaunchConfig(BaseModel):
|
||||||
"""Configuration for launching a new trading task."""
|
"""Configuration for launching a new trading task."""
|
||||||
|
launch_mode: str = Field(default="fresh", description="启动形式: fresh, restore")
|
||||||
|
restore_run_id: Optional[str] = Field(default=None, description="历史任务 run_id,用于恢复启动")
|
||||||
tickers: List[str] = Field(default_factory=list, description="股票池")
|
tickers: List[str] = Field(default_factory=list, description="股票池")
|
||||||
schedule_mode: str = Field(default="daily", description="调度模式: daily, interval")
|
schedule_mode: str = Field(default="daily", description="调度模式: daily, interval")
|
||||||
interval_minutes: int = Field(default=60, ge=1, description="间隔分钟数")
|
interval_minutes: int = Field(default=60, ge=1, description="间隔分钟数")
|
||||||
@@ -177,7 +181,6 @@ class LaunchConfig(BaseModel):
|
|||||||
start_date: Optional[str] = Field(default=None, description="回测开始日期 YYYY-MM-DD")
|
start_date: Optional[str] = Field(default=None, description="回测开始日期 YYYY-MM-DD")
|
||||||
end_date: Optional[str] = Field(default=None, description="回测结束日期 YYYY-MM-DD")
|
end_date: Optional[str] = Field(default=None, description="回测结束日期 YYYY-MM-DD")
|
||||||
poll_interval: int = Field(default=10, ge=1, le=300, description="市场数据轮询间隔(秒)")
|
poll_interval: int = Field(default=10, ge=1, le=300, description="市场数据轮询间隔(秒)")
|
||||||
enable_mock: bool = Field(default=False, description="是否启用模拟模式(使用模拟价格数据)")
|
|
||||||
|
|
||||||
|
|
||||||
class LaunchResponse(BaseModel):
|
class LaunchResponse(BaseModel):
|
||||||
@@ -188,11 +191,30 @@ class LaunchResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHistoryItem(BaseModel):
|
||||||
|
run_id: str
|
||||||
|
run_dir: str
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
total_trades: int = 0
|
||||||
|
total_asset_value: Optional[float] = None
|
||||||
|
bootstrap: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHistoryResponse(BaseModel):
|
||||||
|
runs: List[RuntimeHistoryItem]
|
||||||
|
|
||||||
|
|
||||||
class StopResponse(BaseModel):
|
class StopResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
kept: int
|
||||||
|
pruned_run_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
class GatewayStatusResponse(BaseModel):
|
class GatewayStatusResponse(BaseModel):
|
||||||
is_running: bool
|
is_running: bool
|
||||||
port: int
|
port: int
|
||||||
@@ -207,6 +229,13 @@ class RuntimeConfigResponse(BaseModel):
|
|||||||
resolved: Dict[str, Any]
|
resolved: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeLogResponse(BaseModel):
|
||||||
|
run_id: Optional[str] = None
|
||||||
|
is_running: bool
|
||||||
|
log_path: Optional[str] = None
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
|
||||||
class UpdateRuntimeConfigRequest(BaseModel):
|
class UpdateRuntimeConfigRequest(BaseModel):
|
||||||
schedule_mode: Optional[str] = None
|
schedule_mode: Optional[str] = None
|
||||||
interval_minutes: Optional[int] = Field(default=None, ge=1)
|
interval_minutes: Optional[int] = Field(default=None, ge=1)
|
||||||
@@ -227,6 +256,128 @@ def _get_run_dir(run_id: str) -> Path:
|
|||||||
return PROJECT_ROOT / "runs" / run_id
|
return PROJECT_ROOT / "runs" / run_id
|
||||||
|
|
||||||
|
|
||||||
|
def _load_run_snapshot(run_id: str) -> Dict[str, Any]:
|
||||||
|
"""Load a specific run snapshot by run_id."""
|
||||||
|
snapshot_path = _get_run_dir(run_id) / "state" / "runtime_state.json"
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Run snapshot not found: {run_id}")
|
||||||
|
return json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_path_if_exists(src: Path, dst: Path) -> None:
|
||||||
|
if not src.exists():
|
||||||
|
return
|
||||||
|
if src.is_dir():
|
||||||
|
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_run_assets(source_run_id: str, target_run_dir: Path) -> None:
|
||||||
|
"""Seed a fresh run directory from a historical run snapshot."""
|
||||||
|
source_run_dir = _get_run_dir(source_run_id)
|
||||||
|
if not source_run_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Source run not found: {source_run_id}")
|
||||||
|
|
||||||
|
for relative in [
|
||||||
|
"team_dashboard",
|
||||||
|
"agents",
|
||||||
|
"skills",
|
||||||
|
"memory",
|
||||||
|
"state/server_state.json",
|
||||||
|
"state/runtime.db",
|
||||||
|
"state/research.db",
|
||||||
|
]:
|
||||||
|
_copy_path_if_exists(source_run_dir / relative, target_run_dir / relative)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_runs(limit: int = 50) -> list[RuntimeHistoryItem]:
|
||||||
|
runs_root = PROJECT_ROOT / "runs"
|
||||||
|
if not runs_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
items: list[RuntimeHistoryItem] = []
|
||||||
|
run_dirs = sorted(
|
||||||
|
[path for path in runs_root.iterdir() if path.is_dir()],
|
||||||
|
key=lambda path: path.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for run_dir in run_dirs[: max(1, int(limit))]:
|
||||||
|
run_id = run_dir.name
|
||||||
|
runtime_state_path = run_dir / "state" / "runtime_state.json"
|
||||||
|
summary_path = run_dir / "team_dashboard" / "summary.json"
|
||||||
|
|
||||||
|
bootstrap: Dict[str, Any] = {}
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
total_trades = 0
|
||||||
|
total_asset_value: Optional[float] = None
|
||||||
|
|
||||||
|
if runtime_state_path.exists():
|
||||||
|
try:
|
||||||
|
snapshot = json.loads(runtime_state_path.read_text(encoding="utf-8"))
|
||||||
|
context = snapshot.get("context") or {}
|
||||||
|
bootstrap = dict(context.get("bootstrap_values") or {})
|
||||||
|
updated_at = snapshot.get("events", [{}])[-1].get("timestamp") if snapshot.get("events") else None
|
||||||
|
except Exception:
|
||||||
|
bootstrap = {}
|
||||||
|
|
||||||
|
if summary_path.exists():
|
||||||
|
try:
|
||||||
|
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||||
|
total_trades = int(summary.get("totalTrades") or 0)
|
||||||
|
total_asset_value = float(summary.get("totalAssetValue")) if summary.get("totalAssetValue") is not None else None
|
||||||
|
except Exception:
|
||||||
|
total_trades = 0
|
||||||
|
total_asset_value = None
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
RuntimeHistoryItem(
|
||||||
|
run_id=run_id,
|
||||||
|
run_dir=str(run_dir),
|
||||||
|
updated_at=updated_at,
|
||||||
|
total_trades=total_trades,
|
||||||
|
total_asset_value=total_asset_value,
|
||||||
|
bootstrap=bootstrap,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _is_timestamped_run_dir(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
datetime.strptime(path.name, "%Y%m%d_%H%M%S")
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_old_timestamped_runs(*, keep: int = 20, exclude_run_ids: Optional[set[str]] = None) -> list[str]:
|
||||||
|
"""Prune old timestamped run directories, preserving the newest N and excluded ids."""
|
||||||
|
exclude = exclude_run_ids or set()
|
||||||
|
runs_root = PROJECT_ROOT / "runs"
|
||||||
|
if not runs_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates = sorted(
|
||||||
|
[
|
||||||
|
path
|
||||||
|
for path in runs_root.iterdir()
|
||||||
|
if path.is_dir() and _is_timestamped_run_dir(path) and path.name not in exclude
|
||||||
|
],
|
||||||
|
key=lambda path: path.name,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pruned: list[str] = []
|
||||||
|
for path in candidates[max(0, keep):]:
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
pruned.append(path.name)
|
||||||
|
return pruned
|
||||||
|
|
||||||
|
|
||||||
def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int:
|
def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int:
|
||||||
"""Find an available port for Gateway."""
|
"""Find an available port for Gateway."""
|
||||||
import socket
|
import socket
|
||||||
@@ -288,29 +439,29 @@ def _start_gateway_process(
|
|||||||
"--bootstrap", json.dumps(bootstrap)
|
"--bootstrap", json.dumps(bootstrap)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Start process
|
log_path = run_dir / "logs" / "gateway.log"
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
log_file = log_path.open("ab")
|
||||||
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
env=env,
|
env=env,
|
||||||
stdout=subprocess.PIPE,
|
stdout=log_file,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.STDOUT,
|
||||||
cwd=PROJECT_ROOT
|
cwd=PROJECT_ROOT
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
log_file.close()
|
||||||
|
|
||||||
return process
|
return process
|
||||||
|
|
||||||
|
|
||||||
@router.get("/context", response_model=RunContextResponse)
|
@router.get("/context", response_model=RunContextResponse)
|
||||||
async def get_run_context() -> RunContextResponse:
|
async def get_run_context() -> RunContextResponse:
|
||||||
"""Return the most recent run context."""
|
"""Return active runtime context, or latest persisted context when stopped."""
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
context = snapshot.get("context")
|
||||||
|
|
||||||
if not snapshots:
|
|
||||||
raise HTTPException(status_code=404, detail="No run context available")
|
|
||||||
|
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
|
||||||
context = latest.get("context")
|
|
||||||
if context is None:
|
if context is None:
|
||||||
raise HTTPException(status_code=404, detail="Run context is not ready")
|
raise HTTPException(status_code=404, detail="Run context is not ready")
|
||||||
|
|
||||||
@@ -323,15 +474,9 @@ async def get_run_context() -> RunContextResponse:
|
|||||||
|
|
||||||
@router.get("/agents", response_model=RuntimeAgentsResponse)
|
@router.get("/agents", response_model=RuntimeAgentsResponse)
|
||||||
async def get_runtime_agents() -> RuntimeAgentsResponse:
|
async def get_runtime_agents() -> RuntimeAgentsResponse:
|
||||||
"""Return agent states from the most recent run."""
|
"""Return agent states from the active runtime, or latest persisted run."""
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
agents = snapshot.get("agents", [])
|
||||||
|
|
||||||
if not snapshots:
|
|
||||||
raise HTTPException(status_code=404, detail="No runtime state available")
|
|
||||||
|
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
|
||||||
agents = latest.get("agents", [])
|
|
||||||
|
|
||||||
return RuntimeAgentsResponse(
|
return RuntimeAgentsResponse(
|
||||||
agents=[RuntimeAgentState(**a) for a in agents]
|
agents=[RuntimeAgentState(**a) for a in agents]
|
||||||
@@ -340,21 +485,21 @@ async def get_runtime_agents() -> RuntimeAgentsResponse:
|
|||||||
|
|
||||||
@router.get("/events", response_model=RuntimeEventsResponse)
|
@router.get("/events", response_model=RuntimeEventsResponse)
|
||||||
async def get_runtime_events() -> RuntimeEventsResponse:
|
async def get_runtime_events() -> RuntimeEventsResponse:
|
||||||
"""Return events from the most recent run."""
|
"""Return events from the active runtime, or latest persisted run."""
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
events = snapshot.get("events", [])
|
||||||
|
|
||||||
if not snapshots:
|
|
||||||
raise HTTPException(status_code=404, detail="No runtime state available")
|
|
||||||
|
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
|
||||||
events = latest.get("events", [])
|
|
||||||
|
|
||||||
return RuntimeEventsResponse(
|
return RuntimeEventsResponse(
|
||||||
events=[RuntimeEvent(**e) for e in events]
|
events=[RuntimeEvent(**e) for e in events]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history", response_model=RuntimeHistoryResponse)
|
||||||
|
async def get_runtime_history(limit: int = 20) -> RuntimeHistoryResponse:
|
||||||
|
"""List recent historical runs for restore/start selection."""
|
||||||
|
return RuntimeHistoryResponse(runs=_list_runs(limit=limit))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/gateway/status", response_model=GatewayStatusResponse)
|
@router.get("/gateway/status", response_model=GatewayStatusResponse)
|
||||||
async def get_gateway_status() -> GatewayStatusResponse:
|
async def get_gateway_status() -> GatewayStatusResponse:
|
||||||
"""Get Gateway process status and port."""
|
"""Get Gateway process status and port."""
|
||||||
@@ -362,15 +507,10 @@ async def get_gateway_status() -> GatewayStatusResponse:
|
|||||||
run_id = None
|
run_id = None
|
||||||
|
|
||||||
if is_running:
|
if is_running:
|
||||||
# Try to find run_id from runtime state
|
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
||||||
if snapshots:
|
|
||||||
try:
|
try:
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
run_id = _get_active_runtime_context().get("config_name")
|
||||||
run_id = latest.get("context", {}).get("config_name")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to parse latest snapshot: {e}")
|
logger.warning(f"Failed to resolve active runtime context: {e}")
|
||||||
|
|
||||||
return GatewayStatusResponse(
|
return GatewayStatusResponse(
|
||||||
is_running=is_running,
|
is_running=is_running,
|
||||||
@@ -390,6 +530,26 @@ async def get_gateway_port(request: Request) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs", response_model=RuntimeLogResponse)
|
||||||
|
async def get_runtime_logs() -> RuntimeLogResponse:
|
||||||
|
"""Return current runtime log tail, or the latest run log if runtime is stopped."""
|
||||||
|
try:
|
||||||
|
context = _get_active_runtime_context() if _is_gateway_running() else _get_runtime_context_from_latest_snapshot()
|
||||||
|
except HTTPException:
|
||||||
|
return RuntimeLogResponse(is_running=False, content="")
|
||||||
|
|
||||||
|
run_id = str(context.get("config_name") or "").strip() or None
|
||||||
|
log_path = _get_gateway_log_path_for_run(run_id) if run_id else None
|
||||||
|
content = _read_log_tail(log_path) if log_path else ""
|
||||||
|
|
||||||
|
return RuntimeLogResponse(
|
||||||
|
run_id=run_id,
|
||||||
|
is_running=_is_gateway_running(),
|
||||||
|
log_path=str(log_path) if log_path else None,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_gateway_ws_url(request: Request, port: int) -> str:
|
def _build_gateway_ws_url(request: Request, port: int) -> str:
|
||||||
"""Build a proxy-safe Gateway WebSocket URL."""
|
"""Build a proxy-safe Gateway WebSocket URL."""
|
||||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip()
|
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip()
|
||||||
@@ -416,10 +576,23 @@ def _load_latest_runtime_snapshot() -> Dict[str, Any]:
|
|||||||
return json.loads(snapshots[0].read_text(encoding="utf-8"))
|
return json.loads(snapshots[0].read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def _get_current_runtime_context() -> Dict[str, Any]:
|
def _get_active_runtime_snapshot() -> Dict[str, Any]:
|
||||||
"""Return the active runtime context from the latest snapshot."""
|
"""Return the active runtime snapshot, preferring in-memory manager state."""
|
||||||
if not _is_gateway_running():
|
if not _is_gateway_running():
|
||||||
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||||
|
|
||||||
|
manager = _runtime_state.runtime_manager
|
||||||
|
if manager is not None and hasattr(manager, "build_snapshot"):
|
||||||
|
snapshot = manager.build_snapshot()
|
||||||
|
context = snapshot.get("context") or {}
|
||||||
|
if context.get("config_name"):
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
return _load_latest_runtime_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_runtime_context_from_latest_snapshot() -> Dict[str, Any]:
|
||||||
|
"""Return the latest persisted runtime context regardless of active process state."""
|
||||||
latest = _load_latest_runtime_snapshot()
|
latest = _load_latest_runtime_snapshot()
|
||||||
context = latest.get("context") or {}
|
context = latest.get("context") or {}
|
||||||
if not context.get("config_name"):
|
if not context.get("config_name"):
|
||||||
@@ -427,6 +600,35 @@ def _get_current_runtime_context() -> Dict[str, Any]:
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _get_gateway_log_path_for_run(run_id: str) -> Path:
|
||||||
|
return _get_run_dir(run_id) / "logs" / "gateway.log"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_tail(path: Path, max_chars: int = 120_000) -> str:
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
return ""
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
return text
|
||||||
|
return text[-max_chars:]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_runtime_context() -> Dict[str, Any]:
|
||||||
|
"""Return the active runtime context from the latest snapshot."""
|
||||||
|
if not _is_gateway_running():
|
||||||
|
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||||
|
snapshot = _get_active_runtime_snapshot()
|
||||||
|
context = snapshot.get("context") or {}
|
||||||
|
if not context.get("config_name"):
|
||||||
|
raise HTTPException(status_code=404, detail="No runtime context available")
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_runtime_context() -> Dict[str, Any]:
|
||||||
|
"""Return the active runtime context, preferring in-memory runtime manager state."""
|
||||||
|
return _get_current_runtime_context()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse:
|
def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse:
|
||||||
"""Build a normalized runtime config response for the active run."""
|
"""Build a normalized runtime config response for the active run."""
|
||||||
context = _get_current_runtime_context()
|
context = _get_current_runtime_context()
|
||||||
@@ -517,12 +719,30 @@ async def start_runtime(
|
|||||||
_stop_gateway()
|
_stop_gateway()
|
||||||
await asyncio.sleep(1) # Wait for port release
|
await asyncio.sleep(1) # Wait for port release
|
||||||
|
|
||||||
# 2. Generate run ID and directory
|
launch_mode = str(config.launch_mode or "fresh").strip().lower()
|
||||||
|
if launch_mode not in {"fresh", "restore"}:
|
||||||
|
raise HTTPException(status_code=400, detail="launch_mode must be 'fresh' or 'restore'")
|
||||||
|
|
||||||
|
# 2. Resolve run ID, directory, and bootstrap
|
||||||
|
if launch_mode == "restore":
|
||||||
|
restore_run_id = str(config.restore_run_id or "").strip()
|
||||||
|
if not restore_run_id:
|
||||||
|
raise HTTPException(status_code=400, detail="restore_run_id is required when launch_mode=restore")
|
||||||
|
snapshot = _load_run_snapshot(restore_run_id)
|
||||||
|
context = snapshot.get("context") or {}
|
||||||
|
if not context.get("config_name"):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Run context not found: {restore_run_id}")
|
||||||
|
run_id = restore_run_id
|
||||||
|
run_dir = _get_run_dir(run_id)
|
||||||
|
bootstrap = dict(context.get("bootstrap_values") or {})
|
||||||
|
bootstrap["launch_mode"] = "restore"
|
||||||
|
bootstrap["restore_run_id"] = restore_run_id
|
||||||
|
else:
|
||||||
run_id = _generate_run_id()
|
run_id = _generate_run_id()
|
||||||
run_dir = _get_run_dir(run_id)
|
run_dir = _get_run_dir(run_id)
|
||||||
|
|
||||||
# 3. Prepare bootstrap config
|
|
||||||
bootstrap = {
|
bootstrap = {
|
||||||
|
"launch_mode": "fresh",
|
||||||
|
"restore_run_id": None,
|
||||||
"tickers": config.tickers,
|
"tickers": config.tickers,
|
||||||
"schedule_mode": config.schedule_mode,
|
"schedule_mode": config.schedule_mode,
|
||||||
"interval_minutes": config.interval_minutes,
|
"interval_minutes": config.interval_minutes,
|
||||||
@@ -535,9 +755,16 @@ async def start_runtime(
|
|||||||
"start_date": config.start_date,
|
"start_date": config.start_date,
|
||||||
"end_date": config.end_date,
|
"end_date": config.end_date,
|
||||||
"poll_interval": config.poll_interval,
|
"poll_interval": config.poll_interval,
|
||||||
"enable_mock": config.enable_mock,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retention_keep = max(1, int(os.getenv("RUNS_RETENTION_COUNT", "20") or "20"))
|
||||||
|
pruned_run_ids = _prune_old_timestamped_runs(
|
||||||
|
keep=retention_keep,
|
||||||
|
exclude_run_ids={run_id},
|
||||||
|
)
|
||||||
|
if pruned_run_ids:
|
||||||
|
logger.info("Pruned old run directories: %s", ", ".join(pruned_run_ids))
|
||||||
|
|
||||||
# 4. Create runtime manager
|
# 4. Create runtime manager
|
||||||
manager = TradingRuntimeManager(
|
manager = TradingRuntimeManager(
|
||||||
config_name=run_id,
|
config_name=run_id,
|
||||||
@@ -567,11 +794,12 @@ async def start_runtime(
|
|||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
if not _is_gateway_running():
|
if not _is_gateway_running():
|
||||||
stdout, stderr = process.communicate(timeout=1)
|
|
||||||
_runtime_state.gateway_process = None
|
_runtime_state.gateway_process = None
|
||||||
|
log_path = _get_gateway_log_path_for_run(run_id)
|
||||||
|
log_tail = _read_log_tail(log_path, max_chars=4000)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Gateway failed to start: {stderr.decode() if stderr else 'Unknown error'}"
|
detail=f"Gateway failed to start: {log_tail or 'Unknown error'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -637,6 +865,25 @@ async def stop_runtime(force: bool = True) -> StopResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cleanup", response_model=CleanupResponse)
|
||||||
|
async def cleanup_old_runs(keep: int = 20) -> CleanupResponse:
|
||||||
|
"""Prune old timestamped run directories while preserving named runs."""
|
||||||
|
keep_count = max(1, int(keep))
|
||||||
|
exclude: set[str] = set()
|
||||||
|
|
||||||
|
if _is_gateway_running():
|
||||||
|
try:
|
||||||
|
active_context = _get_active_runtime_context()
|
||||||
|
active_run_id = str(active_context.get("config_name") or "").strip()
|
||||||
|
if active_run_id:
|
||||||
|
exclude.add(active_run_id)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pruned = _prune_old_timestamped_runs(keep=keep_count, exclude_run_ids=exclude)
|
||||||
|
return CleanupResponse(status="ok", kept=keep_count, pruned_run_ids=pruned)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/restart")
|
@router.post("/restart")
|
||||||
async def restart_runtime(
|
async def restart_runtime(
|
||||||
config: LaunchConfig,
|
config: LaunchConfig,
|
||||||
@@ -663,15 +910,7 @@ async def get_current_runtime():
|
|||||||
if not _is_gateway_running():
|
if not _is_gateway_running():
|
||||||
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||||
|
|
||||||
# Find latest runtime state
|
context = _get_active_runtime_context()
|
||||||
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
|
|
||||||
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
||||||
|
|
||||||
if not snapshots:
|
|
||||||
raise HTTPException(status_code=404, detail="No runtime information available")
|
|
||||||
|
|
||||||
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
|
|
||||||
context = latest.get("context", {})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"run_id": context.get("config_name"),
|
"run_id": context.get("config_name"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
@@ -24,4 +25,6 @@ __all__ = [
|
|||||||
"create_runtime_app",
|
"create_runtime_app",
|
||||||
"trading_app",
|
"trading_app",
|
||||||
"create_trading_app",
|
"create_trading_app",
|
||||||
|
"add_cors_middleware",
|
||||||
|
"get_cors_origins",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from pathlib import Path
|
|||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
from backend.apps.cors import add_cors_middleware
|
||||||
|
|
||||||
from backend.api import agents_router, guard_router, workspaces_router
|
from backend.api import agents_router, guard_router, workspaces_router
|
||||||
from backend.agents import AgentFactory, WorkspaceManager, get_registry
|
from backend.agents import AgentFactory, WorkspaceManager, get_registry
|
||||||
@@ -47,13 +48,7 @@ def create_app(project_root: Path | None = None) -> FastAPI:
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
add_cors_middleware(app)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, object]:
|
async def health_check() -> dict[str, object]:
|
||||||
|
|||||||
30
backend/apps/cors.py
Normal file
30
backend/apps/cors.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Shared CORS configuration for all microservice apps."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def get_cors_origins() -> Sequence[str]:
|
||||||
|
"""Get allowed CORS origins from environment variable.
|
||||||
|
|
||||||
|
Defaults to ["*"] for backward compatibility.
|
||||||
|
Set CORS_ALLOWED_ORIGINS env var (comma-separated) in production.
|
||||||
|
"""
|
||||||
|
origins = os.getenv("CORS_ALLOWED_ORIGINS", "").strip()
|
||||||
|
if not origins:
|
||||||
|
return ["*"]
|
||||||
|
return [o.strip() for o in origins.split(",") if o.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def add_cors_middleware(app: "FastAPI") -> None:
|
||||||
|
"""Add CORS middleware to app with environment-configured origins."""
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=get_cors_origins(),
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
@@ -6,15 +6,15 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Query
|
from fastapi import Depends, FastAPI, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from backend.apps.cors import add_cors_middleware
|
||||||
|
|
||||||
from backend.data.market_store import MarketStore
|
from backend.data.market_store import MarketStore
|
||||||
from backend.domains import news as news_domain
|
from backend.domains import news as news_domain
|
||||||
|
|
||||||
|
|
||||||
def get_market_store() -> MarketStore:
|
def get_market_store() -> MarketStore:
|
||||||
"""Create a market store dependency."""
|
"""Get the MarketStore singleton dependency."""
|
||||||
return MarketStore()
|
return MarketStore.get_instance()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -25,13 +25,7 @@ def create_app() -> FastAPI:
|
|||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
add_cors_middleware(app)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, str]:
|
async def health_check() -> dict[str, str]:
|
||||||
@@ -51,6 +45,7 @@ def create_app() -> FastAPI:
|
|||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/news-for-date")
|
@app.get("/api/news-for-date")
|
||||||
@@ -65,6 +60,7 @@ def create_app() -> FastAPI:
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
date=date,
|
date=date,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/news-timeline")
|
@app.get("/api/news-timeline")
|
||||||
@@ -79,6 +75,7 @@ def create_app() -> FastAPI:
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/categories")
|
@app.get("/api/categories")
|
||||||
@@ -95,6 +92,7 @@ def create_app() -> FastAPI:
|
|||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/similar-days")
|
@app.get("/api/similar-days")
|
||||||
@@ -109,6 +107,7 @@ def create_app() -> FastAPI:
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
date=date,
|
date=date,
|
||||||
n_similar=n_similar,
|
n_similar=n_similar,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/stories/{ticker}")
|
@app.get("/api/stories/{ticker}")
|
||||||
@@ -121,6 +120,7 @@ def create_app() -> FastAPI:
|
|||||||
store,
|
store,
|
||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
as_of_date=as_of_date,
|
as_of_date=as_of_date,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/range-explain")
|
@app.get("/api/range-explain")
|
||||||
@@ -139,6 +139,7 @@ def create_app() -> FastAPI:
|
|||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
article_ids=article_ids,
|
article_ids=article_ids,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from backend.api import runtime_router
|
from backend.api import runtime_router
|
||||||
from backend.api.runtime import get_runtime_state
|
from backend.api.runtime import get_runtime_state
|
||||||
|
from backend.apps.cors import add_cors_middleware
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -18,13 +18,7 @@ def create_app() -> FastAPI:
|
|||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
add_cors_middleware(app)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, object]:
|
async def health_check() -> dict[str, object]:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from backend.apps.cors import add_cors_middleware
|
||||||
|
|
||||||
from backend.domains import trading as trading_domain
|
from backend.domains import trading as trading_domain
|
||||||
from shared.schema import (
|
from shared.schema import (
|
||||||
@@ -26,13 +26,7 @@ def create_app() -> FastAPI:
|
|||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
add_cors_middleware(app)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, str]:
|
async def health_check() -> dict[str, str]:
|
||||||
|
|||||||
@@ -1019,11 +1019,6 @@ def backtest(
|
|||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def live(
|
def live(
|
||||||
mock: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
"--mock",
|
|
||||||
help="Use mock mode with simulated prices (for testing)",
|
|
||||||
),
|
|
||||||
config_name: str = typer.Option(
|
config_name: str = typer.Option(
|
||||||
"live",
|
"live",
|
||||||
"--config-name",
|
"--config-name",
|
||||||
@@ -1078,7 +1073,6 @@ def live(
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
evotraders live # Run immediately (default)
|
evotraders live # Run immediately (default)
|
||||||
evotraders live --mock # Mock mode
|
|
||||||
evotraders live -t 22:30 # Run at 22:30 local time daily
|
evotraders live -t 22:30 # Run at 22:30 local time daily
|
||||||
evotraders live --schedule-mode intraday --interval-minutes 60
|
evotraders live --schedule-mode intraday --interval-minutes 60
|
||||||
evotraders live --trigger-time now # Run immediately
|
evotraders live --trigger-time now # Run immediately
|
||||||
@@ -1086,16 +1080,14 @@ def live(
|
|||||||
"""
|
"""
|
||||||
schedule_mode = str(_normalize_typer_value(schedule_mode, "daily"))
|
schedule_mode = str(_normalize_typer_value(schedule_mode, "daily"))
|
||||||
interval_minutes = int(_normalize_typer_value(interval_minutes, 60))
|
interval_minutes = int(_normalize_typer_value(interval_minutes, 60))
|
||||||
mode_name = "MOCK" if mock else "LIVE"
|
|
||||||
console.print(
|
console.print(
|
||||||
Panel.fit(
|
Panel.fit(
|
||||||
f"[bold cyan]EvoTraders {mode_name} Mode[/bold cyan]",
|
"[bold cyan]EvoTraders LIVE Mode[/bold cyan]",
|
||||||
border_style="cyan",
|
border_style="cyan",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for required API key in live mode
|
# Check for required API key in live mode
|
||||||
if not mock:
|
|
||||||
env_file = get_project_root() / ".env"
|
env_file = get_project_root() / ".env"
|
||||||
if not env_file.exists():
|
if not env_file.exists():
|
||||||
console.print("\n[yellow]Warning: .env file not found[/yellow]")
|
console.print("\n[yellow]Warning: .env file not found[/yellow]")
|
||||||
@@ -1168,9 +1160,6 @@ def live(
|
|||||||
|
|
||||||
# Display configuration
|
# Display configuration
|
||||||
console.print("\n[bold]Configuration:[/bold]")
|
console.print("\n[bold]Configuration:[/bold]")
|
||||||
if mock:
|
|
||||||
console.print(" Mode: [yellow]MOCK[/yellow] (Simulated prices)")
|
|
||||||
else:
|
|
||||||
console.print(
|
console.print(
|
||||||
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
||||||
)
|
)
|
||||||
@@ -1188,8 +1177,7 @@ def live(
|
|||||||
project_root = get_project_root()
|
project_root = get_project_root()
|
||||||
os.chdir(project_root)
|
os.chdir(project_root)
|
||||||
|
|
||||||
# Data update (if not mock mode)
|
# Data update
|
||||||
if not mock:
|
|
||||||
run_data_updater(project_root)
|
run_data_updater(project_root)
|
||||||
auto_update_market_store(
|
auto_update_market_store(
|
||||||
config_name,
|
config_name,
|
||||||
@@ -1200,10 +1188,6 @@ def live(
|
|||||||
end_date=nyse_now.date().isoformat(),
|
end_date=nyse_now.date().isoformat(),
|
||||||
force=False,
|
force=False,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
console.print(
|
|
||||||
"\n[dim]Mock mode enabled - skipping data update[/dim]\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build command using backend.main
|
# Build command using backend.main
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -1229,8 +1213,6 @@ def live(
|
|||||||
str(interval_minutes),
|
str(interval_minutes),
|
||||||
]
|
]
|
||||||
|
|
||||||
if mock:
|
|
||||||
cmd.append("--mock")
|
|
||||||
if enable_memory:
|
if enable_memory:
|
||||||
cmd.append("--enable-memory")
|
cmd.append("--enable-memory")
|
||||||
|
|
||||||
|
|||||||
@@ -76,27 +76,19 @@ def _resolve_config() -> DataSourceConfig:
|
|||||||
"""
|
"""
|
||||||
Resolve data source configuration based on available API keys.
|
Resolve data source configuration based on available API keys.
|
||||||
|
|
||||||
Priority:
|
The effective source should always match the first item in the resolved
|
||||||
1. FINNHUB_API_KEY (if set)
|
ordered source list.
|
||||||
2. FINANCIAL_DATASETS_API_KEY (if set)
|
|
||||||
3. Raises error if neither is available
|
|
||||||
"""
|
"""
|
||||||
sources = _ordered_sources()
|
sources = _ordered_sources()
|
||||||
if "finnhub" in sources:
|
source = sources[0] if sources else "local_csv"
|
||||||
return DataSourceConfig(
|
|
||||||
source="finnhub",
|
api_key = ""
|
||||||
api_key=os.getenv("FINNHUB_API_KEY", "").strip(),
|
if source == "finnhub":
|
||||||
sources=sources,
|
api_key = os.getenv("FINNHUB_API_KEY", "").strip()
|
||||||
)
|
elif source == "financial_datasets":
|
||||||
if "financial_datasets" in sources:
|
api_key = os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip()
|
||||||
return DataSourceConfig(
|
|
||||||
source="financial_datasets",
|
return DataSourceConfig(source=source, api_key=api_key, sources=sources)
|
||||||
api_key=os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip(),
|
|
||||||
sources=sources,
|
|
||||||
)
|
|
||||||
if "yfinance" in sources:
|
|
||||||
return DataSourceConfig(source="yfinance", api_key="", sources=sources)
|
|
||||||
return DataSourceConfig(source="local_csv", api_key="", sources=sources)
|
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> DataSourceConfig:
|
def get_config() -> DataSourceConfig:
|
||||||
|
|||||||
@@ -244,10 +244,8 @@ async def run_pipeline(
|
|||||||
start_date = bootstrap.get("start_date")
|
start_date = bootstrap.get("start_date")
|
||||||
end_date = bootstrap.get("end_date")
|
end_date = bootstrap.get("end_date")
|
||||||
enable_memory = bootstrap.get("enable_memory", False)
|
enable_memory = bootstrap.get("enable_memory", False)
|
||||||
enable_mock = bootstrap.get("enable_mock", False)
|
|
||||||
|
|
||||||
is_backtest = mode == "backtest"
|
is_backtest = mode == "backtest"
|
||||||
is_mock = enable_mock or mode == "mock" or (not is_backtest and os.getenv("MOCK_MODE", "false").lower() == "true")
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# PHASE 0: Initialize runtime manager
|
# PHASE 0: Initialize runtime manager
|
||||||
@@ -266,10 +264,6 @@ async def run_pipeline(
|
|||||||
|
|
||||||
set_global_runtime_manager(runtime_manager)
|
set_global_runtime_manager(runtime_manager)
|
||||||
|
|
||||||
# Register runtime manager with API
|
|
||||||
from backend.api.runtime import register_runtime_manager
|
|
||||||
register_runtime_manager(runtime_manager)
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# PHASE 1 & 2: Create infrastructure services (Market, Storage)
|
# PHASE 1 & 2: Create infrastructure services (Market, Storage)
|
||||||
# These will be started by Gateway in the correct order
|
# These will be started by Gateway in the correct order
|
||||||
@@ -292,9 +286,8 @@ async def run_pipeline(
|
|||||||
market_service = MarketService(
|
market_service = MarketService(
|
||||||
tickers=tickers,
|
tickers=tickers,
|
||||||
poll_interval=10,
|
poll_interval=10,
|
||||||
mock_mode=is_mock and not is_backtest,
|
|
||||||
backtest_mode=is_backtest,
|
backtest_mode=is_backtest,
|
||||||
api_key=os.getenv("FINNHUB_API_KEY") if not is_mock and not is_backtest else None,
|
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None,
|
||||||
backtest_start_date=start_date if is_backtest else None,
|
backtest_start_date=start_date if is_backtest else None,
|
||||||
backtest_end_date=end_date if is_backtest else None,
|
backtest_end_date=end_date if is_backtest else None,
|
||||||
)
|
)
|
||||||
@@ -391,7 +384,6 @@ async def run_pipeline(
|
|||||||
scheduler_callback=scheduler_callback,
|
scheduler_callback=scheduler_callback,
|
||||||
config={
|
config={
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"mock_mode": is_mock,
|
|
||||||
"backtest_mode": is_backtest,
|
"backtest_mode": is_backtest,
|
||||||
"tickers": tickers,
|
"tickers": tickers,
|
||||||
"config_name": run_id,
|
"config_name": run_id,
|
||||||
|
|||||||
@@ -465,7 +465,6 @@ class StateSync:
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"server_mode": self._state.get("server_mode", "live"),
|
"server_mode": self._state.get("server_mode", "live"),
|
||||||
"is_mock_mode": self._state.get("is_mock_mode", False),
|
|
||||||
"is_backtest": self._state.get("is_backtest", False),
|
"is_backtest": self._state.get("is_backtest", False),
|
||||||
"tickers": self._state.get("tickers"),
|
"tickers": self._state.get("tickers"),
|
||||||
"runtime_config": self._state.get("runtime_config"),
|
"runtime_config": self._state.get("runtime_config"),
|
||||||
@@ -488,12 +487,13 @@ class StateSync:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if include_dashboard:
|
if include_dashboard:
|
||||||
|
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self._state)
|
||||||
payload["dashboard"] = {
|
payload["dashboard"] = {
|
||||||
"summary": self.storage.load_file("summary"),
|
"summary": dashboard_snapshot.get("summary"),
|
||||||
"holdings": self.storage.load_file("holdings"),
|
"holdings": dashboard_snapshot.get("holdings"),
|
||||||
"stats": self.storage.load_file("stats"),
|
"stats": dashboard_snapshot.get("stats"),
|
||||||
"trades": self.storage.load_file("trades"),
|
"trades": dashboard_snapshot.get("trades"),
|
||||||
"leaderboard": self.storage.load_file("leaderboard"),
|
"leaderboard": dashboard_snapshot.get("leaderboard"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from backend.data.historical_price_manager import HistoricalPriceManager
|
from backend.data.historical_price_manager import HistoricalPriceManager
|
||||||
from backend.data.mock_price_manager import MockPriceManager
|
|
||||||
from backend.data.polling_price_manager import PollingPriceManager
|
from backend.data.polling_price_manager import PollingPriceManager
|
||||||
|
|
||||||
__all__ = ["MockPriceManager", "PollingPriceManager", "HistoricalPriceManager"]
|
__all__ = ["PollingPriceManager", "HistoricalPriceManager"]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Iterable
|
|||||||
|
|
||||||
from backend.data.market_store import MarketStore
|
from backend.data.market_store import MarketStore
|
||||||
from backend.data.news_alignment import align_news_for_symbol
|
from backend.data.news_alignment import align_news_for_symbol
|
||||||
|
from backend.data.provider_router import DataProviderRouter
|
||||||
from backend.data.polygon_client import (
|
from backend.data.polygon_client import (
|
||||||
fetch_news,
|
fetch_news,
|
||||||
fetch_ohlc,
|
fetch_ohlc,
|
||||||
@@ -24,6 +25,35 @@ 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,
|
||||||
*,
|
*,
|
||||||
@@ -114,6 +144,80 @@ def update_ticker_incremental(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_news_incremental(
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
end_date: str | None = None,
|
||||||
|
store: MarketStore | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Incrementally fetch company news using the configured provider router."""
|
||||||
|
ticker = normalize_symbol(symbol)
|
||||||
|
market_store = store or MarketStore()
|
||||||
|
watermarks = market_store.get_ticker_watermarks(ticker)
|
||||||
|
end = end_date or _today_utc()
|
||||||
|
start_news = (
|
||||||
|
(datetime.fromisoformat(watermarks["last_news_fetch"]) + timedelta(days=1)).date().isoformat()
|
||||||
|
if watermarks.get("last_news_fetch")
|
||||||
|
else _default_start()
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_news > end:
|
||||||
|
return {
|
||||||
|
"symbol": ticker,
|
||||||
|
"start_news_date": start_news,
|
||||||
|
"end_date": end,
|
||||||
|
"news": 0,
|
||||||
|
"aligned": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
router = DataProviderRouter()
|
||||||
|
news_items, source = router.get_company_news(
|
||||||
|
ticker=ticker,
|
||||||
|
start_date=start_news,
|
||||||
|
end_date=end,
|
||||||
|
limit=1000,
|
||||||
|
)
|
||||||
|
news_rows = _normalize_provider_news_rows(ticker, news_items)
|
||||||
|
news_count = market_store.upsert_news(ticker, news_rows, source=source) if news_rows else 0
|
||||||
|
aligned_count = align_news_for_symbol(market_store, ticker)
|
||||||
|
market_store.update_fetch_watermark(
|
||||||
|
symbol=ticker,
|
||||||
|
news_date=end if news_rows or watermarks.get("last_news_fetch") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"symbol": ticker,
|
||||||
|
"start_news_date": start_news,
|
||||||
|
"end_date": end,
|
||||||
|
"news": news_count,
|
||||||
|
"aligned": aligned_count,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_news_for_symbols(
|
||||||
|
symbols: Iterable[str],
|
||||||
|
*,
|
||||||
|
end_date: str | None = None,
|
||||||
|
store: MarketStore | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Incrementally refresh company news for a list of tickers."""
|
||||||
|
market_store = store or MarketStore()
|
||||||
|
results = []
|
||||||
|
for symbol in symbols:
|
||||||
|
ticker = normalize_symbol(symbol)
|
||||||
|
if not ticker:
|
||||||
|
continue
|
||||||
|
results.append(
|
||||||
|
refresh_news_incremental(
|
||||||
|
ticker,
|
||||||
|
end_date=end_date,
|
||||||
|
store=market_store,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def ingest_symbols(
|
def ingest_symbols(
|
||||||
symbols: Iterable[str],
|
symbols: Iterable[str],
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import os
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
@@ -147,12 +147,30 @@ def _utc_timestamp() -> str:
|
|||||||
|
|
||||||
|
|
||||||
class MarketStore:
|
class MarketStore:
|
||||||
"""SQLite-backed market research warehouse."""
|
"""SQLite-backed market research warehouse. Use get_instance() for the singleton."""
|
||||||
|
|
||||||
|
_instance: Optional["MarketStore"] = None
|
||||||
|
|
||||||
|
def __new__(cls, db_path: Path | None = None) -> "MarketStore":
|
||||||
|
if cls._instance is not None:
|
||||||
|
if db_path is None or cls._instance.db_path == Path(db_path or get_market_db_path()):
|
||||||
|
return cls._instance
|
||||||
|
instance = super().__new__(cls)
|
||||||
|
cls._instance = instance
|
||||||
|
return instance
|
||||||
|
|
||||||
def __init__(self, db_path: Path | None = None):
|
def __init__(self, db_path: Path | None = None):
|
||||||
|
if getattr(self, "_initialized", False):
|
||||||
|
return
|
||||||
self.db_path = Path(db_path or get_market_db_path())
|
self.db_path = Path(db_path or get_market_db_path())
|
||||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls, db_path: Path | None = None) -> "MarketStore":
|
||||||
|
"""Get the MarketStore singleton instance."""
|
||||||
|
return cls(db_path)
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(self.db_path)
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Mock Price Manager - For testing during non-trading hours
|
|
||||||
Generates virtual real-time price data
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Callable, Dict, List, Optional
|
|
||||||
from backend.data.provider_utils import normalize_symbol
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MockPriceManager:
|
|
||||||
"""Mock Price Manager - Generates virtual prices for testing"""
|
|
||||||
|
|
||||||
def __init__(self, poll_interval: int = 10, volatility: float = 0.5):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
poll_interval: Price update interval in seconds
|
|
||||||
volatility: Price volatility percentage
|
|
||||||
"""
|
|
||||||
if poll_interval is None:
|
|
||||||
poll_interval = int(os.getenv("MOCK_POLL_INTERVAL", "5"))
|
|
||||||
if volatility is None:
|
|
||||||
volatility = float(os.getenv("MOCK_VOLATILITY", "0.5"))
|
|
||||||
|
|
||||||
self.poll_interval = poll_interval
|
|
||||||
self.volatility = volatility
|
|
||||||
|
|
||||||
self.subscribed_symbols: List[str] = []
|
|
||||||
self.base_prices: Dict[str, float] = {}
|
|
||||||
self.open_prices: Dict[str, float] = {}
|
|
||||||
self.latest_prices: Dict[str, float] = {}
|
|
||||||
self.price_callbacks: List[Callable] = []
|
|
||||||
|
|
||||||
self.running = False
|
|
||||||
self._thread: Optional[threading.Thread] = None
|
|
||||||
|
|
||||||
self.default_base_prices = {
|
|
||||||
"AAPL": 237.50,
|
|
||||||
"MSFT": 425.30,
|
|
||||||
"GOOGL": 161.50,
|
|
||||||
"AMZN": 218.45,
|
|
||||||
"NVDA": 950.00,
|
|
||||||
"META": 573.22,
|
|
||||||
"TSLA": 342.15,
|
|
||||||
"AMD": 168.90,
|
|
||||||
"NFLX": 688.25,
|
|
||||||
"INTC": 42.18,
|
|
||||||
"COIN": 285.50,
|
|
||||||
"PLTR": 45.80,
|
|
||||||
"BABA": 88.30,
|
|
||||||
"DIS": 112.50,
|
|
||||||
"BKNG": 4850.00,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"MockPriceManager initialized (interval: {self.poll_interval}s, "
|
|
||||||
f"volatility: {self.volatility}%)",
|
|
||||||
)
|
|
||||||
|
|
||||||
def subscribe(
|
|
||||||
self,
|
|
||||||
symbols: List[str],
|
|
||||||
base_prices: Dict[str, float] = None,
|
|
||||||
):
|
|
||||||
"""Subscribe to stock symbols"""
|
|
||||||
for symbol in symbols:
|
|
||||||
symbol = normalize_symbol(symbol)
|
|
||||||
if symbol not in self.subscribed_symbols:
|
|
||||||
self.subscribed_symbols.append(symbol)
|
|
||||||
|
|
||||||
if base_prices and symbol in base_prices:
|
|
||||||
base_price = base_prices[symbol]
|
|
||||||
elif symbol in self.default_base_prices:
|
|
||||||
base_price = self.default_base_prices[symbol]
|
|
||||||
else:
|
|
||||||
base_price = random.uniform(50, 500)
|
|
||||||
|
|
||||||
self.base_prices[symbol] = base_price
|
|
||||||
self.open_prices[symbol] = base_price
|
|
||||||
self.latest_prices[symbol] = base_price
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Subscribed to mock price: {symbol} (base: ${base_price:.2f})", # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
def unsubscribe(self, symbols: List[str]):
|
|
||||||
"""Unsubscribe from symbols"""
|
|
||||||
for symbol in symbols:
|
|
||||||
symbol = normalize_symbol(symbol)
|
|
||||||
if symbol in self.subscribed_symbols:
|
|
||||||
self.subscribed_symbols.remove(symbol)
|
|
||||||
self.base_prices.pop(symbol, None)
|
|
||||||
self.open_prices.pop(symbol, None)
|
|
||||||
self.latest_prices.pop(symbol, None)
|
|
||||||
logger.info(f"Unsubscribed: {symbol}")
|
|
||||||
|
|
||||||
def add_price_callback(self, callback: Callable):
|
|
||||||
"""Add price update callback"""
|
|
||||||
self.price_callbacks.append(callback)
|
|
||||||
|
|
||||||
def _generate_price_update(self, symbol: str) -> float:
|
|
||||||
"""Generate price update based on random walk"""
|
|
||||||
current_price = self.latest_prices.get(
|
|
||||||
symbol,
|
|
||||||
self.base_prices[symbol],
|
|
||||||
)
|
|
||||||
|
|
||||||
change_percent = random.uniform(-self.volatility, self.volatility)
|
|
||||||
new_price = current_price * (1 + change_percent / 100)
|
|
||||||
|
|
||||||
# 10% chance of larger movement
|
|
||||||
if random.random() < 0.1:
|
|
||||||
trend_factor = random.uniform(-2, 2)
|
|
||||||
new_price = new_price * (1 + trend_factor / 100)
|
|
||||||
|
|
||||||
# Limit intraday movement to +/-10%
|
|
||||||
open_price = self.open_prices[symbol]
|
|
||||||
max_price = open_price * 1.10
|
|
||||||
min_price = open_price * 0.90
|
|
||||||
new_price = max(min_price, min(max_price, new_price))
|
|
||||||
|
|
||||||
return new_price
|
|
||||||
|
|
||||||
def _update_prices(self):
|
|
||||||
"""Update prices for all subscribed stocks"""
|
|
||||||
timestamp = int(time.time() * 1000)
|
|
||||||
|
|
||||||
for symbol in self.subscribed_symbols:
|
|
||||||
try:
|
|
||||||
new_price = self._generate_price_update(symbol)
|
|
||||||
self.latest_prices[symbol] = new_price
|
|
||||||
|
|
||||||
open_price = self.open_prices[symbol]
|
|
||||||
ret = ((new_price - open_price) / open_price) * 100
|
|
||||||
|
|
||||||
price_data = {
|
|
||||||
"symbol": symbol,
|
|
||||||
"price": new_price,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"volume": random.randint(1000000, 10000000),
|
|
||||||
"open": open_price,
|
|
||||||
"high": max(new_price, open_price),
|
|
||||||
"low": min(new_price, open_price),
|
|
||||||
"previous_close": open_price,
|
|
||||||
"ret": ret,
|
|
||||||
}
|
|
||||||
|
|
||||||
for callback in self.price_callbacks:
|
|
||||||
try:
|
|
||||||
callback(price_data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Mock price callback error ({symbol}): {e}",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Mock {symbol}: ${new_price:.2f} [ret: {ret:+.2f}%]",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to generate mock price ({symbol}): {e}")
|
|
||||||
|
|
||||||
def _polling_loop(self):
|
|
||||||
"""Main polling loop"""
|
|
||||||
logger.info(
|
|
||||||
f"Mock price generation started (interval: {self.poll_interval}s)",
|
|
||||||
)
|
|
||||||
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
start_time = time.time()
|
|
||||||
self._update_prices()
|
|
||||||
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
sleep_time = max(0, self.poll_interval - elapsed)
|
|
||||||
if sleep_time > 0:
|
|
||||||
time.sleep(sleep_time)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Mock polling loop error: {e}")
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""Start mock price generation"""
|
|
||||||
if self.running:
|
|
||||||
logger.warning("Mock price manager already running")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.subscribed_symbols:
|
|
||||||
logger.warning("No stocks subscribed")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.running = True
|
|
||||||
self._thread = threading.Thread(target=self._polling_loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Mock price manager started: {', '.join(self.subscribed_symbols)}", # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Stop mock price generation"""
|
|
||||||
self.running = False
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=5)
|
|
||||||
logger.info("Mock price manager stopped")
|
|
||||||
|
|
||||||
def get_latest_price(self, symbol: str) -> Optional[float]:
|
|
||||||
"""Get latest price for symbol"""
|
|
||||||
return self.latest_prices.get(symbol)
|
|
||||||
|
|
||||||
def get_all_latest_prices(self) -> Dict[str, float]:
|
|
||||||
"""Get all latest prices"""
|
|
||||||
return self.latest_prices.copy()
|
|
||||||
|
|
||||||
def get_open_price(self, symbol: str) -> Optional[float]:
|
|
||||||
"""Get open price for symbol"""
|
|
||||||
return self.open_prices.get(symbol)
|
|
||||||
|
|
||||||
def reset_open_prices(self):
|
|
||||||
"""Reset open prices for new trading day"""
|
|
||||||
for symbol in self.subscribed_symbols:
|
|
||||||
last_close = self.latest_prices[symbol]
|
|
||||||
gap_percent = random.uniform(-1, 1)
|
|
||||||
new_open = last_close * (1 + gap_percent / 100)
|
|
||||||
self.open_prices[symbol] = new_open
|
|
||||||
self.latest_prices[symbol] = new_open
|
|
||||||
logger.info("Open prices reset")
|
|
||||||
|
|
||||||
def set_base_price(self, symbol: str, price: float):
|
|
||||||
"""Manually set base price for testing"""
|
|
||||||
if symbol in self.subscribed_symbols:
|
|
||||||
self.base_prices[symbol] = price
|
|
||||||
self.open_prices[symbol] = price
|
|
||||||
self.latest_prices[symbol] = price
|
|
||||||
logger.info(f"{symbol} base price set to: ${price:.2f}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"{symbol} not subscribed")
|
|
||||||
@@ -15,6 +15,9 @@ from backend.data.provider_utils import normalize_symbol
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_SUPPRESSED_LOG_EVERY = 20
|
||||||
|
|
||||||
|
|
||||||
class PollingPriceManager:
|
class PollingPriceManager:
|
||||||
"""Polling-based price manager using Finnhub or yfinance."""
|
"""Polling-based price manager using Finnhub or yfinance."""
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ class PollingPriceManager:
|
|||||||
self.latest_prices: Dict[str, float] = {}
|
self.latest_prices: Dict[str, float] = {}
|
||||||
self.open_prices: Dict[str, float] = {}
|
self.open_prices: Dict[str, float] = {}
|
||||||
self.price_callbacks: List[Callable] = []
|
self.price_callbacks: List[Callable] = []
|
||||||
|
self._failure_counts: Dict[str, int] = {}
|
||||||
|
|
||||||
self.running = False
|
self.running = False
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
@@ -77,6 +81,8 @@ class PollingPriceManager:
|
|||||||
for symbol in self.subscribed_symbols:
|
for symbol in self.subscribed_symbols:
|
||||||
try:
|
try:
|
||||||
quote_data = self._fetch_quote(symbol)
|
quote_data = self._fetch_quote(symbol)
|
||||||
|
if not isinstance(quote_data, dict):
|
||||||
|
raise ValueError(f"{symbol}: Empty quote payload")
|
||||||
|
|
||||||
current_price = quote_data.get("c")
|
current_price = quote_data.get("c")
|
||||||
open_price = quote_data.get("o")
|
open_price = quote_data.get("o")
|
||||||
@@ -103,6 +109,13 @@ class PollingPriceManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.latest_prices[symbol] = current_price
|
self.latest_prices[symbol] = current_price
|
||||||
|
previous_failures = self._failure_counts.pop(symbol, 0)
|
||||||
|
if previous_failures > 0:
|
||||||
|
logger.info(
|
||||||
|
"%s quote polling recovered after %d consecutive failures",
|
||||||
|
symbol,
|
||||||
|
previous_failures,
|
||||||
|
)
|
||||||
|
|
||||||
price_data = {
|
price_data = {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
@@ -128,7 +141,20 @@ class PollingPriceManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch {symbol} price: {e}")
|
failure_count = self._failure_counts.get(symbol, 0) + 1
|
||||||
|
self._failure_counts[symbol] = failure_count
|
||||||
|
message = f"Failed to fetch {symbol} price: {e}"
|
||||||
|
|
||||||
|
if failure_count == 1:
|
||||||
|
logger.warning(message)
|
||||||
|
elif failure_count % _SUPPRESSED_LOG_EVERY == 0:
|
||||||
|
logger.warning(
|
||||||
|
"%s (repeated %d times; suppressing intermediate failures)",
|
||||||
|
message,
|
||||||
|
failure_count,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(message)
|
||||||
|
|
||||||
def _fetch_quote(self, symbol: str) -> Dict[str, float]:
|
def _fetch_quote(self, symbol: str) -> Dict[str, float]:
|
||||||
"""Fetch a normalized quote payload from the configured provider."""
|
"""Fetch a normalized quote payload from the configured provider."""
|
||||||
@@ -136,7 +162,10 @@ class PollingPriceManager:
|
|||||||
return self._fetch_yfinance_quote(symbol)
|
return self._fetch_yfinance_quote(symbol)
|
||||||
if not self.finnhub_client:
|
if not self.finnhub_client:
|
||||||
raise ValueError("Finnhub API key required for finnhub polling")
|
raise ValueError("Finnhub API key required for finnhub polling")
|
||||||
return self.finnhub_client.quote(symbol)
|
quote = self.finnhub_client.quote(symbol)
|
||||||
|
if not isinstance(quote, dict):
|
||||||
|
raise ValueError(f"{symbol}: Invalid Finnhub quote payload")
|
||||||
|
return quote
|
||||||
|
|
||||||
def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]:
|
def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]:
|
||||||
"""Fetch quote data from yfinance and normalize to Finnhub-like keys."""
|
"""Fetch quote data from yfinance and normalize to Finnhub-like keys."""
|
||||||
@@ -162,6 +191,8 @@ class PollingPriceManager:
|
|||||||
|
|
||||||
if current_price is None:
|
if current_price is None:
|
||||||
history = ticker.history(period="1d", interval="1m", auto_adjust=False)
|
history = ticker.history(period="1d", interval="1m", auto_adjust=False)
|
||||||
|
if history is None:
|
||||||
|
raise ValueError(f"{symbol}: yfinance returned no history frame")
|
||||||
if history.empty:
|
if history.empty:
|
||||||
raise ValueError(f"{symbol}: No yfinance quote data")
|
raise ValueError(f"{symbol}: No yfinance quote data")
|
||||||
latest = history.iloc[-1]
|
latest = history.iloc[-1]
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def ensure_news_fresh(
|
|||||||
*,
|
*,
|
||||||
ticker: str,
|
ticker: str,
|
||||||
target_date: str | None = None,
|
target_date: str | None = None,
|
||||||
|
refresh_if_stale: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Refresh raw news incrementally when stored watermarks are stale."""
|
"""Refresh raw news incrementally when stored watermarks are stale."""
|
||||||
normalized_target = str(target_date or "").strip()[:10]
|
normalized_target = str(target_date or "").strip()[:10]
|
||||||
@@ -44,7 +45,7 @@ def ensure_news_fresh(
|
|||||||
watermarks = store.get_ticker_watermarks(ticker)
|
watermarks = store.get_ticker_watermarks(ticker)
|
||||||
last_news_fetch = str(watermarks.get("last_news_fetch") or "").strip()[:10]
|
last_news_fetch = str(watermarks.get("last_news_fetch") or "").strip()[:10]
|
||||||
refreshed = False
|
refreshed = False
|
||||||
if not last_news_fetch or last_news_fetch < normalized_target:
|
if refresh_if_stale and (not last_news_fetch or last_news_fetch < normalized_target):
|
||||||
update_ticker_incremental(
|
update_ticker_incremental(
|
||||||
ticker,
|
ticker,
|
||||||
end_date=normalized_target,
|
end_date=normalized_target,
|
||||||
@@ -69,8 +70,14 @@ def get_enriched_news(
|
|||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=end_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
rows = store.get_news_items_enriched(
|
rows = store.get_news_items_enriched(
|
||||||
ticker,
|
ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
@@ -100,8 +107,14 @@ def get_news_for_date(
|
|||||||
ticker: str,
|
ticker: str,
|
||||||
date: str,
|
date: str,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
rows = store.get_news_items_enriched(
|
rows = store.get_news_items_enriched(
|
||||||
ticker,
|
ticker,
|
||||||
trade_date=date,
|
trade_date=date,
|
||||||
@@ -129,8 +142,14 @@ def get_news_timeline(
|
|||||||
ticker: str,
|
ticker: str,
|
||||||
start_date: str,
|
start_date: str,
|
||||||
end_date: str,
|
end_date: str,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=end_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
timeline = store.get_news_timeline_enriched(
|
timeline = store.get_news_timeline_enriched(
|
||||||
ticker,
|
ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
@@ -165,8 +184,14 @@ def get_news_categories(
|
|||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=end_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
rows = store.get_news_items_enriched(
|
rows = store.get_news_items_enriched(
|
||||||
ticker,
|
ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
@@ -196,8 +221,14 @@ def get_similar_days_payload(
|
|||||||
ticker: str,
|
ticker: str,
|
||||||
date: str,
|
date: str,
|
||||||
n_similar: int = 5,
|
n_similar: int = 5,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
result = find_similar_days(
|
result = find_similar_days(
|
||||||
store,
|
store,
|
||||||
symbol=ticker,
|
symbol=ticker,
|
||||||
@@ -213,8 +244,14 @@ def get_story_payload(
|
|||||||
*,
|
*,
|
||||||
ticker: str,
|
ticker: str,
|
||||||
as_of_date: str,
|
as_of_date: str,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=as_of_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=as_of_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
enrich_news_for_symbol(
|
enrich_news_for_symbol(
|
||||||
store,
|
store,
|
||||||
ticker,
|
ticker,
|
||||||
@@ -238,8 +275,14 @@ def get_range_explain_payload(
|
|||||||
end_date: str,
|
end_date: str,
|
||||||
article_ids: list[str] | None = None,
|
article_ids: list[str] | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
refresh_if_stale: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
|
freshness = ensure_news_fresh(
|
||||||
|
store,
|
||||||
|
ticker=ticker,
|
||||||
|
target_date=end_date,
|
||||||
|
refresh_if_stale=refresh_if_stale,
|
||||||
|
)
|
||||||
news_rows = []
|
news_rows = []
|
||||||
if article_ids:
|
if article_ids:
|
||||||
news_rows = store.get_news_by_ids_enriched(ticker, article_ids)
|
news_rows = store.get_news_by_ids_enriched(ticker, article_ids)
|
||||||
|
|||||||
@@ -43,6 +43,71 @@ 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,
|
||||||
@@ -65,10 +130,8 @@ async def run_gateway(
|
|||||||
end_date = bootstrap.get("end_date")
|
end_date = bootstrap.get("end_date")
|
||||||
enable_memory = bootstrap.get("enable_memory", False)
|
enable_memory = bootstrap.get("enable_memory", False)
|
||||||
poll_interval = int(bootstrap.get("poll_interval", 10))
|
poll_interval = int(bootstrap.get("poll_interval", 10))
|
||||||
enable_mock = bootstrap.get("enable_mock", False)
|
|
||||||
|
|
||||||
is_backtest = mode == "backtest"
|
is_backtest = mode == "backtest"
|
||||||
is_mock = enable_mock or mode == "mock" or (not is_backtest and os.getenv("MOCK_MODE", "false").lower() == "true")
|
|
||||||
|
|
||||||
logger.info(f"[Gateway Server] Starting run {run_id} on port {port}")
|
logger.info(f"[Gateway Server] Starting run {run_id} on port {port}")
|
||||||
|
|
||||||
@@ -87,9 +150,8 @@ async def run_gateway(
|
|||||||
market_service = MarketService(
|
market_service = MarketService(
|
||||||
tickers=tickers,
|
tickers=tickers,
|
||||||
poll_interval=poll_interval,
|
poll_interval=poll_interval,
|
||||||
mock_mode=is_mock and not is_backtest,
|
|
||||||
backtest_mode=is_backtest,
|
backtest_mode=is_backtest,
|
||||||
api_key=os.getenv("FINNHUB_API_KEY") if not is_mock and not is_backtest else None,
|
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None,
|
||||||
backtest_start_date=start_date if is_backtest else None,
|
backtest_start_date=start_date if is_backtest else None,
|
||||||
backtest_end_date=end_date if is_backtest else None,
|
backtest_end_date=end_date if is_backtest else None,
|
||||||
)
|
)
|
||||||
@@ -182,7 +244,6 @@ async def run_gateway(
|
|||||||
scheduler_callback=scheduler_callback,
|
scheduler_callback=scheduler_callback,
|
||||||
config={
|
config={
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"mock_mode": is_mock,
|
|
||||||
"backtest_mode": is_backtest,
|
"backtest_mode": is_backtest,
|
||||||
"tickers": tickers,
|
"tickers": tickers,
|
||||||
"config_name": run_id,
|
"config_name": run_id,
|
||||||
@@ -222,11 +283,7 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
level = logging.DEBUG if args.verbose else logging.INFO
|
configure_gateway_logging(verbose=args.verbose)
|
||||||
logging.basicConfig(
|
|
||||||
level=level,
|
|
||||||
format="%(asctime)s | %(levelname)-7s | %(name)s:%(lineno)d - %(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse bootstrap
|
# Parse bootstrap
|
||||||
bootstrap = json.loads(args.bootstrap)
|
bootstrap = json.loads(args.bootstrap)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
AgentScope Native Model Factory
|
AgentScope Native Model Factory
|
||||||
Uses native AgentScope model classes for LLM calls
|
Uses native AgentScope model classes for LLM calls
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
@@ -34,6 +36,27 @@ logger = logging.getLogger(__name__)
|
|||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_value(usage: Any, key: str, default: Any = 0) -> Any:
|
||||||
|
"""Read usage fields from both object-style and dict-style usage payloads."""
|
||||||
|
if usage is None:
|
||||||
|
return default
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
return usage.get(key, default)
|
||||||
|
try:
|
||||||
|
return getattr(usage, key)
|
||||||
|
except (AttributeError, KeyError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_total_tokens(usage: Any) -> int:
|
||||||
|
total = _usage_value(usage, "total_tokens", None)
|
||||||
|
if total is not None:
|
||||||
|
return int(total or 0)
|
||||||
|
input_tokens = _usage_value(usage, "input_tokens", 0)
|
||||||
|
output_tokens = _usage_value(usage, "output_tokens", 0)
|
||||||
|
return int((input_tokens or 0) + (output_tokens or 0))
|
||||||
|
|
||||||
|
|
||||||
class RetryChatModel:
|
class RetryChatModel:
|
||||||
"""Wraps an AgentScope model with automatic retry for transient errors.
|
"""Wraps an AgentScope model with automatic retry for transient errors.
|
||||||
|
|
||||||
@@ -55,6 +78,7 @@ class RetryChatModel:
|
|||||||
"502",
|
"502",
|
||||||
"504",
|
"504",
|
||||||
"connection",
|
"connection",
|
||||||
|
"disconnected",
|
||||||
"temporary",
|
"temporary",
|
||||||
"overloaded",
|
"overloaded",
|
||||||
"too_many_requests",
|
"too_many_requests",
|
||||||
@@ -150,8 +174,8 @@ class RetryChatModel:
|
|||||||
# Track usage if available
|
# Track usage if available
|
||||||
if hasattr(result, "usage") and result.usage:
|
if hasattr(result, "usage") and result.usage:
|
||||||
usage = result.usage
|
usage = result.usage
|
||||||
self._total_tokens_used += getattr(usage, "total_tokens", 0)
|
self._total_tokens_used += _usage_total_tokens(usage)
|
||||||
self._total_cost += getattr(usage, "cost", 0.0)
|
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -192,9 +216,66 @@ class RetryChatModel:
|
|||||||
raise last_error
|
raise last_error
|
||||||
raise RuntimeError("RetryChatModel: Unexpected state, no error but no result")
|
raise RuntimeError("RetryChatModel: Unexpected state, no error but no result")
|
||||||
|
|
||||||
|
async def _call_with_retry_async(self, func: Callable[..., T], *args, **kwargs) -> T:
|
||||||
|
"""Call an async function with retry logic for transient errors."""
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(1, self._max_retries + 1):
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
|
||||||
|
if hasattr(result, "usage") and result.usage:
|
||||||
|
usage = result.usage
|
||||||
|
self._total_tokens_used += _usage_total_tokens(usage)
|
||||||
|
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
|
||||||
|
if attempt >= self._max_retries:
|
||||||
|
logger.error(
|
||||||
|
"RetryChatModel: Max retries (%d) exhausted for %s",
|
||||||
|
self._max_retries,
|
||||||
|
self.model_name,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not self._is_transient_error(e):
|
||||||
|
logger.warning(
|
||||||
|
"RetryChatModel: Non-transient error, not retrying: %s",
|
||||||
|
str(e),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
delay = self._calculate_delay(attempt)
|
||||||
|
logger.warning(
|
||||||
|
"RetryChatModel: Transient async error on attempt %d/%d, "
|
||||||
|
"retrying in %.1fs: %s",
|
||||||
|
attempt,
|
||||||
|
self._max_retries,
|
||||||
|
delay,
|
||||||
|
str(e)[:200],
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._on_retry:
|
||||||
|
self._on_retry(attempt, e, delay)
|
||||||
|
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
raise RuntimeError("RetryChatModel: Unexpected async state, no error but no result")
|
||||||
|
|
||||||
def __call__(self, *args, **kwargs) -> Any:
|
def __call__(self, *args, **kwargs) -> Any:
|
||||||
"""Forward calls to the wrapped model with retry logic."""
|
"""Forward calls to the wrapped model with retry logic."""
|
||||||
return self._call_with_retry(self._model, *args, **kwargs)
|
model_call = getattr(self._model, "__call__", None)
|
||||||
|
if inspect.iscoroutinefunction(self._model) or inspect.iscoroutinefunction(model_call):
|
||||||
|
return self._call_with_retry_async(self._model, *args, **kwargs)
|
||||||
|
|
||||||
|
result = self._model(*args, **kwargs)
|
||||||
|
return result
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
def __getattr__(self, name: str) -> Any:
|
||||||
"""Proxy attribute access to the wrapped model."""
|
"""Proxy attribute access to the wrapped model."""
|
||||||
@@ -248,10 +329,18 @@ class TokenRecordingModelWrapper:
|
|||||||
if usage is None:
|
if usage is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._prompt_tokens += getattr(usage, "prompt_tokens", 0)
|
prompt_tokens = _usage_value(usage, "prompt_tokens", None)
|
||||||
self._completion_tokens += getattr(usage, "completion_tokens", 0)
|
completion_tokens = _usage_value(usage, "completion_tokens", None)
|
||||||
self._total_tokens += getattr(usage, "total_tokens", 0)
|
|
||||||
self._total_cost += getattr(usage, "cost", 0.0)
|
if prompt_tokens is None:
|
||||||
|
prompt_tokens = _usage_value(usage, "input_tokens", 0)
|
||||||
|
if completion_tokens is None:
|
||||||
|
completion_tokens = _usage_value(usage, "output_tokens", 0)
|
||||||
|
|
||||||
|
self._prompt_tokens += int(prompt_tokens or 0)
|
||||||
|
self._completion_tokens += int(completion_tokens or 0)
|
||||||
|
self._total_tokens += _usage_total_tokens(usage)
|
||||||
|
self._total_cost += float(_usage_value(usage, "cost", 0.0) or 0.0)
|
||||||
|
|
||||||
def __call__(self, *args, **kwargs) -> Any:
|
def __call__(self, *args, **kwargs) -> Any:
|
||||||
"""Forward calls and record usage."""
|
"""Forward calls and record usage."""
|
||||||
@@ -401,7 +490,8 @@ def create_model(
|
|||||||
if host:
|
if host:
|
||||||
model_kwargs["host"] = host
|
model_kwargs["host"] = host
|
||||||
|
|
||||||
return model_class(**model_kwargs)
|
model = model_class(**model_kwargs)
|
||||||
|
return RetryChatModel(model)
|
||||||
|
|
||||||
|
|
||||||
def get_agent_model(agent_id: str, stream: bool = False):
|
def get_agent_model(agent_id: str, stream: bool = False):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Main Entry Point
|
Main Entry Point
|
||||||
Supports: backtest, live, mock modes
|
Supports: backtest, live modes
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -226,17 +226,13 @@ async def run_with_gateway(args):
|
|||||||
)
|
)
|
||||||
runtime_manager.prepare_run()
|
runtime_manager.prepare_run()
|
||||||
set_global_runtime_manager(runtime_manager)
|
set_global_runtime_manager(runtime_manager)
|
||||||
register_runtime_manager(runtime_manager)
|
|
||||||
|
|
||||||
# Create market service
|
# Create market service
|
||||||
market_service = MarketService(
|
market_service = MarketService(
|
||||||
tickers=tickers,
|
tickers=tickers,
|
||||||
poll_interval=args.poll_interval,
|
poll_interval=args.poll_interval,
|
||||||
mock_mode=args.mock and not is_backtest,
|
|
||||||
backtest_mode=is_backtest,
|
backtest_mode=is_backtest,
|
||||||
api_key=os.getenv("FINNHUB_API_KEY")
|
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None,
|
||||||
if not args.mock and not is_backtest
|
|
||||||
else None,
|
|
||||||
backtest_start_date=args.start_date if is_backtest else None,
|
backtest_start_date=args.start_date if is_backtest else None,
|
||||||
backtest_end_date=args.end_date if is_backtest else None,
|
backtest_end_date=args.end_date if is_backtest else None,
|
||||||
)
|
)
|
||||||
@@ -321,7 +317,6 @@ async def run_with_gateway(args):
|
|||||||
scheduler_callback=scheduler_callback,
|
scheduler_callback=scheduler_callback,
|
||||||
config={
|
config={
|
||||||
"mode": args.mode,
|
"mode": args.mode,
|
||||||
"mock_mode": args.mock,
|
|
||||||
"backtest_mode": is_backtest,
|
"backtest_mode": is_backtest,
|
||||||
"tickers": tickers,
|
"tickers": tickers,
|
||||||
"config_name": config_name,
|
"config_name": config_name,
|
||||||
@@ -354,8 +349,7 @@ def main():
|
|||||||
"""Main entry point"""
|
"""Main entry point"""
|
||||||
parser = argparse.ArgumentParser(description="Trading System")
|
parser = argparse.ArgumentParser(description="Trading System")
|
||||||
parser.add_argument("--mode", choices=["live", "backtest"], default="live")
|
parser.add_argument("--mode", choices=["live", "backtest"], default="live")
|
||||||
parser.add_argument("--mock", action="store_true")
|
parser.add_argument("--config-name", default="live")
|
||||||
parser.add_argument("--config-name", default="mock")
|
|
||||||
parser.add_argument("--host", default="0.0.0.0")
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
parser.add_argument("--port", type=int, default=8765)
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -13,15 +13,30 @@ from .registry import RuntimeRegistry
|
|||||||
_global_runtime_manager: Optional["TradingRuntimeManager"] = None
|
_global_runtime_manager: Optional["TradingRuntimeManager"] = None
|
||||||
_shutdown_event: Optional[asyncio.Event] = None
|
_shutdown_event: Optional[asyncio.Event] = None
|
||||||
|
|
||||||
|
# Lazy import to avoid circular dependency
|
||||||
|
_api_runtime = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_runtime():
|
||||||
|
global _api_runtime
|
||||||
|
if _api_runtime is None:
|
||||||
|
from backend.api import runtime as api_runtime_module
|
||||||
|
_api_runtime = api_runtime_module
|
||||||
|
return _api_runtime
|
||||||
|
|
||||||
|
|
||||||
def set_global_runtime_manager(manager: "TradingRuntimeManager") -> None:
|
def set_global_runtime_manager(manager: "TradingRuntimeManager") -> None:
|
||||||
global _global_runtime_manager
|
global _global_runtime_manager
|
||||||
_global_runtime_manager = manager
|
_global_runtime_manager = manager
|
||||||
|
# Sync to RuntimeState for consistency
|
||||||
|
_get_api_runtime().register_runtime_manager(manager)
|
||||||
|
|
||||||
|
|
||||||
def clear_global_runtime_manager() -> None:
|
def clear_global_runtime_manager() -> None:
|
||||||
global _global_runtime_manager
|
global _global_runtime_manager
|
||||||
_global_runtime_manager = None
|
_global_runtime_manager = None
|
||||||
|
# Sync to RuntimeState for consistency
|
||||||
|
_get_api_runtime().unregister_runtime_manager()
|
||||||
|
|
||||||
|
|
||||||
def get_global_runtime_manager() -> Optional["TradingRuntimeManager"]:
|
def get_global_runtime_manager() -> Optional["TradingRuntimeManager"]:
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ 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 "",
|
||||||
@@ -125,10 +124,6 @@ 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",
|
||||||
@@ -152,10 +147,11 @@ class Gateway:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Load and display existing portfolio state if available
|
# Load and display existing portfolio state if available
|
||||||
summary = self.storage.load_file("summary")
|
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self.state_sync.state)
|
||||||
|
summary = dashboard_snapshot.get("summary")
|
||||||
if summary:
|
if summary:
|
||||||
holdings = self.storage.load_file("holdings") or []
|
holdings = dashboard_snapshot.get("holdings") or []
|
||||||
trades = self.storage.load_file("trades") or []
|
trades = dashboard_snapshot.get("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 "-",
|
||||||
@@ -544,13 +540,13 @@ class Gateway:
|
|||||||
websocket: ServerConnection,
|
websocket: ServerConnection,
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run one live/mock trading cycle on demand."""
|
"""Run one live trading cycle on demand."""
|
||||||
if self.is_backtest:
|
if self.is_backtest:
|
||||||
await websocket.send(
|
await websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"message": "Manual trigger is only available in live/mock mode.",
|
"message": "Manual trigger is only available in live mode.",
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Runtime/workspace/skills handlers extracted from the main Gateway module."""
|
"""Runtime/workspace/skills handlers extracted from the main Gateway module.
|
||||||
|
|
||||||
|
Deprecated note:
|
||||||
|
Agent/workspace/skill read-write operations are being migrated to
|
||||||
|
agent_service REST endpoints. These websocket handlers remain as a
|
||||||
|
compatibility fallback and should not be considered the primary control
|
||||||
|
plane path for frontend reads/writes.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.data.market_ingest import ingest_symbols
|
from backend.data.market_ingest import ingest_symbols, refresh_news_for_symbols
|
||||||
from backend.domains import trading as trading_domain
|
from backend.domains import trading as trading_domain
|
||||||
from backend.utils.msg_adapter import FrontendAdapter
|
from backend.utils.msg_adapter import FrontendAdapter
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ async def market_status_monitor(gateway: Any) -> None:
|
|||||||
status = gateway.market_service.get_market_status()
|
status = gateway.market_service.get_market_status()
|
||||||
if status["status"] == "open" and not gateway.storage.is_live_session_active:
|
if status["status"] == "open" and not gateway.storage.is_live_session_active:
|
||||||
gateway.storage.start_live_session()
|
gateway.storage.start_live_session()
|
||||||
summary = gateway.storage.load_file("summary") or {}
|
summary = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state).get("summary") or {}
|
||||||
gateway._session_start_portfolio_value = summary.get(
|
gateway._session_start_portfolio_value = summary.get(
|
||||||
"totalAssetValue",
|
"totalAssetValue",
|
||||||
gateway.storage.initial_cash,
|
gateway.storage.initial_cash,
|
||||||
@@ -200,6 +200,23 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
|
|||||||
trading_date = gateway.market_service.get_live_trading_date()
|
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...")
|
||||||
|
|
||||||
@@ -240,14 +257,15 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def finalize_cycle(gateway: Any, date: str) -> None:
|
async def finalize_cycle(gateway: Any, date: str) -> None:
|
||||||
summary = gateway.storage.load_file("summary") or {}
|
dashboard_snapshot = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state)
|
||||||
|
summary = dashboard_snapshot.get("summary") or {}
|
||||||
if gateway.storage.is_live_session_active:
|
if gateway.storage.is_live_session_active:
|
||||||
summary.update(gateway.storage.get_live_returns())
|
summary.update(gateway.storage.get_live_returns())
|
||||||
|
|
||||||
await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary)
|
await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary)
|
||||||
holdings = gateway.storage.load_file("holdings") or []
|
holdings = dashboard_snapshot.get("holdings") or []
|
||||||
trades = gateway.storage.load_file("trades") or []
|
trades = dashboard_snapshot.get("trades") or []
|
||||||
leaderboard = gateway.storage.load_file("leaderboard") or []
|
leaderboard = dashboard_snapshot.get("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)
|
||||||
@@ -319,7 +337,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.load_file("summary") or {}
|
summary = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state).get("summary") or {}
|
||||||
gateway._dashboard.update(status="Complete", portfolio=summary, days_completed=len(dates))
|
gateway._dashboard.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()
|
||||||
|
|||||||
@@ -164,9 +164,10 @@ 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))
|
||||||
|
|
||||||
summary = gateway.storage.load_file("summary") or {}
|
dashboard_snapshot = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state)
|
||||||
holdings = gateway.storage.load_file("holdings") or []
|
summary = dashboard_snapshot.get("summary") or {}
|
||||||
trades = gateway.storage.load_file("trades") or []
|
holdings = dashboard_snapshot.get("holdings") or []
|
||||||
|
trades = dashboard_snapshot.get("trades") or []
|
||||||
gateway._dashboard.update(
|
gateway._dashboard.update(
|
||||||
portfolio=summary,
|
portfolio=summary,
|
||||||
holdings=holdings,
|
holdings=holdings,
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ async def handle_get_stock_news(gateway: Any, websocket: Any, data: dict[str, An
|
|||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
limit=max(limit, 50),
|
limit=max(limit, 50),
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
news_rows = (payload.get("news") or [])[-limit:]
|
news_rows = (payload.get("news") or [])[-limit:]
|
||||||
source = "market_store"
|
source = "market_store"
|
||||||
@@ -202,6 +203,7 @@ async def handle_get_stock_news_for_date(gateway: Any, websocket: Any, data: dic
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
date=trade_date,
|
date=trade_date,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
news_rows = payload.get("news") or []
|
news_rows = payload.get("news") or []
|
||||||
source = "market_store"
|
source = "market_store"
|
||||||
@@ -255,6 +257,7 @@ async def handle_get_stock_news_timeline(gateway: Any, websocket: Any, data: dic
|
|||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
timeline = payload.get("timeline") or []
|
timeline = payload.get("timeline") or []
|
||||||
|
|
||||||
@@ -313,6 +316,7 @@ async def handle_get_stock_news_categories(gateway: Any, websocket: Any, data: d
|
|||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
limit=200,
|
limit=200,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
categories = payload.get("categories") or {}
|
categories = payload.get("categories") or {}
|
||||||
|
|
||||||
@@ -361,6 +365,7 @@ async def handle_get_stock_range_explain(gateway: Any, websocket: Any, data: dic
|
|||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
article_ids=article_ids if isinstance(article_ids, list) else None,
|
article_ids=article_ids if isinstance(article_ids, list) else None,
|
||||||
limit=100,
|
limit=100,
|
||||||
|
refresh_if_stale=False,
|
||||||
)
|
)
|
||||||
result = payload.get("result")
|
result = payload.get("result")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Market Data Service
|
Market Data Service
|
||||||
Supports live, mock, and backtest modes
|
Supports live and backtest modes
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
@@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pandas_market_calendars as mcal
|
import pandas_market_calendars as mcal
|
||||||
from backend.config.data_config import get_data_source
|
from backend.config.data_config import get_data_sources
|
||||||
from backend.data.provider_utils import normalize_symbol
|
from backend.data.provider_utils import normalize_symbol
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -36,7 +36,6 @@ class MarketService:
|
|||||||
self,
|
self,
|
||||||
tickers: List[str],
|
tickers: List[str],
|
||||||
poll_interval: int = 10,
|
poll_interval: int = 10,
|
||||||
mock_mode: bool = False,
|
|
||||||
backtest_mode: bool = False,
|
backtest_mode: bool = False,
|
||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
backtest_start_date: Optional[str] = None,
|
backtest_start_date: Optional[str] = None,
|
||||||
@@ -44,7 +43,6 @@ class MarketService:
|
|||||||
):
|
):
|
||||||
self.tickers = [normalize_symbol(ticker) for ticker in tickers]
|
self.tickers = [normalize_symbol(ticker) for ticker in tickers]
|
||||||
self.poll_interval = poll_interval
|
self.poll_interval = poll_interval
|
||||||
self.mock_mode = mock_mode
|
|
||||||
self.backtest_mode = backtest_mode
|
self.backtest_mode = backtest_mode
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.backtest_start_date = backtest_start_date
|
self.backtest_start_date = backtest_start_date
|
||||||
@@ -69,8 +67,6 @@ class MarketService:
|
|||||||
"""Return the active live quote provider for UI/debugging."""
|
"""Return the active live quote provider for UI/debugging."""
|
||||||
if self.backtest_mode:
|
if self.backtest_mode:
|
||||||
return "backtest"
|
return "backtest"
|
||||||
if self.mock_mode:
|
|
||||||
return "mock"
|
|
||||||
if self._price_manager and hasattr(self._price_manager, "provider"):
|
if self._price_manager and hasattr(self._price_manager, "provider"):
|
||||||
provider = getattr(self._price_manager, "provider", None)
|
provider = getattr(self._price_manager, "provider", None)
|
||||||
if isinstance(provider, str) and provider.strip():
|
if isinstance(provider, str) and provider.strip():
|
||||||
@@ -81,8 +77,6 @@ class MarketService:
|
|||||||
def mode_name(self) -> str:
|
def mode_name(self) -> str:
|
||||||
if self.backtest_mode:
|
if self.backtest_mode:
|
||||||
return "BACKTEST"
|
return "BACKTEST"
|
||||||
elif self.mock_mode:
|
|
||||||
return "MOCK"
|
|
||||||
return "LIVE"
|
return "LIVE"
|
||||||
|
|
||||||
async def start(self, broadcast_func: Callable):
|
async def start(self, broadcast_func: Callable):
|
||||||
@@ -96,8 +90,6 @@ class MarketService:
|
|||||||
|
|
||||||
if self.backtest_mode:
|
if self.backtest_mode:
|
||||||
self._start_backtest_mode()
|
self._start_backtest_mode()
|
||||||
elif self.mock_mode:
|
|
||||||
self._start_mock_mode()
|
|
||||||
else:
|
else:
|
||||||
self._start_real_mode()
|
self._start_real_mode()
|
||||||
|
|
||||||
@@ -125,26 +117,10 @@ class MarketService:
|
|||||||
|
|
||||||
return callback
|
return callback
|
||||||
|
|
||||||
def _start_mock_mode(self):
|
|
||||||
from backend.data.mock_price_manager import MockPriceManager
|
|
||||||
|
|
||||||
self._price_manager = MockPriceManager(
|
|
||||||
poll_interval=self.poll_interval,
|
|
||||||
volatility=0.5,
|
|
||||||
)
|
|
||||||
self._price_manager.add_price_callback(self._make_price_callback())
|
|
||||||
self._price_manager.subscribe(
|
|
||||||
self.tickers,
|
|
||||||
base_prices={t: 100.0 for t in self.tickers},
|
|
||||||
)
|
|
||||||
self._price_manager.start()
|
|
||||||
|
|
||||||
def _start_real_mode(self):
|
def _start_real_mode(self):
|
||||||
from backend.data.polling_price_manager import PollingPriceManager
|
from backend.data.polling_price_manager import PollingPriceManager
|
||||||
|
|
||||||
provider = get_data_source()
|
provider = self._resolve_live_quote_provider()
|
||||||
if provider == "local_csv":
|
|
||||||
provider = "yfinance"
|
|
||||||
|
|
||||||
if provider == "finnhub" and not self.api_key:
|
if provider == "finnhub" and not self.api_key:
|
||||||
raise ValueError("API key required for live mode")
|
raise ValueError("API key required for live mode")
|
||||||
@@ -157,6 +133,13 @@ class MarketService:
|
|||||||
self._price_manager.subscribe(self.tickers)
|
self._price_manager.subscribe(self.tickers)
|
||||||
self._price_manager.start()
|
self._price_manager.start()
|
||||||
|
|
||||||
|
def _resolve_live_quote_provider(self) -> str:
|
||||||
|
"""Pick the first configured provider that supports live quote polling."""
|
||||||
|
for provider in get_data_sources():
|
||||||
|
if provider in {"finnhub", "yfinance"}:
|
||||||
|
return provider
|
||||||
|
return "yfinance"
|
||||||
|
|
||||||
def _start_backtest_mode(self):
|
def _start_backtest_mode(self):
|
||||||
from backend.data.historical_price_manager import (
|
from backend.data.historical_price_manager import (
|
||||||
HistoricalPriceManager,
|
HistoricalPriceManager,
|
||||||
@@ -257,12 +240,6 @@ class MarketService:
|
|||||||
if removed:
|
if removed:
|
||||||
self._price_manager.unsubscribe(removed)
|
self._price_manager.unsubscribe(removed)
|
||||||
if added:
|
if added:
|
||||||
if self.mock_mode:
|
|
||||||
self._price_manager.subscribe(
|
|
||||||
added,
|
|
||||||
base_prices={ticker: 100.0 for ticker in added},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._price_manager.subscribe(added)
|
self._price_manager.subscribe(added)
|
||||||
|
|
||||||
if self.backtest_mode and self._current_date:
|
if self.backtest_mode and self._current_date:
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from backend.data.market_store import MarketStore
|
from backend.data.market_store import MarketStore
|
||||||
from .research_db import ResearchDb
|
|
||||||
from .runtime_db import RuntimeDb
|
from .runtime_db import RuntimeDb
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -22,12 +21,18 @@ class StorageService:
|
|||||||
Storage service for data persistence
|
Storage service for data persistence
|
||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
1. Load/save dashboard JSON files
|
1. Export dashboard JSON files
|
||||||
(summary, holdings, stats, trades, leaderboard)
|
(summary, holdings, stats, trades, leaderboard)
|
||||||
2. Load/save internal state (_internal_state.json)
|
2. Load/save internal state (_internal_state.json)
|
||||||
3. Load/save server state (server_state.json) with feed history
|
3. Load/save server state (server_state.json) with feed history
|
||||||
4. Manage portfolio state persistence
|
4. Manage portfolio state persistence
|
||||||
5. Support loading from saved state to resume execution
|
5. Support loading from saved state to resume execution
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- team_dashboard/*.json is treated as an export/compatibility layer
|
||||||
|
rather than the authoritative runtime source of truth.
|
||||||
|
- authoritative runtime reads should prefer in-memory state, server_state,
|
||||||
|
runtime.db, and market_research.db.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -49,7 +54,7 @@ class StorageService:
|
|||||||
self.initial_cash = initial_cash
|
self.initial_cash = initial_cash
|
||||||
self.config_name = config_name
|
self.config_name = config_name
|
||||||
|
|
||||||
# Dashboard file paths
|
# Dashboard export file paths
|
||||||
self.files = {
|
self.files = {
|
||||||
"summary": self.dashboard_dir / "summary.json",
|
"summary": self.dashboard_dir / "summary.json",
|
||||||
"holdings": self.dashboard_dir / "holdings.json",
|
"holdings": self.dashboard_dir / "holdings.json",
|
||||||
@@ -66,7 +71,6 @@ class StorageService:
|
|||||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.server_state_file = self.state_dir / "server_state.json"
|
self.server_state_file = self.state_dir / "server_state.json"
|
||||||
self.runtime_db = RuntimeDb(self.state_dir / "runtime.db")
|
self.runtime_db = RuntimeDb(self.state_dir / "runtime.db")
|
||||||
self.research_db = ResearchDb(self.state_dir / "research.db")
|
|
||||||
self.market_store = MarketStore()
|
self.market_store = MarketStore()
|
||||||
|
|
||||||
# Feed history (for agent messages)
|
# Feed history (for agent messages)
|
||||||
@@ -84,16 +88,8 @@ class StorageService:
|
|||||||
|
|
||||||
logger.info(f"Storage service initialized: {self.dashboard_dir}")
|
logger.info(f"Storage service initialized: {self.dashboard_dir}")
|
||||||
|
|
||||||
def load_file(self, file_type: str) -> Optional[Any]:
|
def load_export_file(self, file_type: str) -> Optional[Any]:
|
||||||
"""
|
"""Load dashboard export JSON file."""
|
||||||
Load dashboard JSON file
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_type: One of: summary, holdings, stats, trades, leaderboard
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Loaded data or None if file doesn't exist
|
|
||||||
"""
|
|
||||||
file_path = self.files.get(file_type)
|
file_path = self.files.get(file_type)
|
||||||
if not file_path or not file_path.exists():
|
if not file_path or not file_path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -105,14 +101,12 @@ class StorageService:
|
|||||||
logger.error(f"Failed to load {file_type}.json: {e}")
|
logger.error(f"Failed to load {file_type}.json: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def save_file(self, file_type: str, data: Any):
|
def load_file(self, file_type: str) -> Optional[Any]:
|
||||||
"""
|
"""Backward-compatible alias for export-layer JSON reads."""
|
||||||
Save dashboard JSON file
|
return self.load_export_file(file_type)
|
||||||
|
|
||||||
Args:
|
def save_export_file(self, file_type: str, data: Any):
|
||||||
file_type: One of: summary, holdings, stats, trades, leaderboard
|
"""Save dashboard export JSON file."""
|
||||||
data: Data to save
|
|
||||||
"""
|
|
||||||
file_path = self.files.get(file_type)
|
file_path = self.files.get(file_type)
|
||||||
if not file_path:
|
if not file_path:
|
||||||
logger.error(f"Unknown file type: {file_type}")
|
logger.error(f"Unknown file type: {file_type}")
|
||||||
@@ -129,6 +123,48 @@ class StorageService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save {file_type}.json: {e}")
|
logger.error(f"Failed to save {file_type}.json: {e}")
|
||||||
|
|
||||||
|
def save_file(self, file_type: str, data: Any):
|
||||||
|
"""Backward-compatible alias for export-layer JSON writes."""
|
||||||
|
self.save_export_file(file_type, data)
|
||||||
|
|
||||||
|
def build_dashboard_snapshot_from_state(
|
||||||
|
self,
|
||||||
|
state: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Build dashboard view data from runtime state instead of JSON exports."""
|
||||||
|
runtime_state = state or self.load_server_state()
|
||||||
|
portfolio = dict(runtime_state.get("portfolio") or {})
|
||||||
|
holdings = list(runtime_state.get("holdings") or [])
|
||||||
|
stats = runtime_state.get("stats") or self._get_default_stats()
|
||||||
|
trades = list(runtime_state.get("trades") or [])
|
||||||
|
leaderboard = list(runtime_state.get("leaderboard") or [])
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"totalAssetValue": portfolio.get("total_value", self.initial_cash),
|
||||||
|
"totalReturn": portfolio.get("pnl_percent", 0.0),
|
||||||
|
"cashPosition": portfolio.get("cash", self.initial_cash),
|
||||||
|
"tickerWeights": stats.get("tickerWeights", {}),
|
||||||
|
"totalTrades": len(trades),
|
||||||
|
"pnlPct": portfolio.get("pnl_percent", 0.0),
|
||||||
|
"balance": portfolio.get("total_value", self.initial_cash),
|
||||||
|
"equity": portfolio.get("equity", []),
|
||||||
|
"baseline": portfolio.get("baseline", []),
|
||||||
|
"baseline_vw": portfolio.get("baseline_vw", []),
|
||||||
|
"momentum": portfolio.get("momentum", []),
|
||||||
|
"equity_return": portfolio.get("equity_return", []),
|
||||||
|
"baseline_return": portfolio.get("baseline_return", []),
|
||||||
|
"baseline_vw_return": portfolio.get("baseline_vw_return", []),
|
||||||
|
"momentum_return": portfolio.get("momentum_return", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"holdings": holdings,
|
||||||
|
"stats": stats,
|
||||||
|
"trades": trades,
|
||||||
|
"leaderboard": leaderboard,
|
||||||
|
}
|
||||||
|
|
||||||
def check_file_updates(self) -> Dict[str, bool]:
|
def check_file_updates(self) -> Dict[str, bool]:
|
||||||
"""
|
"""
|
||||||
Check which dashboard files have been updated since last check
|
Check which dashboard files have been updated since last check
|
||||||
@@ -297,7 +333,7 @@ class StorageService:
|
|||||||
def initialize_empty_dashboard(self):
|
def initialize_empty_dashboard(self):
|
||||||
"""Initialize empty dashboard files with default values"""
|
"""Initialize empty dashboard files with default values"""
|
||||||
# Summary
|
# Summary
|
||||||
self.save_file(
|
self.save_export_file(
|
||||||
"summary",
|
"summary",
|
||||||
{
|
{
|
||||||
"totalAssetValue": self.initial_cash,
|
"totalAssetValue": self.initial_cash,
|
||||||
@@ -315,10 +351,10 @@ class StorageService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Holdings
|
# Holdings
|
||||||
self.save_file("holdings", [])
|
self.save_export_file("holdings", [])
|
||||||
|
|
||||||
# Stats
|
# Stats
|
||||||
self.save_file(
|
self.save_export_file(
|
||||||
"stats",
|
"stats",
|
||||||
{
|
{
|
||||||
"totalAssetValue": self.initial_cash,
|
"totalAssetValue": self.initial_cash,
|
||||||
@@ -335,7 +371,7 @@ class StorageService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Trades
|
# Trades
|
||||||
self.save_file("trades", [])
|
self.save_export_file("trades", [])
|
||||||
|
|
||||||
# Leaderboard with model info
|
# Leaderboard with model info
|
||||||
self.generate_leaderboard()
|
self.generate_leaderboard()
|
||||||
@@ -375,7 +411,7 @@ class StorageService:
|
|||||||
ranking_entries.append(entry)
|
ranking_entries.append(entry)
|
||||||
|
|
||||||
leaderboard = team_entries + ranking_entries
|
leaderboard = team_entries + ranking_entries
|
||||||
self.save_file("leaderboard", leaderboard)
|
self.save_export_file("leaderboard", leaderboard)
|
||||||
logger.info("Leaderboard generated with model info")
|
logger.info("Leaderboard generated with model info")
|
||||||
|
|
||||||
def update_leaderboard_model_info(self):
|
def update_leaderboard_model_info(self):
|
||||||
@@ -398,7 +434,7 @@ class StorageService:
|
|||||||
entry["modelName"] = model_name
|
entry["modelName"] = model_name
|
||||||
entry["modelProvider"] = model_provider
|
entry["modelProvider"] = model_provider
|
||||||
|
|
||||||
self.save_file("leaderboard", existing)
|
self.save_export_file("leaderboard", existing)
|
||||||
logger.info("Leaderboard model info updated")
|
logger.info("Leaderboard model info updated")
|
||||||
|
|
||||||
def get_current_timestamp_ms(self, date: str = None) -> int:
|
def get_current_timestamp_ms(self, date: str = None) -> int:
|
||||||
@@ -653,7 +689,7 @@ class StorageService:
|
|||||||
"momentum": state.get("momentum_history", []),
|
"momentum": state.get("momentum_history", []),
|
||||||
}
|
}
|
||||||
|
|
||||||
self.save_file("summary", summary)
|
self.save_export_file("summary", summary)
|
||||||
|
|
||||||
def _generate_holdings(
|
def _generate_holdings(
|
||||||
self,
|
self,
|
||||||
@@ -715,7 +751,7 @@ class StorageService:
|
|||||||
# Sort by weight
|
# Sort by weight
|
||||||
holdings.sort(key=lambda x: abs(x["weight"]), reverse=True)
|
holdings.sort(key=lambda x: abs(x["weight"]), reverse=True)
|
||||||
|
|
||||||
self.save_file("holdings", holdings)
|
self.save_export_file("holdings", holdings)
|
||||||
|
|
||||||
def _generate_stats(self, state: Dict[str, Any], net_value: float):
|
def _generate_stats(self, state: Dict[str, Any], net_value: float):
|
||||||
"""Generate stats.json"""
|
"""Generate stats.json"""
|
||||||
@@ -738,7 +774,7 @@ class StorageService:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
self.save_file("stats", stats)
|
self.save_export_file("stats", stats)
|
||||||
|
|
||||||
def _generate_trades(self, state: Dict[str, Any]):
|
def _generate_trades(self, state: Dict[str, Any]):
|
||||||
"""Generate trades.json"""
|
"""Generate trades.json"""
|
||||||
@@ -764,7 +800,7 @@ class StorageService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.save_file("trades", trades)
|
self.save_export_file("trades", trades)
|
||||||
|
|
||||||
# Server State Management Methods
|
# Server State Management Methods
|
||||||
|
|
||||||
@@ -1001,12 +1037,12 @@ class StorageService:
|
|||||||
Args:
|
Args:
|
||||||
state: Server state dictionary to update
|
state: Server state dictionary to update
|
||||||
"""
|
"""
|
||||||
# Load dashboard data
|
dashboard_snapshot = self.build_dashboard_snapshot_from_state(state)
|
||||||
summary = self.load_file("summary") or {}
|
summary = dashboard_snapshot.get("summary") or {}
|
||||||
holdings = self.load_file("holdings") or []
|
holdings = dashboard_snapshot.get("holdings") or []
|
||||||
stats = self.load_file("stats") or self._get_default_stats()
|
stats = dashboard_snapshot.get("stats") or self._get_default_stats()
|
||||||
trades = self.load_file("trades") or []
|
trades = dashboard_snapshot.get("trades") or []
|
||||||
leaderboard = self.load_file("leaderboard") or []
|
leaderboard = dashboard_snapshot.get("leaderboard") or []
|
||||||
internal_state = self.load_internal_state()
|
internal_state = self.load_internal_state()
|
||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
@@ -1040,7 +1076,6 @@ class StorageService:
|
|||||||
Start tracking live returns for current trading session.
|
Start tracking live returns for current trading session.
|
||||||
Captures current values as session start baseline.
|
Captures current values as session start baseline.
|
||||||
"""
|
"""
|
||||||
summary = self.load_file("summary") or {}
|
|
||||||
state = self.load_internal_state()
|
state = self.load_internal_state()
|
||||||
|
|
||||||
# Capture current values as session start
|
# Capture current values as session start
|
||||||
@@ -1052,7 +1087,7 @@ class StorageService:
|
|||||||
self._session_start_equity = (
|
self._session_start_equity = (
|
||||||
equity_history[-1]["v"]
|
equity_history[-1]["v"]
|
||||||
if equity_history
|
if equity_history
|
||||||
else summary.get("totalAssetValue", self.initial_cash)
|
else self.initial_cash
|
||||||
)
|
)
|
||||||
self._session_start_baseline = (
|
self._session_start_baseline = (
|
||||||
baseline_history[-1]["v"]
|
baseline_history[-1]["v"]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from backend.apps.agent_service import create_app
|
from backend.apps.agent_service import create_app
|
||||||
|
from backend.api import agents as agents_module
|
||||||
|
|
||||||
|
|
||||||
def test_agent_service_routes_include_control_plane_endpoints(tmp_path):
|
def test_agent_service_routes_include_control_plane_endpoints(tmp_path):
|
||||||
@@ -25,3 +26,79 @@ def test_agent_service_excludes_runtime_routes(tmp_path):
|
|||||||
|
|
||||||
assert "/api/runtime/start" not in paths
|
assert "/api/runtime/start" not in paths
|
||||||
assert "/api/runtime/gateway/port" not in paths
|
assert "/api/runtime/gateway/port" not in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_service_read_routes(monkeypatch, tmp_path):
|
||||||
|
class _FakeSkillsManager:
|
||||||
|
project_root = tmp_path
|
||||||
|
|
||||||
|
def get_agent_asset_dir(self, config_name, agent_id):
|
||||||
|
return tmp_path / "runs" / config_name / "agents" / agent_id
|
||||||
|
|
||||||
|
def resolve_agent_skill_names(self, config_name, agent_id, default_skills=None):
|
||||||
|
return ["demo_skill"]
|
||||||
|
|
||||||
|
def list_agent_skill_catalog(self, config_name, agent_id):
|
||||||
|
return [
|
||||||
|
type(
|
||||||
|
"Skill",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"skill_name": "demo_skill",
|
||||||
|
"name": "Demo Skill",
|
||||||
|
"description": "demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "builtin",
|
||||||
|
"tools": [],
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
]
|
||||||
|
|
||||||
|
def load_agent_skill_document(self, config_name, agent_id, skill_name):
|
||||||
|
return {"skill_name": skill_name, "content": "# demo"}
|
||||||
|
|
||||||
|
class _FakeWorkspaceManager:
|
||||||
|
def load_agent_file(self, config_name, agent_id, filename):
|
||||||
|
return f"{config_name}:{agent_id}:{filename}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(agents_module, "load_agent_profiles", lambda: {"portfolio_manager": {"skills": ["demo_skill"]}})
|
||||||
|
monkeypatch.setattr(agents_module, "get_agent_model_info", lambda agent_id: ("deepseek-v3.2", "DASHSCOPE"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agents_module,
|
||||||
|
"load_agent_workspace_config",
|
||||||
|
lambda path: type(
|
||||||
|
"Cfg",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"active_tool_groups": ["portfolio_ops"],
|
||||||
|
"disabled_tool_groups": [],
|
||||||
|
"enabled_skills": [],
|
||||||
|
"disabled_skills": [],
|
||||||
|
"prompt_files": ["SOUL.md", "MEMORY.md"],
|
||||||
|
},
|
||||||
|
)(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agents_module,
|
||||||
|
"get_bootstrap_config_for_run",
|
||||||
|
lambda project_root, config_name: type("Bootstrap", (), {"agent_override": lambda self, agent_id: {}})(),
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_app(project_root=tmp_path)
|
||||||
|
app.dependency_overrides[agents_module.get_skills_manager] = lambda: _FakeSkillsManager()
|
||||||
|
app.dependency_overrides[agents_module.get_workspace_manager] = lambda: _FakeWorkspaceManager()
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
profile = client.get("/api/workspaces/demo/agents/portfolio_manager/profile")
|
||||||
|
skills = client.get("/api/workspaces/demo/agents/portfolio_manager/skills")
|
||||||
|
detail = client.get("/api/workspaces/demo/agents/portfolio_manager/skills/demo_skill")
|
||||||
|
workspace_file = client.get("/api/workspaces/demo/agents/portfolio_manager/files/MEMORY.md")
|
||||||
|
|
||||||
|
assert profile.status_code == 200
|
||||||
|
assert profile.json()["profile"]["model_name"] == "deepseek-v3.2"
|
||||||
|
assert skills.status_code == 200
|
||||||
|
assert skills.json()["skills"][0]["skill_name"] == "demo_skill"
|
||||||
|
assert detail.status_code == 200
|
||||||
|
assert detail.json()["skill"]["content"] == "# demo"
|
||||||
|
assert workspace_file.status_code == 200
|
||||||
|
assert workspace_file.json()["content"] == "demo:portfolio_manager:MEMORY.md"
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ def test_live_runs_incremental_market_store_update_before_start(monkeypatch, tmp
|
|||||||
monkeypatch.setattr(cli.subprocess, "run", fake_run)
|
monkeypatch.setattr(cli.subprocess, "run", fake_run)
|
||||||
|
|
||||||
cli.live(
|
cli.live(
|
||||||
mock=False,
|
|
||||||
config_name="smoke_fullstack",
|
config_name="smoke_fullstack",
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=8765,
|
port=8765,
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ class _DummyStorage:
|
|||||||
return {"totalAssetValue": self.initial_cash}
|
return {"totalAssetValue": self.initial_cash}
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def build_dashboard_snapshot_from_state(self, state):
|
||||||
|
return {
|
||||||
|
"summary": {"totalAssetValue": self.initial_cash},
|
||||||
|
"holdings": [],
|
||||||
|
"stats": {},
|
||||||
|
"trades": [],
|
||||||
|
"leaderboard": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class _DummyPM:
|
class _DummyPM:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
@@ -2,153 +2,12 @@
|
|||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
from unittest.mock import MagicMock, AsyncMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
from backend.services.market import MarketService
|
from backend.services.market import MarketService
|
||||||
from backend.data.mock_price_manager import MockPriceManager
|
|
||||||
from backend.data.polling_price_manager import PollingPriceManager
|
from backend.data.polling_price_manager import PollingPriceManager
|
||||||
|
from backend.llm.models import RetryChatModel
|
||||||
|
|
||||||
class TestMockPriceManager:
|
|
||||||
def test_init_default(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
|
|
||||||
assert manager.poll_interval == 10
|
|
||||||
assert manager.volatility == 0.5
|
|
||||||
assert manager.running is False
|
|
||||||
assert len(manager.subscribed_symbols) == 0
|
|
||||||
|
|
||||||
def test_init_custom(self):
|
|
||||||
manager = MockPriceManager(poll_interval=5, volatility=1.0)
|
|
||||||
|
|
||||||
assert manager.poll_interval == 5
|
|
||||||
assert manager.volatility == 1.0
|
|
||||||
|
|
||||||
def test_subscribe(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL", "MSFT"])
|
|
||||||
|
|
||||||
assert "AAPL" in manager.subscribed_symbols
|
|
||||||
assert "MSFT" in manager.subscribed_symbols
|
|
||||||
assert manager.base_prices["AAPL"] == 237.50 # default price
|
|
||||||
assert manager.base_prices["MSFT"] == 425.30 # default price
|
|
||||||
|
|
||||||
def test_subscribe_with_base_prices(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
assert manager.base_prices["AAPL"] == 100.0
|
|
||||||
assert manager.open_prices["AAPL"] == 100.0
|
|
||||||
assert manager.latest_prices["AAPL"] == 100.0
|
|
||||||
|
|
||||||
def test_subscribe_unknown_symbol(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["UNKNOWN"])
|
|
||||||
|
|
||||||
assert "UNKNOWN" in manager.subscribed_symbols
|
|
||||||
assert manager.base_prices["UNKNOWN"] > 0 # random price generated
|
|
||||||
|
|
||||||
def test_unsubscribe(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL", "MSFT"])
|
|
||||||
manager.unsubscribe(["AAPL"])
|
|
||||||
|
|
||||||
assert "AAPL" not in manager.subscribed_symbols
|
|
||||||
assert "MSFT" in manager.subscribed_symbols
|
|
||||||
|
|
||||||
def test_add_price_callback(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
callback = MagicMock()
|
|
||||||
manager.add_price_callback(callback)
|
|
||||||
|
|
||||||
assert callback in manager.price_callbacks
|
|
||||||
|
|
||||||
def test_generate_price_update_within_bounds(self):
|
|
||||||
manager = MockPriceManager(volatility=0.5)
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
for _ in range(100):
|
|
||||||
new_price = manager._generate_price_update("AAPL")
|
|
||||||
# Should be within +/-10% of open
|
|
||||||
assert 90.0 <= new_price <= 110.0
|
|
||||||
|
|
||||||
def test_update_prices_triggers_callback(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
callback = MagicMock()
|
|
||||||
manager.add_price_callback(callback)
|
|
||||||
|
|
||||||
manager._update_prices()
|
|
||||||
|
|
||||||
callback.assert_called_once()
|
|
||||||
call_args = callback.call_args[0][0]
|
|
||||||
assert call_args["symbol"] == "AAPL"
|
|
||||||
assert "price" in call_args
|
|
||||||
assert "timestamp" in call_args
|
|
||||||
|
|
||||||
def test_start_stop(self):
|
|
||||||
manager = MockPriceManager(poll_interval=1)
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
manager.start()
|
|
||||||
assert manager.running is True
|
|
||||||
|
|
||||||
time.sleep(0.1) # let thread start
|
|
||||||
|
|
||||||
manager.stop()
|
|
||||||
assert manager.running is False
|
|
||||||
|
|
||||||
def test_start_without_subscription(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.start()
|
|
||||||
|
|
||||||
assert (
|
|
||||||
manager.running is False
|
|
||||||
) # should not start without subscriptions
|
|
||||||
|
|
||||||
def test_get_latest_price(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
price = manager.get_latest_price("AAPL")
|
|
||||||
assert price == 100.0
|
|
||||||
|
|
||||||
def test_get_latest_price_unknown(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
price = manager.get_latest_price("UNKNOWN")
|
|
||||||
assert price is None
|
|
||||||
|
|
||||||
def test_get_all_latest_prices(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(
|
|
||||||
["AAPL", "MSFT"],
|
|
||||||
base_prices={"AAPL": 100.0, "MSFT": 200.0},
|
|
||||||
)
|
|
||||||
|
|
||||||
prices = manager.get_all_latest_prices()
|
|
||||||
assert prices["AAPL"] == 100.0
|
|
||||||
assert prices["MSFT"] == 200.0
|
|
||||||
|
|
||||||
def test_reset_open_prices(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
manager.latest_prices["AAPL"] = 105.0
|
|
||||||
|
|
||||||
manager.reset_open_prices()
|
|
||||||
|
|
||||||
# Open price should change (based on latest with small gap)
|
|
||||||
assert manager.open_prices["AAPL"] != 100.0
|
|
||||||
|
|
||||||
def test_set_base_price(self):
|
|
||||||
manager = MockPriceManager()
|
|
||||||
manager.subscribe(["AAPL"], base_prices={"AAPL": 100.0})
|
|
||||||
|
|
||||||
manager.set_base_price("AAPL", 150.0)
|
|
||||||
|
|
||||||
assert manager.base_prices["AAPL"] == 150.0
|
|
||||||
assert manager.open_prices["AAPL"] == 150.0
|
|
||||||
assert manager.latest_prices["AAPL"] == 150.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestPollingPriceManager:
|
class TestPollingPriceManager:
|
||||||
@@ -231,37 +90,67 @@ class TestPollingPriceManager:
|
|||||||
|
|
||||||
assert len(manager.open_prices) == 0
|
assert len(manager.open_prices) == 0
|
||||||
|
|
||||||
|
def test_fetch_prices_suppresses_repeated_failures(self, caplog):
|
||||||
|
manager = PollingPriceManager(provider="yfinance", poll_interval=10)
|
||||||
|
manager.subscribe(["AAPL"])
|
||||||
|
|
||||||
|
with patch.object(manager, "_fetch_quote", side_effect=ValueError("empty quote")):
|
||||||
|
with caplog.at_level(logging.DEBUG):
|
||||||
|
for _ in range(3):
|
||||||
|
manager._fetch_prices()
|
||||||
|
|
||||||
|
assert manager._failure_counts["AAPL"] == 3
|
||||||
|
warning_messages = [record.message for record in caplog.records if record.levelno >= logging.WARNING]
|
||||||
|
assert any("Failed to fetch AAPL price: empty quote" in message for message in warning_messages)
|
||||||
|
|
||||||
|
def test_fetch_prices_logs_recovery_after_failure(self, caplog):
|
||||||
|
manager = PollingPriceManager(provider="yfinance", poll_interval=10)
|
||||||
|
manager.subscribe(["AAPL"])
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
manager,
|
||||||
|
"_fetch_quote",
|
||||||
|
side_effect=[
|
||||||
|
ValueError("temporary outage"),
|
||||||
|
{"c": 100.0, "o": 99.0, "h": 101.0, "l": 98.0, "pc": 99.5, "d": 0.5, "dp": 0.5, "t": 1},
|
||||||
|
],
|
||||||
|
):
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
manager._fetch_prices()
|
||||||
|
manager._fetch_prices()
|
||||||
|
|
||||||
|
assert "AAPL" not in manager._failure_counts
|
||||||
|
assert any("recovered after 1 consecutive failures" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryChatModel:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_retry_recovers_from_disconnect(self):
|
||||||
|
attempts = {"count": 0}
|
||||||
|
|
||||||
|
class FakeAsyncModel:
|
||||||
|
model_name = "fake-async-model"
|
||||||
|
|
||||||
|
async def __call__(self, *args, **kwargs):
|
||||||
|
attempts["count"] += 1
|
||||||
|
if attempts["count"] < 2:
|
||||||
|
raise RuntimeError("Server disconnected")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
wrapped = RetryChatModel(FakeAsyncModel(), max_retries=2, initial_delay=0.01)
|
||||||
|
result = await wrapped("hello")
|
||||||
|
|
||||||
|
assert result == {"ok": True}
|
||||||
|
assert attempts["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
class TestMarketService:
|
class TestMarketService:
|
||||||
def test_init_mock_mode(self):
|
@patch("backend.services.market.get_data_sources", return_value=["yfinance", "local_csv"])
|
||||||
service = MarketService(
|
|
||||||
tickers=["AAPL", "MSFT"],
|
|
||||||
poll_interval=10,
|
|
||||||
mock_mode=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert service.tickers == ["AAPL", "MSFT"]
|
|
||||||
assert service.poll_interval == 10
|
|
||||||
assert service.mock_mode is True
|
|
||||||
assert service.running is False
|
|
||||||
|
|
||||||
def test_init_real_mode(self):
|
|
||||||
service = MarketService(
|
|
||||||
tickers=["AAPL"],
|
|
||||||
mock_mode=False,
|
|
||||||
api_key="test_key",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert service.mock_mode is False
|
|
||||||
assert service.api_key == "test_key"
|
|
||||||
|
|
||||||
@patch("backend.services.market.get_data_source", return_value="yfinance")
|
|
||||||
@patch.object(PollingPriceManager, "start")
|
@patch.object(PollingPriceManager, "start")
|
||||||
def test_start_real_mode_with_yfinance(self, _mock_start, _mock_source):
|
def test_start_real_mode_with_yfinance(self, _mock_start, _mock_sources):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
poll_interval=10,
|
poll_interval=10,
|
||||||
mock_mode=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
service._start_real_mode()
|
service._start_real_mode()
|
||||||
@@ -269,30 +158,24 @@ class TestMarketService:
|
|||||||
assert isinstance(service._price_manager, PollingPriceManager)
|
assert isinstance(service._price_manager, PollingPriceManager)
|
||||||
assert service._price_manager.provider == "yfinance"
|
assert service._price_manager.provider == "yfinance"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@patch("backend.services.market.get_data_sources", return_value=["financial_datasets", "yfinance", "local_csv"])
|
||||||
async def test_start_mock_mode(self):
|
@patch.object(PollingPriceManager, "start")
|
||||||
|
def test_start_real_mode_uses_first_supported_live_provider(self, _mock_start, _mock_sources):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
poll_interval=10,
|
poll_interval=10,
|
||||||
mock_mode=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
broadcast_func = AsyncMock()
|
service._start_real_mode()
|
||||||
|
|
||||||
await service.start(broadcast_func)
|
assert isinstance(service._price_manager, PollingPriceManager)
|
||||||
|
assert service._price_manager.provider == "yfinance"
|
||||||
|
|
||||||
assert service.running is True
|
@patch("backend.services.market.get_data_sources", return_value=["finnhub", "yfinance"])
|
||||||
assert service._price_manager is not None
|
|
||||||
assert isinstance(service._price_manager, MockPriceManager)
|
|
||||||
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
@patch("backend.services.market.get_data_source", return_value="finnhub")
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_real_mode_without_api_key(self, _mock_source):
|
async def test_start_real_mode_without_api_key(self, _mock_sources):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=False,
|
|
||||||
api_key=None,
|
api_key=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -307,11 +190,12 @@ class TestMarketService:
|
|||||||
async def test_start_already_running(self):
|
async def test_start_already_running(self):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=True,
|
backtest_mode=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
broadcast_func = AsyncMock()
|
broadcast_func = AsyncMock()
|
||||||
|
|
||||||
|
# First start with backtest mode
|
||||||
await service.start(broadcast_func)
|
await service.start(broadcast_func)
|
||||||
assert service.running is True
|
assert service.running is True
|
||||||
|
|
||||||
@@ -323,7 +207,7 @@ class TestMarketService:
|
|||||||
def test_stop(self):
|
def test_stop(self):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=True,
|
backtest_mode=True,
|
||||||
)
|
)
|
||||||
service.running = True
|
service.running = True
|
||||||
service._price_manager = MagicMock()
|
service._price_manager = MagicMock()
|
||||||
@@ -336,7 +220,7 @@ class TestMarketService:
|
|||||||
def test_stop_when_not_running(self):
|
def test_stop_when_not_running(self):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=True,
|
backtest_mode=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should not raise
|
# Should not raise
|
||||||
@@ -344,20 +228,20 @@ class TestMarketService:
|
|||||||
assert service.running is False
|
assert service.running is False
|
||||||
|
|
||||||
def test_get_price_sync(self):
|
def test_get_price_sync(self):
|
||||||
service = MarketService(tickers=["AAPL"], mock_mode=True)
|
service = MarketService(tickers=["AAPL"], backtest_mode=True)
|
||||||
service.cache["AAPL"] = {"price": 150.0, "open": 148.0}
|
service.cache["AAPL"] = {"price": 150.0, "open": 148.0}
|
||||||
|
|
||||||
price = service.get_price_sync("AAPL")
|
price = service.get_price_sync("AAPL")
|
||||||
assert price == 150.0
|
assert price == 150.0
|
||||||
|
|
||||||
def test_get_price_sync_not_found(self):
|
def test_get_price_sync_not_found(self):
|
||||||
service = MarketService(tickers=["AAPL"], mock_mode=True)
|
service = MarketService(tickers=["AAPL"], backtest_mode=True)
|
||||||
|
|
||||||
price = service.get_price_sync("MSFT")
|
price = service.get_price_sync("MSFT")
|
||||||
assert price is None
|
assert price is None
|
||||||
|
|
||||||
def test_get_all_prices(self):
|
def test_get_all_prices(self):
|
||||||
service = MarketService(tickers=["AAPL", "MSFT"], mock_mode=True)
|
service = MarketService(tickers=["AAPL", "MSFT"], backtest_mode=True)
|
||||||
service.cache["AAPL"] = {"price": 150.0}
|
service.cache["AAPL"] = {"price": 150.0}
|
||||||
service.cache["MSFT"] = {"price": 400.0}
|
service.cache["MSFT"] = {"price": 400.0}
|
||||||
|
|
||||||
@@ -368,7 +252,7 @@ class TestMarketService:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_broadcast_price_update(self):
|
async def test_broadcast_price_update(self):
|
||||||
service = MarketService(tickers=["AAPL"], mock_mode=True)
|
service = MarketService(tickers=["AAPL"], backtest_mode=True)
|
||||||
service._broadcast_func = AsyncMock()
|
service._broadcast_func = AsyncMock()
|
||||||
|
|
||||||
price_data = {
|
price_data = {
|
||||||
@@ -388,7 +272,7 @@ class TestMarketService:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_broadcast_price_update_no_func(self):
|
async def test_broadcast_price_update_no_func(self):
|
||||||
service = MarketService(tickers=["AAPL"], mock_mode=True)
|
service = MarketService(tickers=["AAPL"], backtest_mode=True)
|
||||||
service._broadcast_func = None
|
service._broadcast_func = None
|
||||||
|
|
||||||
price_data = {"symbol": "AAPL", "price": 150.0, "open": 148.0}
|
price_data = {"symbol": "AAPL", "price": 150.0, "open": 148.0}
|
||||||
@@ -396,67 +280,6 @@ class TestMarketService:
|
|||||||
# Should not raise
|
# Should not raise
|
||||||
await service._broadcast_price_update(price_data)
|
await service._broadcast_price_update(price_data)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_price_callback_thread_safety(self):
|
|
||||||
service = MarketService(
|
|
||||||
tickers=["AAPL"],
|
|
||||||
poll_interval=1,
|
|
||||||
mock_mode=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
received_prices = []
|
|
||||||
|
|
||||||
async def capture_broadcast(msg):
|
|
||||||
received_prices.append(msg)
|
|
||||||
|
|
||||||
await service.start(capture_broadcast)
|
|
||||||
|
|
||||||
# Wait for at least one price update
|
|
||||||
await asyncio.sleep(1.5)
|
|
||||||
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
# Should have received at least one price update
|
|
||||||
assert len(received_prices) >= 1
|
|
||||||
assert received_prices[0]["type"] == "price_update"
|
|
||||||
|
|
||||||
|
|
||||||
class TestMarketServiceIntegration:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_full_mock_cycle(self):
|
|
||||||
service = MarketService(
|
|
||||||
tickers=["AAPL", "MSFT"],
|
|
||||||
poll_interval=1,
|
|
||||||
mock_mode=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
messages = []
|
|
||||||
|
|
||||||
async def collect_messages(msg):
|
|
||||||
messages.append(msg)
|
|
||||||
|
|
||||||
await service.start(collect_messages)
|
|
||||||
|
|
||||||
# Wait for price updates
|
|
||||||
await asyncio.sleep(2.5)
|
|
||||||
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
# Should have received multiple price updates
|
|
||||||
assert len(messages) >= 2
|
|
||||||
|
|
||||||
# Check message structure
|
|
||||||
symbols_seen = set()
|
|
||||||
for msg in messages:
|
|
||||||
assert msg["type"] == "price_update"
|
|
||||||
assert "symbol" in msg
|
|
||||||
assert "price" in msg
|
|
||||||
assert "ret" in msg
|
|
||||||
symbols_seen.add(msg["symbol"])
|
|
||||||
|
|
||||||
# Should have prices for both tickers
|
|
||||||
assert "AAPL" in symbols_seen or "MSFT" in symbols_seen
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def test_get_enriched_news_returns_rows_without_enrichment_when_present(monkeypa
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
news_domain,
|
news_domain,
|
||||||
"ensure_news_fresh",
|
"ensure_news_fresh",
|
||||||
lambda store, ticker, target_date=None: {
|
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"target_date": target_date,
|
"target_date": target_date,
|
||||||
"last_news_fetch": target_date,
|
"last_news_fetch": target_date,
|
||||||
@@ -109,7 +109,7 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
news_domain,
|
news_domain,
|
||||||
"ensure_news_fresh",
|
"ensure_news_fresh",
|
||||||
lambda store, ticker, target_date=None: {
|
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"target_date": target_date,
|
"target_date": target_date,
|
||||||
"last_news_fetch": target_date,
|
"last_news_fetch": target_date,
|
||||||
@@ -137,12 +137,38 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
|
|||||||
assert "freshness" in similar
|
assert "freshness" in similar
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_enriched_news_defaults_to_read_only_freshness(monkeypatch):
|
||||||
|
store = _FakeStore()
|
||||||
|
ensure_calls = []
|
||||||
|
|
||||||
|
def fake_ensure(store, ticker, target_date=None, refresh_if_stale=False):
|
||||||
|
ensure_calls.append(refresh_if_stale)
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"target_date": target_date,
|
||||||
|
"last_news_fetch": target_date,
|
||||||
|
"refreshed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(news_domain, "ensure_news_fresh", fake_ensure)
|
||||||
|
monkeypatch.setattr(news_domain, "news_rows_need_enrichment", lambda rows: False)
|
||||||
|
|
||||||
|
payload = news_domain.get_enriched_news(
|
||||||
|
store,
|
||||||
|
ticker="AAPL",
|
||||||
|
end_date="2026-03-16",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["ticker"] == "AAPL"
|
||||||
|
assert ensure_calls == [False]
|
||||||
|
|
||||||
|
|
||||||
def test_get_range_explain_payload_uses_article_ids(monkeypatch):
|
def test_get_range_explain_payload_uses_article_ids(monkeypatch):
|
||||||
store = _FakeStore()
|
store = _FakeStore()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
news_domain,
|
news_domain,
|
||||||
"ensure_news_fresh",
|
"ensure_news_fresh",
|
||||||
lambda store, ticker, target_date=None: {
|
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"target_date": target_date,
|
"target_date": target_date,
|
||||||
"last_news_fetch": target_date,
|
"last_news_fetch": target_date,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Tests for the extracted runtime service app surface."""
|
"""Tests for the extracted runtime service app surface."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ def test_runtime_service_routes_are_exposed():
|
|||||||
assert "/api/status" in paths
|
assert "/api/status" in paths
|
||||||
assert "/api/runtime/start" in paths
|
assert "/api/runtime/start" in paths
|
||||||
assert "/api/runtime/stop" in paths
|
assert "/api/runtime/stop" in paths
|
||||||
|
assert "/api/runtime/cleanup" in paths
|
||||||
|
assert "/api/runtime/history" in paths
|
||||||
assert "/api/runtime/current" in paths
|
assert "/api/runtime/current" in paths
|
||||||
assert "/api/runtime/gateway/port" in paths
|
assert "/api/runtime/gateway/port" in paths
|
||||||
|
|
||||||
@@ -192,3 +195,170 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
|
|||||||
assert payload["bootstrap"]["schedule_mode"] == "intraday"
|
assert payload["bootstrap"]["schedule_mode"] == "intraday"
|
||||||
assert payload["resolved"]["interval_minutes"] == 15
|
assert payload["resolved"]["interval_minutes"] == 15
|
||||||
assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8")
|
assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_old_timestamped_runs_keeps_named_runs(monkeypatch, tmp_path):
|
||||||
|
runs_dir = tmp_path / "runs"
|
||||||
|
runs_dir.mkdir()
|
||||||
|
|
||||||
|
keep_dirs = ["20260324_110000", "20260324_120000"]
|
||||||
|
prune_dir = "20260324_100000"
|
||||||
|
named_dir = "smoke_fullstack"
|
||||||
|
|
||||||
|
for name in [*keep_dirs, prune_dir, named_dir]:
|
||||||
|
(runs_dir / name).mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
|
||||||
|
pruned = runtime_module._prune_old_timestamped_runs(keep=1, exclude_run_ids={"20260324_120000"})
|
||||||
|
|
||||||
|
assert prune_dir in pruned
|
||||||
|
assert (runs_dir / named_dir).exists()
|
||||||
|
assert (runs_dir / "20260324_120000").exists()
|
||||||
|
assert (runs_dir / "20260324_110000").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_cleanup_endpoint_prunes_old_runs(monkeypatch, tmp_path):
|
||||||
|
runs_dir = tmp_path / "runs"
|
||||||
|
runs_dir.mkdir()
|
||||||
|
|
||||||
|
for name in ["20260324_090000", "20260324_100000", "20260324_110000", "smoke_fullstack"]:
|
||||||
|
(runs_dir / name).mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(runtime_module, "_is_gateway_running", lambda: False)
|
||||||
|
|
||||||
|
with TestClient(create_app()) as client:
|
||||||
|
response = client.post("/api/runtime/cleanup?keep=1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["status"] == "ok"
|
||||||
|
assert sorted(payload["pruned_run_ids"]) == ["20260324_090000", "20260324_100000"]
|
||||||
|
assert (runs_dir / "20260324_110000").exists()
|
||||||
|
assert (runs_dir / "smoke_fullstack").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_history_lists_recent_runs(monkeypatch, tmp_path):
|
||||||
|
run_dir = tmp_path / "runs" / "20260324_120000"
|
||||||
|
(run_dir / "state").mkdir(parents=True)
|
||||||
|
(run_dir / "team_dashboard").mkdir(parents=True)
|
||||||
|
(run_dir / "state" / "runtime_state.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"context": {
|
||||||
|
"config_name": "20260324_120000",
|
||||||
|
"run_dir": str(run_dir),
|
||||||
|
"bootstrap_values": {"tickers": ["AAPL"]},
|
||||||
|
},
|
||||||
|
"events": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(run_dir / "team_dashboard" / "summary.json").write_text(
|
||||||
|
json.dumps({"totalTrades": 3, "totalAssetValue": 123456.0}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
|
||||||
|
with TestClient(create_app()) as client:
|
||||||
|
response = client.get("/api/runtime/history?limit=5")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["runs"][0]["run_id"] == "20260324_120000"
|
||||||
|
assert payload["runs"][0]["total_trades"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_run_assets_copies_state(monkeypatch, tmp_path):
|
||||||
|
source_run = tmp_path / "runs" / "20260324_100000"
|
||||||
|
(source_run / "team_dashboard").mkdir(parents=True)
|
||||||
|
(source_run / "state").mkdir(parents=True)
|
||||||
|
(source_run / "agents").mkdir(parents=True)
|
||||||
|
(source_run / "team_dashboard" / "_internal_state.json").write_text("{}", encoding="utf-8")
|
||||||
|
(source_run / "state" / "server_state.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
target_run = tmp_path / "runs" / "20260324_130000"
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
|
||||||
|
runtime_module._restore_run_assets("20260324_100000", target_run)
|
||||||
|
|
||||||
|
assert (target_run / "team_dashboard" / "_internal_state.json").exists()
|
||||||
|
assert (target_run / "state" / "server_state.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_runtime_restore_reuses_historical_run_id(monkeypatch, tmp_path):
|
||||||
|
run_dir = tmp_path / "runs" / "20260324_100000"
|
||||||
|
(run_dir / "state").mkdir(parents=True)
|
||||||
|
(run_dir / "state" / "runtime_state.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"context": {
|
||||||
|
"config_name": "20260324_100000",
|
||||||
|
"run_dir": str(run_dir),
|
||||||
|
"bootstrap_values": {
|
||||||
|
"tickers": ["AAPL"],
|
||||||
|
"schedule_mode": "intraday",
|
||||||
|
"interval_minutes": 30,
|
||||||
|
"trigger_time": "now",
|
||||||
|
"max_comm_cycles": 2,
|
||||||
|
"initial_cash": 100000.0,
|
||||||
|
"margin_requirement": 0.0,
|
||||||
|
"enable_memory": False,
|
||||||
|
"mode": "live",
|
||||||
|
"poll_interval": 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
class _DummyManager:
|
||||||
|
def __init__(self, config_name, run_dir, bootstrap):
|
||||||
|
self.config_name = config_name
|
||||||
|
self.run_dir = Path(run_dir)
|
||||||
|
self.bootstrap = bootstrap
|
||||||
|
self.context = None
|
||||||
|
|
||||||
|
def prepare_run(self):
|
||||||
|
self.context = type(
|
||||||
|
"Ctx",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"config_name": self.config_name,
|
||||||
|
"run_dir": self.run_dir,
|
||||||
|
"bootstrap_values": self.bootstrap,
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
return self.context
|
||||||
|
|
||||||
|
class _DummyProcess:
|
||||||
|
def poll(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(runtime_module, "_find_available_port", lambda start_port=8765, max_port=9000: 8765)
|
||||||
|
monkeypatch.setattr(runtime_module, "_start_gateway_process", lambda **kwargs: _DummyProcess())
|
||||||
|
monkeypatch.setattr(runtime_module, "_stop_gateway", lambda: True)
|
||||||
|
monkeypatch.setattr("backend.runtime.manager.TradingRuntimeManager", _DummyManager)
|
||||||
|
runtime_state = runtime_module.get_runtime_state()
|
||||||
|
runtime_state.gateway_process = None
|
||||||
|
|
||||||
|
with TestClient(create_app()) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/runtime/start",
|
||||||
|
json={
|
||||||
|
"launch_mode": "restore",
|
||||||
|
"restore_run_id": "20260324_100000",
|
||||||
|
"tickers": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["run_id"] == "20260324_100000"
|
||||||
|
assert payload["run_dir"] == str(run_dir)
|
||||||
|
|||||||
@@ -228,12 +228,12 @@ class SettlementCoordinator:
|
|||||||
|
|
||||||
all_evaluations = {**analyst_evaluations, **pm_evaluations}
|
all_evaluations = {**analyst_evaluations, **pm_evaluations}
|
||||||
|
|
||||||
leaderboard = self.storage.load_file("leaderboard") or []
|
leaderboard = self.storage.load_export_file("leaderboard") or []
|
||||||
updated_leaderboard = update_leaderboard_with_evaluations(
|
updated_leaderboard = update_leaderboard_with_evaluations(
|
||||||
leaderboard,
|
leaderboard,
|
||||||
all_evaluations,
|
all_evaluations,
|
||||||
)
|
)
|
||||||
self.storage.save_file("leaderboard", updated_leaderboard)
|
self.storage.save_export_file("leaderboard", updated_leaderboard)
|
||||||
|
|
||||||
self._update_summary_with_baselines(
|
self._update_summary_with_baselines(
|
||||||
date,
|
date,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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 = ""
|
||||||
@@ -65,7 +64,6 @@ 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 = "",
|
||||||
@@ -82,7 +80,6 @@ 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
|
||||||
@@ -109,8 +106,6 @@ 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]"
|
||||||
|
|
||||||
@@ -216,8 +211,6 @@ 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]"
|
||||||
|
|
||||||
|
|||||||
3338
frontend/src/App.jsx
3338
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
borderBottom: '2px solid #000000',
|
borderBottom: '2px solid #000000',
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||||
zIndex: 1000,
|
zIndex: 800,
|
||||||
animation: isClosing ? 'slideUp 0.2s ease-out forwards' : 'slideDown 0.25s ease-out'
|
animation: isClosing ? 'slideUp 0.2s ease-out forwards' : 'slideDown 0.25s ease-out'
|
||||||
}}>
|
}}>
|
||||||
{/* Horizontal scrollable content */}
|
{/* Horizontal scrollable content */}
|
||||||
|
|||||||
@@ -35,14 +35,22 @@ const stripMarkdown = (text) => {
|
|||||||
.replace(/^[-=]+$/gm, '');
|
.replace(/^[-=]+$/gm, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
|
const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) => {
|
||||||
const feedContentRef = useRef(null);
|
const feedContentRef = useRef(null);
|
||||||
const [highlightedId, setHighlightedId] = useState(null);
|
const [highlightedId, setHighlightedId] = useState(null);
|
||||||
const [selectedAgent, setSelectedAgent] = useState('all');
|
const [selectedAgent, setSelectedAgent] = useState('all');
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const getAgentModelInfo = (agentId) => {
|
const getAgentModelInfo = (agentId) => {
|
||||||
if (!leaderboard || !agentId) return { modelName: null, modelProvider: null };
|
if (!agentId) return { modelName: null, modelProvider: null };
|
||||||
|
const profile = agentProfilesByAgent?.[agentId];
|
||||||
|
if (profile?.model_name) {
|
||||||
|
return {
|
||||||
|
modelName: profile.model_name,
|
||||||
|
modelProvider: profile.model_provider
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!leaderboard) return { modelName: null, modelProvider: null };
|
||||||
const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId);
|
const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId);
|
||||||
return {
|
return {
|
||||||
modelName: agentData?.modelName,
|
modelName: agentData?.modelName,
|
||||||
@@ -52,7 +60,17 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => {
|
|||||||
|
|
||||||
// Get agent info by name
|
// Get agent info by name
|
||||||
const getAgentInfoByName = (agentName) => {
|
const getAgentInfoByName = (agentName) => {
|
||||||
if (!leaderboard || !agentName) return null;
|
if (!agentName) return null;
|
||||||
|
const agentConfig = AGENTS.find((agent) => agent.name === agentName);
|
||||||
|
const profile = agentConfig ? agentProfilesByAgent?.[agentConfig.id] : null;
|
||||||
|
if (agentConfig && profile?.model_name) {
|
||||||
|
return {
|
||||||
|
agentId: agentConfig.id,
|
||||||
|
modelName: profile.model_name,
|
||||||
|
modelProvider: profile.model_provider
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!leaderboard) return null;
|
||||||
const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName);
|
const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName);
|
||||||
if (!agentData) return null;
|
if (!agentData) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
506
frontend/src/components/AppShell.jsx
Normal file
506
frontend/src/components/AppShell.jsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import React, { Suspense, lazy, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import GlobalStyles from '../styles/GlobalStyles';
|
||||||
|
import Header from './Header.jsx';
|
||||||
|
import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx';
|
||||||
|
import StockLogo from './StockLogo.jsx';
|
||||||
|
import NetValueChart from './NetValueChart.jsx';
|
||||||
|
import { AGENTS } from '../config/constants';
|
||||||
|
import { useRuntimeStore } from '../store/runtimeStore';
|
||||||
|
import { useUIStore } from '../store/uiStore';
|
||||||
|
import { formatNumber, formatTickerPrice } from '../utils/formatters';
|
||||||
|
|
||||||
|
const RoomView = lazy(() => import('./RoomView'));
|
||||||
|
const AgentFeed = lazy(() => import('./AgentFeed'));
|
||||||
|
const StatisticsView = lazy(() => import('./StatisticsView'));
|
||||||
|
const StockExplainView = lazy(() => import('./StockExplainView.jsx'));
|
||||||
|
const TraderView = lazy(() => import('./TraderView.jsx'));
|
||||||
|
|
||||||
|
function ViewLoadingFallback({ label = '加载中...' }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: 240,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#ffffff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.4
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppShell - Layout shell containing Header, TickerBar, ViewNavBar, View container, and AgentFeed
|
||||||
|
*/
|
||||||
|
export default function AppShell({
|
||||||
|
// Connection & status
|
||||||
|
isConnected,
|
||||||
|
virtualTime,
|
||||||
|
now,
|
||||||
|
marketStatus,
|
||||||
|
serverMode,
|
||||||
|
marketStatusLabel,
|
||||||
|
dataSourceLabel,
|
||||||
|
runtimeSummaryLabel,
|
||||||
|
isUpdating,
|
||||||
|
// Handlers
|
||||||
|
onManualTrigger,
|
||||||
|
onOpenRuntimeLogs,
|
||||||
|
onRuntimeSettingsToggle,
|
||||||
|
// Runtime settings panel props
|
||||||
|
isRuntimeSettingsOpen,
|
||||||
|
isRuntimeConfigSaving,
|
||||||
|
isWatchlistSaving,
|
||||||
|
runtimeConfigFeedback,
|
||||||
|
watchlistFeedback,
|
||||||
|
launchModeDraft,
|
||||||
|
restoreRunIdDraft,
|
||||||
|
runtimeHistoryRuns,
|
||||||
|
scheduleModeDraft,
|
||||||
|
intervalMinutesDraft,
|
||||||
|
triggerTimeDraft,
|
||||||
|
maxCommCyclesDraft,
|
||||||
|
initialCashDraft,
|
||||||
|
marginRequirementDraft,
|
||||||
|
enableMemoryDraft,
|
||||||
|
modeDraft,
|
||||||
|
pollIntervalDraft,
|
||||||
|
startDateDraft,
|
||||||
|
endDateDraft,
|
||||||
|
watchlistDraftSymbols,
|
||||||
|
watchlistInputValue,
|
||||||
|
watchlistSuggestions,
|
||||||
|
onLaunchModeChange,
|
||||||
|
onRestoreRunIdChange,
|
||||||
|
onScheduleModeChange,
|
||||||
|
onIntervalMinutesChange,
|
||||||
|
onTriggerTimeChange,
|
||||||
|
onMaxCommCyclesChange,
|
||||||
|
onInitialCashChange,
|
||||||
|
onMarginRequirementChange,
|
||||||
|
onEnableMemoryChange,
|
||||||
|
onModeChange,
|
||||||
|
onPollIntervalChange,
|
||||||
|
onStartDateChange,
|
||||||
|
onEndDateChange,
|
||||||
|
onWatchlistInputChange,
|
||||||
|
onWatchlistInputKeyDown,
|
||||||
|
onWatchlistAdd,
|
||||||
|
onWatchlistRemove,
|
||||||
|
onWatchlistRestoreCurrent,
|
||||||
|
onWatchlistRestoreDefault,
|
||||||
|
onWatchlistSuggestionClick,
|
||||||
|
onLaunchConfigSave,
|
||||||
|
onRestoreDefaults,
|
||||||
|
// Ticker and portfolio data
|
||||||
|
displayTickers,
|
||||||
|
portfolioData,
|
||||||
|
rollingTickers,
|
||||||
|
// Feed data
|
||||||
|
feed,
|
||||||
|
bubbles,
|
||||||
|
bubbleFor,
|
||||||
|
leaderboard,
|
||||||
|
// Views data
|
||||||
|
currentView,
|
||||||
|
chartTab,
|
||||||
|
holdings,
|
||||||
|
trades,
|
||||||
|
stats,
|
||||||
|
priceHistoryByTicker,
|
||||||
|
ohlcHistoryByTicker,
|
||||||
|
selectedExplainSymbol,
|
||||||
|
onSelectedExplainSymbolChange,
|
||||||
|
historySourceByTicker,
|
||||||
|
explainEventsByTicker,
|
||||||
|
newsByTicker,
|
||||||
|
insiderTradesByTicker,
|
||||||
|
technicalIndicatorsByTicker,
|
||||||
|
currentDate,
|
||||||
|
// Stock request handlers
|
||||||
|
stockRequests,
|
||||||
|
// Agent request handlers
|
||||||
|
agentRequests,
|
||||||
|
agentProfilesByAgent,
|
||||||
|
// Layout
|
||||||
|
leftWidth,
|
||||||
|
isResizing,
|
||||||
|
onMouseDown,
|
||||||
|
agentFeedRef
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const { setIsRuntimeSettingsOpen, setIsWatchlistPanelOpen } = useRuntimeStore();
|
||||||
|
const { setChartTab, setCurrentView, setIsResizing, setLeftWidth } = useUIStore();
|
||||||
|
|
||||||
|
// Resize handler
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
||||||
|
if (newLeftWidth >= 30 && newLeftWidth <= 85) {
|
||||||
|
setLeftWidth(newLeftWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => setIsResizing(false);
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isResizing, setIsResizing, setLeftWidth]);
|
||||||
|
|
||||||
|
const handleJumpToMessage = (bubble) => {
|
||||||
|
if (agentFeedRef.current && agentFeedRef.current.scrollToMessage) {
|
||||||
|
agentFeedRef.current.scrollToMessage(bubble);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewClassName = useMemo(() => {
|
||||||
|
const base = `view-slider-five ${currentView === 'traders' ? 'show-traders' :
|
||||||
|
currentView === 'room' ? 'show-room' :
|
||||||
|
currentView === 'explain' ? 'show-explain' :
|
||||||
|
currentView === 'statistics' ? 'show-statistics' : 'show-chart'}`;
|
||||||
|
return base;
|
||||||
|
}, [currentView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<GlobalStyles />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="header">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
|
||||||
|
{/* Unified Status Indicator */}
|
||||||
|
<div className="header-status-inline">
|
||||||
|
<span className={`status-dot ${isConnected ? (isUpdating ? 'updating' : 'live') : 'offline'}`} />
|
||||||
|
<span className={`status-text ${isConnected ? 'live' : 'offline'}`}>
|
||||||
|
{isConnected ? (isUpdating ? '同步中' : '在线') : '离线'}
|
||||||
|
</span>
|
||||||
|
{marketStatus && (
|
||||||
|
<>
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
|
||||||
|
{marketStatusLabel}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{dataSourceLabel && (
|
||||||
|
<>
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className="market-text backtest">{dataSourceLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{runtimeSummaryLabel && (
|
||||||
|
<>
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className="market-text backtest" title="当前运行配置">{runtimeSummaryLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{serverMode !== 'backtest' && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
{onOpenRuntimeLogs && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenRuntimeLogs}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #111111',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
letterSpacing: '0.4px',
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}
|
||||||
|
title="查看当前运行日志"
|
||||||
|
>
|
||||||
|
运行日志
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onManualTrigger}
|
||||||
|
disabled={!isConnected}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: isConnected ? '#111111' : '#8a8a8a',
|
||||||
|
border: '1px solid #111111',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: isConnected ? 'pointer' : 'not-allowed',
|
||||||
|
letterSpacing: '0.4px',
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}
|
||||||
|
title="手动触发一轮分析与交易决策"
|
||||||
|
>
|
||||||
|
手动运行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RuntimeSettingsPanel
|
||||||
|
showTrigger={false}
|
||||||
|
isOpen={isRuntimeSettingsOpen}
|
||||||
|
isConnected={isConnected}
|
||||||
|
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
|
||||||
|
feedback={runtimeConfigFeedback || watchlistFeedback}
|
||||||
|
launchMode={launchModeDraft}
|
||||||
|
restoreRunId={restoreRunIdDraft}
|
||||||
|
runtimeHistoryRuns={runtimeHistoryRuns}
|
||||||
|
scheduleMode={scheduleModeDraft}
|
||||||
|
intervalMinutes={intervalMinutesDraft}
|
||||||
|
triggerTime={triggerTimeDraft}
|
||||||
|
maxCommCycles={maxCommCyclesDraft}
|
||||||
|
initialCash={initialCashDraft}
|
||||||
|
marginRequirement={marginRequirementDraft}
|
||||||
|
enableMemory={enableMemoryDraft}
|
||||||
|
mode={modeDraft}
|
||||||
|
pollInterval={pollIntervalDraft}
|
||||||
|
startDate={startDateDraft}
|
||||||
|
endDate={endDateDraft}
|
||||||
|
watchlistSymbols={watchlistDraftSymbols}
|
||||||
|
watchlistInputValue={watchlistInputValue}
|
||||||
|
watchlistSuggestions={watchlistSuggestions}
|
||||||
|
onToggle={onRuntimeSettingsToggle}
|
||||||
|
onClose={() => setIsRuntimeSettingsOpen(false)}
|
||||||
|
onLaunchModeChange={onLaunchModeChange}
|
||||||
|
onRestoreRunIdChange={onRestoreRunIdChange}
|
||||||
|
onScheduleModeChange={onScheduleModeChange}
|
||||||
|
onIntervalMinutesChange={onIntervalMinutesChange}
|
||||||
|
onTriggerTimeChange={onTriggerTimeChange}
|
||||||
|
onMaxCommCyclesChange={onMaxCommCyclesChange}
|
||||||
|
onInitialCashChange={onInitialCashChange}
|
||||||
|
onMarginRequirementChange={onMarginRequirementChange}
|
||||||
|
onEnableMemoryChange={onEnableMemoryChange}
|
||||||
|
onModeChange={onModeChange}
|
||||||
|
onPollIntervalChange={onPollIntervalChange}
|
||||||
|
onStartDateChange={onStartDateChange}
|
||||||
|
onEndDateChange={onEndDateChange}
|
||||||
|
onWatchlistInputChange={onWatchlistInputChange}
|
||||||
|
onWatchlistInputKeyDown={onWatchlistInputKeyDown}
|
||||||
|
onWatchlistAdd={onWatchlistAdd}
|
||||||
|
onWatchlistRemove={onWatchlistRemove}
|
||||||
|
onWatchlistRestoreCurrent={onWatchlistRestoreCurrent}
|
||||||
|
onWatchlistRestoreDefault={onWatchlistRestoreDefault}
|
||||||
|
onWatchlistSuggestionClick={onWatchlistSuggestionClick}
|
||||||
|
onSave={onLaunchConfigSave}
|
||||||
|
onRestoreDefaults={onRestoreDefaults}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<>
|
||||||
|
{/* Ticker Bar */}
|
||||||
|
<div className="ticker-bar">
|
||||||
|
<div className="ticker-track">
|
||||||
|
{[0, 1].map((groupIdx) => (
|
||||||
|
<div key={groupIdx} className="ticker-group">
|
||||||
|
{displayTickers.map(ticker => (
|
||||||
|
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
|
||||||
|
<StockLogo ticker={ticker.symbol} size={16} />
|
||||||
|
<span className="ticker-symbol">{ticker.symbol}</span>
|
||||||
|
<span className="ticker-price">
|
||||||
|
<span className={`ticker-price-value ${rollingTickers[ticker.symbol] ? 'rolling' : ''}`}>
|
||||||
|
{ticker.price !== null && ticker.price !== undefined
|
||||||
|
? `$${formatTickerPrice(ticker.price)}` : '-'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`ticker-change ${
|
||||||
|
ticker.change === null || ticker.change === undefined
|
||||||
|
? '' : ticker.change >= 0 ? 'positive' : 'negative'
|
||||||
|
}`}>
|
||||||
|
{ticker.change !== null && ticker.change !== undefined
|
||||||
|
? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="portfolio-value">
|
||||||
|
<span className="portfolio-label">投资组合</span>
|
||||||
|
<span className="portfolio-amount">${formatNumber(portfolioData.netValue)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main-container" ref={containerRef}>
|
||||||
|
{/* Left Panel */}
|
||||||
|
<div className="left-panel" style={{ width: `${leftWidth}%` }}>
|
||||||
|
<div className="chart-section">
|
||||||
|
<div className="view-container">
|
||||||
|
<div className="view-nav-bar">
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'traders' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('traders')}
|
||||||
|
>
|
||||||
|
交易员
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('room')}
|
||||||
|
>
|
||||||
|
交易室
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('explain')}
|
||||||
|
>
|
||||||
|
个股分析
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('chart')}
|
||||||
|
>
|
||||||
|
业绩图表
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'statistics' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('statistics')}
|
||||||
|
>
|
||||||
|
统计
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={viewClassName}>
|
||||||
|
{/* Traders View */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
|
||||||
|
<TraderView {...agentRequests} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Room View Panel */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
|
||||||
|
<RoomView
|
||||||
|
bubbles={bubbles}
|
||||||
|
bubbleFor={bubbleFor}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
agentProfilesByAgent={agentProfilesByAgent}
|
||||||
|
feed={feed}
|
||||||
|
onJumpToMessage={handleJumpToMessage}
|
||||||
|
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock Explain View Panel */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载个股分析..." />}>
|
||||||
|
<StockExplainView
|
||||||
|
tickers={displayTickers}
|
||||||
|
holdings={holdings}
|
||||||
|
trades={trades}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
feed={feed}
|
||||||
|
priceHistoryByTicker={priceHistoryByTicker}
|
||||||
|
ohlcHistoryByTicker={ohlcHistoryByTicker}
|
||||||
|
selectedSymbol={selectedExplainSymbol}
|
||||||
|
onSelectedSymbolChange={onSelectedExplainSymbolChange}
|
||||||
|
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
||||||
|
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
||||||
|
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
|
||||||
|
insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null}
|
||||||
|
technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null}
|
||||||
|
onRequestHistory={stockRequests?.requestStockHistory}
|
||||||
|
onRequestExplainEvents={stockRequests?.requestStockExplainEvents}
|
||||||
|
onRequestNews={stockRequests?.requestStockNews}
|
||||||
|
onRequestRangeExplain={stockRequests?.requestStockRangeExplain}
|
||||||
|
onRequestNewsForDate={stockRequests?.requestStockNewsForDate}
|
||||||
|
onRequestStory={stockRequests?.requestStockStory}
|
||||||
|
onRequestInsiderTrades={stockRequests?.requestStockInsiderTrades}
|
||||||
|
onRequestTechnicalIndicators={stockRequests?.requestStockTechnicalIndicators}
|
||||||
|
currentDate={currentDate}
|
||||||
|
onRequestSimilarDays={stockRequests?.requestStockSimilarDays}
|
||||||
|
onRequestStockEnrich={stockRequests?.requestStockEnrich}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart View Panel */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<div className="chart-container">
|
||||||
|
<div className="chart-tabs-floating">
|
||||||
|
<button
|
||||||
|
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
|
||||||
|
onClick={() => setChartTab('all')}
|
||||||
|
>
|
||||||
|
日线
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{currentView === 'chart' ? (
|
||||||
|
<NetValueChart
|
||||||
|
equity={portfolioData.equity}
|
||||||
|
baseline={portfolioData.baseline}
|
||||||
|
baseline_vw={portfolioData.baseline_vw}
|
||||||
|
momentum={portfolioData.momentum}
|
||||||
|
strategies={portfolioData.strategies}
|
||||||
|
equity_return={portfolioData.equity_return}
|
||||||
|
baseline_return={portfolioData.baseline_return}
|
||||||
|
baseline_vw_return={portfolioData.baseline_vw_return}
|
||||||
|
momentum_return={portfolioData.momentum_return}
|
||||||
|
chartTab={chartTab}
|
||||||
|
virtualTime={virtualTime}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '100%', minHeight: 320 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics View Panel */}
|
||||||
|
<div className="view-panel">
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载统计视图..." />}>
|
||||||
|
<StatisticsView
|
||||||
|
trades={trades}
|
||||||
|
holdings={holdings}
|
||||||
|
stats={stats}
|
||||||
|
portfolioData={portfolioData}
|
||||||
|
baseline_vw={portfolioData.baseline_vw}
|
||||||
|
equity={portfolioData.equity}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resizer */}
|
||||||
|
<div className={`resizer ${isResizing ? 'resizing' : ''}`} onMouseDown={onMouseDown} />
|
||||||
|
|
||||||
|
{/* Right Panel: Agent Feed */}
|
||||||
|
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
|
||||||
|
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
|
||||||
|
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} agentProfilesByAgent={agentProfilesByAgent} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import { formatNumber, formatFullNumber } from '../utils/formatters';
|
|||||||
/**
|
/**
|
||||||
* Helper function to get the start time of the most recent trading session
|
* Helper function to get the start time of the most recent trading session
|
||||||
* Trading session: 22:30 - next day 05:00
|
* Trading session: 22:30 - next day 05:00
|
||||||
* @param {Date|null} virtualTime - Virtual time from server (for mock mode), or null to use real time
|
* @param {Date|null} virtualTime - Virtual time from server, or null to use real time
|
||||||
*/
|
*/
|
||||||
function getRecentTradingSessionStart(virtualTime = null) {
|
function getRecentTradingSessionStart(virtualTime = null) {
|
||||||
// Use virtual time if provided (for mock mode), otherwise use real time
|
// Use virtual time if provided, otherwise use real time
|
||||||
let now;
|
let now;
|
||||||
if (virtualTime) {
|
if (virtualTime) {
|
||||||
// Ensure virtualTime is a valid Date object
|
// Ensure virtualTime is a valid Date object
|
||||||
|
|||||||
@@ -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, feed, onJumpToMessage, onOpenLaunchConfig }) {
|
export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfilesByAgent, feed, onJumpToMessage, onOpenLaunchConfig }) {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
@@ -162,11 +162,14 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
|||||||
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,
|
||||||
@@ -181,6 +184,8 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
|||||||
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,
|
||||||
@@ -193,6 +198,8 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
|||||||
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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
190
frontend/src/components/RuntimeLogsModal.jsx
Normal file
190
frontend/src/components/RuntimeLogsModal.jsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
export default function RuntimeLogsModal({
|
||||||
|
isOpen,
|
||||||
|
isLoading,
|
||||||
|
logPayload,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onRefresh
|
||||||
|
}) {
|
||||||
|
const logRef = useRef(null);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [followTail, setFollowTail] = useState(true);
|
||||||
|
|
||||||
|
const refreshIntervalMs = useMemo(() => 2000, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !autoRefresh) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerId = window.setInterval(() => {
|
||||||
|
onRefresh();
|
||||||
|
}, refreshIntervalMs);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timerId);
|
||||||
|
}, [autoRefresh, isOpen, onRefresh, refreshIntervalMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !followTail || !logRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||||
|
}, [followTail, isOpen, logPayload?.content]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(15, 23, 42, 0.32)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
zIndex: 10000
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: 'min(980px, 94vw)',
|
||||||
|
maxHeight: '82vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 16,
|
||||||
|
border: '1px solid #D9E0E7',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateRows: 'auto auto minmax(0, 1fr)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '18px 20px 10px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'grid', gap: 4 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>运行日志</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||||
|
{logPayload?.run_id ? `任务 ${logPayload.run_id}` : '当前运行任务'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
style={{
|
||||||
|
padding: '7px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #D0D7DE',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: '7px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: '#111111',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '0 20px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 11, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
|
||||||
|
{logPayload?.log_path || '未找到日志文件'}
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ fontSize: 11, color: '#2563EB', fontWeight: 700 }}>加载中...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ fontSize: 11, color: '#B91C1C', fontWeight: 700 }}>{error}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '0 20px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(event) => setAutoRefresh(event.target.checked)}
|
||||||
|
/>
|
||||||
|
实时刷新
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={followTail}
|
||||||
|
onChange={(event) => setFollowTail(event.target.checked)}
|
||||||
|
/>
|
||||||
|
自动滚底
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0 20px 20px', minHeight: 0 }}>
|
||||||
|
<pre
|
||||||
|
ref={logRef}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 320,
|
||||||
|
maxHeight: 'calc(82vh - 140px)',
|
||||||
|
overflow: 'auto',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid #D9E0E7',
|
||||||
|
background: '#0F172A',
|
||||||
|
color: '#E2E8F0',
|
||||||
|
padding: 16,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
fontFamily: '"SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logPayload?.content || '暂无日志输出'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
import React from 'react';
|
import 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,
|
||||||
@@ -18,13 +30,14 @@ 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,
|
||||||
@@ -35,7 +48,6 @@ export default function RuntimeSettingsPanel({
|
|||||||
onPollIntervalChange,
|
onPollIntervalChange,
|
||||||
onStartDateChange,
|
onStartDateChange,
|
||||||
onEndDateChange,
|
onEndDateChange,
|
||||||
onEnableMockChange,
|
|
||||||
onWatchlistInputChange,
|
onWatchlistInputChange,
|
||||||
onWatchlistInputKeyDown,
|
onWatchlistInputKeyDown,
|
||||||
onWatchlistAdd,
|
onWatchlistAdd,
|
||||||
@@ -134,6 +146,75 @@ export default function RuntimeSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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={{
|
<div style={{
|
||||||
border: '1px solid #E5EAF1',
|
border: '1px solid #E5EAF1',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@@ -273,7 +354,9 @@ 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,
|
||||||
@@ -495,22 +578,8 @@ export default function RuntimeSettingsPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enableMock}
|
|
||||||
onChange={(e) => onEnableMockChange(e.target.checked)}
|
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
accentColor: '#0D47A1',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用模拟数据 (Mock)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
border: '1px solid #E5EAF1',
|
border: '1px solid #E5EAF1',
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ 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">
|
||||||
@@ -722,6 +734,9 @@ 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={{
|
||||||
@@ -739,6 +754,9 @@ 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,
|
||||||
|
|||||||
@@ -8,12 +8,36 @@ 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 }) {
|
export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard, portfolioData }) {
|
||||||
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;
|
||||||
@@ -28,12 +52,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 (!stats || !baseline_vw || baseline_vw.length === 0) {
|
if (!effectiveStats || !baseline_vw || baseline_vw.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Evatraders return from stats
|
// Get Evatraders return from stats
|
||||||
const evatradersReturn = stats.totalReturn || 0; // Already in percentage
|
const evatradersReturn = effectiveStats.totalReturn || 0; // Already in percentage
|
||||||
|
|
||||||
// Calculate benchmark return from baseline_vw
|
// Calculate benchmark return from baseline_vw
|
||||||
// baseline_vw format: [{t: timestamp, v: value}, ...] or [value, ...]
|
// baseline_vw format: [{t: timestamp, v: value}, ...] or [value, ...]
|
||||||
@@ -130,7 +154,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
|||||||
borderRight: '2px solid #e0e0e0',
|
borderRight: '2px solid #e0e0e0',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
{stats ? (
|
{effectiveStats ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -179,7 +203,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
|||||||
fontFamily: '"Courier New", monospace',
|
fontFamily: '"Courier New", monospace',
|
||||||
lineHeight: 1
|
lineHeight: 1
|
||||||
}}>
|
}}>
|
||||||
${formatNumber(stats.totalAssetValue || 0)}
|
${formatNumber(effectiveStats.totalAssetValue || 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -272,10 +296,10 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
|||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: (stats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
|
color: (effectiveStats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
|
||||||
fontFamily: '"Courier New", monospace'
|
fontFamily: '"Courier New", monospace'
|
||||||
}}>
|
}}>
|
||||||
{(stats.totalReturn || 0) >= 0 ? '+' : ''}{(stats.totalReturn || 0).toFixed(2)}%
|
{(effectiveStats.totalReturn || 0) >= 0 ? '+' : ''}{(effectiveStats.totalReturn || 0).toFixed(2)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,7 +328,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
|||||||
color: '#000000',
|
color: '#000000',
|
||||||
fontFamily: '"Courier New", monospace'
|
fontFamily: '"Courier New", monospace'
|
||||||
}}>
|
}}>
|
||||||
${formatNumber(stats.cashPosition || 0)}
|
${formatNumber(effectiveStats.cashPosition || 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -330,13 +354,13 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
|||||||
color: '#000000',
|
color: '#000000',
|
||||||
fontFamily: '"Courier New", monospace'
|
fontFamily: '"Courier New", monospace'
|
||||||
}}>
|
}}>
|
||||||
{stats.totalTrades || 0}
|
{effectiveStats.totalTrades || 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ticker Weights - Compact */}
|
{/* Ticker Weights - Compact */}
|
||||||
{stats.tickerWeights && Object.keys(stats.tickerWeights).length > 0 && (
|
{effectiveStats?.tickerWeights && Object.keys(effectiveStats.tickerWeights).length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: 'auto',
|
marginTop: 'auto',
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
@@ -358,7 +382,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
maxHeight: 120
|
maxHeight: 120
|
||||||
}}>
|
}}>
|
||||||
{Object.entries(stats.tickerWeights).map(([ticker, weight]) => {
|
{Object.entries(effectiveStats.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);
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export default function StockExplainView({
|
|||||||
insiderTradesSnapshot,
|
insiderTradesSnapshot,
|
||||||
technicalIndicatorsSnapshot,
|
technicalIndicatorsSnapshot,
|
||||||
onRequestRangeExplain,
|
onRequestRangeExplain,
|
||||||
|
onRequestHistory,
|
||||||
|
onRequestExplainEvents,
|
||||||
|
onRequestNews,
|
||||||
onRequestNewsForDate,
|
onRequestNewsForDate,
|
||||||
onRequestStory,
|
onRequestStory,
|
||||||
onRequestInsiderTrades,
|
onRequestInsiderTrades,
|
||||||
@@ -142,11 +145,37 @@ 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 (Array.isArray(newsSnapshot?.byDate?.[selectedEventDate]) && newsSnapshot.byDate[selectedEventDate].length > 0) {
|
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.byDate || {}, selectedEventDate)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onRequestNewsForDate(selectedSymbol, selectedEventDate);
|
onRequestNewsForDate(selectedSymbol, selectedEventDate);
|
||||||
@@ -156,21 +185,21 @@ export default function StockExplainView({
|
|||||||
if (!selectedSymbol || !onRequestStory || !currentDate) {
|
if (!selectedSymbol || !onRequestStory || !currentDate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedStory?.story) {
|
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.storyCache || {}, currentDate)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onRequestStory(selectedSymbol, currentDate);
|
onRequestStory(selectedSymbol, currentDate);
|
||||||
}, [currentDate, onRequestStory, selectedStory, selectedSymbol]);
|
}, [currentDate, newsSnapshot, onRequestStory, selectedStory, selectedSymbol]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSymbol || !selectedEventDate || !onRequestSimilarDays) {
|
if (!selectedSymbol || !selectedEventDate || !onRequestSimilarDays) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedSimilarDays?.items?.length) {
|
if (Object.prototype.hasOwnProperty.call(newsSnapshot?.similarDaysCache || {}, selectedEventDate)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onRequestSimilarDays(selectedSymbol, selectedEventDate);
|
onRequestSimilarDays(selectedSymbol, selectedEventDate);
|
||||||
}, [onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
|
}, [newsSnapshot, onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSymbol || !onRequestTechnicalIndicators) {
|
if (!selectedSymbol || !onRequestTechnicalIndicators) {
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ 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);
|
||||||
@@ -460,6 +472,9 @@ 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={{
|
||||||
@@ -557,6 +572,9 @@ 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 内容'}
|
||||||
@@ -687,7 +705,13 @@ 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"
|
||||||
@@ -741,7 +765,13 @@ 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) => {
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ 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
|
||||||
@@ -117,7 +129,13 @@ 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}
|
||||||
|
|||||||
@@ -11,6 +11,37 @@ 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">
|
||||||
@@ -66,12 +97,35 @@ 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}
|
||||||
@@ -123,7 +177,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.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
<title>{`${marker.title} · ${marker.timestamp || marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
388
frontend/src/hooks/useAgentDataRequests.js
Normal file
388
frontend/src/hooks/useAgentDataRequests.js
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
createAgentLocalSkill,
|
||||||
|
deleteAgentLocalSkill,
|
||||||
|
disableAgentSkill,
|
||||||
|
enableAgentSkill,
|
||||||
|
fetchAgentProfile,
|
||||||
|
fetchAgentSkillDetail,
|
||||||
|
fetchAgentSkills,
|
||||||
|
fetchAgentWorkspaceFile,
|
||||||
|
fetchCurrentRuntime,
|
||||||
|
updateAgentLocalSkill,
|
||||||
|
updateAgentWorkspaceFile,
|
||||||
|
uploadAgentSkillZip
|
||||||
|
} from '../services/runtimeApi';
|
||||||
|
import { useAgentStore } from '../store/agentStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for agent operation callbacks.
|
||||||
|
* Takes clientRef, uses agentStore.
|
||||||
|
*/
|
||||||
|
export function useAgentDataRequests(clientRef) {
|
||||||
|
const {
|
||||||
|
selectedSkillAgentId,
|
||||||
|
setSelectedSkillAgentId,
|
||||||
|
setAgentProfilesByAgent,
|
||||||
|
setIsAgentSkillsLoading,
|
||||||
|
setAgentSkillsFeedback,
|
||||||
|
setAgentSkillsSavingKey,
|
||||||
|
setSkillDetailLoadingKey,
|
||||||
|
setAgentSkillsByAgent,
|
||||||
|
setSkillDetailsByName,
|
||||||
|
localSkillDraftsByKey,
|
||||||
|
selectedWorkspaceFile,
|
||||||
|
setWorkspaceFilesByAgent,
|
||||||
|
setWorkspaceDraftContent,
|
||||||
|
workspaceDraftContent,
|
||||||
|
setWorkspaceFileFeedback,
|
||||||
|
setWorkspaceFileSavingKey,
|
||||||
|
setIsWorkspaceFileLoading
|
||||||
|
} = useAgentStore();
|
||||||
|
|
||||||
|
const resolveWorkspaceId = useCallback(async () => {
|
||||||
|
const runtime = await fetchCurrentRuntime();
|
||||||
|
const workspaceId = runtime?.run_id;
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new Error('未检测到正在运行的任务');
|
||||||
|
}
|
||||||
|
return workspaceId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestAgentSkills = useCallback((agentId) => {
|
||||||
|
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
|
||||||
|
if (!normalized) return false;
|
||||||
|
setIsAgentSkillsLoading(true);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => fetchAgentSkills(workspaceId, normalized))
|
||||||
|
.then((payload) => {
|
||||||
|
setAgentSkillsByAgent((prev) => ({ ...prev, [normalized]: Array.isArray(payload?.skills) ? payload.skills : [] }));
|
||||||
|
setIsAgentSkillsLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setIsAgentSkillsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug('REST agent skills request failed, falling back to websocket compatibility path');
|
||||||
|
const success = clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized });
|
||||||
|
if (!success) {
|
||||||
|
setIsAgentSkillsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, [clientRef, resolveWorkspaceId, setAgentSkillsByAgent, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
|
||||||
|
|
||||||
|
const requestAgentProfile = useCallback((agentId) => {
|
||||||
|
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
|
||||||
|
if (!normalized) return false;
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => fetchAgentProfile(workspaceId, normalized))
|
||||||
|
.then((payload) => {
|
||||||
|
setAgentProfilesByAgent((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: payload?.profile && typeof payload.profile === 'object' ? payload.profile : {}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (clientRef.current) {
|
||||||
|
console.debug('REST agent profile request failed, falling back to websocket compatibility path');
|
||||||
|
clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, [clientRef, resolveWorkspaceId, setAgentProfilesByAgent]);
|
||||||
|
|
||||||
|
const requestSkillDetail = useCallback((skillName) => {
|
||||||
|
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
|
||||||
|
if (!normalized) return false;
|
||||||
|
const detailKey = `${selectedSkillAgentId}:${normalized}`;
|
||||||
|
setSkillDetailLoadingKey(detailKey);
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => fetchAgentSkillDetail(workspaceId, selectedSkillAgentId, normalized))
|
||||||
|
.then((payload) => {
|
||||||
|
setSkillDetailsByName((prev) => ({ ...prev, [detailKey]: payload?.skill || null }));
|
||||||
|
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[detailKey]: typeof payload?.skill?.content === 'string' ? payload.skill.content : ''
|
||||||
|
}));
|
||||||
|
setSkillDetailLoadingKey(null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setSkillDetailLoadingKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug('REST skill detail request failed, falling back to websocket compatibility path');
|
||||||
|
const success = clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized });
|
||||||
|
if (!success) {
|
||||||
|
setSkillDetailLoadingKey(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, [clientRef, resolveWorkspaceId, selectedSkillAgentId, setSkillDetailLoadingKey, setSkillDetailsByName]);
|
||||||
|
|
||||||
|
const handleCreateLocalSkill = useCallback((skillName) => {
|
||||||
|
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
|
||||||
|
if (!normalized) {
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => createAgentLocalSkill(workspaceId, selectedSkillAgentId, normalized))
|
||||||
|
.then(() => {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `已创建本地技能 ${normalized}` });
|
||||||
|
requestAgentSkills(selectedSkillAgentId);
|
||||||
|
requestSkillDetail(normalized);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug('REST local skill create failed, falling back to websocket compatibility path');
|
||||||
|
const success = clientRef.current.send({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized });
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [clientRef, requestAgentSkills, requestSkillDetail, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||||
|
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||||
|
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({ ...prev, [detailKey]: content }));
|
||||||
|
}, [selectedSkillAgentId]);
|
||||||
|
|
||||||
|
const handleLocalSkillSave = useCallback((skillName) => {
|
||||||
|
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||||
|
const content = localSkillDraftsByKey[detailKey];
|
||||||
|
if (typeof content !== 'string') return;
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => updateAgentLocalSkill(workspaceId, selectedSkillAgentId, skillName, content))
|
||||||
|
.then(() => {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 的本地技能 ${skillName} 已保存` });
|
||||||
|
requestSkillDetail(skillName);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug('REST local skill save failed, falling back to websocket compatibility path');
|
||||||
|
const success = clientRef.current.send({ type: 'update_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName, content });
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [clientRef, localSkillDraftsByKey, requestSkillDetail, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
const handleLocalSkillDelete = useCallback((skillName) => {
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => deleteAgentLocalSkill(workspaceId, selectedSkillAgentId, skillName))
|
||||||
|
.then(() => {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 的本地技能 ${skillName} 已删除` });
|
||||||
|
requestAgentSkills(selectedSkillAgentId);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug('REST local skill delete failed, falling back to websocket compatibility path');
|
||||||
|
const success = clientRef.current.send({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
const handleRemoveSharedSkill = useCallback((skillName) => {
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => disableAgentSkill(workspaceId, selectedSkillAgentId, skillName))
|
||||||
|
.then(() => {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 已移除共享技能 ${skillName}` });
|
||||||
|
requestAgentSkills(selectedSkillAgentId);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug('REST shared skill remove failed, falling back to websocket compatibility path');
|
||||||
|
const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
|
||||||
|
const agentId = selectedSkillAgentId;
|
||||||
|
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => enabled
|
||||||
|
? enableAgentSkill(workspaceId, agentId, skillName)
|
||||||
|
: disableAgentSkill(workspaceId, agentId, skillName))
|
||||||
|
.then(() => {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `${agentId} ${enabled ? '已启用' : '已禁用'} ${skillName}` });
|
||||||
|
requestAgentSkills(agentId);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug('REST skill toggle failed, falling back to websocket compatibility path');
|
||||||
|
const success = clientRef.current.send({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled });
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
const handleSkillAgentChange = useCallback((agentId) => {
|
||||||
|
setSelectedSkillAgentId(agentId);
|
||||||
|
requestAgentProfile(agentId);
|
||||||
|
requestAgentSkills(agentId);
|
||||||
|
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||||
|
}, [requestAgentProfile, requestAgentSkills, setSelectedSkillAgentId, selectedWorkspaceFile]);
|
||||||
|
|
||||||
|
const requestWorkspaceFile = useCallback((agentId, filename) => {
|
||||||
|
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
|
||||||
|
const normalizedFilename = typeof filename === 'string' ? filename.trim() : '';
|
||||||
|
if (!normalizedAgentId || !normalizedFilename) return false;
|
||||||
|
setIsWorkspaceFileLoading(true);
|
||||||
|
setWorkspaceFileFeedback(null);
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => fetchAgentWorkspaceFile(workspaceId, normalizedAgentId, normalizedFilename))
|
||||||
|
.then((payload) => {
|
||||||
|
setWorkspaceFilesByAgent((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedAgentId]: {
|
||||||
|
...(prev[normalizedAgentId] || {}),
|
||||||
|
[normalizedFilename]: typeof payload?.content === 'string' ? payload.content : ''
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setWorkspaceDraftContent(typeof payload?.content === 'string' ? payload.content : '');
|
||||||
|
setIsWorkspaceFileLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setIsWorkspaceFileLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug('REST workspace file read failed, falling back to websocket compatibility path');
|
||||||
|
const success = clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename });
|
||||||
|
if (!success) {
|
||||||
|
setIsWorkspaceFileLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, [clientRef, resolveWorkspaceId, setIsWorkspaceFileLoading, setWorkspaceDraftContent, setWorkspaceFileFeedback, setWorkspaceFilesByAgent]);
|
||||||
|
|
||||||
|
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||||
|
useAgentStore.getState().setSelectedWorkspaceFile(filename);
|
||||||
|
requestWorkspaceFile(selectedSkillAgentId, filename);
|
||||||
|
}, [requestWorkspaceFile, selectedSkillAgentId]);
|
||||||
|
|
||||||
|
const handleWorkspaceFileSave = useCallback(() => {
|
||||||
|
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
|
||||||
|
setWorkspaceFileSavingKey(key);
|
||||||
|
setWorkspaceFileFeedback(null);
|
||||||
|
void resolveWorkspaceId()
|
||||||
|
.then((workspaceId) => updateAgentWorkspaceFile(workspaceId, selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent))
|
||||||
|
.then((payload) => {
|
||||||
|
setWorkspaceFileSavingKey(null);
|
||||||
|
setWorkspaceFileFeedback({ type: 'success', text: `${selectedSkillAgentId} 的 ${selectedWorkspaceFile} 已保存` });
|
||||||
|
setWorkspaceFilesByAgent((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[selectedSkillAgentId]: {
|
||||||
|
...(prev[selectedSkillAgentId] || {}),
|
||||||
|
[selectedWorkspaceFile]: typeof payload?.content === 'string' ? payload.content : workspaceDraftContent
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setWorkspaceFileSavingKey(null);
|
||||||
|
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug('REST workspace file save failed, falling back to websocket compatibility path');
|
||||||
|
const success = clientRef.current.send({
|
||||||
|
type: 'update_agent_workspace_file',
|
||||||
|
agent_id: selectedSkillAgentId,
|
||||||
|
filename: selectedWorkspaceFile,
|
||||||
|
content: workspaceDraftContent
|
||||||
|
});
|
||||||
|
if (!success) {
|
||||||
|
setWorkspaceFileSavingKey(null);
|
||||||
|
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [clientRef, resolveWorkspaceId, selectedSkillAgentId, selectedWorkspaceFile, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, workspaceDraftContent]);
|
||||||
|
|
||||||
|
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '请选择 zip 文件后再上传' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedSkillAgentId) {
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: '未选择目标 Agent' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
try {
|
||||||
|
const result = await uploadAgentSkillZip({ agentId: selectedSkillAgentId, file, activate: true });
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `已上传并安装技能 ${result.skill_name || ''}`.trim() });
|
||||||
|
requestAgentSkills(selectedSkillAgentId);
|
||||||
|
} catch (error) {
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: `上传失败: ${error.message || '未知错误'}` });
|
||||||
|
} finally {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
}
|
||||||
|
}, [requestAgentSkills, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestAgentSkills,
|
||||||
|
requestAgentProfile,
|
||||||
|
requestSkillDetail,
|
||||||
|
handleCreateLocalSkill,
|
||||||
|
handleLocalSkillDraftChange,
|
||||||
|
handleLocalSkillSave,
|
||||||
|
handleLocalSkillDelete,
|
||||||
|
handleRemoveSharedSkill,
|
||||||
|
handleAgentSkillToggle,
|
||||||
|
handleSkillAgentChange,
|
||||||
|
requestWorkspaceFile,
|
||||||
|
handleWorkspaceFileChange,
|
||||||
|
handleWorkspaceFileSave,
|
||||||
|
handleUploadExternalSkill
|
||||||
|
};
|
||||||
|
}
|
||||||
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
import { AGENTS } from "../config/constants";
|
||||||
|
import { uploadAgentSkillZip } from "../services/runtimeApi";
|
||||||
|
|
||||||
|
export function useAgentWorkspacePanel({
|
||||||
|
clientRef,
|
||||||
|
currentView,
|
||||||
|
isConnected,
|
||||||
|
connectionStatus,
|
||||||
|
selectedSkillAgentId,
|
||||||
|
selectedWorkspaceFile,
|
||||||
|
selectedWorkspaceContent,
|
||||||
|
localSkillDraftsByKey,
|
||||||
|
agentProfilesByAgent,
|
||||||
|
agentSkillsByAgent,
|
||||||
|
workspaceFilesByAgent,
|
||||||
|
workspaceDraftContent,
|
||||||
|
setSelectedSkillAgentId,
|
||||||
|
setSelectedWorkspaceFile,
|
||||||
|
setWorkspaceDraftContent,
|
||||||
|
setIsAgentSkillsLoading,
|
||||||
|
setAgentSkillsFeedback,
|
||||||
|
setSkillDetailLoadingKey,
|
||||||
|
setAgentSkillsSavingKey,
|
||||||
|
setLocalSkillDraftsByKey,
|
||||||
|
setIsWorkspaceFileLoading,
|
||||||
|
setWorkspaceFileFeedback,
|
||||||
|
setWorkspaceFileSavingKey
|
||||||
|
}) {
|
||||||
|
const sendWithRetry = useCallback((payload, retries = 3, delayMs = 250) => {
|
||||||
|
const attemptSend = (remaining) => {
|
||||||
|
const client = clientRef.current;
|
||||||
|
if (!client) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sent = client.send(payload);
|
||||||
|
if (sent || remaining <= 0) {
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
window.setTimeout(() => {
|
||||||
|
attemptSend(remaining - 1);
|
||||||
|
}, delayMs);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return attemptSend(retries);
|
||||||
|
}, [clientRef]);
|
||||||
|
|
||||||
|
const requestAgentSkills = useCallback((agentId) => {
|
||||||
|
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||||
|
if (!normalized || !clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setIsAgentSkillsLoading(true);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
return sendWithRetry({
|
||||||
|
type: "get_agent_skills",
|
||||||
|
agent_id: normalized
|
||||||
|
});
|
||||||
|
}, [clientRef, sendWithRetry, setAgentSkillsFeedback, setIsAgentSkillsLoading]);
|
||||||
|
|
||||||
|
const requestAgentProfile = useCallback((agentId) => {
|
||||||
|
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||||
|
if (!normalized || !clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return sendWithRetry({
|
||||||
|
type: "get_agent_profile",
|
||||||
|
agent_id: normalized
|
||||||
|
});
|
||||||
|
}, [clientRef, sendWithRetry]);
|
||||||
|
|
||||||
|
const requestSkillDetail = useCallback((skillName) => {
|
||||||
|
const normalized = typeof skillName === "string" ? skillName.trim() : "";
|
||||||
|
if (!normalized || !clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const detailKey = `${selectedSkillAgentId}:${normalized}`;
|
||||||
|
setSkillDetailLoadingKey(detailKey);
|
||||||
|
return sendWithRetry({
|
||||||
|
type: "get_skill_detail",
|
||||||
|
agent_id: selectedSkillAgentId,
|
||||||
|
skill_name: normalized
|
||||||
|
});
|
||||||
|
}, [clientRef, selectedSkillAgentId, sendWithRetry, setSkillDetailLoadingKey]);
|
||||||
|
|
||||||
|
const handleCreateLocalSkill = useCallback((skillName) => {
|
||||||
|
const normalized = typeof skillName === "string" ? skillName.trim() : "";
|
||||||
|
if (!normalized) {
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "技能名称不能为空" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
const success = sendWithRetry({
|
||||||
|
type: "create_agent_local_skill",
|
||||||
|
agent_id: selectedSkillAgentId,
|
||||||
|
skill_name: normalized
|
||||||
|
});
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||||
|
}
|
||||||
|
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||||
|
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||||
|
setLocalSkillDraftsByKey((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[detailKey]: content
|
||||||
|
}));
|
||||||
|
}, [selectedSkillAgentId, setLocalSkillDraftsByKey]);
|
||||||
|
|
||||||
|
const handleLocalSkillSave = useCallback((skillName) => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||||
|
const content = localSkillDraftsByKey[detailKey];
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
const success = sendWithRetry({
|
||||||
|
type: "update_agent_local_skill",
|
||||||
|
agent_id: selectedSkillAgentId,
|
||||||
|
skill_name: skillName,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clientRef,
|
||||||
|
localSkillDraftsByKey,
|
||||||
|
selectedSkillAgentId,
|
||||||
|
setAgentSkillsFeedback,
|
||||||
|
setAgentSkillsSavingKey
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleLocalSkillDelete = useCallback((skillName) => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
const success = sendWithRetry({
|
||||||
|
type: "delete_agent_local_skill",
|
||||||
|
agent_id: selectedSkillAgentId,
|
||||||
|
skill_name: skillName
|
||||||
|
});
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||||
|
}
|
||||||
|
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
const handleRemoveSharedSkill = useCallback((skillName) => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
const success = sendWithRetry({
|
||||||
|
type: "remove_agent_skill",
|
||||||
|
agent_id: selectedSkillAgentId,
|
||||||
|
skill_name: skillName
|
||||||
|
});
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||||
|
}
|
||||||
|
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
const requestWorkspaceFile = useCallback((agentId, filename) => {
|
||||||
|
const normalizedAgentId = typeof agentId === "string" ? agentId.trim() : "";
|
||||||
|
const normalizedFilename = typeof filename === "string" ? filename.trim() : "";
|
||||||
|
if (!normalizedAgentId || !normalizedFilename || !clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setIsWorkspaceFileLoading(true);
|
||||||
|
setWorkspaceFileFeedback(null);
|
||||||
|
return sendWithRetry({
|
||||||
|
type: "get_agent_workspace_file",
|
||||||
|
agent_id: normalizedAgentId,
|
||||||
|
filename: normalizedFilename
|
||||||
|
});
|
||||||
|
}, [clientRef, sendWithRetry, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
|
||||||
|
|
||||||
|
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentId = selectedSkillAgentId;
|
||||||
|
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
const success = sendWithRetry({
|
||||||
|
type: "update_agent_skill",
|
||||||
|
agent_id: agentId,
|
||||||
|
skill_name: skillName,
|
||||||
|
enabled
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||||
|
}
|
||||||
|
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||||
|
|
||||||
|
const handleSkillAgentChange = useCallback((agentId) => {
|
||||||
|
setSelectedSkillAgentId(agentId);
|
||||||
|
requestAgentProfile(agentId);
|
||||||
|
requestAgentSkills(agentId);
|
||||||
|
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||||
|
}, [
|
||||||
|
requestAgentProfile,
|
||||||
|
requestAgentSkills,
|
||||||
|
requestWorkspaceFile,
|
||||||
|
selectedWorkspaceFile,
|
||||||
|
setSelectedSkillAgentId
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||||
|
setSelectedWorkspaceFile(filename);
|
||||||
|
requestWorkspaceFile(selectedSkillAgentId, filename);
|
||||||
|
}, [requestWorkspaceFile, selectedSkillAgentId, setSelectedWorkspaceFile]);
|
||||||
|
|
||||||
|
const handleWorkspaceFileSave = useCallback(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setWorkspaceFileFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
|
||||||
|
setWorkspaceFileSavingKey(key);
|
||||||
|
setWorkspaceFileFeedback(null);
|
||||||
|
const success = sendWithRetry({
|
||||||
|
type: "update_agent_workspace_file",
|
||||||
|
agent_id: selectedSkillAgentId,
|
||||||
|
filename: selectedWorkspaceFile,
|
||||||
|
content: workspaceDraftContent
|
||||||
|
});
|
||||||
|
if (!success) {
|
||||||
|
setWorkspaceFileSavingKey(null);
|
||||||
|
setWorkspaceFileFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clientRef,
|
||||||
|
selectedSkillAgentId,
|
||||||
|
selectedWorkspaceFile,
|
||||||
|
sendWithRetry,
|
||||||
|
setWorkspaceFileFeedback,
|
||||||
|
setWorkspaceFileSavingKey,
|
||||||
|
workspaceDraftContent
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "请选择 zip 文件后再上传" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedSkillAgentId) {
|
||||||
|
setAgentSkillsFeedback({ type: "error", text: "未选择目标 Agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
try {
|
||||||
|
const result = await uploadAgentSkillZip({
|
||||||
|
agentId: selectedSkillAgentId,
|
||||||
|
file,
|
||||||
|
activate: true
|
||||||
|
});
|
||||||
|
setAgentSkillsFeedback({
|
||||||
|
type: "success",
|
||||||
|
text: `已上传并安装技能 ${result.skill_name || ""}`.trim()
|
||||||
|
});
|
||||||
|
requestAgentSkills(selectedSkillAgentId);
|
||||||
|
} catch (error) {
|
||||||
|
setAgentSkillsFeedback({
|
||||||
|
type: "error",
|
||||||
|
text: `上传失败: ${error.message || "未知错误"}`
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
requestAgentSkills,
|
||||||
|
selectedSkillAgentId,
|
||||||
|
setAgentSkillsFeedback,
|
||||||
|
setAgentSkillsSavingKey
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWorkspaceDraftContent(selectedWorkspaceContent);
|
||||||
|
}, [selectedWorkspaceContent, setWorkspaceDraftContent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentView !== "traders") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
AGENTS.forEach((agent) => {
|
||||||
|
if (!agentProfilesByAgent[agent.id]) {
|
||||||
|
requestAgentProfile(agent.id);
|
||||||
|
}
|
||||||
|
if (!agentSkillsByAgent[agent.id]) {
|
||||||
|
requestAgentSkills(agent.id);
|
||||||
|
}
|
||||||
|
if (!workspaceFilesByAgent[agent.id]?.["MEMORY.md"]) {
|
||||||
|
requestWorkspaceFile(agent.id, "MEMORY.md");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [
|
||||||
|
agentProfilesByAgent,
|
||||||
|
agentSkillsByAgent,
|
||||||
|
connectionStatus,
|
||||||
|
currentView,
|
||||||
|
isConnected,
|
||||||
|
requestAgentProfile,
|
||||||
|
requestAgentSkills,
|
||||||
|
requestWorkspaceFile,
|
||||||
|
workspaceFilesByAgent
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentView !== "traders" || !selectedSkillAgentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
if (!agentProfilesByAgent[selectedSkillAgentId]) {
|
||||||
|
requestAgentProfile(selectedSkillAgentId);
|
||||||
|
}
|
||||||
|
if (!agentSkillsByAgent[selectedSkillAgentId]) {
|
||||||
|
requestAgentSkills(selectedSkillAgentId);
|
||||||
|
}
|
||||||
|
if (selectedWorkspaceFile && !workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile]) {
|
||||||
|
requestWorkspaceFile(selectedSkillAgentId, selectedWorkspaceFile);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [
|
||||||
|
agentProfilesByAgent,
|
||||||
|
agentSkillsByAgent,
|
||||||
|
connectionStatus,
|
||||||
|
currentView,
|
||||||
|
isConnected,
|
||||||
|
requestAgentProfile,
|
||||||
|
requestAgentSkills,
|
||||||
|
requestWorkspaceFile,
|
||||||
|
selectedSkillAgentId,
|
||||||
|
selectedWorkspaceFile,
|
||||||
|
workspaceFilesByAgent
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestAgentSkills,
|
||||||
|
requestAgentProfile,
|
||||||
|
requestSkillDetail,
|
||||||
|
requestWorkspaceFile,
|
||||||
|
handleCreateLocalSkill,
|
||||||
|
handleLocalSkillDraftChange,
|
||||||
|
handleLocalSkillSave,
|
||||||
|
handleLocalSkillDelete,
|
||||||
|
handleRemoveSharedSkill,
|
||||||
|
handleAgentSkillToggle,
|
||||||
|
handleSkillAgentChange,
|
||||||
|
handleWorkspaceFileChange,
|
||||||
|
handleWorkspaceFileSave,
|
||||||
|
handleUploadExternalSkill
|
||||||
|
};
|
||||||
|
}
|
||||||
581
frontend/src/hooks/useRuntimeControls.js
Normal file
581
frontend/src/hooks/useRuntimeControls.js
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { INITIAL_TICKERS } from "../config/constants";
|
||||||
|
import { fetchRuntimeHistory, startRuntime } from "../services/runtimeApi";
|
||||||
|
import {
|
||||||
|
buildRuntimeSummaryLabel,
|
||||||
|
normalizeTickerSymbols,
|
||||||
|
normalizeRuntimeWatchlistSymbols,
|
||||||
|
parseWatchlistInput
|
||||||
|
} from "../services/runtimeControls";
|
||||||
|
import { useAgentStore } from "../store/agentStore";
|
||||||
|
import { useRuntimeStore } from "../store/runtimeStore";
|
||||||
|
|
||||||
|
const DEFAULT_SCHEDULE_MODE = "daily";
|
||||||
|
const DEFAULT_INTERVAL_MINUTES = "60";
|
||||||
|
const DEFAULT_TRIGGER_TIME = "now";
|
||||||
|
const DEFAULT_MAX_COMM_CYCLES = "2";
|
||||||
|
const DEFAULT_INITIAL_CASH = "100000";
|
||||||
|
const DEFAULT_MARGIN_REQUIREMENT = "0";
|
||||||
|
const DEFAULT_MODE = "live";
|
||||||
|
const DEFAULT_POLL_INTERVAL = "10";
|
||||||
|
|
||||||
|
export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage, onRuntimeStarted }) {
|
||||||
|
const {
|
||||||
|
runtimeConfig,
|
||||||
|
setRuntimeConfig,
|
||||||
|
isWatchlistPanelOpen,
|
||||||
|
setIsWatchlistPanelOpen,
|
||||||
|
isRuntimeSettingsOpen,
|
||||||
|
setIsRuntimeSettingsOpen,
|
||||||
|
watchlistDraftSymbols,
|
||||||
|
setWatchlistDraftSymbols,
|
||||||
|
watchlistInputValue,
|
||||||
|
setWatchlistInputValue,
|
||||||
|
watchlistFeedback,
|
||||||
|
setWatchlistFeedback,
|
||||||
|
isWatchlistSaving,
|
||||||
|
setIsWatchlistSaving,
|
||||||
|
launchModeDraft,
|
||||||
|
setLaunchModeDraft,
|
||||||
|
restoreRunIdDraft,
|
||||||
|
setRestoreRunIdDraft,
|
||||||
|
runtimeHistoryRuns,
|
||||||
|
setRuntimeHistoryRuns,
|
||||||
|
scheduleModeDraft,
|
||||||
|
setScheduleModeDraft,
|
||||||
|
intervalMinutesDraft,
|
||||||
|
setIntervalMinutesDraft,
|
||||||
|
triggerTimeDraft,
|
||||||
|
setTriggerTimeDraft,
|
||||||
|
maxCommCyclesDraft,
|
||||||
|
setMaxCommCyclesDraft,
|
||||||
|
initialCashDraft,
|
||||||
|
setInitialCashDraft,
|
||||||
|
marginRequirementDraft,
|
||||||
|
setMarginRequirementDraft,
|
||||||
|
enableMemoryDraft,
|
||||||
|
setEnableMemoryDraft,
|
||||||
|
modeDraft,
|
||||||
|
setModeDraft,
|
||||||
|
pollIntervalDraft,
|
||||||
|
setPollIntervalDraft,
|
||||||
|
startDateDraft,
|
||||||
|
setStartDateDraft,
|
||||||
|
endDateDraft,
|
||||||
|
setEndDateDraft,
|
||||||
|
runtimeConfigFeedback,
|
||||||
|
setRuntimeConfigFeedback,
|
||||||
|
isRuntimeConfigSaving,
|
||||||
|
setIsRuntimeConfigSaving
|
||||||
|
} = useRuntimeStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
setAgentSkillsFeedback,
|
||||||
|
setWorkspaceFileFeedback
|
||||||
|
} = useAgentStore();
|
||||||
|
|
||||||
|
const isWatchlistSavingRef = useRef(false);
|
||||||
|
const isRuntimeConfigSavingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||||
|
}, [isWatchlistSaving]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||||
|
}, [isRuntimeConfigSaving]);
|
||||||
|
|
||||||
|
const displayTickers = useMemo(
|
||||||
|
() => normalizeTickerSymbols(runtimeConfig?.tickers, currentTickers),
|
||||||
|
[currentTickers, runtimeConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtimeWatchlistSymbols = useMemo(
|
||||||
|
() => normalizeRuntimeWatchlistSymbols(runtimeConfig, currentTickers),
|
||||||
|
[currentTickers, runtimeConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtimeSummaryLabel = useMemo(
|
||||||
|
() => buildRuntimeSummaryLabel(runtimeConfig),
|
||||||
|
[runtimeConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const watchlistSuggestions = useMemo(
|
||||||
|
() => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isWatchlistDraftDirty = useMemo(() => {
|
||||||
|
if (watchlistInputValue.trim()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]);
|
||||||
|
}, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((!isWatchlistPanelOpen && !isRuntimeSettingsOpen) || !isWatchlistDraftDirty) {
|
||||||
|
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||||
|
if (!isWatchlistPanelOpen && !isRuntimeSettingsOpen) {
|
||||||
|
setWatchlistInputValue("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isWatchlistDraftDirty,
|
||||||
|
isWatchlistPanelOpen,
|
||||||
|
isRuntimeSettingsOpen,
|
||||||
|
runtimeWatchlistSymbols,
|
||||||
|
setWatchlistDraftSymbols,
|
||||||
|
setWatchlistInputValue
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!runtimeConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScheduleModeDraft(String(runtimeConfig.schedule_mode || DEFAULT_SCHEDULE_MODE));
|
||||||
|
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || DEFAULT_INTERVAL_MINUTES));
|
||||||
|
setTriggerTimeDraft(String(runtimeConfig.trigger_time || DEFAULT_TRIGGER_TIME));
|
||||||
|
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || DEFAULT_MAX_COMM_CYCLES));
|
||||||
|
setInitialCashDraft(String(runtimeConfig.initial_cash ?? DEFAULT_INITIAL_CASH));
|
||||||
|
setMarginRequirementDraft(String(runtimeConfig.margin_requirement ?? DEFAULT_MARGIN_REQUIREMENT));
|
||||||
|
setEnableMemoryDraft(Boolean(runtimeConfig.enable_memory ?? false));
|
||||||
|
}, [
|
||||||
|
runtimeConfig,
|
||||||
|
setEnableMemoryDraft,
|
||||||
|
setInitialCashDraft,
|
||||||
|
setIntervalMinutesDraft,
|
||||||
|
setMarginRequirementDraft,
|
||||||
|
setMaxCommCyclesDraft,
|
||||||
|
setScheduleModeDraft,
|
||||||
|
setTriggerTimeDraft
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRuntimeSettingsOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void fetchRuntimeHistory(20)
|
||||||
|
.then((payload) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const runs = Array.isArray(payload?.runs) ? payload.runs : [];
|
||||||
|
setRuntimeHistoryRuns(runs);
|
||||||
|
if (!restoreRunIdDraft && runs.length > 0) {
|
||||||
|
setRestoreRunIdDraft(runs[0].run_id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRuntimeHistoryRuns([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isRuntimeSettingsOpen, restoreRunIdDraft, setRestoreRunIdDraft, setRuntimeHistoryRuns]);
|
||||||
|
|
||||||
|
const commitWatchlistInput = useCallback((value) => {
|
||||||
|
const parsed = parseWatchlistInput(value);
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed])));
|
||||||
|
setWatchlistInputValue("");
|
||||||
|
if (watchlistFeedback) {
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||||
|
|
||||||
|
const handleWatchlistRemove = useCallback((symbolToRemove) => {
|
||||||
|
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
|
||||||
|
if (watchlistFeedback) {
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
}
|
||||||
|
}, [setWatchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||||
|
|
||||||
|
const handleWatchlistPanelToggle = useCallback(() => {
|
||||||
|
setIsRuntimeSettingsOpen(false);
|
||||||
|
setIsWatchlistPanelOpen((open) => {
|
||||||
|
const nextOpen = !open;
|
||||||
|
if (nextOpen) {
|
||||||
|
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||||
|
setWatchlistInputValue("");
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
}
|
||||||
|
return nextOpen;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
runtimeWatchlistSymbols,
|
||||||
|
setIsRuntimeSettingsOpen,
|
||||||
|
setIsWatchlistPanelOpen,
|
||||||
|
setWatchlistDraftSymbols,
|
||||||
|
setWatchlistFeedback,
|
||||||
|
setWatchlistInputValue
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleWatchlistInputChange = useCallback((value) => {
|
||||||
|
setWatchlistInputValue(value);
|
||||||
|
if (watchlistFeedback) {
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
}
|
||||||
|
}, [setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||||
|
|
||||||
|
const handleWatchlistInputKeyDown = useCallback((event) => {
|
||||||
|
if (event.key === "Enter" || event.key === ",") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitWatchlistInput(watchlistInputValue);
|
||||||
|
}
|
||||||
|
}, [commitWatchlistInput, watchlistInputValue]);
|
||||||
|
|
||||||
|
const handleWatchlistSuggestionClick = useCallback((symbol) => {
|
||||||
|
if (watchlistDraftSymbols.includes(symbol)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
|
||||||
|
if (watchlistFeedback) {
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
}
|
||||||
|
}, [setWatchlistDraftSymbols, watchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||||
|
|
||||||
|
const handleWatchlistRestoreCurrent = useCallback(() => {
|
||||||
|
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||||
|
setWatchlistInputValue("");
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
}, [runtimeWatchlistSymbols, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback]);
|
||||||
|
|
||||||
|
const handleWatchlistRestoreDefault = useCallback(() => {
|
||||||
|
setWatchlistDraftSymbols(watchlistSuggestions);
|
||||||
|
setWatchlistInputValue("");
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistSuggestions]);
|
||||||
|
|
||||||
|
const handleWatchlistSave = useCallback(() => {
|
||||||
|
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||||
|
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||||
|
if (nextTickers.length === 0) {
|
||||||
|
setWatchlistFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setWatchlistFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsWatchlistSaving(true);
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
setWatchlistDraftSymbols(nextTickers);
|
||||||
|
setWatchlistInputValue("");
|
||||||
|
const success = clientRef.current.send({
|
||||||
|
type: "update_watchlist",
|
||||||
|
tickers: nextTickers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setIsWatchlistSaving(false);
|
||||||
|
setWatchlistFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clientRef,
|
||||||
|
setIsWatchlistSaving,
|
||||||
|
setWatchlistDraftSymbols,
|
||||||
|
setWatchlistFeedback,
|
||||||
|
setWatchlistInputValue,
|
||||||
|
watchlistDraftSymbols,
|
||||||
|
watchlistInputValue
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRuntimeConfigSave = useCallback(() => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = Number(intervalMinutesDraft);
|
||||||
|
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||||
|
if (!Number.isInteger(interval) || interval <= 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRuntimeConfigSaving(true);
|
||||||
|
setRuntimeConfigFeedback(null);
|
||||||
|
const success = clientRef.current.send({
|
||||||
|
type: "update_runtime_config",
|
||||||
|
schedule_mode: scheduleModeDraft,
|
||||||
|
interval_minutes: interval,
|
||||||
|
trigger_time: triggerTimeDraft,
|
||||||
|
max_comm_cycles: maxCommCycles,
|
||||||
|
initial_cash: Number(initialCashDraft),
|
||||||
|
margin_requirement: Number(marginRequirementDraft),
|
||||||
|
enable_memory: Boolean(enableMemoryDraft)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setIsRuntimeConfigSaving(false);
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clientRef,
|
||||||
|
enableMemoryDraft,
|
||||||
|
initialCashDraft,
|
||||||
|
intervalMinutesDraft,
|
||||||
|
marginRequirementDraft,
|
||||||
|
maxCommCyclesDraft,
|
||||||
|
scheduleModeDraft,
|
||||||
|
setIsRuntimeConfigSaving,
|
||||||
|
setRuntimeConfigFeedback,
|
||||||
|
triggerTimeDraft
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleLaunchConfigSave = useCallback(async () => {
|
||||||
|
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||||
|
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||||
|
if (nextTickers.length === 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = Number(intervalMinutesDraft);
|
||||||
|
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||||
|
const initialCash = Number(initialCashDraft);
|
||||||
|
const marginRequirement = Number(marginRequirementDraft);
|
||||||
|
if (!Number.isInteger(interval) || interval <= 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(initialCash) || initialCash <= 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "初始资金必须是正数" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(marginRequirement) || marginRequirement < 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "保证金要求不能为负数" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (launchModeDraft === "restore" && !restoreRunIdDraft) {
|
||||||
|
setRuntimeConfigFeedback({ type: "error", text: "请选择一个历史任务用于恢复启动" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRuntimeConfigSaving(true);
|
||||||
|
setIsWatchlistSaving(true);
|
||||||
|
setRuntimeConfigFeedback(null);
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
setWatchlistDraftSymbols(nextTickers);
|
||||||
|
setWatchlistInputValue("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await startRuntime({
|
||||||
|
launch_mode: launchModeDraft,
|
||||||
|
restore_run_id: launchModeDraft === "restore" ? restoreRunIdDraft : null,
|
||||||
|
tickers: nextTickers,
|
||||||
|
schedule_mode: scheduleModeDraft,
|
||||||
|
interval_minutes: interval,
|
||||||
|
trigger_time: triggerTimeDraft,
|
||||||
|
max_comm_cycles: maxCommCycles,
|
||||||
|
initial_cash: initialCash,
|
||||||
|
margin_requirement: marginRequirement,
|
||||||
|
enable_memory: Boolean(enableMemoryDraft),
|
||||||
|
mode: modeDraft || DEFAULT_MODE,
|
||||||
|
poll_interval: Number(pollIntervalDraft) || Number(DEFAULT_POLL_INTERVAL),
|
||||||
|
start_date: startDateDraft || null,
|
||||||
|
end_date: endDateDraft || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsRuntimeConfigSaving(false);
|
||||||
|
setIsWatchlistSaving(false);
|
||||||
|
setIsRuntimeSettingsOpen(false);
|
||||||
|
setRuntimeConfigFeedback({
|
||||||
|
type: "success",
|
||||||
|
text: `任务已启动: ${result.run_id}`
|
||||||
|
});
|
||||||
|
addSystemMessage(`新任务已启动: ${result.run_id}`);
|
||||||
|
onRuntimeStarted?.(result);
|
||||||
|
} catch (error) {
|
||||||
|
setIsRuntimeConfigSaving(false);
|
||||||
|
setIsWatchlistSaving(false);
|
||||||
|
setRuntimeConfigFeedback({
|
||||||
|
type: "error",
|
||||||
|
text: `启动失败: ${error.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
addSystemMessage,
|
||||||
|
clientRef,
|
||||||
|
enableMemoryDraft,
|
||||||
|
endDateDraft,
|
||||||
|
initialCashDraft,
|
||||||
|
intervalMinutesDraft,
|
||||||
|
launchModeDraft,
|
||||||
|
marginRequirementDraft,
|
||||||
|
maxCommCyclesDraft,
|
||||||
|
modeDraft,
|
||||||
|
pollIntervalDraft,
|
||||||
|
restoreRunIdDraft,
|
||||||
|
scheduleModeDraft,
|
||||||
|
setIsRuntimeConfigSaving,
|
||||||
|
setIsRuntimeSettingsOpen,
|
||||||
|
setIsWatchlistSaving,
|
||||||
|
setRuntimeConfigFeedback,
|
||||||
|
setWatchlistDraftSymbols,
|
||||||
|
setWatchlistFeedback,
|
||||||
|
setWatchlistInputValue,
|
||||||
|
startDateDraft,
|
||||||
|
onRuntimeStarted,
|
||||||
|
triggerTimeDraft,
|
||||||
|
watchlistDraftSymbols,
|
||||||
|
watchlistInputValue
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRuntimeDefaultsRestore = useCallback(() => {
|
||||||
|
setScheduleModeDraft(DEFAULT_SCHEDULE_MODE);
|
||||||
|
setIntervalMinutesDraft(DEFAULT_INTERVAL_MINUTES);
|
||||||
|
setTriggerTimeDraft(DEFAULT_TRIGGER_TIME);
|
||||||
|
setMaxCommCyclesDraft(DEFAULT_MAX_COMM_CYCLES);
|
||||||
|
setInitialCashDraft(DEFAULT_INITIAL_CASH);
|
||||||
|
setMarginRequirementDraft(DEFAULT_MARGIN_REQUIREMENT);
|
||||||
|
setEnableMemoryDraft(false);
|
||||||
|
setLaunchModeDraft("fresh");
|
||||||
|
setRestoreRunIdDraft("");
|
||||||
|
setModeDraft(DEFAULT_MODE);
|
||||||
|
setPollIntervalDraft(DEFAULT_POLL_INTERVAL);
|
||||||
|
setStartDateDraft("");
|
||||||
|
setEndDateDraft("");
|
||||||
|
setRuntimeConfigFeedback(null);
|
||||||
|
}, [
|
||||||
|
setEnableMemoryDraft,
|
||||||
|
setEndDateDraft,
|
||||||
|
setInitialCashDraft,
|
||||||
|
setIntervalMinutesDraft,
|
||||||
|
setLaunchModeDraft,
|
||||||
|
setMarginRequirementDraft,
|
||||||
|
setMaxCommCyclesDraft,
|
||||||
|
setModeDraft,
|
||||||
|
setPollIntervalDraft,
|
||||||
|
setRestoreRunIdDraft,
|
||||||
|
setRuntimeConfigFeedback,
|
||||||
|
setScheduleModeDraft,
|
||||||
|
setStartDateDraft,
|
||||||
|
setTriggerTimeDraft
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRuntimeSettingsToggle = useCallback(() => {
|
||||||
|
setRuntimeConfigFeedback(null);
|
||||||
|
setAgentSkillsFeedback(null);
|
||||||
|
setWorkspaceFileFeedback(null);
|
||||||
|
setIsRuntimeSettingsOpen((prev) => {
|
||||||
|
const nextOpen = !prev;
|
||||||
|
if (nextOpen) {
|
||||||
|
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||||
|
setWatchlistInputValue("");
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
}
|
||||||
|
return nextOpen;
|
||||||
|
});
|
||||||
|
setIsWatchlistPanelOpen(false);
|
||||||
|
}, [
|
||||||
|
runtimeWatchlistSymbols,
|
||||||
|
setAgentSkillsFeedback,
|
||||||
|
setIsRuntimeSettingsOpen,
|
||||||
|
setIsWatchlistPanelOpen,
|
||||||
|
setRuntimeConfigFeedback,
|
||||||
|
setWatchlistDraftSymbols,
|
||||||
|
setWatchlistFeedback,
|
||||||
|
setWatchlistInputValue,
|
||||||
|
setWorkspaceFileFeedback
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRuntimeSettingsClose = useCallback(() => {
|
||||||
|
setIsRuntimeSettingsOpen(false);
|
||||||
|
}, [setIsRuntimeSettingsOpen]);
|
||||||
|
|
||||||
|
const handleWatchlistAdd = useCallback(() => commitWatchlistInput(watchlistInputValue), [commitWatchlistInput, watchlistInputValue]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtimeConfig,
|
||||||
|
displayTickers,
|
||||||
|
runtimeWatchlistSymbols,
|
||||||
|
runtimeSummaryLabel,
|
||||||
|
watchlistSuggestions,
|
||||||
|
isWatchlistDraftDirty,
|
||||||
|
isWatchlistPanelOpen,
|
||||||
|
isRuntimeSettingsOpen,
|
||||||
|
watchlistDraftSymbols,
|
||||||
|
watchlistInputValue,
|
||||||
|
watchlistFeedback,
|
||||||
|
isWatchlistSaving,
|
||||||
|
launchModeDraft,
|
||||||
|
restoreRunIdDraft,
|
||||||
|
runtimeHistoryRuns,
|
||||||
|
scheduleModeDraft,
|
||||||
|
intervalMinutesDraft,
|
||||||
|
triggerTimeDraft,
|
||||||
|
maxCommCyclesDraft,
|
||||||
|
initialCashDraft,
|
||||||
|
marginRequirementDraft,
|
||||||
|
enableMemoryDraft,
|
||||||
|
modeDraft,
|
||||||
|
pollIntervalDraft,
|
||||||
|
startDateDraft,
|
||||||
|
endDateDraft,
|
||||||
|
runtimeConfigFeedback,
|
||||||
|
isRuntimeConfigSaving,
|
||||||
|
isWatchlistSavingRef,
|
||||||
|
isRuntimeConfigSavingRef,
|
||||||
|
commitWatchlistInput,
|
||||||
|
handleWatchlistRemove,
|
||||||
|
handleWatchlistPanelToggle,
|
||||||
|
handleWatchlistInputChange,
|
||||||
|
handleWatchlistInputKeyDown,
|
||||||
|
handleWatchlistSuggestionClick,
|
||||||
|
handleWatchlistRestoreCurrent,
|
||||||
|
handleWatchlistRestoreDefault,
|
||||||
|
handleWatchlistSave,
|
||||||
|
handleWatchlistAdd,
|
||||||
|
handleRuntimeConfigSave,
|
||||||
|
handleLaunchConfigSave,
|
||||||
|
handleRuntimeDefaultsRestore,
|
||||||
|
handleRuntimeSettingsToggle,
|
||||||
|
handleRuntimeSettingsClose,
|
||||||
|
setRuntimeConfig,
|
||||||
|
setWatchlistDraftSymbols,
|
||||||
|
setWatchlistInputValue,
|
||||||
|
setWatchlistFeedback,
|
||||||
|
setRuntimeConfigFeedback,
|
||||||
|
setIsWatchlistPanelOpen,
|
||||||
|
setIsRuntimeSettingsOpen,
|
||||||
|
setScheduleModeDraft,
|
||||||
|
setIntervalMinutesDraft,
|
||||||
|
setTriggerTimeDraft,
|
||||||
|
setMaxCommCyclesDraft,
|
||||||
|
setInitialCashDraft,
|
||||||
|
setMarginRequirementDraft,
|
||||||
|
setEnableMemoryDraft,
|
||||||
|
setLaunchModeDraft,
|
||||||
|
setRestoreRunIdDraft,
|
||||||
|
setModeDraft,
|
||||||
|
setPollIntervalDraft,
|
||||||
|
setStartDateDraft,
|
||||||
|
setEndDateDraft,
|
||||||
|
setIsWatchlistSaving,
|
||||||
|
setIsRuntimeConfigSaving
|
||||||
|
};
|
||||||
|
}
|
||||||
352
frontend/src/hooks/useStockDataRequests.js
Normal file
352
frontend/src/hooks/useStockDataRequests.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useMarketStore } from '../store/marketStore';
|
||||||
|
import { useRuntimeStore } from '../store/runtimeStore';
|
||||||
|
import {
|
||||||
|
fetchNewsCategoriesDirect,
|
||||||
|
fetchNewsForDateDirect,
|
||||||
|
fetchRangeExplainDirect,
|
||||||
|
fetchSimilarDaysDirect,
|
||||||
|
fetchStockStoryDirect,
|
||||||
|
hasDirectNewsService
|
||||||
|
} from '../services/newsApi';
|
||||||
|
import {
|
||||||
|
fetchInsiderTradesDirect,
|
||||||
|
fetchStockHistoryDirect,
|
||||||
|
hasDirectTradingService
|
||||||
|
} from '../services/tradingApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for stock data request callbacks.
|
||||||
|
* Takes clientRef, calls store setters directly.
|
||||||
|
*/
|
||||||
|
export function useStockDataRequests(clientRef, { setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories }) {
|
||||||
|
const requestedStockHistoryRef = useRef(new Set());
|
||||||
|
|
||||||
|
const { currentDate } = useRuntimeStore();
|
||||||
|
const { setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker,
|
||||||
|
setNewsByTicker, setInsiderTradesByTicker } = useMarketStore();
|
||||||
|
|
||||||
|
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
if (!force && requestedStockHistoryRef.current.has(normalized)) return false;
|
||||||
|
|
||||||
|
const endDate = currentDate
|
||||||
|
? String(currentDate).slice(0, 10)
|
||||||
|
: new Date().toISOString().slice(0, 10);
|
||||||
|
const end = new Date(`${endDate}T00:00:00`);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 120);
|
||||||
|
const startDate = start.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (hasDirectTradingService()) {
|
||||||
|
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||||
|
.then((payload) => {
|
||||||
|
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||||
|
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||||
|
setPriceHistoryByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: prices
|
||||||
|
.map((point) => {
|
||||||
|
const price = Number(point?.close);
|
||||||
|
const timestamp = point?.time;
|
||||||
|
if (!timestamp || !Number.isFinite(price)) return null;
|
||||||
|
return { timestamp: String(timestamp), label: String(timestamp), price };
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}));
|
||||||
|
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: 'trading_service' }));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Direct stock-history fetch failed, falling back to websocket:', error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
const success = clientRef.current.send({
|
||||||
|
type: 'get_stock_history',
|
||||||
|
ticker: normalized,
|
||||||
|
lookback_days: 120
|
||||||
|
});
|
||||||
|
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
requestedStockHistoryRef.current.add(normalized);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) return false;
|
||||||
|
const success = clientRef.current.send({ type: 'get_stock_history', ticker: normalized, lookback_days: 120 });
|
||||||
|
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||||
|
return success;
|
||||||
|
}, [clientRef, currentDate, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]);
|
||||||
|
|
||||||
|
const requestStockExplainEvents = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized || !clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_explain_events', ticker: normalized });
|
||||||
|
}, [clientRef]);
|
||||||
|
|
||||||
|
const requestStockNews = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized || !clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_news', ticker: normalized, lookback_days: 45, limit: 12 });
|
||||||
|
}, [clientRef]);
|
||||||
|
|
||||||
|
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized || !date) return false;
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchNewsForDateDirect(normalized, date, 20)
|
||||||
|
.then((payload) => {
|
||||||
|
const targetDate = typeof payload?.date === 'string' ? payload.date.trim() : date;
|
||||||
|
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||||
|
const freshness = payload?.freshness || null;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
byDate: { ...((prev[normalized] && prev[normalized].byDate) || {}), [targetDate]: news },
|
||||||
|
byDateFreshness: { ...((prev[normalized] && prev[normalized].byDateFreshness) || {}), [targetDate]: freshness }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Direct news-for-date fetch failed, falling back to websocket:', error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockNewsTimeline = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized || !clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_news_timeline', ticker: normalized, lookback_days: 90 });
|
||||||
|
}, [clientRef]);
|
||||||
|
|
||||||
|
const requestStockNewsCategories = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
const endDate = currentDate
|
||||||
|
? String(currentDate).slice(0, 10)
|
||||||
|
: new Date().toISOString().slice(0, 10);
|
||||||
|
const end = new Date(`${endDate}T00:00:00`);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 90);
|
||||||
|
const startDate = start.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||||
|
.then((payload) => {
|
||||||
|
const freshness = payload?.freshness || null;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
categories: payload?.categories || {},
|
||||||
|
categoriesStartDate: startDate,
|
||||||
|
categoriesEndDate: endDate,
|
||||||
|
categoriesFreshness: freshness
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Direct news-categories fetch failed, falling back to websocket:', error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||||
|
}, [clientRef, currentDate, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
if (hasDirectTradingService()) {
|
||||||
|
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||||
|
.then((payload) => {
|
||||||
|
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||||
|
setInsiderTradesByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: { ticker: normalized, startDate, endDate, trades: rows }
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Direct insider-trades fetch failed, falling back to websocket:', error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||||
|
}, [clientRef, setInsiderTradesByTicker]);
|
||||||
|
|
||||||
|
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized || !clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_technical_indicators', ticker: normalized });
|
||||||
|
}, [clientRef]);
|
||||||
|
|
||||||
|
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized || !startDate || !endDate) return false;
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||||
|
.then((payload) => {
|
||||||
|
const result = payload?.result && typeof payload.result === 'object' ? payload.result : null;
|
||||||
|
const freshness = payload?.freshness || null;
|
||||||
|
if (!result?.start_date || !result?.end_date) return;
|
||||||
|
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
rangeExplainCache: {
|
||||||
|
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||||
|
[cacheKey]: { ...result, freshness }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Direct range explain fetch failed, falling back to websocket:', error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchStockStoryDirect(normalized, asOfDate)
|
||||||
|
.then((payload) => {
|
||||||
|
const storyDate = typeof payload?.as_of_date === 'string' ? payload.as_of_date.trim() : '';
|
||||||
|
const freshness = payload?.freshness || null;
|
||||||
|
if (!storyDate) return;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
storyCache: {
|
||||||
|
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||||
|
[storyDate]: { story: payload.story || '', source: payload.source || 'news_service', asOfDate: storyDate, freshness }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Direct story fetch failed, falling back to websocket:', error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized || !date) return false;
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||||
|
.then((payload) => {
|
||||||
|
const targetDate = typeof payload?.target_date === 'string' ? payload.target_date.trim() : date;
|
||||||
|
if (!targetDate) return;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
similarDaysCache: {
|
||||||
|
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||||
|
[targetDate]: payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Direct similar-days fetch failed, falling back to websocket:', error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) return false;
|
||||||
|
return clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockEnrich = useCallback((symbol, options = {}) => {
|
||||||
|
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||||
|
if (!normalized || !clientRef.current) return false;
|
||||||
|
const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : '';
|
||||||
|
const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : '';
|
||||||
|
if (!startDate || !endDate) return false;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
maintenanceStatus: { running: true, error: null, updatedAt: new Date().toISOString(), stats: null }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: 'run_stock_enrich',
|
||||||
|
ticker: normalized,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
force: Boolean(options.force),
|
||||||
|
only_local_to_llm: Boolean(options.onlyLocalToLlm),
|
||||||
|
rebuild_story: Boolean(options.rebuildStory),
|
||||||
|
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
|
||||||
|
story_date: options.storyDate || null,
|
||||||
|
target_date: options.targetDate || null
|
||||||
|
});
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
// Register request functions with WebSocket connection hook
|
||||||
|
if (setRequestStockHistory) setRequestStockHistory(requestStockHistory);
|
||||||
|
if (setRequestStockNewsTimeline) setRequestStockNewsTimeline(requestStockNewsTimeline);
|
||||||
|
if (setRequestStockNewsCategories) setRequestStockNewsCategories(requestStockNewsCategories);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestStockHistory,
|
||||||
|
requestStockExplainEvents,
|
||||||
|
requestStockNews,
|
||||||
|
requestStockNewsForDate,
|
||||||
|
requestStockNewsTimeline,
|
||||||
|
requestStockNewsCategories,
|
||||||
|
requestStockInsiderTrades,
|
||||||
|
requestStockTechnicalIndicators,
|
||||||
|
requestStockRangeExplain,
|
||||||
|
requestStockStory,
|
||||||
|
requestStockSimilarDays,
|
||||||
|
requestStockEnrich
|
||||||
|
};
|
||||||
|
}
|
||||||
546
frontend/src/hooks/useStockExplainData.js
Normal file
546
frontend/src/hooks/useStockExplainData.js
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchNewsCategoriesDirect,
|
||||||
|
fetchNewsForDateDirect,
|
||||||
|
fetchRangeExplainDirect,
|
||||||
|
fetchSimilarDaysDirect,
|
||||||
|
fetchStockStoryDirect,
|
||||||
|
hasDirectNewsService
|
||||||
|
} from "../services/newsApi";
|
||||||
|
import {
|
||||||
|
fetchInsiderTradesDirect,
|
||||||
|
fetchStockHistoryDirect,
|
||||||
|
hasDirectTradingService
|
||||||
|
} from "../services/tradingApi";
|
||||||
|
|
||||||
|
export function useStockExplainData({
|
||||||
|
clientRef,
|
||||||
|
currentDate,
|
||||||
|
currentView,
|
||||||
|
selectedExplainSymbol,
|
||||||
|
requestedStockHistoryRef,
|
||||||
|
setOhlcHistoryByTicker,
|
||||||
|
setPriceHistoryByTicker,
|
||||||
|
setHistorySourceByTicker,
|
||||||
|
setNewsByTicker,
|
||||||
|
setInsiderTradesByTicker
|
||||||
|
}) {
|
||||||
|
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && requestedStockHistoryRef.current.has(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = currentDate
|
||||||
|
? String(currentDate).slice(0, 10)
|
||||||
|
: new Date().toISOString().slice(0, 10);
|
||||||
|
const end = new Date(`${endDate}T00:00:00`);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 120);
|
||||||
|
const startDate = start.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (hasDirectTradingService()) {
|
||||||
|
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||||
|
.then((payload) => {
|
||||||
|
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||||
|
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||||
|
setPriceHistoryByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: prices
|
||||||
|
.map((point) => {
|
||||||
|
const price = Number(point?.close);
|
||||||
|
const timestamp = point?.time;
|
||||||
|
if (!timestamp || !Number.isFinite(price)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
timestamp: String(timestamp),
|
||||||
|
label: String(timestamp),
|
||||||
|
price
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}));
|
||||||
|
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: "trading_service" }));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Direct stock-history fetch failed, falling back to websocket:", error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
const success = clientRef.current.send({
|
||||||
|
type: "get_stock_history",
|
||||||
|
ticker: normalized,
|
||||||
|
lookback_days: 120
|
||||||
|
});
|
||||||
|
if (success) {
|
||||||
|
requestedStockHistoryRef.current.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
requestedStockHistoryRef.current.add(normalized);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = clientRef.current.send({
|
||||||
|
type: "get_stock_history",
|
||||||
|
ticker: normalized,
|
||||||
|
lookback_days: 120
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
requestedStockHistoryRef.current.add(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}, [
|
||||||
|
clientRef,
|
||||||
|
currentDate,
|
||||||
|
requestedStockHistoryRef,
|
||||||
|
setHistorySourceByTicker,
|
||||||
|
setOhlcHistoryByTicker,
|
||||||
|
setPriceHistoryByTicker
|
||||||
|
]);
|
||||||
|
|
||||||
|
const requestStockExplainEvents = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized || !clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_explain_events",
|
||||||
|
ticker: normalized
|
||||||
|
});
|
||||||
|
}, [clientRef]);
|
||||||
|
|
||||||
|
const requestStockNews = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized || !clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_news",
|
||||||
|
ticker: normalized,
|
||||||
|
lookback_days: 45,
|
||||||
|
limit: 12
|
||||||
|
});
|
||||||
|
}, [clientRef]);
|
||||||
|
|
||||||
|
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized || !date) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchNewsForDateDirect(normalized, date, 20)
|
||||||
|
.then((payload) => {
|
||||||
|
const targetDate = typeof payload?.date === "string" ? payload.date.trim() : date;
|
||||||
|
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||||
|
const freshness = payload?.freshness || null;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
byDate: {
|
||||||
|
...((prev[normalized] && prev[normalized].byDate) || {}),
|
||||||
|
[targetDate]: news
|
||||||
|
},
|
||||||
|
byDateFreshness: {
|
||||||
|
...((prev[normalized] && prev[normalized].byDateFreshness) || {}),
|
||||||
|
[targetDate]: freshness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Direct news-for-date fetch failed, falling back to websocket:", error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({
|
||||||
|
type: "get_stock_news_for_date",
|
||||||
|
ticker: normalized,
|
||||||
|
date,
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_news_for_date",
|
||||||
|
ticker: normalized,
|
||||||
|
date,
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockNewsTimeline = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized || !clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_news_timeline",
|
||||||
|
ticker: normalized,
|
||||||
|
lookback_days: 90
|
||||||
|
});
|
||||||
|
}, [clientRef]);
|
||||||
|
|
||||||
|
const requestStockNewsCategories = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = currentDate
|
||||||
|
? String(currentDate).slice(0, 10)
|
||||||
|
: new Date().toISOString().slice(0, 10);
|
||||||
|
const end = new Date(`${endDate}T00:00:00`);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 90);
|
||||||
|
const startDate = start.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||||
|
.then((payload) => {
|
||||||
|
const freshness = payload?.freshness || null;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
categories: payload?.categories || {},
|
||||||
|
categoriesStartDate: startDate,
|
||||||
|
categoriesEndDate: endDate,
|
||||||
|
categoriesFreshness: freshness
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Direct news-categories fetch failed, falling back to websocket:", error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({
|
||||||
|
type: "get_stock_news_categories",
|
||||||
|
ticker: normalized,
|
||||||
|
lookback_days: 90
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_news_categories",
|
||||||
|
ticker: normalized,
|
||||||
|
lookback_days: 90
|
||||||
|
});
|
||||||
|
}, [clientRef, currentDate, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDirectTradingService()) {
|
||||||
|
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||||
|
.then((payload) => {
|
||||||
|
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||||
|
setInsiderTradesByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
ticker: normalized,
|
||||||
|
startDate: startDate || null,
|
||||||
|
endDate: endDate || null,
|
||||||
|
trades: rows
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Direct insider-trades fetch failed, falling back to websocket:", error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({
|
||||||
|
type: "get_stock_insider_trades",
|
||||||
|
ticker: normalized,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_insider_trades",
|
||||||
|
ticker: normalized,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
}, [clientRef, setInsiderTradesByTicker]);
|
||||||
|
|
||||||
|
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized || !clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_technical_indicators",
|
||||||
|
ticker: normalized
|
||||||
|
});
|
||||||
|
}, [clientRef]);
|
||||||
|
|
||||||
|
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized || !startDate || !endDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||||
|
.then((payload) => {
|
||||||
|
const result = payload?.result && typeof payload.result === "object" ? payload.result : null;
|
||||||
|
const freshness = payload?.freshness || null;
|
||||||
|
if (!result?.start_date || !result?.end_date) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
rangeExplainCache: {
|
||||||
|
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||||
|
[cacheKey]: {
|
||||||
|
...result,
|
||||||
|
freshness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Direct range explain fetch failed, falling back to websocket:", error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({
|
||||||
|
type: "get_stock_range_explain",
|
||||||
|
ticker: normalized,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
article_ids: Array.isArray(articleIds) ? articleIds : []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_range_explain",
|
||||||
|
ticker: normalized,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
article_ids: Array.isArray(articleIds) ? articleIds : []
|
||||||
|
});
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchStockStoryDirect(normalized, asOfDate)
|
||||||
|
.then((payload) => {
|
||||||
|
const storyDate = typeof payload?.as_of_date === "string" ? payload.as_of_date.trim() : "";
|
||||||
|
const freshness = payload?.freshness || null;
|
||||||
|
if (!storyDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
storyCache: {
|
||||||
|
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||||
|
[storyDate]: {
|
||||||
|
story: payload.story || "",
|
||||||
|
source: payload.source || "news_service",
|
||||||
|
asOfDate: storyDate,
|
||||||
|
freshness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Direct story fetch failed, falling back to websocket:", error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({
|
||||||
|
type: "get_stock_story",
|
||||||
|
ticker: normalized,
|
||||||
|
as_of_date: asOfDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_story",
|
||||||
|
ticker: normalized,
|
||||||
|
as_of_date: asOfDate
|
||||||
|
});
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized || !date) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDirectNewsService()) {
|
||||||
|
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||||
|
.then((payload) => {
|
||||||
|
const targetDate = typeof payload?.target_date === "string" ? payload.target_date.trim() : date;
|
||||||
|
if (!targetDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
similarDaysCache: {
|
||||||
|
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||||
|
[targetDate]: payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Direct similar-days fetch failed, falling back to websocket:", error);
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.send({
|
||||||
|
type: "get_stock_similar_days",
|
||||||
|
ticker: normalized,
|
||||||
|
date,
|
||||||
|
top_k: topK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "get_stock_similar_days",
|
||||||
|
ticker: normalized,
|
||||||
|
date,
|
||||||
|
top_k: topK
|
||||||
|
});
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
const requestStockEnrich = useCallback((symbol, options = {}) => {
|
||||||
|
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||||
|
if (!normalized || !clientRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const startDate = typeof options.startDate === "string" ? options.startDate.trim() : "";
|
||||||
|
const endDate = typeof options.endDate === "string" ? options.endDate.trim() : "";
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalized]: {
|
||||||
|
...(prev[normalized] || {}),
|
||||||
|
maintenanceStatus: {
|
||||||
|
running: true,
|
||||||
|
error: null,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
stats: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return clientRef.current.send({
|
||||||
|
type: "run_stock_enrich",
|
||||||
|
ticker: normalized,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
force: Boolean(options.force),
|
||||||
|
only_local_to_llm: Boolean(options.onlyLocalToLlm),
|
||||||
|
rebuild_story: Boolean(options.rebuildStory),
|
||||||
|
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
|
||||||
|
story_date: options.storyDate || null,
|
||||||
|
target_date: options.targetDate || null
|
||||||
|
});
|
||||||
|
}, [clientRef, setNewsByTicker]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentView !== "explain" || !selectedExplainSymbol) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestStockHistory(selectedExplainSymbol);
|
||||||
|
requestStockExplainEvents(selectedExplainSymbol);
|
||||||
|
requestStockNews(selectedExplainSymbol);
|
||||||
|
requestStockNewsTimeline(selectedExplainSymbol);
|
||||||
|
requestStockNewsCategories(selectedExplainSymbol);
|
||||||
|
requestStockStory(selectedExplainSymbol, currentDate);
|
||||||
|
}, [
|
||||||
|
currentDate,
|
||||||
|
currentView,
|
||||||
|
requestStockExplainEvents,
|
||||||
|
requestStockHistory,
|
||||||
|
requestStockNews,
|
||||||
|
requestStockNewsCategories,
|
||||||
|
requestStockNewsTimeline,
|
||||||
|
requestStockStory,
|
||||||
|
selectedExplainSymbol
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestStockHistory,
|
||||||
|
requestStockExplainEvents,
|
||||||
|
requestStockNews,
|
||||||
|
requestStockNewsForDate,
|
||||||
|
requestStockNewsTimeline,
|
||||||
|
requestStockNewsCategories,
|
||||||
|
requestStockInsiderTrades,
|
||||||
|
requestStockTechnicalIndicators,
|
||||||
|
requestStockRangeExplain,
|
||||||
|
requestStockStory,
|
||||||
|
requestStockSimilarDays,
|
||||||
|
requestStockEnrich
|
||||||
|
};
|
||||||
|
}
|
||||||
861
frontend/src/hooks/useWebSocketConnection.js
Normal file
861
frontend/src/hooks/useWebSocketConnection.js
Normal file
@@ -0,0 +1,861 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { AGENTS } from '../config/constants';
|
||||||
|
import { ReadOnlyClient } from '../services/websocket';
|
||||||
|
import { useRuntimeStore } from '../store/runtimeStore';
|
||||||
|
import { useMarketStore } from '../store/marketStore';
|
||||||
|
import { usePortfolioStore } from '../store/portfolioStore';
|
||||||
|
import { useAgentStore } from '../store/agentStore';
|
||||||
|
import { useUIStore } from '../store/uiStore';
|
||||||
|
import { normalizeTickerSymbols } from '../services/runtimeControls';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize price history from server format
|
||||||
|
*/
|
||||||
|
function normalizePriceHistory(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = {};
|
||||||
|
Object.entries(payload).forEach(([symbol, points]) => {
|
||||||
|
const ticker = String(symbol || '').trim().toUpperCase();
|
||||||
|
if (!ticker || !Array.isArray(points)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized[ticker] = points
|
||||||
|
.map((point) => {
|
||||||
|
if (Array.isArray(point) && point.length >= 2) {
|
||||||
|
const [label, value] = point;
|
||||||
|
const price = Number(value);
|
||||||
|
if (!label || !Number.isFinite(price)) return null;
|
||||||
|
return { timestamp: String(label), label: String(label), price };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point && typeof point === 'object') {
|
||||||
|
const rawTimestamp = point.timestamp ?? point.t ?? point.date ?? point.label;
|
||||||
|
const price = Number(point.price ?? point.v ?? point.value ?? point.close);
|
||||||
|
if (!rawTimestamp || !Number.isFinite(price)) return null;
|
||||||
|
return { timestamp: String(rawTimestamp), label: String(rawTimestamp), price };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(-120);
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build tickers from symbols array
|
||||||
|
*/
|
||||||
|
function buildTickersFromSymbols(symbols, previousTickers = []) {
|
||||||
|
if (!Array.isArray(symbols) || symbols.length === 0) {
|
||||||
|
return previousTickers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return symbols
|
||||||
|
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
|
||||||
|
.map((symbol) => {
|
||||||
|
const normalized = symbol.trim().toUpperCase();
|
||||||
|
const existing = previousTickers.find((ticker) => ticker.symbol === normalized);
|
||||||
|
return existing || { symbol: normalized, price: null, change: null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for WebSocket connection lifecycle and event handling.
|
||||||
|
* Manages clientRef, connection, and ALL event handlers.
|
||||||
|
* Feeds directly into stores (no props drilling).
|
||||||
|
*/
|
||||||
|
export function useWebSocketConnection({
|
||||||
|
processHistoricalFeed,
|
||||||
|
processFeedEvent,
|
||||||
|
addSystemMessage
|
||||||
|
}) {
|
||||||
|
const clientRef = useRef(null);
|
||||||
|
const isWatchlistSavingRef = useRef(false);
|
||||||
|
const isRuntimeConfigSavingRef = useRef(false);
|
||||||
|
const selectedSkillAgentIdRef = useRef(null);
|
||||||
|
const requestedStockHistoryRef = useRef(new Set());
|
||||||
|
|
||||||
|
// Store state
|
||||||
|
const { setIsConnected, setConnectionStatus, setSystemStatus, setCurrentDate,
|
||||||
|
setServerMode, setDataSources, setRuntimeConfig, setMarketStatus,
|
||||||
|
setVirtualTime, setProgress, watchlistDraftSymbols, setWatchlistInputValue,
|
||||||
|
setIsWatchlistSaving, setWatchlistFeedback, setIsRuntimeConfigSaving,
|
||||||
|
setRuntimeConfigFeedback, isWatchlistSaving, isRuntimeConfigSaving,
|
||||||
|
setLastDayHistory } = useRuntimeStore();
|
||||||
|
|
||||||
|
const { tickers, setTickers, setRollingTickers, setPriceHistoryByTicker,
|
||||||
|
setExplainEventsByTicker, setNewsByTicker, setInsiderTradesByTicker,
|
||||||
|
setTechnicalIndicatorsByTicker, setHistorySourceByTicker,
|
||||||
|
setOhlcHistoryByTicker } = useMarketStore();
|
||||||
|
|
||||||
|
const { setPortfolioData, setHoldings, setTrades, setStats, setLeaderboard } = usePortfolioStore();
|
||||||
|
|
||||||
|
const { setAgentSkillsByAgent, setAgentProfilesByAgent, setSkillDetailsByName,
|
||||||
|
setLocalSkillDraftsByKey, setIsAgentSkillsLoading, setSkillDetailLoadingKey,
|
||||||
|
setAgentSkillsSavingKey, setAgentSkillsFeedback, setIsWorkspaceFileLoading,
|
||||||
|
setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, setWorkspaceFileFeedback,
|
||||||
|
selectedSkillAgentId } = useAgentStore();
|
||||||
|
|
||||||
|
const { setBubbles } = useUIStore();
|
||||||
|
|
||||||
|
// Helper: Update tickers from realtime prices
|
||||||
|
const updateTickersFromPrices = useCallback((realtimePrices) => {
|
||||||
|
try {
|
||||||
|
setTickers((prevTickers) => prevTickers.map((ticker) => {
|
||||||
|
const realtimeData = realtimePrices[ticker.symbol];
|
||||||
|
if (realtimeData && realtimeData.price !== null && realtimeData.price !== undefined) {
|
||||||
|
const newChange = (realtimeData.ret !== null && realtimeData.ret !== undefined)
|
||||||
|
? realtimeData.ret
|
||||||
|
: (ticker.change !== null && ticker.change !== undefined ? ticker.change : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ticker,
|
||||||
|
price: realtimeData.price,
|
||||||
|
change: newChange,
|
||||||
|
open: realtimeData.open || ticker.open
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ticker;
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating tickers from prices:', error);
|
||||||
|
}
|
||||||
|
}, [setTickers]);
|
||||||
|
|
||||||
|
// Stock request callbacks (these will be provided by useStockDataRequests)
|
||||||
|
const requestStockHistoryRef = useRef(null);
|
||||||
|
const requestStockNewsTimelineRef = useRef(null);
|
||||||
|
const requestStockNewsCategoriesRef = useRef(null);
|
||||||
|
|
||||||
|
const setRequestStockHistory = useCallback((fn) => {
|
||||||
|
requestStockHistoryRef.current = fn;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setRequestStockNewsTimeline = useCallback((fn) => {
|
||||||
|
requestStockNewsTimelineRef.current = fn;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setRequestStockNewsCategories = useCallback((fn) => {
|
||||||
|
requestStockNewsCategoriesRef.current = fn;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePushEvent = (evt) => {
|
||||||
|
if (!evt) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
handleEventInternal(evt);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Event Handler] Error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEventInternal = (evt) => {
|
||||||
|
if (evt?.type && evt.type !== 'pong') {
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
setIsConnected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
error: (e) => {
|
||||||
|
const message = typeof e.message === 'string' ? e.message : '请求失败';
|
||||||
|
console.error('[Error]', message);
|
||||||
|
setIsAgentSkillsLoading(false);
|
||||||
|
setSkillDetailLoadingKey(null);
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
setIsWorkspaceFileLoading(false);
|
||||||
|
setWorkspaceFileSavingKey(null);
|
||||||
|
if (isWatchlistSavingRef.current) {
|
||||||
|
setIsWatchlistSaving(false);
|
||||||
|
setWatchlistFeedback({ type: 'error', text: message || '更新 watchlist 失败' });
|
||||||
|
}
|
||||||
|
if (isRuntimeConfigSavingRef.current) {
|
||||||
|
setIsRuntimeConfigSaving(false);
|
||||||
|
setRuntimeConfigFeedback({ type: 'error', text: message });
|
||||||
|
}
|
||||||
|
if (message.includes('skill') || message.includes('agent_id')) {
|
||||||
|
setAgentSkillsFeedback({ type: 'error', text: message || '更新技能失败' });
|
||||||
|
}
|
||||||
|
if (message.includes('workspace_file') || message.includes('filename')) {
|
||||||
|
setWorkspaceFileFeedback({ type: 'error', text: message || '更新工作区文件失败' });
|
||||||
|
}
|
||||||
|
if (message.includes('fast forward')) {
|
||||||
|
console.warn(`⚠️ ${message}`);
|
||||||
|
handlePushEvent({ type: 'system', content: `⚠️ ${message}`, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
addSystemMessage(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
system: (e) => {
|
||||||
|
console.log('[System]', e.content);
|
||||||
|
if (e.content.includes('Connected') || e.content.includes('已连接')) {
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
setIsConnected(true);
|
||||||
|
} else if (e.content.includes('Disconnected') || e.content.includes('断开')) {
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
processFeedEvent(e);
|
||||||
|
},
|
||||||
|
|
||||||
|
pong: () => {
|
||||||
|
console.log('[Heartbeat] Pong received');
|
||||||
|
},
|
||||||
|
|
||||||
|
initial_state: (e) => {
|
||||||
|
try {
|
||||||
|
const state = e.state;
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
setIsConnected(true);
|
||||||
|
setSystemStatus(state.status || 'initializing');
|
||||||
|
setCurrentDate(state.current_date);
|
||||||
|
|
||||||
|
if (state.server_mode) setServerMode(state.server_mode);
|
||||||
|
if (state.data_sources) setDataSources(state.data_sources);
|
||||||
|
if (state.runtime_config) setRuntimeConfig(state.runtime_config);
|
||||||
|
if (Array.isArray(state.tickers) && state.tickers.length > 0) {
|
||||||
|
setTickers((prevTickers) => buildTickersFromSymbols(state.tickers, prevTickers));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.market_status) {
|
||||||
|
setMarketStatus(state.market_status);
|
||||||
|
setVirtualTime(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.trading_days_total) {
|
||||||
|
setProgress({
|
||||||
|
current: state.trading_days_completed || 0,
|
||||||
|
total: state.trading_days_total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.portfolio) {
|
||||||
|
setPortfolioData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
netValue: state.portfolio.total_value || prev.netValue,
|
||||||
|
pnl: state.portfolio.pnl_percent || 0,
|
||||||
|
equity: state.portfolio.equity || prev.equity,
|
||||||
|
baseline: state.portfolio.baseline || prev.baseline,
|
||||||
|
baseline_vw: state.portfolio.baseline_vw || prev.baseline_vw,
|
||||||
|
momentum: state.portfolio.momentum || prev.momentum,
|
||||||
|
strategies: state.portfolio.strategies || prev.strategies,
|
||||||
|
equity_return: state.portfolio.equity_return || prev.equity_return,
|
||||||
|
baseline_return: state.portfolio.baseline_return || prev.baseline_return,
|
||||||
|
baseline_vw_return: state.portfolio.baseline_vw_return || prev.baseline_vw_return,
|
||||||
|
momentum_return: state.portfolio.momentum_return || prev.momentum_return
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.dashboard) {
|
||||||
|
if (state.dashboard.holdings) setHoldings(state.dashboard.holdings);
|
||||||
|
if (state.dashboard.trades) setTrades(state.dashboard.trades);
|
||||||
|
if (state.dashboard.stats) setStats(state.dashboard.stats);
|
||||||
|
if (state.dashboard.leaderboard) setLeaderboard(state.dashboard.leaderboard);
|
||||||
|
}
|
||||||
|
if (state.realtime_prices) updateTickersFromPrices(state.realtime_prices);
|
||||||
|
if (state.price_history) {
|
||||||
|
setPriceHistoryByTicker(normalizePriceHistory(state.price_history));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.feed_history && Array.isArray(state.feed_history)) {
|
||||||
|
console.log(`✅ Loading ${state.feed_history.length} historical events`);
|
||||||
|
processHistoricalFeed(state.feed_history);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.last_day_history && Array.isArray(state.last_day_history)) {
|
||||||
|
setLastDayHistory(state.last_day_history);
|
||||||
|
console.log(`✅ Loaded ${state.last_day_history.length} last day events for replay`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Initial state loaded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading initial state:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
market_status_update: (e) => {
|
||||||
|
if (e.market_status) setMarketStatus(e.market_status);
|
||||||
|
},
|
||||||
|
|
||||||
|
data_sources_update: (e) => {
|
||||||
|
if (e.data_sources) setDataSources(e.data_sources);
|
||||||
|
},
|
||||||
|
|
||||||
|
runtime_assets_reloaded: (e) => {
|
||||||
|
if (e.runtime_config_applied) setRuntimeConfig(e.runtime_config_applied);
|
||||||
|
if (Array.isArray(e.runtime_config_applied?.tickers)) {
|
||||||
|
setTickers((prevTickers) =>
|
||||||
|
buildTickersFromSymbols(e.runtime_config_applied.tickers, prevTickers)
|
||||||
|
);
|
||||||
|
setWatchlistInputValue('');
|
||||||
|
}
|
||||||
|
if (isWatchlistSavingRef.current) setIsWatchlistSaving(false);
|
||||||
|
if (isRuntimeConfigSavingRef.current) {
|
||||||
|
setIsRuntimeConfigSaving(false);
|
||||||
|
setRuntimeConfigFeedback({ type: 'success', text: '运行配置已保存并生效' });
|
||||||
|
}
|
||||||
|
const warnings = Array.isArray(e.runtime_config_warnings) ? e.runtime_config_warnings : [];
|
||||||
|
warnings.forEach((warning) => addSystemMessage(warning));
|
||||||
|
addSystemMessage('运行时配置已热更新');
|
||||||
|
},
|
||||||
|
|
||||||
|
agent_skills_loaded: (e) => {
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||||
|
if (!agentId) {
|
||||||
|
setIsAgentSkillsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAgentSkillsByAgent((prev) => ({ ...prev, [agentId]: Array.isArray(e.skills) ? e.skills : [] }));
|
||||||
|
setIsAgentSkillsLoading(false);
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
},
|
||||||
|
|
||||||
|
agent_profile_loaded: (e) => {
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||||
|
if (!agentId) return;
|
||||||
|
setAgentProfilesByAgent((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[agentId]: e.profile && typeof e.profile === 'object' ? e.profile : {}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
skill_detail_loaded: (e) => {
|
||||||
|
const skillName = typeof e.skill?.skill_name === 'string' ? e.skill.skill_name.trim() : '';
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : selectedSkillAgentIdRef.current;
|
||||||
|
if (!skillName) {
|
||||||
|
setSkillDetailLoadingKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detailKey = `${agentId}:${skillName}`;
|
||||||
|
setSkillDetailsByName((prev) => ({ ...prev, [detailKey]: e.skill }));
|
||||||
|
setLocalSkillDraftsByKey((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[detailKey]: typeof e.skill?.content === 'string' ? e.skill.content : ''
|
||||||
|
}));
|
||||||
|
setSkillDetailLoadingKey(null);
|
||||||
|
},
|
||||||
|
|
||||||
|
agent_skill_updated: (e) => {
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||||
|
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||||
|
if (!agentId || !skillName) return;
|
||||||
|
setAgentSkillsFeedback({
|
||||||
|
type: 'success',
|
||||||
|
text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}`
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
agent_local_skill_created: (e) => {
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||||
|
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
if (!agentId || !skillName) return;
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已创建本地技能 ${skillName}` });
|
||||||
|
},
|
||||||
|
|
||||||
|
agent_local_skill_updated: (e) => {
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||||
|
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
if (!agentId || !skillName) return;
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已保存` });
|
||||||
|
},
|
||||||
|
|
||||||
|
agent_local_skill_deleted: (e) => {
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||||
|
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
if (!agentId || !skillName) return;
|
||||||
|
setSkillDetailsByName((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[`${agentId}:${skillName}`];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setLocalSkillDraftsByKey((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[`${agentId}:${skillName}`];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已删除` });
|
||||||
|
},
|
||||||
|
|
||||||
|
agent_skill_removed: (e) => {
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||||
|
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
|
||||||
|
setAgentSkillsSavingKey(null);
|
||||||
|
if (!agentId || !skillName) return;
|
||||||
|
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已移除共享技能 ${skillName}` });
|
||||||
|
},
|
||||||
|
|
||||||
|
agent_workspace_file_loaded: (e) => {
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||||
|
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
|
||||||
|
if (!agentId || !filename) {
|
||||||
|
setIsWorkspaceFileLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWorkspaceFilesByAgent((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[agentId]: { ...(prev[agentId] || {}), [filename]: typeof e.content === 'string' ? e.content : '' }
|
||||||
|
}));
|
||||||
|
setIsWorkspaceFileLoading(false);
|
||||||
|
setWorkspaceFileSavingKey(null);
|
||||||
|
},
|
||||||
|
|
||||||
|
agent_workspace_file_updated: (e) => {
|
||||||
|
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
|
||||||
|
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
|
||||||
|
if (!agentId || !filename) return;
|
||||||
|
setWorkspaceFileFeedback({ type: 'success', text: `${agentId} 的 ${filename} 已保存` });
|
||||||
|
},
|
||||||
|
|
||||||
|
watchlist_updated: (e) => {
|
||||||
|
if (Array.isArray(e.tickers)) {
|
||||||
|
const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase());
|
||||||
|
setRuntimeConfig((prev) => ({ ...(prev || {}), tickers: normalizedTickers }));
|
||||||
|
setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers));
|
||||||
|
}
|
||||||
|
setIsWatchlistSaving(false);
|
||||||
|
setWatchlistFeedback({
|
||||||
|
type: 'success',
|
||||||
|
text: `已更新为 ${Array.isArray(e.tickers) ? e.tickers.join(', ') : '最新列表'}`
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_history_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!symbol) return;
|
||||||
|
if (Array.isArray(e.prices)) {
|
||||||
|
setOhlcHistoryByTicker((prev) => ({ ...prev, [symbol]: e.prices }));
|
||||||
|
setHistorySourceByTicker((prev) => ({ ...prev, [symbol]: e.source || null }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_explain_events_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!symbol) return;
|
||||||
|
setExplainEventsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: {
|
||||||
|
events: Array.isArray(e.events) ? e.events : [],
|
||||||
|
signals: Array.isArray(e.signals) ? e.signals : [],
|
||||||
|
trades: Array.isArray(e.trades) ? e.trades : []
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_news_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!symbol) return;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: {
|
||||||
|
...(prev[symbol] || {}),
|
||||||
|
items: Array.isArray(e.news) ? e.news : [],
|
||||||
|
source: e.source || null,
|
||||||
|
startDate: e.start_date || null,
|
||||||
|
endDate: e.end_date || null,
|
||||||
|
freshness: e.freshness || null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(symbol);
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_news_for_date_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
const date = typeof e.date === 'string' ? e.date.trim() : '';
|
||||||
|
if (!symbol || !date) return;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: {
|
||||||
|
...(prev[symbol] || {}),
|
||||||
|
byDate: { ...((prev[symbol] && prev[symbol].byDate) || {}), [date]: Array.isArray(e.news) ? e.news : [] },
|
||||||
|
byDateFreshness: { ...((prev[symbol] && prev[symbol].byDateFreshness) || {}), [date]: e.freshness || null }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_news_timeline_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!symbol) return;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: {
|
||||||
|
...(prev[symbol] || {}),
|
||||||
|
timeline: Array.isArray(e.timeline) ? e.timeline : [],
|
||||||
|
timelineStartDate: e.start_date || null,
|
||||||
|
timelineEndDate: e.end_date || null,
|
||||||
|
timelineFreshness: e.freshness || null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_news_categories_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!symbol) return;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: {
|
||||||
|
...(prev[symbol] || {}),
|
||||||
|
categories: e.categories || {},
|
||||||
|
categoriesStartDate: e.start_date || null,
|
||||||
|
categoriesEndDate: e.end_date || null,
|
||||||
|
categoriesFreshness: e.freshness || null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_insider_trades_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!symbol) return;
|
||||||
|
setInsiderTradesByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: { trades: Array.isArray(e.trades) ? e.trades : [], startDate: e.start_date || null, endDate: e.end_date || null }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_technical_indicators_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!symbol) return;
|
||||||
|
setTechnicalIndicatorsByTicker((prev) => ({ ...prev, [symbol]: e.indicators || null }));
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_range_explain_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!symbol) return;
|
||||||
|
const result = e.result && typeof e.result === 'object' ? e.result : null;
|
||||||
|
if (!result?.start_date || !result?.end_date) return;
|
||||||
|
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: {
|
||||||
|
...(prev[symbol] || {}),
|
||||||
|
rangeExplainCache: {
|
||||||
|
...((prev[symbol] && prev[symbol].rangeExplainCache) || {}),
|
||||||
|
[cacheKey]: { ...result, freshness: e.freshness || null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_story_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
const asOfDate = typeof e.as_of_date === 'string' ? e.as_of_date.trim() : '';
|
||||||
|
if (!symbol || !asOfDate) return;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: {
|
||||||
|
...(prev[symbol] || {}),
|
||||||
|
storyCache: {
|
||||||
|
...((prev[symbol] && prev[symbol].storyCache) || {}),
|
||||||
|
[asOfDate]: { story: e.story || '', source: e.source || null, asOfDate, freshness: e.freshness || null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_similar_days_loaded: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
const date = typeof e.target_date === 'string' ? e.target_date.trim() : typeof e.date === 'string' ? e.date.trim() : '';
|
||||||
|
if (!symbol || !date) return;
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: {
|
||||||
|
...(prev[symbol] || {}),
|
||||||
|
similarDaysCache: {
|
||||||
|
...((prev[symbol] && prev[symbol].similarDaysCache) || {}),
|
||||||
|
[date]: {
|
||||||
|
target_features: e.target_features || {},
|
||||||
|
items: Array.isArray(e.items) ? e.items : [],
|
||||||
|
error: e.error || null,
|
||||||
|
freshness: e.freshness || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
stock_enrich_completed: (e) => {
|
||||||
|
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!symbol) return;
|
||||||
|
const completedAt = new Date().toISOString();
|
||||||
|
const historyEntry = {
|
||||||
|
timestamp: completedAt,
|
||||||
|
startDate: e.start_date || '',
|
||||||
|
endDate: e.end_date || '',
|
||||||
|
force: Boolean(e.force),
|
||||||
|
onlyLocalToLlm: Boolean(e.only_local_to_llm),
|
||||||
|
error: e.error || null,
|
||||||
|
stats: e.stats || null,
|
||||||
|
storyStatus: e.story_status || null,
|
||||||
|
similarStatus: e.similar_status || null
|
||||||
|
};
|
||||||
|
setNewsByTicker((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[symbol]: {
|
||||||
|
...(prev[symbol] || {}),
|
||||||
|
items: [], byDate: {}, timeline: [], categories: {},
|
||||||
|
rangeExplainCache: {}, storyCache: {}, similarDaysCache: {},
|
||||||
|
maintenanceStatus: { running: false, error: e.error || null, updatedAt: completedAt, stats: e.stats || null, storyStatus: e.story_status || null, similarStatus: e.similar_status || null },
|
||||||
|
maintenanceHistory: [historyEntry, ...(((prev[symbol] && prev[symbol].maintenanceHistory) || []).slice(0, 7))]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (!e.error) {
|
||||||
|
if (requestStockHistoryRef.current) requestStockHistoryRef.current(symbol);
|
||||||
|
if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(symbol);
|
||||||
|
if (requestStockNewsCategoriesRef.current) requestStockNewsCategoriesRef.current(symbol);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
price_update: (e) => {
|
||||||
|
try {
|
||||||
|
const { symbol, price, ret, open, portfolio, realtime_prices } = e;
|
||||||
|
if (!symbol || !price) {
|
||||||
|
console.warn('[Price Update] Missing symbol or price:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
setIsConnected(true);
|
||||||
|
console.log(`[Price Update] ${symbol}: $${price} (ret: ${ret !== undefined ? ret.toFixed(2) : 'N/A'}%)`);
|
||||||
|
|
||||||
|
setPriceHistoryByTicker((prev) => {
|
||||||
|
const ticker = String(symbol).trim().toUpperCase();
|
||||||
|
const nextPoint = { timestamp: new Date().toISOString(), label: new Date().toISOString(), price: Number(price) };
|
||||||
|
const existing = Array.isArray(prev[ticker]) ? prev[ticker] : [];
|
||||||
|
const lastPoint = existing[existing.length - 1];
|
||||||
|
if (lastPoint && Number(lastPoint.price) === Number(nextPoint.price)) return prev;
|
||||||
|
return { ...prev, [ticker]: [...existing, nextPoint].slice(-120) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedSymbol = String(symbol).trim().toUpperCase();
|
||||||
|
let shouldAnimateTicker = false;
|
||||||
|
setTickers((prevTickers) => prevTickers.map((ticker) => {
|
||||||
|
if (ticker.symbol === symbol) {
|
||||||
|
const oldPrice = ticker.price;
|
||||||
|
let newChange = ticker.change;
|
||||||
|
if (ret !== null && ret !== undefined) {
|
||||||
|
newChange = ret;
|
||||||
|
} else if (oldPrice !== null && oldPrice !== undefined && isFinite(oldPrice)) {
|
||||||
|
const priceChange = ((price - oldPrice) / oldPrice) * 100;
|
||||||
|
newChange = (newChange !== null && newChange !== undefined) ? newChange + priceChange : priceChange;
|
||||||
|
} else {
|
||||||
|
newChange = 0;
|
||||||
|
}
|
||||||
|
if (oldPrice !== price) shouldAnimateTicker = true;
|
||||||
|
return { ...ticker, price, change: newChange, open: open || ticker.open };
|
||||||
|
}
|
||||||
|
return ticker;
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (shouldAnimateTicker) {
|
||||||
|
setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: true }));
|
||||||
|
setTimeout(() => setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: false })), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (realtime_prices) updateTickersFromPrices(realtime_prices);
|
||||||
|
if (portfolio && portfolio.total_value) {
|
||||||
|
setPortfolioData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
netValue: portfolio.total_value,
|
||||||
|
pnl: portfolio.pnl_percent || 0,
|
||||||
|
equity: portfolio.equity || prev.equity
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Price Update] Error:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
day_start: (e) => {
|
||||||
|
setCurrentDate(e.date);
|
||||||
|
if (e.progress !== undefined) {
|
||||||
|
setProgress((prev) => ({ ...prev, current: Math.floor(e.progress * (prev.total || 1)) }));
|
||||||
|
}
|
||||||
|
setSystemStatus('running');
|
||||||
|
processFeedEvent(e);
|
||||||
|
},
|
||||||
|
|
||||||
|
day_complete: (e) => {
|
||||||
|
const result = e.result;
|
||||||
|
if (result && typeof result === 'object') {
|
||||||
|
if (result.portfolio_summary) {
|
||||||
|
const summary = result.portfolio_summary;
|
||||||
|
setPortfolioData((prev) => {
|
||||||
|
const newEquity = [...prev.equity];
|
||||||
|
const dateObj = new Date(e.date);
|
||||||
|
newEquity.push({ t: dateObj.getTime(), v: summary.total_value || summary.cash || prev.netValue });
|
||||||
|
return { ...prev, netValue: summary.total_value || summary.cash || prev.netValue, pnl: summary.pnl_percent || 0, equity: newEquity };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processFeedEvent(e);
|
||||||
|
},
|
||||||
|
|
||||||
|
day_error: (e) => {
|
||||||
|
console.error('Day error:', e.date, e.error);
|
||||||
|
processFeedEvent(e);
|
||||||
|
},
|
||||||
|
|
||||||
|
conference_start: (e) => processFeedEvent(e),
|
||||||
|
conference_end: (e) => processFeedEvent(e),
|
||||||
|
|
||||||
|
agent_message: (e) => {
|
||||||
|
const agent = AGENTS.find((item) => item.id === e.agentId);
|
||||||
|
setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || e.agentId } });
|
||||||
|
processFeedEvent(e);
|
||||||
|
},
|
||||||
|
|
||||||
|
conference_message: (e) => {
|
||||||
|
const agent = AGENTS.find((item) => item.id === e.agentId);
|
||||||
|
setBubbles({ [e.agentId]: { text: e.content, ts: Date.now(), agentName: agent?.name || e.agentName || e.agentId } });
|
||||||
|
processFeedEvent(e);
|
||||||
|
},
|
||||||
|
|
||||||
|
memory: (e) => processFeedEvent(e),
|
||||||
|
|
||||||
|
team_summary: (e) => {
|
||||||
|
setPortfolioData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
netValue: e.balance || prev.netValue,
|
||||||
|
pnl: e.pnlPct || 0,
|
||||||
|
equity: e.equity || prev.equity,
|
||||||
|
baseline: e.baseline || prev.baseline,
|
||||||
|
baseline_vw: e.baseline_vw || prev.baseline_vw,
|
||||||
|
momentum: e.momentum || prev.momentum,
|
||||||
|
equity_return: e.equity_return || prev.equity_return,
|
||||||
|
baseline_return: e.baseline_return || prev.baseline_return,
|
||||||
|
baseline_vw_return: e.baseline_vw_return || prev.baseline_vw_return,
|
||||||
|
momentum_return: e.momentum_return || prev.momentum_return
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
team_portfolio: (e) => {
|
||||||
|
if (e.holdings) setHoldings(e.holdings);
|
||||||
|
},
|
||||||
|
|
||||||
|
team_holdings: (e) => {
|
||||||
|
if (e.data && Array.isArray(e.data)) {
|
||||||
|
setHoldings(e.data);
|
||||||
|
console.log(`✅ Holdings updated: ${e.data.length} positions`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
team_trades: (e) => {
|
||||||
|
if (e.mode === 'full' && e.data && Array.isArray(e.data)) {
|
||||||
|
setTrades(e.data);
|
||||||
|
} else if (Array.isArray(e.trades)) {
|
||||||
|
setTrades(e.trades);
|
||||||
|
} else if (e.trade) {
|
||||||
|
setTrades((prev) => [e.trade, ...prev].slice(0, 100));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
team_stats: (e) => {
|
||||||
|
if (e.data) setStats(e.data);
|
||||||
|
else if (e.stats) setStats(e.stats);
|
||||||
|
},
|
||||||
|
|
||||||
|
team_leaderboard: (e) => {
|
||||||
|
if (Array.isArray(e.data)) setLeaderboard(e.data);
|
||||||
|
else if (Array.isArray(e.rows)) setLeaderboard(e.rows);
|
||||||
|
else if (Array.isArray(e.leaderboard)) setLeaderboard(e.leaderboard);
|
||||||
|
},
|
||||||
|
|
||||||
|
time_update: (e) => {
|
||||||
|
if (e.beijing_time_str) {
|
||||||
|
const statusEmoji = { market_open: '📊', off_market: '⏸️', non_trading_day: '📅', trade_execution: '💼' };
|
||||||
|
const emoji = statusEmoji[e.status] || '⏰';
|
||||||
|
let logMessage = `${emoji} 时间: ${e.beijing_time_str} | 状态: ${e.status}`;
|
||||||
|
if (e.hours_to_open !== undefined) logMessage += ` | 距离开盘: ${e.hours_to_open}小时`;
|
||||||
|
if (e.hours_to_trade !== undefined) logMessage += ` | 距离交易: ${e.hours_to_trade}小时`;
|
||||||
|
if (e.trading_date) logMessage += ` | 交易日: ${e.trading_date}`;
|
||||||
|
console.log(logMessage);
|
||||||
|
|
||||||
|
setVirtualTime(null);
|
||||||
|
}
|
||||||
|
if (e.market_status) setMarketStatus(e.market_status);
|
||||||
|
},
|
||||||
|
|
||||||
|
time_fast_forwarded: (e) => {
|
||||||
|
console.log(`⏩ 时间已快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`);
|
||||||
|
if (e.new_time) {
|
||||||
|
try {
|
||||||
|
setVirtualTime(new Date(e.new_time));
|
||||||
|
handlePushEvent({ type: 'system', content: `⏩ 时间快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`, timestamp: Date.now() });
|
||||||
|
} catch (error) { console.error('Error parsing fast forwarded time:', error); }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fast_forward_success: (e) => {
|
||||||
|
console.log(`✅ ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handler = handlers[evt.type];
|
||||||
|
if (handler) handler(evt);
|
||||||
|
else console.log('[handleEvent] Unknown event type:', evt.type);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleEvent] Error handling event:', evt.type, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create and connect WebSocket client
|
||||||
|
const client = new ReadOnlyClient(handlePushEvent);
|
||||||
|
clientRef.current = client;
|
||||||
|
client.connect();
|
||||||
|
setConnectionStatus('connecting');
|
||||||
|
|
||||||
|
// Sync refs with store state
|
||||||
|
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||||
|
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||||
|
selectedSkillAgentIdRef.current = selectedSkillAgentId;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
addSystemMessage, processFeedEvent,
|
||||||
|
processHistoricalFeed, setAgentProfilesByAgent,
|
||||||
|
setAgentSkillsByAgent, setAgentSkillsFeedback, setAgentSkillsSavingKey,
|
||||||
|
setBubbles, setConnectionStatus, setCurrentDate, setDataSources,
|
||||||
|
setExplainEventsByTicker, setHistorySourceByTicker, setHoldings,
|
||||||
|
setInsiderTradesByTicker, setIsAgentSkillsLoading, setIsConnected,
|
||||||
|
setIsRuntimeConfigSaving, setIsWatchlistSaving, setIsWorkspaceFileLoading,
|
||||||
|
setLastDayHistory, setLeaderboard, setLocalSkillDraftsByKey,
|
||||||
|
setMarketStatus, setNewsByTicker, setOhlcHistoryByTicker,
|
||||||
|
setPortfolioData, setPriceHistoryByTicker, setProgress,
|
||||||
|
setRollingTickers, setRuntimeConfig, setRuntimeConfigFeedback,
|
||||||
|
setServerMode, setSkillDetailLoadingKey, setSkillDetailsByName,
|
||||||
|
setStats, setSystemStatus, setTechnicalIndicatorsByTicker,
|
||||||
|
setTickers, setTrades, setVirtualTime, setWatchlistFeedback,
|
||||||
|
setWatchlistInputValue, setWorkspaceFileFeedback, setWorkspaceFileSavingKey,
|
||||||
|
setWorkspaceFilesByAgent, updateTickersFromPrices
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sync refs
|
||||||
|
useEffect(() => {
|
||||||
|
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||||
|
}, [isWatchlistSaving]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||||
|
}, [isRuntimeConfigSaving]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedSkillAgentIdRef.current = selectedSkillAgentId;
|
||||||
|
}, [selectedSkillAgentId]);
|
||||||
|
|
||||||
|
return { clientRef, setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories };
|
||||||
|
}
|
||||||
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* useWebsocketSessionSync - DEPRECATED
|
||||||
|
*
|
||||||
|
* This hook is deprecated. WebSocket connection and event handling is now managed
|
||||||
|
* by useWebSocketConnection.js. This file is kept for backwards compatibility
|
||||||
|
* but will be removed in a future version.
|
||||||
|
*
|
||||||
|
* All functionality has been consolidated into:
|
||||||
|
* - useWebSocketConnection.js: WebSocket lifecycle and event handlers
|
||||||
|
* - useStockDataRequests.js: Stock data request callbacks
|
||||||
|
* - useAgentDataRequests.js: Agent operation callbacks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useWebSocketConnection } from './useWebSocketConnection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use useWebSocketConnection directly instead.
|
||||||
|
* This hook is a thin wrapper that delegates to useWebSocketConnection
|
||||||
|
* for backwards compatibility.
|
||||||
|
*/
|
||||||
|
export function useWebsocketSessionSync(props) {
|
||||||
|
// Delegate to useWebSocketConnection
|
||||||
|
const { clientRef } = useWebSocketConnection();
|
||||||
|
|
||||||
|
// Return clientRef so existing code can still access it
|
||||||
|
return { clientRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useWebsocketSessionSync;
|
||||||
@@ -38,6 +38,10 @@ export function fetchRuntimeEvents() {
|
|||||||
return safeFetch(RUNTIME_API_BASE, '/events');
|
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');
|
||||||
}
|
}
|
||||||
@@ -121,6 +125,73 @@ 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,
|
||||||
|
|||||||
81
frontend/src/services/runtimeControls.js
Normal file
81
frontend/src/services/runtimeControls.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
const normalizeSymbol = (symbol) => {
|
||||||
|
if (typeof symbol !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return symbol.trim().toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeTickerSymbols = (symbols, previousTickers = []) => {
|
||||||
|
if (!Array.isArray(symbols) || symbols.length === 0) {
|
||||||
|
return previousTickers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return symbols
|
||||||
|
.map(normalizeSymbol)
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((acc, symbol) => {
|
||||||
|
const existing = acc.find((ticker) => ticker.symbol === symbol);
|
||||||
|
if (existing) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
const prior = previousTickers.find((ticker) => ticker.symbol === symbol);
|
||||||
|
acc.push(
|
||||||
|
prior || {
|
||||||
|
symbol,
|
||||||
|
price: null,
|
||||||
|
change: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeRuntimeWatchlistSymbols = (runtimeConfig, fallbackTickers = []) => {
|
||||||
|
const runtimeSymbols = Array.isArray(runtimeConfig?.tickers)
|
||||||
|
? runtimeConfig.tickers.map(normalizeSymbol).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (runtimeSymbols.length > 0) {
|
||||||
|
return runtimeSymbols;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackTickers
|
||||||
|
.map((ticker) => normalizeSymbol(ticker?.symbol))
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseWatchlistInput = (value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
value
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map(normalizeSymbol)
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildRuntimeSummaryLabel = (runtimeConfig) => {
|
||||||
|
if (!runtimeConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleMode = String(runtimeConfig.schedule_mode || "daily");
|
||||||
|
const intervalMinutes = Number(runtimeConfig.interval_minutes || 60);
|
||||||
|
const triggerTime = String(runtimeConfig.trigger_time || "now");
|
||||||
|
const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2);
|
||||||
|
|
||||||
|
if (scheduleMode === "intraday") {
|
||||||
|
return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles} 轮`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggerTime.toLowerCase() === "now") {
|
||||||
|
return `调度 daily / 立即执行 / 讨论 ${maxCommCycles} 轮`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles} 轮`;
|
||||||
|
};
|
||||||
59
frontend/src/services/runtimeControls.test.js
Normal file
59
frontend/src/services/runtimeControls.test.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildRuntimeSummaryLabel,
|
||||||
|
normalizeRuntimeWatchlistSymbols,
|
||||||
|
normalizeTickerSymbols,
|
||||||
|
parseWatchlistInput
|
||||||
|
} from "./runtimeControls";
|
||||||
|
|
||||||
|
describe("runtimeControls", () => {
|
||||||
|
it("normalizes ticker symbols while preserving existing entries", () => {
|
||||||
|
const previous = [
|
||||||
|
{ symbol: "AAPL", price: 10, change: 1 },
|
||||||
|
{ symbol: "MSFT", price: 20, change: 2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(normalizeTickerSymbols(["aapl", "nvda", "MSFT"], previous)).toEqual([
|
||||||
|
{ symbol: "AAPL", price: 10, change: 1 },
|
||||||
|
{ symbol: "NVDA", price: null, change: null },
|
||||||
|
{ symbol: "MSFT", price: 20, change: 2 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives runtime watchlist symbols from runtime config or fallback tickers", () => {
|
||||||
|
const runtimeConfig = { tickers: ["tsla", "meta", "tsla"] };
|
||||||
|
const fallbackTickers = [{ symbol: "AAPL" }, { symbol: "MSFT" }];
|
||||||
|
|
||||||
|
expect(normalizeRuntimeWatchlistSymbols(runtimeConfig, fallbackTickers)).toEqual([
|
||||||
|
"TSLA",
|
||||||
|
"META",
|
||||||
|
"TSLA"
|
||||||
|
]);
|
||||||
|
expect(normalizeRuntimeWatchlistSymbols({}, fallbackTickers)).toEqual([
|
||||||
|
"AAPL",
|
||||||
|
"MSFT"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses watchlist input tokens and removes duplicates", () => {
|
||||||
|
expect(parseWatchlistInput(" aapl, msft nvda\nNVDA ")).toEqual([
|
||||||
|
"AAPL",
|
||||||
|
"MSFT",
|
||||||
|
"NVDA"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds runtime summary labels", () => {
|
||||||
|
expect(buildRuntimeSummaryLabel({
|
||||||
|
schedule_mode: "daily",
|
||||||
|
trigger_time: "09:30",
|
||||||
|
max_comm_cycles: 3
|
||||||
|
})).toBe("调度 daily / 09:30 ET / 讨论 3 轮");
|
||||||
|
|
||||||
|
expect(buildRuntimeSummaryLabel({
|
||||||
|
schedule_mode: "intraday",
|
||||||
|
interval_minutes: 15,
|
||||||
|
max_comm_cycles: 2
|
||||||
|
})).toBe("调度 intraday / 15m / 讨论 2 轮");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,13 +24,13 @@ export async function fetchGatewayPort() {
|
|||||||
if (data.is_running && data.port) {
|
if (data.is_running && data.port) {
|
||||||
cachedGatewayPort = data.port;
|
cachedGatewayPort = data.port;
|
||||||
cachedWsUrl = data.ws_url;
|
cachedWsUrl = data.ws_url;
|
||||||
return { port: data.port, wsUrl: data.ws_url };
|
return { status: "running", port: data.port, wsUrl: data.ws_url };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return { status: "stopped", port: data.port || null, wsUrl: data.ws_url || null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Gateway] Failed to fetch port:', error);
|
console.warn('[Gateway] Failed to fetch port:', error);
|
||||||
return null;
|
return { status: "unavailable", port: null, wsUrl: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,15 +86,29 @@ export class ReadOnlyClient {
|
|||||||
// Resolve WebSocket URL if not set
|
// 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) {
|
if (gatewayInfo?.status === "running" && gatewayInfo.wsUrl) {
|
||||||
targetUrl = gatewayInfo.wsUrl;
|
targetUrl = gatewayInfo.wsUrl;
|
||||||
console.log(`[WebSocket] Resolved Gateway port: ${gatewayInfo.port}`);
|
console.log(`[WebSocket] Resolved Gateway port: ${gatewayInfo.port}`);
|
||||||
} else {
|
} else if (gatewayInfo?.status === "unavailable") {
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,62 @@
|
|||||||
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({ selectedSkillAgentId }),
|
setSelectedSkillAgentId: (selectedSkillAgentId) => set((state) => ({ selectedSkillAgentId: resolveValue(selectedSkillAgentId, state.selectedSkillAgentId) })),
|
||||||
|
|
||||||
// Agent profiles
|
// Agent profiles
|
||||||
agentProfilesByAgent: {},
|
agentProfilesByAgent: {},
|
||||||
setAgentProfilesByAgent: (agentProfilesByAgent) => set({ agentProfilesByAgent }),
|
setAgentProfilesByAgent: (agentProfilesByAgent) => set((state) => ({ agentProfilesByAgent: resolveValue(agentProfilesByAgent, state.agentProfilesByAgent) })),
|
||||||
|
|
||||||
// Agent skills
|
// Agent skills
|
||||||
agentSkillsByAgent: {},
|
agentSkillsByAgent: {},
|
||||||
setAgentSkillsByAgent: (agentSkillsByAgent) => set({ agentSkillsByAgent }),
|
setAgentSkillsByAgent: (agentSkillsByAgent) => set((state) => ({ agentSkillsByAgent: resolveValue(agentSkillsByAgent, state.agentSkillsByAgent) })),
|
||||||
|
|
||||||
// Skill details
|
// Skill details
|
||||||
skillDetailsByName: {},
|
skillDetailsByName: {},
|
||||||
setSkillDetailsByName: (skillDetailsByName) => set({ skillDetailsByName }),
|
setSkillDetailsByName: (skillDetailsByName) => set((state) => ({ skillDetailsByName: resolveValue(skillDetailsByName, state.skillDetailsByName) })),
|
||||||
|
|
||||||
// Local skill drafts
|
// Local skill drafts
|
||||||
localSkillDraftsByKey: {},
|
localSkillDraftsByKey: {},
|
||||||
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set({ localSkillDraftsByKey }),
|
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set((state) => ({ localSkillDraftsByKey: resolveValue(localSkillDraftsByKey, state.localSkillDraftsByKey) })),
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
isAgentSkillsLoading: false,
|
isAgentSkillsLoading: false,
|
||||||
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set({ isAgentSkillsLoading }),
|
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set((state) => ({ isAgentSkillsLoading: resolveValue(isAgentSkillsLoading, state.isAgentSkillsLoading) })),
|
||||||
|
|
||||||
skillDetailLoadingKey: null,
|
skillDetailLoadingKey: null,
|
||||||
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set({ skillDetailLoadingKey }),
|
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set((state) => ({ skillDetailLoadingKey: resolveValue(skillDetailLoadingKey, state.skillDetailLoadingKey) })),
|
||||||
|
|
||||||
agentSkillsSavingKey: null,
|
agentSkillsSavingKey: null,
|
||||||
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set({ agentSkillsSavingKey }),
|
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set((state) => ({ agentSkillsSavingKey: resolveValue(agentSkillsSavingKey, state.agentSkillsSavingKey) })),
|
||||||
|
|
||||||
agentSkillsFeedback: null,
|
agentSkillsFeedback: null,
|
||||||
setAgentSkillsFeedback: (agentSkillsFeedback) => set({ agentSkillsFeedback }),
|
setAgentSkillsFeedback: (agentSkillsFeedback) => set((state) => ({ agentSkillsFeedback: resolveValue(agentSkillsFeedback, state.agentSkillsFeedback) })),
|
||||||
|
|
||||||
// Workspace files
|
// Workspace files
|
||||||
selectedWorkspaceFile: null,
|
selectedWorkspaceFile: null,
|
||||||
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set({ selectedWorkspaceFile }),
|
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set((state) => ({ selectedWorkspaceFile: resolveValue(selectedWorkspaceFile, state.selectedWorkspaceFile) })),
|
||||||
|
|
||||||
workspaceFilesByAgent: {},
|
workspaceFilesByAgent: {},
|
||||||
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set({ workspaceFilesByAgent }),
|
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set((state) => ({ workspaceFilesByAgent: resolveValue(workspaceFilesByAgent, state.workspaceFilesByAgent) })),
|
||||||
|
|
||||||
workspaceDraftContent: '',
|
workspaceDraftContent: '',
|
||||||
setWorkspaceDraftContent: (workspaceDraftContent) => set({ workspaceDraftContent }),
|
setWorkspaceDraftContent: (workspaceDraftContent) => set((state) => ({ workspaceDraftContent: resolveValue(workspaceDraftContent, state.workspaceDraftContent) })),
|
||||||
|
|
||||||
isWorkspaceFileLoading: false,
|
isWorkspaceFileLoading: false,
|
||||||
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set({ isWorkspaceFileLoading }),
|
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set((state) => ({ isWorkspaceFileLoading: resolveValue(isWorkspaceFileLoading, state.isWorkspaceFileLoading) })),
|
||||||
|
|
||||||
workspaceFileSavingKey: null,
|
workspaceFileSavingKey: null,
|
||||||
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set({ workspaceFileSavingKey }),
|
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set((state) => ({ workspaceFileSavingKey: resolveValue(workspaceFileSavingKey, state.workspaceFileSavingKey) })),
|
||||||
|
|
||||||
workspaceFileFeedback: null,
|
workspaceFileFeedback: null,
|
||||||
setWorkspaceFileFeedback: (workspaceFileFeedback) => set({ workspaceFileFeedback }),
|
setWorkspaceFileFeedback: (workspaceFileFeedback) => set((state) => ({ workspaceFileFeedback: resolveValue(workspaceFileFeedback, state.workspaceFileFeedback) })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
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({ tickers }),
|
setTickers: (tickers) => set((state) => ({ tickers: resolveValue(tickers, state.tickers) })),
|
||||||
rollingTickers: {},
|
rollingTickers: {},
|
||||||
setRollingTickers: (rollingTickers) => set({ rollingTickers }),
|
setRollingTickers: (rollingTickers) => set((state) => ({ rollingTickers: resolveValue(rollingTickers, state.rollingTickers) })),
|
||||||
|
|
||||||
// Price history
|
// Price history
|
||||||
priceHistoryByTicker: {},
|
priceHistoryByTicker: {},
|
||||||
setPriceHistoryByTicker: (priceHistoryByTicker) => set({ priceHistoryByTicker }),
|
setPriceHistoryByTicker: (priceHistoryByTicker) => set((state) => ({ priceHistoryByTicker: resolveValue(priceHistoryByTicker, state.priceHistoryByTicker) })),
|
||||||
|
|
||||||
// OHLC history
|
// OHLC history
|
||||||
ohlcHistoryByTicker: {},
|
ohlcHistoryByTicker: {},
|
||||||
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set({ ohlcHistoryByTicker }),
|
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set((state) => ({ ohlcHistoryByTicker: resolveValue(ohlcHistoryByTicker, state.ohlcHistoryByTicker) })),
|
||||||
|
|
||||||
// History source tracking
|
// History source tracking
|
||||||
historySourceByTicker: {},
|
historySourceByTicker: {},
|
||||||
setHistorySourceByTicker: (historySourceByTicker) => set({ historySourceByTicker }),
|
setHistorySourceByTicker: (historySourceByTicker) => set((state) => ({ historySourceByTicker: resolveValue(historySourceByTicker, state.historySourceByTicker) })),
|
||||||
|
|
||||||
// Explain events
|
// Explain events
|
||||||
explainEventsByTicker: {},
|
explainEventsByTicker: {},
|
||||||
setExplainEventsByTicker: (explainEventsByTicker) => set({ explainEventsByTicker }),
|
setExplainEventsByTicker: (explainEventsByTicker) => set((state) => ({ explainEventsByTicker: resolveValue(explainEventsByTicker, state.explainEventsByTicker) })),
|
||||||
|
|
||||||
// Selected explain symbol
|
// Selected explain symbol
|
||||||
selectedExplainSymbol: '',
|
selectedExplainSymbol: '',
|
||||||
setSelectedExplainSymbol: (selectedExplainSymbol) => set({ selectedExplainSymbol }),
|
setSelectedExplainSymbol: (selectedExplainSymbol) => set((state) => ({ selectedExplainSymbol: resolveValue(selectedExplainSymbol, state.selectedExplainSymbol) })),
|
||||||
|
|
||||||
// News by ticker
|
// News by ticker
|
||||||
newsByTicker: {},
|
newsByTicker: {},
|
||||||
setNewsByTicker: (newsByTicker) => set({ newsByTicker }),
|
setNewsByTicker: (newsByTicker) => set((state) => ({ newsByTicker: resolveValue(newsByTicker, state.newsByTicker) })),
|
||||||
|
|
||||||
// Insider trades
|
// Insider trades
|
||||||
insiderTradesByTicker: {},
|
insiderTradesByTicker: {},
|
||||||
setInsiderTradesByTicker: (insiderTradesByTicker) => set({ insiderTradesByTicker }),
|
setInsiderTradesByTicker: (insiderTradesByTicker) => set((state) => ({ insiderTradesByTicker: resolveValue(insiderTradesByTicker, state.insiderTradesByTicker) })),
|
||||||
|
|
||||||
// Technical indicators
|
// Technical indicators
|
||||||
technicalIndicatorsByTicker: {},
|
technicalIndicatorsByTicker: {},
|
||||||
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set({ technicalIndicatorsByTicker }),
|
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set((state) => ({ technicalIndicatorsByTicker: resolveValue(technicalIndicatorsByTicker, state.technicalIndicatorsByTicker) })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
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
|
||||||
*/
|
*/
|
||||||
@@ -18,21 +22,21 @@ export const usePortfolioStore = create((set) => ({
|
|||||||
baseline_vw_return: 0,
|
baseline_vw_return: 0,
|
||||||
momentum_return: 0,
|
momentum_return: 0,
|
||||||
},
|
},
|
||||||
setPortfolioData: (portfolioData) => set({ portfolioData }),
|
setPortfolioData: (portfolioData) => set((state) => ({ portfolioData: resolveValue(portfolioData, state.portfolioData) })),
|
||||||
|
|
||||||
// Holdings
|
// Holdings
|
||||||
holdings: [],
|
holdings: [],
|
||||||
setHoldings: (holdings) => set({ holdings }),
|
setHoldings: (holdings) => set((state) => ({ holdings: resolveValue(holdings, state.holdings) })),
|
||||||
|
|
||||||
// Trades
|
// Trades
|
||||||
trades: [],
|
trades: [],
|
||||||
setTrades: (trades) => set({ trades }),
|
setTrades: (trades) => set((state) => ({ trades: resolveValue(trades, state.trades) })),
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
stats: null,
|
stats: null,
|
||||||
setStats: (stats) => set({ stats }),
|
setStats: (stats) => set((state) => ({ stats: resolveValue(stats, state.stats) })),
|
||||||
|
|
||||||
// Leaderboard
|
// Leaderboard
|
||||||
leaderboard: [],
|
leaderboard: [],
|
||||||
setLeaderboard: (leaderboard) => set({ leaderboard }),
|
setLeaderboard: (leaderboard) => set((state) => ({ leaderboard: resolveValue(leaderboard, state.leaderboard) })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
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
|
||||||
*/
|
*/
|
||||||
@@ -7,59 +11,62 @@ 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({ isConnected }),
|
setIsConnected: (isConnected) => set((state) => ({ isConnected: resolveValue(isConnected, state.isConnected) })),
|
||||||
setConnectionStatus: (connectionStatus) => set({ connectionStatus }),
|
setConnectionStatus: (connectionStatus) => set((state) => ({ connectionStatus: resolveValue(connectionStatus, state.connectionStatus) })),
|
||||||
|
|
||||||
// System state
|
// System state
|
||||||
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
|
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
|
||||||
currentDate: null,
|
currentDate: null,
|
||||||
setSystemStatus: (systemStatus) => set({ systemStatus }),
|
setSystemStatus: (systemStatus) => set((state) => ({ systemStatus: resolveValue(systemStatus, state.systemStatus) })),
|
||||||
setCurrentDate: (currentDate) => set({ currentDate }),
|
setCurrentDate: (currentDate) => set((state) => ({ currentDate: resolveValue(currentDate, state.currentDate) })),
|
||||||
|
|
||||||
// Progress
|
// Progress
|
||||||
progress: { current: 0, total: 0 },
|
progress: { current: 0, total: 0 },
|
||||||
setProgress: (progress) => set({ progress }),
|
setProgress: (progress) => set((state) => ({ progress: resolveValue(progress, state.progress) })),
|
||||||
|
|
||||||
// Server mode
|
// Server mode
|
||||||
serverMode: null, // 'live' | 'backtest' | null
|
serverMode: null, // 'live' | 'backtest' | null
|
||||||
setServerMode: (serverMode) => set({ serverMode }),
|
setServerMode: (serverMode) => set((state) => ({ serverMode: resolveValue(serverMode, state.serverMode) })),
|
||||||
|
|
||||||
// Market status
|
// Market status
|
||||||
marketStatus: null,
|
marketStatus: null,
|
||||||
virtualTime: null,
|
virtualTime: null,
|
||||||
setMarketStatus: (marketStatus) => set({ marketStatus }),
|
setMarketStatus: (marketStatus) => set((state) => ({ marketStatus: resolveValue(marketStatus, state.marketStatus) })),
|
||||||
setVirtualTime: (virtualTime) => set({ virtualTime }),
|
setVirtualTime: (virtualTime) => set((state) => ({ virtualTime: resolveValue(virtualTime, state.virtualTime) })),
|
||||||
|
|
||||||
// Data sources
|
// Data sources
|
||||||
dataSources: null,
|
dataSources: null,
|
||||||
setDataSources: (dataSources) => set({ dataSources }),
|
setDataSources: (dataSources) => set((state) => ({ dataSources: resolveValue(dataSources, state.dataSources) })),
|
||||||
|
|
||||||
// Runtime config
|
// Runtime config
|
||||||
runtimeConfig: null,
|
runtimeConfig: null,
|
||||||
setRuntimeConfig: (runtimeConfig) => set({ runtimeConfig }),
|
setRuntimeConfig: (runtimeConfig) => set((state) => ({ runtimeConfig: resolveValue(runtimeConfig, state.runtimeConfig) })),
|
||||||
|
|
||||||
// Watchlist panel
|
// Watchlist panel
|
||||||
isWatchlistPanelOpen: false,
|
isWatchlistPanelOpen: false,
|
||||||
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set({ isWatchlistPanelOpen }),
|
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set((state) => ({ isWatchlistPanelOpen: resolveValue(isWatchlistPanelOpen, state.isWatchlistPanelOpen) })),
|
||||||
|
|
||||||
// Watchlist draft
|
// Watchlist draft
|
||||||
watchlistDraftSymbols: [],
|
watchlistDraftSymbols: [],
|
||||||
watchlistInputValue: '',
|
watchlistInputValue: '',
|
||||||
watchlistFeedback: null,
|
watchlistFeedback: null,
|
||||||
isWatchlistSaving: false,
|
isWatchlistSaving: false,
|
||||||
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set({ watchlistDraftSymbols }),
|
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set((state) => ({ watchlistDraftSymbols: resolveValue(watchlistDraftSymbols, state.watchlistDraftSymbols) })),
|
||||||
setWatchlistInputValue: (watchlistInputValue) => set({ watchlistInputValue }),
|
setWatchlistInputValue: (watchlistInputValue) => set((state) => ({ watchlistInputValue: resolveValue(watchlistInputValue, state.watchlistInputValue) })),
|
||||||
setWatchlistFeedback: (watchlistFeedback) => set({ watchlistFeedback }),
|
setWatchlistFeedback: (watchlistFeedback) => set((state) => ({ watchlistFeedback: resolveValue(watchlistFeedback, state.watchlistFeedback) })),
|
||||||
setIsWatchlistSaving: (isWatchlistSaving) => set({ isWatchlistSaving }),
|
setIsWatchlistSaving: (isWatchlistSaving) => set((state) => ({ isWatchlistSaving: resolveValue(isWatchlistSaving, state.isWatchlistSaving) })),
|
||||||
|
|
||||||
// Runtime settings panel
|
// Runtime settings panel
|
||||||
isRuntimeSettingsOpen: false,
|
isRuntimeSettingsOpen: false,
|
||||||
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set({ isRuntimeSettingsOpen }),
|
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })),
|
||||||
|
|
||||||
// Runtime config drafts
|
// Runtime config drafts
|
||||||
|
launchModeDraft: 'fresh',
|
||||||
|
restoreRunIdDraft: '',
|
||||||
|
runtimeHistoryRuns: [],
|
||||||
scheduleModeDraft: 'daily',
|
scheduleModeDraft: 'daily',
|
||||||
intervalMinutesDraft: '60',
|
intervalMinutesDraft: '60',
|
||||||
triggerTimeDraft: '09:30',
|
triggerTimeDraft: 'now',
|
||||||
maxCommCyclesDraft: '2',
|
maxCommCyclesDraft: '2',
|
||||||
initialCashDraft: '100000',
|
initialCashDraft: '100000',
|
||||||
marginRequirementDraft: '0',
|
marginRequirementDraft: '0',
|
||||||
@@ -68,23 +75,28 @@ export const useRuntimeStore = create((set) => ({
|
|||||||
pollIntervalDraft: '10',
|
pollIntervalDraft: '10',
|
||||||
startDateDraft: '',
|
startDateDraft: '',
|
||||||
endDateDraft: '',
|
endDateDraft: '',
|
||||||
enableMockDraft: false,
|
setLaunchModeDraft: (launchModeDraft) => set((state) => ({ launchModeDraft: resolveValue(launchModeDraft, state.launchModeDraft) })),
|
||||||
setScheduleModeDraft: (scheduleModeDraft) => set({ scheduleModeDraft }),
|
setRestoreRunIdDraft: (restoreRunIdDraft) => set((state) => ({ restoreRunIdDraft: resolveValue(restoreRunIdDraft, state.restoreRunIdDraft) })),
|
||||||
setIntervalMinutesDraft: (intervalMinutesDraft) => set({ intervalMinutesDraft }),
|
setRuntimeHistoryRuns: (runtimeHistoryRuns) => set((state) => ({ runtimeHistoryRuns: resolveValue(runtimeHistoryRuns, state.runtimeHistoryRuns) })),
|
||||||
setTriggerTimeDraft: (triggerTimeDraft) => set({ triggerTimeDraft }),
|
setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })),
|
||||||
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set({ maxCommCyclesDraft }),
|
setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })),
|
||||||
setInitialCashDraft: (initialCashDraft) => set({ initialCashDraft }),
|
setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })),
|
||||||
setMarginRequirementDraft: (marginRequirementDraft) => set({ marginRequirementDraft }),
|
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set((state) => ({ maxCommCyclesDraft: resolveValue(maxCommCyclesDraft, state.maxCommCyclesDraft) })),
|
||||||
setEnableMemoryDraft: (enableMemoryDraft) => set({ enableMemoryDraft }),
|
setInitialCashDraft: (initialCashDraft) => set((state) => ({ initialCashDraft: resolveValue(initialCashDraft, state.initialCashDraft) })),
|
||||||
setModeDraft: (modeDraft) => set({ modeDraft }),
|
setMarginRequirementDraft: (marginRequirementDraft) => set((state) => ({ marginRequirementDraft: resolveValue(marginRequirementDraft, state.marginRequirementDraft) })),
|
||||||
setPollIntervalDraft: (pollIntervalDraft) => set({ pollIntervalDraft }),
|
setEnableMemoryDraft: (enableMemoryDraft) => set((state) => ({ enableMemoryDraft: resolveValue(enableMemoryDraft, state.enableMemoryDraft) })),
|
||||||
setStartDateDraft: (startDateDraft) => set({ startDateDraft }),
|
setModeDraft: (modeDraft) => set((state) => ({ modeDraft: resolveValue(modeDraft, state.modeDraft) })),
|
||||||
setEndDateDraft: (endDateDraft) => set({ endDateDraft }),
|
setPollIntervalDraft: (pollIntervalDraft) => set((state) => ({ pollIntervalDraft: resolveValue(pollIntervalDraft, state.pollIntervalDraft) })),
|
||||||
setEnableMockDraft: (enableMockDraft) => set({ enableMockDraft }),
|
setStartDateDraft: (startDateDraft) => set((state) => ({ startDateDraft: resolveValue(startDateDraft, state.startDateDraft) })),
|
||||||
|
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({ runtimeConfigFeedback }),
|
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set((state) => ({ runtimeConfigFeedback: resolveValue(runtimeConfigFeedback, state.runtimeConfigFeedback) })),
|
||||||
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set({ isRuntimeConfigSaving }),
|
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set((state) => ({ isRuntimeConfigSaving: resolveValue(isRuntimeConfigSaving, state.isRuntimeConfigSaving) })),
|
||||||
|
|
||||||
|
// Last day history (for replay)
|
||||||
|
lastDayHistory: [],
|
||||||
|
setLastDayHistory: (lastDayHistory) => set((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,40 +1,44 @@
|
|||||||
import { create } from 'zustand';
|
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({ currentView }),
|
setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
|
||||||
|
|
||||||
// Chart tab
|
// Chart tab
|
||||||
chartTab: 'all',
|
chartTab: 'all',
|
||||||
setChartTab: (chartTab) => set({ chartTab }),
|
setChartTab: (chartTab) => set((state) => ({ chartTab: resolveValue(chartTab, state.chartTab) })),
|
||||||
|
|
||||||
// Initial animation
|
// Initial animation
|
||||||
isInitialAnimating: true,
|
isInitialAnimating: true,
|
||||||
setIsInitialAnimating: (isInitialAnimating) => set({ isInitialAnimating }),
|
setIsInitialAnimating: (isInitialAnimating) => set((state) => ({ isInitialAnimating: resolveValue(isInitialAnimating, state.isInitialAnimating) })),
|
||||||
|
|
||||||
// Last update timestamp
|
// Last update timestamp
|
||||||
lastUpdate: new Date(),
|
lastUpdate: new Date(),
|
||||||
setLastUpdate: (lastUpdate) => set({ lastUpdate }),
|
setLastUpdate: (lastUpdate) => set((state) => ({ lastUpdate: resolveValue(lastUpdate, state.lastUpdate) })),
|
||||||
|
|
||||||
// Is updating
|
// Is updating
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
setIsUpdating: (isUpdating) => set({ isUpdating }),
|
setIsUpdating: (isUpdating) => set((state) => ({ isUpdating: resolveValue(isUpdating, state.isUpdating) })),
|
||||||
|
|
||||||
// Room bubbles
|
// Room bubbles
|
||||||
bubbles: {},
|
bubbles: {},
|
||||||
setBubbles: (bubbles) => set({ bubbles }),
|
setBubbles: (bubbles) => set((state) => ({ bubbles: resolveValue(bubbles, state.bubbles) })),
|
||||||
|
|
||||||
// Resizable panels
|
// Resizable panels
|
||||||
leftWidth: 70,
|
leftWidth: 70,
|
||||||
setLeftWidth: (leftWidth) => set({ leftWidth }),
|
setLeftWidth: (leftWidth) => set((state) => ({ leftWidth: resolveValue(leftWidth, state.leftWidth) })),
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
setIsResizing: (isResizing) => set({ isResizing }),
|
setIsResizing: (isResizing) => set((state) => ({ isResizing: resolveValue(isResizing, state.isResizing) })),
|
||||||
|
|
||||||
// Now timestamp (for current time display)
|
// Now timestamp (for current time display)
|
||||||
now: new Date(),
|
now: new Date(),
|
||||||
setNow: (now) => set({ now }),
|
setNow: (now) => set((state) => ({ now: resolveValue(now, state.now) })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ export default function GlobalStyles() {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1000;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-indicator {
|
.agent-indicator {
|
||||||
@@ -578,11 +578,12 @@ export default function GlobalStyles() {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 999;
|
z-index: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-scene-wrapper {
|
.room-scene-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
@@ -646,7 +647,7 @@ export default function GlobalStyles() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -656,6 +657,7 @@ 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 {
|
||||||
@@ -666,7 +668,8 @@ export default function GlobalStyles() {
|
|||||||
|
|
||||||
.room-bubble {
|
.room-bubble {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
max-width: 300px;
|
max-width: 320px;
|
||||||
|
max-height: 260px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
@@ -676,6 +679,8 @@ 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 {
|
||||||
@@ -708,7 +713,7 @@ export default function GlobalStyles() {
|
|||||||
right: 8px;
|
right: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
z-index: 10;
|
z-index: 1510;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-jump-btn,
|
.bubble-jump-btn,
|
||||||
@@ -786,6 +791,9 @@ 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 {
|
||||||
|
|||||||
4
frontend/test-results/.last-run.json
Normal file
4
frontend/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
1
reference/CoPaw
Submodule
1
reference/CoPaw
Submodule
Submodule reference/CoPaw added at 934cfce0a7
1
reference/Hyper-Alpha-Arena
Submodule
1
reference/Hyper-Alpha-Arena
Submodule
Submodule reference/Hyper-Alpha-Arena added at f137cff476
1
reference/openclaw
Submodule
1
reference/openclaw
Submodule
Submodule reference/openclaw added at 7b151afeeb
10
start-dev.sh
10
start-dev.sh
@@ -29,13 +29,6 @@ else
|
|||||||
echo -e "${YELLOW}Warning: .env file not found${NC}"
|
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=()
|
||||||
|
|
||||||
@@ -50,7 +43,8 @@ start_service() {
|
|||||||
--port "${port}" \
|
--port "${port}" \
|
||||||
--reload \
|
--reload \
|
||||||
--reload-dir backend \
|
--reload-dir backend \
|
||||||
--log-level info &
|
--log-level warning \
|
||||||
|
--no-access-log &
|
||||||
PIDS+=($!)
|
PIDS+=($!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user