feat(strategy): 策略实例做实P0+P1+策略库A+B混合布局(spec§12.6定稿)——实例=策略档案:①绑定:paper/live账户+回测任务加instance_id(paper_accounts/live_accounts ALTER迁移),发起即建档(无档案自动建),绑已有档案时D1发起快照(账户用档案参数复印件)②回写:事件型(回测_on_done/回放线程)落盘update_instance_run;持续型(实走/影子/实盘)读时聚合_instance_runtime(四格覆盖+在跑账户+漂移检测)③D2/D3同步:POST /paper/sync/{id}批量刷运行中模拟账户(实走+影子锁死一致),实盘不在线改参④D5删除保护409⑤P1全景:GET instances/{id}/overview(全部运行账户+净值尾部+合并持仓归因)收编挂起项「按实例归因持仓」⑥前端:策略库重做A+B混合(统计条+在跑巡检模式+左栏代码树中文主显/文件副行+档案区漂移角标/同步/全景;STRATEGY_LABELS抽共享常量),InstanceOverview抽屉(echarts净值对比+归因持仓表),模拟盘/实盘表单加实例档案下拉(选中预填+绑定,路由?instance=直进);mock层enriched/sync/overview(路由序enriched先于{id});+7绑定测试,921绿,build绿,dev浏览器验收过 [vps]
CI/CD / test (push) Successful in 14s
CI/CD / nas-deploy (push) Successful in 52s
CI/CD / nas-verify (push) Successful in 17s

This commit is contained in:
2026-08-15 22:39:05 +08:00
parent 5615b11640
commit 256820d36f
24 changed files with 2048 additions and 204 deletions
+2
View File
@@ -48,6 +48,8 @@ export interface LiveCreateRequest {
pool?: string
max_pool?: number
benchmark?: string
// §12.6 实例做实:绑档案发起(空=发起即建档)
instance_id?: number | null
}
export interface LiveStatus {
+2
View File
@@ -23,6 +23,8 @@ export interface PaperCreate {
benchmark?: string
// 撮合引擎(影子柜台 P1):eod_replay=日终回放 / shadow=影子柜台
engine?: string
// §12.6 实例做实:绑档案发起(空=发起即建档)
instance_id?: number | null
}
export interface PaperAccount {
+64
View File
@@ -53,3 +53,67 @@ export async function updateStrategyConfig(id: number, req: StrategyConfigInput)
export async function deleteStrategyConfig(id: number): Promise<void> {
await apiClient.delete(`/strategy/configs/${id}`)
}
// ----- §12.6 策略实例做实:档案 + 读时聚合 -----
export interface RunningAccount {
kind: 'paper' | 'shadow' | 'live'
aid: number
label: string
ret: number | null
drifted?: boolean
}
export 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
running_accounts?: RunningAccount[]
drift?: boolean
}
export async function getInstances(): Promise<Instance[]> {
const { data } = await apiClient.get<{ instances: Instance[] }>('/strategy/instances/enriched')
return data.instances
}
export async function syncInstanceParams(instanceId: number): Promise<number> {
const { data } = await apiClient.post<{ synced: number }>(`/paper/sync/${instanceId}`)
return data.synced
}
export interface OverviewRun {
kind: 'paper' | 'shadow' | 'live'
aid: number
label: string | null
mode?: string
status: string | null
ret: number | null
equity: { date: string; equity: number }[]
}
export interface OverviewPosition {
symbol: string
volume: number
avg_price: number
accounts: { label: string; volume: number }[]
}
export interface InstanceOverview {
instance: { name: string; code_file: string; type: string; params: Record<string, unknown>; symbol_or_pool: string; interval: string }
runs: OverviewRun[]
positions: OverviewPosition[]
}
export async function getInstanceOverview(instanceId: number): Promise<InstanceOverview> {
const { data } = await apiClient.get<InstanceOverview>(`/strategy/instances/${instanceId}/overview`)
return data
}
+19
View File
@@ -0,0 +1,19 @@
// 策略显示名(中文主显;文件名/类名兜底)——策略库左栏与模拟盘下拉共用
export const STRATEGY_LABELS: Record<string, string> = {
all_weather: '全天候轮动',
momentum_timing: '牛熊动量',
value_selection: '价值精选',
small_cap: '小市值轮动',
channel_test: '通路测试',
// TET 对照副本(策略 session issue#19
all_weather_ex: '全天候·TET对照',
momentum_timing_ex: '牛熊·TET对照',
value_selection_ex: '价值·TET对照',
small_cap_ex: '小市值·TET对照',
}
/** 文件名 → 中文显示名;无映射回退去掉 .py 的文件名(CTA 无中文名) */
export function strategyLabel(fileName: string): string {
const key = fileName.replace(/\.py$/, '')
return STRATEGY_LABELS[key] || key
}
+37
View File
@@ -401,6 +401,43 @@ export const strategyInstancesMock: { instances: StrategyInstance[] } = {
],
}
/* §12.6 实例做实:enriched(读时聚合)与全景 overview mock */
export interface RunningAccountMock {
kind: 'paper' | 'shadow' | 'live'
aid: number
label: string
ret: number | null
drifted?: boolean
}
export const strategyInstancesEnrichedMock = {
instances: (strategyInstancesMock.instances as Array<StrategyInstance & { running_accounts?: RunningAccountMock[]; drift?: boolean }>).map((i) => ({
...i,
running_accounts: i.status.paper_live === 'running'
? [{ kind: 'paper', aid: 101, label: i.name, ret: i.last_return }]
: (i.status.live === 'running'
? [{ kind: 'live', aid: 201, label: `${i.name}·live`, ret: i.last_return }]
: []),
drift: i.id === 2,
})),
}
export function instanceOverviewMock(id: number) {
const inst = strategyInstancesMock.instances.find((i) => i.id === id) || strategyInstancesMock.instances[0]
const days = ['07-20', '07-21', '07-22', '07-23', '07-24', '07-25', '07-26', '07-27', '07-28']
const mk = (base: number, drift: number) => days.map((d, k) => ({ date: d, equity: Math.round(base * (1 + 0.002 * k * drift + (k % 3) * 0.001 * drift)) }))
return {
instance: { ...inst },
runs: [
{ kind: 'paper', aid: 101, label: inst.name, mode: 'live', status: 'running', ret: inst.last_return, equity: mk(1_000_000, 1) },
{ kind: 'shadow', aid: 102, label: `${inst.name}·影子`, mode: 'shadow', status: 'running', ret: (inst.last_return || 0) - 0.001, equity: mk(1_000_000, 0.98) },
],
positions: [
{ symbol: '600000', volume: 2000, avg_price: 10.52, accounts: [{ label: inst.name, volume: 2000 }] },
{ symbol: '600519', volume: 100, avg_price: 1480.0, accounts: [{ label: `${inst.name}·影子`, volume: 100 }] },
],
}
}
/* ========== 因子 factor ========== */
export const factorListMock = {
factors: [
+4
View File
@@ -20,9 +20,13 @@ const routes: Route[] = [
{ m: 'get', re: /^\/strategy\/list$/, build: () => D.strategiesMock },
// strategy instances(实例层 · 代码下参数变体)
{ m: 'get', re: /^\/strategy\/instances$/, build: () => D.strategyInstancesMock },
// enriched 必须排在单实例正则之前(否则 /instances/enriched 被当 {id} 吞)
{ m: 'get', re: /^\/strategy\/instances\/enriched$/, build: () => D.strategyInstancesEnrichedMock },
{ m: 'get', re: /^\/strategy\/instances\/[^/]+\/overview$/, build: (url) => D.instanceOverviewMock(Number(url.split('/')[3])) },
{ m: 'get', re: /^\/strategy\/instances\/[^/]+$/, build: (url) => { const id = Number(url.split('/')[3]); const inst = D.strategyInstancesMock.instances.find((i) => i.id === id); return { instance: inst ?? D.strategyInstancesMock.instances[0] } } },
{ m: 'post', re: /^\/strategy\/instances$/, build: () => ({ id: 99 }) },
{ m: 'delete', re: /^\/strategy\/instances\/[^/]+$/, build: () => ({ ok: true }) },
{ m: 'post', re: /^\/paper\/sync\/[^/]+$/, build: () => ({ synced: 2 }) },
// strategy code files(在线编辑)
{ m: 'get', re: /^\/strategy\/files$/, build: () => D.strategyFilesMock },
{ m: 'post', re: /^\/strategy\/file\/[^/]+\/check$/, build: () => ({ ok: true }) },
+44 -13
View File
@@ -4,7 +4,9 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createLive, type LiveCreateRequest } from '@/api/live'
import { apiClient } from '@/api/client'
import { getInstances, type Instance } from '@/api/strategy'
import { INTERVAL_OPTIONS } from '@/constants/intervals'
import { STRATEGY_LABELS } from '@/constants/strategy'
const route = useRoute()
const router = useRouter()
@@ -28,13 +30,10 @@ const BENCH_OPTIONS = [
{ label: '中证1000', value: '000852.XSHG' },
{ label: '中证2000', value: '932000.XSHG' },
]
const PORTFOLIO_LABELS: Record<string, string> = {
all_weather: '全天候轮动',
momentum_timing: '牛熊动量',
value_selection: '价值精选',
small_cap: '小市值轮动',
channel_test: '通路测试(影子vs实盘双轨)',
}
const PORTFOLIO_LABELS = STRATEGY_LABELS
// §12.6 选实例发起:选中即预填+绑定;不选=自由配置(后端发起即建档)
const instanceOptions = ref<Instance[]>([])
const selectedInstanceId = ref<number | null>(null)
onMounted(async () => {
try {
@@ -43,8 +42,14 @@ onMounted(async () => {
.filter((f) => f.type === 'portfolio')
.map((f) => f.name.replace(/\.py$/, ''))
} catch {
/* 下拉加载失败不阻塞表单 */
}
try {
instanceOptions.value = await getInstances()
} catch {
/* 档案下拉失败不阻塞 */
}
})
const form = ref<LiveCreateRequest>({
@@ -71,18 +76,17 @@ const strategyClassOptions = [
{ value: 'AShareDoubleMaStrategy', label: 'AShareDoubleMaStrategy(双均线 A 股策略)' },
]
onMounted(async () => {
const instId = route.query.instance
if (!instId) return
async function applyInstance(instId: number | string): Promise<void> {
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'),
apiClient.get<{ instance: { name?: string; code_file: string; symbol_or_pool: string; interval: string; params: Record<string, unknown>; type?: string } }>(`/strategy/instances/${instId}`),
apiClient.get<{ files: { name: string; class_name: string; type: 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
if (file?.type === 'portfolio' || file?.type === 'cta') strategyType.value = file.type
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
@@ -91,8 +95,20 @@ onMounted(async () => {
} catch {
/* 预填失败走默认 */
}
}
onMounted(() => {
const instId = route.query.instance
if (!instId || Array.isArray(instId)) return
selectedInstanceId.value = Number(instId)
applyInstance(instId)
})
function onPickInstance(id: number | null): void {
selectedInstanceId.value = id
if (id != null) applyInstance(id)
}
async function onSubmit(): Promise<void> {
if (!form.value.name.trim()) {
ElMessage.warning('请填写实例名')
@@ -112,7 +128,7 @@ async function onSubmit(): Promise<void> {
}
loading.value = true
try {
const payload: LiveCreateRequest = { ...form.value }
const payload: LiveCreateRequest = { ...form.value, instance_id: selectedInstanceId.value }
if (isPortfolio.value) {
payload.strategy_type = 'portfolio'
payload.strategy_class = portfolioStrategy.value
@@ -164,6 +180,21 @@ async function onSubmit(): Promise<void> {
</div>
<el-form label-width="140px" style="margin-top:16px">
<el-form-item label="实例档案">
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="选档案发起(推荐);不选=自由配置,提交后自动建档"
style="width: 420px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instanceOptions" :key="i.id" :value="i.id"
:label="`${i.name}${i.code_file || i.symbol_or_pool}·${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">已绑档案 #{{ selectedInstanceId }}提交用档案参数发起时快照</span>
<span v-else class="muted form-hint">策略库点实盘会带档案跳进来</span>
</el-form-item>
<el-form-item label="实例名" required>
<el-input v-model="form.name" placeholder="live-600000" style="width: 320px" />
<span class="muted form-hint">页面显示用</span>
+42 -6
View File
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createPaper, type PaperCreate } from '@/api/paper'
import { apiClient } from '@/api/client'
import { getStrategies, type StrategyItem } from '@/api/strategy'
import { getStrategies, getInstances, type StrategyItem, type Instance } from '@/api/strategy'
import { INTERVAL_OPTIONS } from '@/constants/intervals'
import { disableFutureDate } from '@/utils/dates'
@@ -17,6 +17,9 @@ const portfolioOptions = ref<string[]>([])
// 策略类型:cta=个股策略 / portfolio=组合策略(组合仅支持实走,历史回放走「组合回测」)
const strategyType = ref<'cta' | 'portfolio'>('cta')
// §12.6 选实例发起:选中即预填+绑定;不选=自由配置(后端发起即建档)
const instanceOptions = ref<Instance[]>([])
const selectedInstanceId = ref<number | null>(null)
const poolForm = reactive({ pool: 'hs300_subset', max_pool: 30, benchmark: '000300.XSHG' })
const POOL_OPTIONS = [
{ label: 'HS300 子集(小范围验证)', value: 'hs300_subset' },
@@ -49,6 +52,11 @@ onMounted(async () => {
} catch {
/* 下拉加载失败不阻塞表单 */
}
try {
instanceOptions.value = await getInstances()
} catch {
/* 档案下拉失败不阻塞 */
}
})
function loadDateRange(): { start: string; end: string } {
try {
@@ -94,13 +102,11 @@ const sessions = [
{ value: 'current_close', label: '当日收盘', desc: '尾盘型信号,当日收盘撮合' },
]
onMounted(async () => {
const instId = route.query.instance
if (!instId) return
async function applyInstance(instId: number | string): Promise<void> {
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'),
apiClient.get<{ files: { name: string; class_name: string; type: string }[] }>('/strategy/files'),
])
const inst = ir.instance
fromInstance.value = inst.name || String(instId)
@@ -110,12 +116,25 @@ onMounted(async () => {
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 (file?.type === 'portfolio' || file?.type === 'cta') strategyType.value = file.type
if (route.query.mode) form.value.mode = String(route.query.mode)
} catch {
/* 预填失败走默认 */
}
}
onMounted(() => {
const instId = route.query.instance
if (!instId || Array.isArray(instId)) return
selectedInstanceId.value = Number(instId)
applyInstance(instId)
})
function onPickInstance(id: number | null): void {
selectedInstanceId.value = id
if (id != null) applyInstance(id)
}
const isPortfolio = computed(() => strategyType.value === 'portfolio')
const portfolioStrategy = ref('all_weather')
@@ -138,7 +157,7 @@ watch(strategyType, (t) => {
async function onSubmit(): Promise<void> {
loading.value = true
try {
const payload: PaperCreate = { ...form.value }
const payload: PaperCreate = { ...form.value, instance_id: selectedInstanceId.value }
if (strategyType.value === 'portfolio') {
payload.strategy_type = 'portfolio'
payload.symbols = [poolForm.pool]
@@ -223,6 +242,23 @@ function onSymbols(v: string): void {
<el-card class="blk" shadow="never">
<template #header><span class="section-title">{{ isPortfolio ? '组合策略与选股' : '标的与策略' }}</span></template>
<el-form label-width="120px" style="margin-bottom: 4px">
<el-form-item label="实例档案">
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="选档案发起(推荐);不选=自由配置,提交后自动建档"
style="width: 420px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instanceOptions" :key="i.id" :value="i.id"
:label="`${i.name}${i.code_file || i.symbol_or_pool}·${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">已绑档案 #{{ selectedInstanceId }}提交用档案参数发起时快照</span>
<span v-else class="muted form-hint">策略库点实走/影子会带档案跳进来</span>
</el-form-item>
</el-form>
<el-form v-if="isPortfolio" :model="poolForm" label-width="120px">
<el-form-item label="组合策略">
<el-select v-model="portfolioStrategy" style="width: 320px">
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import type { Ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getInstanceOverview, type InstanceOverview as OverviewData } from '@/api/strategy'
import { useChart } from '@/composables/useChart'
import { darkTooltip, darkAxis } from '@/utils/echartsDark'
import { strategyLabel } from '@/constants/strategy'
const props = defineProps<{ instanceId: number }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const loading = ref(false)
const data = ref<OverviewData | null>(null)
const equityEl = ref<HTMLDivElement | undefined>()
const { setOption } = useChart(equityEl as Ref<HTMLDivElement | undefined>)
async function load(): Promise<void> {
loading.value = true
try {
data.value = await getInstanceOverview(props.instanceId)
renderEquity()
} catch {
ElMessage.error('全景加载失败')
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => props.instanceId, load)
function renderEquity(): void {
const runs = (data.value?.runs || []).filter((r) => r.equity.length >= 2)
const option = {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' as const, ...darkTooltip },
legend: { top: 0, textStyle: { color: '#9fb2bf', fontSize: 10 } },
grid: { left: 56, right: 16, top: 30, bottom: 26 },
xAxis: { type: 'category' as const, data: runs[0]?.equity.map((p) => p.date) || [], ...darkAxis },
yAxis: { type: 'value' as const, scale: true, ...darkAxis },
series: runs.map((r) => ({
name: `${r.label || r.kind}#${r.aid}`,
type: 'line' as const,
showSymbol: false,
lineStyle: { width: 1.4 },
data: r.equity.map((p) => p.equity),
})),
}
setOption(option)
}
const kindLabel: Record<string, string> = { paper: '模拟实走', shadow: '影子', live: '实盘' }
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')
const stLabel: Record<string, string> = { running: '运行中', done: '已完成', failed: '失败', stopped: '已停止', created: '已创建' }
</script>
<template>
<el-drawer
:model-value="true" size="680px" :with-header="false"
destroy-on-close append-to-body @close="emit('close')"
>
<div v-loading="loading" class="ov">
<template v-if="data">
<div class="ov-head">
<div>
<div class="ov-name">{{ data.instance.name }}</div>
<div class="mono muted" style="font-size:11px">
{{ strategyLabel(data.instance.code_file || '') }}{{ data.instance.code_file ? ` · ${data.instance.code_file}` : '' }}
· {{ data.instance.interval }} · {{ data.instance.symbol_or_pool }}
</div>
</div>
<button class="term-btn sm" @click="emit('close')">关闭</button>
</div>
<div class="sec">
<div class="sec-title">运行账户{{ data.runs.length }}</div>
<div class="runs">
<div v-for="r in data.runs" :key="`${r.kind}-${r.aid}`" class="run-chip">
<span class="chip" :class="r.kind === 'live' ? 'chip-cta' : 'chip-portfolio'">{{ kindLabel[r.kind] }}</span>
<span class="mono" style="font-size:11px">{{ r.label || `#${r.aid}` }}</span>
<span class="muted mono" style="font-size:10px">{{ stLabel[r.status || ''] || r.status }}</span>
<span class="mono ret" :class="retClass(r.ret)" style="font-weight:700">{{ pct(r.ret) }}</span>
</div>
<div v-if="!data.runs.length" class="muted" style="font-size:12px;padding:8px 0">该档案还没有账户运行</div>
</div>
</div>
<div class="sec">
<div class="sec-title">净值对比各账户归一</div>
<div ref="equityEl" class="chart" :class="{ empty: !data.runs.some(r => r.equity.length >= 2) }"></div>
<div v-if="!data.runs.some(r => r.equity.length >= 2)" class="muted chart-empty">暂无足够净值数据账户运行数日后生成</div>
</div>
<div class="sec">
<div class="sec-title">合并持仓归因{{ data.positions.length }} </div>
<el-table :data="data.positions" size="small" empty-text="暂无持仓">
<el-table-column prop="symbol" label="标的" width="100">
<template #default="{ row }"><span class="mono">{{ row.symbol }}</span></template>
</el-table-column>
<el-table-column prop="volume" label="总数量" width="110">
<template #default="{ row }"><span class="mono">{{ row.volume }}</span></template>
</el-table-column>
<el-table-column prop="avg_price" label="均价" width="100">
<template #default="{ row }"><span class="mono muted">{{ Number(row.avg_price).toFixed(2) }}</span></template>
</el-table-column>
<el-table-column label="归属">
<template #default="{ row }">
<span v-for="(a, i) in row.accounts" :key="i" class="chip" style="margin-right:6px">{{ a.label }} · {{ a.volume }}</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
</el-drawer>
</template>
<style scoped>
.ov { display: flex; flex-direction: column; gap: 16px; padding: 4px 2px; }
.ov-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
.ov-name { font-size: 16px; font-weight: 700; color: var(--text); }
.sec-title {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 1.5px; color: var(--text-3);
text-transform: uppercase; margin-bottom: 8px;
}
.runs { display: flex; flex-direction: column; gap: 6px; }
.run-chip {
display: flex; align-items: center; gap: 10px; padding: 7px 10px;
border: 1px solid var(--border-2); border-radius: var(--r-sm);
}
.run-chip .ret { margin-left: auto; }
.chart { height: 220px; }
.chart.empty { visibility: hidden; height: 0; }
.chart-empty { padding: 10px 0; font-size: 12px; text-align: center; }
.up { color: var(--lamp-err); }
.down { color: var(--lamp-ok); }
</style>
+336 -177
View File
@@ -3,6 +3,9 @@ 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
@@ -10,54 +13,134 @@ interface CodeFile {
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)
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'),
apiClient.get<{ instances: Instance[] }>('/strategy/instances'),
getInstances(),
])
files.value = f.data.files
instances.value = ins.data.instances
instances.value = ins
if (files.value.length) selectedFile.value = files.value[0].name
} catch {
ElMessage.error('策略库加载失败')
} finally {
loading.value = false
}
})
const grouped = computed(() =>
files.value.map((f) => ({
file: f,
instances: instances.value.filter((i) => i.code_file === f.name),
})),
)
// ===== 左栏:代码树(中文主显 + 文件名副行 + 实例数 + 在跑绿点)=====
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
}
// ===== 行内:运行灯(含在跑账户数标签,如实走×2 / 影子)=====
function runLabel(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): string {
const base: Record<string, string> = { backtest: '回测', replay: '回放', paper_live: '实走', live: '实盘' }
if (kind === 'paper_live') {
const runs = (i.running_accounts || []).filter((a) => a.kind === 'paper' || a.kind === 'shadow')
if (runs.length > 1) return `${runs.some((r) => r.kind === 'shadow') ? '影子' : base[kind]}×${runs.length}`
}
return base[kind]
}
function stTxt(s: string): string {
if (s === '-') return '—'
return ({ running: '运行', done: '已测', stopped: '已停', failed: '失败' } as Record<string, string>)[s] || s
}
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')
function latestRet(i: Instance): number | null {
const runs = (i.running_accounts || []).map((a) => a.ret)
const vals = runs.filter((r): r is number => r != null)
return vals.length ? vals[vals.length - 1] : i.last_return
}
function retSub(i: Instance): string {
const runs = i.running_accounts || []
if (runs.some((r) => r.kind === 'live')) return '实盘 · 最新'
if (runs.some((r) => r.kind === 'shadow')) return '影子 · 最新'
if (runs.some((r) => r.kind === 'paper')) return '模拟实走 · 最新'
if (i.status.backtest === 'done') return '回测 · 最新'
if (i.status.replay === 'done') return '回放 · 最新'
return '未运行'
}
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 newStrategy(): void {
router.push('/strategy/new')
}
function newInstance(f: CodeFile): void {
router.push({ path: '/strategy/instance/new', query: { code: f.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}`)
@@ -68,21 +151,35 @@ function runBacktest(i: Instance): void {
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 runPaper(i: Instance, mode: 'replay' | 'live' | 'shadow'): void {
router.push({ path: '/paper/new', query: { mode, instance: String(i.id) } })
}
function runLive(i: Instance): void {
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: '取消',
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
} catch {
return
@@ -91,173 +188,235 @@ async function onDelInst(i: Instance): Promise<void> {
await apiClient.delete(`/strategy/instances/${i.id}`)
instances.value = instances.value.filter((x) => x.id !== i.id)
ElMessage.success('已删除')
} catch {
ElMessage.error('删除失败')
} catch (e) {
// 409 删除保护:有运行中账户
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
ElMessage.warning(msg || '删除失败')
}
}
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(' · ')
// ===== 巡检模式展示 =====
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">
<div v-loading="loading" class="page lib">
<div class="page-head">
<div>
<h2 class="page-title">策略库</h2>
<p class="page-subtitle">策略代码 实例参数变体 回测 / 模拟 / 实盘 全生命周期</p>
<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 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>
<!-- 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 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">{{ 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 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>
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'live') }" @click="runLive(i)">
<span class="lamp" :class="lampOf(i.status.live)"></span>{{ stTxt(i.status.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>
.page { display: flex; flex-direction: column; gap: 16px; }
.lib { display: flex; flex-direction: column; gap: 14px; }
.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;
.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;
}
.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; }
.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); }
.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;
.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;
}
.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;
.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);
}
.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; }
.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: 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;
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: var(--border); }
.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; }
.inst-empty { padding: 16px 14px; font-size: 12px; }
.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>