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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user