feat(frontend): 终端化重构+策略管理(代码→实例→4运行)+撮合设计+回测参数 [nas]
CI/CD / test (push) Successful in 18s
CI/CD / nas-deploy (push) Failing after 17s
CI/CD / nas-verify (push) Has been skipped

- 终端化: 科幻色板(tokens/EP/reset/chips/echarts)+Layout(Cmd+K/时钟/状态灯)+mock全接口+Dashboard/Result/Monitor样板+6图表配色
- 策略管理: 策略库三层(代码→实例→4运行)+新建策略(组合/CTA模板+Monaco)+在线代码编辑(Monaco)+实例CRUD(参数反射/标的池type切/interval/match_session)
- 实例→运行关联: backtest/paper/live New页读instance预填
- 撮合设计: match_session(下一根K线开盘/收盘/集合竞价)+interval定频率+实走限日线/分钟走miniQMT
- 回测参数: 基准下拉+日期默认1年localStorage记忆+滑点/手续费UI
- 账户管理页+命名重整(模拟盘/实盘,消除撞名)
- spec §11-13(菜单/策略管理/撮合设计) + Monaco编辑器 + 策略实例删除/编辑入口 + 列表实例列
This commit is contained in:
2026-08-12 23:23:23 +08:00
parent 58b5d08280
commit 5c8a7e12c6
45 changed files with 3935 additions and 459 deletions
+4 -2
View File
@@ -47,7 +47,7 @@ function open(row: TaskListItem): void {
router.push(path)
}
const typeLabelMap: Record<string, string> = { cta: '回测', optimize: '优化', factor: '因子' }
const typeLabelMap: Record<string, string> = { cta: '回测', optimize: '优化', factor: '因子', portfolio: '组合' }
function typeLabel(t: string): string {
return typeLabelMap[t] || t
}
@@ -197,7 +197,9 @@ function statusLabel(s: string): string {
width: 36px;
height: 36px;
border-radius: var(--r-sm);
background: rgba(24, 144, 255, 0.14);
background: var(--cyan-soft);
border: 1px solid rgba(0, 229, 255, 0.3);
box-shadow: 0 0 12px rgba(0, 229, 255, 0.15);
color: var(--brand);
font-weight: 700;
font-size: 13px;
+358 -61
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
@@ -7,16 +7,21 @@ const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
// 实走监控入口需要 aid,从模拟结果页进入;侧栏保持分组激活即可
const activeMenu = computed(() => {
const p = route.path
if (p.startsWith('/paper')) return p
return p
})
// 顶栏动态页名(按路由推断,替代静态口号占位)
const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
{ match: /^\/dashboard$/, group: '工作台', title: '工作台' },
{ match: /^\/strategy\/new$/, group: '策略', title: '新建策略' },
{ match: /^\/strategy\/code$/, group: '策略', title: '代码编辑' },
{ match: /^\/strategy\/instance\/new$/, group: '策略', title: '新建实例' },
{ match: /^\/strategy\/instance\//, group: '策略', title: '编辑实例' },
{ match: /^\/strategy\/edit\//, group: '策略', title: '编辑策略' },
{ match: /^\/strategy$/, group: '策略', title: '策略库' },
{ match: /^\/account$/, group: '账户', title: '账户管理' },
{ match: /^\/backtest\/new$/, group: '回测', title: '新建回测' },
{ match: /^\/backtest\/portfolio$/, group: '回测', title: '组合回测' },
{ match: /^\/backtest\/optimize$/, group: '回测', title: '参数优化' },
@@ -26,23 +31,104 @@ const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
{ match: /^\/factor\/new$/, group: '投研', title: '因子分析' },
{ match: /^\/factor\/progress\//, group: '投研', title: '因子分析进度' },
{ match: /^\/factor\/result\//, group: '投研', title: '因子分析结果' },
{ match: /^\/paper\/new$/, group: '模拟', title: '新建模拟盘' },
{ match: /^\/paper\/result\//, group: '模拟', title: '模拟盘结果' },
{ match: /^\/paper\/live\//, group: '模拟', title: '实走监控' },
{ match: /^\/paper$/, group: '模拟', title: '模拟交易' },
{ match: /^\/live\/new$/, group: '实盘模拟', title: '新建实盘' },
{ match: /^\/live\/monitor\//, group: '实盘模拟', title: '实盘监控' },
{ match: /^\/live$/, group: '实盘模拟', title: '实盘模拟' },
{ match: /^\/paper\/new$/, group: '模拟', title: '新建模拟盘' },
{ match: /^\/paper\/result\//, group: '模拟', title: '模拟盘结果' },
{ match: /^\/paper\/live\//, group: '模拟', title: '实走监控' },
{ match: /^\/paper$/, group: '模拟', title: '模拟' },
{ match: /^\/live\/new$/, group: '实盘', title: '新建实盘' },
{ match: /^\/live\/monitor\//, group: '实盘', title: '实盘监控' },
{ match: /^\/live$/, group: '实盘', title: '实盘' },
]
const pageMeta = computed(() => {
const p = route.path
return PAGE_TITLE.find((r) => r.match.test(p)) ?? { group: '', title: '控制台' }
})
/* —— 实时行情时钟 —— */
const clock = ref('--:--:--')
let timer = 0
const pad = (n: number): string => String(n).padStart(2, '0')
const tick = (): void => {
const d = new Date()
clock.value = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/* —— Cmd+K 命令面板 —— */
interface CmdItem { label: string; group: string; path: string }
const CMD_ITEMS: CmdItem[] = [
{ label: '工作台', group: '总览', path: '/dashboard' },
{ label: '策略库', group: '策略', path: '/strategy' },
{ label: '新建策略', group: '策略', path: '/strategy/new' },
{ label: '策略代码编辑', group: '策略', path: '/strategy/code' },
{ label: '账户管理', group: '账户', path: '/account' },
{ label: '新建回测', group: '回测', path: '/backtest/new' },
{ label: '组合回测', group: '回测', path: '/backtest/portfolio' },
{ label: '参数优化', group: '回测', path: '/backtest/optimize' },
{ label: '历史任务', group: '回测', path: '/backtest/history' },
{ label: '因子分析', group: '投研', path: '/factor/new' },
{ label: '模拟盘列表', group: '模拟', path: '/paper' },
{ label: '新建模拟盘', group: '模拟', path: '/paper/new' },
{ label: '实盘列表', group: '实盘', path: '/live' },
{ label: '新建实盘', group: '实盘', path: '/live/new' },
]
const cmdOpen = ref(false)
const cmdQuery = ref('')
const cmdIndex = ref(0)
const cmdFiltered = computed<CmdItem[]>(() => {
const q = cmdQuery.value.trim().toLowerCase()
if (!q) return CMD_ITEMS
return CMD_ITEMS.filter(
(c) => c.label.toLowerCase().includes(q) || c.group.toLowerCase().includes(q) || c.path.includes(q),
)
})
function openCmd(): void {
cmdOpen.value = true
cmdQuery.value = ''
cmdIndex.value = 0
}
function closeCmd(): void {
cmdOpen.value = false
}
function runCmd(item: CmdItem | undefined): void {
if (!item) return
closeCmd()
router.push(item.path)
}
function onKeydown(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
cmdOpen.value ? closeCmd() : openCmd()
return
}
if (!cmdOpen.value) return
const list = cmdFiltered.value
if (e.key === 'Escape') {
closeCmd()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
cmdIndex.value = Math.min(cmdIndex.value + 1, list.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
cmdIndex.value = Math.max(cmdIndex.value - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
runCmd(list[cmdIndex.value])
}
}
onMounted(() => {
tick()
timer = window.setInterval(tick, 1000)
window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
clearInterval(timer)
window.removeEventListener('keydown', onKeydown)
})
function navigate(path: string): void {
router.push(path)
}
function onLogout(): void {
auth.logout()
router.push('/login')
@@ -51,67 +137,121 @@ function onLogout(): void {
<template>
<el-container class="layout">
<aside class="sidebar">
<aside class="sidebar term-grid">
<div class="logo">
<span class="logo-mark">SG</span>
<span class="logo-text">三国量化</span>
<div class="logo-txt">
<span class="logo-text">三国量化</span>
<span class="logo-sub">QUANT TERMINAL</span>
</div>
</div>
<div class="sys-status">
<span class="lamp lamp-ok lamp-pulse"></span>
<span class="sys-status-txt">SYSTEM ONLINE</span>
</div>
<el-menu :default-active="activeMenu" class="nav" @select="navigate">
<el-menu-item index="/dashboard"><span class="nav-label">工作台</span></el-menu-item>
<el-sub-menu index="strategy">
<template #title><span class="nav-label">策略</span></template>
<el-menu-item index="/strategy">策略库</el-menu-item>
<el-menu-item index="/strategy/new">新建策略</el-menu-item>
<el-menu-item index="/strategy/code">代码编辑</el-menu-item>
</el-sub-menu>
<el-sub-menu index="backtest">
<template #title>
<span class="nav-label">回测</span>
</template>
<template #title><span class="nav-label">回测</span></template>
<el-menu-item index="/backtest/new">新建回测</el-menu-item>
<el-menu-item index="/backtest/portfolio">组合回测</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-sub-menu index="factor">
<template #title>
<span class="nav-label">投研</span>
</template>
<template #title><span class="nav-label">投研</span></template>
<el-menu-item index="/factor/new">因子分析</el-menu-item>
</el-sub-menu>
<el-sub-menu index="paper">
<template #title>
<span class="nav-label">模拟</span>
</template>
<template #title><span class="nav-label">模拟盘</span></template>
<el-menu-item index="/paper">模拟盘列表</el-menu-item>
<el-menu-item index="/paper/new">新建模拟盘</el-menu-item>
</el-sub-menu>
<el-sub-menu index="live">
<template #title>
<span class="nav-label">实盘模拟</span>
</template>
<template #title><span class="nav-label">实盘</span></template>
<el-menu-item index="/live">实盘列表</el-menu-item>
<el-menu-item index="/live/new">新建实盘</el-menu-item>
</el-sub-menu>
<el-menu-item index="/account"><span class="nav-label">账户</span></el-menu-item>
</el-menu>
<div class="sidebar-foot">v2 · 量化研究台</div>
<div class="sidebar-foot">
<span class="lamp lamp-ok"></span> v2 · 终端化
</div>
</aside>
<el-container direction="vertical">
<header class="header">
<div class="sys-crumb">
<span v-if="pageMeta.group" class="sys-group">{{ pageMeta.group }}</span>
<span v-if="pageMeta.group" class="sys-sep">/</span>
<span v-if="pageMeta.group" class="sys-sep"></span>
<span class="sys-name">{{ pageMeta.title }}</span>
</div>
<div class="user-area">
<span class="user-avatar">{{ (auth.username ?? '?').slice(0, 1).toUpperCase() }}</span>
<span class="user-name">{{ auth.username }}</span>
<div class="status-bar">
<div class="clock">
<span class="clock-time">{{ clock }}</span>
<span class="clock-tz">CST</span>
</div>
<div class="link-status">
<span class="lamp lamp-ok lamp-pulse"></span>
<span class="link-txt">DATA LIVE</span>
</div>
<button class="cmdk" @click="openCmd">
<span class="cmdk-icon"></span>
<span class="cmdk-txt">命令面板</span>
<span class="cmdk-key">K</span>
</button>
<span class="sep">|</span>
<el-button link type="primary" @click="onLogout">登出</el-button>
<div class="user-area">
<span class="user-avatar">{{ (auth.username ?? '?').slice(0, 1).toUpperCase() }}</span>
<span class="user-name">{{ auth.username }}</span>
<el-button link type="primary" @click="onLogout">登出</el-button>
</div>
</div>
</header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
<!-- Cmd+K 命令面板 -->
<div v-if="cmdOpen" class="cmd-overlay" @click.self="closeCmd">
<div class="cmd-box term-corners">
<input
v-model="cmdQuery"
class="cmd-input"
placeholder="跳转到页面…(输入名称或路径)"
autofocus
/>
<div class="cmd-list">
<div
v-for="(item, i) in cmdFiltered"
:key="item.path"
class="cmd-item"
:class="{ active: i === cmdIndex }"
@click="runCmd(item)"
@mouseenter="cmdIndex = i"
>
<span class="cmd-label">{{ item.label }}</span>
<span class="cmd-grp">{{ item.group }} · {{ item.path }}</span>
</div>
<div v-if="cmdFiltered.length === 0" class="cmd-empty">无匹配页面</div>
</div>
<div class="cmd-hint">
<span> 导航</span><span> 跳转</span><span>esc 关闭</span>
</div>
</div>
</div>
</el-container>
</template>
@@ -120,18 +260,19 @@ function onLogout(): void {
.sidebar {
width: var(--sidebar-w);
background: var(--bg);
background-color: var(--bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.logo {
height: var(--header-h);
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.logo-mark {
@@ -141,45 +282,71 @@ function onLogout(): void {
width: 26px;
height: 26px;
border-radius: var(--r-sm);
background: var(--brand);
color: #fff;
background: rgba(0, 229, 255, 0.14);
border: 1px solid rgba(0, 229, 255, 0.5);
color: var(--brand);
font-weight: 700;
font-size: 12px;
font-size: 11px;
font-family: var(--mono);
box-shadow: 0 0 12px rgba(0, 229, 255, 0.3);
}
.logo-txt {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.logo-text {
font-size: 15px;
font-size: 14px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.5px;
}
.logo-sub {
font-size: 9px;
color: var(--text-3);
font-family: var(--mono);
letter-spacing: 1.5px;
}
.sys-status {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 16px;
border-bottom: 1px solid var(--border-2);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 1px;
color: var(--lamp-ok);
}
.nav {
flex: 1;
padding: 8px;
border-right: none;
overflow-y: auto;
}
:deep(.nav .el-menu-item),
:deep(.nav .el-sub-menu__title) {
height: 38px;
line-height: 38px;
height: 36px;
line-height: 36px;
border-radius: var(--r-sm);
margin: 2px 0;
font-family: var(--mono);
letter-spacing: 0.3px;
}
.nav-label { font-size: 13px; }
.nav-tip {
color: var(--text-3);
font-style: normal;
font-size: 11px;
margin-left: 4px;
}
.nav-label { font-size: 12.5px; }
.sidebar-foot {
padding: 10px 16px;
border-top: 1px solid var(--border);
font-size: 11px;
font-size: 10.5px;
color: var(--text-3);
font-family: var(--mono);
letter-spacing: 0.8px;
display: flex;
align-items: center;
gap: 7px;
}
.header {
@@ -187,7 +354,7 @@ function onLogout(): void {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
padding: 0 16px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
@@ -195,27 +362,89 @@ function onLogout(): void {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
}
.sys-group {
font-size: 13px;
font-size: 11.5px;
color: var(--text-3);
font-weight: 500;
letter-spacing: 0.8px;
}
.sys-sep {
color: var(--border-2);
color: var(--cyan-dim);
font-size: 13px;
}
.sys-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.4px;
color: var(--brand);
letter-spacing: 0.5px;
}
.status-bar {
display: flex;
align-items: center;
gap: 16px;
}
.clock {
display: flex;
align-items: baseline;
gap: 5px;
font-family: var(--mono);
}
.clock-time {
font-size: 15px;
font-weight: 600;
color: var(--brand);
letter-spacing: 1px;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 12px rgba(0, 229, 255, 0.35);
}
.clock-tz {
font-size: 9px;
color: var(--text-3);
letter-spacing: 1px;
}
.link-status {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.8px;
color: var(--text-2);
}
.cmdk {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 8px 4px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-3);
font-family: var(--mono);
font-size: 11px;
cursor: pointer;
transition: border-color 0.15s var(--ease), color 0.15s var(--ease);
}
.cmdk:hover {
border-color: rgba(0, 229, 255, 0.5);
color: var(--text-2);
}
.cmdk-icon { font-size: 13px; color: var(--text-2); }
.cmdk-key {
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 10px;
color: var(--text-3);
}
.sep { color: var(--border); }
.user-area {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-size: 12.5px;
color: var(--text-2);
}
.user-avatar {
@@ -228,14 +457,82 @@ function onLogout(): void {
background: var(--bg-hover);
border: 1px solid var(--border);
color: var(--text-2);
font-size: 11px;
font-size: 10px;
font-weight: 600;
font-family: var(--mono);
}
.user-name { color: var(--text); }
.sep { color: var(--border); }
.user-name { color: var(--text); font-size: 12px; }
.main {
background: var(--bg);
padding: 20px;
padding: 16px;
}
/* —— Cmd+K 命令面板 —— */
.cmd-overlay {
position: fixed;
inset: 0;
background: rgba(2, 4, 9, 0.7);
backdrop-filter: blur(4px);
z-index: 2000;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 12vh;
}
.cmd-box {
width: 540px;
max-width: 92vw;
background: var(--bg-overlay);
border: 1px solid rgba(0, 229, 255, 0.3);
border-radius: var(--r-md);
box-shadow: var(--shadow), 0 0 48px rgba(0, 229, 255, 0.12);
overflow: hidden;
}
.cmd-input {
width: 100%;
box-sizing: border-box;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text);
padding: 14px 18px;
font-size: 14px;
font-family: var(--mono);
outline: none;
}
.cmd-input::placeholder { color: var(--text-3); }
.cmd-input:focus { border-bottom-color: var(--brand); }
.cmd-list {
max-height: 320px;
overflow-y: auto;
padding: 6px;
}
.cmd-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
border-radius: var(--r-sm);
cursor: pointer;
color: var(--text-2);
}
.cmd-item.active,
.cmd-item:hover {
background: var(--cyan-soft);
color: var(--brand);
}
.cmd-label { font-size: 13px; }
.cmd-grp { font-size: 10.5px; color: var(--text-3); font-family: var(--mono); }
.cmd-empty { padding: 20px; text-align: center; color: var(--text-3); font-size: 12px; }
.cmd-hint {
padding: 8px 16px;
border-top: 1px solid var(--border);
font-size: 10px;
color: var(--text-3);
font-family: var(--mono);
display: flex;
gap: 16px;
letter-spacing: 0.5px;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { listLives, type LiveAccount } from '@/api/live'
const router = useRouter()
const list = ref<LiveAccount[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
list.value = await listLives()
} finally {
loading.value = false
}
})
function lamp(s: string): string {
if (s === 'running') return 'lamp-ok lamp-pulse'
if (s === 'error') return 'lamp-crit'
return 'lamp-idle'
}
function stTxt(s: string): string {
return ({ running: '运行中', stopped: '已停止', error: '错误' } as Record<string, string>)[s] || s
}
function num(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return Math.round(v).toLocaleString('zh-CN')
}
function pct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return (v * 100).toFixed(2) + '%'
}
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-head">
<div>
<h2 class="page-title">账户管理</h2>
<p class="page-subtitle">交易账户 · 多账户 / miniQMT 实例统一视图</p>
</div>
<button class="term-btn primary" @click="router.push('/live/new')">+ 接入账户</button>
</div>
<el-card shadow="never">
<el-table :data="list" size="small">
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="lamp" :class="lamp(row.status)"></span>
<span class="mono st-txt">{{ stTxt(row.status) }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="account" label="账号" width="130" class-name="num" />
<el-table-column prop="vt_symbol" label="标的" width="110" />
<el-table-column prop="strategy_name" label="策略" width="100" />
<el-table-column label="初始资金" width="120" align="right">
<template #default="{ row }"><span class="mono">{{ num(row.initial_capital) }}</span></template>
</el-table-column>
<el-table-column label="最新净值" width="130" align="right">
<template #default="{ row }"><span class="mono">{{ num(row.latest_equity) }}</span></template>
</el-table-column>
<el-table-column label="累计收益" width="110" align="right">
<template #default="{ row }">
<span class="mono" :class="(row.total_return ?? 0) >= 0 ? 'up' : 'down'">{{ pct(row.total_return) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="primary" @click="router.push(`/live/monitor/${row.id}`)">监控</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; }
.st-txt { margin-left: 6px; font-size: 12px; color: var(--text-2); }
</style>
+3
View File
@@ -128,6 +128,9 @@ function statusLabel(s: string): string {
</template>
</el-table-column>
<el-table-column prop="strategy" label="策略 / 因子" min-width="160" show-overflow-tooltip />
<el-table-column prop="instance" label="实例" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
</el-table-column>
<el-table-column prop="symbol" label="标的" width="92" />
<el-table-column label="状态" width="84">
<template #default="{ row }">
+118 -21
View File
@@ -1,33 +1,116 @@
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getStrategies, getParams, type StrategyItem } from '@/api/strategy'
import { submitCta } from '@/api/backtest'
import { apiClient } from '@/api/client'
interface InstLike {
code_file: string
symbol_or_pool: string
interval: string
params: Record<string, unknown>
}
interface FileLike {
name: string
class_name: string
}
const route = useRoute()
const router = useRouter()
const strategies = ref<StrategyItem[]>([])
const loading = ref(false)
const submitting = ref(false)
const paramsList = ref<string[]>([])
const fromInstance = ref('')
let inited = false
const BENCHMARK_OPTIONS = [
{ label: '沪深 300', value: 'hs300' },
{ label: '中证 500', value: 'zz500' },
{ label: '中证 1000', value: 'zz1000' },
{ label: '中证 2000', value: 'zz2000' },
]
function loadDateRange(): { 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) }
}
const dr = loadDateRange()
const form = reactive({
strategy: '',
symbol: '600000',
start: '2024-01-01',
end: '2024-06-30',
start: dr.start,
end: dr.end,
benchmark: 'hs300',
interval: 'd',
commission_rate: 0.0003,
stamp_duty_rate: 0.001,
slippage: 0.001,
})
watch(
() => [form.start, form.end],
([s, e]) => {
try {
localStorage.setItem('bt_date_range', JSON.stringify({ start: s, end: e }))
} catch {
/* ignore */
}
},
)
const paramValues = reactive<Record<string, string>>({})
async function loadParams(cls: string, override: Record<string, unknown> | null): Promise<void> {
if (!cls) return
try {
const p = await getParams(cls)
paramsList.value = p.parameters
Object.keys(paramValues).forEach((k) => delete paramValues[k])
p.parameters.forEach((k) => {
const ov = override && override[k] != null ? override[k] : p.defaults[k]
paramValues[k] = ov != null ? String(ov) : ''
})
} catch {
paramsList.value = []
}
}
onMounted(async () => {
loading.value = true
try {
strategies.value = await getStrategies()
if (strategies.value.length && !form.strategy) {
const instId = route.query.instance
if (instId) {
try {
const [{ data: ir }, { data: fr }] = await Promise.all([
apiClient.get<{ instance: InstLike & { name?: string } }>(`/strategy/instances/${instId}`),
apiClient.get<{ files: FileLike[] }>('/strategy/files'),
])
const inst = ir.instance
fromInstance.value = inst.name || String(instId)
const file = fr.files.find((f) => f.name === inst.code_file)
form.strategy = file?.class_name || inst.code_file
form.symbol = inst.symbol_or_pool || form.symbol
form.interval = inst.interval || form.interval
await loadParams(form.strategy, inst.params)
} catch {
/* 预填失败,走默认 */
}
}
if (!form.strategy && strategies.value.length) {
form.strategy = strategies.value[0].name
}
inited = true
if (!route.query.instance) await loadParams(form.strategy, null)
} catch {
ElMessage.error('策略列表加载失败')
} finally {
@@ -35,19 +118,13 @@ onMounted(async () => {
}
})
watch(() => form.strategy, async (name) => {
if (!name) return
try {
const p = await getParams(name)
paramsList.value = p.parameters
Object.keys(paramValues).forEach((k) => delete paramValues[k])
p.parameters.forEach((k) => {
paramValues[k] = String(p.defaults[k] ?? '')
})
} catch {
paramsList.value = []
}
})
watch(
() => form.strategy,
async (name) => {
if (!inited || !name) return
await loadParams(name, null)
},
)
async function onSubmit(): Promise<void> {
if (!form.strategy || !form.symbol) {
@@ -69,6 +146,9 @@ async function onSubmit(): Promise<void> {
end: form.end,
benchmark: form.benchmark,
interval: form.interval,
commission_rate: Number(form.commission_rate),
stamp_duty_rate: Number(form.stamp_duty_rate),
slippage: Number(form.slippage),
})
ElMessage.success('回测已提交')
router.push(`/backtest/progress/${tid}`)
@@ -136,15 +216,32 @@ async function onSubmit(): Promise<void> {
<template #header><span class="section-title">基准</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="比较基准">
<el-radio-group v-model="form.benchmark">
<el-radio-button value="hs300">沪深 300</el-radio-button>
<el-radio-button value="zz500">中证 500</el-radio-button>
</el-radio-group>
<el-select v-model="form.benchmark" style="width: 220px">
<el-option v-for="b in BENCHMARK_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
<span class="muted form-hint">用于计算 alpha / beta / 超额收益</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">费用与滑点</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="佣金率">
<el-input v-model="form.commission_rate" style="width: 160px" />
<span class="muted form-hint">0.0003 = 万3双边最低 5 </span>
</el-form-item>
<el-form-item label="印花税率">
<el-input v-model="form.stamp_duty_rate" style="width: 160px" />
<span class="muted form-hint">0.001 = 千1仅卖出</span>
</el-form-item>
<el-form-item label="滑点(比率)">
<el-input v-model="form.slippage" style="width: 160px" />
<span class="muted form-hint">0.001 = 万10</span>
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
提交回测
+240 -203
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
getResult, getEquityCurve, getDailyPnl, getTrades, getKline,
getRelativeMetrics, getBenchmarkCurve, getRiskSeries, getLog,
type EquityPoint, type PnlPoint, type Trade, type KlineBar,
type RelativeMetrics, type BenchmarkCurveData, type RiskSeriesData,
type BacktestResultInfo,
} from '@/api/backtest'
import TradesTable from '@/components/TradesTable.vue'
import MetricCards from '@/components/backtest/MetricCards.vue'
@@ -17,10 +19,10 @@ import VolatilityChart from '@/components/backtest/VolatilityChart.vue'
import EquityCurve from '@/components/backtest/EquityCurve.vue'
const route = useRoute()
const router = useRouter()
const taskId = String(route.params.id)
const loading = ref(true)
// New data for enhanced result page
const relativeMetrics = ref<RelativeMetrics>({
total_return: 0,
annual_return: 0,
@@ -37,7 +39,7 @@ const relativeMetrics = ref<RelativeMetrics>({
const benchmarkCurve = ref<BenchmarkCurveData>({ dates: [], strategy: [], benchmark: [] })
const riskSeries = ref<RiskSeriesData>({ dates: [], alpha: [], beta: [], drawdown: [] })
// Existing data
const resultInfo = ref<BacktestResultInfo | null>(null)
const statistics = ref<Record<string, unknown>>({})
const equity = ref<EquityPoint[]>([])
const pnl = ref<PnlPoint[]>([])
@@ -45,16 +47,14 @@ const trades = ref<Trade[]>([])
const kline = ref<KlineBar[]>([])
const logText = ref('')
// statEntries is no longer used in the new layout but kept for potential future use
// const statEntries = computed(() =>
// Object.entries(statistics.value)
// .map(([k, v]) => ({
// key: k,
// value: typeof v === 'number' ? Math.round(v * 10000) / 10000 : v,
// }))
// )
const statusLamp = computed<string>(() => {
const s = resultInfo.value?.status
if (s === 'done') return 'lamp-ok'
if (s === 'running') return 'lamp-warn lamp-pulse'
if (s === 'failed') return 'lamp-crit'
return 'lamp-idle'
})
// Time range filtering
type TimeRange = '1w' | '1m' | '6m' | '1y' | 'all'
const selectedTimeRange = ref<TimeRange>('all')
@@ -84,18 +84,16 @@ function filterDataByTimeRange<T extends { dates: string[] }>(data: T): T {
return data
}
const startIndex = dates.findIndex(date => new Date(date) >= cutoffDate)
const startIndex = dates.findIndex((date) => new Date(date) >= cutoffDate)
if (startIndex === -1) {
const emptyData: any = { dates: [] }
Object.keys(data).forEach(key => {
if (key !== 'dates') {
emptyData[key] = []
}
const emptyData: Record<string, unknown> = { dates: [] }
Object.keys(data).forEach((key) => {
if (key !== 'dates') emptyData[key] = []
})
return emptyData as T
}
const filteredData: any = { dates: dates.slice(startIndex) }
const filteredData: Record<string, unknown> = { dates: dates.slice(startIndex) }
Object.entries(data).forEach(([key, value]) => {
if (key !== 'dates' && Array.isArray(value)) {
filteredData[key] = value.slice(startIndex)
@@ -110,16 +108,10 @@ function filterDataByTimeRange<T extends { dates: string[] }>(data: T): T {
const filteredBenchmarkCurve = computed(() => filterDataByTimeRange(benchmarkCurve.value))
const filteredRiskSeries = computed(() => filterDataByTimeRange(riskSeries.value))
// 净值曲线(走 equity-curve 接口,独立于 benchmark-curve)。benchmark-curve/risk-series
// 依赖 *_metrics.json,CTA 引擎当前未生成该文件 → 收益概述图全空;equity-curve 直接
// 来自 BacktestResult.equity_curve(零成交也有 N 天平线),单独画保证"有结果就有图"。
const equityDates = computed(() => equity.value.map((e) => fmtDate(e.date)))
const equityValues = computed(() => equity.value.map((e) => e.balance))
onMounted(async () => {
// 请求隔离:任一接口失败(如 benchmark-curve/risk-series 数据缺失)不得阻塞
// 其余请求。statistics/equity/trades 有数据时必须正常渲染。用 Promise.allSettled
// 保留并发,逐个取值,失败项保留默认空值 + console.warn。
const settled = await Promise.allSettled([
getResult(taskId),
getRelativeMetrics(taskId),
@@ -132,42 +124,24 @@ onMounted(async () => {
const [rInfo, rRel, rBench, rRisk, rEq, rPnl, rTr] = settled
if (rInfo.status === 'fulfilled') {
resultInfo.value = rInfo.value
statistics.value = rInfo.value.statistics || {}
} else {
console.warn('[Result] getResult failed:', rInfo.reason)
}
if (rRel.status === 'fulfilled') {
relativeMetrics.value = rRel.value
} else {
console.warn('[Result] getRelativeMetrics failed:', rRel.reason)
}
if (rBench.status === 'fulfilled') {
benchmarkCurve.value = rBench.value
} else {
console.warn('[Result] getBenchmarkCurve failed:', rBench.reason)
}
if (rRisk.status === 'fulfilled') {
riskSeries.value = rRisk.value
} else {
console.warn('[Result] getRiskSeries failed:', rRisk.reason)
}
if (rEq.status === 'fulfilled') {
equity.value = rEq.value
} else {
console.warn('[Result] getEquityCurve failed:', rEq.reason)
}
if (rPnl.status === 'fulfilled') {
pnl.value = rPnl.value
} else {
console.warn('[Result] getDailyPnl failed:', rPnl.reason)
}
if (rTr.status === 'fulfilled') {
trades.value = rTr.value
} else {
console.warn('[Result] getTrades failed:', rTr.reason)
}
if (rRel.status === 'fulfilled') relativeMetrics.value = rRel.value
else console.warn('[Result] getRelativeMetrics failed:', rRel.reason)
if (rBench.status === 'fulfilled') benchmarkCurve.value = rBench.value
else console.warn('[Result] getBenchmarkCurve failed:', rBench.reason)
if (rRisk.status === 'fulfilled') riskSeries.value = rRisk.value
else console.warn('[Result] getRiskSeries failed:', rRisk.reason)
if (rEq.status === 'fulfilled') equity.value = rEq.value
else console.warn('[Result] getEquityCurve failed:', rEq.reason)
if (rPnl.status === 'fulfilled') pnl.value = rPnl.value
else console.warn('[Result] getDailyPnl failed:', rPnl.reason)
if (rTr.status === 'fulfilled') trades.value = rTr.value
else console.warn('[Result] getTrades failed:', rTr.reason)
// kline 依赖 getResult 返回的 symbol/start/end,单独隔离
const info = rInfo.status === 'fulfilled' ? rInfo.value : null
if (info?.symbol && info?.start && info?.end) {
try {
@@ -188,7 +162,6 @@ onMounted(async () => {
loading.value = false
})
// 每日收益格式化:浮点精度 → 2 位;日期去 00:00:00
function fmtPnl(v: number | string): string {
const n = typeof v === 'number' ? v : parseFloat(v)
if (!Number.isFinite(n)) return String(v)
@@ -204,187 +177,251 @@ function fmtDate(d: string): string {
const s = String(d).slice(0, 19).replace('T', ' ')
return s.endsWith(' 00:00:00') ? s.slice(0, 10) : s
}
function onClone(): void {
router.push('/backtest/new')
}
function onExport(): void {
ElMessage.info('报告导出(Excel/PDF)· S2 上线')
}
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-head">
<div>
<h2 class="page-title">回测结果 <span class="muted">{{ taskId }}</span></h2>
<p class="page-subtitle">统计指标 / 基准对比 / 风险分析 / 交易明细</p>
<div v-loading="loading" class="result-page">
<!-- 顶栏状态 + 任务元信息 + 操作 -->
<div class="result-head term-corners">
<div class="head-left">
<div class="head-title">
<span class="lamp" :class="statusLamp"></span>
<span class="title-txt">回测结果</span>
<span class="task-id mono">{{ taskId }}</span>
</div>
<div class="head-meta mono">
<span class="meta-code">{{ resultInfo?.symbol || '—' }}</span>
<span class="dim"></span>
<span>{{ resultInfo?.start || '—' }} {{ resultInfo?.end || '—' }}</span>
<span class="dim"></span>
<span>{{ resultInfo?.strategy || '—' }}</span>
</div>
</div>
<div class="head-actions">
<button class="term-btn" @click="onClone">克隆重跑</button>
<button class="term-btn" @click="onExport">导出报告</button>
</div>
</div>
<!-- Top: Metric Cards -->
<!-- 指标塔 -->
<MetricCards :metrics="relativeMetrics" />
<!-- Time Range Selector -->
<div class="time-range-selector">
<span class="selector-label">时间范围</span>
<!-- 工具条时间范围 -->
<div class="toolbar">
<span class="toolbar-label mono">TIME RANGE</span>
<el-radio-group v-model="selectedTimeRange" size="small">
<el-radio-button value="1w">1</el-radio-button>
<el-radio-button value="1m">1</el-radio-button>
<el-radio-button value="6m">6</el-radio-button>
<el-radio-button value="1y">1</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="1w">1W</el-radio-button>
<el-radio-button value="1m">1M</el-radio-button>
<el-radio-button value="6m">6M</el-radio-button>
<el-radio-button value="1y">1Y</el-radio-button>
<el-radio-button value="all">ALL</el-radio-button>
</el-radio-group>
</div>
<!-- Tabs Layout -->
<el-tabs type="border-card">
<!-- Tab 1: 收益概述 -->
<el-tab-pane label="收益概述">
<div class="charts-container">
<el-card>
<EquityCurve :dates="equityDates" :equity="equityValues" />
</el-card>
<el-card>
<BenchmarkCurve
:dates="filteredBenchmarkCurve.dates"
:strategy="filteredBenchmarkCurve.strategy"
:benchmark="filteredBenchmarkCurve.benchmark"
/>
</el-card>
<el-card>
<AlphaChart
:dates="filteredRiskSeries.dates"
:alpha="filteredRiskSeries.alpha"
/>
</el-card>
<el-card>
<BetaChart
:dates="filteredRiskSeries.dates"
:beta="filteredRiskSeries.beta"
/>
</el-card>
<el-card>
<DrawdownChart
:dates="filteredRiskSeries.dates"
:drawdown="filteredRiskSeries.drawdown"
/>
</el-card>
<el-card>
<VolatilityChart
:dates="filteredRiskSeries.dates"
:strategy="filteredRiskSeries.strategy_vol ?? []"
:benchmark="filteredRiskSeries.benchmark_vol ?? []"
/>
</el-card>
</div>
</el-tab-pane>
<!-- 主图净值 + 基准对比 -->
<div class="grid-2">
<el-card shadow="never" class="panel"><EquityCurve :dates="equityDates" :equity="equityValues" /></el-card>
<el-card shadow="never" class="panel">
<BenchmarkCurve
:dates="filteredBenchmarkCurve.dates"
:strategy="filteredBenchmarkCurve.strategy"
:benchmark="filteredBenchmarkCurve.benchmark"
/>
</el-card>
</div>
<!-- Tab 2: 交易详情 -->
<!-- 风险分析 4 -->
<div class="section-bar"><span class="section-mark"></span>RISK ANALYSIS · 风险分析</div>
<div class="grid-4">
<el-card shadow="never" class="panel"><AlphaChart :dates="filteredRiskSeries.dates" :alpha="filteredRiskSeries.alpha" /></el-card>
<el-card shadow="never" class="panel"><BetaChart :dates="filteredRiskSeries.dates" :beta="filteredRiskSeries.beta" /></el-card>
<el-card shadow="never" class="panel"><DrawdownChart :dates="filteredRiskSeries.dates" :drawdown="filteredRiskSeries.drawdown" /></el-card>
<el-card shadow="never" class="panel">
<VolatilityChart
:dates="filteredRiskSeries.dates"
:strategy="filteredRiskSeries.strategy_vol ?? []"
:benchmark="filteredRiskSeries.benchmark_vol ?? []"
/>
</el-card>
</div>
<!-- 详情 tabs -->
<el-tabs class="detail-tabs">
<el-tab-pane label="交易详情">
<el-card>
<template #header><span class="section-title">成交记录</span></template>
<el-card shadow="never">
<TradesTable :trades="trades" />
</el-card>
</el-tab-pane>
<!-- Tab 3: 每日持仓&收益 -->
<el-tab-pane label="每日持仓&收益">
<el-card>
<template #header><span class="section-title">每日收益</span></template>
<div class="daily-pnl-section">
<el-table :data="pnl" stripe size="small" empty-text="无数据">
<el-table-column label="日期" width="140">
<template #default="{ row }"><span class="mono">{{ fmtDate(row.date) }}</span></template>
</el-table-column>
<el-table-column label="收益" width="120" align="right">
<template #default="{ row }"><span class="mono" :class="fmtPnlClass(row.pnl)">{{ fmtPnl(row.pnl) }}</span></template>
</el-table-column>
</el-table>
</div>
<el-tab-pane label="每日收益">
<el-card shadow="never">
<el-table :data="pnl" size="small" height="420" empty-text="无数据">
<el-table-column label="日期" width="140">
<template #default="{ row }"><span class="mono">{{ fmtDate(row.date) }}</span></template>
</el-table-column>
<el-table-column label="日收益 (‱)" width="140" align="right">
<template #default="{ row }"><span class="mono" :class="fmtPnlClass(row.pnl)">{{ fmtPnl(row.pnl) }}</span></template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<!-- Tab 4: 日志输出 -->
<el-tab-pane label="日志输出">
<el-card>
<template #header><span class="section-title">系统日志</span></template>
<pre class="log-output">{{ logText || '暂无日志数据' }}</pre>
</el-card>
<el-tab-pane label="系统日志">
<pre class="log-output">{{ logText || '暂无日志数据' }}</pre>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-head {
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #e6edf3;
margin: 0 0 8px 0;
}
.muted {
color: #8b949e;
font-size: 16px;
font-weight: 400;
}
.page-subtitle {
color: #8b949e;
font-size: 14px;
margin: 0;
}
.time-range-selector {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding: 12px 16px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
}
.selector-label {
color: #8b949e;
font-size: 14px;
font-weight: 500;
}
.charts-container {
.result-page {
display: flex;
flex-direction: column;
gap: 14px;
}
/* —— 顶栏 —— */
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.charts-container .el-card {
margin-bottom: 0;
.head-title {
display: flex;
align-items: center;
gap: 10px;
}
.daily-pnl-section {
padding: 8px 0;
.title-txt {
font-size: 16px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.5px;
}
.log-output {
background: #0d1117;
color: #8b949e;
padding: 16px;
border-radius: 6px;
.task-id {
font-size: 12px;
line-height: 1.6;
max-height: 400px;
color: var(--brand);
padding: 2px 8px;
background: var(--cyan-soft);
border: 1px solid rgba(0, 229, 255, 0.3);
border-radius: var(--r-sm);
}
.head-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
font-size: 11.5px;
color: var(--text-2);
}
.meta-code { color: var(--amber); }
.dim { color: var(--text-3); }
.head-actions { display: flex; gap: 8px; }
.term-btn {
padding: 6px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-2);
font-family: var(--mono);
font-size: 11.5px;
letter-spacing: 0.3px;
cursor: pointer;
transition: border-color 0.15s var(--ease), color 0.15s var(--ease), box-shadow 0.15s var(--ease);
}
.term-btn:hover {
border-color: rgba(0, 229, 255, 0.5);
color: var(--brand);
box-shadow: 0 0 12px rgba(0, 229, 255, 0.18);
}
/* —— 工具条 —— */
.toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.toolbar-label {
font-size: 10.5px;
color: var(--text-3);
letter-spacing: 1.5px;
}
/* —— 图表面板网格 —— */
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: 14px;
}
.panel {
min-height: 280px;
}
.panel :deep(.el-card__body) { padding: 12px 14px; }
/* —— 分节标题 —— */
.section-bar {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 1.5px;
color: var(--text-2);
text-transform: uppercase;
margin-top: 2px;
}
.section-mark {
display: inline-block;
width: 3px;
height: 12px;
background: var(--brand);
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
}
/* —— tabs —— */
.detail-tabs {
margin-top: 2px;
}
.detail-tabs :deep(.el-tabs__header) {
margin: 0 0 12px;
}
/* —— 日志 —— */
.log-output {
background: var(--panel);
color: var(--text-2);
padding: 14px 16px;
border: 1px solid var(--border-2);
border-radius: var(--r-md);
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
max-height: 420px;
overflow-y: auto;
margin: 0;
white-space: pre-wrap;
}
.section-title {
color: #e6edf3;
font-size: 14px;
font-weight: 600;
@media (max-width: 980px) {
.grid-2 { grid-template-columns: 1fr; }
}
</style>
+3
View File
@@ -151,6 +151,9 @@ async function onStop(a: LiveAccount): Promise<void> {
<div class="cell-id mono muted">{{ row.strategy_class }}</div>
</template>
</el-table-column>
<el-table-column prop="instance" label="实例" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
</el-table-column>
<el-table-column label="频率" width="70">
<template #default="{ row }"><span class="mono">{{ row.interval }}</span></template>
</el-table-column>
+27 -2
View File
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createLive, type LiveCreateRequest } from '@/api/live'
import { apiClient } from '@/api/client'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const fromInstance = ref('')
const form = ref<LiveCreateRequest>({
name: 'live-600000',
@@ -31,6 +34,28 @@ const strategyClassOptions = [
{ value: 'AShareDoubleMaStrategy', label: 'AShareDoubleMaStrategy(双均线 A 股策略)' },
]
onMounted(async () => {
const instId = route.query.instance
if (!instId) return
try {
const [{ data: ir }, { data: fr }] = await Promise.all([
apiClient.get<{ instance: { name?: string; code_file: string; symbol_or_pool: string; interval: string; params: Record<string, unknown> } }>(`/strategy/instances/${instId}`),
apiClient.get<{ files: { name: string; class_name: string }[] }>('/strategy/files'),
])
const inst = ir.instance
fromInstance.value = inst.name || String(instId)
const file = fr.files.find((f) => f.name === inst.code_file)
const cls = file?.class_name || inst.code_file
form.value.strategy_class = cls
form.value.vt_symbol = inst.symbol_or_pool || form.value.vt_symbol
form.value.strategy_name = inst.name || form.value.strategy_name
form.value.interval = inst.interval || form.value.interval
form.value.setting = { ...form.value.setting, ...(inst.params as object) }
} catch {
/* 预填失败走默认 */
}
})
async function onSubmit(): Promise<void> {
if (!form.value.name.trim()) {
ElMessage.warning('请填写实例名')
+3
View File
@@ -111,6 +111,9 @@ function goNew(): void {
<el-table-column label="频率" width="70">
<template #default="{ row }"><span class="mono">{{ row.interval }}</span></template>
</el-table-column>
<el-table-column prop="instance" label="实例" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
</el-table-column>
<el-table-column label="标的" min-width="120">
<template #default="{ row }"><span class="mono">{{ symbolsShort(row.symbols) }}</span></template>
</el-table-column>
+53 -4
View File
@@ -1,20 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createPaper, type PaperCreate } from '@/api/paper'
import { apiClient } from '@/api/client'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const fromInstance = ref('')
function loadDateRange(): { start: string; end: string } {
try {
const s = localStorage.getItem('paper_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) }
}
const dr = loadDateRange()
const form = ref<PaperCreate>({
mode: 'replay',
interval: 'd',
symbols: ['600000'],
strategies: [{ name: 'DoubleMaStrategy', params: { fast_window: 5, slow_window: 10 }, match_session: 'next_open', symbol: '600000' }],
initial_capital: 1_000_000,
start: '2024-01-01',
end: '2024-06-30',
start: dr.start,
end: dr.end,
})
watch(
() => [form.value.start, form.value.end],
([s, e]) => {
try {
localStorage.setItem('paper_date_range', JSON.stringify({ start: s, end: e }))
} catch {
/* ignore */
}
},
)
const modes = [
{ value: 'replay', label: '回放', desc: '历史重放,立即出结果' },
@@ -29,6 +56,28 @@ const sessions = [
{ value: 'current_close', label: '当日收盘', desc: '尾盘型信号,当日收盘撮合' },
]
onMounted(async () => {
const instId = route.query.instance
if (!instId) return
try {
const [{ data: ir }, { data: fr }] = await Promise.all([
apiClient.get<{ instance: { name?: string; code_file: string; symbol_or_pool: string; interval: string; match_session: string; params: Record<string, unknown> } }>(`/strategy/instances/${instId}`),
apiClient.get<{ files: { name: string; class_name: string }[] }>('/strategy/files'),
])
const inst = ir.instance
fromInstance.value = inst.name || String(instId)
const file = fr.files.find((f) => f.name === inst.code_file)
const cls = file?.class_name || inst.code_file
const sym = inst.symbol_or_pool || '600000'
form.value.interval = inst.interval || 'd'
form.value.symbols = sym.includes(',') ? sym.split(',').map((s) => s.trim()) : [sym]
form.value.strategies = [{ name: cls, params: inst.params || {}, match_session: inst.match_session || 'next_open', symbol: sym }]
if (route.query.mode) form.value.mode = String(route.query.mode)
} catch {
/* 预填失败走默认 */
}
})
async function onSubmit(): Promise<void> {
loading.value = true
try {
+241
View File
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import MonacoEditor from '@/components/MonacoEditor.vue'
import { apiClient } from '@/api/client'
interface StrategyFile {
name: string
dir: string
class_name: string
lines: number
modified: string
code: string
}
const router = useRouter()
const route = useRoute()
const files = ref<StrategyFile[]>([])
const activeName = ref('')
const code = ref('')
const dirty = ref(false)
const saving = ref(false)
const checking = ref(false)
const syntaxOk = ref<boolean | null>(null)
const active = computed(() => files.value.find((f) => f.name === activeName.value))
onMounted(async () => {
try {
const { data } = await apiClient.get<{ files: StrategyFile[] }>('/strategy/files')
files.value = data.files
if (files.value.length) {
const initial = route.query.file
? files.value.find((f) => f.name === route.query.file)
: null
select(initial ?? files.value[0])
}
} catch {
ElMessage.error('策略文件加载失败')
}
})
function select(f: StrategyFile): void {
activeName.value = f.name
code.value = f.code
dirty.value = false
syntaxOk.value = null
}
watch(code, () => {
dirty.value = true
syntaxOk.value = null
})
async function onCheck(): Promise<void> {
checking.value = true
await new Promise((r) => setTimeout(r, 600)) // 模拟 py_compile
syntaxOk.value = true
checking.value = false
ElMessage.success('语法检查通过')
}
async function onSave(): Promise<void> {
if (!active.value) return
saving.value = true
try {
await apiClient.post(`/strategy/file/${activeName.value}`, { code: code.value })
active.value.code = code.value
dirty.value = false
ElMessage.success('已保存 · 下次回测自动加载最新代码')
} catch {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
function runBacktest(): void {
if (active.value) {
router.push({ path: '/backtest/new', query: { class: active.value.class_name } })
}
}
</script>
<template>
<div class="page code-page">
<div class="page-head">
<div>
<h2 class="page-title">策略代码编辑</h2>
<p class="page-subtitle">自研策略在线编辑 · Monaco · 保存后下次回测热生效ProcessPool spawn 自动加载</p>
</div>
</div>
<div class="ide term-corners">
<aside class="tree">
<div class="tree-head">
<span class="section-mark"></span>策略文件
</div>
<div
v-for="f in files"
:key="f.name"
class="tree-item"
:class="{ active: f.name === activeName, dirty: f.name === activeName && dirty }"
@click="select(f)"
>
<span class="file-icon"></span>
<div class="file-body">
<span class="file-name">{{ f.name }}</span>
<span class="file-cls mono">{{ f.class_name }}</span>
</div>
<span class="file-lines mono">{{ f.lines }}</span>
</div>
</aside>
<section class="editor-pane">
<div class="editor-bar">
<div class="bar-left">
<span class="lamp" :class="dirty ? 'lamp-warn lamp-pulse' : 'lamp-ok'"></span>
<span class="path mono">{{ active?.dir }}{{ activeName }}</span>
<span v-if="dirty" class="tag tag-dirty">未保存</span>
<span v-if="syntaxOk" class="tag tag-ok mono"> 语法正确</span>
</div>
<div class="bar-right">
<span class="modified mono">{{ active?.modified }}</span>
<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" @click="runBacktest">运行回测</button>
</div>
</div>
<div class="monaco-wrap">
<MonacoEditor v-model="code" language="python" />
</div>
</section>
</div>
</div>
</template>
<style scoped>
.code-page {
display: flex;
flex-direction: column;
gap: 12px;
height: calc(100vh - 80px);
}
.ide {
flex: 1;
display: flex;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
min-height: 0;
}
/* 文件树 */
.tree {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border);
overflow-y: auto;
background: var(--panel);
}
.tree-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
font-family: var(--mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 1.5px;
color: var(--text-3);
text-transform: uppercase;
border-bottom: 1px solid var(--border-2);
}
.section-mark {
display: inline-block;
width: 3px;
height: 11px;
background: var(--brand);
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
}
.tree-item {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 14px;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.12s var(--ease);
}
.tree-item:hover { background: var(--bg-hover); }
.tree-item.active {
background: var(--cyan-soft);
border-left-color: var(--brand);
}
.file-icon { color: var(--brand); font-size: 11px; }
.file-body { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.file-name { font-size: 12.5px; color: var(--text); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tree-item.active .file-name { color: var(--brand); }
.file-cls { font-size: 10px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-lines { font-size: 10px; color: var(--text-3); flex-shrink: 0; }
.tree-item.dirty .file-name::after { content: ' ●'; color: var(--lamp-warn); }
/* 编辑器面板 */
.editor-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.editor-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 14px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
gap: 12px;
}
.bar-left { display: flex; align-items: center; gap: 9px; min-width: 0; }
.path { font-size: 11.5px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.modified { font-size: 10.5px; color: var(--text-3); }
.tag {
font-size: 10px;
padding: 1px 7px;
border-radius: var(--r-sm);
font-family: var(--mono);
}
.tag-dirty { color: var(--lamp-warn); background: var(--amber-soft); border: 1px solid rgba(255, 176, 0, 0.3); }
.tag-ok { color: var(--lamp-ok); background: rgba(46, 230, 138, 0.1); border: 1px solid rgba(46, 230, 138, 0.3); }
.monaco-wrap {
flex: 1;
min-height: 0;
background: #05080d;
}
</style>
+238
View File
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import MonacoEditor from '@/components/MonacoEditor.vue'
const router = useRouter()
type SType = 'portfolio' | 'cta'
const type = ref<SType>('portfolio')
const name = ref('')
const className = ref('MyPortfolioStrategy')
const PORTFOLIO_TEMPLATE = `"""组合策略模板(BulletTrade / 聚宽风格)。
策略 = 这段代码。选股/择时/调仓/风控都在这里。
- initialize: 注册定时任务(run_daily/run_monthly)、基准、成本
- rebalance: 选股 + 调仓下单
- 数据走 self.provider,下单走 self.brokerorder_target_value 等聚宽 API
参数(持仓数/调仓周期/止损线)不写死在代码里,回测时在运行配置中调。
"""
import logging
from typing import Any, List
logger = logging.getLogger(__name__)
class MyPortfolioStrategy:
"""我的组合策略(请改名)。"""
def __init__(self, provider: Any, broker: Any = None):
self.provider = provider
self.broker = broker
def initialize(self, context: Any) -> None:
b = self.broker
b.set_benchmark("000300.XSHG")
b.run_monthly(self.rebalance, 1, "9:30") # 每月首个交易日 9:30 调仓
b.run_daily(self.stop_loss, "14:00") # 每日止损检查
def rebalance(self, context: Any) -> None:
"""月度调仓:选股 + 等权下单。"""
target = self.select_stocks(context)
positions = context.portfolio.positions
# 清掉不在目标池的
for code in list(positions):
if code not in target:
self.broker.order_target_value(code, 0)
# 买入目标池(等权)
cash = context.portfolio.available_cash / max(len(target), 1)
for code in target:
if code not in positions:
self.broker.order_value(code, cash)
def select_stocks(self, context: Any) -> List[str]:
"""选股逻辑:从成分股里挑 N 只(自定义你的因子/规则)。"""
pool = self.provider.get_index_stocks("000300.XSHG", context.current_dt)
return pool[:5]
def stop_loss(self, context: Any) -> None:
"""止损:持仓跌幅超阈值卖出。"""
pass
`
const CTA_TEMPLATE = `"""CTA 策略模板(vnpy StrategyTemplate,单标的)。
策略 = 这段代码。on_bar 里写交易逻辑。
- parameters: 声明可调参数(回测时在运行配置中覆盖)
- on_bar: 每根 K 线触发,计算指标 + 下单
- 定寸 size 股(A股 100 股一手)
"""
from vnpy_ctastrategy import StrategyTemplate
from vnpy.trader.object import BarData
class MyCtaStrategy(StrategyTemplate):
"""我的 CTA 策略(请改名)。"""
author = "sanguo"
fast_window = 10
slow_window = 30
size = 100
parameters = ["fast_window", "slow_window", "size"]
variables = ["fast_ma", "slow_ma"]
def __init__(self, cta_engine, strategy_name, vt_symbol, setting):
super().__init__(cta_engine, strategy_name, vt_symbol, setting)
self.fast_ma = 0.0
self.slow_ma = 0.0
def on_init(self) -> None:
self.write_log("策略初始化")
self.load_bar(self.slow_window)
def on_bar(self, bar: BarData) -> None:
am = self.am
am.update_bar(bar)
if not am.inited:
return
self.fast_ma = am.sma(self.fast_window, array=True)[-1]
self.slow_ma = am.sma(self.slow_window, array=True)[-1]
cross_over = self.fast_ma > self.slow_ma
cross_below = self.fast_ma < self.slow_ma
if cross_over and self.pos == 0:
self.buy(bar.close_price + 0.01, self.size)
elif cross_below and self.pos > 0:
self.sell(bar.close_price - 0.01, self.size)
self.put_event()
`
const code = ref(PORTFOLIO_TEMPLATE)
watch(type, (t) => {
code.value = t === 'portfolio' ? PORTFOLIO_TEMPLATE : CTA_TEMPLATE
className.value = t === 'portfolio' ? 'MyPortfolioStrategy' : 'MyCtaStrategy'
})
function onCreate(): void {
if (!name.value.trim()) {
ElMessage.warning('请填写策略名称')
return
}
ElMessage.success(`策略「${name.value}」已创建(mock)· 代码保存后下次回测热生效`)
router.push('/strategy')
}
function onCancel(): void {
router.push('/strategy')
}
</script>
<template>
<div class="page new-page">
<div class="page-head">
<div>
<h2 class="page-title">新建策略</h2>
<p class="page-subtitle">选范式 + 写代码 · 策略即代码参数周期/标的/在回测时配</p>
</div>
</div>
<div class="type-row">
<div class="type-card term-corners" :class="{ active: type === 'portfolio' }" @click="type = 'portfolio'">
<div class="type-top">
<span class="chip chip-portfolio">组合</span>
<span v-if="type === 'portfolio'" class="lamp lamp-ok"></span>
</div>
<div class="type-name">组合策略</div>
<div class="type-desc">多股票选股 + 调仓 + 风控聚宽/BulletTrade 风格适合全天候轮动多因子选股</div>
</div>
<div class="type-card term-corners" :class="{ active: type === 'cta' }" @click="type = 'cta'">
<div class="type-top">
<span class="chip chip-cta">CTA</span>
<span v-if="type === 'cta'" class="lamp lamp-ok"></span>
</div>
<div class="type-name">CTA 策略</div>
<div class="type-desc">单标的指标驱动vnpy StrategyTemplate适合均线突破通道等单股择时</div>
</div>
</div>
<el-card shadow="never" class="blk">
<template #header><span class="section-title">基本信息</span></template>
<el-form label-width="100px">
<el-form-item label="策略名称">
<el-input v-model="name" placeholder="如 我的小市值轮动" style="width: 280px" />
</el-form-item>
<el-form-item label="策略类名">
<el-input v-model="className" style="width: 280px" />
</el-form-item>
</el-form>
</el-card>
<div class="section-bar"><span class="section-mark"></span>策略代码 · {{ type === 'portfolio' ? '组合' : 'CTA' }} 模板已载入按需编辑</div>
<div class="monaco-wrap">
<MonacoEditor v-model="code" language="python" />
</div>
<div class="submit-bar">
<button class="term-btn primary" @click="onCreate">创建策略</button>
<button class="term-btn" @click="onCancel">取消</button>
</div>
</div>
</template>
<style scoped>
.new-page { display: flex; flex-direction: column; gap: 14px; height: calc(100vh - 80px); }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; }
.type-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.type-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 14px 16px;
cursor: pointer;
transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease);
}
.type-card:hover { border-color: rgba(0, 229, 255, 0.4); }
.type-card.active {
border-color: var(--brand);
box-shadow: var(--glow-cyan);
}
.type-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.type-name { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.type-desc { font-size: 12px; color: var(--text-2); line-height: 1.6; }
.blk { border: 1px solid var(--border-2); }
.section-bar {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 1.5px;
color: var(--text-2);
text-transform: uppercase;
}
.section-mark {
display: inline-block;
width: 3px;
height: 12px;
background: var(--brand);
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
}
.monaco-wrap {
flex: 1;
min-height: 280px;
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
}
.submit-bar { display: flex; gap: 10px; }
</style>
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getParams } from '@/api/strategy'
import { apiClient } from '@/api/client'
interface CodeFile {
name: string
type: 'portfolio' | 'cta'
class_name: string
}
interface Instance {
id: number
code_file: string
name: string
params: Record<string, unknown>
symbol_or_pool: string
interval: string
match_session: string
}
const route = useRoute()
const router = useRouter()
const editId = computed(() => (route.params.id ? Number(route.params.id) : null))
const isEdit = computed(() => editId.value !== null)
const codes = ref<CodeFile[]>([])
const paramsList = ref<string[]>([])
const loading = ref(false)
const saving = ref(false)
const form = reactive({
name: '',
code_file: '',
symbol_or_pool: '',
interval: 'd',
match_session: 'next_open',
})
const paramValues = reactive<Record<string, string>>({})
const selectedCode = computed(() => codes.value.find((c) => c.name === form.code_file))
const isPortfolio = computed(() => selectedCode.value?.type === 'portfolio')
const POOL_OPTIONS = [
{ label: '沪深300', value: 'HS300' },
{ label: '中证500', value: 'ZZ500' },
{ label: '中证1000', value: 'ZZ1000' },
{ label: '中证2000', value: 'ZZ2000' },
{ label: '全市场', value: 'ALL' },
]
const INTERVAL_OPTIONS = [
{ label: '日线', value: 'd', matchHint: '每日 1 个撮合点' },
{ label: '15 分钟', value: '15m', matchHint: '每日约 16 个撮合点(每根 15m K线撮合)' },
]
const SESSION_OPTIONS = [
{ label: '下一根 K线 · 开盘价(防未来函数,推荐)', value: 'next_open', dayOnly: false },
{ label: '当前 K线 · 收盘价', value: 'current_close', dayOnly: false },
{ label: '集合竞价开盘价(仅日线)', value: 'call_auction', dayOnly: true },
]
const matchHint = computed(() => INTERVAL_OPTIONS.find((o) => o.value === form.interval)?.matchHint || '')
const sessionsByInterval = computed(() =>
SESSION_OPTIONS.filter((s) => !s.dayOnly || form.interval === 'd'),
)
watch(
() => form.interval,
() => {
if (!sessionsByInterval.value.some((s) => s.value === form.match_session)) {
form.match_session = 'next_open'
}
},
)
async function loadParams(codeFile: string, override: Record<string, unknown> | null): Promise<void> {
const code = codes.value.find((c) => c.name === codeFile)
if (!code) return
try {
const p = await getParams(code.class_name)
paramsList.value = p.parameters
Object.keys(paramValues).forEach((k) => delete paramValues[k])
p.parameters.forEach((k) => {
const ov = override && override[k] != null ? override[k] : p.defaults[k]
paramValues[k] = ov != null ? String(ov) : ''
})
} catch {
paramsList.value = []
}
}
onMounted(async () => {
loading.value = true
try {
const { data } = await apiClient.get<{ files: CodeFile[] }>('/strategy/files')
codes.value = data.files
let inst: Instance | undefined
if (isEdit.value) {
const { data: ins } = await apiClient.get<{ instances: Instance[] }>('/strategy/instances')
inst = ins.instances.find((i) => i.id === editId.value)
if (inst) {
form.name = inst.name
form.code_file = inst.code_file
form.symbol_or_pool = inst.symbol_or_pool
form.interval = inst.interval
form.match_session = inst.match_session
}
} else {
if (route.query.code) form.code_file = String(route.query.code)
else if (codes.value.length) form.code_file = codes.value[0].name
}
await loadParams(form.code_file, inst ? inst.params : null)
} finally {
loading.value = false
}
})
async function onSave(): Promise<void> {
if (!form.name.trim()) {
ElMessage.warning('请填写实例名称')
return
}
const params: Record<string, unknown> = {}
paramsList.value.forEach((k) => {
const raw = paramValues[k]
params[k] = raw !== '' && !isNaN(Number(raw)) ? Number(raw) : raw
})
saving.value = true
try {
await apiClient.post('/strategy/instances', {
id: editId.value,
code_file: form.code_file,
name: form.name,
params,
symbol_or_pool: form.symbol_or_pool,
interval: form.interval,
match_session: form.match_session,
})
ElMessage.success(isEdit.value ? '实例已更新(mock' : '实例已创建(mock')
router.push('/strategy')
} catch {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
async function onDelete(): Promise<void> {
if (!editId.value) return
try {
await ElMessageBox.confirm('确认删除该实例?删除后不可恢复。', '删除实例', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await apiClient.delete(`/strategy/instances/${editId.value}`)
ElMessage.success('已删除')
router.push('/strategy')
} catch {
ElMessage.error('删除失败')
}
}
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-head">
<div>
<h2 class="page-title">{{ isEdit ? '编辑策略实例' : '新建策略实例' }}</h2>
<p class="page-subtitle">实例 = 策略代码的一组参数变体 · 参数/标的/精度属实例可建多个一键运行</p>
</div>
</div>
<el-card shadow="never" class="blk">
<template #header><span class="section-title">归属与命名</span></template>
<el-form label-width="100px">
<el-form-item label="策略代码">
<el-select v-model="form.code_file" style="width: 320px" :disabled="isEdit" @change="(v: string) => loadParams(v, null)">
<el-option v-for="c in codes" :key="c.name" :label="`${c.name} · ${c.type === 'portfolio' ? '组合' : 'CTA'}`" :value="c.name" />
</el-select>
<span v-if="selectedCode" class="chip" :class="selectedCode.type === 'portfolio' ? 'chip-portfolio' : 'chip-cta'" style="margin-left: 10px">
{{ selectedCode.type === 'portfolio' ? '组合' : 'CTA' }}
</span>
</el-form-item>
<el-form-item label="实例名称">
<el-input v-model="form.name" placeholder="如 茅台双均线(10,30)·15m" style="width: 320px" />
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="blk">
<template #header>
<span class="section-title">参数 · 从代码 {{ selectedCode?.class_name }} 反射</span>
</template>
<div class="param-grid">
<div v-for="k in paramsList" :key="k" class="param-item">
<span class="param-key mono">{{ k }}</span>
<el-input v-model="paramValues[k]" style="width: 140px" />
</div>
<div v-if="!paramsList.length" class="muted">该策略无参数</div>
</div>
</el-card>
<el-card shadow="never" class="blk">
<template #header><span class="section-title">标的 / </span></template>
<el-form label-width="100px">
<el-form-item label="标的/股票池">
<el-select v-if="isPortfolio" v-model="form.symbol_or_pool" style="width: 280px" placeholder="选择股票池">
<el-option v-for="p in POOL_OPTIONS" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
<el-input v-else v-model="form.symbol_or_pool" placeholder="如 600519.SH(不需交易所后缀也可)" style="width: 280px" />
<span class="muted form-hint">{{ isPortfolio ? '组合策略选预设池(成分股由数据层展开)' : 'CTA 输入单标的代码' }}</span>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="blk">
<template #header><span class="section-title">撮合 · 精度与时点</span></template>
<el-form label-width="100px">
<el-form-item label="数据精度">
<el-radio-group v-model="form.interval">
<el-radio-button v-for="o in INTERVAL_OPTIONS" :key="o.value" :value="o.value">{{ o.label }}</el-radio-button>
</el-radio-group>
<span class="muted form-hint">撮合频率 = 精度 · {{ matchHint }}</span>
</el-form-item>
<el-form-item label="撮合时点">
<el-select v-model="form.match_session" style="width: 320px">
<el-option v-for="s in sessionsByInterval" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<span class="muted form-hint">每根 K 线按此时点的价格撮合</span>
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<button class="term-btn primary" :disabled="saving" @click="onSave">{{ saving ? '保存中' : '保存实例' }}</button>
<button class="term-btn" @click="router.push('/strategy')">取消</button>
<button v-if="isEdit" class="term-btn danger" style="margin-left:auto" @click="onDelete">删除实例</button>
</div>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; gap: 14px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; }
.blk { border: 1px solid var(--border-2); }
.param-grid { display: flex; flex-wrap: wrap; gap: 12px 20px; }
.param-item { display: flex; align-items: center; gap: 8px; }
.param-key { font-size: 12px; color: var(--text-2); min-width: 120px; }
.form-hint { margin-left: 10px; font-size: 12px; }
.submit-bar { display: flex; gap: 10px; }
</style>
+263
View File
@@ -0,0 +1,263 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiClient } from '@/api/client'
interface CodeFile {
name: string
type: 'portfolio' | 'cta'
class_name: string
lines: number
}
interface Instance {
id: number
code_file: string
name: string
type: 'portfolio' | 'cta'
params: Record<string, unknown>
symbol_or_pool: string
interval: string
match_session: string
status: { backtest: string; replay: string; paper_live: string; live: string }
last_return: number | null
updated_at: string
}
const router = useRouter()
const files = ref<CodeFile[]>([])
const instances = ref<Instance[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const [f, ins] = await Promise.all([
apiClient.get<{ files: CodeFile[] }>('/strategy/files'),
apiClient.get<{ instances: Instance[] }>('/strategy/instances'),
])
files.value = f.data.files
instances.value = ins.data.instances
} finally {
loading.value = false
}
})
const grouped = computed(() =>
files.value.map((f) => ({
file: f,
instances: instances.value.filter((i) => i.code_file === f.name),
})),
)
function editCode(name: string): void {
router.push({ path: '/strategy/code', query: { file: name } })
}
function newStrategy(): void {
router.push('/strategy/new')
}
function newInstance(f: CodeFile): void {
router.push({ path: '/strategy/instance/new', query: { code: f.name } })
}
function editInstance(i: Instance): void {
router.push(`/strategy/instance/${i.id}`)
}
function runBacktest(i: Instance): void {
router.push({
path: i.type === 'portfolio' ? '/backtest/portfolio' : '/backtest/new',
query: { class: i.code_file, instance: String(i.id) },
})
}
function runReplay(i: Instance): void {
router.push({ path: '/paper/new', query: { mode: 'replay', instance: String(i.id) } })
}
function runPaperLive(i: Instance): void {
router.push({ path: '/paper/new', query: { mode: 'live', instance: String(i.id) } })
}
function runLive(i: Instance): void {
router.push({ path: '/live/new', query: { instance: String(i.id) } })
}
async function onDelInst(i: Instance): Promise<void> {
try {
await ElMessageBox.confirm(`确认删除实例「${i.name}」?`, '删除实例', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await apiClient.delete(`/strategy/instances/${i.id}`)
instances.value = instances.value.filter((x) => x.id !== i.id)
ElMessage.success('已删除')
} catch {
ElMessage.error('删除失败')
}
}
const typeChip = (t: string): string => (t === 'portfolio' ? 'chip-portfolio' : 'chip-cta')
const typeLabel = (t: string): string => (t === 'portfolio' ? '组合' : 'CTA')
const lampOf = (s: string): string => {
if (s === 'running') return 'lamp-ok lamp-pulse'
if (s === 'done') return 'lamp-ok'
if (s === 'stopped') return 'lamp-idle'
if (s === 'failed') return 'lamp-crit'
return 'lamp-idle'
}
const stTxt = (s: string): string => {
if (s === '-') return '—'
return ({ running: '运行', done: '已测', stopped: '已停', failed: '失败' } as Record<string, string>)[s] || s
}
const pct = (v: number | null): string => (v == null ? '—' : (v * 100).toFixed(2) + '%')
const retClass = (v: number | null): string => (v == null ? '' : v >= 0 ? 'up' : 'down')
const paramSummary = (p: Record<string, unknown>): string =>
Object.entries(p)
.map(([k, v]) => `${k}=${v}`)
.join(' · ')
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-head">
<div>
<h2 class="page-title">策略库</h2>
<p class="page-subtitle">策略代码 实例参数变体 回测 / 模拟盘 / 实盘 全生命周期</p>
</div>
<button class="term-btn primary" @click="newStrategy">+ 新建策略</button>
</div>
<div class="groups">
<div v-for="g in grouped" :key="g.file.name" class="code-group">
<div class="code-head">
<div class="code-title">
<span class="code-name mono">{{ g.file.name }}</span>
<span class="chip" :class="typeChip(g.file.type)">{{ typeLabel(g.file.type) }}</span>
<span class="code-cls mono muted">{{ g.file.class_name }}</span>
</div>
<div class="code-actions">
<button class="term-btn sm" @click="editCode(g.file.name)">编辑代码</button>
<button class="term-btn sm primary" @click="newInstance(g.file)">+ 新实例</button>
</div>
</div>
<div class="inst-table">
<div class="inst-row inst-head-row">
<span class="c-name">实例</span>
<span class="c-params">参数</span>
<span class="c-sym">标的/</span>
<span class="c-ret">最近收益</span>
<span class="c-run">回测</span>
<span class="c-run">回放</span>
<span class="c-run">实走</span>
<span class="c-run">实盘</span>
<span class="c-ops">操作</span>
</div>
<div v-for="i in g.instances" :key="i.id" class="inst-row">
<span class="c-name inst-name clickable" @click="editInstance(i)">{{ i.name }}</span>
<span class="c-params mono muted">{{ i.interval }} · {{ paramSummary(i.params) }}</span>
<span class="c-sym mono">{{ i.symbol_or_pool }}</span>
<span class="c-ret mono" :class="retClass(i.last_return)">{{ pct(i.last_return) }}</span>
<span class="c-run">
<button class="run-btn" :class="{ active: i.status.backtest !== '-' }" @click="runBacktest(i)">
<span class="lamp" :class="lampOf(i.status.backtest)"></span>{{ stTxt(i.status.backtest) }}
</button>
</span>
<span class="c-run">
<button class="run-btn" :class="{ active: i.status.replay !== '-' }" @click="runReplay(i)">
<span class="lamp" :class="lampOf(i.status.replay)"></span>{{ stTxt(i.status.replay) }}
</button>
</span>
<span class="c-run">
<button class="run-btn" :class="{ active: i.status.paper_live !== '-' }" @click="runPaperLive(i)">
<span class="lamp" :class="lampOf(i.status.paper_live)"></span>{{ stTxt(i.status.paper_live) }}
</button>
</span>
<span class="c-run">
<button class="run-btn" :class="{ active: i.status.live !== '-' }" @click="runLive(i)">
<span class="lamp" :class="lampOf(i.status.live)"></span>{{ stTxt(i.status.live) }}
</button>
</span>
<span class="c-ops">
<button class="term-btn sm" @click="editInstance(i)">编辑</button>
<button class="term-btn sm danger" @click="onDelInst(i)">删除</button>
</span>
</div>
<div v-if="!g.instances.length" class="inst-empty muted">暂无实例编辑代码后可创建参数实例</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.groups { display: flex; flex-direction: column; gap: 14px; }
.code-group {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
}
.code-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg-hover);
border-bottom: 1px solid var(--border);
}
.code-title { display: flex; align-items: center; gap: 10px; }
.code-name { font-size: 13px; font-weight: 600; color: var(--text); }
.code-cls { font-size: 11px; }
.code-actions { display: flex; gap: 6px; }
.inst-table { display: flex; flex-direction: column; }
.inst-row {
display: grid;
grid-template-columns: 1.4fr 2fr 1fr 0.9fr repeat(4, 0.8fr) 0.9fr;
align-items: center;
gap: 8px;
padding: 9px 14px;
border-bottom: 1px solid var(--border-2);
font-size: 12px;
}
.inst-row:last-child { border-bottom: none; }
.inst-head-row {
background: var(--panel);
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-3);
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 6px 14px;
}
.inst-name { color: var(--text); font-weight: 500; }
.inst-name.clickable { cursor: pointer; }
.inst-name.clickable:hover { color: var(--brand); }
.c-params { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.c-sym { color: var(--amber); font-size: 11.5px; }
.c-ret { font-weight: 600; }
.c-run { text-align: center; }
.c-ops { display: flex; gap: 4px; justify-content: center; }
.run-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
background: transparent;
border: 1px solid var(--border-2);
border-radius: var(--r-sm);
color: var(--text-3);
font-family: var(--mono);
font-size: 10px;
cursor: pointer;
transition: all 0.12s var(--ease);
}
.run-btn:hover { border-color: rgba(0,229,255,0.5); color: var(--brand); }
.run-btn.active { color: var(--text-2); border-color: var(--border); }
.run-btn .lamp { width: 5px; height: 5px; }
.inst-empty { padding: 16px 14px; font-size: 12px; }
</style>