Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2025-12-29 03:06:49 +08:00
commit f43312a858
292 changed files with 103739 additions and 0 deletions
@@ -0,0 +1,221 @@
<template>
<a-drawer
:title="$t('dashboard.indicator.backtest.historyTitle')"
:visible="visible"
:width="isMobile ? '100%' : 980"
:maskClosable="true"
@close="$emit('cancel')"
>
<div style="display:flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
<a-switch v-model="useCurrentFilters" />
<span style="color:#8c8c8c;">
{{ $t('dashboard.indicator.backtest.historyUseCurrent') }}
</span>
<a-button type="primary" :loading="loading" @click="loadRuns">
{{ $t('dashboard.indicator.backtest.historyRefresh') }}
</a-button>
<a-button
type="primary"
:disabled="selectedRowKeys.length === 0"
:loading="analyzing"
@click="handleAIAnalyze"
>
{{ $t('dashboard.indicator.backtest.historyAIAnalyze') }}
</a-button>
</div>
<div v-if="!useCurrentFilters" style="display:flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
<a-input v-model="filterSymbol" style="width: 220px" :placeholder="$t('dashboard.indicator.backtest.historyFilterSymbol')" />
<a-select v-model="filterTimeframe" style="width: 140px" :placeholder="$t('dashboard.indicator.backtest.historyFilterTimeframe')" allowClear>
<a-select-option v-for="tf in timeframes" :key="tf" :value="tf">{{ tf }}</a-select-option>
</a-select>
<a-button :loading="loading" @click="loadRuns">{{ $t('dashboard.indicator.backtest.historyApply') }}</a-button>
<span style="color:#8c8c8c;">{{ filterLabel }}</span>
</div>
<a-table
:columns="columns"
:data-source="runs"
:loading="loading"
size="small"
:pagination="{ pageSize: 10, size: 'small' }"
rowKey="id"
:scroll="{ x: 900 }"
:rowSelection="{ selectedRowKeys: selectedRowKeys, onChange: onRowSelectionChange }"
>
<template slot="range" slot-scope="text, record">
<span>{{ (record.start_date || '') }} ~ {{ (record.end_date || '') }}</span>
</template>
<template slot="status" slot-scope="text">
<a-tag :color="text === 'success' ? 'green' : text === 'failed' ? 'red' : 'blue'">
{{ text === 'success' ? $t('dashboard.indicator.backtest.historyStatusSuccess') : text === 'failed' ? $t('dashboard.indicator.backtest.historyStatusFailed') : text }}
</a-tag>
</template>
<template slot="actions" slot-scope="text, record">
<a-button type="link" size="small" :loading="detailLoadingId === record.id" @click="viewRun(record)">
{{ $t('dashboard.indicator.backtest.historyView') }}
</a-button>
</template>
</a-table>
<a-empty v-if="!loading && runs.length === 0" :description="$t('dashboard.indicator.backtest.historyNoData')" />
<a-modal
:title="$t('dashboard.indicator.backtest.historyAIAnalyzeTitle')"
:visible="showAIResult"
:footer="null"
:width="isMobile ? '100%' : 900"
@cancel="showAIResult = false"
>
<div v-if="analyzing" style="padding: 12px 0;">
<a-spin />
</div>
<div v-else style="white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;">
{{ aiResult || $t('dashboard.indicator.backtest.historyNoAIResult') }}
</div>
</a-modal>
</a-drawer>
</template>
<script>
import request from '@/utils/request'
export default {
name: 'BacktestHistoryDrawer',
props: {
visible: { type: Boolean, default: false },
userId: { type: [Number, String], default: 1 },
indicatorId: { type: [Number, String], default: null },
symbol: { type: String, default: '' },
market: { type: String, default: '' },
timeframe: { type: String, default: '' },
isMobile: { type: Boolean, default: false }
},
data () {
return {
loading: false,
detailLoadingId: null,
analyzing: false,
showAIResult: false,
aiResult: '',
useCurrentFilters: true,
filterSymbol: '',
filterTimeframe: '',
timeframes: ['1m', '5m', '15m', '30m', '1H', '4H', '1D', '1W'],
runs: [],
columns: [],
selectedRowKeys: []
}
},
computed: {
filterLabel () {
const parts = []
if (this.indicatorId) parts.push(`indicatorId=${this.indicatorId}`)
const m = this.useCurrentFilters ? this.market : (this.market || '')
const s = this.useCurrentFilters ? this.symbol : (this.filterSymbol || '')
const tf = this.useCurrentFilters ? this.timeframe : (this.filterTimeframe || '')
if (m) parts.push(`market=${m}`)
if (s) parts.push(`symbol=${s}`)
if (tf) parts.push(`timeframe=${tf}`)
return parts.length ? parts.join(' | ') : ''
}
},
watch: {
visible (val) {
if (val) {
this.initColumns()
this.useCurrentFilters = true
this.filterSymbol = this.symbol || ''
this.filterTimeframe = this.timeframe || ''
this.selectedRowKeys = []
this.aiResult = ''
this.showAIResult = false
this.loadRuns()
}
}
},
methods: {
onRowSelectionChange (keys) {
this.selectedRowKeys = keys || []
},
initColumns () {
if (this.columns.length) return
this.columns = [
{ title: this.$t('dashboard.indicator.backtest.historyRunId'), dataIndex: 'id', key: 'id', width: 90 },
{ title: this.$t('dashboard.indicator.backtest.historyCreatedAt'), dataIndex: 'created_at', key: 'created_at', width: 140 },
{ title: this.$t('dashboard.indicator.backtest.tradeDirection'), dataIndex: 'trade_direction', key: 'trade_direction', width: 90 },
{ title: this.$t('dashboard.indicator.backtest.leverage'), dataIndex: 'leverage', key: 'leverage', width: 90 },
{ title: this.$t('dashboard.indicator.backtest.historyRange'), key: 'range', width: 220, scopedSlots: { customRender: 'range' } },
{ title: this.$t('dashboard.indicator.backtest.historyStatus'), dataIndex: 'status', key: 'status', width: 90, scopedSlots: { customRender: 'status' } },
{ title: this.$t('dashboard.indicator.backtest.historyActions'), key: 'actions', width: 90, scopedSlots: { customRender: 'actions' } }
]
},
async loadRuns () {
if (!this.userId) return
this.loading = true
try {
const symbol = this.useCurrentFilters ? this.symbol : (this.filterSymbol || '')
const timeframe = this.useCurrentFilters ? this.timeframe : (this.filterTimeframe || '')
const market = this.market || ''
const res = await request({
url: '/api/indicator/backtest/history',
method: 'post',
data: {
userid: this.userId,
limit: 100,
offset: 0,
indicatorId: this.indicatorId,
symbol,
market,
timeframe
}
})
if (res && res.code === 1 && Array.isArray(res.data)) {
this.runs = res.data
} else {
this.runs = []
}
} finally {
this.loading = false
}
},
async viewRun (record) {
if (!record || !record.id) return
this.detailLoadingId = record.id
try {
const res = await request({
url: '/api/indicator/backtest/get',
method: 'post',
data: { userid: this.userId, runId: record.id }
})
if (res && res.code === 1 && res.data) {
this.$emit('view', res.data)
}
} finally {
this.detailLoadingId = null
}
},
async handleAIAnalyze () {
if (!this.userId || !this.selectedRowKeys.length) return
this.analyzing = true
this.showAIResult = true
this.aiResult = ''
try {
const lang = (this.$i18n && this.$i18n.locale) ? this.$i18n.locale : 'zh-CN'
const res = await request({
url: '/api/indicator/backtest/aiAnalyze',
method: 'post',
data: { userid: this.userId, runIds: this.selectedRowKeys, lang }
})
if (res && res.code === 1 && res.data && res.data.analysis) {
this.aiResult = res.data.analysis
} else {
this.aiResult = res.msg || this.$t('dashboard.indicator.backtest.historyNoAIResult')
}
} finally {
this.analyzing = false
}
}
}
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,283 @@
<template>
<a-modal
:title="modalTitle"
:visible="visible"
:width="1100"
:maskClosable="false"
@cancel="$emit('cancel')"
class="backtest-run-viewer"
>
<div v-if="!run || !run.result" style="padding: 12px 0;">
<a-empty :description="$t('dashboard.indicator.backtest.historyNoData')" />
</div>
<div v-else>
<a-alert
v-if="run.id"
type="info"
show-icon
style="margin-bottom: 12px;"
:message="$t('dashboard.indicator.backtest.savedRunId', { id: run.id })"
/>
<!-- Metrics -->
<div class="metrics-cards">
<div class="metric-card" :class="{ positive: result.totalReturn > 0, negative: result.totalReturn < 0 }">
<div class="metric-label">{{ $t('dashboard.indicator.backtest.totalReturn') }}</div>
<div class="metric-value">{{ formatPercent(result.totalReturn) }}</div>
<div class="metric-amount">{{ formatMoney(result.totalProfit) }}</div>
</div>
<div class="metric-card" :class="{ positive: result.annualReturn > 0, negative: result.annualReturn < 0 }">
<div class="metric-label">{{ $t('dashboard.indicator.backtest.annualReturn') }}</div>
<div class="metric-value">{{ formatPercent(result.annualReturn) }}</div>
</div>
<div class="metric-card negative">
<div class="metric-label">{{ $t('dashboard.indicator.backtest.maxDrawdown') }}</div>
<div class="metric-value">{{ formatPercent(result.maxDrawdown) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ $t('dashboard.indicator.backtest.sharpeRatio') }}</div>
<div class="metric-value">{{ (result.sharpeRatio ?? 0).toFixed(2) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ $t('dashboard.indicator.backtest.winRate') }}</div>
<div class="metric-value">{{ formatPercent(result.winRate) }}</div>
</div>
<div class="metric-card" :class="{ positive: result.profitFactor >= 1.5, negative: result.profitFactor < 1 }">
<div class="metric-label">{{ $t('dashboard.indicator.backtest.profitFactor') }}</div>
<div class="metric-value">{{ (result.profitFactor ?? 0).toFixed(2) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ $t('dashboard.indicator.backtest.totalTrades') }}</div>
<div class="metric-value">{{ result.totalTrades ?? 0 }}</div>
</div>
<div class="metric-card negative">
<div class="metric-label">{{ $t('dashboard.indicator.backtest.totalCommission') }}</div>
<div class="metric-value">-${{ result.totalCommission ? result.totalCommission.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '0.00' }}</div>
</div>
</div>
<!-- Equity curve -->
<div class="chart-section">
<div class="chart-title">{{ $t('dashboard.indicator.backtest.equityCurve') }}</div>
<div ref="equityChartRef" class="equity-chart"></div>
</div>
<!-- Trades -->
<div class="trades-section">
<div class="chart-title">{{ $t('dashboard.indicator.backtest.tradeHistory') }}</div>
<a-table
:columns="tradeColumns"
:data-source="result.trades || []"
:pagination="{ pageSize: 10, size: 'small' }"
size="small"
:scroll="{ x: 800 }"
:rowKey="rowKey"
>
<template slot="type" slot-scope="text">
<a-tag :color="getTradeTypeColor(text)">
{{ getTradeTypeText(text) }}
</a-tag>
</template>
<template slot="balance" slot-scope="text">
<span style="color: #1890ff; font-weight: 500;">
${{ text ? text.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '--' }}
</span>
</template>
<template slot="profit" slot-scope="text">
<span :style="{ color: text > 0 ? '#52c41a' : text < 0 ? '#f5222d' : '#666' }">
{{ formatMoney(text) }}
</span>
</template>
</a-table>
</div>
</div>
<template slot="footer">
<a-button @click="$emit('cancel')">{{ $t('dashboard.indicator.backtest.close') }}</a-button>
</template>
</a-modal>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'BacktestRunViewer',
props: {
visible: { type: Boolean, default: false },
run: { type: Object, default: null }
},
data () {
return {
equityChart: null,
tradeColumns: []
}
},
computed: {
result () {
return (this.run && this.run.result) ? this.run.result : {}
},
modalTitle () {
const id = this.run && this.run.id ? `#${this.run.id}` : ''
return `${this.$t('dashboard.indicator.backtest.historyTitle')} ${id}`.trim()
}
},
watch: {
visible (val) {
if (val) {
this.$nextTick(() => {
this.initColumns()
this.renderEquityChart()
})
} else {
if (this.equityChart) {
this.equityChart.dispose()
this.equityChart = null
}
}
}
},
methods: {
rowKey (record, index) {
return index
},
initColumns () {
if (this.tradeColumns.length) return
this.tradeColumns = [
{ title: this.$t('dashboard.indicator.backtest.tradeTime'), dataIndex: 'time', key: 'time', width: 160 },
{ title: this.$t('dashboard.indicator.backtest.tradeType'), dataIndex: 'type', key: 'type', width: 150, scopedSlots: { customRender: 'type' } },
{ title: this.$t('dashboard.indicator.backtest.price'), dataIndex: 'price', key: 'price', width: 110 },
{ title: this.$t('dashboard.indicator.backtest.amount'), dataIndex: 'amount', key: 'amount', width: 100 },
{ title: this.$t('dashboard.indicator.backtest.profit'), dataIndex: 'profit', key: 'profit', width: 110, scopedSlots: { customRender: 'profit' } },
{ title: this.$t('dashboard.indicator.backtest.balance'), dataIndex: 'balance', key: 'balance', width: 120, scopedSlots: { customRender: 'balance' } }
]
},
getTradeTypeColor (type) {
const colorMap = {
buy: 'green',
sell: 'red',
liquidation: 'orange',
open_long: 'green',
add_long: 'cyan',
close_long: 'orange',
close_long_stop: 'red',
close_long_profit: 'lime',
close_long_trailing: 'gold',
reduce_long: 'volcano',
open_short: 'red',
add_short: 'magenta',
close_short: 'blue',
close_short_stop: 'red',
close_short_profit: 'cyan',
close_short_trailing: 'gold',
reduce_short: 'volcano'
}
return colorMap[type] || 'default'
},
getTradeTypeText (type) {
const textMap = {
// New format - long
open_long: this.$t('dashboard.indicator.backtest.openLong'),
add_long: this.$t('dashboard.indicator.backtest.addLong'),
close_long: this.$t('dashboard.indicator.backtest.closeLong'),
close_long_stop: this.$t('dashboard.indicator.backtest.closeLongStop'),
close_long_profit: this.$t('dashboard.indicator.backtest.closeLongProfit'),
close_long_trailing: this.$t('dashboard.indicator.backtest.closeLongTrailing'),
reduce_long: this.$t('dashboard.indicator.backtest.reduceLong'),
// New format - short
open_short: this.$t('dashboard.indicator.backtest.openShort'),
add_short: this.$t('dashboard.indicator.backtest.addShort'),
close_short: this.$t('dashboard.indicator.backtest.closeShort'),
close_short_stop: this.$t('dashboard.indicator.backtest.closeShortStop'),
close_short_profit: this.$t('dashboard.indicator.backtest.closeShortProfit'),
close_short_trailing: this.$t('dashboard.indicator.backtest.closeShortTrailing'),
reduce_short: this.$t('dashboard.indicator.backtest.reduceShort'),
liquidation: this.$t('dashboard.indicator.backtest.liquidation')
}
return textMap[type] || type
},
formatPercent (value) {
if (value === null || value === undefined) return '--'
const sign = value >= 0 ? '+' : ''
return `${sign}${Number(value).toFixed(2)}%`
},
formatMoney (value) {
if (value === null || value === undefined) return '--'
const sign = value >= 0 ? '+' : '-'
return `${sign}$${Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
},
renderEquityChart () {
if (!this.$refs.equityChartRef) return
if (this.equityChart) this.equityChart.dispose()
this.equityChart = echarts.init(this.$refs.equityChartRef)
const data = this.result.equityCurve || []
const dates = data.map(item => item.time || item.date)
const equity = data.map(item => item.value !== undefined ? item.value : item.equity)
const initialValue = equity[0] || 100000
const finalValue = equity[equity.length - 1] || initialValue
const isPositive = finalValue >= initialValue
const mainColor = isPositive ? '#52c41a' : '#f5222d'
const gradientColor = isPositive
? [{ offset: 0, color: 'rgba(82, 196, 26, 0.35)' }, { offset: 1, color: 'rgba(82, 196, 26, 0.02)' }]
: [{ offset: 0, color: 'rgba(245, 34, 45, 0.35)' }, { offset: 1, color: 'rgba(245, 34, 45, 0.02)' }]
const option = {
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
xAxis: { type: 'category', data: dates, boundaryGap: false },
yAxis: { type: 'value' },
series: [
{
name: this.$t('dashboard.indicator.backtest.strategy'),
type: 'line',
data: equity,
smooth: 0.4,
symbol: 'none',
sampling: 'lttb',
lineStyle: { width: 2.5, color: mainColor },
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, gradientColor) }
}
]
}
this.equityChart.setOption(option)
window.addEventListener('resize', () => this.equityChart && this.equityChart.resize())
}
}
}
</script>
<style lang="less" scoped>
.backtest-run-viewer {
:deep(.ant-modal-body) {
padding: 16px;
max-height: 70vh;
overflow-y: auto;
}
}
.metrics-cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
}
.metric-card.positive { border-color: rgba(82, 196, 26, 0.35); }
.metric-card.negative { border-color: rgba(245, 34, 45, 0.35); }
.metric-label { color: #8c8c8c; font-size: 12px; }
.metric-value { font-size: 18px; font-weight: 600; margin-top: 4px; }
.metric-amount { color: #8c8c8c; font-size: 12px; margin-top: 4px; }
.chart-section { margin-top: 8px; }
.chart-title { font-weight: 600; margin: 8px 0; color: #262626; }
.equity-chart { width: 100%; height: 280px; }
.trades-section { margin-top: 16px; }
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff