Add restore-mode task launch flow

This commit is contained in:
2026-03-24 15:27:35 +08:00
parent 6413edf8c9
commit 8d6c3c5647
12 changed files with 572 additions and 52 deletions

View File

@@ -167,6 +167,8 @@ class RuntimeEventsResponse(BaseModel):
class LaunchConfig(BaseModel):
"""Configuration for launching a new trading task."""
launch_mode: str = Field(default="fresh", description="启动形式: fresh, restore")
restore_run_id: Optional[str] = Field(default=None, description="历史任务 run_id用于恢复启动")
tickers: List[str] = Field(default_factory=list, description="股票池")
schedule_mode: str = Field(default="daily", description="调度模式: daily, interval")
interval_minutes: int = Field(default=60, ge=1, description="间隔分钟数")
@@ -190,6 +192,19 @@ class LaunchResponse(BaseModel):
message: str
class RuntimeHistoryItem(BaseModel):
run_id: str
run_dir: str
updated_at: Optional[str] = None
total_trades: int = 0
total_asset_value: Optional[float] = None
bootstrap: Dict[str, Any] = Field(default_factory=dict)
class RuntimeHistoryResponse(BaseModel):
runs: List[RuntimeHistoryItem]
class StopResponse(BaseModel):
status: str
message: str
@@ -242,6 +257,96 @@ def _get_run_dir(run_id: str) -> Path:
return PROJECT_ROOT / "runs" / run_id
def _load_run_snapshot(run_id: str) -> Dict[str, Any]:
"""Load a specific run snapshot by run_id."""
snapshot_path = _get_run_dir(run_id) / "state" / "runtime_state.json"
if not snapshot_path.exists():
raise HTTPException(status_code=404, detail=f"Run snapshot not found: {run_id}")
return json.loads(snapshot_path.read_text(encoding="utf-8"))
def _copy_path_if_exists(src: Path, dst: Path) -> None:
if not src.exists():
return
if src.is_dir():
shutil.copytree(src, dst, dirs_exist_ok=True)
else:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def _restore_run_assets(source_run_id: str, target_run_dir: Path) -> None:
"""Seed a fresh run directory from a historical run snapshot."""
source_run_dir = _get_run_dir(source_run_id)
if not source_run_dir.exists():
raise HTTPException(status_code=404, detail=f"Source run not found: {source_run_id}")
for relative in [
"team_dashboard",
"agents",
"skills",
"memory",
"state/server_state.json",
"state/runtime.db",
"state/research.db",
]:
_copy_path_if_exists(source_run_dir / relative, target_run_dir / relative)
def _list_runs(limit: int = 50) -> list[RuntimeHistoryItem]:
runs_root = PROJECT_ROOT / "runs"
if not runs_root.exists():
return []
items: list[RuntimeHistoryItem] = []
run_dirs = sorted(
[path for path in runs_root.iterdir() if path.is_dir()],
key=lambda path: path.stat().st_mtime,
reverse=True,
)
for run_dir in run_dirs[: max(1, int(limit))]:
run_id = run_dir.name
runtime_state_path = run_dir / "state" / "runtime_state.json"
summary_path = run_dir / "team_dashboard" / "summary.json"
bootstrap: Dict[str, Any] = {}
updated_at: Optional[str] = None
total_trades = 0
total_asset_value: Optional[float] = None
if runtime_state_path.exists():
try:
snapshot = json.loads(runtime_state_path.read_text(encoding="utf-8"))
context = snapshot.get("context") or {}
bootstrap = dict(context.get("bootstrap_values") or {})
updated_at = snapshot.get("events", [{}])[-1].get("timestamp") if snapshot.get("events") else None
except Exception:
bootstrap = {}
if summary_path.exists():
try:
summary = json.loads(summary_path.read_text(encoding="utf-8"))
total_trades = int(summary.get("totalTrades") or 0)
total_asset_value = float(summary.get("totalAssetValue")) if summary.get("totalAssetValue") is not None else None
except Exception:
total_trades = 0
total_asset_value = None
items.append(
RuntimeHistoryItem(
run_id=run_id,
run_dir=str(run_dir),
updated_at=updated_at,
total_trades=total_trades,
total_asset_value=total_asset_value,
bootstrap=bootstrap,
)
)
return items
def _is_timestamped_run_dir(path: Path) -> bool:
try:
datetime.strptime(path.name, "%Y%m%d_%H%M%S")
@@ -390,6 +495,12 @@ async def get_runtime_events() -> RuntimeEventsResponse:
)
@router.get("/history", response_model=RuntimeHistoryResponse)
async def get_runtime_history(limit: int = 20) -> RuntimeHistoryResponse:
"""List recent historical runs for restore/start selection."""
return RuntimeHistoryResponse(runs=_list_runs(limit=limit))
@router.get("/gateway/status", response_model=GatewayStatusResponse)
async def get_gateway_status() -> GatewayStatusResponse:
"""Get Gateway process status and port."""
@@ -609,20 +720,30 @@ async def start_runtime(
_stop_gateway()
await asyncio.sleep(1) # Wait for port release
# 2. Generate run ID and directory
launch_mode = str(config.launch_mode or "fresh").strip().lower()
if launch_mode not in {"fresh", "restore"}:
raise HTTPException(status_code=400, detail="launch_mode must be 'fresh' or 'restore'")
# 2. Resolve run ID, directory, and bootstrap
if launch_mode == "restore":
restore_run_id = str(config.restore_run_id or "").strip()
if not restore_run_id:
raise HTTPException(status_code=400, detail="restore_run_id is required when launch_mode=restore")
snapshot = _load_run_snapshot(restore_run_id)
context = snapshot.get("context") or {}
if not context.get("config_name"):
raise HTTPException(status_code=404, detail=f"Run context not found: {restore_run_id}")
run_id = restore_run_id
run_dir = _get_run_dir(run_id)
bootstrap = dict(context.get("bootstrap_values") or {})
bootstrap["launch_mode"] = "restore"
bootstrap["restore_run_id"] = restore_run_id
else:
run_id = _generate_run_id()
run_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
bootstrap = {
"launch_mode": "fresh",
"restore_run_id": None,
"tickers": config.tickers,
"schedule_mode": config.schedule_mode,
"interval_minutes": config.interval_minutes,
@@ -638,6 +759,14 @@ async def start_runtime(
"enable_mock": config.enable_mock,
}
retention_keep = max(1, int(os.getenv("RUNS_RETENTION_COUNT", "20") or "20"))
pruned_run_ids = _prune_old_timestamped_runs(
keep=retention_keep,
exclude_run_ids={run_id},
)
if pruned_run_ids:
logger.info("Pruned old run directories: %s", ", ".join(pruned_run_ids))
# 4. Create runtime manager
manager = TradingRuntimeManager(
config_name=run_id,

View File

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

View File

@@ -70,8 +70,14 @@ def get_enriched_news(
start_date: str | None = None,
end_date: str | None = None,
limit: int = 100,
refresh_if_stale: bool = False,
) -> dict[str, Any]:
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
freshness = ensure_news_fresh(
store,
ticker=ticker,
target_date=end_date,
refresh_if_stale=refresh_if_stale,
)
rows = store.get_news_items_enriched(
ticker,
start_date=start_date,
@@ -101,8 +107,14 @@ def get_news_for_date(
ticker: str,
date: str,
limit: int = 20,
refresh_if_stale: bool = False,
) -> dict[str, Any]:
freshness = ensure_news_fresh(store, ticker=ticker, target_date=date)
freshness = ensure_news_fresh(
store,
ticker=ticker,
target_date=date,
refresh_if_stale=refresh_if_stale,
)
rows = store.get_news_items_enriched(
ticker,
trade_date=date,
@@ -130,8 +142,14 @@ def get_news_timeline(
ticker: str,
start_date: str,
end_date: str,
refresh_if_stale: bool = False,
) -> dict[str, Any]:
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
freshness = ensure_news_fresh(
store,
ticker=ticker,
target_date=end_date,
refresh_if_stale=refresh_if_stale,
)
timeline = store.get_news_timeline_enriched(
ticker,
start_date=start_date,
@@ -166,8 +184,14 @@ def get_news_categories(
start_date: str | None = None,
end_date: str | None = None,
limit: int = 200,
refresh_if_stale: bool = False,
) -> dict[str, Any]:
freshness = ensure_news_fresh(store, ticker=ticker, target_date=end_date)
freshness = ensure_news_fresh(
store,
ticker=ticker,
target_date=end_date,
refresh_if_stale=refresh_if_stale,
)
rows = store.get_news_items_enriched(
ticker,
start_date=start_date,
@@ -197,8 +221,14 @@ def get_similar_days_payload(
ticker: str,
date: str,
n_similar: int = 5,
refresh_if_stale: bool = False,
) -> dict[str, Any]:
freshness = ensure_news_fresh(store, ticker=ticker, target_date=date)
freshness = ensure_news_fresh(
store,
ticker=ticker,
target_date=date,
refresh_if_stale=refresh_if_stale,
)
result = find_similar_days(
store,
symbol=ticker,
@@ -214,8 +244,14 @@ def get_story_payload(
*,
ticker: str,
as_of_date: str,
refresh_if_stale: bool = False,
) -> dict[str, Any]:
freshness = ensure_news_fresh(store, ticker=ticker, target_date=as_of_date)
freshness = ensure_news_fresh(
store,
ticker=ticker,
target_date=as_of_date,
refresh_if_stale=refresh_if_stale,
)
enrich_news_for_symbol(
store,
ticker,

View File

@@ -152,6 +152,7 @@ async def handle_get_stock_news(gateway: Any, websocket: Any, data: dict[str, An
start_date=start_date,
end_date=end_date,
limit=max(limit, 50),
refresh_if_stale=False,
)
news_rows = (payload.get("news") or [])[-limit:]
source = "market_store"
@@ -202,6 +203,7 @@ async def handle_get_stock_news_for_date(gateway: Any, websocket: Any, data: dic
ticker=ticker,
date=trade_date,
limit=limit,
refresh_if_stale=False,
)
news_rows = payload.get("news") or []
source = "market_store"
@@ -255,6 +257,7 @@ async def handle_get_stock_news_timeline(gateway: Any, websocket: Any, data: dic
ticker=ticker,
start_date=start_date,
end_date=end_date,
refresh_if_stale=False,
)
timeline = payload.get("timeline") or []
@@ -313,6 +316,7 @@ async def handle_get_stock_news_categories(gateway: Any, websocket: Any, data: d
start_date=start_date,
end_date=end_date,
limit=200,
refresh_if_stale=False,
)
categories = payload.get("categories") or {}

View File

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

View File

@@ -2,6 +2,7 @@
"""Tests for the extracted runtime service app surface."""
import json
from pathlib import Path
from fastapi.testclient import TestClient
@@ -18,6 +19,7 @@ def test_runtime_service_routes_are_exposed():
assert "/api/runtime/start" in paths
assert "/api/runtime/stop" in paths
assert "/api/runtime/cleanup" in paths
assert "/api/runtime/history" in paths
assert "/api/runtime/current" in paths
assert "/api/runtime/gateway/port" in paths
@@ -235,3 +237,129 @@ def test_runtime_cleanup_endpoint_prunes_old_runs(monkeypatch, tmp_path):
assert sorted(payload["pruned_run_ids"]) == ["20260324_090000", "20260324_100000"]
assert (runs_dir / "20260324_110000").exists()
assert (runs_dir / "smoke_fullstack").exists()
def test_runtime_history_lists_recent_runs(monkeypatch, tmp_path):
run_dir = tmp_path / "runs" / "20260324_120000"
(run_dir / "state").mkdir(parents=True)
(run_dir / "team_dashboard").mkdir(parents=True)
(run_dir / "state" / "runtime_state.json").write_text(
json.dumps(
{
"context": {
"config_name": "20260324_120000",
"run_dir": str(run_dir),
"bootstrap_values": {"tickers": ["AAPL"]},
},
"events": [],
}
),
encoding="utf-8",
)
(run_dir / "team_dashboard" / "summary.json").write_text(
json.dumps({"totalTrades": 3, "totalAssetValue": 123456.0}),
encoding="utf-8",
)
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
with TestClient(create_app()) as client:
response = client.get("/api/runtime/history?limit=5")
assert response.status_code == 200
payload = response.json()
assert payload["runs"][0]["run_id"] == "20260324_120000"
assert payload["runs"][0]["total_trades"] == 3
def test_restore_run_assets_copies_state(monkeypatch, tmp_path):
source_run = tmp_path / "runs" / "20260324_100000"
(source_run / "team_dashboard").mkdir(parents=True)
(source_run / "state").mkdir(parents=True)
(source_run / "agents").mkdir(parents=True)
(source_run / "team_dashboard" / "_internal_state.json").write_text("{}", encoding="utf-8")
(source_run / "state" / "server_state.json").write_text("{}", encoding="utf-8")
target_run = tmp_path / "runs" / "20260324_130000"
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
runtime_module._restore_run_assets("20260324_100000", target_run)
assert (target_run / "team_dashboard" / "_internal_state.json").exists()
assert (target_run / "state" / "server_state.json").exists()
def test_start_runtime_restore_reuses_historical_run_id(monkeypatch, tmp_path):
run_dir = tmp_path / "runs" / "20260324_100000"
(run_dir / "state").mkdir(parents=True)
(run_dir / "state" / "runtime_state.json").write_text(
json.dumps(
{
"context": {
"config_name": "20260324_100000",
"run_dir": str(run_dir),
"bootstrap_values": {
"tickers": ["AAPL"],
"schedule_mode": "intraday",
"interval_minutes": 30,
"trigger_time": "now",
"max_comm_cycles": 2,
"initial_cash": 100000.0,
"margin_requirement": 0.0,
"enable_memory": False,
"mode": "live",
"poll_interval": 10,
"enable_mock": False,
},
}
}
),
encoding="utf-8",
)
class _DummyManager:
def __init__(self, config_name, run_dir, bootstrap):
self.config_name = config_name
self.run_dir = Path(run_dir)
self.bootstrap = bootstrap
self.context = None
def prepare_run(self):
self.context = type(
"Ctx",
(),
{
"config_name": self.config_name,
"run_dir": self.run_dir,
"bootstrap_values": self.bootstrap,
},
)()
return self.context
class _DummyProcess:
def poll(self):
return None
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(runtime_module, "_find_available_port", lambda start_port=8765, max_port=9000: 8765)
monkeypatch.setattr(runtime_module, "_start_gateway_process", lambda **kwargs: _DummyProcess())
monkeypatch.setattr(runtime_module, "_stop_gateway", lambda: True)
monkeypatch.setattr("backend.runtime.manager.TradingRuntimeManager", _DummyManager)
runtime_state = runtime_module.get_runtime_state()
runtime_state.gateway_process = None
with TestClient(create_app()) as client:
response = client.post(
"/api/runtime/start",
json={
"launch_mode": "restore",
"restore_run_id": "20260324_100000",
"tickers": [],
},
)
assert response.status_code == 200
payload = response.json()
assert payload["run_id"] == "20260324_100000"
assert payload["run_dir"] == str(run_dir)

View File

@@ -96,7 +96,40 @@ export default function LiveTradingApp() {
setWorkspaceDraftContent,
} = useAgentStore();
const { feed, processHistoricalFeed, processFeedEvent, addSystemMessage } = useFeedProcessor();
const { feed, processHistoricalFeed, processFeedEvent, addSystemMessage, clearFeed } = useFeedProcessor();
const resetRuntimeViewState = useCallback(() => {
clearFeed();
useMarketStore.getState().setPriceHistoryByTicker({});
useMarketStore.getState().setOhlcHistoryByTicker({});
useMarketStore.getState().setHistorySourceByTicker({});
useMarketStore.getState().setExplainEventsByTicker({});
useMarketStore.getState().setNewsByTicker({});
useMarketStore.getState().setInsiderTradesByTicker({});
useMarketStore.getState().setTechnicalIndicatorsByTicker({});
usePortfolioStore.getState().setHoldings([]);
usePortfolioStore.getState().setTrades([]);
usePortfolioStore.getState().setStats(null);
usePortfolioStore.getState().setLeaderboard([]);
usePortfolioStore.getState().setPortfolioData({
netValue: 10000,
pnl: 0,
equity: [],
baseline: [],
baseline_vw: [],
momentum: [],
strategies: [],
equity_return: 0,
baseline_return: 0,
baseline_vw_return: 0,
momentum_return: 0,
});
useRuntimeStore.getState().setLastDayHistory([]);
useUIStore.getState().setBubbles({});
}, [clearFeed]);
const {
clientRef,
setRequestStockHistory,
@@ -112,6 +145,7 @@ export default function LiveTradingApp() {
clientRef,
currentTickers: tickers,
addSystemMessage,
onRuntimeStarted: resetRuntimeViewState,
});
const stockRequests = useStockDataRequests(clientRef, {
@@ -367,6 +401,9 @@ export default function LiveTradingApp() {
isWatchlistSaving={runtimeControls.isWatchlistSaving}
runtimeConfigFeedback={runtimeControls.runtimeConfigFeedback}
watchlistFeedback={runtimeControls.watchlistFeedback}
launchModeDraft={runtimeControls.launchModeDraft}
restoreRunIdDraft={runtimeControls.restoreRunIdDraft}
runtimeHistoryRuns={runtimeControls.runtimeHistoryRuns}
scheduleModeDraft={runtimeControls.scheduleModeDraft}
intervalMinutesDraft={runtimeControls.intervalMinutesDraft}
triggerTimeDraft={runtimeControls.triggerTimeDraft}
@@ -382,6 +419,8 @@ export default function LiveTradingApp() {
watchlistDraftSymbols={runtimeControls.watchlistDraftSymbols}
watchlistInputValue={runtimeControls.watchlistInputValue}
watchlistSuggestions={runtimeControls.watchlistSuggestions}
onLaunchModeChange={runtimeControls.setLaunchModeDraft}
onRestoreRunIdChange={runtimeControls.setRestoreRunIdDraft}
onScheduleModeChange={runtimeControls.setScheduleModeDraft}
onIntervalMinutesChange={runtimeControls.setIntervalMinutesDraft}
onTriggerTimeChange={runtimeControls.setTriggerTimeDraft}

View File

@@ -58,6 +58,9 @@ export default function AppShell({
isWatchlistSaving,
runtimeConfigFeedback,
watchlistFeedback,
launchModeDraft,
restoreRunIdDraft,
runtimeHistoryRuns,
scheduleModeDraft,
intervalMinutesDraft,
triggerTimeDraft,
@@ -73,6 +76,8 @@ export default function AppShell({
watchlistDraftSymbols,
watchlistInputValue,
watchlistSuggestions,
onLaunchModeChange,
onRestoreRunIdChange,
onScheduleModeChange,
onIntervalMinutesChange,
onTriggerTimeChange,
@@ -300,6 +305,9 @@ export default function AppShell({
isConnected={isConnected}
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
feedback={runtimeConfigFeedback || watchlistFeedback}
launchMode={launchModeDraft}
restoreRunId={restoreRunIdDraft}
runtimeHistoryRuns={runtimeHistoryRuns}
scheduleMode={scheduleModeDraft}
intervalMinutes={intervalMinutesDraft}
triggerTime={triggerTimeDraft}
@@ -317,6 +325,8 @@ export default function AppShell({
watchlistSuggestions={watchlistSuggestions}
onToggle={onRuntimeSettingsToggle}
onClose={() => setIsRuntimeSettingsOpen(false)}
onLaunchModeChange={onLaunchModeChange}
onRestoreRunIdChange={onRestoreRunIdChange}
onScheduleModeChange={onScheduleModeChange}
onIntervalMinutesChange={onIntervalMinutesChange}
onTriggerTimeChange={onTriggerTimeChange}

View File

@@ -1,12 +1,24 @@
import React from 'react';
import { createPortal } from 'react-dom';
const formatHistorySummary = (run) => {
const updatedAt = run?.updated_at ? String(run.updated_at).replace("T", " ").slice(0, 16) : "未知时间";
const mode = run?.bootstrap?.mode ? String(run.bootstrap.mode).toUpperCase() : "LIVE";
const tickers = Array.isArray(run?.bootstrap?.tickers) ? run.bootstrap.tickers.length : 0;
const assetValue = Number(run?.total_asset_value ?? 0).toFixed(2);
const trades = Number(run?.total_trades ?? 0);
return `${run.run_id} · ${updatedAt} · ${mode} · ${tickers}标的 · ${trades}笔交易 · $${assetValue}`;
};
export default function RuntimeSettingsPanel({
showTrigger = true,
isOpen,
isConnected,
isSaving,
feedback,
launchMode,
restoreRunId,
runtimeHistoryRuns,
scheduleMode,
intervalMinutes,
triggerTime,
@@ -25,6 +37,8 @@ export default function RuntimeSettingsPanel({
onToggle,
onClose,
onScheduleModeChange,
onLaunchModeChange,
onRestoreRunIdChange,
onIntervalMinutesChange,
onTriggerTimeChange,
onMaxCommCyclesChange,
@@ -134,6 +148,75 @@ export default function RuntimeSettingsPanel({
</div>
</div>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 12
}}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>启动形式</div>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>任务模式</span>
<select
value={launchMode}
onChange={(e) => onLaunchModeChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px'
}}
>
<option value="fresh">重新启动</option>
<option value="restore">从历史任务恢复</option>
</select>
</label>
{launchMode === 'restore' && (
<>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>历史任务</span>
<select
value={restoreRunId}
onChange={(e) => onRestoreRunIdChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px'
}}
>
<option value="">请选择历史任务</option>
{runtimeHistoryRuns.map((run) => (
<option key={run.run_id} value={run.run_id}>
{formatHistorySummary(run)}
</option>
))}
</select>
</label>
<div style={{
fontSize: '11px',
color: '#6B7280',
lineHeight: 1.6,
padding: '10px 12px',
borderRadius: 8,
background: '#FFFFFF',
border: '1px dashed #D0D7DE'
}}>
恢复启动会从所选历史任务复制运行状态组合交易记录和 Agent 工作区资产并以新的任务 ID 继续运行
</div>
</>
)}
</div>
{launchMode === 'fresh' && (
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
@@ -273,7 +356,9 @@ export default function RuntimeSettingsPanel({
</button>
</div>
</div>
)}
{launchMode === 'fresh' && (
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
@@ -511,6 +596,7 @@ export default function RuntimeSettingsPanel({
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用模拟数据 (Mock)</span>
</label>
</div>
)}
<div style={{
border: '1px solid #E5EAF1',

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { INITIAL_TICKERS } from "../config/constants";
import { startRuntime } from "../services/runtimeApi";
import { fetchRuntimeHistory, startRuntime } from "../services/runtimeApi";
import {
buildRuntimeSummaryLabel,
normalizeTickerSymbols,
@@ -19,7 +19,7 @@ const DEFAULT_MARGIN_REQUIREMENT = "0";
const DEFAULT_MODE = "live";
const DEFAULT_POLL_INTERVAL = "10";
export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage }) {
export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage, onRuntimeStarted }) {
const {
runtimeConfig,
setRuntimeConfig,
@@ -35,6 +35,12 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
setWatchlistFeedback,
isWatchlistSaving,
setIsWatchlistSaving,
launchModeDraft,
setLaunchModeDraft,
restoreRunIdDraft,
setRestoreRunIdDraft,
runtimeHistoryRuns,
setRuntimeHistoryRuns,
scheduleModeDraft,
setScheduleModeDraft,
intervalMinutesDraft,
@@ -152,6 +158,32 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
setTriggerTimeDraft
]);
useEffect(() => {
if (!isRuntimeSettingsOpen) {
return;
}
let cancelled = false;
void fetchRuntimeHistory(20)
.then((payload) => {
if (cancelled) return;
const runs = Array.isArray(payload?.runs) ? payload.runs : [];
setRuntimeHistoryRuns(runs);
if (!restoreRunIdDraft && runs.length > 0) {
setRestoreRunIdDraft(runs[0].run_id);
}
})
.catch(() => {
if (!cancelled) {
setRuntimeHistoryRuns([]);
}
});
return () => {
cancelled = true;
};
}, [isRuntimeSettingsOpen, restoreRunIdDraft, setRestoreRunIdDraft, setRuntimeHistoryRuns]);
const commitWatchlistInput = useCallback((value) => {
const parsed = parseWatchlistInput(value);
if (parsed.length === 0) {
@@ -340,6 +372,10 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
setRuntimeConfigFeedback({ type: "error", text: "保证金要求不能为负数" });
return;
}
if (launchModeDraft === "restore" && !restoreRunIdDraft) {
setRuntimeConfigFeedback({ type: "error", text: "请选择一个历史任务用于恢复启动" });
return;
}
setIsRuntimeConfigSaving(true);
setIsWatchlistSaving(true);
@@ -350,6 +386,8 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
try {
const result = await startRuntime({
launch_mode: launchModeDraft,
restore_run_id: launchModeDraft === "restore" ? restoreRunIdDraft : null,
tickers: nextTickers,
schedule_mode: scheduleModeDraft,
interval_minutes: interval,
@@ -373,6 +411,7 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
text: `任务已启动: ${result.run_id}`
});
addSystemMessage(`新任务已启动: ${result.run_id}`);
onRuntimeStarted?.(result);
} catch (error) {
setIsRuntimeConfigSaving(false);
setIsWatchlistSaving(false);
@@ -389,10 +428,12 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
endDateDraft,
initialCashDraft,
intervalMinutesDraft,
launchModeDraft,
marginRequirementDraft,
maxCommCyclesDraft,
modeDraft,
pollIntervalDraft,
restoreRunIdDraft,
scheduleModeDraft,
setIsRuntimeConfigSaving,
setIsRuntimeSettingsOpen,
@@ -402,6 +443,7 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
setWatchlistFeedback,
setWatchlistInputValue,
startDateDraft,
onRuntimeStarted,
triggerTimeDraft,
watchlistDraftSymbols,
watchlistInputValue
@@ -415,6 +457,8 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
setInitialCashDraft(DEFAULT_INITIAL_CASH);
setMarginRequirementDraft(DEFAULT_MARGIN_REQUIREMENT);
setEnableMemoryDraft(false);
setLaunchModeDraft("fresh");
setRestoreRunIdDraft("");
setModeDraft(DEFAULT_MODE);
setPollIntervalDraft(DEFAULT_POLL_INTERVAL);
setStartDateDraft("");
@@ -427,10 +471,12 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
setEndDateDraft,
setInitialCashDraft,
setIntervalMinutesDraft,
setLaunchModeDraft,
setMarginRequirementDraft,
setMaxCommCyclesDraft,
setModeDraft,
setPollIntervalDraft,
setRestoreRunIdDraft,
setRuntimeConfigFeedback,
setScheduleModeDraft,
setStartDateDraft,
@@ -482,6 +528,9 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
watchlistInputValue,
watchlistFeedback,
isWatchlistSaving,
launchModeDraft,
restoreRunIdDraft,
runtimeHistoryRuns,
scheduleModeDraft,
intervalMinutesDraft,
triggerTimeDraft,
@@ -527,6 +576,8 @@ export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage
setInitialCashDraft,
setMarginRequirementDraft,
setEnableMemoryDraft,
setLaunchModeDraft,
setRestoreRunIdDraft,
setModeDraft,
setPollIntervalDraft,
setStartDateDraft,

View File

@@ -38,6 +38,10 @@ export function fetchRuntimeEvents() {
return safeFetch(RUNTIME_API_BASE, '/events');
}
export function fetchRuntimeHistory(limit = 20) {
return safeFetch(RUNTIME_API_BASE, `/history?limit=${limit}`);
}
export function fetchPendingApprovals() {
return safeFetch(CONTROL_API_BASE, '/guard/pending');
}

View File

@@ -61,6 +61,9 @@ export const useRuntimeStore = create((set) => ({
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })),
// Runtime config drafts
launchModeDraft: 'fresh',
restoreRunIdDraft: '',
runtimeHistoryRuns: [],
scheduleModeDraft: 'daily',
intervalMinutesDraft: '60',
triggerTimeDraft: 'now',
@@ -73,6 +76,9 @@ export const useRuntimeStore = create((set) => ({
startDateDraft: '',
endDateDraft: '',
enableMockDraft: false,
setLaunchModeDraft: (launchModeDraft) => set((state) => ({ launchModeDraft: resolveValue(launchModeDraft, state.launchModeDraft) })),
setRestoreRunIdDraft: (restoreRunIdDraft) => set((state) => ({ restoreRunIdDraft: resolveValue(restoreRunIdDraft, state.restoreRunIdDraft) })),
setRuntimeHistoryRuns: (runtimeHistoryRuns) => set((state) => ({ runtimeHistoryRuns: resolveValue(runtimeHistoryRuns, state.runtimeHistoryRuns) })),
setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })),
setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })),
setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })),