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]
This commit is contained in:
@@ -48,6 +48,8 @@ export interface LiveCreateRequest {
|
||||
pool?: string
|
||||
max_pool?: number
|
||||
benchmark?: string
|
||||
// §12.6 实例做实:绑档案发起(空=发起即建档)
|
||||
instance_id?: number | null
|
||||
}
|
||||
|
||||
export interface LiveStatus {
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface PaperCreate {
|
||||
benchmark?: string
|
||||
// 撮合引擎(影子柜台 P1):eod_replay=日终回放 / shadow=影子柜台
|
||||
engine?: string
|
||||
// §12.6 实例做实:绑档案发起(空=发起即建档)
|
||||
instance_id?: number | null
|
||||
}
|
||||
|
||||
export interface PaperAccount {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user