Fix runtime logging and frontend app regressions

This commit is contained in:
2026-03-24 10:58:41 +08:00
parent 032c37538f
commit c5eaf2b5ad
33 changed files with 4763 additions and 3131 deletions

41
.env.example Normal file
View 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

View File

@@ -38,12 +38,13 @@ class RuntimeState:
""" """
_instance: Optional["RuntimeState"] = None _instance: Optional["RuntimeState"] = None
_lock: asyncio.Lock = asyncio.Lock() _lock: "threading.Lock" = __import__("threading").Lock()
def __new__(cls) -> "RuntimeState": def __new__(cls) -> "RuntimeState":
if cls._instance is None: with cls._lock:
cls._instance = super().__new__(cls) if cls._instance is None:
cls._instance._initialized = False cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance return cls._instance
def __init__(self) -> None: def __init__(self) -> None:
@@ -207,6 +208,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)
@@ -288,14 +296,20 @@ def _start_gateway_process(
"--bootstrap", json.dumps(bootstrap) "--bootstrap", json.dumps(bootstrap)
] ]
# Start process log_path = run_dir / "logs" / "gateway.log"
process = subprocess.Popen( log_path.parent.mkdir(parents=True, exist_ok=True)
cmd,
env=env, log_file = log_path.open("ab")
stdout=subprocess.PIPE, try:
stderr=subprocess.PIPE, process = subprocess.Popen(
cwd=PROJECT_ROOT cmd,
) env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
cwd=PROJECT_ROOT
)
finally:
log_file.close()
return process return process
@@ -390,6 +404,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_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 +450,8 @@ 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_runtime_context_from_latest_snapshot() -> Dict[str, Any]:
"""Return the active runtime context from the latest snapshot.""" """Return the latest persisted runtime context regardless of active process state."""
if not _is_gateway_running():
raise HTTPException(status_code=404, detail="No runtime is currently running")
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 +459,26 @@ 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")
return _get_runtime_context_from_latest_snapshot()
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()
@@ -567,11 +619,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:

30
backend/apps/cors.py Normal file
View 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=["*"],
)

View File

@@ -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:

View File

@@ -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]

View File

@@ -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,
@@ -222,11 +287,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)

View File

@@ -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):

View File

@@ -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__)
@@ -142,9 +142,7 @@ class MarketService:
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 +155,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,

View File

@@ -2,11 +2,13 @@
# 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.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: class TestMockPriceManager:
@@ -231,6 +233,59 @@ 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): def test_init_mock_mode(self):
@@ -255,9 +310,23 @@ class TestMarketService:
assert service.mock_mode is False assert service.mock_mode is False
assert service.api_key == "test_key" assert service.api_key == "test_key"
@patch("backend.services.market.get_data_source", return_value="yfinance") @patch("backend.services.market.get_data_sources", return_value=["yfinance", "local_csv"])
@patch.object(PollingPriceManager, "start") @patch.object(PollingPriceManager, "start")
def test_start_real_mode_with_yfinance(self, _mock_start, _mock_source): def test_start_real_mode_with_yfinance(self, _mock_start, _mock_sources):
service = MarketService(
tickers=["AAPL"],
poll_interval=10,
mock_mode=False,
)
service._start_real_mode()
assert isinstance(service._price_manager, PollingPriceManager)
assert service._price_manager.provider == "yfinance"
@patch("backend.services.market.get_data_sources", return_value=["financial_datasets", "yfinance", "local_csv"])
@patch.object(PollingPriceManager, "start")
def test_start_real_mode_uses_first_supported_live_provider(self, _mock_start, _mock_sources):
service = MarketService( service = MarketService(
tickers=["AAPL"], tickers=["AAPL"],
poll_interval=10, poll_interval=10,
@@ -287,9 +356,9 @@ class TestMarketService:
service.stop() service.stop()
@patch("backend.services.market.get_data_source", return_value="finnhub") @patch("backend.services.market.get_data_sources", return_value=["finnhub", "yfinance"])
@pytest.mark.asyncio @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, mock_mode=False,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,532 @@
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,
scheduleModeDraft,
intervalMinutesDraft,
triggerTimeDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
enableMockDraft,
watchlistDraftSymbols,
watchlistInputValue,
watchlistSuggestions,
onScheduleModeChange,
onIntervalMinutesChange,
onTriggerTimeChange,
onMaxCommCyclesChange,
onInitialCashChange,
onMarginRequirementChange,
onEnableMemoryChange,
onModeChange,
onPollIntervalChange,
onStartDateChange,
onEndDateChange,
onEnableMockChange,
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,
// 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 }}>
{/* Mock Mode Indicator */}
{virtualTime && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 4, background: '#FF9800', border: '1px solid #FFB74D' }}>
<span style={{ fontSize: '14px' }}></span>
<span style={{ fontSize: '11px', fontWeight: 600, color: '#FFFFFF', fontFamily: '"Courier New", monospace', letterSpacing: '0.5px' }}>
模拟模式
</span>
</div>
)}
{/* Clock Display (only in Mock mode) */}
{virtualTime && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, padding: '4px 12px', borderRadius: 4, background: '#1A237E', border: '1px solid #3F51B5' }}>
<span style={{ fontSize: '11px', color: '#999', fontFamily: '"Courier New", monospace', textTransform: 'uppercase', letterSpacing: '0.5px' }}>虚拟时间</span>
<span style={{ fontSize: '14px', fontWeight: 700, color: '#FFFFFF', fontFamily: '"Courier New", monospace', letterSpacing: '1px' }}>
{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}
</span>
<span style={{ fontSize: '10px', color: '#999', fontFamily: '"Courier New", monospace' }}>
{now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
{/* Fast Forward Button (only in Mock mode) */}
<button
onClick={() => {
if (agentRequests?.clientRef?.current) {
agentRequests.clientRef.current.send({ type: 'fast_forward_time', minutes: 30 });
}
}}
style={{ padding: '6px 12px', borderRadius: 4, background: '#3F51B5', border: '1px solid #5C6BC0', color: '#FFFFFF', fontSize: '12px', fontFamily: '"Courier New", monospace', fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}
title="快进30分钟 (Mock模式)"
>
+30min
</button>
</div>
)}
{/* 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}
scheduleMode={scheduleModeDraft}
intervalMinutes={intervalMinutesDraft}
triggerTime={triggerTimeDraft}
maxCommCycles={maxCommCyclesDraft}
initialCash={initialCashDraft}
marginRequirement={marginRequirementDraft}
enableMemory={enableMemoryDraft}
mode={modeDraft}
pollInterval={pollIntervalDraft}
startDate={startDateDraft}
endDate={endDateDraft}
enableMock={enableMockDraft}
watchlistSymbols={watchlistDraftSymbols}
watchlistInputValue={watchlistInputValue}
watchlistSuggestions={watchlistSuggestions}
onToggle={onRuntimeSettingsToggle}
onClose={() => setIsRuntimeSettingsOpen(false)}
onScheduleModeChange={onScheduleModeChange}
onIntervalMinutesChange={onIntervalMinutesChange}
onTriggerTimeChange={onTriggerTimeChange}
onMaxCommCyclesChange={onMaxCommCyclesChange}
onInitialCashChange={onInitialCashChange}
onMarginRequirementChange={onMarginRequirementChange}
onEnableMemoryChange={onEnableMemoryChange}
onModeChange={onModeChange}
onPollIntervalChange={onPollIntervalChange}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
onEnableMockChange={onEnableMockChange}
onWatchlistInputChange={onWatchlistInputChange}
onWatchlistInputKeyDown={onWatchlistInputKeyDown}
onWatchlistAdd={onWatchlistAdd}
onWatchlistRemove={onWatchlistRemove}
onWatchlistRestoreCurrent={onWatchlistRestoreCurrent}
onWatchlistRestoreDefault={onWatchlistRestoreDefault}
onWatchlistSuggestionClick={onWatchlistSuggestionClick}
onSave={onLaunchConfigSave}
onRestoreDefaults={onRestoreDefaults}
/>
</div>
</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}
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}
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}
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} />
</Suspense>
</div>
</div>
</>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { createPortal } from 'react-dom';
export default function RuntimeLogsModal({
isOpen,
isLoading,
logPayload,
error,
onClose,
onRefresh
}) {
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 20px', minHeight: 0 }}>
<pre style={{
margin: 0,
height: '100%',
minHeight: 320,
maxHeight: 'calc(82vh - 140px)',
overflow: 'auto',
borderRadius: 12,
border: '1px solid #D9E0E7',
background: '#0F172A',
color: '#E2E8F0',
padding: 16,
fontSize: 11,
lineHeight: 1.6,
fontFamily: '"SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{logPayload?.content || '暂无日志输出'}
</pre>
</div>
</div>
</div>,
document.body
);
}

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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}

View File

@@ -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>
); );
})} })}

View File

@@ -0,0 +1,211 @@
import { useCallback } from 'react';
import { 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,
setIsAgentSkillsLoading,
setAgentSkillsFeedback,
setAgentSkillsSavingKey,
setSkillDetailLoadingKey,
localSkillDraftsByKey,
selectedWorkspaceFile,
setWorkspaceDraftContent,
workspaceDraftContent,
setWorkspaceFileFeedback,
setWorkspaceFileSavingKey,
setIsWorkspaceFileLoading
} = useAgentStore();
const requestAgentSkills = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) return false;
setIsAgentSkillsLoading(true);
setAgentSkillsFeedback(null);
return clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized });
}, [clientRef, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
const requestAgentProfile = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) return false;
return clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized });
}, [clientRef]);
const requestSkillDetail = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized || !clientRef.current) return false;
const detailKey = `${selectedSkillAgentId}:${normalized}`;
setSkillDetailLoadingKey(detailKey);
return clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized });
}, [clientRef, selectedSkillAgentId, setSkillDetailLoadingKey]);
const handleCreateLocalSkill = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized) {
setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
return;
}
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized });
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
const detailKey = `${selectedSkillAgentId}:${skillName}`;
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({ ...prev, [detailKey]: content }));
}, [selectedSkillAgentId]);
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 = 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, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleLocalSkillDelete = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleRemoveSharedSkill = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const agentId = selectedSkillAgentId;
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
setAgentSkillsFeedback(null);
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, 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 || !clientRef.current) return false;
setIsWorkspaceFileLoading(true);
setWorkspaceFileFeedback(null);
return clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename });
}, [clientRef, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
const handleWorkspaceFileChange = useCallback((filename) => {
useAgentStore.getState().setSelectedWorkspaceFile(filename);
requestWorkspaceFile(selectedSkillAgentId, filename);
}, [requestWorkspaceFile, selectedSkillAgentId]);
const handleWorkspaceFileSave = useCallback(() => {
if (!clientRef.current) {
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
setWorkspaceFileSavingKey(key);
setWorkspaceFileFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_workspace_file',
agent_id: selectedSkillAgentId,
filename: selectedWorkspaceFile,
content: workspaceDraftContent
});
if (!success) {
setWorkspaceFileSavingKey(null);
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, selectedWorkspaceFile, 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]);
return {
requestAgentSkills,
requestAgentProfile,
requestSkillDetail,
handleCreateLocalSkill,
handleLocalSkillDraftChange,
handleLocalSkillSave,
handleLocalSkillDelete,
handleRemoveSharedSkill,
handleAgentSkillToggle,
handleSkillAgentChange,
requestWorkspaceFile,
handleWorkspaceFileChange,
handleWorkspaceFileSave,
handleUploadExternalSkill
};
}

View 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
};
}

View File

@@ -0,0 +1,538 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { INITIAL_TICKERS } from "../config/constants";
import { 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 }) {
const {
runtimeConfig,
setRuntimeConfig,
isWatchlistPanelOpen,
setIsWatchlistPanelOpen,
isRuntimeSettingsOpen,
setIsRuntimeSettingsOpen,
watchlistDraftSymbols,
setWatchlistDraftSymbols,
watchlistInputValue,
setWatchlistInputValue,
watchlistFeedback,
setWatchlistFeedback,
isWatchlistSaving,
setIsWatchlistSaving,
scheduleModeDraft,
setScheduleModeDraft,
intervalMinutesDraft,
setIntervalMinutesDraft,
triggerTimeDraft,
setTriggerTimeDraft,
maxCommCyclesDraft,
setMaxCommCyclesDraft,
initialCashDraft,
setInitialCashDraft,
marginRequirementDraft,
setMarginRequirementDraft,
enableMemoryDraft,
setEnableMemoryDraft,
modeDraft,
setModeDraft,
pollIntervalDraft,
setPollIntervalDraft,
startDateDraft,
setStartDateDraft,
endDateDraft,
setEndDateDraft,
enableMockDraft,
setEnableMockDraft,
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
]);
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;
}
setIsRuntimeConfigSaving(true);
setIsWatchlistSaving(true);
setRuntimeConfigFeedback(null);
setWatchlistFeedback(null);
setWatchlistDraftSymbols(nextTickers);
setWatchlistInputValue("");
try {
const result = await startRuntime({
tickers: nextTickers,
schedule_mode: scheduleModeDraft,
interval_minutes: interval,
trigger_time: triggerTimeDraft,
max_comm_cycles: maxCommCycles,
initial_cash: initialCash,
margin_requirement: marginRequirement,
enable_memory: Boolean(enableMemoryDraft),
mode: modeDraft || DEFAULT_MODE,
poll_interval: Number(pollIntervalDraft) || Number(DEFAULT_POLL_INTERVAL),
start_date: startDateDraft || null,
end_date: endDateDraft || null,
enable_mock: Boolean(enableMockDraft)
});
setIsRuntimeConfigSaving(false);
setIsWatchlistSaving(false);
setIsRuntimeSettingsOpen(false);
setRuntimeConfigFeedback({
type: "success",
text: `任务已启动: ${result.run_id}`
});
addSystemMessage(`新任务已启动: ${result.run_id}`);
} catch (error) {
setIsRuntimeConfigSaving(false);
setIsWatchlistSaving(false);
setRuntimeConfigFeedback({
type: "error",
text: `启动失败: ${error.message}`
});
}
}, [
addSystemMessage,
clientRef,
enableMemoryDraft,
enableMockDraft,
endDateDraft,
initialCashDraft,
intervalMinutesDraft,
marginRequirementDraft,
maxCommCyclesDraft,
modeDraft,
pollIntervalDraft,
scheduleModeDraft,
setIsRuntimeConfigSaving,
setIsRuntimeSettingsOpen,
setIsWatchlistSaving,
setRuntimeConfigFeedback,
setWatchlistDraftSymbols,
setWatchlistFeedback,
setWatchlistInputValue,
startDateDraft,
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);
setModeDraft(DEFAULT_MODE);
setPollIntervalDraft(DEFAULT_POLL_INTERVAL);
setStartDateDraft("");
setEndDateDraft("");
setEnableMockDraft(false);
setRuntimeConfigFeedback(null);
}, [
setEnableMemoryDraft,
setEnableMockDraft,
setEndDateDraft,
setInitialCashDraft,
setIntervalMinutesDraft,
setMarginRequirementDraft,
setMaxCommCyclesDraft,
setModeDraft,
setPollIntervalDraft,
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,
scheduleModeDraft,
intervalMinutesDraft,
triggerTimeDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
enableMockDraft,
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,
setModeDraft,
setPollIntervalDraft,
setStartDateDraft,
setEndDateDraft,
setEnableMockDraft,
setIsWatchlistSaving,
setIsRuntimeConfigSaving
};
}

View 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
};
}

View 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
};
}

View File

@@ -0,0 +1,875 @@
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));
}
const isMockMode = state.is_mock_mode === true;
if (state.market_status) {
setMarketStatus(state.market_status);
if (isMockMode && state.market_status.current_time) {
try {
setVirtualTime(new Date(state.market_status.current_time));
} catch (error) {
console.error('Error parsing virtual time from market_status:', error);
}
} else {
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] || '⏰';
const isMockMode = e.is_mock_mode === true;
let logMessage = `${emoji} ${isMockMode ? '虚拟时间' : '时间'}: ${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);
if (isMockMode && e.beijing_time) {
try { setVirtualTime(new Date(e.beijing_time)); } catch (error) { console.error('Error parsing virtual time:', error); }
} else {
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 };
}

View 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;

View File

@@ -121,6 +121,10 @@ 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 async function uploadAgentSkillZip({ export async function uploadAgentSkillZip({
agentId, agentId,
file, file,

View 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}`;
};

View 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 轮");
});
});

View File

@@ -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) })),
})); }));

View File

@@ -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) })),
})); }));

View File

@@ -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) })),
})); }));

View File

@@ -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,59 @@ export const useRuntimeStore = create((set) => ({
// Connection state // Connection state
isConnected: false, isConnected: false,
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected' connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
setIsConnected: (isConnected) => set({ 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
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',
@@ -69,26 +73,26 @@ export const useRuntimeStore = create((set) => ({
startDateDraft: '', startDateDraft: '',
endDateDraft: '', endDateDraft: '',
enableMockDraft: false, enableMockDraft: false,
setScheduleModeDraft: (scheduleModeDraft) => set({ scheduleModeDraft }), setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })),
setIntervalMinutesDraft: (intervalMinutesDraft) => set({ intervalMinutesDraft }), setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })),
setTriggerTimeDraft: (triggerTimeDraft) => set({ triggerTimeDraft }), setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })),
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set({ maxCommCyclesDraft }), setMaxCommCyclesDraft: (maxCommCyclesDraft) => set((state) => ({ maxCommCyclesDraft: resolveValue(maxCommCyclesDraft, state.maxCommCyclesDraft) })),
setInitialCashDraft: (initialCashDraft) => set({ initialCashDraft }), setInitialCashDraft: (initialCashDraft) => set((state) => ({ initialCashDraft: resolveValue(initialCashDraft, state.initialCashDraft) })),
setMarginRequirementDraft: (marginRequirementDraft) => set({ marginRequirementDraft }), setMarginRequirementDraft: (marginRequirementDraft) => set((state) => ({ marginRequirementDraft: resolveValue(marginRequirementDraft, state.marginRequirementDraft) })),
setEnableMemoryDraft: (enableMemoryDraft) => set({ enableMemoryDraft }), setEnableMemoryDraft: (enableMemoryDraft) => set((state) => ({ enableMemoryDraft: resolveValue(enableMemoryDraft, state.enableMemoryDraft) })),
setModeDraft: (modeDraft) => set({ modeDraft }), setModeDraft: (modeDraft) => set((state) => ({ modeDraft: resolveValue(modeDraft, state.modeDraft) })),
setPollIntervalDraft: (pollIntervalDraft) => set({ pollIntervalDraft }), setPollIntervalDraft: (pollIntervalDraft) => set((state) => ({ pollIntervalDraft: resolveValue(pollIntervalDraft, state.pollIntervalDraft) })),
setStartDateDraft: (startDateDraft) => set({ startDateDraft }), setStartDateDraft: (startDateDraft) => set((state) => ({ startDateDraft: resolveValue(startDateDraft, state.startDateDraft) })),
setEndDateDraft: (endDateDraft) => set({ endDateDraft }), setEndDateDraft: (endDateDraft) => set((state) => ({ endDateDraft: resolveValue(endDateDraft, state.endDateDraft) })),
setEnableMockDraft: (enableMockDraft) => set({ enableMockDraft }), setEnableMockDraft: (enableMockDraft) => set((state) => ({ enableMockDraft: resolveValue(enableMockDraft, state.enableMockDraft) })),
// 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) // Last day history (for replay)
lastDayHistory: [], lastDayHistory: [],
setLastDayHistory: (lastDayHistory) => set({ lastDayHistory }), setLastDayHistory: (lastDayHistory) => set((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })),
})); }));

View File

@@ -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) })),
})); }));

View File

@@ -478,7 +478,7 @@ export default function GlobalStyles() {
background: #ffffff; background: #ffffff;
flex-wrap: wrap; flex-wrap: wrap;
position: relative; position: relative;
z-index: 1000; z-index: 10;
} }
.agent-indicator { .agent-indicator {
@@ -583,6 +583,7 @@ export default function GlobalStyles() {
.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: 30;
} }
@keyframes bubbleAppear { @keyframes bubbleAppear {
@@ -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 {

View File

@@ -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=()