feat(frontend): 终端化重构+策略管理(代码→实例→4运行)+撮合设计+回测参数 [nas]
- 终端化: 科幻色板(tokens/EP/reset/chips/echarts)+Layout(Cmd+K/时钟/状态灯)+mock全接口+Dashboard/Result/Monitor样板+6图表配色 - 策略管理: 策略库三层(代码→实例→4运行)+新建策略(组合/CTA模板+Monaco)+在线代码编辑(Monaco)+实例CRUD(参数反射/标的池type切/interval/match_session) - 实例→运行关联: backtest/paper/live New页读instance预填 - 撮合设计: match_session(下一根K线开盘/收盘/集合竞价)+interval定频率+实走限日线/分钟走miniQMT - 回测参数: 基准下拉+日期默认1年localStorage记忆+滑点/手续费UI - 账户管理页+命名重整(模拟盘/实盘,消除撞名) - spec §11-13(菜单/策略管理/撮合设计) + Monaco编辑器 + 策略实例删除/编辑入口 + 列表实例列
This commit is contained in:
@@ -128,6 +128,9 @@ function statusLabel(s: string): string {
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="strategy" label="策略 / 因子" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="instance" label="实例" min-width="160">
|
||||
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="symbol" label="标的" width="92" />
|
||||
<el-table-column label="状态" width="84">
|
||||
<template #default="{ row }">
|
||||
|
||||
@@ -1,33 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getStrategies, getParams, type StrategyItem } from '@/api/strategy'
|
||||
import { submitCta } from '@/api/backtest'
|
||||
import { apiClient } from '@/api/client'
|
||||
|
||||
interface InstLike {
|
||||
code_file: string
|
||||
symbol_or_pool: string
|
||||
interval: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
interface FileLike {
|
||||
name: string
|
||||
class_name: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const strategies = ref<StrategyItem[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const paramsList = ref<string[]>([])
|
||||
const fromInstance = ref('')
|
||||
let inited = false
|
||||
|
||||
const BENCHMARK_OPTIONS = [
|
||||
{ label: '沪深 300', value: 'hs300' },
|
||||
{ label: '中证 500', value: 'zz500' },
|
||||
{ label: '中证 1000', value: 'zz1000' },
|
||||
{ label: '中证 2000', value: 'zz2000' },
|
||||
]
|
||||
function loadDateRange(): { start: string; end: string } {
|
||||
try {
|
||||
const s = localStorage.getItem('bt_date_range')
|
||||
if (s) return JSON.parse(s) as { start: string; end: string }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const end = new Date()
|
||||
const start = new Date(end)
|
||||
start.setFullYear(start.getFullYear() - 1)
|
||||
const f = (d: Date): string => d.toISOString().slice(0, 10)
|
||||
return { start: f(start), end: f(end) }
|
||||
}
|
||||
const dr = loadDateRange()
|
||||
const form = reactive({
|
||||
strategy: '',
|
||||
symbol: '600000',
|
||||
start: '2024-01-01',
|
||||
end: '2024-06-30',
|
||||
start: dr.start,
|
||||
end: dr.end,
|
||||
benchmark: 'hs300',
|
||||
interval: 'd',
|
||||
commission_rate: 0.0003,
|
||||
stamp_duty_rate: 0.001,
|
||||
slippage: 0.001,
|
||||
})
|
||||
watch(
|
||||
() => [form.start, form.end],
|
||||
([s, e]) => {
|
||||
try {
|
||||
localStorage.setItem('bt_date_range', JSON.stringify({ start: s, end: e }))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
)
|
||||
const paramValues = reactive<Record<string, string>>({})
|
||||
|
||||
async function loadParams(cls: string, override: Record<string, unknown> | null): Promise<void> {
|
||||
if (!cls) return
|
||||
try {
|
||||
const p = await getParams(cls)
|
||||
paramsList.value = p.parameters
|
||||
Object.keys(paramValues).forEach((k) => delete paramValues[k])
|
||||
p.parameters.forEach((k) => {
|
||||
const ov = override && override[k] != null ? override[k] : p.defaults[k]
|
||||
paramValues[k] = ov != null ? String(ov) : ''
|
||||
})
|
||||
} catch {
|
||||
paramsList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
strategies.value = await getStrategies()
|
||||
if (strategies.value.length && !form.strategy) {
|
||||
const instId = route.query.instance
|
||||
if (instId) {
|
||||
try {
|
||||
const [{ data: ir }, { data: fr }] = await Promise.all([
|
||||
apiClient.get<{ instance: InstLike & { name?: string } }>(`/strategy/instances/${instId}`),
|
||||
apiClient.get<{ files: FileLike[] }>('/strategy/files'),
|
||||
])
|
||||
const inst = ir.instance
|
||||
fromInstance.value = inst.name || String(instId)
|
||||
const file = fr.files.find((f) => f.name === inst.code_file)
|
||||
form.strategy = file?.class_name || inst.code_file
|
||||
form.symbol = inst.symbol_or_pool || form.symbol
|
||||
form.interval = inst.interval || form.interval
|
||||
await loadParams(form.strategy, inst.params)
|
||||
} catch {
|
||||
/* 预填失败,走默认 */
|
||||
}
|
||||
}
|
||||
if (!form.strategy && strategies.value.length) {
|
||||
form.strategy = strategies.value[0].name
|
||||
}
|
||||
inited = true
|
||||
if (!route.query.instance) await loadParams(form.strategy, null)
|
||||
} catch {
|
||||
ElMessage.error('策略列表加载失败')
|
||||
} finally {
|
||||
@@ -35,19 +118,13 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => form.strategy, async (name) => {
|
||||
if (!name) return
|
||||
try {
|
||||
const p = await getParams(name)
|
||||
paramsList.value = p.parameters
|
||||
Object.keys(paramValues).forEach((k) => delete paramValues[k])
|
||||
p.parameters.forEach((k) => {
|
||||
paramValues[k] = String(p.defaults[k] ?? '')
|
||||
})
|
||||
} catch {
|
||||
paramsList.value = []
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => form.strategy,
|
||||
async (name) => {
|
||||
if (!inited || !name) return
|
||||
await loadParams(name, null)
|
||||
},
|
||||
)
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (!form.strategy || !form.symbol) {
|
||||
@@ -69,6 +146,9 @@ async function onSubmit(): Promise<void> {
|
||||
end: form.end,
|
||||
benchmark: form.benchmark,
|
||||
interval: form.interval,
|
||||
commission_rate: Number(form.commission_rate),
|
||||
stamp_duty_rate: Number(form.stamp_duty_rate),
|
||||
slippage: Number(form.slippage),
|
||||
})
|
||||
ElMessage.success('回测已提交')
|
||||
router.push(`/backtest/progress/${tid}`)
|
||||
@@ -136,15 +216,32 @@ async function onSubmit(): Promise<void> {
|
||||
<template #header><span class="section-title">基准</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="比较基准">
|
||||
<el-radio-group v-model="form.benchmark">
|
||||
<el-radio-button value="hs300">沪深 300</el-radio-button>
|
||||
<el-radio-button value="zz500">中证 500</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-select v-model="form.benchmark" style="width: 220px">
|
||||
<el-option v-for="b in BENCHMARK_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
|
||||
</el-select>
|
||||
<span class="muted form-hint">用于计算 alpha / beta / 超额收益</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">费用与滑点</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="佣金率">
|
||||
<el-input v-model="form.commission_rate" style="width: 160px" />
|
||||
<span class="muted form-hint">0.0003 = 万3(双边,最低 5 元)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="印花税率">
|
||||
<el-input v-model="form.stamp_duty_rate" style="width: 160px" />
|
||||
<span class="muted form-hint">0.001 = 千1(仅卖出)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="滑点(比率)">
|
||||
<el-input v-model="form.slippage" style="width: 160px" />
|
||||
<span class="muted form-hint">0.001 = 万10</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="submit-bar">
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
|
||||
提交回测
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getResult, getEquityCurve, getDailyPnl, getTrades, getKline,
|
||||
getRelativeMetrics, getBenchmarkCurve, getRiskSeries, getLog,
|
||||
type EquityPoint, type PnlPoint, type Trade, type KlineBar,
|
||||
type RelativeMetrics, type BenchmarkCurveData, type RiskSeriesData,
|
||||
type BacktestResultInfo,
|
||||
} from '@/api/backtest'
|
||||
import TradesTable from '@/components/TradesTable.vue'
|
||||
import MetricCards from '@/components/backtest/MetricCards.vue'
|
||||
@@ -17,10 +19,10 @@ import VolatilityChart from '@/components/backtest/VolatilityChart.vue'
|
||||
import EquityCurve from '@/components/backtest/EquityCurve.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const taskId = String(route.params.id)
|
||||
const loading = ref(true)
|
||||
|
||||
// New data for enhanced result page
|
||||
const relativeMetrics = ref<RelativeMetrics>({
|
||||
total_return: 0,
|
||||
annual_return: 0,
|
||||
@@ -37,7 +39,7 @@ const relativeMetrics = ref<RelativeMetrics>({
|
||||
const benchmarkCurve = ref<BenchmarkCurveData>({ dates: [], strategy: [], benchmark: [] })
|
||||
const riskSeries = ref<RiskSeriesData>({ dates: [], alpha: [], beta: [], drawdown: [] })
|
||||
|
||||
// Existing data
|
||||
const resultInfo = ref<BacktestResultInfo | null>(null)
|
||||
const statistics = ref<Record<string, unknown>>({})
|
||||
const equity = ref<EquityPoint[]>([])
|
||||
const pnl = ref<PnlPoint[]>([])
|
||||
@@ -45,16 +47,14 @@ const trades = ref<Trade[]>([])
|
||||
const kline = ref<KlineBar[]>([])
|
||||
const logText = ref('')
|
||||
|
||||
// statEntries is no longer used in the new layout but kept for potential future use
|
||||
// const statEntries = computed(() =>
|
||||
// Object.entries(statistics.value)
|
||||
// .map(([k, v]) => ({
|
||||
// key: k,
|
||||
// value: typeof v === 'number' ? Math.round(v * 10000) / 10000 : v,
|
||||
// }))
|
||||
// )
|
||||
const statusLamp = computed<string>(() => {
|
||||
const s = resultInfo.value?.status
|
||||
if (s === 'done') return 'lamp-ok'
|
||||
if (s === 'running') return 'lamp-warn lamp-pulse'
|
||||
if (s === 'failed') return 'lamp-crit'
|
||||
return 'lamp-idle'
|
||||
})
|
||||
|
||||
// Time range filtering
|
||||
type TimeRange = '1w' | '1m' | '6m' | '1y' | 'all'
|
||||
const selectedTimeRange = ref<TimeRange>('all')
|
||||
|
||||
@@ -84,18 +84,16 @@ function filterDataByTimeRange<T extends { dates: string[] }>(data: T): T {
|
||||
return data
|
||||
}
|
||||
|
||||
const startIndex = dates.findIndex(date => new Date(date) >= cutoffDate)
|
||||
const startIndex = dates.findIndex((date) => new Date(date) >= cutoffDate)
|
||||
if (startIndex === -1) {
|
||||
const emptyData: any = { dates: [] }
|
||||
Object.keys(data).forEach(key => {
|
||||
if (key !== 'dates') {
|
||||
emptyData[key] = []
|
||||
}
|
||||
const emptyData: Record<string, unknown> = { dates: [] }
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key !== 'dates') emptyData[key] = []
|
||||
})
|
||||
return emptyData as T
|
||||
}
|
||||
|
||||
const filteredData: any = { dates: dates.slice(startIndex) }
|
||||
const filteredData: Record<string, unknown> = { dates: dates.slice(startIndex) }
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (key !== 'dates' && Array.isArray(value)) {
|
||||
filteredData[key] = value.slice(startIndex)
|
||||
@@ -110,16 +108,10 @@ function filterDataByTimeRange<T extends { dates: string[] }>(data: T): T {
|
||||
const filteredBenchmarkCurve = computed(() => filterDataByTimeRange(benchmarkCurve.value))
|
||||
const filteredRiskSeries = computed(() => filterDataByTimeRange(riskSeries.value))
|
||||
|
||||
// 净值曲线(走 equity-curve 接口,独立于 benchmark-curve)。benchmark-curve/risk-series
|
||||
// 依赖 *_metrics.json,CTA 引擎当前未生成该文件 → 收益概述图全空;equity-curve 直接
|
||||
// 来自 BacktestResult.equity_curve(零成交也有 N 天平线),单独画保证"有结果就有图"。
|
||||
const equityDates = computed(() => equity.value.map((e) => fmtDate(e.date)))
|
||||
const equityValues = computed(() => equity.value.map((e) => e.balance))
|
||||
|
||||
onMounted(async () => {
|
||||
// 请求隔离:任一接口失败(如 benchmark-curve/risk-series 数据缺失)不得阻塞
|
||||
// 其余请求。statistics/equity/trades 有数据时必须正常渲染。用 Promise.allSettled
|
||||
// 保留并发,逐个取值,失败项保留默认空值 + console.warn。
|
||||
const settled = await Promise.allSettled([
|
||||
getResult(taskId),
|
||||
getRelativeMetrics(taskId),
|
||||
@@ -132,42 +124,24 @@ onMounted(async () => {
|
||||
const [rInfo, rRel, rBench, rRisk, rEq, rPnl, rTr] = settled
|
||||
|
||||
if (rInfo.status === 'fulfilled') {
|
||||
resultInfo.value = rInfo.value
|
||||
statistics.value = rInfo.value.statistics || {}
|
||||
} else {
|
||||
console.warn('[Result] getResult failed:', rInfo.reason)
|
||||
}
|
||||
if (rRel.status === 'fulfilled') {
|
||||
relativeMetrics.value = rRel.value
|
||||
} else {
|
||||
console.warn('[Result] getRelativeMetrics failed:', rRel.reason)
|
||||
}
|
||||
if (rBench.status === 'fulfilled') {
|
||||
benchmarkCurve.value = rBench.value
|
||||
} else {
|
||||
console.warn('[Result] getBenchmarkCurve failed:', rBench.reason)
|
||||
}
|
||||
if (rRisk.status === 'fulfilled') {
|
||||
riskSeries.value = rRisk.value
|
||||
} else {
|
||||
console.warn('[Result] getRiskSeries failed:', rRisk.reason)
|
||||
}
|
||||
if (rEq.status === 'fulfilled') {
|
||||
equity.value = rEq.value
|
||||
} else {
|
||||
console.warn('[Result] getEquityCurve failed:', rEq.reason)
|
||||
}
|
||||
if (rPnl.status === 'fulfilled') {
|
||||
pnl.value = rPnl.value
|
||||
} else {
|
||||
console.warn('[Result] getDailyPnl failed:', rPnl.reason)
|
||||
}
|
||||
if (rTr.status === 'fulfilled') {
|
||||
trades.value = rTr.value
|
||||
} else {
|
||||
console.warn('[Result] getTrades failed:', rTr.reason)
|
||||
}
|
||||
if (rRel.status === 'fulfilled') relativeMetrics.value = rRel.value
|
||||
else console.warn('[Result] getRelativeMetrics failed:', rRel.reason)
|
||||
if (rBench.status === 'fulfilled') benchmarkCurve.value = rBench.value
|
||||
else console.warn('[Result] getBenchmarkCurve failed:', rBench.reason)
|
||||
if (rRisk.status === 'fulfilled') riskSeries.value = rRisk.value
|
||||
else console.warn('[Result] getRiskSeries failed:', rRisk.reason)
|
||||
if (rEq.status === 'fulfilled') equity.value = rEq.value
|
||||
else console.warn('[Result] getEquityCurve failed:', rEq.reason)
|
||||
if (rPnl.status === 'fulfilled') pnl.value = rPnl.value
|
||||
else console.warn('[Result] getDailyPnl failed:', rPnl.reason)
|
||||
if (rTr.status === 'fulfilled') trades.value = rTr.value
|
||||
else console.warn('[Result] getTrades failed:', rTr.reason)
|
||||
|
||||
// kline 依赖 getResult 返回的 symbol/start/end,单独隔离
|
||||
const info = rInfo.status === 'fulfilled' ? rInfo.value : null
|
||||
if (info?.symbol && info?.start && info?.end) {
|
||||
try {
|
||||
@@ -188,7 +162,6 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
// 每日收益格式化:浮点精度 → 2 位;日期去 00:00:00
|
||||
function fmtPnl(v: number | string): string {
|
||||
const n = typeof v === 'number' ? v : parseFloat(v)
|
||||
if (!Number.isFinite(n)) return String(v)
|
||||
@@ -204,187 +177,251 @@ function fmtDate(d: string): string {
|
||||
const s = String(d).slice(0, 19).replace('T', ' ')
|
||||
return s.endsWith(' 00:00:00') ? s.slice(0, 10) : s
|
||||
}
|
||||
|
||||
function onClone(): void {
|
||||
router.push('/backtest/new')
|
||||
}
|
||||
function onExport(): void {
|
||||
ElMessage.info('报告导出(Excel/PDF)· S2 上线')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2 class="page-title">回测结果 <span class="muted">{{ taskId }}</span></h2>
|
||||
<p class="page-subtitle">统计指标 / 基准对比 / 风险分析 / 交易明细</p>
|
||||
<div v-loading="loading" class="result-page">
|
||||
<!-- 顶栏:状态 + 任务元信息 + 操作 -->
|
||||
<div class="result-head term-corners">
|
||||
<div class="head-left">
|
||||
<div class="head-title">
|
||||
<span class="lamp" :class="statusLamp"></span>
|
||||
<span class="title-txt">回测结果</span>
|
||||
<span class="task-id mono">{{ taskId }}</span>
|
||||
</div>
|
||||
<div class="head-meta mono">
|
||||
<span class="meta-code">{{ resultInfo?.symbol || '—' }}</span>
|
||||
<span class="dim">›</span>
|
||||
<span>{{ resultInfo?.start || '—' }} → {{ resultInfo?.end || '—' }}</span>
|
||||
<span class="dim">›</span>
|
||||
<span>{{ resultInfo?.strategy || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="term-btn" @click="onClone">克隆重跑</button>
|
||||
<button class="term-btn" @click="onExport">导出报告</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top: Metric Cards -->
|
||||
<!-- 指标塔 -->
|
||||
<MetricCards :metrics="relativeMetrics" />
|
||||
|
||||
<!-- Time Range Selector -->
|
||||
<div class="time-range-selector">
|
||||
<span class="selector-label">时间范围:</span>
|
||||
<!-- 工具条:时间范围 -->
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-label mono">TIME RANGE</span>
|
||||
<el-radio-group v-model="selectedTimeRange" size="small">
|
||||
<el-radio-button value="1w">1周</el-radio-button>
|
||||
<el-radio-button value="1m">1月</el-radio-button>
|
||||
<el-radio-button value="6m">6月</el-radio-button>
|
||||
<el-radio-button value="1y">1年</el-radio-button>
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
<el-radio-button value="1w">1W</el-radio-button>
|
||||
<el-radio-button value="1m">1M</el-radio-button>
|
||||
<el-radio-button value="6m">6M</el-radio-button>
|
||||
<el-radio-button value="1y">1Y</el-radio-button>
|
||||
<el-radio-button value="all">ALL</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Layout -->
|
||||
<el-tabs type="border-card">
|
||||
<!-- Tab 1: 收益概述 -->
|
||||
<el-tab-pane label="收益概述">
|
||||
<div class="charts-container">
|
||||
<el-card>
|
||||
<EquityCurve :dates="equityDates" :equity="equityValues" />
|
||||
</el-card>
|
||||
<el-card>
|
||||
<BenchmarkCurve
|
||||
:dates="filteredBenchmarkCurve.dates"
|
||||
:strategy="filteredBenchmarkCurve.strategy"
|
||||
:benchmark="filteredBenchmarkCurve.benchmark"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card>
|
||||
<AlphaChart
|
||||
:dates="filteredRiskSeries.dates"
|
||||
:alpha="filteredRiskSeries.alpha"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card>
|
||||
<BetaChart
|
||||
:dates="filteredRiskSeries.dates"
|
||||
:beta="filteredRiskSeries.beta"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card>
|
||||
<DrawdownChart
|
||||
:dates="filteredRiskSeries.dates"
|
||||
:drawdown="filteredRiskSeries.drawdown"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card>
|
||||
<VolatilityChart
|
||||
:dates="filteredRiskSeries.dates"
|
||||
:strategy="filteredRiskSeries.strategy_vol ?? []"
|
||||
:benchmark="filteredRiskSeries.benchmark_vol ?? []"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<!-- 主图:净值 + 基准对比 -->
|
||||
<div class="grid-2">
|
||||
<el-card shadow="never" class="panel"><EquityCurve :dates="equityDates" :equity="equityValues" /></el-card>
|
||||
<el-card shadow="never" class="panel">
|
||||
<BenchmarkCurve
|
||||
:dates="filteredBenchmarkCurve.dates"
|
||||
:strategy="filteredBenchmarkCurve.strategy"
|
||||
:benchmark="filteredBenchmarkCurve.benchmark"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: 交易详情 -->
|
||||
<!-- 风险分析 4 图 -->
|
||||
<div class="section-bar"><span class="section-mark"></span>RISK ANALYSIS · 风险分析</div>
|
||||
<div class="grid-4">
|
||||
<el-card shadow="never" class="panel"><AlphaChart :dates="filteredRiskSeries.dates" :alpha="filteredRiskSeries.alpha" /></el-card>
|
||||
<el-card shadow="never" class="panel"><BetaChart :dates="filteredRiskSeries.dates" :beta="filteredRiskSeries.beta" /></el-card>
|
||||
<el-card shadow="never" class="panel"><DrawdownChart :dates="filteredRiskSeries.dates" :drawdown="filteredRiskSeries.drawdown" /></el-card>
|
||||
<el-card shadow="never" class="panel">
|
||||
<VolatilityChart
|
||||
:dates="filteredRiskSeries.dates"
|
||||
:strategy="filteredRiskSeries.strategy_vol ?? []"
|
||||
:benchmark="filteredRiskSeries.benchmark_vol ?? []"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 详情 tabs -->
|
||||
<el-tabs class="detail-tabs">
|
||||
<el-tab-pane label="交易详情">
|
||||
<el-card>
|
||||
<template #header><span class="section-title">成交记录</span></template>
|
||||
<el-card shadow="never">
|
||||
<TradesTable :trades="trades" />
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 每日持仓&收益 -->
|
||||
<el-tab-pane label="每日持仓&收益">
|
||||
<el-card>
|
||||
<template #header><span class="section-title">每日收益</span></template>
|
||||
<div class="daily-pnl-section">
|
||||
<el-table :data="pnl" stripe size="small" empty-text="无数据">
|
||||
<el-table-column label="日期" width="140">
|
||||
<template #default="{ row }"><span class="mono">{{ fmtDate(row.date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="收益" width="120" align="right">
|
||||
<template #default="{ row }"><span class="mono" :class="fmtPnlClass(row.pnl)">{{ fmtPnl(row.pnl) }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-tab-pane label="每日收益">
|
||||
<el-card shadow="never">
|
||||
<el-table :data="pnl" size="small" height="420" empty-text="无数据">
|
||||
<el-table-column label="日期" width="140">
|
||||
<template #default="{ row }"><span class="mono">{{ fmtDate(row.date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日收益 (‱)" width="140" align="right">
|
||||
<template #default="{ row }"><span class="mono" :class="fmtPnlClass(row.pnl)">{{ fmtPnl(row.pnl) }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 日志输出 -->
|
||||
<el-tab-pane label="日志输出">
|
||||
<el-card>
|
||||
<template #header><span class="section-title">系统日志</span></template>
|
||||
<pre class="log-output">{{ logText || '暂无日志数据' }}</pre>
|
||||
</el-card>
|
||||
<el-tab-pane label="系统日志">
|
||||
<pre class="log-output">{{ logText || '暂无日志数据' }}</pre>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #8b949e;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #8b949e;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.time-range-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 16px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.selector-label {
|
||||
color: #8b949e;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.charts-container {
|
||||
.result-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* —— 顶栏 —— */
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.charts-container .el-card {
|
||||
margin-bottom: 0;
|
||||
.head-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.daily-pnl-section {
|
||||
padding: 8px 0;
|
||||
.title-txt {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
background: #0d1117;
|
||||
color: #8b949e;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
.task-id {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
max-height: 400px;
|
||||
color: var(--brand);
|
||||
padding: 2px 8px;
|
||||
background: var(--cyan-soft);
|
||||
border: 1px solid rgba(0, 229, 255, 0.3);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.head-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.meta-code { color: var(--amber); }
|
||||
.dim { color: var(--text-3); }
|
||||
.head-actions { display: flex; gap: 8px; }
|
||||
.term-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-2);
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.3px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s var(--ease), color 0.15s var(--ease), box-shadow 0.15s var(--ease);
|
||||
}
|
||||
.term-btn:hover {
|
||||
border-color: rgba(0, 229, 255, 0.5);
|
||||
color: var(--brand);
|
||||
box-shadow: 0 0 12px rgba(0, 229, 255, 0.18);
|
||||
}
|
||||
|
||||
/* —— 工具条 —— */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.toolbar-label {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
/* —— 图表面板网格 —— */
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.panel {
|
||||
min-height: 280px;
|
||||
}
|
||||
.panel :deep(.el-card__body) { padding: 12px 14px; }
|
||||
|
||||
/* —— 分节标题 —— */
|
||||
.section-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-2);
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.section-mark {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background: var(--brand);
|
||||
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
|
||||
}
|
||||
|
||||
/* —— tabs —— */
|
||||
.detail-tabs {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.detail-tabs :deep(.el-tabs__header) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
/* —— 日志 —— */
|
||||
.log-output {
|
||||
background: var(--panel);
|
||||
color: var(--text-2);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--r-md);
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #e6edf3;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@media (max-width: 980px) {
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user