feat: improve backtest system and add OpenRouter balance query
This commit is contained in:
@@ -44,3 +44,13 @@ export function testConnection (service, params = {}) {
|
||||
data: { service, ...params }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 OpenRouter 账户余额
|
||||
*/
|
||||
export function getOpenRouterBalance () {
|
||||
return request({
|
||||
url: '/api/settings/openrouter-balance',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -729,6 +729,7 @@ const locale = {
|
||||
'dashboard.indicator.backtest.commission': 'Commission Rate (%)',
|
||||
'dashboard.indicator.backtest.commissionHint': 'Charged on notional value (incl. leverage) per trade; both entry & exit are deducted from balance.',
|
||||
'dashboard.indicator.backtest.leverage': 'Leverage',
|
||||
'dashboard.indicator.backtest.timeframe': 'Timeframe',
|
||||
'dashboard.indicator.backtest.tradeDirection': 'Trade Direction',
|
||||
'dashboard.indicator.backtest.longOnly': 'Long Only',
|
||||
'dashboard.indicator.backtest.shortOnly': 'Short Only',
|
||||
@@ -737,6 +738,11 @@ const locale = {
|
||||
'dashboard.indicator.backtest.rerun': 'Run Again',
|
||||
'dashboard.indicator.backtest.close': 'Close',
|
||||
'dashboard.indicator.backtest.running': 'Running indicator backtest...',
|
||||
'dashboard.indicator.backtest.loadingTip1': 'Fetching historical K-line data...',
|
||||
'dashboard.indicator.backtest.loadingTip2': 'Executing strategy signal calculation...',
|
||||
'dashboard.indicator.backtest.loadingTip3': 'Simulating trade execution...',
|
||||
'dashboard.indicator.backtest.loadingTip4': 'Calculating backtest metrics...',
|
||||
'dashboard.indicator.backtest.loadingTip5': 'Almost done, please wait...',
|
||||
'dashboard.indicator.backtest.results': 'Backtest Results',
|
||||
'dashboard.indicator.backtest.totalReturn': 'Total Return',
|
||||
'dashboard.indicator.backtest.annualReturn': 'Annual Return',
|
||||
@@ -782,6 +788,15 @@ const locale = {
|
||||
'dashboard.indicator.backtest.profit': 'P&L',
|
||||
'dashboard.indicator.backtest.success': 'Backtest completed',
|
||||
'dashboard.indicator.backtest.failed': 'Backtest failed, please try again later',
|
||||
'dashboard.indicator.backtest.quickSelect': 'Quick Select',
|
||||
// Multi-timeframe backtest precision hints
|
||||
'dashboard.indicator.backtest.precisionMode': 'Backtest Precision Mode',
|
||||
'dashboard.indicator.backtest.estimatedCandles': 'Est. {count} candles to process',
|
||||
'dashboard.indicator.backtest.highPrecisionDesc': 'Using 1-minute candles for high-precision backtest, stop-loss/take-profit triggers are more accurate',
|
||||
'dashboard.indicator.backtest.mediumPrecisionDesc': 'Range exceeds 30 days, using 5-minute candles to balance precision and performance',
|
||||
'dashboard.indicator.backtest.standardModeWarning': 'Using Standard Backtest Mode',
|
||||
'dashboard.indicator.backtest.standardModeDesc': 'Current config does not support high-precision backtest, using strategy timeframe',
|
||||
'dashboard.indicator.backtest.onlyCryptoSupported': 'High-precision backtest only supports cryptocurrency market',
|
||||
'dashboard.indicator.backtest.noIndicatorCode': 'This indicator has no backtestable code',
|
||||
'dashboard.indicator.backtest.noSymbol': 'Please select a trading symbol first',
|
||||
'dashboard.indicator.backtest.dateRangeExceeded': 'Backtest date range exceeded: {timeframe} timeframe allows max {maxRange}',
|
||||
@@ -1892,6 +1907,14 @@ const locale = {
|
||||
'settings.saveSuccess': 'Settings saved successfully',
|
||||
'settings.saveFailed': 'Failed to save settings',
|
||||
'settings.loadFailed': 'Failed to load settings',
|
||||
'settings.openrouterBalance': 'OpenRouter Account Balance',
|
||||
'settings.queryBalance': 'Query Balance',
|
||||
'settings.balanceUsage': 'Used',
|
||||
'settings.balanceRemaining': 'Remaining',
|
||||
'settings.balanceLimit': 'Total Limit',
|
||||
'settings.balanceQuerySuccess': 'Balance query successful',
|
||||
'settings.balanceQueryFailed': 'Failed to query balance',
|
||||
'settings.balanceNotQueried': 'Click "Query Balance" to get account info',
|
||||
'settings.default': 'Default',
|
||||
'settings.pleaseSelect': 'Please select',
|
||||
'settings.inputApiKey': 'Enter key',
|
||||
|
||||
@@ -638,6 +638,7 @@ const locale = {
|
||||
'dashboard.indicator.backtest.commission': '手续费率(%)',
|
||||
'dashboard.indicator.backtest.commissionHint': '按名义价值(含杠杆)收取;每笔成交(开/平仓)都会从余额扣除。',
|
||||
'dashboard.indicator.backtest.leverage': '杠杆倍率',
|
||||
'dashboard.indicator.backtest.timeframe': 'K线周期',
|
||||
'dashboard.indicator.backtest.tradeDirection': '交易方向',
|
||||
'dashboard.indicator.backtest.longOnly': '做多',
|
||||
'dashboard.indicator.backtest.shortOnly': '做空',
|
||||
@@ -646,6 +647,11 @@ const locale = {
|
||||
'dashboard.indicator.backtest.rerun': '重新回测',
|
||||
'dashboard.indicator.backtest.close': '关闭',
|
||||
'dashboard.indicator.backtest.running': '正在进行指标回测...',
|
||||
'dashboard.indicator.backtest.loadingTip1': '正在获取历史K线数据...',
|
||||
'dashboard.indicator.backtest.loadingTip2': '正在执行策略信号计算...',
|
||||
'dashboard.indicator.backtest.loadingTip3': '正在模拟交易执行...',
|
||||
'dashboard.indicator.backtest.loadingTip4': '正在计算回测指标...',
|
||||
'dashboard.indicator.backtest.loadingTip5': '即将完成,请稍候...',
|
||||
'dashboard.indicator.backtest.results': '回测结果',
|
||||
'dashboard.indicator.backtest.totalReturn': '总收益率',
|
||||
'dashboard.indicator.backtest.annualReturn': '年化收益',
|
||||
@@ -687,6 +693,15 @@ const locale = {
|
||||
'dashboard.indicator.backtest.profit': '盈亏',
|
||||
'dashboard.indicator.backtest.success': '回测完成',
|
||||
'dashboard.indicator.backtest.failed': '回测失败,请稍后重试',
|
||||
'dashboard.indicator.backtest.quickSelect': '快速选择',
|
||||
// 多时间框架回测精度提示
|
||||
'dashboard.indicator.backtest.precisionMode': '回测精度模式',
|
||||
'dashboard.indicator.backtest.estimatedCandles': '预计处理约 {count} 根K线',
|
||||
'dashboard.indicator.backtest.highPrecisionDesc': '使用1分钟级别K线进行高精度回测,止损止盈触发更精确',
|
||||
'dashboard.indicator.backtest.mediumPrecisionDesc': '回测区间超过30天,使用5分钟级别K线以平衡精度与性能',
|
||||
'dashboard.indicator.backtest.standardModeWarning': '使用标准回测模式',
|
||||
'dashboard.indicator.backtest.standardModeDesc': '当前配置不支持高精度回测,将使用策略K线进行回测',
|
||||
'dashboard.indicator.backtest.onlyCryptoSupported': '高精度回测仅支持加密货币市场',
|
||||
'dashboard.indicator.backtest.noIndicatorCode': '该指标没有可回测的代码',
|
||||
'dashboard.indicator.backtest.noSymbol': '请先选择交易标的',
|
||||
'dashboard.indicator.backtest.dateRangeExceeded': '回测时间范围超出限制:{timeframe}周期最多可回测{maxRange}',
|
||||
@@ -1631,6 +1646,14 @@ const locale = {
|
||||
'settings.saveSuccess': '设置保存成功',
|
||||
'settings.saveFailed': '保存设置失败',
|
||||
'settings.loadFailed': '加载配置失败',
|
||||
'settings.openrouterBalance': 'OpenRouter 账户余额',
|
||||
'settings.queryBalance': '查询余额',
|
||||
'settings.balanceUsage': '已使用',
|
||||
'settings.balanceRemaining': '剩余额度',
|
||||
'settings.balanceLimit': '总限额',
|
||||
'settings.balanceQuerySuccess': '余额查询成功',
|
||||
'settings.balanceQueryFailed': '余额查询失败',
|
||||
'settings.balanceNotQueried': '点击"查询余额"获取账户信息',
|
||||
'settings.default': '默认',
|
||||
'settings.pleaseSelect': '请选择',
|
||||
'settings.inputApiKey': '请输入密钥',
|
||||
|
||||
@@ -638,6 +638,7 @@ const locale = {
|
||||
'dashboard.indicator.backtest.commission': '手續費率(%)',
|
||||
'dashboard.indicator.backtest.commissionHint': '按名義價值(含槓桿)收取;每筆成交(開/平倉)都會從餘額扣除。',
|
||||
'dashboard.indicator.backtest.leverage': '槓杆倍率',
|
||||
'dashboard.indicator.backtest.timeframe': 'K線週期',
|
||||
'dashboard.indicator.backtest.tradeDirection': '交易方向',
|
||||
'dashboard.indicator.backtest.longOnly': '做多',
|
||||
'dashboard.indicator.backtest.shortOnly': '做空',
|
||||
@@ -646,6 +647,11 @@ const locale = {
|
||||
'dashboard.indicator.backtest.rerun': '重新回測',
|
||||
'dashboard.indicator.backtest.close': '關閉',
|
||||
'dashboard.indicator.backtest.running': '正在進行指標回測...',
|
||||
'dashboard.indicator.backtest.loadingTip1': '正在獲取歷史K線數據...',
|
||||
'dashboard.indicator.backtest.loadingTip2': '正在執行策略信號計算...',
|
||||
'dashboard.indicator.backtest.loadingTip3': '正在模擬交易執行...',
|
||||
'dashboard.indicator.backtest.loadingTip4': '正在計算回測指標...',
|
||||
'dashboard.indicator.backtest.loadingTip5': '即將完成,請稍候...',
|
||||
'dashboard.indicator.backtest.results': '回測結果',
|
||||
'dashboard.indicator.backtest.totalReturn': '總收益率',
|
||||
'dashboard.indicator.backtest.annualReturn': '年化收益',
|
||||
@@ -687,6 +693,15 @@ const locale = {
|
||||
'dashboard.indicator.backtest.profit': '盈虧',
|
||||
'dashboard.indicator.backtest.success': '回測完成',
|
||||
'dashboard.indicator.backtest.failed': '回測失敗,請稍後重試',
|
||||
'dashboard.indicator.backtest.quickSelect': '快速選擇',
|
||||
// 多時間框架回測精度提示
|
||||
'dashboard.indicator.backtest.precisionMode': '回測精度模式',
|
||||
'dashboard.indicator.backtest.estimatedCandles': '預計處理約 {count} 根K線',
|
||||
'dashboard.indicator.backtest.highPrecisionDesc': '使用1分鐘級別K線進行高精度回測,止損止盈觸發更精確',
|
||||
'dashboard.indicator.backtest.mediumPrecisionDesc': '回測區間超過30天,使用5分鐘級別K線以平衡精度與性能',
|
||||
'dashboard.indicator.backtest.standardModeWarning': '使用標準回測模式',
|
||||
'dashboard.indicator.backtest.standardModeDesc': '當前配置不支持高精度回測,將使用策略K線進行回測',
|
||||
'dashboard.indicator.backtest.onlyCryptoSupported': '高精度回測僅支持加密貨幣市場',
|
||||
'dashboard.indicator.backtest.noIndicatorCode': '該指標沒有可回測的代碼',
|
||||
'dashboard.indicator.backtest.noSymbol': '請先選擇交易標的',
|
||||
'dashboard.indicator.backtest.dateRangeExceeded': '回測時間範圍超出限制:{timeframe}周期最多可回測{maxRange}',
|
||||
@@ -1631,6 +1646,14 @@ const locale = {
|
||||
'settings.saveSuccess': '設置保存成功',
|
||||
'settings.saveFailed': '保存設置失敗',
|
||||
'settings.loadFailed': '加載配置失敗',
|
||||
'settings.openrouterBalance': 'OpenRouter 賬戶餘額',
|
||||
'settings.queryBalance': '查詢餘額',
|
||||
'settings.balanceUsage': '已使用',
|
||||
'settings.balanceRemaining': '剩餘額度',
|
||||
'settings.balanceLimit': '總限額',
|
||||
'settings.balanceQuerySuccess': '餘額查詢成功',
|
||||
'settings.balanceQueryFailed': '餘額查詢失敗',
|
||||
'settings.balanceNotQueried': '點擊"查詢餘額"獲取賬戶信息',
|
||||
'settings.default': '默認',
|
||||
'settings.pleaseSelect': '請選擇',
|
||||
'settings.inputApiKey': '請輸入密鑰',
|
||||
|
||||
@@ -309,12 +309,61 @@
|
||||
|
||||
<!-- Step 2: trading settings -->
|
||||
<div v-show="currentStep === 1">
|
||||
<!-- 合并的信息提示:交易对信息 + 精度信息 -->
|
||||
<a-alert
|
||||
type="info"
|
||||
:type="combinedAlertType"
|
||||
show-icon
|
||||
style="margin-bottom: 12px;"
|
||||
:message="$t('dashboard.indicator.backtest.metaLine', { symbol: symbol || '-', market: market || '-', timeframe: timeframe || '-' })"
|
||||
/>
|
||||
>
|
||||
<template slot="message">
|
||||
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||
<!-- 基本信息 -->
|
||||
<span>
|
||||
<strong>Symbol:</strong> {{ symbol || '-' }}
|
||||
<span style="color: #999; margin: 0 6px;">|</span>
|
||||
<strong>Market:</strong> {{ market || '-' }}
|
||||
<span style="color: #999; margin: 0 6px;">|</span>
|
||||
<strong>Timeframe:</strong> {{ selectedTimeframe || timeframe || '-' }}
|
||||
</span>
|
||||
<!-- 精度信息 -->
|
||||
<span v-if="precisionInfo && precisionInfo.enabled" style="margin-left: 12px; border-left: 1px solid #d9d9d9; padding-left: 12px;">
|
||||
<a-icon :type="precisionInfo.precision === 'high' ? 'thunderbolt' : 'clock-circle'" style="margin-right: 4px;" />
|
||||
{{ $t('dashboard.indicator.backtest.precisionMode') }}:
|
||||
<a-tag :color="precisionInfo.precision === 'high' ? 'green' : 'blue'" size="small" style="margin-left: 4px;">
|
||||
{{ precisionInfo.timeframe }}
|
||||
</a-tag>
|
||||
<span style="color: #666; margin-left: 6px;">
|
||||
({{ $t('dashboard.indicator.backtest.estimatedCandles', { count: precisionInfo.estimated_candles ? precisionInfo.estimated_candles.toLocaleString() : '-' }) }})
|
||||
</span>
|
||||
</span>
|
||||
<span v-else-if="precisionInfo && !precisionInfo.enabled && market && market.toLowerCase() === 'crypto'" style="margin-left: 12px; border-left: 1px solid #d9d9d9; padding-left: 12px; color: #faad14;">
|
||||
<a-icon type="warning" style="margin-right: 4px;" />
|
||||
{{ $t('dashboard.indicator.backtest.standardModeWarning') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="description">
|
||||
<span v-if="precisionInfo && precisionInfo.enabled" style="font-size: 12px; color: #888;">
|
||||
{{ precisionInfo.precision === 'high' ? $t('dashboard.indicator.backtest.highPrecisionDesc') : $t('dashboard.indicator.backtest.mediumPrecisionDesc') }}
|
||||
</span>
|
||||
<span v-else-if="precisionInfo && !precisionInfo.enabled && market && market.toLowerCase() === 'crypto'" style="font-size: 12px; color: #888;">
|
||||
{{ precisionInfo.message || $t('dashboard.indicator.backtest.standardModeDesc') }}
|
||||
</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- 快捷日期选择按钮 -->
|
||||
<div class="date-quick-select" style="margin-bottom: 12px;">
|
||||
<span style="margin-right: 8px; color: #666; font-size: 13px;">{{ $t('dashboard.indicator.backtest.quickSelect') || '快速选择' }}:</span>
|
||||
<a-button-group size="small">
|
||||
<a-button
|
||||
v-for="preset in datePresets"
|
||||
:key="preset.key"
|
||||
:type="selectedDatePreset === preset.key ? 'primary' : 'default'"
|
||||
@click="applyDatePreset(preset)"
|
||||
>{{ preset.label }}</a-button>
|
||||
</a-button-group>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
@@ -324,6 +373,7 @@
|
||||
style="width: 100%"
|
||||
:disabled-date="disabledStartDate"
|
||||
:placeholder="$t('dashboard.indicator.backtest.selectStartDate')"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -334,6 +384,7 @@
|
||||
style="width: 100%"
|
||||
:disabled-date="disabledEndDate"
|
||||
:placeholder="$t('dashboard.indicator.backtest.selectEndDate')"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -413,7 +464,24 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12"></a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="$t('dashboard.indicator.backtest.timeframe')">
|
||||
<a-select
|
||||
v-model="selectedTimeframe"
|
||||
style="width: 100%"
|
||||
@change="onTimeframeChange"
|
||||
>
|
||||
<a-select-option value="1m">1m</a-select-option>
|
||||
<a-select-option value="5m">5m</a-select-option>
|
||||
<a-select-option value="15m">15m</a-select-option>
|
||||
<a-select-option value="30m">30m</a-select-option>
|
||||
<a-select-option value="1H">1H</a-select-option>
|
||||
<a-select-option value="4H">4H</a-select-option>
|
||||
<a-select-option value="1D">1D</a-select-option>
|
||||
<a-select-option value="1W">1W</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</a-form>
|
||||
@@ -501,12 +569,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<!-- 加载状态 - 增强版动画 -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<a-spin size="large">
|
||||
<a-icon slot="indicator" type="loading" style="font-size: 32px; color: #1890ff" spin />
|
||||
</a-spin>
|
||||
<div class="loading-text">{{ $t('dashboard.indicator.backtest.running') }}</div>
|
||||
<div class="loading-content">
|
||||
<div class="loading-animation">
|
||||
<div class="chart-bars">
|
||||
<div class="bar bar1"></div>
|
||||
<div class="bar bar2"></div>
|
||||
<div class="bar bar3"></div>
|
||||
<div class="bar bar4"></div>
|
||||
<div class="bar bar5"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-text">{{ $t('dashboard.indicator.backtest.running') }}</div>
|
||||
<div class="loading-subtext">{{ loadingTip }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -581,6 +658,8 @@ export default {
|
||||
return {
|
||||
form: this.$form.createForm(this),
|
||||
loading: false,
|
||||
loadingTip: '',
|
||||
loadingTimer: null,
|
||||
currentStep: 0,
|
||||
hasResult: false,
|
||||
backtestRunId: null,
|
||||
@@ -588,6 +667,9 @@ export default {
|
||||
// Step1 UI state (Ant Form getFieldValue is not reactive)
|
||||
trailingEnabledUi: false,
|
||||
entryPctMaxUi: 100,
|
||||
precisionInfo: null, // 回测精度信息
|
||||
selectedDatePreset: null, // 当前选中的快捷日期
|
||||
selectedTimeframe: '1D', // 用户选择的时间周期(默认使用props传入的值)
|
||||
result: {
|
||||
totalReturn: 0,
|
||||
annualReturn: 0,
|
||||
@@ -609,26 +691,67 @@ export default {
|
||||
// 根据周期计算最大回测时间范围
|
||||
maxBacktestRange () {
|
||||
// 1分钟线:最多1个月
|
||||
if (this.timeframe === '1m') {
|
||||
return { months: 1, label: '1个月' }
|
||||
const tf = this.selectedTimeframe || this.timeframe || '1D'
|
||||
if (tf === '1m') {
|
||||
return { days: 30, label: '1个月' }
|
||||
}
|
||||
// 5分钟线:最多6个月
|
||||
if (this.timeframe === '5m') {
|
||||
return { months: 6, label: '6个月' }
|
||||
if (tf === '5m') {
|
||||
return { days: 180, label: '6个月' }
|
||||
}
|
||||
// 15分钟和30分钟:最多1年
|
||||
if (['15m', '30m'].includes(this.timeframe)) {
|
||||
return { years: 1, label: '1年' }
|
||||
if (['15m', '30m'].includes(tf)) {
|
||||
return { days: 365, label: '1年' }
|
||||
}
|
||||
// 1小时及以上:最多3年
|
||||
return { years: 3, label: '3年' }
|
||||
return { days: 1095, label: '3年' }
|
||||
},
|
||||
// 根据时间周期推荐的默认日期范围 - 统一默认30天
|
||||
recommendedRange () {
|
||||
return { days: 30, label: '30天', key: '30d' }
|
||||
},
|
||||
// 合并提示框的类型
|
||||
combinedAlertType () {
|
||||
if (this.precisionInfo && this.precisionInfo.enabled) {
|
||||
return this.precisionInfo.precision === 'high' ? 'success' : 'info'
|
||||
}
|
||||
if (this.precisionInfo && !this.precisionInfo.enabled && this.market && this.market.toLowerCase() === 'crypto') {
|
||||
return 'warning'
|
||||
}
|
||||
return 'info'
|
||||
},
|
||||
// 快捷日期选项 - 所有周期都包含30天作为默认选项
|
||||
datePresets () {
|
||||
const presets = []
|
||||
const tf = this.selectedTimeframe || this.timeframe || '1D'
|
||||
// 根据时间周期动态生成合理的快捷选项
|
||||
// 使用国际通用格式:7D, 14D, 30D, 3M, 6M, 1Y
|
||||
if (tf === '1m') {
|
||||
presets.push({ key: '7d', days: 7, label: '7D' })
|
||||
presets.push({ key: '14d', days: 14, label: '14D' })
|
||||
presets.push({ key: '30d', days: 30, label: '30D' })
|
||||
} else if (tf === '5m') {
|
||||
presets.push({ key: '14d', days: 14, label: '14D' })
|
||||
presets.push({ key: '30d', days: 30, label: '30D' })
|
||||
presets.push({ key: '90d', days: 90, label: '3M' })
|
||||
presets.push({ key: '180d', days: 180, label: '6M' })
|
||||
} else if (['15m', '30m'].includes(tf)) {
|
||||
presets.push({ key: '30d', days: 30, label: '30D' })
|
||||
presets.push({ key: '90d', days: 90, label: '3M' })
|
||||
presets.push({ key: '180d', days: 180, label: '6M' })
|
||||
presets.push({ key: '365d', days: 365, label: '1Y' })
|
||||
} else {
|
||||
// 1H, 4H, 1D, 1W
|
||||
presets.push({ key: '30d', days: 30, label: '30D' })
|
||||
presets.push({ key: '90d', days: 90, label: '3M' })
|
||||
presets.push({ key: '180d', days: 180, label: '6M' })
|
||||
presets.push({ key: '365d', days: 365, label: '1Y' })
|
||||
}
|
||||
return presets
|
||||
},
|
||||
defaultStartDate () {
|
||||
// 默认开始日期:根据周期限制
|
||||
if (this.maxBacktestRange.months) {
|
||||
return moment().subtract(this.maxBacktestRange.months, 'months')
|
||||
}
|
||||
return moment().subtract(1, 'years') // 默认1年
|
||||
// 默认开始日期:使用推荐范围
|
||||
return moment().subtract(this.recommendedRange.days, 'days')
|
||||
},
|
||||
defaultEndDate () {
|
||||
// 默认结束日期:今天
|
||||
@@ -636,10 +759,7 @@ export default {
|
||||
},
|
||||
// 最早可选日期
|
||||
earliestDate () {
|
||||
if (this.maxBacktestRange.months) {
|
||||
return moment().subtract(this.maxBacktestRange.months, 'months')
|
||||
}
|
||||
return moment().subtract(this.maxBacktestRange.years, 'years')
|
||||
return moment().subtract(this.maxBacktestRange.days, 'days')
|
||||
},
|
||||
labelCol () {
|
||||
// Wider label area in Step 1 to avoid overlap with inputs
|
||||
@@ -662,6 +782,9 @@ export default {
|
||||
this.step1CollapseKeys = ['risk']
|
||||
this.trailingEnabledUi = false
|
||||
this.entryPctMaxUi = 100
|
||||
this.precisionInfo = null
|
||||
this.selectedDatePreset = null
|
||||
this.selectedTimeframe = this.timeframe || '1D' // 初始化为props传入的时间周期
|
||||
this.result = {
|
||||
totalReturn: 0,
|
||||
annualReturn: 0,
|
||||
@@ -681,6 +804,10 @@ export default {
|
||||
// Sync non-reactive form values into UI state
|
||||
this.trailingEnabledUi = !!this.form.getFieldValue('trailingEnabled')
|
||||
this.recalcEntryPctMaxUi()
|
||||
// 默认选中30天
|
||||
this.selectedDatePreset = '30d'
|
||||
// 弹窗打开时立即获取精度信息(使用默认日期范围)
|
||||
this.fetchPrecisionInfo()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -873,29 +1000,93 @@ export default {
|
||||
// 如果已选择开始日期,限制结束日期不能超过开始日期+最大回测范围
|
||||
const startDate = this.form.getFieldValue('startDate')
|
||||
if (startDate) {
|
||||
const maxDays = this.maxBacktestRange.months
|
||||
? Math.floor(this.maxBacktestRange.months * 30.44)
|
||||
: (this.maxBacktestRange.years * 365)
|
||||
const maxDays = this.maxBacktestRange.days || 365
|
||||
const maxEndDate = moment(startDate).add(maxDays, 'days')
|
||||
if (current > maxEndDate.endOf('day')) return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
// 应用快捷日期选择
|
||||
applyDatePreset (preset) {
|
||||
this.selectedDatePreset = preset.key
|
||||
const endDate = moment()
|
||||
const startDate = moment().subtract(preset.days, 'days')
|
||||
this.form.setFieldsValue({
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
})
|
||||
// 更新精度信息
|
||||
this.fetchPrecisionInfo(startDate, endDate)
|
||||
},
|
||||
// 获取精度信息
|
||||
async fetchPrecisionInfo (startDate, endDate) {
|
||||
// 如果没有传入日期,尝试从表单获取或使用默认值
|
||||
if (!startDate || !endDate) {
|
||||
startDate = this.form ? this.form.getFieldValue('startDate') : null
|
||||
endDate = this.form ? this.form.getFieldValue('endDate') : null
|
||||
}
|
||||
// 如果还是没有,使用默认值
|
||||
if (!startDate) startDate = this.defaultStartDate
|
||||
if (!endDate) endDate = this.defaultEndDate
|
||||
|
||||
// 仅加密货币市场支持高精度回测
|
||||
if (!this.market || this.market.toLowerCase() !== 'crypto') {
|
||||
this.precisionInfo = {
|
||||
enabled: false,
|
||||
reason: 'only_crypto',
|
||||
message: this.$t('dashboard.indicator.backtest.onlyCryptoSupported')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/api/indicator/backtest/precision-info',
|
||||
method: 'post',
|
||||
data: {
|
||||
market: this.market,
|
||||
startDate: startDate.format('YYYY-MM-DD'),
|
||||
endDate: endDate.format('YYYY-MM-DD')
|
||||
}
|
||||
})
|
||||
|
||||
if (response.code === 1 && response.data) {
|
||||
this.precisionInfo = response.data
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,不影响正常使用
|
||||
this.precisionInfo = null
|
||||
}
|
||||
},
|
||||
// 时间周期变化时重新获取精度信息和更新快捷日期选项
|
||||
onTimeframeChange () {
|
||||
// 重置日期选择为默认30天
|
||||
this.selectedDatePreset = '30d'
|
||||
const endDate = moment()
|
||||
const startDate = moment().subtract(30, 'days')
|
||||
this.form.setFieldsValue({
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
})
|
||||
// 重新获取精度信息
|
||||
this.fetchPrecisionInfo(startDate, endDate)
|
||||
},
|
||||
// 日期变化时获取精度信息
|
||||
onDateChange () {
|
||||
this.selectedDatePreset = null // 清除快捷选择状态
|
||||
this.$nextTick(() => {
|
||||
this.fetchPrecisionInfo()
|
||||
})
|
||||
},
|
||||
// 验证日期范围
|
||||
validateDateRange (startDate, endDate) {
|
||||
if (!startDate || !endDate) return true
|
||||
const diffDays = endDate.diff(startDate, 'days')
|
||||
let maxDays = 0
|
||||
if (this.maxBacktestRange.months) {
|
||||
// 对于月份限制,使用实际月份天数(约30.44天/月)
|
||||
maxDays = Math.floor(this.maxBacktestRange.months * 30.44)
|
||||
} else if (this.maxBacktestRange.years) {
|
||||
maxDays = this.maxBacktestRange.years * 365
|
||||
}
|
||||
const maxDays = this.maxBacktestRange.days || 365
|
||||
if (diffDays > maxDays) {
|
||||
this.$message.error(this.$t('dashboard.indicator.backtest.dateRangeExceededDays', {
|
||||
timeframe: this.timeframe,
|
||||
timeframe: this.selectedTimeframe || this.timeframe,
|
||||
maxRange: this.maxBacktestRange.label,
|
||||
maxDays
|
||||
}))
|
||||
@@ -941,6 +1132,29 @@ export default {
|
||||
this.hasResult = false
|
||||
this.backtestRunId = null
|
||||
},
|
||||
// 加载动画提示轮播
|
||||
startLoadingAnimation () {
|
||||
const tips = [
|
||||
this.$t('dashboard.indicator.backtest.loadingTip1') || '正在获取历史K线数据...',
|
||||
this.$t('dashboard.indicator.backtest.loadingTip2') || '正在执行策略信号计算...',
|
||||
this.$t('dashboard.indicator.backtest.loadingTip3') || '正在模拟交易执行...',
|
||||
this.$t('dashboard.indicator.backtest.loadingTip4') || '正在计算回测指标...',
|
||||
this.$t('dashboard.indicator.backtest.loadingTip5') || '即将完成,请稍候...'
|
||||
]
|
||||
let idx = 0
|
||||
this.loadingTip = tips[0]
|
||||
this.loadingTimer = setInterval(() => {
|
||||
idx = (idx + 1) % tips.length
|
||||
this.loadingTip = tips[idx]
|
||||
}, 2000)
|
||||
},
|
||||
stopLoadingAnimation () {
|
||||
if (this.loadingTimer) {
|
||||
clearInterval(this.loadingTimer)
|
||||
this.loadingTimer = null
|
||||
}
|
||||
this.loadingTip = ''
|
||||
},
|
||||
async handleRunBacktest () {
|
||||
// Only validate Step 2 fields (dates/capital/fees/etc.)
|
||||
const step2Fields = ['startDate', 'endDate', 'initialCapital', 'commission', 'leverage', 'tradeDirection', 'slippage']
|
||||
@@ -970,6 +1184,7 @@ export default {
|
||||
|
||||
this.loading = true
|
||||
this.hasResult = false
|
||||
this.startLoadingAnimation()
|
||||
|
||||
try {
|
||||
const pct = (v) => Number(v || 0) / 100
|
||||
@@ -1019,16 +1234,18 @@ export default {
|
||||
indicatorId: this.indicator.id,
|
||||
symbol: this.symbol,
|
||||
market: this.market,
|
||||
timeframe: this.timeframe,
|
||||
timeframe: this.selectedTimeframe || this.timeframe,
|
||||
startDate: values.startDate.format('YYYY-MM-DD'),
|
||||
endDate: values.endDate.format('YYYY-MM-DD'),
|
||||
initialCapital: values.initialCapital,
|
||||
commission: pct(values.commission || 0),
|
||||
slippage: pct(values.slippage || 0),
|
||||
leverage: values.leverage || 1,
|
||||
tradeDirection: values.tradeDirection || 'long',
|
||||
strategyConfig
|
||||
}
|
||||
endDate: values.endDate.format('YYYY-MM-DD'),
|
||||
initialCapital: values.initialCapital,
|
||||
commission: pct(values.commission || 0),
|
||||
slippage: pct(values.slippage || 0),
|
||||
leverage: values.leverage || 1,
|
||||
tradeDirection: values.tradeDirection || 'long',
|
||||
strategyConfig,
|
||||
// 启用多时间框架高精度回测(加密货币市场)
|
||||
enableMtf: this.market && this.market.toLowerCase() === 'crypto'
|
||||
}
|
||||
|
||||
const response = await request({
|
||||
url: '/api/indicator/backtest',
|
||||
@@ -1054,6 +1271,7 @@ export default {
|
||||
} catch (error) {
|
||||
this.$message.error(this.$t('dashboard.indicator.backtest.failed'))
|
||||
} finally {
|
||||
this.stopLoadingAnimation()
|
||||
this.loading = false
|
||||
}
|
||||
})
|
||||
@@ -1249,6 +1467,22 @@ export default {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.precision-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.date-quick-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
@@ -1344,6 +1578,7 @@ export default {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1352,10 +1587,56 @@ export default {
|
||||
z-index: 100;
|
||||
border-radius: 8px;
|
||||
|
||||
.loading-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-animation {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
height: 60px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 8px;
|
||||
background: linear-gradient(180deg, #1890ff 0%, #52c41a 100%);
|
||||
border-radius: 4px;
|
||||
animation: barPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bar1 { height: 20px; animation-delay: 0s; }
|
||||
.bar2 { height: 35px; animation-delay: 0.1s; }
|
||||
.bar3 { height: 50px; animation-delay: 0.2s; }
|
||||
.bar4 { height: 35px; animation-delay: 0.3s; }
|
||||
.bar5 { height: 20px; animation-delay: 0.4s; }
|
||||
|
||||
@keyframes barPulse {
|
||||
0%, 100% { transform: scaleY(1); opacity: 0.7; }
|
||||
50% { transform: scaleY(1.5); opacity: 1; }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1890ff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.loading-subtext {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
animation: fadeInOut 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,6 +36,59 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- AI 组特殊:显示 OpenRouter 余额查询卡片 -->
|
||||
<div v-if="groupKey === 'ai'" class="openrouter-balance-card">
|
||||
<a-card size="small" :bordered="false">
|
||||
<div class="balance-header">
|
||||
<span class="balance-title">
|
||||
<a-icon type="wallet" style="margin-right: 6px;" />
|
||||
{{ $t('settings.openrouterBalance') || 'OpenRouter 账户余额' }}
|
||||
</span>
|
||||
<a-button size="small" type="primary" ghost :loading="balanceLoading" @click="queryOpenRouterBalance">
|
||||
<a-icon type="sync" />
|
||||
{{ $t('settings.queryBalance') || '查询余额' }}
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="openrouterBalance" class="balance-info">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-statistic
|
||||
:title="$t('settings.balanceUsage') || '已使用'"
|
||||
:value="openrouterBalance.usage"
|
||||
prefix="$"
|
||||
:precision="4"
|
||||
:value-style="{ color: '#cf1322' }"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-statistic
|
||||
:title="$t('settings.balanceRemaining') || '剩余额度'"
|
||||
:value="openrouterBalance.limit_remaining !== null ? openrouterBalance.limit_remaining : '∞'"
|
||||
:prefix="openrouterBalance.limit_remaining !== null ? '$' : ''"
|
||||
:precision="openrouterBalance.limit_remaining !== null ? 4 : 0"
|
||||
:value-style="{ color: openrouterBalance.limit_remaining !== null && openrouterBalance.limit_remaining < 1 ? '#cf1322' : '#3f8600' }"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-statistic
|
||||
:title="$t('settings.balanceLimit') || '总限额'"
|
||||
:value="openrouterBalance.limit !== null ? openrouterBalance.limit : '∞'"
|
||||
:prefix="openrouterBalance.limit !== null ? '$' : ''"
|
||||
:precision="openrouterBalance.limit !== null ? 2 : 0"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div v-if="openrouterBalance.is_free_tier" class="free-tier-badge">
|
||||
<a-tag color="blue">Free Tier</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="balance-empty">
|
||||
<a-icon type="info-circle" style="margin-right: 6px;" />
|
||||
{{ $t('settings.balanceNotQueried') || '点击"查询余额"获取账户信息' }}
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<a-form :form="form" layout="vertical" class="settings-form">
|
||||
<a-row :gutter="24">
|
||||
<a-col
|
||||
@@ -150,7 +203,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getSettingsSchema, getSettingsValues, saveSettings } from '@/api/settings'
|
||||
import { getSettingsSchema, getSettingsValues, saveSettings, getOpenRouterBalance } from '@/api/settings'
|
||||
import { baseMixin } from '@/store/app-mixin'
|
||||
|
||||
export default {
|
||||
@@ -164,7 +217,10 @@ export default {
|
||||
values: {},
|
||||
activeKeys: ['server', 'auth', 'ai', 'trading'],
|
||||
passwordVisible: {},
|
||||
showRestartTip: false
|
||||
showRestartTip: false,
|
||||
// OpenRouter 余额
|
||||
balanceLoading: false,
|
||||
openrouterBalance: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -215,6 +271,24 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 查询 OpenRouter 余额
|
||||
async queryOpenRouterBalance () {
|
||||
this.balanceLoading = true
|
||||
try {
|
||||
const res = await getOpenRouterBalance()
|
||||
if (res.code === 1 && res.data) {
|
||||
this.openrouterBalance = res.data
|
||||
this.$message.success(this.$t('settings.balanceQuerySuccess') || '余额查询成功')
|
||||
} else {
|
||||
this.$message.error(res.msg || this.$t('settings.balanceQueryFailed') || '余额查询失败')
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(this.$t('settings.balanceQueryFailed') || '余额查询失败')
|
||||
} finally {
|
||||
this.balanceLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
getGroupIcon (groupKey) {
|
||||
const icons = {
|
||||
auth: 'lock',
|
||||
@@ -400,6 +474,54 @@ export default {
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
// OpenRouter 余额查询卡片
|
||||
.openrouter-balance-card {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.ant-card {
|
||||
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
|
||||
border: 1px solid #91d5ff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.balance-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.balance-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.balance-info {
|
||||
padding: 8px 0;
|
||||
|
||||
/deep/ .ant-statistic-title {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/deep/ .ant-statistic-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.free-tier-badge {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.balance-empty {
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-collapse {
|
||||
background: transparent;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user