fix(frontend): 用户验收8项遗漏全补(并发中反馈漏处理对账)——①模拟/实盘改回必选档案(初始设计口径,提交拦截+必选文案)②backtest/new?class=all_weather.py&instance=1失败根治:档案类型按文件判非instance.type(老档案type不准)+组合页兼容class参数+CTA页组合档案自动重定向组合页(实测跳转+预填全通)③运行灯语义:有在跑→跳列表按档案过滤,无→创建页(用户'点带绿点按钮应看运行不该到参数页');组合档案隐藏回放按钮 ④代码编辑运行回测不好使:根因chain=dev缺GET file mock→读失败catch清code被当编辑→dirty卡死;三层修=补mock+catch走echoGrace+dirty改确认框(保存并跳/直接跳) ⑤模拟盘列表重设计对齐实盘(类型chip入名称行+新增策略/档案列+净值补日期) ⑥策略库去重复编辑代码按钮 ⑦模拟盘/实盘列表行加「快照」按钮→CodeSnapshotDrawer共享抽屉(发起时代码vs当前,MonacoDiff滚动联动);dev浏览器自验7项全过+927绿+build绿 [vps]
CI/CD / test (push) Successful in 16s
CI/CD / nas-deploy (push) Successful in 43s
CI/CD / nas-verify (push) Successful in 19s

This commit is contained in:
2026-08-16 08:25:57 +08:00
parent 11ccda271d
commit 23e1beb177
10 changed files with 231 additions and 28 deletions
+6
View File
@@ -31,6 +31,12 @@ const routes: Route[] = [
{ m: 'get', re: /^\/strategy\/files$/, build: () => D.strategyFilesMock },
{ m: 'post', re: /^\/strategy\/file\/[^/]+\/check$/, build: () => ({ ok: true }) },
{ m: 'post', re: /^\/strategy\/file\/[^/]+$/, build: () => ({ ok: true }) },
// 读单文件全量(编辑器主区)——此前 dev 缺此 mock,切文件报「读取失败」
{ m: 'get', re: /^\/strategy\/file\/[^/]+$/, build: (url) => {
const name = decodeURIComponent(url.split('/').filter(Boolean)[2] || '')
return { name, dir: '', class_name: '', type: 'cta', lines: 10, modified: '',
code: `# ${name}mock 源码)\n\nclass DemoStrategy:\n """dev mock"""\n\n def initialize(self, context):\n self.n = 3\n\n def handle_data(self, context, data):\n pass\n` }
} },
// §12.6 补:代码版本快照(列表在前,单版本 4 段路径在后)
{ m: 'get', re: /^\/strategy\/code-versions$/, build: (url) => D.codeVersionsMock(new URL(url, 'http://x').searchParams.get('file') || '') },
{ m: 'get', re: /^\/strategy\/code-versions\/[^/]+\/[^/]+$/, build: (url) => { const seg = url.split('/').filter(Boolean); return D.codeVersionMock(decodeURIComponent(seg[2]), seg[3]) } },
+16
View File
@@ -16,6 +16,7 @@ interface InstLike {
interface FileLike {
name: string
class_name: string
type?: string
}
const route = useRoute()
@@ -137,6 +138,21 @@ onMounted(async () => {
])
instanceOptions.value = ins
classToFile.value = Object.fromEntries(fr.data.files.map((f) => [f.class_name, f.name]))
// 组合策略档案误入 CTA 回测页 → 重定向组合页(老链接 class=all_weather.py 场景)
const cls = route.query.class
const instId0 = route.query.instance
const inst = instId0 && !Array.isArray(instId0)
? ins.find((x) => x.id === Number(instId0))
: undefined
const probe = inst?.code_file || (typeof cls === 'string' && cls.includes('.') ? cls : '')
const probeFile = fr.data.files.find((f) => f.name === probe || f.name === `${probe}.py`)
if (probeFile?.type === 'portfolio') {
router.replace({
path: '/backtest/portfolio',
query: { strategy: probeFile.name.replace(/\.py$/, ''), ...(instId0 ? { instance: String(instId0) } : {}) },
})
return
}
} catch {
/* 档案下拉失败不阻塞 */
}
@@ -389,9 +389,9 @@ const fromTaskId = computed(() => (route.query.from_task as string) || '')
onMounted(async () => {
if (viewTaskId.value) {
loadResult(viewTaskId.value)
} else if (route.query.strategy) {
// 从代码编辑页「运行回测」跳转:按策略预填
form.strategy = String(route.query.strategy)
} else if (route.query.strategy || route.query.class) {
// 跳转预填:strategy=文件名去.py;兼容旧链接的 class=(可能是 all_weather.py 带后缀)
form.strategy = String(route.query.strategy || route.query.class).replace(/\.py$/, '')
} else if (fromTaskId.value) {
// 从任务列表「点任务ID」跳转:按该任务参数完整预填
void prefillFromTask(fromTaskId.value)
+25 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import CodeSnapshotDrawer from '@/views/strategy/CodeSnapshotDrawer.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listLives, startLive, stopLive, deleteLive, updateLive,
@@ -102,6 +103,16 @@ async function onDelete(a: LiveAccount): Promise<void> {
}
// 编辑(仅 stopped 可改;后端运行中会 400)
// 代码快照(§12.6 补:查看该账户发起时的代码 vs 当前)
const snapshotRow = ref<{ instanceId: number; codeHash: string; label: string } | null>(null)
function openSnapshot(row: { id: number; name?: string; code_hash?: string; instance_id?: number }): void {
snapshotRow.value = {
instanceId: Number(row.instance_id),
codeHash: String(row.code_hash || ''),
label: row.name || `live#${row.id}`,
}
}
const editVisible = ref(false)
const editForm = reactive({ id: 0, name: '', account: '', vt_symbol: '', strategy_name: '', interval: '' })
function openEdit(a: LiveAccount): void {
@@ -235,7 +246,7 @@ async function onStop(a: LiveAccount): Promise<void> {
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="240">
<el-table-column label="操作" width="290">
<template #default="{ row }">
<el-button
v-if="row.status !== 'running'"
@@ -250,6 +261,11 @@ async function onStop(a: LiveAccount): Promise<void> {
@click="onStop(row)"
>停止</el-button>
<el-button link type="primary" size="small" @click="goMonitor(row)">监控</el-button>
<el-button
v-if="row.code_hash && row.instance_id"
link type="primary" size="small"
@click="openSnapshot(row)"
>快照</el-button>
<el-button v-if="row.status !== 'running'" link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.status !== 'running'" link type="danger" size="small" :loading="actionLoading === row.id" @click="onDelete(row)">删除</el-button>
</template>
@@ -257,6 +273,14 @@ async function onStop(a: LiveAccount): Promise<void> {
</el-table>
</el-card>
<CodeSnapshotDrawer
v-if="snapshotRow"
:instance-id="snapshotRow.instanceId"
:code-hash="snapshotRow.codeHash"
:label="snapshotRow.label"
@close="snapshotRow = null"
/>
<el-dialog v-model="editVisible" title="编辑实盘实例" width="480px">
<el-form :model="editForm" label-width="90px">
<el-form-item label="名称"><el-input v-model="editForm.name" /></el-form-item>
+9 -4
View File
@@ -126,6 +126,11 @@ function onPickInstance(id: number | null): void {
}
async function onSubmit(): Promise<void> {
// §12.6 初始设计口径(2026-08-16 用户重申):实盘必须从实例档案发起
if (selectedInstanceId.value == null) {
ElMessage.warning('实盘必须选择「实例档案」发起——可先在策略库创建档案')
return
}
if (!form.value.name.trim()) {
ElMessage.warning('请填写实例名')
return
@@ -232,7 +237,7 @@ async function onSubmit(): Promise<void> {
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="实例档案(可选·带出参数)"
placeholder="必选:实例档案(策略库可创建)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
@@ -242,7 +247,7 @@ async function onSubmit(): Promise<void> {
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}发起时快照</span>
<span v-else class="muted form-hint">实盘需 A 股适配,目前仅 DoubleMa 完成适配</span>
<span v-else class="muted form-hint" style="color: var(--amber)">必选实盘从档案发起 DoubleMa 完成 A 股适配</span>
</el-form-item>
<el-form-item label="标的(vt_symbol)">
<el-input v-model="form.vt_symbol" placeholder="600000 或 600000.SSE" style="width: 320px" />
@@ -319,7 +324,7 @@ async function onSubmit(): Promise<void> {
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="实例档案(可选·带出参数)"
placeholder="必选:实例档案(策略库可创建)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
@@ -329,7 +334,7 @@ async function onSubmit(): Promise<void> {
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}发起时快照</span>
<span v-else class="muted form-hint">supervisor 拉起 runner_live 子进程(bullet_trade LiveEngine)</span>
<span v-else class="muted form-hint" style="color: var(--amber)">必选实盘从档案发起supervisor 拉起 runner_live 子进程</span>
</el-form-item>
<el-form-item label="标的池">
<el-select v-model="poolForm.pool" style="width: 320px">
+51 -9
View File
@@ -2,6 +2,7 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import CodeSnapshotDrawer from '@/views/strategy/CodeSnapshotDrawer.vue'
import {
listPapers, stopPaper, resumePaper, deletePaper, updatePaper,
type PaperAccount,
@@ -52,6 +53,17 @@ const stats = computed(() => ({
}))
const modeLabel: Record<string, string> = { replay: '回放', live: '实走', shadow: '影子' }
// 行内解析策略名(strategies 是 JSON 字符串或已解析数组)
function strategyOf(row: { strategies?: unknown }): string {
const raw = row.strategies
let list: { name?: string }[] = []
if (typeof raw === 'string') {
try { list = JSON.parse(raw) as { name?: string }[] } catch { list = [] }
} else if (Array.isArray(raw)) {
list = raw as { name?: string }[]
}
return list[0]?.name || ''
}
const statusLabel: Record<string, string> = { running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队', stopped: '已停止' }
function pct(v: number | null | undefined): string {
@@ -137,6 +149,16 @@ async function onDelete(a: PaperAccount): Promise<void> {
}
}
// 代码快照(§12.6 补:查看该账户发起时的代码 vs 当前)
const snapshotRow = ref<{ instanceId: number; codeHash: string; label: string } | null>(null)
function openSnapshot(row: PaperAccount & { code_hash?: string; instance_id?: number }): void {
snapshotRow.value = {
instanceId: Number(row.instance_id),
codeHash: String(row.code_hash || ''),
label: row.name || `paper#${row.id}`,
}
}
// 编辑(MVP:名称/标的/初始资金)
const editVisible = ref(false)
const editForm = reactive({ id: 0, name: '', symbols: '', initial_capital: 0 })
@@ -207,17 +229,21 @@ async function saveEdit(): Promise<void> {
<el-card class="table-card" shadow="never">
<el-table :data="filtered" size="small" empty-text="暂无模拟盘">
<el-table-column label="名称 / ID" min-width="150">
<el-table-column label="名称 / ID" min-width="160">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-name">
{{ row.name }}
<span class="chip" :class="row.strategy_type === 'portfolio' ? 'chip-portfolio' : 'chip-cta'">
{{ typeLabel[row.strategy_type] || '个股' }}
</span>
</div>
<div class="cell-id mono">#{{ row.id }}</div>
</template>
</el-table-column>
<el-table-column label="类型" width="76">
<el-table-column label="策略 / 档案" min-width="170">
<template #default="{ row }">
<span class="chip" :class="row.strategy_type === 'portfolio' ? 'chip-portfolio' : 'chip-cta'">
{{ typeLabel[row.strategy_type] || row.strategy_type || '个股' }}
</span>
<div class="cell-name">{{ strategyOf(row) || '—' }}</div>
<div class="cell-id mono muted">{{ row.instance_id ? `档案 #${row.instance_id}` : '未绑档案' }}</div>
</template>
</el-table-column>
<el-table-column label="模式" width="92">
@@ -237,8 +263,11 @@ async function saveEdit(): Promise<void> {
<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="104" align="right">
<template #default="{ row }"><span class="mono">{{ num(row.latest_equity) }}</span></template>
<el-table-column label="最新净值" width="128" align="right">
<template #default="{ row }">
<div class="mono">{{ num(row.latest_equity) }}</div>
<div class="cell-id mono muted">{{ row.latest_date ?? '—' }}</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="142">
<template #default="{ row }"><span class="mono muted">{{ createdShort(row.created_at) }}</span></template>
@@ -246,9 +275,14 @@ async function saveEdit(): Promise<void> {
<el-table-column label="状态" width="80">
<template #default="{ row }"><span class="chip" :class="`st-${row.status}`">{{ statusLabel[row.status] || row.status }}</span></template>
</el-table-column>
<el-table-column label="操作" width="176" fixed="right">
<el-table-column label="操作" width="216" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="open(row)">查看</el-button>
<el-button
v-if="row.code_hash && row.instance_id"
link type="primary"
@click="openSnapshot(row)"
>快照</el-button>
<el-button
v-if="row.mode !== 'replay' && row.status === 'running'"
link type="warning" :loading="actionLoading === row.id"
@@ -266,6 +300,14 @@ async function saveEdit(): Promise<void> {
</el-table>
</el-card>
<CodeSnapshotDrawer
v-if="snapshotRow"
:instance-id="snapshotRow.instanceId"
:code-hash="snapshotRow.codeHash"
:label="snapshotRow.label"
@close="snapshotRow = null"
/>
<el-dialog v-model="editVisible" title="编辑模拟盘" width="480px">
<el-form :model="editForm" label-width="90px">
<el-form-item label="名称">
+9 -3
View File
@@ -166,6 +166,11 @@ watch(strategyType, (t) => {
})
async function onSubmit(): Promise<void> {
// §12.6 初始设计口径(2026-08-16 用户重申):模拟盘必须从实例档案发起
if (selectedInstanceId.value == null) {
ElMessage.warning('模拟盘必须选择「实例档案」发起——可先在策略库创建档案(选参数保存即成档案)')
return
}
loading.value = true
try {
const payload: PaperCreate = { ...form.value, instance_id: selectedInstanceId.value }
@@ -264,7 +269,7 @@ function onSymbols(v: string): void {
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="实例档案(可选·带出参数)"
placeholder="必选:实例档案(策略库可创建)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
@@ -274,6 +279,7 @@ function onSymbols(v: string): void {
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}发起时快照</span>
<span v-else class="muted form-hint" style="color: var(--amber)">必选模拟盘从档案发起</span>
</el-form-item>
<el-form-item label="标的池">
<el-select v-model="poolForm.pool" style="width: 320px">
@@ -330,7 +336,7 @@ function onSymbols(v: string): void {
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="实例档案(可选·带出参数)"
placeholder="必选:实例档案(策略库可创建)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
@@ -340,7 +346,7 @@ function onSymbols(v: string): void {
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}发起时快照</span>
<span v-else class="muted form-hint">CTA 策略个股 DoubleMaStrategy</span>
<span v-else class="muted form-hint" style="color: var(--amber)">必选模拟盘从档案发起CTA 个股策略</span>
</el-form-item>
<el-form-item label="撮合时点">
<el-select v-model="form.strategies[0].match_session" style="width: 280px">
+17 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import MonacoEditor from '@/components/MonacoEditor.vue'
import MonacoDiff from '@/components/MonacoDiff.vue'
import { apiClient } from '@/api/client'
@@ -71,7 +71,11 @@ async function select(f: StrategyFile): Promise<void> {
code.value = data.code
window.setTimeout(() => { echoGrace = false }, 300)
} catch {
// 失败也走 echoGrace:清空 code 的回写不能当用户编辑(否则 dirty 卡死,
// 运行回测被「未保存」拦死——2026-08-16 实锤链)
echoGrace = true
code.value = ''
window.setTimeout(() => { echoGrace = false }, 300)
ElMessage.error(`读取 ${f.name} 失败`)
} finally {
loadingCode.value = false
@@ -122,11 +126,20 @@ async function onSave(): Promise<void> {
}
// 跳到对应的回测参数页:初始值按当前策略预填,区间等参数由人调整
function runBacktest(): void {
async function runBacktest(): Promise<void> {
if (!active.value) return
if (dirty.value) {
ElMessage.warning('有未保存修改,请先保存再跳转回测')
return
// 未保存不再死拦:确认「保存并跳」/「直接跳」/取消
try {
await ElMessageBox.confirm('有未保存修改。保存后跳转,或直接跳转(不保存)?', '运行回测', {
confirmButtonText: '保存并跳转',
distinguishCancelAndClose: true,
cancelButtonText: '直接跳转',
})
await onSave()
} catch (action) {
if (action === 'close') return // 右上角 X = 取消整个跳转
}
}
if (active.value.type === 'portfolio') {
router.push({ path: '/backtest/portfolio', query: { strategy: active.value.name.replace(/\.py$/, '') } })
@@ -0,0 +1,72 @@
<script setup lang="ts">
// 行级代码快照查看(§12.6 补):模拟盘/实盘列表行「快照」按钮 → 该账户发起时的代码 vs 当前。
import { ref, onMounted } from 'vue'
import MonacoDiff from '@/components/MonacoDiff.vue'
import { apiClient } from '@/api/client'
import { getCodeVersion } from '@/api/strategy'
const props = defineProps<{
instanceId: number
codeHash: string // 32位 md5(账户 code_hash 列)
label: string
}>()
const emit = defineEmits<{ (e: 'close'): void }>()
const loading = ref(false)
const snapCode = ref<string | null>(null)
const curCode = ref<string | null>(null)
const diffReady = ref(false)
const err = ref('')
const codeFile = ref('')
async function load(): Promise<void> {
loading.value = true
try {
const { data: ir } = await apiClient.get<{ instance: { code_file: string } }>(`/strategy/instances/${props.instanceId}`)
codeFile.value = ir.instance?.code_file || ''
if (!codeFile.value) throw new Error('no code_file')
const [snap, cur] = await Promise.all([
getCodeVersion(codeFile.value, props.codeHash.slice(0, 8)),
apiClient.get<{ code: string }>(`/strategy/file/${codeFile.value}`),
])
snapCode.value = snap
curCode.value = cur.data.code
// 等抽屉动画展开后容器全宽再挂 Monaco(diff 分栏测量问题同 Code.vue 版本历史)
setTimeout(() => { diffReady.value = true }, 380)
} catch {
err.value = '快照不存在(该账户发起于版本快照上线前,或档案未关联代码文件)'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<el-drawer :model-value="true" size="860px" :with-header="false" append-to-body destroy-on-close @close="emit('close')">
<div v-loading="loading" class="snap">
<div class="snap-head">
<div>
<div style="font-size:15px;font-weight:700;color:var(--text)">代码快照 · {{ label }}</div>
<div class="mono muted" style="font-size:11px;margin-top:2px">
{{ codeFile || '…' }} · 发起版本 v{{ codeHash.slice(0, 8) }}
</div>
</div>
<button class="term-btn sm" @click="emit('close')">关闭</button>
</div>
<div v-if="err" class="muted" style="padding:24px 0;text-align:center;font-size:12.5px">{{ err }}</div>
<template v-else-if="diffReady && snapCode != null && curCode != null">
<div class="muted mono" style="font-size:11px;margin:6px 0">=发起时快照 · =当前代码 · 滚动联动</div>
<div class="snap-diff">
<MonacoDiff :original="snapCode" :modified="curCode" />
</div>
</template>
</div>
</el-drawer>
</template>
<style scoped>
.snap { display: flex; flex-direction: column; gap: 8px; padding: 4px 2px; }
.snap-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
.snap-diff { height: 520px; border: 1px solid var(--border-2); border-radius: var(--r-sm); overflow: hidden; }
</style>
+23 -4
View File
@@ -145,16 +145,36 @@ function newInstance(): void {
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
}
function runBacktest(i: Instance): void {
const isPf = fileTypeOf(i) === 'portfolio'
router.push({
path: i.type === 'portfolio' ? '/backtest/portfolio' : '/backtest/new',
query: { class: i.code_file, instance: String(i.id) },
path: isPf ? '/backtest/portfolio' : '/backtest/new',
// 组合页吃 strategy(文件名去.py)CTA 页吃 class(类名)
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),
},
})
}
// 运行灯语义:有在跑账户 → 看运行(列表按档案过滤);没有 → 发起(创建页带档案)
function runPaper(i: Instance, mode: 'replay' | 'live' | 'shadow'): void {
if (mode !== 'replay' && (i.running_accounts || []).some((a) => a.kind === 'paper' || a.kind === 'shadow')) {
router.push(`/paper?instance=${i.id}`)
return
}
router.push({ path: '/paper/new', query: { mode, instance: String(i.id) } })
}
function runLive(i: Instance): void {
if ((i.running_accounts || []).some((a) => a.kind === 'live')) {
router.push(`/live?instance=${i.id}`)
return
}
router.push({ path: '/live/new', query: { instance: String(i.id) } })
}
async function onSync(i: Instance): Promise<void> {
@@ -211,7 +231,6 @@ const runningRows = computed(() =>
<p class="page-subtitle">代码 实例档案 回测 / 模拟 / 实盘 · 全生命周期</p>
</div>
<div style="display:flex;gap:8px">
<button class="term-btn" @click="editCode(selectedFile)">编辑代码</button>
<button class="term-btn primary" @click="router.push('/strategy/new')">+ 新建策略</button>
</div>
</div>
@@ -297,7 +316,7 @@ const runningRows = computed(() =>
<div class="ret mono" :class="retClass(latestRet(i))">{{ pct(latestRet(i)) }}<span class="sub">{{ retSub(i) }}</span></div>
<div class="runs">
<button class="run-btn" @click="runBacktest(i)"><span class="lamp" :class="lampOf(i.status.backtest)"></span>回测</button>
<button class="run-btn" @click="runPaper(i, 'replay')"><span class="lamp" :class="lampOf(i.status.replay)"></span>回放</button>
<button v-if="fileTypeOf(i) !== 'portfolio'" class="run-btn" @click="runPaper(i, 'replay')"><span class="lamp" :class="lampOf(i.status.replay)"></span>回放</button>
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'paper' || a.kind === 'shadow') }" @click="runPaper(i, 'live')">
<span class="lamp" :class="lampOf(i.status.paper_live)"></span>{{ runLabel(i, 'paper_live') }}
</button>