feat(frontend): 验收UX批A: CTA回测提交统一跳任务中心; 代码编辑「运行回测」直接发起(默认参数+最近1年,CTA/组合分流); 模拟盘策略改下拉; 状态徽标实走文案+去排队兜底 [nas]
This commit is contained in:
@@ -151,7 +151,8 @@ async function onSubmit(): Promise<void> {
|
|||||||
slippage: Number(form.slippage),
|
slippage: Number(form.slippage),
|
||||||
})
|
})
|
||||||
ElMessage.success('回测已提交')
|
ElMessage.success('回测已提交')
|
||||||
router.push(`/backtest/progress/${tid}`)
|
void tid
|
||||||
|
router.push('/backtest/history')
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('提交失败')
|
ElMessage.error('提交失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -165,7 +166,7 @@ async function onSubmit(): Promise<void> {
|
|||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">个股回测</h2>
|
<h2 class="page-title">个股回测</h2>
|
||||||
<p class="page-subtitle">CTA 策略历史回测 · 选择策略与标的,提交后进入进度跟踪</p>
|
<p class="page-subtitle">CTA 策略历史回测 · 提交后可在「历史任务」跟踪进度与结果</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const stats = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const modeLabel: Record<string, string> = { replay: '回放', live: '实走' }
|
const modeLabel: Record<string, string> = { replay: '回放', live: '实走' }
|
||||||
const statusLabel: Record<string, string> = { running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队' }
|
const statusLabel: Record<string, string> = { running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队', stopped: '已停止' }
|
||||||
|
|
||||||
function pct(v: number | null | undefined): string {
|
function pct(v: number | null | undefined): string {
|
||||||
if (v == null || !Number.isFinite(v)) return '—'
|
if (v == null || !Number.isFinite(v)) return '—'
|
||||||
|
|||||||
@@ -4,11 +4,21 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { createPaper, type PaperCreate } from '@/api/paper'
|
import { createPaper, type PaperCreate } from '@/api/paper'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
|
import { getStrategies, type StrategyItem } from '@/api/strategy'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const fromInstance = ref('')
|
const fromInstance = ref('')
|
||||||
|
const strategyOptions = ref<StrategyItem[]>([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
strategyOptions.value = await getStrategies()
|
||||||
|
} catch {
|
||||||
|
/* 下拉加载失败不阻塞表单 */
|
||||||
|
}
|
||||||
|
})
|
||||||
function loadDateRange(): { start: string; end: string } {
|
function loadDateRange(): { start: string; end: string } {
|
||||||
try {
|
try {
|
||||||
const s = localStorage.getItem('paper_date_range')
|
const s = localStorage.getItem('paper_date_range')
|
||||||
@@ -137,9 +147,17 @@ function onSymbols(v: string): void {
|
|||||||
/>
|
/>
|
||||||
<span class="muted form-hint">多个标的用英文逗号分隔</span>
|
<span class="muted form-hint">多个标的用英文逗号分隔</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="策略名">
|
<el-form-item label="策略">
|
||||||
<el-input v-model="form.strategies[0].name" style="width: 280px" />
|
<el-select
|
||||||
<span class="muted form-hint">CTA 策略类名,如 DoubleMaStrategy</span>
|
v-model="form.strategies[0].name"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
placeholder="选择或输入策略类名"
|
||||||
|
style="width: 320px"
|
||||||
|
>
|
||||||
|
<el-option v-for="s in strategyOptions" :key="s.name" :label="s.name" :value="s.name" />
|
||||||
|
</el-select>
|
||||||
|
<span class="muted form-hint">CTA 策略(个股),如 DoubleMaStrategy</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="撮合时点">
|
<el-form-item label="撮合时点">
|
||||||
<el-select v-model="form.strategies[0].match_session" style="width: 280px">
|
<el-select v-model="form.strategies[0].match_session" style="width: 280px">
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function num(v: number | undefined | null): string {
|
|||||||
return Math.round(v).toLocaleString('zh-CN')
|
return Math.round(v).toLocaleString('zh-CN')
|
||||||
}
|
}
|
||||||
function statusLabel(s: string): string {
|
function statusLabel(s: string): string {
|
||||||
return ({ running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队' } as Record<string, string>)[s] || s
|
return ({ running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队', stopped: '已停止' } as Record<string, string>)[s] || s
|
||||||
}
|
}
|
||||||
function rowClass({ row }: { row: PaperTrade }): string {
|
function rowClass({ row }: { row: PaperTrade }): string {
|
||||||
return row.rejected ? 'rejected-row' : ''
|
return row.rejected ? 'rejected-row' : ''
|
||||||
@@ -119,7 +119,11 @@ function toLive(): void {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<span class="chip" :class="`st-${account?.status ?? 'pending'}`">{{ statusLabel(account?.status ?? '') }}</span>
|
<span v-if="account" class="chip" :class="`st-${account.status}`">{{
|
||||||
|
account.mode === 'live' && account.status === 'running'
|
||||||
|
? '实走中 · 每日 20:30 结算'
|
||||||
|
: statusLabel(account.status)
|
||||||
|
}}</span>
|
||||||
<el-button v-if="account?.mode === 'live'" type="primary" @click="toLive">实走监控 →</el-button>
|
<el-button v-if="account?.mode === 'live'" type="primary" @click="toLive">实走监控 →</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import MonacoEditor from '@/components/MonacoEditor.vue'
|
import MonacoEditor from '@/components/MonacoEditor.vue'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
|
import { getParams } from '@/api/strategy'
|
||||||
|
import { submitCta } from '@/api/backtest'
|
||||||
|
import { postPortfolioBacktest } from '@/api/portfolio'
|
||||||
|
|
||||||
interface StrategyFile {
|
interface StrategyFile {
|
||||||
name: string
|
name: string
|
||||||
@@ -76,9 +79,72 @@ async function onSave(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runBacktest(): void {
|
const running = ref(false)
|
||||||
if (active.value) {
|
|
||||||
router.push({ path: '/backtest/new', query: { class: active.value.class_name } })
|
function lastDateRange(): { start: string; end: string } {
|
||||||
|
try {
|
||||||
|
const s = localStorage.getItem('bt_date_range')
|
||||||
|
if (s) return JSON.parse(s) as { start: string; end: string }
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date(end)
|
||||||
|
start.setFullYear(start.getFullYear() - 1)
|
||||||
|
const f = (d: Date): string => d.toISOString().slice(0, 10)
|
||||||
|
return { start: f(start), end: f(end) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接用当前策略发起回测:默认参数 + 最近区间,提交后跳「历史任务」看进度
|
||||||
|
async function runBacktest(): Promise<void> {
|
||||||
|
if (!active.value || running.value) return
|
||||||
|
if (dirty.value) {
|
||||||
|
ElMessage.warning('有未保存修改,请先保存再运行回测')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running.value = true
|
||||||
|
try {
|
||||||
|
const dr = lastDateRange()
|
||||||
|
if (active.value.type === 'portfolio') {
|
||||||
|
await postPortfolioBacktest({
|
||||||
|
pool: 'hs300_subset',
|
||||||
|
strategy: active.value.name.replace(/\.py$/, ''),
|
||||||
|
max_pool: 30,
|
||||||
|
start_date: dr.start,
|
||||||
|
end_date: dr.end,
|
||||||
|
initial_cash: 1_000_000,
|
||||||
|
benchmark: '000300.XSHG',
|
||||||
|
commission_rate: 0.0003,
|
||||||
|
stamp_duty_rate: 0.001,
|
||||||
|
min_commission: 5,
|
||||||
|
slippage: 0.001,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const p = await getParams(active.value.class_name)
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
p.parameters.forEach((k) => {
|
||||||
|
const v = p.defaults[k]
|
||||||
|
params[k] = v != null ? v : ''
|
||||||
|
})
|
||||||
|
await submitCta({
|
||||||
|
symbol: '600000',
|
||||||
|
strategy: active.value.class_name,
|
||||||
|
params,
|
||||||
|
start: dr.start,
|
||||||
|
end: dr.end,
|
||||||
|
benchmark: 'hs300',
|
||||||
|
interval: 'd',
|
||||||
|
commission_rate: 0.0003,
|
||||||
|
stamp_duty_rate: 0.001,
|
||||||
|
slippage: 0.001,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ElMessage.success('回测已提交,可在「历史任务」查看进度')
|
||||||
|
router.push('/backtest/history')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('回测提交失败')
|
||||||
|
} finally {
|
||||||
|
running.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -125,7 +191,7 @@ function runBacktest(): void {
|
|||||||
<span class="modified mono">{{ active?.modified }}</span>
|
<span class="modified mono">{{ active?.modified }}</span>
|
||||||
<button class="term-btn sm" :disabled="checking" @click="onCheck">{{ checking ? '校验中' : '语法检查' }}</button>
|
<button class="term-btn sm" :disabled="checking" @click="onCheck">{{ checking ? '校验中' : '语法检查' }}</button>
|
||||||
<button class="term-btn sm primary" :disabled="!dirty || saving" @click="onSave">{{ saving ? '保存中' : '保存' }}</button>
|
<button class="term-btn sm primary" :disabled="!dirty || saving" @click="onSave">{{ saving ? '保存中' : '保存' }}</button>
|
||||||
<button class="term-btn sm" @click="runBacktest">运行回测</button>
|
<button class="term-btn sm" :disabled="running" @click="runBacktest">{{ running ? '提交中' : '运行回测' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="monaco-wrap">
|
<div class="monaco-wrap">
|
||||||
|
|||||||
Reference in New Issue
Block a user