From 54fc1b656f7e76d42fdef057390c3b9124fa47e6 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Tue, 7 Jul 2026 06:35:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(s3):=20=E5=8E=86=E5=8F=B2=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20+=20=E5=8F=82=E6=95=B0=E4=BC=98=E5=8C=96=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E8=B7=91=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - result_store.load_result_by_task_id + orchestrator.get_result DB 兜底(历史回看) - GET /task 列表、GET /task/{id}/optimization-results - Task.raw_result 存优化结果 list(内存) - cta_optimizer 修同款 bug(interval d / capital 1M / vnpy DB SETTINGS) - get_status 返回 error_msg(str 守卫) - 前端 优化页(网格输入+轮询+结果表)、历史页(任务列表+回看)、侧栏子菜单 - 修 5 个旧 test_routes 回归;73 tests passed - 冒烟:历史 3 任务 + 优化 9 组合 --- frontend/src/api/backtest.ts | 42 +++++++ frontend/src/router/index.ts | 2 + frontend/src/views/Layout.vue | 9 +- frontend/src/views/backtest/History.vue | 51 +++++++++ frontend/src/views/backtest/Optimize.vue | 133 +++++++++++++++++++++++ sanguo_api/routes.py | 44 +++++++- sanguo_backtest/cta_optimizer.py | 14 ++- sanguo_backtest/result_store.py | 15 +++ sanguo_orchestrator/runner.py | 6 +- scripts/smoke_phase3b_optimize.py | 67 ++++++++++++ tests/api/test_routes.py | 17 ++- 11 files changed, 386 insertions(+), 14 deletions(-) create mode 100644 frontend/src/views/backtest/History.vue create mode 100644 frontend/src/views/backtest/Optimize.vue create mode 100644 scripts/smoke_phase3b_optimize.py diff --git a/frontend/src/api/backtest.ts b/frontend/src/api/backtest.ts index 5c7ad6f..6315ae2 100644 --- a/frontend/src/api/backtest.ts +++ b/frontend/src/api/backtest.ts @@ -87,3 +87,45 @@ export async function getKline(symbol: string, start: string, end: string): Prom const { data } = await apiClient.get<{ kline: KlineBar[] }>('/kline', { params: { symbol, start, end } }) return data.kline } + +// ----- S3: history + optimization ----- + +export interface TaskListItem { + id: number + task_id: string + type: string + status: string + strategy: string + symbol: string + start: string + end: string +} + +export async function getTasks(type?: string): Promise { + const { data } = await apiClient.get<{ tasks: TaskListItem[] }>('/task', { params: type ? { type } : {} }) + return data.tasks +} + +export interface OptimizeSubmit { + symbol: string + strategy: string + grid: Record + start: string + end: string +} + +export async function submitOptimize(req: OptimizeSubmit): Promise { + const { data } = await apiClient.post<{ task_id: string }>('/backtest/optimize', req) + return data.task_id +} + +export interface OptRow { + params: Record + statistics: Record +} + +export async function getOptimizationResults(taskId: string): Promise { + const { data } = await apiClient.get<{ results: OptRow[] }>(`/task/${taskId}/optimization-results`) + return data.results +} + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 91bc0cb..4ea7229 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -11,6 +11,8 @@ const routes: RouteRecordRaw[] = [ { path: 'backtest/new', name: 'bt-new', component: () => import('@/views/backtest/New.vue') }, { path: 'backtest/progress/:id', name: 'bt-progress', component: () => import('@/views/backtest/Progress.vue') }, { path: 'backtest/result/:id', name: 'bt-result', component: () => import('@/views/backtest/Result.vue') }, + { path: 'backtest/optimize', name: 'bt-optimize', component: () => import('@/views/backtest/Optimize.vue') }, + { path: 'backtest/history', name: 'bt-history', component: () => import('@/views/backtest/History.vue') }, { path: 'factor/new', name: 'fc-new', component: () => import('@/views/factor/New.vue') }, { path: 'factor/progress/:id', name: 'fc-progress', component: () => import('@/views/backtest/Progress.vue') }, { path: 'factor/result/:id', name: 'fc-result', component: () => import('@/views/factor/Result.vue') }, diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 376e180..769737d 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -20,9 +20,12 @@ function onLogout(): void { - - 📊 回测 - + + + 新建回测 + 参数优化 + 历史任务 + 🔬 投研 diff --git a/frontend/src/views/backtest/History.vue b/frontend/src/views/backtest/History.vue new file mode 100644 index 0000000..2d996de --- /dev/null +++ b/frontend/src/views/backtest/History.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/views/backtest/Optimize.vue b/frontend/src/views/backtest/Optimize.vue new file mode 100644 index 0000000..8b890e3 --- /dev/null +++ b/frontend/src/views/backtest/Optimize.vue @@ -0,0 +1,133 @@ + + + diff --git a/sanguo_api/routes.py b/sanguo_api/routes.py index b64665c..827de16 100644 --- a/sanguo_api/routes.py +++ b/sanguo_api/routes.py @@ -120,7 +120,7 @@ def get_status(task_id: str): "task_id": task_id, "status": s.value if hasattr(s, "value") else str(s), "stage": stage or "", - "error_msg": task.error_msg if task else None, + "error_msg": task.error_msg if (task and isinstance(task.error_msg, str)) else None, } @@ -266,4 +266,44 @@ def factor_report(task_id: str, factor: str, token: str = Query(...)): path = paths.get(factor) if not path or not os.path.exists(path): raise HTTPException(status_code=404, detail=f"report for {factor} not found") - return FileResponse(path) \ No newline at end of file + return FileResponse(path) + + +# ===== History + Optimization endpoints (S3) ===== + +@router.get("/task", dependencies=[Depends(verify_token)]) +def list_tasks(type: str | None = None, status: str | None = None): + """List historical tasks (from the results DB).""" + from sanguo_backtest.result_store import list_results + orch = get_orchestrator() + items = [] + for r in list_results(type_filter=type, db_path=orch.db_path): + if status and r.status != status: + continue + items.append({ + "id": r.id, + "task_id": r.task_id, + "type": r.type, + "status": r.status, + "strategy": r.strategy, + "symbol": r.symbol, + "start": r.start, + "end": r.end, + }) + items.reverse() # newest first + return {"tasks": items} + + +@router.get("/task/{task_id}/optimization-results", dependencies=[Depends(verify_token)]) +def optimization_results(task_id: str): + """Optimization results: list of {params, statistics} per parameter combo.""" + raw = get_orchestrator().get_raw_result(task_id) + if raw is None: + raise HTTPException(status_code=404, detail="optimization results not ready") + rows = [] + for r in (raw if isinstance(raw, list) else [raw]): + rows.append({ + "params": getattr(r, "params", {}), + "statistics": getattr(r, "statistics", {}), + }) + return {"task_id": task_id, "results": rows} \ No newline at end of file diff --git a/sanguo_backtest/cta_optimizer.py b/sanguo_backtest/cta_optimizer.py index e885000..a35192b 100644 --- a/sanguo_backtest/cta_optimizer.py +++ b/sanguo_backtest/cta_optimizer.py @@ -64,19 +64,29 @@ def run_cta_optimization( # Set parameters with A-share specific values (same as cta_engine) engine.set_parameters( vt_symbol=vt_symbol, - interval="1d", # Daily interval for A-shares + interval="d", # Interval.DAILY.value (vnpy enum uses "d" not "1d") start=start_dt, end=end_dt, rate=0.001, # Commission rate (0.1% for A-shares) slippage=0, # No slippage for simplicity size=1, # Contract size (1 for stocks) pricetick=0.01, # Minimum price tick (0.01 yuan for A-shares) - capital=0 # No initial capital limit + capital=1_000_000, # 0 causes instant liquidation on first trade ) # Add strategy without parameters (will be set by optimization) engine.add_strategy(strategy_class, {}) + # Configure vnpy DB → quant_trading.db (worker process; spawn isolation). + try: + from vnpy.trader.setting import SETTINGS + from sanguo_data.config import load_config, find_config_path + _dcfg = load_config(find_config_path()) + SETTINGS["database.name"] = "sqlite" + SETTINGS["database.database"] = _dcfg.data_paths["vnpy_db"] + except Exception: + pass + # Load historical data engine.load_data() diff --git a/sanguo_backtest/result_store.py b/sanguo_backtest/result_store.py index e152636..112d68e 100644 --- a/sanguo_backtest/result_store.py +++ b/sanguo_backtest/result_store.py @@ -155,6 +155,21 @@ def load_result(rid: int, db_path: str) -> BacktestResult: conn.close() +def load_result_by_task_id(task_id: str, db_path: str) -> BacktestResult | None: + """Load the most recent result for a task_id (historical lookup after restart).""" + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT id FROM backtest_stats WHERE task_id=? ORDER BY id DESC LIMIT 1", + (task_id,), + ).fetchone() + if not row: + return None + return load_result(row[0], db_path) + finally: + conn.close() + + def list_results(type_filter: Optional[str] = None, db_path: str = "") -> list[BacktestResult]: """ List all backtest results, optionally filtered by type. diff --git a/sanguo_orchestrator/runner.py b/sanguo_orchestrator/runner.py index 7866b95..aa9c0e8 100644 --- a/sanguo_orchestrator/runner.py +++ b/sanguo_orchestrator/runner.py @@ -143,13 +143,15 @@ class Orchestrator: return self.pool.get_status(task_id) def get_result(self, task_id: str): - """Get task result by ID (lazy import)""" + """Get task result by ID. Tries in-memory (current run) then DB (history).""" task = self.pool.get_task(task_id) if task and task.status == TaskState.DONE and task.result_id: # Lazy import to avoid vnpy dependency issues from sanguo_backtest.result_store import load_result return load_result(task.result_id, self.db_path) - return None + # Fallback: historical task persisted in DB (e.g. after restart) + from sanguo_backtest.result_store import load_result_by_task_id + return load_result_by_task_id(task_id, self.db_path) def get_raw_result(self, task_id: str): """Get the raw in-memory result object (e.g. FactorReport) by task ID. diff --git a/scripts/smoke_phase3b_optimize.py b/scripts/smoke_phase3b_optimize.py new file mode 100644 index 0000000..7a2be66 --- /dev/null +++ b/scripts/smoke_phase3b_optimize.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Phase 3b S3 smoke: history list + parameter optimization end-to-end.""" +import json +import sys +import time +import urllib.request + +BASE = "http://192.168.2.154:8000" + + +def _req(method, path, token=None, body=None): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read()) + + +def main() -> int: + tok = _req("POST", "/api/v1/auth/login", body={"username": "admin", "password": "admin"})["token"] + + tasks = _req("GET", "/api/v1/task", token=tok)["tasks"] + print(f"[history] {len(tasks)} tasks; latest: {tasks[0]['task_id'] if tasks else 'none'}") + assert isinstance(tasks, list) + + sub = _req("POST", "/api/v1/backtest/optimize", token=tok, body={ + "symbol": "600000", + "strategy": "DoubleMaStrategy", + "grid": {"fast_window": [5, 15, 5], "slow_window": [15, 25, 5]}, + "start": "2024-01-01", + "end": "2024-06-30", + "max_workers": 2, + }) + tid = sub["task_id"] + print(f"[optimize] submitted: {tid}") + + status = "pending" + for i in range(60): + s = _req("GET", f"/api/v1/task/{tid}", token=tok) + status = s["status"] + print(f" [{i:02d}] {status} {s.get('stage', '')}") + if status in ("done", "failed"): + break + time.sleep(3) + + if status != "done": + print(f"[!] optimize failed: {status}") + return 1 + + res = _req("GET", f"/api/v1/task/{tid}/optimization-results", token=tok)["results"] + print(f"[results] {len(res)} param combos") + assert len(res) > 0, "no optimization results" + for r in res[:3]: + print(f" params={r['params']} sharpe={r['statistics'].get('sharpe_ratio')}") + print("SMOKE PASSED") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except AssertionError as e: + print(f"[FAILED] {e}"); sys.exit(2) + except Exception as e: + print(f"[ERROR] {type(e).__name__}: {e}"); sys.exit(3) diff --git a/tests/api/test_routes.py b/tests/api/test_routes.py index 4cec281..1cbbb98 100644 --- a/tests/api/test_routes.py +++ b/tests/api/test_routes.py @@ -22,7 +22,8 @@ def test_submit_cta_backtest(): token = create_token("admin") # Mock get_orchestrator to return mock orchestrator - with patch("sanguo_api.routes.get_orchestrator") as mock_get_orch: + with patch("sanguo_api.routes.get_orchestrator") as mock_get_orch, \ + patch("sanguo_api.routes.get_strategy_class", return_value=Mock()): mock_orch = Mock() mock_orch.submit_cta = AsyncMock(return_value="cta_test_123") mock_get_orch.return_value = mock_orch @@ -148,9 +149,13 @@ def test_get_task_result(): token = create_token("admin") with patch("sanguo_api.routes.get_orchestrator") as mock_get_orch: + from sanguo_backtest.result_store import BacktestResult mock_orch = Mock() - mock_result = Mock() - mock_result.statistics = {"total_trades": 10, "total_return": 0.15} + mock_result = BacktestResult( + task_id="cta_test_123", type="cta", status="done", strategy="S", symbol="600000", + params={}, start="2024-01-01", end="2024-12-31", + statistics={"total_trades": 10, "total_return": 0.15}, + ) mock_orch.get_result.return_value = mock_result mock_get_orch.return_value = mock_orch @@ -205,7 +210,8 @@ def test_submit_optimize_returns_pending(): app = create_app(db_path=db_path, file_dir=None) token = create_token("admin") - with patch("sanguo_api.routes.get_orchestrator") as mock_get_orch: + with patch("sanguo_api.routes.get_orchestrator") as mock_get_orch, \ + patch("sanguo_api.routes.get_strategy_class", return_value=Mock()): mock_orch = Mock() mock_orch.submit_optimize = AsyncMock(return_value="opt_test_123") mock_get_orch.return_value = mock_orch @@ -322,7 +328,8 @@ def test_optimize_route_calls_submit(tmp_path): client = TestClient(app) token = create_token("admin") - with patch("sanguo_api.routes.get_orchestrator") as m: + with patch("sanguo_api.routes.get_orchestrator") as m, \ + patch("sanguo_api.routes.get_strategy_class", return_value=Mock()): orch = Mock() orch.submit_optimize = AsyncMock(return_value="opt_1") m.return_value = orch