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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
"""Tests for the extracted runtime service app surface.""" """Tests for the extracted runtime service app surface."""
import json import json
from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -18,6 +19,7 @@ def test_runtime_service_routes_are_exposed():
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/cleanup" in paths
assert "/api/runtime/history" in paths
assert "/api/runtime/current" in paths assert "/api/runtime/current" in paths
assert "/api/runtime/gateway/port" in paths assert "/api/runtime/gateway/port" in paths
@@ -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 sorted(payload["pruned_run_ids"]) == ["20260324_090000", "20260324_100000"]
assert (runs_dir / "20260324_110000").exists() assert (runs_dir / "20260324_110000").exists()
assert (runs_dir / "smoke_fullstack").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, setWorkspaceDraftContent,
} = useAgentStore(); } = 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 { const {
clientRef, clientRef,
setRequestStockHistory, setRequestStockHistory,
@@ -112,6 +145,7 @@ export default function LiveTradingApp() {
clientRef, clientRef,
currentTickers: tickers, currentTickers: tickers,
addSystemMessage, addSystemMessage,
onRuntimeStarted: resetRuntimeViewState,
}); });
const stockRequests = useStockDataRequests(clientRef, { const stockRequests = useStockDataRequests(clientRef, {
@@ -367,6 +401,9 @@ export default function LiveTradingApp() {
isWatchlistSaving={runtimeControls.isWatchlistSaving} isWatchlistSaving={runtimeControls.isWatchlistSaving}
runtimeConfigFeedback={runtimeControls.runtimeConfigFeedback} runtimeConfigFeedback={runtimeControls.runtimeConfigFeedback}
watchlistFeedback={runtimeControls.watchlistFeedback} watchlistFeedback={runtimeControls.watchlistFeedback}
launchModeDraft={runtimeControls.launchModeDraft}
restoreRunIdDraft={runtimeControls.restoreRunIdDraft}
runtimeHistoryRuns={runtimeControls.runtimeHistoryRuns}
scheduleModeDraft={runtimeControls.scheduleModeDraft} scheduleModeDraft={runtimeControls.scheduleModeDraft}
intervalMinutesDraft={runtimeControls.intervalMinutesDraft} intervalMinutesDraft={runtimeControls.intervalMinutesDraft}
triggerTimeDraft={runtimeControls.triggerTimeDraft} triggerTimeDraft={runtimeControls.triggerTimeDraft}
@@ -382,6 +419,8 @@ export default function LiveTradingApp() {
watchlistDraftSymbols={runtimeControls.watchlistDraftSymbols} watchlistDraftSymbols={runtimeControls.watchlistDraftSymbols}
watchlistInputValue={runtimeControls.watchlistInputValue} watchlistInputValue={runtimeControls.watchlistInputValue}
watchlistSuggestions={runtimeControls.watchlistSuggestions} watchlistSuggestions={runtimeControls.watchlistSuggestions}
onLaunchModeChange={runtimeControls.setLaunchModeDraft}
onRestoreRunIdChange={runtimeControls.setRestoreRunIdDraft}
onScheduleModeChange={runtimeControls.setScheduleModeDraft} onScheduleModeChange={runtimeControls.setScheduleModeDraft}
onIntervalMinutesChange={runtimeControls.setIntervalMinutesDraft} onIntervalMinutesChange={runtimeControls.setIntervalMinutesDraft}
onTriggerTimeChange={runtimeControls.setTriggerTimeDraft} onTriggerTimeChange={runtimeControls.setTriggerTimeDraft}

View File

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

View File

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

View File

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

View File

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

View File

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