feat(portfolio): 组合回测异步化(接入 orchestrator task 框架)
CI/CD / test (push) Successful in 10s
CI/CD / nas-deploy (push) Successful in 21s
CI/CD / nas-verify (push) Successful in 3s

routes_portfolio 同步 subprocess.run(timeout=600) >10min 回测被 504 杀。
改异步复用现有 sanguo_orchestrator:submit_portfolio 仿 submit_factor,
子进程执行搬至 portfolio_worker.py(三机自适应保留,timeout 600→3600),
复用 _wait_future/_on_done 持久化。前端 onSubmit 改 POST 拿 task_id
→ 轮询 /task/{id} → done 取 /portfolio/task/{id}/result。

- runner.py: + submit_portfolio + _portfolio_worker(lazy import)
- portfolio_worker.py(新): 三机自适应 argv + 子进程 + stdout JSON → BacktestResult
- routes_portfolio.py: POST async 返 task_id + GET /portfolio/task/{id}/result
- 前端: postPortfolioBacktest 返 task_id + 轮询 status + 取 result(去 600s timeout)

不动策略逻辑/runner_backtest 核心/现有 task 接口。
This commit is contained in:
2026-08-01 13:52:46 +08:00
parent 2ca2facce3
commit 4986e5c3c4
5 changed files with 329 additions and 120 deletions
+25 -3
View File
@@ -58,11 +58,33 @@ export interface PortfolioBacktestResult {
export async function postPortfolioBacktest(
req: PortfolioBacktestReq,
): Promise<PortfolioBacktestResult> {
const { data } = await apiClient.post<PortfolioBacktestResult>(
): Promise<string> {
const { data } = await apiClient.post<{ task_id: string }>(
'/portfolio/backtest',
req,
{ timeout: 600000 },
)
return data.task_id
}
export interface PortfolioTaskStatus {
task_id: string
status: string
stage: string
error_msg: string | null
}
export async function getPortfolioTaskStatus(
taskId: string,
): Promise<PortfolioTaskStatus> {
const { data } = await apiClient.get<PortfolioTaskStatus>(`/task/${taskId}`)
return data
}
export async function getPortfolioResult(
taskId: string,
): Promise<PortfolioBacktestResult> {
const { data } = await apiClient.get<PortfolioBacktestResult>(
`/portfolio/task/${taskId}/result`,
)
return data
}
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
import {
postPortfolioBacktest,
getPortfolioTaskStatus,
getPortfolioResult,
type PortfolioBacktestResult,
type EquityPoint,
type StockPicked,
@@ -21,6 +23,8 @@ const trades = ref<PortfolioTrade[]>([])
const metrics = ref<PortfolioMetrics | null>(null)
const period = ref<{ start: string; end: string; trading_days: number } | null>(null)
const errorMsg = ref('')
const stageHint = ref('')
let pollTimer: ReturnType<typeof setInterval> | null = null
const form = reactive({
pool: 'hs300_subset',
@@ -65,6 +69,9 @@ function renderEquity(): void {
onMounted(() => {
// 等 dom 挂载后渲染(若有初始数据)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
watch(equityCurve, renderEquity, { deep: true, flush: 'post' })
function fmtPct(v: number | null | undefined): string {
@@ -79,19 +86,40 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
async function onSubmit(): Promise<void> {
submitting.value = true
errorMsg.value = ''
stageHint.value = ''
result.value = null
equityCurve.value = []
stocks.value = []
trades.value = []
metrics.value = null
try {
const r = await postPortfolioBacktest({
const tid = await postPortfolioBacktest({
pool: form.pool,
start_date: form.start,
end_date: form.end,
initial_cash: form.cash,
benchmark: form.benchmark,
})
// Poll task status until done/failed, then fetch result
const r = await new Promise<PortfolioBacktestResult>((resolve, reject) => {
pollTimer = setInterval(async () => {
try {
const s = await getPortfolioTaskStatus(tid)
if (s.stage) stageHint.value = s.stage
if (s.status === 'done') {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
const res = await getPortfolioResult(tid)
resolve(res)
} else if (s.status === 'failed') {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
reject(new Error(s.error_msg || '回测失败'))
}
// pending/running: keep polling
} catch (pollErr) {
// transient network error on poll — keep polling
}
}, 2500)
})
result.value = r
equityCurve.value = r.equity_curve || []
stocks.value = r.stocks_selected || []
@@ -173,10 +201,10 @@ async function onSubmit(): Promise<void> {
:loading="submitting"
@click="onSubmit"
>
{{ submitting ? '回测中(VPS 执行,请等待...)' : '开始回测' }}
{{ submitting ? `回测中${stageHint ? '(' + stageHint + ')' : '...'}` : '开始回测' }}
</el-button>
<span v-if="submitting" class="muted form-hint">
最长 600s,期间请勿关闭页面
异步执行,请勿关闭页面
</span>
</div>
</el-card>