feat(portfolio): 组合回测异步化(接入 orchestrator task 框架)
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user