Add restore-mode task launch flow
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def test_get_enriched_news_returns_rows_without_enrichment_when_present(monkeypa
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
news_domain,
|
news_domain,
|
||||||
"ensure_news_fresh",
|
"ensure_news_fresh",
|
||||||
lambda store, ticker, target_date=None: {
|
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"target_date": target_date,
|
"target_date": target_date,
|
||||||
"last_news_fetch": target_date,
|
"last_news_fetch": target_date,
|
||||||
@@ -109,7 +109,7 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
news_domain,
|
news_domain,
|
||||||
"ensure_news_fresh",
|
"ensure_news_fresh",
|
||||||
lambda store, ticker, target_date=None: {
|
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"target_date": target_date,
|
"target_date": target_date,
|
||||||
"last_news_fetch": target_date,
|
"last_news_fetch": target_date,
|
||||||
@@ -137,12 +137,38 @@ def test_get_story_and_similar_days_delegate(monkeypatch):
|
|||||||
assert "freshness" in similar
|
assert "freshness" in similar
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_enriched_news_defaults_to_read_only_freshness(monkeypatch):
|
||||||
|
store = _FakeStore()
|
||||||
|
ensure_calls = []
|
||||||
|
|
||||||
|
def fake_ensure(store, ticker, target_date=None, refresh_if_stale=False):
|
||||||
|
ensure_calls.append(refresh_if_stale)
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"target_date": target_date,
|
||||||
|
"last_news_fetch": target_date,
|
||||||
|
"refreshed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(news_domain, "ensure_news_fresh", fake_ensure)
|
||||||
|
monkeypatch.setattr(news_domain, "news_rows_need_enrichment", lambda rows: False)
|
||||||
|
|
||||||
|
payload = news_domain.get_enriched_news(
|
||||||
|
store,
|
||||||
|
ticker="AAPL",
|
||||||
|
end_date="2026-03-16",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["ticker"] == "AAPL"
|
||||||
|
assert ensure_calls == [False]
|
||||||
|
|
||||||
|
|
||||||
def test_get_range_explain_payload_uses_article_ids(monkeypatch):
|
def test_get_range_explain_payload_uses_article_ids(monkeypatch):
|
||||||
store = _FakeStore()
|
store = _FakeStore()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
news_domain,
|
news_domain,
|
||||||
"ensure_news_fresh",
|
"ensure_news_fresh",
|
||||||
lambda store, ticker, target_date=None: {
|
lambda store, ticker, target_date=None, refresh_if_stale=False: {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"target_date": target_date,
|
"target_date": target_date,
|
||||||
"last_news_fetch": target_date,
|
"last_news_fetch": target_date,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Tests for the extracted runtime service app surface."""
|
"""Tests for the extracted runtime service app surface."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) })),
|
||||||
|
|||||||
Reference in New Issue
Block a user