feat: Add task launcher API with timestamp-based run directories
- Add POST /runtime/start, /stop, /restart APIs - Run directories use timestamp format: YYYYMMDD_HHMMSS - Add force stop support in TradingRuntimeManager - Frontend: use REST API to start runtime instead of WebSocket - Remove RuntimeView page from navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,22 +3,28 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.runtime.agent_runtime import AgentRuntimeState
|
||||
from backend.runtime.context import TradingRunContext
|
||||
from backend.runtime.manager import TradingRuntimeManager
|
||||
from backend.runtime.manager import TradingRuntimeManager, get_global_runtime_manager
|
||||
|
||||
router = APIRouter(prefix="/api/runtime", tags=["runtime"])
|
||||
|
||||
runtime_manager: Optional[TradingRuntimeManager] = None
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Global task reference for running pipeline
|
||||
_running_task: Optional[asyncio.Task] = None
|
||||
_stop_event: Optional[asyncio.Event] = None
|
||||
|
||||
|
||||
class RunContextResponse(BaseModel):
|
||||
config_name: str
|
||||
@@ -48,6 +54,49 @@ class RuntimeEventsResponse(BaseModel):
|
||||
events: List[RuntimeEvent]
|
||||
|
||||
|
||||
class LaunchConfig(BaseModel):
|
||||
"""Configuration for launching a new trading task."""
|
||||
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="间隔分钟数")
|
||||
trigger_time: str = Field(default="09:30", description="触发时间 HH:MM")
|
||||
max_comm_cycles: int = Field(default=2, ge=1, description="最大会商轮数")
|
||||
initial_cash: float = Field(default=100000.0, gt=0, description="初始资金")
|
||||
margin_requirement: float = Field(default=0.0, ge=0, description="保证金要求")
|
||||
enable_memory: bool = Field(default=False, description="是否启用长期记忆")
|
||||
mode: str = Field(default="live", description="运行模式: live, backtest")
|
||||
start_date: Optional[str] = Field(default=None, description="回测开始日期 YYYY-MM-DD")
|
||||
end_date: Optional[str] = Field(default=None, description="回测结束日期 YYYY-MM-DD")
|
||||
|
||||
|
||||
class LaunchResponse(BaseModel):
|
||||
run_id: str
|
||||
status: str
|
||||
run_dir: str
|
||||
message: str
|
||||
|
||||
|
||||
class StopResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
class RestartResponse(BaseModel):
|
||||
run_id: str
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
def _generate_run_id() -> str:
|
||||
"""Generate timestamp-based run ID: YYYYMMDD_HHMMSS"""
|
||||
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
|
||||
def _get_run_dir(run_id: str) -> Path:
|
||||
"""Return the run directory for a given run ID."""
|
||||
return PROJECT_ROOT / "runs" / run_id
|
||||
|
||||
|
||||
def _latest_snapshot_path() -> Optional[Path]:
|
||||
candidates = sorted(
|
||||
PROJECT_ROOT.glob("runs/*/state/runtime_state.json"),
|
||||
@@ -133,3 +182,214 @@ def unregister_runtime_manager() -> None:
|
||||
"""Drop the runtime manager reference (used for shutdown/testing)."""
|
||||
global runtime_manager
|
||||
runtime_manager = None
|
||||
|
||||
|
||||
async def _stop_current_runtime(force: bool = True) -> bool:
|
||||
"""Stop the current running runtime if exists.
|
||||
|
||||
Args:
|
||||
force: If True, cancel the running task immediately
|
||||
|
||||
Returns:
|
||||
True if a runtime was stopped, False if no runtime was running
|
||||
"""
|
||||
global _running_task, _stop_event
|
||||
|
||||
# Signal stop
|
||||
if _stop_event is not None:
|
||||
_stop_event.set()
|
||||
|
||||
# Cancel running task
|
||||
if _running_task is not None and not _running_task.done():
|
||||
if force:
|
||||
_running_task.cancel()
|
||||
try:
|
||||
await _running_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
else:
|
||||
# Wait for graceful shutdown
|
||||
try:
|
||||
await asyncio.wait_for(_running_task, timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
_running_task.cancel()
|
||||
try:
|
||||
await _running_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
_running_task = None
|
||||
_stop_event = None
|
||||
|
||||
# Unregister runtime manager
|
||||
if runtime_manager is not None:
|
||||
unregister_runtime_manager()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/start", response_model=LaunchResponse)
|
||||
async def start_runtime(
|
||||
config: LaunchConfig,
|
||||
background_tasks: BackgroundTasks
|
||||
) -> LaunchResponse:
|
||||
"""Start a new trading runtime with the given configuration.
|
||||
|
||||
If a runtime is already running, it will be forcefully stopped first.
|
||||
Creates a new timestamped run directory.
|
||||
"""
|
||||
global _running_task, _stop_event, runtime_manager
|
||||
|
||||
# 1. Stop current runtime if exists
|
||||
await _stop_current_runtime(force=True)
|
||||
|
||||
# 2. Generate run ID and directory
|
||||
run_id = _generate_run_id()
|
||||
run_dir = _get_run_dir(run_id)
|
||||
|
||||
# 3. Prepare bootstrap config
|
||||
bootstrap = {
|
||||
"tickers": config.tickers,
|
||||
"schedule_mode": config.schedule_mode,
|
||||
"interval_minutes": config.interval_minutes,
|
||||
"trigger_time": config.trigger_time,
|
||||
"max_comm_cycles": config.max_comm_cycles,
|
||||
"initial_cash": config.initial_cash,
|
||||
"margin_requirement": config.margin_requirement,
|
||||
"enable_memory": config.enable_memory,
|
||||
"mode": config.mode,
|
||||
"start_date": config.start_date,
|
||||
"end_date": config.end_date,
|
||||
}
|
||||
|
||||
# 4. Create and prepare runtime manager
|
||||
runtime_manager = TradingRuntimeManager(
|
||||
config_name=run_id,
|
||||
run_dir=run_dir,
|
||||
bootstrap=bootstrap,
|
||||
)
|
||||
runtime_manager.prepare_run()
|
||||
set_global_runtime_manager = None # Will be set by main module
|
||||
|
||||
# 5. Write BOOTSTRAP.md
|
||||
_write_bootstrap_md(run_dir, bootstrap)
|
||||
|
||||
# 6. Start pipeline in background
|
||||
_stop_event = asyncio.Event()
|
||||
_running_task = asyncio.create_task(
|
||||
_run_pipeline(run_id, run_dir, bootstrap, _stop_event)
|
||||
)
|
||||
|
||||
return LaunchResponse(
|
||||
run_id=run_id,
|
||||
status="started",
|
||||
run_dir=str(run_dir),
|
||||
message=f"Runtime started with run_id: {run_id}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stop", response_model=StopResponse)
|
||||
async def stop_runtime(force: bool = True) -> StopResponse:
|
||||
"""Stop the current running runtime.
|
||||
|
||||
Args:
|
||||
force: If True, forcefully cancel the running task
|
||||
"""
|
||||
was_running = await _stop_current_runtime(force=force)
|
||||
|
||||
if not was_running:
|
||||
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||
|
||||
return StopResponse(
|
||||
status="stopped",
|
||||
message="Runtime stopped successfully",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restart", response_model=RestartResponse)
|
||||
async def restart_runtime(
|
||||
config: LaunchConfig,
|
||||
background_tasks: BackgroundTasks
|
||||
) -> RestartResponse:
|
||||
"""Restart the runtime with a new configuration.
|
||||
|
||||
Equivalent to stop + start.
|
||||
"""
|
||||
# Stop current runtime
|
||||
await _stop_current_runtime(force=True)
|
||||
|
||||
# Start new runtime
|
||||
response = await start_runtime(config, background_tasks)
|
||||
|
||||
return RestartResponse(
|
||||
run_id=response.run_id,
|
||||
status="restarted",
|
||||
message=f"Runtime restarted with run_id: {response.run_id}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
async def get_current_runtime():
|
||||
"""Get information about the currently running runtime."""
|
||||
global _running_task, runtime_manager
|
||||
|
||||
is_running = _running_task is not None and not _running_task.done()
|
||||
|
||||
if not is_running or runtime_manager is None:
|
||||
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||
|
||||
return {
|
||||
"run_id": runtime_manager.config_name,
|
||||
"run_dir": str(runtime_manager.run_dir),
|
||||
"is_running": is_running,
|
||||
"bootstrap": runtime_manager.bootstrap,
|
||||
}
|
||||
|
||||
|
||||
def _write_bootstrap_md(run_dir: Path, bootstrap: Dict[str, Any]) -> None:
|
||||
"""Write bootstrap configuration to BOOTSTRAP.md."""
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
bootstrap_path = run_dir / "BOOTSTRAP.md"
|
||||
bootstrap_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Filter out None values
|
||||
values = {k: v for k, v in bootstrap.items() if v is not None}
|
||||
|
||||
if yaml:
|
||||
front_matter = yaml.safe_dump(values, allow_unicode=True, sort_keys=False)
|
||||
else:
|
||||
# Fallback to JSON if yaml not available
|
||||
front_matter = json.dumps(values, ensure_ascii=False, indent=2)
|
||||
|
||||
content = f"---\n{front_matter}---\n"
|
||||
bootstrap_path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
async def _run_pipeline(
|
||||
run_id: str,
|
||||
run_dir: Path,
|
||||
bootstrap: Dict[str, Any],
|
||||
stop_event: asyncio.Event
|
||||
) -> None:
|
||||
"""Background task to run the trading pipeline.
|
||||
|
||||
This is a placeholder - actual implementation will integrate with main.py
|
||||
"""
|
||||
try:
|
||||
# TODO: Integrate with main.py pipeline execution
|
||||
# This should call the actual pipeline startup logic from main.py
|
||||
|
||||
# For now, just wait until stop event is set
|
||||
while not stop_event.is_set():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Handle cancellation gracefully
|
||||
raise
|
||||
finally:
|
||||
# Cleanup
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user