Refine runtime data flow and UI layering

This commit is contained in:
2026-03-24 15:00:35 +08:00
parent c5eaf2b5ad
commit 6413edf8c9
17 changed files with 373 additions and 114 deletions

View File

@@ -110,6 +110,21 @@ evotraders frontend # Default connects to port 8765, you can modi
Visit `http://localhost:5173/` to view the trading room, select a date and click Run/Replay to observe the decision-making process. Visit `http://localhost:5173/` to view the trading room, select a date and click Run/Replay to observe the decision-making process.
### Runtime Data Layout
- Long-lived research data is stored in `data/market_research.db`
- Each task run writes run-scoped state under `runs/<run_id>/`
- `runs/<run_id>/team_dashboard/*.json` is an export/compatibility layer for dashboard views, not the authoritative runtime source of truth
- Runtime APIs prefer active runtime state, `server_state.json`, and `runtime.db`
Optional retention control:
```bash
RUNS_RETENTION_COUNT=20
```
Only timestamped run folders like `YYYYMMDD_HHMMSS` are pruned automatically when starting a new runtime. Named runs such as `smoke_fullstack` or `test_*` are preserved.
--- ---
## System Architecture ## System Architecture

View File

@@ -8,6 +8,7 @@ import json
import logging import logging
import os import os
import signal import signal
import shutil
import subprocess import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
@@ -194,6 +195,12 @@ class StopResponse(BaseModel):
message: str message: str
class CleanupResponse(BaseModel):
status: str
kept: int
pruned_run_ids: List[str]
class GatewayStatusResponse(BaseModel): class GatewayStatusResponse(BaseModel):
is_running: bool is_running: bool
port: int port: int
@@ -235,6 +242,38 @@ def _get_run_dir(run_id: str) -> Path:
return PROJECT_ROOT / "runs" / run_id return PROJECT_ROOT / "runs" / run_id
def _is_timestamped_run_dir(path: Path) -> bool:
try:
datetime.strptime(path.name, "%Y%m%d_%H%M%S")
return True
except ValueError:
return False
def _prune_old_timestamped_runs(*, keep: int = 20, exclude_run_ids: Optional[set[str]] = None) -> list[str]:
"""Prune old timestamped run directories, preserving the newest N and excluded ids."""
exclude = exclude_run_ids or set()
runs_root = PROJECT_ROOT / "runs"
if not runs_root.exists():
return []
candidates = sorted(
[
path
for path in runs_root.iterdir()
if path.is_dir() and _is_timestamped_run_dir(path) and path.name not in exclude
],
key=lambda path: path.name,
reverse=True,
)
pruned: list[str] = []
for path in candidates[max(0, keep):]:
shutil.rmtree(path, ignore_errors=True)
pruned.append(path.name)
return pruned
def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int: def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int:
"""Find an available port for Gateway.""" """Find an available port for Gateway."""
import socket import socket
@@ -316,15 +355,9 @@ def _start_gateway_process(
@router.get("/context", response_model=RunContextResponse) @router.get("/context", response_model=RunContextResponse)
async def get_run_context() -> RunContextResponse: async def get_run_context() -> RunContextResponse:
"""Return the most recent run context.""" """Return active runtime context, or latest persisted context when stopped."""
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json") snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True) context = snapshot.get("context")
if not snapshots:
raise HTTPException(status_code=404, detail="No run context available")
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
context = latest.get("context")
if context is None: if context is None:
raise HTTPException(status_code=404, detail="Run context is not ready") raise HTTPException(status_code=404, detail="Run context is not ready")
@@ -337,15 +370,9 @@ async def get_run_context() -> RunContextResponse:
@router.get("/agents", response_model=RuntimeAgentsResponse) @router.get("/agents", response_model=RuntimeAgentsResponse)
async def get_runtime_agents() -> RuntimeAgentsResponse: async def get_runtime_agents() -> RuntimeAgentsResponse:
"""Return agent states from the most recent run.""" """Return agent states from the active runtime, or latest persisted run."""
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json") snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True) agents = snapshot.get("agents", [])
if not snapshots:
raise HTTPException(status_code=404, detail="No runtime state available")
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
agents = latest.get("agents", [])
return RuntimeAgentsResponse( return RuntimeAgentsResponse(
agents=[RuntimeAgentState(**a) for a in agents] agents=[RuntimeAgentState(**a) for a in agents]
@@ -354,15 +381,9 @@ async def get_runtime_agents() -> RuntimeAgentsResponse:
@router.get("/events", response_model=RuntimeEventsResponse) @router.get("/events", response_model=RuntimeEventsResponse)
async def get_runtime_events() -> RuntimeEventsResponse: async def get_runtime_events() -> RuntimeEventsResponse:
"""Return events from the most recent run.""" """Return events from the active runtime, or latest persisted run."""
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json") snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True) events = snapshot.get("events", [])
if not snapshots:
raise HTTPException(status_code=404, detail="No runtime state available")
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
events = latest.get("events", [])
return RuntimeEventsResponse( return RuntimeEventsResponse(
events=[RuntimeEvent(**e) for e in events] events=[RuntimeEvent(**e) for e in events]
@@ -376,15 +397,10 @@ async def get_gateway_status() -> GatewayStatusResponse:
run_id = None run_id = None
if is_running: if is_running:
# Try to find run_id from runtime state
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
if snapshots:
try: try:
latest = json.loads(snapshots[0].read_text(encoding="utf-8")) run_id = _get_active_runtime_context().get("config_name")
run_id = latest.get("context", {}).get("config_name")
except Exception as e: except Exception as e:
logger.warning(f"Failed to parse latest snapshot: {e}") logger.warning(f"Failed to resolve active runtime context: {e}")
return GatewayStatusResponse( return GatewayStatusResponse(
is_running=is_running, is_running=is_running,
@@ -408,7 +424,7 @@ async def get_gateway_port(request: Request) -> Dict[str, Any]:
async def get_runtime_logs() -> RuntimeLogResponse: async def get_runtime_logs() -> RuntimeLogResponse:
"""Return current runtime log tail, or the latest run log if runtime is stopped.""" """Return current runtime log tail, or the latest run log if runtime is stopped."""
try: try:
context = _get_runtime_context_from_latest_snapshot() context = _get_active_runtime_context() if _is_gateway_running() else _get_runtime_context_from_latest_snapshot()
except HTTPException: except HTTPException:
return RuntimeLogResponse(is_running=False, content="") return RuntimeLogResponse(is_running=False, content="")
@@ -450,6 +466,21 @@ 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_active_runtime_snapshot() -> Dict[str, Any]:
"""Return the active runtime snapshot, preferring in-memory manager state."""
if not _is_gateway_running():
raise HTTPException(status_code=404, detail="No runtime is currently running")
manager = _runtime_state.runtime_manager
if manager is not None and hasattr(manager, "build_snapshot"):
snapshot = manager.build_snapshot()
context = snapshot.get("context") or {}
if context.get("config_name"):
return snapshot
return _load_latest_runtime_snapshot()
def _get_runtime_context_from_latest_snapshot() -> Dict[str, Any]: def _get_runtime_context_from_latest_snapshot() -> Dict[str, Any]:
"""Return the latest persisted runtime context regardless of active process state.""" """Return the latest persisted runtime context regardless of active process state."""
latest = _load_latest_runtime_snapshot() latest = _load_latest_runtime_snapshot()
@@ -476,7 +507,16 @@ def _get_current_runtime_context() -> Dict[str, Any]:
"""Return the active runtime context from the latest snapshot.""" """Return the active runtime context from the latest snapshot."""
if not _is_gateway_running(): if not _is_gateway_running():
raise HTTPException(status_code=404, detail="No runtime is currently running") raise HTTPException(status_code=404, detail="No runtime is currently running")
return _get_runtime_context_from_latest_snapshot() snapshot = _get_active_runtime_snapshot()
context = snapshot.get("context") or {}
if not context.get("config_name"):
raise HTTPException(status_code=404, detail="No runtime context available")
return context
def _get_active_runtime_context() -> Dict[str, Any]:
"""Return the active runtime context, preferring in-memory runtime manager state."""
return _get_current_runtime_context()
def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse: def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse:
@@ -573,6 +613,14 @@ async def start_runtime(
run_id = _generate_run_id() run_id = _generate_run_id()
run_dir = _get_run_dir(run_id) run_dir = _get_run_dir(run_id)
retention_keep = max(1, int(os.getenv("RUNS_RETENTION_COUNT", "20") or "20"))
pruned_run_ids = _prune_old_timestamped_runs(
keep=retention_keep,
exclude_run_ids={run_id},
)
if pruned_run_ids:
logger.info("Pruned old run directories: %s", ", ".join(pruned_run_ids))
# 3. Prepare bootstrap config # 3. Prepare bootstrap config
bootstrap = { bootstrap = {
"tickers": config.tickers, "tickers": config.tickers,
@@ -690,6 +738,25 @@ async def stop_runtime(force: bool = True) -> StopResponse:
) )
@router.post("/cleanup", response_model=CleanupResponse)
async def cleanup_old_runs(keep: int = 20) -> CleanupResponse:
"""Prune old timestamped run directories while preserving named runs."""
keep_count = max(1, int(keep))
exclude: set[str] = set()
if _is_gateway_running():
try:
active_context = _get_active_runtime_context()
active_run_id = str(active_context.get("config_name") or "").strip()
if active_run_id:
exclude.add(active_run_id)
except HTTPException:
pass
pruned = _prune_old_timestamped_runs(keep=keep_count, exclude_run_ids=exclude)
return CleanupResponse(status="ok", kept=keep_count, pruned_run_ids=pruned)
@router.post("/restart") @router.post("/restart")
async def restart_runtime( async def restart_runtime(
config: LaunchConfig, config: LaunchConfig,
@@ -716,15 +783,7 @@ async def get_current_runtime():
if not _is_gateway_running(): if not _is_gateway_running():
raise HTTPException(status_code=404, detail="No runtime is currently running") raise HTTPException(status_code=404, detail="No runtime is currently running")
# Find latest runtime state context = _get_active_runtime_context()
snapshot_path = PROJECT_ROOT.glob("runs/*/state/runtime_state.json")
snapshots = sorted(snapshot_path, key=lambda p: p.stat().st_mtime, reverse=True)
if not snapshots:
raise HTTPException(status_code=404, detail="No runtime information available")
latest = json.loads(snapshots[0].read_text(encoding="utf-8"))
context = latest.get("context", {})
return { return {
"run_id": context.get("config_name"), "run_id": context.get("config_name"),

View File

@@ -488,12 +488,13 @@ class StateSync:
} }
if include_dashboard: if include_dashboard:
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self._state)
payload["dashboard"] = { payload["dashboard"] = {
"summary": self.storage.load_file("summary"), "summary": dashboard_snapshot.get("summary"),
"holdings": self.storage.load_file("holdings"), "holdings": dashboard_snapshot.get("holdings"),
"stats": self.storage.load_file("stats"), "stats": dashboard_snapshot.get("stats"),
"trades": self.storage.load_file("trades"), "trades": dashboard_snapshot.get("trades"),
"leaderboard": self.storage.load_file("leaderboard"), "leaderboard": dashboard_snapshot.get("leaderboard"),
} }
return payload return payload

View File

@@ -30,6 +30,7 @@ def ensure_news_fresh(
*, *,
ticker: str, ticker: str,
target_date: str | None = None, target_date: str | None = None,
refresh_if_stale: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Refresh raw news incrementally when stored watermarks are stale.""" """Refresh raw news incrementally when stored watermarks are stale."""
normalized_target = str(target_date or "").strip()[:10] normalized_target = str(target_date or "").strip()[:10]
@@ -44,7 +45,7 @@ def ensure_news_fresh(
watermarks = store.get_ticker_watermarks(ticker) watermarks = store.get_ticker_watermarks(ticker)
last_news_fetch = str(watermarks.get("last_news_fetch") or "").strip()[:10] last_news_fetch = str(watermarks.get("last_news_fetch") or "").strip()[:10]
refreshed = False refreshed = False
if not last_news_fetch or last_news_fetch < normalized_target: if refresh_if_stale and (not last_news_fetch or last_news_fetch < normalized_target):
update_ticker_incremental( update_ticker_incremental(
ticker, ticker,
end_date=normalized_target, end_date=normalized_target,
@@ -238,8 +239,14 @@ def get_range_explain_payload(
end_date: str, end_date: str,
article_ids: list[str] | None = None, article_ids: list[str] | None = None,
limit: int = 100, limit: int = 100,
refresh_if_stale: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date) freshness = ensure_news_fresh(
store,
ticker=ticker,
target_date=end_date,
refresh_if_stale=refresh_if_stale,
)
news_rows = [] news_rows = []
if article_ids: if article_ids:
news_rows = store.get_news_by_ids_enriched(ticker, article_ids) news_rows = store.get_news_by_ids_enriched(ticker, article_ids)

View File

@@ -152,10 +152,11 @@ class Gateway:
) )
# Load and display existing portfolio state if available # Load and display existing portfolio state if available
summary = self.storage.load_file("summary") dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self.state_sync.state)
summary = dashboard_snapshot.get("summary")
if summary: if summary:
holdings = self.storage.load_file("holdings") or [] holdings = dashboard_snapshot.get("holdings") or []
trades = self.storage.load_file("trades") or [] trades = dashboard_snapshot.get("trades") or []
current_date = self.state_sync.state.get("current_date") current_date = self.state_sync.state.get("current_date")
self._dashboard.update( self._dashboard.update(
date=current_date or "-", date=current_date or "-",

View File

@@ -61,7 +61,7 @@ async def market_status_monitor(gateway: Any) -> None:
status = gateway.market_service.get_market_status() status = gateway.market_service.get_market_status()
if status["status"] == "open" and not gateway.storage.is_live_session_active: if status["status"] == "open" and not gateway.storage.is_live_session_active:
gateway.storage.start_live_session() gateway.storage.start_live_session()
summary = gateway.storage.load_file("summary") or {} summary = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state).get("summary") or {}
gateway._session_start_portfolio_value = summary.get( gateway._session_start_portfolio_value = summary.get(
"totalAssetValue", "totalAssetValue",
gateway.storage.initial_cash, gateway.storage.initial_cash,
@@ -240,14 +240,15 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
async def finalize_cycle(gateway: Any, date: str) -> None: async def finalize_cycle(gateway: Any, date: str) -> None:
summary = gateway.storage.load_file("summary") or {} dashboard_snapshot = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state)
summary = dashboard_snapshot.get("summary") or {}
if gateway.storage.is_live_session_active: if gateway.storage.is_live_session_active:
summary.update(gateway.storage.get_live_returns()) summary.update(gateway.storage.get_live_returns())
await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary) await gateway.state_sync.on_cycle_end(date, portfolio_summary=summary)
holdings = gateway.storage.load_file("holdings") or [] holdings = dashboard_snapshot.get("holdings") or []
trades = gateway.storage.load_file("trades") or [] trades = dashboard_snapshot.get("trades") or []
leaderboard = gateway.storage.load_file("leaderboard") or [] leaderboard = dashboard_snapshot.get("leaderboard") or []
if leaderboard: if leaderboard:
await gateway.state_sync.on_leaderboard_update(leaderboard) await gateway.state_sync.on_leaderboard_update(leaderboard)
gateway._dashboard.update(date=date, status="Running", portfolio=summary, holdings=holdings, trades=trades) gateway._dashboard.update(date=date, status="Running", portfolio=summary, holdings=holdings, trades=trades)
@@ -319,7 +320,7 @@ async def run_backtest_dates(gateway: Any, dates: list[str]) -> None:
await gateway.on_strategy_trigger(date=date) await gateway.on_strategy_trigger(date=date)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
await gateway.state_sync.on_system_message(f"Backtest complete - {len(dates)} days") await gateway.state_sync.on_system_message(f"Backtest complete - {len(dates)} days")
summary = gateway.storage.load_file("summary") or {} summary = gateway.storage.build_dashboard_snapshot_from_state(gateway.state_sync.state).get("summary") or {}
gateway._dashboard.update(status="Complete", portfolio=summary, days_completed=len(dates)) gateway._dashboard.update(status="Complete", portfolio=summary, days_completed=len(dates))
gateway._dashboard.stop() gateway._dashboard.stop()
gateway._dashboard.print_final_summary() gateway._dashboard.print_final_summary()

View File

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

View File

@@ -361,6 +361,7 @@ async def handle_get_stock_range_explain(gateway: Any, websocket: Any, data: dic
end_date=end_date, end_date=end_date,
article_ids=article_ids if isinstance(article_ids, list) else None, article_ids=article_ids if isinstance(article_ids, list) else None,
limit=100, limit=100,
refresh_if_stale=False,
) )
result = payload.get("result") result = payload.get("result")

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ def test_runtime_service_routes_are_exposed():
assert "/api/status" in paths assert "/api/status" in paths
assert "/api/runtime/start" in paths assert "/api/runtime/start" in paths
assert "/api/runtime/stop" in paths assert "/api/runtime/stop" in paths
assert "/api/runtime/cleanup" in paths
assert "/api/runtime/current" in paths assert "/api/runtime/current" in paths
assert "/api/runtime/gateway/port" in paths assert "/api/runtime/gateway/port" in paths
@@ -192,3 +193,45 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
assert payload["bootstrap"]["schedule_mode"] == "intraday" assert payload["bootstrap"]["schedule_mode"] == "intraday"
assert payload["resolved"]["interval_minutes"] == 15 assert payload["resolved"]["interval_minutes"] == 15
assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8") assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8")
def test_prune_old_timestamped_runs_keeps_named_runs(monkeypatch, tmp_path):
runs_dir = tmp_path / "runs"
runs_dir.mkdir()
keep_dirs = ["20260324_110000", "20260324_120000"]
prune_dir = "20260324_100000"
named_dir = "smoke_fullstack"
for name in [*keep_dirs, prune_dir, named_dir]:
(runs_dir / name).mkdir(parents=True)
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
pruned = runtime_module._prune_old_timestamped_runs(keep=1, exclude_run_ids={"20260324_120000"})
assert prune_dir in pruned
assert (runs_dir / named_dir).exists()
assert (runs_dir / "20260324_120000").exists()
assert (runs_dir / "20260324_110000").exists()
def test_runtime_cleanup_endpoint_prunes_old_runs(monkeypatch, tmp_path):
runs_dir = tmp_path / "runs"
runs_dir.mkdir()
for name in ["20260324_090000", "20260324_100000", "20260324_110000", "smoke_fullstack"]:
(runs_dir / name).mkdir(parents=True)
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(runtime_module, "_is_gateway_running", lambda: False)
with TestClient(create_app()) as client:
response = client.post("/api/runtime/cleanup?keep=1")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert sorted(payload["pruned_run_ids"]) == ["20260324_090000", "20260324_100000"]
assert (runs_dir / "20260324_110000").exists()
assert (runs_dir / "smoke_fullstack").exists()

View File

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

View File

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

View File

@@ -455,6 +455,9 @@ export default function AppShell({
newsSnapshot={newsByTicker[selectedExplainSymbol] || null} newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null} insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null}
technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null} technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null}
onRequestHistory={stockRequests?.requestStockHistory}
onRequestExplainEvents={stockRequests?.requestStockExplainEvents}
onRequestNews={stockRequests?.requestStockNews}
onRequestRangeExplain={stockRequests?.requestStockRangeExplain} onRequestRangeExplain={stockRequests?.requestStockRangeExplain}
onRequestNewsForDate={stockRequests?.requestStockNewsForDate} onRequestNewsForDate={stockRequests?.requestStockNewsForDate}
onRequestStory={stockRequests?.requestStockStory} onRequestStory={stockRequests?.requestStockStory}

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
export default function RuntimeLogsModal({ export default function RuntimeLogsModal({
@@ -9,6 +9,32 @@ export default function RuntimeLogsModal({
onClose, onClose,
onRefresh onRefresh
}) { }) {
const logRef = useRef(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const [followTail, setFollowTail] = useState(true);
const refreshIntervalMs = useMemo(() => 2000, []);
useEffect(() => {
if (!isOpen || !autoRefresh) {
return undefined;
}
const timerId = window.setInterval(() => {
onRefresh();
}, refreshIntervalMs);
return () => window.clearInterval(timerId);
}, [autoRefresh, isOpen, onRefresh, refreshIntervalMs]);
useEffect(() => {
if (!isOpen || !followTail || !logRef.current) {
return;
}
logRef.current.scrollTop = logRef.current.scrollHeight;
}, [followTail, isOpen, logPayload?.content]);
if (!isOpen) { if (!isOpen) {
return null; return null;
} }
@@ -108,8 +134,35 @@ export default function RuntimeLogsModal({
) : null} ) : null}
</div> </div>
<div style={{
padding: '0 20px 12px',
display: 'flex',
gap: 16,
alignItems: 'center',
flexWrap: 'wrap'
}}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(event) => setAutoRefresh(event.target.checked)}
/>
实时刷新
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
<input
type="checkbox"
checked={followTail}
onChange={(event) => setFollowTail(event.target.checked)}
/>
自动滚底
</label>
</div>
<div style={{ padding: '0 20px 20px', minHeight: 0 }}> <div style={{ padding: '0 20px 20px', minHeight: 0 }}>
<pre style={{ <pre
ref={logRef}
style={{
margin: 0, margin: 0,
height: '100%', height: '100%',
minHeight: 320, minHeight: 320,
@@ -125,7 +178,8 @@ export default function RuntimeLogsModal({
fontFamily: '"SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace', fontFamily: '"SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word' wordBreak: 'break-word'
}}> }}
>
{logPayload?.content || '暂无日志输出'} {logPayload?.content || '暂无日志输出'}
</pre> </pre>
</div> </div>

View File

@@ -33,6 +33,9 @@ export default function StockExplainView({
insiderTradesSnapshot, insiderTradesSnapshot,
technicalIndicatorsSnapshot, technicalIndicatorsSnapshot,
onRequestRangeExplain, onRequestRangeExplain,
onRequestHistory,
onRequestExplainEvents,
onRequestNews,
onRequestNewsForDate, onRequestNewsForDate,
onRequestStory, onRequestStory,
onRequestInsiderTrades, onRequestInsiderTrades,
@@ -142,6 +145,32 @@ export default function StockExplainView({
setActiveNewsSentiment('all'); setActiveNewsSentiment('all');
}, [selectedSymbol, selectedEventDate]); }, [selectedSymbol, selectedEventDate]);
useEffect(() => {
if (!selectedSymbol) {
return;
}
if (onRequestHistory && (!Array.isArray(ohlcHistoryByTicker?.[selectedSymbol]) || ohlcHistoryByTicker[selectedSymbol].length === 0)) {
onRequestHistory(selectedSymbol);
}
if (onRequestExplainEvents && !explainEventsSnapshot) {
onRequestExplainEvents(selectedSymbol);
}
if (onRequestNews && (!Array.isArray(newsSnapshot?.items) || newsSnapshot.items.length === 0)) {
onRequestNews(selectedSymbol);
}
}, [
explainEventsSnapshot,
newsSnapshot,
ohlcHistoryByTicker,
onRequestExplainEvents,
onRequestHistory,
onRequestNews,
selectedSymbol,
]);
useEffect(() => { useEffect(() => {
if (!selectedSymbol || !selectedEventDate || !onRequestNewsForDate) { if (!selectedSymbol || !selectedEventDate || !onRequestNewsForDate) {
return; return;

View File

@@ -578,7 +578,7 @@ export default function GlobalStyles() {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 999; z-index: 700;
} }
.room-scene-wrapper { .room-scene-wrapper {
@@ -680,7 +680,7 @@ export default function GlobalStyles() {
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; overflow: hidden;
z-index: 30; z-index: 1500;
} }
@keyframes bubbleAppear { @keyframes bubbleAppear {
@@ -713,7 +713,7 @@ export default function GlobalStyles() {
right: 8px; right: 8px;
display: flex; display: flex;
gap: 4px; gap: 4px;
z-index: 10; z-index: 1510;
} }
.bubble-jump-btn, .bubble-jump-btn,