feat(s3): 历史任务 + 参数优化端到端跑通

- 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 组合
This commit is contained in:
2026-07-07 06:35:54 +08:00
parent 212ad6426d
commit 54fc1b656f
11 changed files with 386 additions and 14 deletions
+42
View File
@@ -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<TaskListItem[]> {
const { data } = await apiClient.get<{ tasks: TaskListItem[] }>('/task', { params: type ? { type } : {} })
return data.tasks
}
export interface OptimizeSubmit {
symbol: string
strategy: string
grid: Record<string, [number, number, number]>
start: string
end: string
}
export async function submitOptimize(req: OptimizeSubmit): Promise<string> {
const { data } = await apiClient.post<{ task_id: string }>('/backtest/optimize', req)
return data.task_id
}
export interface OptRow {
params: Record<string, unknown>
statistics: Record<string, unknown>
}
export async function getOptimizationResults(taskId: string): Promise<OptRow[]> {
const { data } = await apiClient.get<{ results: OptRow[] }>(`/task/${taskId}/optimization-results`)
return data.results
}
+2
View File
@@ -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') },
+6 -3
View File
@@ -20,9 +20,12 @@ function onLogout(): void {
<el-aside width="200px" class="sidebar">
<div class="logo">三国量化</div>
<el-menu :default-active="$route.path" @select="navigate">
<el-menu-item index="/backtest/new">
<span>📊 回测</span>
</el-menu-item>
<el-sub-menu index="backtest">
<template #title><span>📊 回测</span></template>
<el-menu-item index="/backtest/new">新建回测</el-menu-item>
<el-menu-item index="/backtest/optimize">参数优化</el-menu-item>
<el-menu-item index="/backtest/history">历史任务</el-menu-item>
</el-sub-menu>
<el-menu-item index="/factor/new">
<span>🔬 投研</span>
</el-menu-item>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getTasks, type TaskListItem } from '@/api/backtest'
const router = useRouter()
const tasks = ref<TaskListItem[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
tasks.value = await getTasks()
} catch {
ElMessage.error('历史加载失败')
} finally {
loading.value = false
}
})
function open(row: TaskListItem): void {
if (row.type === 'factor') {
router.push(`/factor/result/${row.task_id}`)
} else {
router.push(`/backtest/result/${row.task_id}`)
}
}
</script>
<template>
<el-card v-loading="loading">
<template #header>
<h3>历史任务</h3>
</template>
<el-table :data="tasks" stripe size="small" empty-text="暂无历史任务">
<el-table-column prop="task_id" label="任务 ID" min-width="220" />
<el-table-column prop="type" label="类型" width="80" />
<el-table-column prop="strategy" label="策略/因子" width="160" />
<el-table-column prop="symbol" label="标的" width="100" />
<el-table-column prop="status" label="状态" width="80" />
<el-table-column prop="start" label="开始" width="110" />
<el-table-column prop="end" label="结束" width="110" />
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="primary" @click="open(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getStrategies, type StrategyItem } from '@/api/strategy'
import { submitOptimize, getOptimizationResults, getStatus, type OptRow } from '@/api/backtest'
const strategies = ref<StrategyItem[]>([])
const loading = ref(false)
const submitting = ref(false)
const polling = ref(false)
const stageText = ref('')
const results = ref<OptRow[]>([])
const form = reactive({
strategy: '',
symbol: '600000',
start: '2024-01-01',
end: '2024-06-30',
gridText: 'fast_window,5,20,5\nslow_window,15,40,5',
})
onMounted(async () => {
loading.value = true
try {
strategies.value = await getStrategies()
if (strategies.value.length && !form.strategy) form.strategy = strategies.value[0].name
} finally {
loading.value = false
}
})
function parseGrid(): Record<string, [number, number, number]> | null {
const grid: Record<string, [number, number, number]> = {}
for (const line of form.gridText.split('\n')) {
const parts = line.trim().split(/[,\s]+/).filter(Boolean)
if (parts.length !== 4) continue
grid[parts[0]] = [Number(parts[1]), Number(parts[2]), Number(parts[3])]
}
if (!Object.keys(grid).length) {
ElMessage.warning('参数网格格式:每行 name,start,end,step')
return null
}
return grid
}
function statColumns(rows: OptRow[]): string[] {
const set = new Set<string>()
rows.forEach((r) => Object.keys(r.statistics || {}).forEach((k) => set.add(k)))
return Array.from(set)
}
function fmt(v: unknown): string {
return typeof v === 'number' ? (Math.round(v * 10000) / 10000).toString() : v == null ? '' : String(v)
}
async function onSubmit(): Promise<void> {
const grid = parseGrid()
if (!grid) return
submitting.value = true
results.value = []
try {
const tid = await submitOptimize({
symbol: form.symbol, strategy: form.strategy, grid,
start: form.start, end: form.end,
})
ElMessage.success('优化已提交,轮询中…')
submitting.value = false
polling.value = true
let status = 'pending'
for (let i = 0; i < 60; i++) {
const s = await getStatus(tid)
status = s.status
stageText.value = s.stage
if (status === 'done' || status === 'failed') break
await new Promise((r) => setTimeout(r, 3000))
}
polling.value = false
if (status !== 'done') {
ElMessage.error('优化未完成: ' + status)
return
}
results.value = await getOptimizationResults(tid)
} catch {
ElMessage.error('提交失败')
} finally {
submitting.value = false
polling.value = false
}
}
</script>
<template>
<el-card v-loading="loading || polling" :element-loading-text="stageText || '优化中…'">
<template #header>
<h3>参数优化</h3>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="策略">
<el-select v-model="form.strategy" style="width: 280px">
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item label="标的">
<el-input v-model="form.symbol" style="width: 220px" />
</el-form-item>
<el-form-item label="参数网格">
<el-input v-model="form.gridText" type="textarea" :rows="3" placeholder="每行:name,start,end,step" />
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交优化</el-button>
</el-form-item>
</el-form>
<el-table v-if="results.length" :data="results" stripe size="small" style="margin-top: 16px">
<el-table-column label="参数">
<template #default="{ row }">
<span v-for="(v, k) in row.params" :key="String(k)" style="margin-right: 8px">
{{ k }}={{ fmt(v) }}
</span>
</template>
</el-table-column>
<el-table-column v-for="k in statColumns(results)" :key="k" :label="k">
<template #default="{ row }">{{ fmt(row.statistics?.[k]) }}</template>
</el-table-column>
</el-table>
</el-card>
</template>
+42 -2
View File
@@ -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)
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}
+12 -2
View File
@@ -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()
+15
View File
@@ -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.
+4 -2
View File
@@ -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.
+67
View File
@@ -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)
+12 -5
View File
@@ -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