Files
sanguo_vnpy_v2/frontend/src/views/strategy/List.vue
T

449 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
import { getInstances, syncInstanceParams, type Instance, type RunningAccount } from '@/api/strategy'
import { strategyLabel } from '@/constants/strategy'
import InstanceOverview from './InstanceOverview.vue'
interface CodeFile {
name: string
type: 'portfolio' | 'cta'
class_name: string
lines: number
}
const router = useRouter()
const files = ref<CodeFile[]>([])
const instances = ref<Instance[]>([])
const loading = ref(false)
const selectedFile = ref('')
const keyword = ref('')
// 巡检模式:跨全部档案看在跑运行(点统计条「在跑运行」进出)
const inspection = ref(false)
const overviewId = ref<number | null>(null)
const syncing = ref(false)
onMounted(async () => {
loading.value = true
try {
const [f, ins] = await Promise.all([
apiClient.get<{ files: CodeFile[] }>('/strategy/files'),
getInstances(),
])
files.value = f.data.files
instances.value = ins
if (files.value.length) selectedFile.value = files.value[0].name
} catch {
ElMessage.error('策略库加载失败')
} finally {
loading.value = false
}
})
// ===== 左栏:代码树(中文主显 + 文件名副行 + 实例数 + 在跑绿点)=====
const treeGroups = computed(() => {
const kw = keyword.value.trim().toLowerCase()
const mk = (type: 'portfolio' | 'cta', label: string) => ({
label,
items: files.value
.filter((f) => f.type === type)
.filter((f) => {
if (!kw) return true
const insts = instancesOf(f.name)
return (
f.name.toLowerCase().includes(kw) ||
strategyLabel(f.name).toLowerCase().includes(kw) ||
insts.some((i) => i.name.toLowerCase().includes(kw))
)
})
.map((f) => ({
file: f,
label: strategyLabel(f.name),
count: instancesOf(f.name).length,
running: instancesOf(f.name).some((i) => (i.running_accounts || []).length > 0),
})),
})
return [mk('portfolio', '组合策略'), mk('cta', '个股策略')].filter((g) => g.items.length)
})
function instancesOf(fileName: string): Instance[] {
return instances.value.filter((i) => i.code_file === fileName)
}
// ===== 统计条 =====
const runningAccounts = computed<RunningAccount[]>(() =>
instances.value.flatMap((i) => i.running_accounts || []),
)
const stats = computed(() => ({
files: files.value.length,
insts: instances.value.length,
running: runningAccounts.value.length,
drift: instances.value.filter((i) => i.drift).length,
}))
function toggleInspection(): void {
inspection.value = !inspection.value
}
// ===== 右侧:选中代码的档案区 =====
const selectedInstances = computed(() => instancesOf(selectedFile.value))
const selectedMeta = computed(() => files.value.find((f) => f.name === selectedFile.value))
function selectFile(name: string): void {
selectedFile.value = name
inspection.value = false
}
// ===== 行内:运行灯(#77 ×N 徽标语义,旧 runLabel/stTxt 已并入 badge=====
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 pct = (v: number | null | undefined): string => (v == null ? '—' : (v * 100).toFixed(2) + '%')
const retClass = (v: number | null | undefined): string => (v == null ? '' : v >= 0 ? 'up' : 'down')
// #75 收益数值与标签同源:运行类 ret 优先(含其类型),否则回测/回放 run_returns
interface RetInfo { v: number | null; label: string }
function retInfoOf(i: Instance): RetInfo {
const runs = (i.running_accounts || []).filter((a) => a.ret != null)
if (runs.length) {
const last = runs[runs.length - 1]
const label = last.kind === 'live' ? '实盘 · 最新' : last.kind === 'shadow' ? '影子 · 最新' : '模拟实走 · 最新'
return { v: last.ret, label }
}
const rr = (i as Instance & { run_returns?: Record<string, number> }).run_returns || {}
if (rr.backtest != null) return { v: rr.backtest, label: '回测 · 最新' }
if (rr.replay != null) return { v: rr.replay, label: '回放 · 最新' }
return { v: null, label: '未运行' }
}
function latestRet(i: Instance): number | null {
return retInfoOf(i).v
}
const paramSummary = (p: Record<string, unknown>): string =>
Object.entries(p).map(([k, v]) => `${k}=${v}`).join(' · ')
// ===== 操作 =====
function editCode(name: string): void {
router.push({ path: '/strategy/code', query: { file: name } })
}
function newInstance(): void {
router.push({ path: '/strategy/instance/new', query: { code: selectedFile.value } })
}
function editInstance(i: Instance): void {
router.push(`/strategy/instance/${i.id}`)
}
// 以文件实际类型为准(老实例 type 字段可能不准,如组合实例标了 cta)
function fileTypeOf(i: Instance): 'portfolio' | 'cta' {
return files.value.find((f) => f.name === i.code_file)?.type || i.type
}
// #77 灯语义(用户定稿):点击一律进该类型列表(按实例过滤)看全部运行;
// 空列表 → 新建页。灯上带 ×N 徽标(该类型运行总数)。历史按钮已删(灯即入口)。
function countOf(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): number {
if (i.counts && i.counts[kind] != null) return i.counts[kind]
// enriched counts 缺失时的退化口径
if (kind === 'backtest' || kind === 'replay') return (i.status?.[kind] || '-') !== '-' ? 1 : 0
return (i.running_accounts || []).filter((a) => (kind === 'live' ? a.kind === 'live' : a.kind === 'paper' || a.kind === 'shadow')).length
}
// 徽标文案:×N(N>0);0 不显示(点击即新建)
function badge(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): string {
const n = countOf(i, kind)
return n > 0 ? `×${n}` : ''
}
function runGo(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): void {
const n = countOf(i, kind)
if (kind === 'backtest') {
if (n > 0) { router.push(`/backtest/history?instance=${i.id}`); return }
const isPf = fileTypeOf(i) === 'portfolio'
router.push({
path: isPf ? '/backtest/portfolio' : '/backtest/new',
query: isPf
? { strategy: i.code_file.replace(/\.py$/, ''), instance: String(i.id) }
: {
class: files.value.find((f) => f.name === i.code_file)?.class_name || i.code_file,
instance: String(i.id),
},
})
return
}
if (kind === 'replay') {
if (n > 0) { router.push(`/paper?instance=${i.id}`); return }
router.push({ path: '/paper/new', query: { mode: 'replay', instance: String(i.id) } })
return
}
if (kind === 'paper_live') {
if (n > 0) { router.push(`/paper?instance=${i.id}`); return }
router.push({ path: '/paper/new', query: { mode: 'live', instance: String(i.id) } })
return
}
// live
if (n > 0) { router.push(`/live?instance=${i.id}`); return }
router.push({ path: '/live/new', query: { instance: String(i.id) } })
}
async function onSync(i: Instance): Promise<void> {
try {
await ElMessageBox.confirm(
`把实例「${i.name}」当前参数同步到全部运行中的模拟账户?实走明晚结算生效;影子账户需重启影子进程生效。实盘不受影响(停了重发)。`,
'参数同步', { type: 'warning', confirmButtonText: '同步', cancelButtonText: '取消' },
)
} catch {
return
}
syncing.value = true
try {
const n = await syncInstanceParams(i.id)
ElMessage.success(n ? `已同步 ${n} 个模拟账户` : '没有运行中的模拟账户(参数已存档案,后续发起即用新参)')
} catch {
ElMessage.error('同步失败')
} finally {
syncing.value = false
}
}
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 (e) {
// 409 删除保护:有运行中账户
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
ElMessage.warning(msg || '删除失败')
}
}
// ===== 巡检模式展示 =====
const kindLabel: Record<string, string> = { paper: '模拟', shadow: '影子', live: '实盘' }
// 构建期关联账户↔档案(模板里反查又绕又脆)
const runningRows = computed(() =>
instances.value.flatMap((i) => (i.running_accounts || []).map((r) => ({ inst: i, run: r }))),
)
</script>
<template>
<div v-loading="loading" class="page lib">
<div class="page-head">
<div>
<h2 class="page-title">策略库</h2>
<p class="page-subtitle">代码 实例档案 回测 / 模拟 / 实盘 · 全生命周期</p>
</div>
<div style="display:flex;gap:8px">
<button class="term-btn primary" @click="router.push('/strategy/new')">+ 新建策略</button>
</div>
</div>
<!-- B 借来的统计条全局一瞥 + 在跑巡检入口 -->
<div class="stats">
<div class="stat"><span class="n brand">{{ stats.files }}</span><span class="l">策略代码</span></div>
<div class="stat clickable" :class="{ on: !inspection }" @click="inspection = false">
<span class="n">{{ stats.insts }}</span><span class="l">实例档案</span>
</div>
<div class="stat clickable" :class="{ on: inspection }" title="跨全部档案只看在跑的运行" @click="toggleInspection">
<span class="n">{{ stats.running }}</span><span class="l">在跑运行 · {{ inspection ? '返回' : '巡检' }}</span>
</div>
<div class="stat"><span class="n warn">{{ stats.drift }}</span><span class="l">参数漂移</span></div>
</div>
<!-- A 主从主体 -->
<div class="body">
<aside class="tree">
<input v-model="keyword" class="search" placeholder="⌕ 搜索策略 / 实例…">
<template v-for="g in treeGroups" :key="g.label">
<div class="grp"><span class="mark"></span>{{ g.label }}</div>
<div
v-for="it in g.items" :key="it.file.name"
class="item" :class="{ on: it.file.name === selectedFile && !inspection }"
@click="selectFile(it.file.name)"
>
<div class="item-names">
<span class="item-label">{{ it.label }}</span>
<span class="item-file mono">{{ it.file.name }}</span>
</div>
<span class="item-side">
<span v-if="it.running" class="lamp lamp-ok lamp-pulse"></span>
<span class="cnt mono">{{ it.count }}</span>
</span>
</div>
</template>
<div v-if="!treeGroups.length" class="muted" style="padding:14px;font-size:12px">无匹配策略</div>
</aside>
<div class="main">
<!-- 巡检模式全部在跑运行 -->
<template v-if="inspection">
<div class="codehead">
<span class="fname">在跑运行</span>
<span class="chip chip-portfolio">跨全部档案</span>
<span class="muted" style="font-size:11px;margin-left:8px">实时巡检 · 点左侧统计条在跑运行返回</span>
</div>
<div class="runsview">
<div v-for="row in runningRows" :key="`${row.run.kind}-${row.run.aid}`" class="runrow">
<span class="chip" :class="row.run.kind === 'live' ? 'chip-cta' : 'chip-portfolio'">{{ kindLabel[row.run.kind] }}</span>
<span class="mono muted">{{ row.inst.name }}</span>
<span class="mono">{{ row.run.label }}</span>
<span class="mono" :class="retClass(row.run.ret)">{{ pct(row.run.ret) }}</span>
<button class="term-btn sm" @click="overviewId = row.inst.id">全景</button>
</div>
<div v-if="!runningRows.length" class="empty muted">当前没有在跑的运行</div>
</div>
</template>
<!-- 默认选中代码的档案区 -->
<template v-else>
<div class="codehead">
<span class="fname mono">{{ selectedMeta?.name || '—' }}</span>
<span class="chip" :class="selectedMeta?.type === 'portfolio' ? 'chip-portfolio' : 'chip-cta'">
{{ selectedMeta?.type === 'portfolio' ? '组合' : 'CTA' }}
</span>
<span class="mono muted cls">{{ selectedMeta?.class_name }}</span>
<span style="margin-left:auto"></span>
<button class="term-btn sm" @click="editCode(selectedFile)">编辑代码</button>
<button class="term-btn sm primary" @click="newInstance">+ 新实例</button>
</div>
<div class="insts">
<div v-for="i in selectedInstances" :key="i.id" class="row">
<div class="iname" @click="editInstance(i)">
{{ i.name }}
<span v-if="i.drift" class="chip drift">参数已漂移</span>
<span v-if="i.code_changed" class="chip drift" title="发起后策略代码被修改过,在跑账户仍是旧代码">代码已变更</span>
<span class="sub mono">#{{ i.id }} · {{ i.interval }} · 更新 {{ i.updated_at }}</span>
</div>
<div class="params mono muted">{{ paramSummary(i.params) }}</div>
<div class="sym mono">{{ i.symbol_or_pool || '—(发起时定)' }}</div>
<div class="ret mono" :class="retClass(latestRet(i))">{{ pct(latestRet(i)) }}<span class="sub">{{ retInfoOf(i).label }}</span></div>
<div class="runs">
<button class="run-btn" @click="runGo(i, 'backtest')"><span class="lamp" :class="lampOf(i.status.backtest)"></span>回测{{ badge(i, 'backtest') }}</button>
<button v-if="fileTypeOf(i) !== 'portfolio'" class="run-btn" @click="runGo(i, 'replay')"><span class="lamp" :class="lampOf(i.status.replay)"></span>回放{{ badge(i, 'replay') }}</button>
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'paper' || a.kind === 'shadow') }" @click="runGo(i, 'paper_live')">
<span class="lamp" :class="lampOf(i.status.paper_live)"></span>实走{{ badge(i, 'paper_live') }}
</button>
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'live') }" @click="runGo(i, 'live')">
<span class="lamp" :class="lampOf(i.status.live)"></span>实盘{{ badge(i, 'live') }}
</button>
</div>
<div class="ops">
<button v-if="i.drift" class="term-btn sm warn" :disabled="syncing" @click="onSync(i)">同步</button>
<button class="term-btn sm" @click="overviewId = i.id">全景</button>
<button class="term-btn sm" @click="editInstance(i)">编辑</button>
<button class="term-btn sm danger" @click="onDelInst(i)">删除</button>
</div>
</div>
<div v-if="!selectedInstances.length" class="empty muted">
该代码还没有实例 点右上+ 新实例创建或直接发起运行发起即建档
</div>
</div>
</template>
</div>
</div>
<InstanceOverview v-if="overviewId != null" :instance-id="overviewId" @close="overviewId = null" />
</div>
</template>
<style scoped>
.lib { display: flex; flex-direction: column; gap: 14px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.stats { display: flex; gap: 10px; }
.stat {
flex: 1; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--bg-card);
padding: 10px 16px; display: flex; align-items: baseline; gap: 10px; cursor: default;
}
.stat .n { font-family: var(--mono); font-size: 21px; font-weight: 700; color: var(--text); }
.stat .n.brand { color: var(--brand); }
.stat .n.warn { color: var(--amber); }
.stat .l { font-size: 11.5px; color: var(--text-3); }
.stat.clickable { cursor: pointer; transition: border-color 0.12s var(--ease); }
.stat.clickable:hover { border-color: rgba(0, 229, 255, 0.45); }
.stat.on { border-color: rgba(0, 229, 255, 0.55); background: rgba(0, 229, 255, 0.06); }
.stat.on .n { color: var(--brand); }
.body {
display: grid; grid-template-columns: 232px 1fr; border: 1px solid var(--border);
border-radius: var(--r-md); overflow: hidden; background: var(--bg-card); min-height: 480px;
}
.tree { border-right: 1px solid var(--border); background: var(--panel); padding: 10px 0; }
.search {
margin: 0 10px 8px; padding: 6px 10px; border: 1px solid var(--border-2); border-radius: var(--r-sm);
background: var(--bg-card); color: var(--text-2); font-family: var(--mono); font-size: 11px;
outline: none; width: calc(100% - 20px);
}
.search:focus { border-color: rgba(0, 229, 255, 0.4); }
.grp {
padding: 10px 13px 4px; font-size: 10.5px; font-weight: 700; color: var(--text-3);
letter-spacing: 1.2px; display: flex; align-items: center; gap: 7px; font-family: var(--mono);
}
.grp .mark { width: 3px; height: 10px; background: var(--brand); box-shadow: 0 0 7px rgba(0, 229, 255, 0.6); }
.item {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
padding: 7px 13px; border-left: 2px solid transparent; cursor: pointer; transition: background 0.1s;
}
.item:hover { background: var(--bg-hover); }
.item.on { background: rgba(0, 229, 255, 0.08); border-left-color: var(--brand); }
.item.on .item-label { color: var(--brand); }
.item-names { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.item-label { font-size: 12.5px; color: var(--text); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-file { font-size: 9.5px; color: var(--text-3); }
.item-side { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.item-side .lamp { width: 5px; height: 5px; }
.cnt { font-size: 9.5px; color: var(--text-3); background: var(--bg-hover); border-radius: 999px; padding: 0 6px; }
.item.on .cnt { color: var(--brand); }
.main { display: flex; flex-direction: column; min-width: 0; }
.codehead {
display: flex; align-items: center; gap: 12px; padding: 13px 18px;
border-bottom: 1px solid var(--border); background: var(--bg-hover);
}
.fname { font-size: 14px; font-weight: 700; color: var(--text); }
.cls { font-size: 11px; }
.insts { padding: 6px 10px; flex: 1; }
.row {
display: grid; grid-template-columns: 1.25fr 1.9fr 0.8fr 1.1fr 1.6fr auto;
gap: 12px; align-items: center; padding: 13px 10px;
border-bottom: 1px solid var(--border-2); border-radius: var(--r-sm);
}
.row:hover { background: var(--bg-hover); }
.row:last-child { border-bottom: none; }
.iname { font-size: 13px; font-weight: 600; color: var(--text); cursor: pointer; }
.iname:hover { color: var(--brand); }
.iname .sub { display: block; font-size: 9.5px; font-weight: 400; color: var(--text-3); margin-top: 2px; }
.chip.drift { color: var(--amber); border-color: rgba(255, 176, 0, 0.5); background: rgba(255, 176, 0, 0.1); margin-left: 6px; }
.params { font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sym { font-size: 11.5px; color: var(--amber); }
.ret { font-weight: 700; font-size: 13.5px; }
.ret .sub { display: block; font-size: 9.5px; font-weight: 400; color: var(--text-3); }
.up { color: var(--lamp-err); }
.down { color: var(--lamp-ok); }
.runs { display: flex; gap: 5px; flex-wrap: wrap; }
.run-btn {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px;
background: transparent; border: 1px solid var(--border-2); border-radius: var(--r-sm);
color: var(--text-3); font-family: var(--mono); font-size: 10.5px; 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: rgba(46, 230, 138, 0.35); }
.run-btn .lamp { width: 5px; height: 5px; }
.ops { display: flex; gap: 5px; justify-content: flex-end; }
.term-btn.warn { color: var(--amber); border-color: rgba(255, 176, 0, 0.4); }
.runsview { padding: 6px 10px; }
.runrow {
display: grid; grid-template-columns: 0.7fr 1.3fr 1.4fr 0.9fr auto 0.4fr;
gap: 12px; align-items: center; padding: 12px 10px;
border-bottom: 1px solid var(--border-2); font-size: 12px;
}
.runrow:hover { background: var(--bg-hover); }
.empty { padding: 34px 16px; color: var(--text-3); font-size: 12.5px; text-align: center; }
</style>