Files
DinQuant/quantdinger_vue/src/views/trading-assistant/index.vue
T
TIANHE d8e093bd60 new
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2025-12-30 18:47:42 +08:00

3858 lines
135 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="trading-assistant" :class="{ 'theme-dark': isDarkTheme }">
<a-row :gutter="24" class="strategy-layout">
<!-- 左侧策略列表 -->
<a-col
:xs="24"
:sm="24"
:md="10"
:lg="8"
:xl="8"
class="strategy-list-col">
<a-card :bordered="false" class="strategy-list-card">
<div slot="title" class="card-title">
<span>{{ $t('trading-assistant.strategyList') }}</span>
<a-button type="primary" size="small" @click="handleCreateStrategy">
<a-icon type="plus" />
{{ $t('trading-assistant.createStrategy') }}
</a-button>
</div>
<a-spin :spinning="loading">
<a-empty v-if="!loading && strategies.length === 0" :description="$t('trading-assistant.noStrategy')" />
<a-list
v-else
:data-source="strategies"
size="small"
>
<a-list-item
slot="renderItem"
slot-scope="item"
:class="['strategy-list-item', { active: selectedStrategy && selectedStrategy.id === item.id }]"
@click="handleSelectStrategy(item)"
>
<a-list-item-meta>
<template slot="title">
<div class="strategy-item-header">
<div class="strategy-name-wrapper">
<a-tag
v-if="item.exchange_config && item.exchange_config.exchange_id"
:color="getExchangeTagColor(item.exchange_config.exchange_id)"
size="small"
class="exchange-tag"
>
<a-icon type="bank" style="margin-right: 4px;" />
{{ getExchangeDisplayName(item.exchange_config.exchange_id) }}
</a-tag>
<span class="strategy-name">{{ item.strategy_name }}</span>
<a-tag
v-if="item.strategy_type === 'PromptBasedStrategy'"
color="purple"
size="small"
class="strategy-type-tag"
>
<a-icon type="robot" style="margin-right: 2px;" />
AI
</a-tag>
</div>
</div>
</template>
<template slot="description">
<div class="strategy-item-info">
<!-- <span class="info-item">
<a-icon type="line-chart" />
{{ getStrategyTypeText(item.strategy_type) }}
</span> -->
<span class="info-item" v-if="item.trading_config && item.trading_config.symbol">
<a-icon type="dollar" />
{{ item.trading_config.symbol }}
</span>
<span
class="status-label"
:class="[
item.status ? `status-${item.status}` : '',
{ 'status-stopped': item.status === 'stopped' }
]"
>
{{ getStatusText(item.status) }}
</span>
</div>
</template>
</a-list-item-meta>
<template slot="actions">
<a-dropdown
:getPopupContainer="getDropdownContainer"
:trigger="['click']">
<a-menu slot="overlay" @click="({ key }) => handleMenuClick(key, item)">
<a-menu-item v-if="item.status === 'stopped'" key="start">
<a-icon type="play-circle" />
{{ $t('trading-assistant.startStrategy') }}
</a-menu-item>
<a-menu-item v-if="item.status === 'running'" key="stop">
<a-icon type="pause-circle" />
{{ $t('trading-assistant.stopStrategy') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="edit">
<a-icon type="edit" />
{{ $t('trading-assistant.editStrategy') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="delete" class="danger-item">
<a-icon type="delete" />
{{ $t('trading-assistant.deleteStrategy') }}
</a-menu-item>
</a-menu>
<a-button type="link" icon="more" size="small" />
</a-dropdown>
</template>
</a-list-item>
</a-list>
</a-spin>
</a-card>
</a-col>
<!-- 右侧策略详情和交易记录 -->
<a-col
:xs="24"
:sm="24"
:md="14"
:lg="16"
:xl="16"
class="strategy-detail-col">
<div v-if="!selectedStrategy" class="empty-detail">
<a-empty :description="$t('trading-assistant.selectStrategy')" />
</div>
<div v-else class="strategy-detail-panel">
<!-- 策略头部信息 -->
<a-card :bordered="false" class="strategy-header-card">
<div class="strategy-header">
<div class="header-left">
<div class="strategy-title-row">
<h3 class="strategy-title">{{ selectedStrategy.strategy_name }}</h3>
<div class="status-badge" :class="[`status-${selectedStrategy.status}`]">
<span class="status-dot"></span>
{{ getStatusText(selectedStrategy.status) }}
</div>
</div>
<!-- 关键数据卡片 -->
<div class="key-stats-grid">
<div class="stat-card" v-if="selectedStrategy.initial_capital || (selectedStrategy.trading_config && selectedStrategy.trading_config.initial_capital)">
<div class="stat-icon investment">
<a-icon type="wallet" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('trading-assistant.detail.totalInvestment') }}</div>
<div class="stat-value">${{ ((selectedStrategy.initial_capital || selectedStrategy.trading_config?.initial_capital) || 0).toLocaleString() }}</div>
</div>
</div>
<div class="stat-card" v-if="currentEquity !== null">
<div class="stat-icon equity">
<a-icon type="fund" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('trading-assistant.detail.currentEquity') }}</div>
<div class="stat-value" :class="getEquityColorClass">{{ formatCurrency(currentEquity) }}</div>
</div>
</div>
<div class="stat-card pnl-card" v-if="totalPnl !== null" :class="{ 'profit': totalPnl > 0, 'loss': totalPnl < 0 }">
<div class="stat-icon pnl">
<a-icon :type="totalPnl >= 0 ? 'rise' : 'fall'" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('trading-assistant.detail.totalPnl') }}</div>
<div class="stat-value" :class="getPnlColorClass">
{{ formatPnl(totalPnl) }}
<span class="pnl-percent">({{ formatPnlPercent(totalPnlPercent) }})</span>
</div>
</div>
</div>
</div>
<!-- 策略详情标签 -->
<div class="strategy-tags">
<div class="tag-item" v-if="selectedStrategy.trading_config">
<a-icon type="stock" />
<span>{{ selectedStrategy.trading_config.symbol }}</span>
</div>
<div class="tag-item" v-if="selectedStrategy.indicator_config && selectedStrategy.indicator_config.indicator_name">
<a-icon type="line-chart" />
<span>{{ selectedStrategy.indicator_config.indicator_name }}</span>
</div>
<div class="tag-item" v-if="selectedStrategy.trading_config">
<a-icon type="thunderbolt" />
<span>{{ selectedStrategy.trading_config.leverage || 1 }}x</span>
</div>
<div class="tag-item" v-if="selectedStrategy.trading_config && selectedStrategy.trading_config.trade_direction">
<a-icon type="swap" />
<span>{{ getTradeDirectionText(selectedStrategy.trading_config.trade_direction) }}</span>
</div>
<div class="tag-item" v-if="selectedStrategy.trading_config && selectedStrategy.trading_config.timeframe">
<a-icon type="clock-circle" />
<span>{{ selectedStrategy.trading_config.timeframe }}</span>
</div>
</div>
</div>
<div class="header-right">
<a-button
v-if="selectedStrategy.status === 'stopped'"
type="primary"
size="large"
class="action-btn start-btn"
@click="handleStartStrategy(selectedStrategy.id)"
>
<a-icon type="play-circle" />
{{ $t('trading-assistant.startStrategy') }}
</a-button>
<a-button
v-if="selectedStrategy.status === 'running'"
type="danger"
size="large"
class="action-btn stop-btn"
@click="handleStopStrategy(selectedStrategy.id)"
>
<a-icon type="pause-circle" />
{{ $t('trading-assistant.stopStrategy') }}
</a-button>
</div>
</div>
</a-card>
<!-- 策略详情标签页 -->
<a-card :bordered="false" class="strategy-content-card">
<a-tabs defaultActiveKey="positions">
<a-tab-pane key="positions" :tab="$t('trading-assistant.tabs.positions')">
<position-records
:strategy-id="selectedStrategy.id"
:market-type="(selectedStrategy.trading_config && selectedStrategy.trading_config.market_type) || 'swap'"
:leverage="(selectedStrategy.trading_config && selectedStrategy.trading_config.leverage) || 1"
:loading="loadingRecords"
/>
</a-tab-pane>
<a-tab-pane key="trades" :tab="$t('trading-assistant.tabs.tradingRecords')">
<trading-records
:strategy-id="selectedStrategy.id"
:loading="loadingRecords"
/>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</a-col>
</a-row>
<!-- 创建/编辑策略弹窗 - 合并版本 -->
<a-modal
:visible="showFormModal"
:title="editingStrategy ? $t('trading-assistant.editStrategy') : $t('trading-assistant.createStrategy')"
:width="isMobile ? '95%' : 1100"
:confirmLoading="saving"
@ok="handleSubmit"
@cancel="handleCloseModal"
:maskClosable="false"
:wrapClassName="isMobile ? 'mobile-modal' : ''"
:bodyStyle="{ maxHeight: '70vh', overflowY: 'auto' }"
>
<a-spin :spinning="loadingIndicators">
<a-steps :current="currentStep" class="steps-container">
<a-step :title="$t('trading-assistant.form.step1')" />
<a-step :title="$t('trading-assistant.form.step2Params')" />
<a-step :title="$t('trading-assistant.form.step3Signal')" />
</a-steps>
<div class="form-container">
<!-- 步骤1: 指标策略-选择技术指标 / AI策略-基础配置 -->
<div v-show="currentStep === 0" class="step-content">
<!-- 指标策略选择技术指标 -->
<div v-if="strategyType === 'indicator'">
<a-form :form="form" layout="vertical">
<a-form-item :label="$t('trading-assistant.form.indicator')">
<a-select
v-decorator="['indicator_id', { rules: [{ required: true, message: $t('trading-assistant.validation.indicatorRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.selectIndicator')"
show-search
:filter-option="filterIndicatorOption"
@focus="handleIndicatorSelectFocus"
@change="handleIndicatorChange"
:loading="loadingIndicators"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
>
<a-select-option
v-for="indicator in availableIndicators"
:key="String(indicator.id)"
:value="String(indicator.id)"
>
<div class="indicator-option">
<span class="indicator-name">{{ indicator.name }}</span>
<a-tag v-if="indicator.type" size="small" :color="getIndicatorTypeColor(indicator.type)">
{{ getIndicatorTypeName(indicator.type) }}
</a-tag>
</div>
</a-select-option>
</a-select>
<div class="form-item-hint">
{{ $t('trading-assistant.form.indicatorHint') }}
</div>
</a-form-item>
<a-form-item v-if="selectedIndicator" :label="$t('trading-assistant.form.indicatorDescription')">
<div class="indicator-description">
{{ selectedIndicator.description || $t('trading-assistant.form.noDescription') }}
</div>
</a-form-item>
<a-divider />
<a-form-item :label="$t('trading-assistant.form.strategyName')">
<a-input
v-decorator="['strategy_name', { rules: [{ required: true, message: $t('trading-assistant.validation.strategyNameRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputStrategyName')"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.symbol')">
<a-select
v-decorator="['symbol', { rules: [{ required: true, message: $t('trading-assistant.validation.symbolRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.selectSymbol')"
show-search
:filter-option="filterWatchlistOption"
:loading="loadingWatchlist"
@change="handleWatchlistSymbolChange"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
>
<a-select-option
v-for="item in watchlist"
:key="`${item.market}:${item.symbol}`"
:value="`${item.market}:${item.symbol}`"
>
<div class="symbol-option">
<a-tag :color="getMarketColor(item.market)" style="margin-right: 8px; margin-bottom: 0;">
{{ item.market }}
</a-tag>
<span class="symbol-name">{{ item.symbol }}</span>
<span v-if="item.name" class="symbol-name-extra">{{ item.name }}</span>
</div>
</a-select-option>
</a-select>
<div class="form-item-hint">
{{ $t('trading-assistant.form.symbolHintCrypto') }}
</div>
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.initialCapital')">
<a-input-number
v-decorator="['initial_capital', { rules: [{ required: true, message: $t('trading-assistant.validation.initialCapitalRequired') }], initialValue: 1000 }]"
:min="10"
:step="100"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.marketType')">
<a-radio-group
v-decorator="['market_type', { initialValue: 'swap' }]"
@change="handleMarketTypeChange"
>
<a-radio value="swap">{{ $t('trading-assistant.form.marketTypeFutures') }}</a-radio>
<a-radio value="spot">{{ $t('trading-assistant.form.marketTypeSpot') }}</a-radio>
</a-radio-group>
<div class="form-item-hint">
{{ $t('trading-assistant.form.marketTypeHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="`${$t('trading-assistant.form.leverage')} (x)`">
<a-input-number
v-decorator="['leverage', { initialValue: 1, rules: [{ required: true, message: $t('trading-assistant.validation.leverageRequired') }] }]"
:min="1"
:max="form.getFieldValue('market_type') === 'spot' ? 1 : 125"
:step="1"
style="width: 100%"
:disabled="form.getFieldValue('market_type') === 'spot'"
/>
<div class="form-item-hint">
<span v-if="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.spotLeverageFixed') }}
</span>
<span v-else>
{{ $t('trading-assistant.form.leverageHint') }}
</span>
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.tradeDirection')">
<a-radio-group
v-decorator="['trade_direction', { initialValue: 'long' }]"
:disabled="form.getFieldValue('market_type') === 'spot'"
>
<a-radio value="long">{{ $t('trading-assistant.form.tradeDirectionLong') }}</a-radio>
<a-radio value="short" :disabled="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.tradeDirectionShort') }}
</a-radio>
<a-radio value="both" :disabled="form.getFieldValue('market_type') === 'spot'">
{{ $t('trading-assistant.form.tradeDirectionBoth') }}
</a-radio>
</a-radio-group>
<div v-if="form.getFieldValue('market_type') === 'spot'" class="form-item-hint" style="color: #ff9800;">
{{ $t('trading-assistant.form.spotOnlyLongHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :sm="24" :md="12" :lg="12">
<a-form-item :label="$t('trading-assistant.form.klinePeriod')">
<a-select
v-decorator="['timeframe', { initialValue: '1H', rules: [{ required: true }] }]"
:placeholder="$t('trading-assistant.placeholders.selectKlinePeriod')"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
>
<a-select-option value="1m">{{ $t('trading-assistant.form.timeframe1m') }}</a-select-option>
<a-select-option value="5m">{{ $t('trading-assistant.form.timeframe5m') }}</a-select-option>
<a-select-option value="15m">{{ $t('trading-assistant.form.timeframe15m') }}</a-select-option>
<a-select-option value="30m">{{ $t('trading-assistant.form.timeframe30m') }}</a-select-option>
<a-select-option value="1H">{{ $t('trading-assistant.form.timeframe1H') }}</a-select-option>
<a-select-option value="4H">{{ $t('trading-assistant.form.timeframe4H') }}</a-select-option>
<a-select-option value="1D">{{ $t('trading-assistant.form.timeframe1D') }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="12" :lg="12"></a-col>
</a-row>
</a-form>
</div>
<!-- Local mode: indicator strategy only (no AI strategy) -->
</div>
<!-- Step 2: params (backtest-like / trading params) -->
<div v-show="currentStep === 1" class="step-content">
<!-- 指标策略策略参数 -->
<div v-if="strategyType === 'indicator'">
<a-form :form="form" layout="vertical">
<!-- Backtest-like configuration (aligned with indicator-analysis BacktestModal) -->
<a-collapse v-model="backtestCollapseKeys" :bordered="false" style="background: #fafafa;">
<a-collapse-panel key="risk" :header="$t('dashboard.indicator.backtest.panel.risk')">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.stopLossPct')">
<a-input-number
v-decorator="['stop_loss_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.takeProfitPct')">
<a-input-number
v-decorator="['take_profit_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trailingEnabled')">
<a-switch
v-decorator="['trailing_enabled', { valuePropName: 'checked', initialValue: false }]"
@change="onTrailingToggle"
/>
</a-form-item>
</a-col>
<a-col :span="12"></a-col>
</a-row>
<template v-if="trailingEnabledUi">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trailingStopPct')">
<a-input-number
v-decorator="['trailing_stop_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trailingActivationPct')">
<a-input-number
v-decorator="['trailing_activation_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
</a-row>
</template>
</a-collapse-panel>
<a-collapse-panel key="scale" :header="$t('dashboard.indicator.backtest.panel.scale')">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendAddEnabled')">
<a-switch
v-decorator="['trend_add_enabled', { valuePropName: 'checked', initialValue: false }]"
@change="onTrendAddToggle"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.dcaAddEnabled')">
<a-switch
v-decorator="['dca_add_enabled', { valuePropName: 'checked', initialValue: false }]"
@change="onDcaAddToggle"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendAddStepPct')">
<a-input-number
v-decorator="['trend_add_step_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.dcaAddStepPct')">
<a-input-number
v-decorator="['dca_add_step_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendAddSizePct')">
<a-input-number
v-decorator="['trend_add_size_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.1"
:precision="4"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.dcaAddSizePct')">
<a-input-number
v-decorator="['dca_add_size_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.1"
:precision="4"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendAddMaxTimes')">
<a-input-number
v-decorator="['trend_add_max_times', { initialValue: 0 }]"
:min="0"
:max="50"
:step="1"
:precision="0"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.dcaAddMaxTimes')">
<a-input-number
v-decorator="['dca_add_max_times', { initialValue: 0 }]"
:min="0"
:max="50"
:step="1"
:precision="0"
style="width: 220px"
@change="onScaleParamsChange"
/>
</a-form-item>
</a-col>
</a-row>
</a-collapse-panel>
<a-collapse-panel key="reduce" :header="$t('dashboard.indicator.backtest.panel.reduce')">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendReduceEnabled')">
<a-switch v-decorator="['trend_reduce_enabled', { valuePropName: 'checked', initialValue: false }]" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.adverseReduceEnabled')">
<a-switch v-decorator="['adverse_reduce_enabled', { valuePropName: 'checked', initialValue: false }]" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendReduceStepPct')">
<a-input-number
v-decorator="['trend_reduce_step_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.adverseReduceStepPct')">
<a-input-number
v-decorator="['adverse_reduce_step_pct', { initialValue: 0 }]"
:min="0"
:max="1000"
:step="0.01"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendReduceSizePct')">
<a-input-number
v-decorator="['trend_reduce_size_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.1"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.adverseReduceSizePct')">
<a-input-number
v-decorator="['adverse_reduce_size_pct', { initialValue: 0 }]"
:min="0"
:max="100"
:step="0.1"
:precision="4"
style="width: 220px"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.trendReduceMaxTimes')">
<a-input-number
v-decorator="['trend_reduce_max_times', { initialValue: 0 }]"
:min="0"
:max="50"
:step="1"
:precision="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('dashboard.indicator.backtest.field.adverseReduceMaxTimes')">
<a-input-number
v-decorator="['adverse_reduce_max_times', { initialValue: 0 }]"
:min="0"
:max="50"
:step="1"
:precision="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-collapse-panel>
<a-collapse-panel key="position" :header="$t('dashboard.indicator.backtest.panel.position')">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item
:label="$t('dashboard.indicator.backtest.field.entryPct')"
:help="$t('dashboard.indicator.backtest.hint.entryPctMax', { maxPct: Number(entryPctMaxUi || 0).toFixed(0) })"
>
<a-input-number
v-decorator="['entry_pct', { initialValue: 100 }]"
:min="0"
:max="entryPctMaxUi"
:step="0.1"
:precision="4"
style="width: 220px"
@change="onEntryPctChange"
/>
</a-form-item>
</a-col>
<a-col :span="12"></a-col>
</a-row>
</a-collapse-panel>
</a-collapse>
<!-- Indicator strategy: AI decision filter -->
<div class="ai-filter-box">
<div class="ai-filter-header">
<div class="ai-filter-title">
<a-icon type="robot" />
<span>{{ $t('trading-assistant.form.enableAiFilter') }}</span>
</div>
<a-switch
:checked="aiFilterEnabledUi"
@change="onAiFilterToggle"
/>
</div>
<div class="ai-filter-hint">{{ $t('trading-assistant.form.enableAiFilterHint') }}</div>
</div>
</a-form>
</div>
<!-- Local mode: indicator strategy only (no AI strategy) -->
</div>
<!-- Step 3: signal push & optional live trading (crypto only) -->
<div v-show="currentStep === 2" class="step-content">
<a-form :form="form" layout="vertical" autocomplete="off">
<a-form-item :label="$t('trading-assistant.form.executionMode')">
<a-radio-group
v-decorator="['execution_mode', { initialValue: 'signal' }]"
:disabled="!isCryptoMarket"
@change="onExecutionModeChange"
>
<a-radio value="signal">{{ $t('trading-assistant.form.executionModeSignal') }}</a-radio>
<a-radio value="live" :disabled="!isCryptoMarket">{{ $t('trading-assistant.form.executionModeLive') }}</a-radio>
</a-radio-group>
<div v-if="!isCryptoMarket" class="form-item-hint" style="color: #ff9800;">
{{ $t('trading-assistant.form.liveTradingCryptoOnlyHint') }}
</div>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.notifyChannels')">
<a-checkbox-group
v-decorator="['notify_channels', { initialValue: ['browser'] }]"
@change="onNotifyChannelsChange"
>
<a-checkbox value="browser">{{ $t('trading-assistant.notify.browser') }}</a-checkbox>
<a-checkbox value="email">{{ $t('trading-assistant.notify.email') }}</a-checkbox>
<a-checkbox value="phone">{{ $t('trading-assistant.notify.phone') }}</a-checkbox>
<a-checkbox value="telegram">{{ $t('trading-assistant.notify.telegram') }}</a-checkbox>
<a-checkbox value="discord">{{ $t('trading-assistant.notify.discord') }}</a-checkbox>
<a-checkbox value="webhook">{{ $t('trading-assistant.notify.webhook') }}</a-checkbox>
</a-checkbox-group>
<div class="form-item-hint">{{ $t('trading-assistant.form.notifyChannelsHint') }}</div>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('email')"
:label="$t('trading-assistant.form.notifyEmail')"
>
<a-input
v-decorator="['notify_email', { rules: [{ type: 'email', message: $t('trading-assistant.validation.emailInvalid') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputEmail')"
/>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('phone')"
:label="$t('trading-assistant.form.notifyPhone')"
>
<a-input
v-decorator="['notify_phone']"
:placeholder="$t('trading-assistant.placeholders.inputPhone')"
/>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('telegram')"
:label="$t('trading-assistant.form.notifyTelegram')"
>
<a-input
v-decorator="['notify_telegram']"
:placeholder="$t('trading-assistant.placeholders.inputTelegram')"
/>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('discord')"
:label="$t('trading-assistant.form.notifyDiscord')"
>
<a-input
v-decorator="['notify_discord']"
:placeholder="$t('trading-assistant.placeholders.inputDiscord')"
/>
</a-form-item>
<a-form-item
v-if="notifyChannelsUi.includes('webhook')"
:label="$t('trading-assistant.form.notifyWebhook')"
>
<a-input
v-decorator="['notify_webhook']"
:placeholder="$t('trading-assistant.placeholders.inputWebhook')"
/>
</a-form-item>
<a-divider v-if="executionModeUi === 'live' && isCryptoMarket" />
<!-- Live trading: exchange credentials (crypto only) -->
<div v-if="executionModeUi === 'live' && isCryptoMarket">
<a-alert
type="info"
show-icon
style="margin-bottom: 12px;"
:message="$t('trading-assistant.form.liveTradingConfigTitle')"
:description="$t('trading-assistant.form.liveTradingConfigHint')"
/>
<a-form-item :label="$t('trading-assistant.form.savedCredential')">
<a-select
v-decorator="['credential_id', { getValueFromEvent: (val) => val || undefined }]"
:placeholder="$t('trading-assistant.placeholders.selectSavedCredential')"
allow-clear
show-search
option-filter-prop="children"
:loading="loadingExchangeCredentials"
@change="handleCredentialSelectChange"
>
<a-select-option
v-for="cred in exchangeCredentials"
:key="cred.id"
:value="cred.id"
>
{{ formatCredentialLabel(cred) }}
</a-select-option>
</a-select>
<div class="form-item-hint">{{ $t('trading-assistant.form.savedCredentialHint') }}</div>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.exchange')">
<a-select
v-decorator="['exchange_id', {
rules: [{ required: true, message: $t('trading-assistant.validation.exchangeRequired') }],
getValueFromEvent: (val) => val || undefined
}]"
:placeholder="$t('trading-assistant.placeholders.selectExchange')"
allow-clear
show-search
option-filter-prop="children"
@change="handleExchangeSelectChange"
>
<a-select-option
v-for="exchange in formattedExchangeOptions"
:key="exchange.value"
:value="exchange.value"
>
{{ exchange.displayName }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.apiKey')">
<a-input-password
v-decorator="['api_key', { rules: [{ required: true, message: $t('trading-assistant.validation.apiKeyRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputApiKey')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.secretKey')">
<a-input-password
v-decorator="['secret_key', { rules: [{ required: true, message: $t('trading-assistant.validation.secretKeyRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputSecretKey')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item
v-if="needsPassphrase"
:label="$t('trading-assistant.form.passphrase')"
>
<a-input-password
v-decorator="['passphrase', { rules: [{ required: true, message: $t('trading-assistant.validation.passphraseRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputPassphrase')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item>
<a-checkbox
v-decorator="['save_credential', { valuePropName: 'checked', initialValue: false }]"
@change="onSaveCredentialChange"
>
{{ $t('trading-assistant.form.saveCredential') }}
</a-checkbox>
</a-form-item>
<a-form-item
v-if="saveCredentialUi"
:label="$t('trading-assistant.form.credentialName')"
>
<a-input
v-decorator="['credential_name']"
:placeholder="$t('trading-assistant.placeholders.inputCredentialName')"
/>
</a-form-item>
<a-form-item>
<a-button
type="default"
:loading="testing"
@click="handleTestConnection"
block
>
<a-icon type="wallet" />
{{ $t('trading-assistant.form.testConnection') }}
</a-button>
<div v-if="testResult" class="test-result" :class="testResult.success ? 'success' : 'error'">
{{ testResult.message }}
</div>
</a-form-item>
</div>
</a-form>
</div>
</div>
</a-spin>
<template slot="footer">
<a-button @click="handleCloseModal">{{ $t('trading-assistant.form.cancel') }}</a-button>
<a-button
v-show="currentStep > 0"
@click="handlePrev"
>
{{ $t('trading-assistant.form.prev') }}
</a-button>
<a-button
v-show="currentStep < 2"
type="primary"
@click="handleNext"
:loading="saving"
>
{{ $t('trading-assistant.form.next') }}
</a-button>
<a-button
v-show="currentStep === 2"
type="primary"
@click="handleSubmit"
:loading="saving"
>
{{ editingStrategy ? $t('trading-assistant.form.confirmEdit') : $t('trading-assistant.form.confirmCreate') }}
</a-button>
</template>
</a-modal>
</div>
</template>
<script>
import { getStrategyList, startStrategy, stopStrategy, deleteStrategy, createStrategy, updateStrategy, testExchangeConnection, getStrategyEquityCurve } from '@/api/strategy'
import { getWatchlist } from '@/api/market'
import { listExchangeCredentials, getExchangeCredential, createExchangeCredential } from '@/api/credentials'
import { baseMixin } from '@/store/app-mixin'
import request from '@/utils/request'
import TradingRecords from './components/TradingRecords.vue'
import PositionRecords from './components/PositionRecords.vue'
// 常见加密货币交易对
const CRYPTO_SYMBOLS = [
'BTC/USDT', 'ETH/USDT', 'BNB/USDT', 'SOL/USDT', 'ADA/USDT',
'XRP/USDT', 'DOGE/USDT', 'DOT/USDT', 'MATIC/USDT', 'AVAX/USDT',
'LINK/USDT', 'UNI/USDT', 'LTC/USDT', 'ATOM/USDT', 'ETC/USDT'
]
// 交易所选项(前端仅展示后端已适配/支持的交易所)
// 注意:这些标签在组件中会通过计算属性动态生成,以支持多语言
const EXCHANGE_OPTIONS = [
{ value: 'binance', labelKey: 'binance' },
{ value: 'okx', labelKey: 'okx' },
{ value: 'bitget', labelKey: 'bitget' },
{ value: 'bybit', labelKey: 'bybit' },
{ value: 'coinbaseexchange', labelKey: 'coinbaseexchange' },
{ value: 'kraken', labelKey: 'kraken' },
{ value: 'kucoin', labelKey: 'kucoin' },
{ value: 'gate', labelKey: 'gate' },
{ value: 'bitfinex', labelKey: 'bitfinex' }
]
export default {
name: 'TradingAssistant',
mixins: [baseMixin],
components: {
TradingRecords,
PositionRecords
},
computed: {
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
needsPassphrase () {
// 需要 passphrase 的交易所
return ['okx', 'okex', 'coinbaseexchange', 'kucoin', 'bitget'].includes(this.currentExchangeId)
},
// 预处理交易所列表,包含显示名称,提升性能
formattedExchangeOptions () {
return EXCHANGE_OPTIONS.map(exchange => {
let label = ''
try {
if (exchange.labelKey) {
const translationKey = `trading-assistant.exchangeNames.${exchange.labelKey}`
const translated = this.$t(translationKey)
if (translated !== translationKey) {
label = translated
}
}
} catch (e) {
// 忽略翻译错误
}
if (!label) {
label = exchange.value.charAt(0).toUpperCase() + exchange.value.slice(1)
}
return {
...exchange,
displayName: label
}
})
},
totalPnl () {
if (this.currentEquity === null || !this.selectedStrategy || !this.selectedStrategy.initial_capital) {
return null
}
return this.currentEquity - (this.selectedStrategy.initial_capital || 0)
},
totalPnlPercent () {
if (this.totalPnl === null || !this.selectedStrategy || !this.selectedStrategy.initial_capital) {
return null
}
if (this.selectedStrategy.initial_capital === 0) return 0
return (this.totalPnl / this.selectedStrategy.initial_capital) * 100
},
getEquityColorClass () {
if (this.totalPnl === null) return ''
return this.totalPnl >= 0 ? 'text-success' : 'text-danger'
},
getPnlColorClass () {
if (this.totalPnl === null) return ''
return this.totalPnl >= 0 ? 'text-success' : 'text-danger'
},
isCryptoMarket () {
// IMPORTANT: do not rely on form.getFieldValue for reactivity (Ant Form is not reactive).
// Always depend on selectedMarketCategory to make UI reactive.
const cat = this.selectedMarketCategory || 'Crypto'
return String(cat).toLowerCase() === 'crypto'
}
},
data () {
return {
loading: false,
loadingRecords: false,
strategies: [],
selectedStrategy: null,
showFormModal: false,
// Only indicator strategy in local mode
strategyType: 'indicator',
selectedMarketCategory: 'Crypto', // AShare / USStock / Crypto / Forex
currentStep: 0,
saving: false,
loadingIndicators: false,
availableIndicators: [],
selectedIndicator: null,
cryptoSymbols: CRYPTO_SYMBOLS,
// Watchlist symbols (same source as indicator-analysis page)
loadingWatchlist: false,
watchlist: [],
exchangeOptions: EXCHANGE_OPTIONS,
currentExchangeId: '',
testing: false,
testResult: null,
connectionTestResult: null,
indicatorsLoaded: false, // 标记指标是否已加载
editingStrategy: null, // 正在编辑的策略
currentEquity: null, // 当前净值
equityPollingTimer: null, // 净值轮询定时器
// Backtest-like UI state (same as indicator-analysis BacktestModal step1)
backtestCollapseKeys: ['risk'],
trailingEnabledUi: false,
entryPctMaxUi: 100,
aiFilterEnabledUi: false,
isEditMode: false, // 是否为编辑模式
supportedIPs: [], // 白名单IP列表
executionModeUi: 'signal',
notifyChannelsUi: ['browser'],
// Exchange credentials vault
loadingExchangeCredentials: false,
exchangeCredentials: [],
saveCredentialUi: false,
suppressApiClearOnce: false
// Market category is inferred from Step 1 watchlist symbol ("Market:SYMBOL").
}
},
beforeCreate () {
this.form = this.$form.createForm(this)
},
mounted () {
this.loadStrategies()
},
beforeDestroy () {
this.stopEquityPolling()
},
methods: {
async loadWatchlist () {
this.loadingWatchlist = true
try {
const res = await getWatchlist({ userid: 1 })
if (res && res.code === 1) {
this.watchlist = Array.isArray(res.data) ? res.data : []
} else {
this.watchlist = []
}
} catch (e) {
this.watchlist = []
} finally {
this.loadingWatchlist = false
}
},
filterWatchlistOption (input, option) {
const value = option.componentOptions?.propsData?.value || ''
return String(value).toLowerCase().includes(String(input || '').toLowerCase())
},
getMarketColor (market) {
const colors = {
USStock: 'green',
Crypto: 'purple',
Forex: 'gold',
Futures: 'cyan',
AShare: 'blue',
HShare: 'orange'
}
return colors[market] || 'default'
},
handleWatchlistSymbolChange (val) {
// val format: "Market:SYMBOL" (same as indicator-analysis page)
if (!val || typeof val !== 'string' || !val.includes(':')) {
return
}
const idx = val.indexOf(':')
const market = val.slice(0, idx)
// Keep selection reactive for Step 3 execution gating
this.selectedMarketCategory = market || 'Crypto'
// Non-crypto markets cannot use live trading. Force back to signal to keep UI consistent.
if (this.selectedMarketCategory !== 'Crypto') {
this.executionModeUi = 'signal'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
} catch (e) {}
}
},
async loadExchangeCredentials () {
this.loadingExchangeCredentials = true
try {
const res = await listExchangeCredentials({ user_id: 1 })
if (res && res.code === 1) {
this.exchangeCredentials = (res.data && res.data.items) || []
} else {
this.exchangeCredentials = []
this.$message.warning(res?.msg || this.$t('trading-assistant.messages.loadFailed'))
}
} catch (e) {
this.exchangeCredentials = []
this.$message.warning(this.$t('trading-assistant.exchange.testFailed'))
} finally {
this.loadingExchangeCredentials = false
}
},
formatCredentialLabel (cred) {
if (!cred) return ''
const name = (cred.name || '').trim()
const ex = cred.exchange_id || ''
const hint = cred.api_key_hint || ''
return name ? `${ex.toUpperCase()} - ${name} (${hint})` : `${ex.toUpperCase()} (${hint})`
},
async handleCredentialSelectChange (credentialId) {
// Selecting a saved credential auto-fills exchange fields
if (!credentialId) {
return
}
// First try to find in local list to avoid API call delay/failure
const localCred = this.exchangeCredentials.find(c => String(c.id) === String(credentialId))
// Function to apply config
const applyConfig = async (cfg) => {
if (!cfg) return
// 1. Update exchange ID to ensure correct fields (like passphrase) are rendered
this.currentExchangeId = cfg.exchange_id || ''
// 2. Wait for UI to update (render passphrase field if needed)
await this.$nextTick()
// 3. Set form values
// Prevent handleExchangeSelectChange() from clearing API fields when we programmatically set exchange_id.
this.suppressApiClearOnce = true
this.form.setFieldsValue({
exchange_id: cfg.exchange_id,
api_key: cfg.api_key,
secret_key: cfg.secret_key,
passphrase: cfg.passphrase || ''
})
this.testResult = null
this.connectionTestResult = null
}
try {
// Try to fetch full config including secrets
const res = await getExchangeCredential(credentialId, { user_id: 1 })
if (res && res.code === 1 && res.data && res.data.config) {
await applyConfig(res.data.config)
} else if (localCred) {
// Fallback to local data if API fails or returns no config
// Note: Local list usually doesn't have secrets, but just in case
await applyConfig(localCred)
}
} catch (e) {
// Fallback to local data on error
if (localCred) {
await applyConfig(localCred)
}
}
},
onSaveCredentialChange (e) {
const checked = !!(e && e.target && e.target.checked)
this.saveCredentialUi = checked
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ save_credential: checked })
} catch (err) {}
},
onExecutionModeChange (e) {
const v = e && e.target ? e.target.value : e
this.executionModeUi = v || 'signal'
if (!this.isCryptoMarket && this.executionModeUi !== 'signal') {
this.executionModeUi = 'signal'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
} catch (err) {}
}
},
onNotifyChannelsChange (vals) {
this.notifyChannelsUi = Array.isArray(vals) ? vals : []
},
formatCurrency (value) {
if (value === null || value === undefined) return '-'
return '$' + value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
},
formatPnl (value) {
if (value === null || value === undefined) return '-'
const prefix = value >= 0 ? '+' : ''
return prefix + '$' + Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
},
formatPnlPercent (value) {
if (value === null || value === undefined) return '-'
const prefix = value >= 0 ? '+' : ''
return prefix + Math.abs(value).toFixed(2) + '%'
},
async loadStrategies () {
this.loading = true
try {
const res = await getStrategyList()
if (res.code === 1) {
// 显示所有策略(包括指标策略和AI策略)
const allStrategies = res.data.strategies || []
this.strategies = allStrategies
// 如果有选中的策略,更新它
if (this.selectedStrategy) {
const updated = this.strategies.find(s => s.id === this.selectedStrategy.id)
if (updated) {
this.selectedStrategy = updated
} else {
// 如果选中的策略被过滤掉了,清空选中状态
this.selectedStrategy = null
}
}
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.loadFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.loadFailed'))
} finally {
this.loading = false
}
},
handleCreateStrategy () {
// Local mode: open indicator strategy modal directly
this.isEditMode = false
this.editingStrategy = null
this.strategyType = 'indicator'
this.currentStep = 0
this.currentExchangeId = ''
this.selectedIndicator = null
this.testResult = null
this.connectionTestResult = null
this.executionModeUi = 'signal'
this.notifyChannelsUi = ['browser']
this.saveCredentialUi = false
this.backtestCollapseKeys = ['risk']
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
this.selectedMarketCategory = 'Crypto'
this.form.resetFields()
this.form.setFieldsValue({
execution_mode: 'signal',
notify_channels: ['browser'],
save_credential: false
})
this.showFormModal = true
this.$nextTick(() => {
this.loadWatchlist()
this.loadIndicators()
this.loadExchangeCredentials()
})
},
handleEditStrategy (strategy) {
// 如果策略正在运行,提示用户先停止
if (strategy.status === 'running') {
this.$message.warning(this.$t('trading-assistant.messages.runningWarning'))
return
}
// Local mode: indicator strategy only
this.strategyType = 'indicator'
this.isEditMode = true
this.editingStrategy = strategy
this.currentStep = 0
this.currentExchangeId = ''
this.selectedIndicator = null
this.testResult = null
this.connectionTestResult = null
this.form.resetFields()
this.backtestCollapseKeys = ['risk']
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
// IMPORTANT:
// Ensure modal is visible BEFORE filling form values, otherwise some fields are not registered yet
// (especially Step 2/3 fields) and setFieldsValue may silently drop values.
this.showFormModal = true
// Delay loading to ensure modal/form items are mounted.
this.$nextTick(async () => {
// Keep data sources in sync (same as create flow)
this.loadWatchlist()
this.loadIndicators()
this.loadExchangeCredentials()
await this.loadStrategyDataToForm(strategy)
})
},
async loadStrategyDataToForm (strategy) {
// 先加载指标列表(如果需要)
if (!this.indicatorsLoaded) {
await this.loadIndicators()
}
// 使用 nextTick 确保表单已初始化
await this.$nextTick()
// Market / execution / notification defaults (backward compatible)
this.selectedMarketCategory = strategy.market_category || 'Crypto'
const executionMode = strategy.execution_mode || 'signal'
this.executionModeUi = executionMode
const notifyChannels = (strategy.notification_config && strategy.notification_config.channels) || ['browser']
this.notifyChannelsUi = Array.isArray(notifyChannels) ? notifyChannels : ['browser']
// Initialize trading config UI states first
let trailingEnabled = false
// Initialize AI filter state
let aiFilterEnabled = false
if (strategy.trading_config) {
const tc = strategy.trading_config || {}
const trailingObj = (tc.trailing && typeof tc.trailing === 'object') ? tc.trailing : null
trailingEnabled = (tc.trailing_enabled !== undefined) ? !!tc.trailing_enabled : !!(trailingObj && trailingObj.enabled)
// Check AI filter - handle various truthy values
const aiVal = tc.enable_ai_filter
aiFilterEnabled = aiVal === true || aiVal === 'true' || aiVal === 1 || aiVal === '1'
// Determine which collapse panels to open based on config to ensure fields are rendered
const scaleObj = (tc.scale && typeof tc.scale === 'object') ? tc.scale : null
const posObj = (tc.position && typeof tc.position === 'object') ? tc.position : null
const panels = new Set(['risk']) // Always keep risk panel open or as default
// Check Scale panel content
if (
tc.trend_add_enabled || tc.dca_add_enabled ||
(scaleObj && (scaleObj.trendAdd?.enabled || scaleObj.dcaAdd?.enabled))
) {
panels.add('scale')
}
// Check Reduce panel content
if (
tc.trend_reduce_enabled || tc.adverse_reduce_enabled ||
(scaleObj && (scaleObj.trendReduce?.enabled || scaleObj.adverseReduce?.enabled))
) {
panels.add('reduce')
}
// Check Position panel content - open if entry_pct is customized or if we just want to show it
// Considering "Entry sizing" parameter missing issue, better to expand it if data exists
if (tc.entry_pct !== undefined || (posObj && posObj.entryPct !== undefined)) {
panels.add('position')
}
this.backtestCollapseKeys = Array.from(panels)
}
this.trailingEnabledUi = trailingEnabled
this.aiFilterEnabledUi = aiFilterEnabled
// Wait for DOM update to ensure v-if fields (trailing) and Collapse panels are rendered
await this.$nextTick()
// First, set the "switch" fields that might control v-if visibility of other fields
// For ai_filter, it relies on aiFilterEnabledUi which is updated above, so form value is secondary for UI display
// But we still need to set form value for submission
this.form.setFieldsValue({
enable_ai_filter: aiFilterEnabled
})
// Wait for form value to update and v-if to re-render (for ai_filter)
await this.$nextTick()
this.form.setFieldsValue({
execution_mode: this.executionModeUi,
notify_channels: this.notifyChannelsUi,
notify_email: strategy.notification_config?.targets?.email || '',
notify_phone: strategy.notification_config?.targets?.phone || '',
notify_telegram: strategy.notification_config?.targets?.telegram || '',
notify_discord: strategy.notification_config?.targets?.discord || '',
notify_webhook: strategy.notification_config?.targets?.webhook || ''
})
// 加载指标数据
if (strategy.indicator_config && strategy.indicator_config.indicator_id) {
// 查找对应的指标,确保ID类型一致(处理 string vs number 问题)
const targetId = strategy.indicator_config.indicator_id
// 使用字符串比较处理类型不匹配问题(string vs number
const indicator = this.availableIndicators.find(ind => {
return String(ind.id) === String(targetId)
})
if (indicator) {
// 找到匹配的指标,使用指标对象中的ID(确保类型一致)
const finalId = String(indicator.id)
this.form.setFieldsValue({
indicator_id: finalId
})
this.handleIndicatorChange(finalId)
} else {
// 如果找不到,仍然设置值,但可能显示为ID
this.form.setFieldsValue({
indicator_id: String(targetId)
})
}
}
// 加载交易所配置
if (strategy.exchange_config) {
this.currentExchangeId = strategy.exchange_config.exchange_id || ''
// Only set form fields if live trading is enabled (fields are rendered)
// Check executionModeUi and market category (via isCryptoMarket, but we need to ensure computed prop is updated or check directly)
const isLive = this.executionModeUi === 'live'
const isCrypto = this.selectedMarketCategory === 'Crypto'
if (isLive && isCrypto) {
// Prevent exchange change handler from clearing API fields while backfilling saved data.
this.suppressApiClearOnce = true
this.form.setFieldsValue({
exchange_id: strategy.exchange_config.exchange_id,
credential_id: strategy.exchange_config.credential_id || undefined,
api_key: strategy.exchange_config.api_key || '',
secret_key: strategy.exchange_config.secret_key || '',
passphrase: strategy.exchange_config.passphrase || ''
})
// If a vault credential is selected, auto-fill secrets from vault (strategy rows may not store secrets).
const credId = strategy.exchange_config.credential_id
if (credId) {
await this.handleCredentialSelectChange(credId)
}
}
// 设置当前交易所ID (UI state)
if (strategy.exchange_config?.exchange_id) {
this.currentExchangeId = strategy.exchange_config.exchange_id
}
}
// 加载交易配置
if (strategy.trading_config) {
const tc = strategy.trading_config || {}
const trailingObj = (tc.trailing && typeof tc.trailing === 'object') ? tc.trailing : null
const scaleObj = (tc.scale && typeof tc.scale === 'object') ? tc.scale : null
const posObj = (tc.position && typeof tc.position === 'object') ? tc.position : null
// Backward compatible: nested configs from indicator-analysis backtest modal
const trendAddObj = scaleObj && scaleObj.trendAdd ? scaleObj.trendAdd : null
const dcaAddObj = scaleObj && scaleObj.dcaAdd ? scaleObj.dcaAdd : null
const trendReduceObj = scaleObj && scaleObj.trendReduce ? scaleObj.trendReduce : null
const adverseReduceObj = scaleObj && scaleObj.adverseReduce ? scaleObj.adverseReduce : null
// Backward compatible: show symbol as "Market:SYMBOL" for watchlist dropdown
const rawSymbol = tc.symbol
const symbolValue = (typeof rawSymbol === 'string' && rawSymbol.includes(':'))
? rawSymbol
: `${this.selectedMarketCategory}:${rawSymbol}`
this.form.setFieldsValue({
strategy_name: strategy.strategy_name,
symbol: symbolValue,
initial_capital: tc.initial_capital,
leverage: tc.leverage,
trade_direction: tc.trade_direction || 'long',
timeframe: tc.timeframe || '1H',
market_type: (tc.market_type === 'futures' ? 'swap' : (tc.market_type || 'swap')),
take_profit_pct: tc.take_profit_pct || 0,
stop_loss_pct: tc.stop_loss_pct || 0,
// Trailing: support both flat fields and nested trailing object
trailing_enabled: trailingEnabled,
trailing_stop_pct: (tc.trailing_stop_pct !== undefined) ? (tc.trailing_stop_pct || 0) : (trailingObj ? (trailingObj.pct || 0) : 0),
trailing_activation_pct: (tc.trailing_activation_pct !== undefined) ? (tc.trailing_activation_pct || 0) : (trailingObj ? (trailingObj.activationPct || 0) : 0),
// Scale / reduce: support both flat fields and nested objects
trend_add_enabled: (tc.trend_add_enabled !== undefined) ? !!tc.trend_add_enabled : !!(trendAddObj && trendAddObj.enabled),
trend_add_step_pct: (tc.trend_add_step_pct !== undefined) ? (tc.trend_add_step_pct || 0) : (trendAddObj ? (trendAddObj.stepPct || 0) : 0),
trend_add_size_pct: (tc.trend_add_size_pct !== undefined) ? (tc.trend_add_size_pct || 0) : (trendAddObj ? (trendAddObj.sizePct || 0) : 0),
trend_add_max_times: (tc.trend_add_max_times !== undefined) ? (tc.trend_add_max_times || 0) : (trendAddObj ? (trendAddObj.maxTimes || 0) : 0),
dca_add_enabled: (tc.dca_add_enabled !== undefined) ? !!tc.dca_add_enabled : !!(dcaAddObj && dcaAddObj.enabled),
dca_add_step_pct: (tc.dca_add_step_pct !== undefined) ? (tc.dca_add_step_pct || 0) : (dcaAddObj ? (dcaAddObj.stepPct || 0) : 0),
dca_add_size_pct: (tc.dca_add_size_pct !== undefined) ? (tc.dca_add_size_pct || 0) : (dcaAddObj ? (dcaAddObj.sizePct || 0) : 0),
dca_add_max_times: (tc.dca_add_max_times !== undefined) ? (tc.dca_add_max_times || 0) : (dcaAddObj ? (dcaAddObj.maxTimes || 0) : 0),
trend_reduce_enabled: (tc.trend_reduce_enabled !== undefined) ? !!tc.trend_reduce_enabled : !!(trendReduceObj && trendReduceObj.enabled),
trend_reduce_step_pct: (tc.trend_reduce_step_pct !== undefined) ? (tc.trend_reduce_step_pct || 0) : (trendReduceObj ? (trendReduceObj.stepPct || 0) : 0),
trend_reduce_size_pct: (tc.trend_reduce_size_pct !== undefined) ? (tc.trend_reduce_size_pct || 0) : (trendReduceObj ? (trendReduceObj.sizePct || 0) : 0),
trend_reduce_max_times: (tc.trend_reduce_max_times !== undefined) ? (tc.trend_reduce_max_times || 0) : (trendReduceObj ? (trendReduceObj.maxTimes || 0) : 0),
adverse_reduce_enabled: (tc.adverse_reduce_enabled !== undefined) ? !!tc.adverse_reduce_enabled : !!(adverseReduceObj && adverseReduceObj.enabled),
adverse_reduce_step_pct: (tc.adverse_reduce_step_pct !== undefined) ? (tc.adverse_reduce_step_pct || 0) : (adverseReduceObj ? (adverseReduceObj.stepPct || 0) : 0),
adverse_reduce_size_pct: (tc.adverse_reduce_size_pct !== undefined) ? (tc.adverse_reduce_size_pct || 0) : (adverseReduceObj ? (adverseReduceObj.sizePct || 0) : 0),
adverse_reduce_max_times: (tc.adverse_reduce_max_times !== undefined) ? (tc.adverse_reduce_max_times || 0) : (adverseReduceObj ? (adverseReduceObj.maxTimes || 0) : 0),
entry_pct: (tc.entry_pct === 0 || tc.entry_pct) ? tc.entry_pct : (posObj && posObj.entryPct ? posObj.entryPct : 100),
// AI智能决策过滤
enable_ai_filter: aiFilterEnabled
})
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
}
},
handleSelectStrategy (strategy) {
this.selectedStrategy = strategy
this.currentEquity = null // 重置当前净值
this.loadStrategyDetails()
this.startEquityPolling() // 开始轮询净值
},
async loadStrategyDetails () {
if (!this.selectedStrategy) {
return Promise.resolve()
}
// 加载净值数据
try {
const res = await getStrategyEquityCurve(this.selectedStrategy.id)
if (res.code === 1 && res.data) {
// Local backend returns an array curve: [{ time, equity }, ...]
if (Array.isArray(res.data) && res.data.length > 0) {
const last = res.data[res.data.length - 1]
this.currentEquity = last.equity
} else {
const base = this.selectedStrategy.trading_config?.initial_capital || this.selectedStrategy.initial_capital
this.currentEquity = base || null
}
}
} catch (error) {
}
},
startEquityPolling () {
this.stopEquityPolling()
if (!this.selectedStrategy) return
// 初始加载一次
this.loadStrategyDetails()
// 每30秒轮询一次
this.equityPollingTimer = setInterval(() => {
this.loadStrategyDetails()
}, 30000)
},
stopEquityPolling () {
if (this.equityPollingTimer) {
clearInterval(this.equityPollingTimer)
this.equityPollingTimer = null
}
},
handleCloseModal () {
this.showFormModal = false
this.editingStrategy = null
this.isEditMode = false
this.strategyType = 'indicator'
this.currentStep = 0
this.currentExchangeId = ''
this.selectedIndicator = null
this.testResult = null
this.connectionTestResult = null
this.indicatorsLoaded = false
this.availableIndicators = []
this.backtestCollapseKeys = ['risk']
this.trailingEnabledUi = false
this.entryPctMaxUi = 100
this.aiFilterEnabledUi = false
this.form.resetFields()
},
handleRefresh () {
this.loadStrategies()
this.showFormModal = false
},
handleMenuClick (key, strategy) {
switch (key) {
case 'start':
this.handleStartStrategy(strategy.id)
break
case 'stop':
this.handleStopStrategy(strategy.id)
break
case 'edit':
this.handleEditStrategy(strategy)
break
case 'delete':
this.handleDeleteStrategy(strategy)
break
}
},
async handleStartStrategy (id) {
try {
const res = await startStrategy(id)
if (res.code === 1) {
this.$message.success(this.$t('trading-assistant.messages.startSuccess'))
this.loadStrategies()
// 更新选中的策略状态
if (this.selectedStrategy && this.selectedStrategy.id === id) {
this.selectedStrategy.status = 'running'
}
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.startFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.startFailed'))
}
},
async handleStopStrategy (id) {
try {
const res = await stopStrategy(id)
if (res.code === 1) {
this.$message.success(this.$t('trading-assistant.messages.stopSuccess'))
this.loadStrategies()
// 更新选中的策略状态
if (this.selectedStrategy && this.selectedStrategy.id === id) {
this.selectedStrategy.status = 'stopped'
}
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.stopFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.stopFailed'))
}
},
handleDeleteStrategy (strategy) {
const confirmText = this.$t('trading-assistant.messages.deleteConfirmWithName', {
name: strategy.strategy_name
})
this.$confirm({
title: this.$t('trading-assistant.deleteStrategy'),
content: confirmText,
okText: this.$t('trading-assistant.deleteStrategy'),
okType: 'danger',
cancelText: this.$t('trading-assistant.form.cancel'),
onOk: async () => {
try {
const res = await deleteStrategy(strategy.id)
if (res.code === 1) {
this.$message.success(this.$t('trading-assistant.messages.deleteSuccess'))
if (this.selectedStrategy && this.selectedStrategy.id === strategy.id) {
this.selectedStrategy = null
this.stopEquityPolling() // 停止轮询
}
this.loadStrategies()
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.deleteFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.deleteFailed'))
}
}
})
},
getStatusColor (status) {
const colors = {
running: 'green',
stopped: 'default',
error: 'red'
}
return colors[status] || 'default'
},
getStatusText (status) {
return this.$t(`trading-assistant.status.${status}`) || status
},
getStrategyTypeText (type) {
return this.$t(`trading-assistant.strategyType.${type}`) || type
},
getTradeDirectionText (direction) {
if (!direction) return ''
const directionMap = {
long: this.$t('trading-assistant.form.tradeDirectionLong') || '做多',
short: this.$t('trading-assistant.form.tradeDirectionShort') || '做空',
both: this.$t('trading-assistant.form.tradeDirectionBoth') || '双向'
}
return directionMap[direction] || direction
},
// 指标相关方法
async handleIndicatorSelectFocus () {
// 懒加载:只在用户点击选择框时才加载指标
if (!this.indicatorsLoaded && !this.loadingIndicators) {
await this.loadIndicators()
}
},
async loadIndicators () {
if (this.loadingIndicators) {
// 如果正在加载,等待加载完成
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this.loadingIndicators) {
clearInterval(checkInterval)
resolve()
}
}, 100)
})
}
if (this.indicatorsLoaded) {
return Promise.resolve()
}
// 获取用户ID
const userInfo = this.$store.getters.userInfo || {}
const userId = userInfo.id || 1
this.loadingIndicators = true
try {
// 使用和 indicator-analysis 页面相同的接口
const res = await request({
url: '/api/indicator/getIndicators',
method: 'post',
data: {
userid: userId
}
})
if (res.code === 1 && res.data) {
// 将所有指标(包括选购的和自己创建的)合并到一个数组
const indicators = res.data.map(item => ({
id: item.id,
name: item.name,
description: item.description,
type: item.indicator_type || item.indicatorType || 'python',
code: item.code,
is_buy: item.is_buy,
source: item.is_buy === 1 ? 'bought' : 'custom'
}))
this.availableIndicators = indicators
this.indicatorsLoaded = true
} else {
this.availableIndicators = []
this.$message.warning(res.msg || this.$t('trading-assistant.messages.loadIndicatorsFailed'))
}
} catch (error) {
this.availableIndicators = []
this.$message.warning(this.$t('trading-assistant.messages.loadIndicatorsFailed'))
} finally {
this.loadingIndicators = false
}
},
handleIndicatorChange (indicatorId) {
const idStr = String(indicatorId)
this.selectedIndicator = this.availableIndicators.find(ind => String(ind.id) === idStr)
},
handleMarketTypeChange (e) {
const marketType = e.target.value
// 如果切换到现货,自动设置交易方向为做多,杠杆为1
if (marketType === 'spot') {
this.form.setFieldsValue({
trade_direction: 'long',
leverage: 1
})
}
},
// --- Backtest-like UI helpers (Ant Form getFieldValue is not reactive) ---
recalcEntryPctMaxUi () {
if (!this.form) {
this.entryPctMaxUi = 100
return
}
const trendOn = !!this.form.getFieldValue('trend_add_enabled')
const dcaOn = !!this.form.getFieldValue('dca_add_enabled')
const trendTimes = Number(this.form.getFieldValue('trend_add_max_times') || 0)
const dcaTimes = Number(this.form.getFieldValue('dca_add_max_times') || 0)
const trendSizePct = Number(this.form.getFieldValue('trend_add_size_pct') || 0)
const dcaSizePct = Number(this.form.getFieldValue('dca_add_size_pct') || 0)
const reservePct = (trendOn ? trendTimes * trendSizePct : 0) + (dcaOn ? dcaTimes * dcaSizePct : 0)
const maxEntryPct = Math.max(0, Math.min(100, 100 - reservePct))
this.entryPctMaxUi = maxEntryPct
},
normalizeEntryPct () {
if (!this.form) return
const current = Number(this.form.getFieldValue('entry_pct') || 0)
const max = Number(this.entryPctMaxUi || 100)
if (current > max) {
this.form.setFieldsValue({ entry_pct: max })
}
},
onTrendAddToggle (checked) {
if (!this.form) return
// Mutual exclusion to avoid double scale-in on the same candle.
if (checked) {
this.form.setFieldsValue({ dca_add_enabled: false })
}
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
},
onDcaAddToggle (checked) {
if (!this.form) return
if (checked) {
this.form.setFieldsValue({ trend_add_enabled: false })
}
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
},
onScaleParamsChange () {
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
},
onEntryPctChange () {
this.$nextTick(() => this.normalizeEntryPct())
},
onTrailingToggle (checked) {
if (!this.form) return
this.trailingEnabledUi = !!checked
// Only show fields when enabled; also clear values when disabled.
if (!checked) {
this.form.setFieldsValue({ trailing_stop_pct: 0, trailing_activation_pct: 0 })
}
},
onAiFilterToggle (checked) {
this.aiFilterEnabledUi = !!checked
// Ensure rc-form value is always in sync even if decorator event binding gets overridden.
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ enable_ai_filter: !!checked })
} catch (e) {}
},
filterIndicatorOption (input, option) {
const text = option.componentOptions.children[0].children[0].text
return text.toLowerCase().indexOf(input.toLowerCase()) >= 0
},
filterSymbolOption (input, option) {
return option.componentOptions.children[0].text.toLowerCase().indexOf(input.toLowerCase()) >= 0
},
getIndicatorTypeColor (type) {
const colors = {
trend: 'blue',
momentum: 'green',
volatility: 'orange',
volume: 'purple',
custom: 'default'
}
return colors[type] || 'default'
},
getIndicatorTypeName (type) {
return this.$t(`trading-assistant.indicatorType.${type}`) || type
},
// 交易所相关方法
getExchangeName (exchange) {
if (exchange.labelKey) {
const translationKey = `trading-assistant.exchangeNames.${exchange.labelKey}`
const translated = this.$t(translationKey)
// 如果翻译不存在,返回键值本身,否则返回翻译后的名称
if (translated === translationKey) {
// 翻译不存在,返回交易所ID的首字母大写
return exchange.value.charAt(0).toUpperCase() + exchange.value.slice(1)
}
return translated
}
return exchange.label || exchange.value
},
getExchangeDisplayName (exchangeId) {
if (!exchangeId) return ''
// 查找对应的交易所选项
const exchange = this.exchangeOptions.find(ex => ex.value === exchangeId)
if (exchange) {
return this.getExchangeName(exchange)
}
// 如果找不到,返回格式化的交易所ID
return exchangeId.charAt(0).toUpperCase() + exchangeId.slice(1)
},
getExchangeTagColor (exchangeId) {
// 为不同交易所设置不同的标签颜色
const colorMap = {
binance: 'gold',
okx: 'blue',
coinbaseexchange: 'cyan',
kraken: 'purple',
bitfinex: 'geekblue',
huobi: 'orange',
gate: 'green',
mexc: 'lime',
kucoin: 'volcano',
bybit: 'red',
bitget: 'magenta',
bitmex: 'red',
deribit: 'blue',
phemex: 'cyan',
bitmart: 'geekblue',
bitstamp: 'purple',
bittrex: 'orange',
poloniex: 'green',
gemini: 'lime',
cryptocom: 'volcano',
blockchaincom: 'magenta',
bitflyer: 'red',
upbit: 'blue',
bithumb: 'cyan',
coinone: 'purple',
zb: 'geekblue',
lbank: 'orange',
bibox: 'green',
bigone: 'lime',
bitrue: 'volcano',
coinex: 'magenta',
ftx: 'red',
ftxus: 'blue',
binanceus: 'gold',
binancecoinm: 'gold',
binanceusdm: 'gold'
}
return colorMap[exchangeId] || 'default'
},
handleApiConfigChange () {
// 当API配置字段变化时,清空测试结果,需要重新测试
this.testResult = null
this.connectionTestResult = null
},
handleExchangeSelectChange (value) {
this.currentExchangeId = value || ''
this.testResult = null
this.connectionTestResult = null
// If exchange_id is set programmatically (e.g. by selecting a saved credential),
// don't clear the api fields we just filled.
if (this.suppressApiClearOnce) {
this.suppressApiClearOnce = false
return
}
// Clear API fields when exchange changes, as we rely on "Saved credential"
this.$nextTick(() => {
const fieldsToClear = {
api_key: undefined,
secret_key: undefined,
passphrase: undefined
}
setTimeout(() => {
this.form.setFieldsValue(fieldsToClear)
}, 100)
})
},
getPlaceholder (fieldType) {
const placeholders = {
okx: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key',
passphrase: '请输入Passphrase(创建API时设置)'
},
okex: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key',
passphrase: '请输入Passphrase(创建API时设置)'
},
binance: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
coinbaseexchange: {
api_key: '请输入API Key(或Key Name',
secret_key: '请输入API Secret(或Private Key',
passphrase: '请输入PassphraseLegacy Pro API需要)'
},
kucoin: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key',
passphrase: '请输入Passphrase'
},
gate: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
mexc: {
api_key: '请输入Access Key',
secret_key: '请输入Secret Key'
},
kraken: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
bitfinex: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
bybit: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key'
},
bitget: {
api_key: '请输入API Key',
secret_key: '请输入Secret Key',
passphrase: '请输入PassphraseLegacy Pro API需要)'
}
}
const exchangePlaceholders = placeholders[this.currentExchangeId] || {}
if (exchangePlaceholders[fieldType]) {
return exchangePlaceholders[fieldType]
}
// 默认占位符使用多语言
const fieldLabels = {
'api_key': this.$t('trading-assistant.placeholders.inputApiKey'),
'secret_key': this.$t('trading-assistant.placeholders.inputSecretKey'),
'passphrase': this.$t('trading-assistant.placeholders.inputPassphrase')
}
return fieldLabels[fieldType] || this.$t('trading-assistant.placeholders.inputApiKey')
},
async handleTestConnection () {
this.testResult = null
this.testing = true
try {
// 构建需要验证的字段列表
const fieldsToValidate = ['exchange_id', 'api_key', 'secret_key']
if (this.needsPassphrase) {
fieldsToValidate.push('passphrase')
}
// 先验证表单字段
this.form.validateFields(fieldsToValidate, async (err, values) => {
if (err) {
this.testing = false
this.$message.error(this.$t('trading-assistant.exchange.fillComplete'))
return
}
try {
const marketType = (this.form.getFieldValue('market_type') || 'swap')
const exchangeConfig = {
exchange_id: values.exchange_id,
api_key: values.api_key,
secret_key: values.secret_key,
// IMPORTANT: let backend pick correct Binance endpoints (spot vs futures)
market_type: String(marketType || 'swap')
}
// 如果需要 passphrase,添加它
if (this.needsPassphrase && values.passphrase) {
exchangeConfig.passphrase = values.passphrase
}
const res = await testExchangeConnection(exchangeConfig)
this.testResult = {
success: res.code === 1,
message: res.msg || (res.code === 1 ? this.$t('trading-assistant.exchange.connectionSuccess') : this.$t('trading-assistant.exchange.connectionFailed'))
}
this.connectionTestResult = null
if (this.testResult.success) {
this.$message.success(this.$t('trading-assistant.exchange.connectionSuccess'))
} else {
this.$message.error(this.testResult.message || this.$t('trading-assistant.exchange.testFailed'))
}
} catch (error) {
this.testResult = {
success: false,
message: error.message || this.$t('trading-assistant.exchange.testFailed')
}
this.$message.error(this.testResult.message)
} finally {
this.testing = false
}
})
} catch (error) {
this.testResult = {
success: false,
message: this.$t('trading-assistant.exchange.testFailed')
}
this.$message.error(this.$t('trading-assistant.exchange.testFailed'))
this.testing = false
}
},
// 表单步骤控制
handleNext () {
if (this.currentStep === 0) {
// Step 1: basic config (strategy name/symbol/capital/leverage/market type/timeframe)
const fieldsToValidate = [
'indicator_id',
'strategy_name',
'symbol',
'initial_capital',
'market_type',
'leverage',
'trade_direction',
'timeframe'
]
this.form.validateFields(fieldsToValidate, (err, values) => {
if (err) return
// Enforce spot limitations
try {
const marketType = (values && values.market_type) || this.form.getFieldValue('market_type')
if (marketType === 'spot') {
this.form.setFieldsValue({ leverage: 1, trade_direction: 'long' })
}
} catch (e) {}
// Init backtest-like UI states for Step 2 (Ant Form is not reactive).
this.backtestCollapseKeys = ['risk']
try {
this.trailingEnabledUi = !!this.form.getFieldValue('trailing_enabled')
} catch (e) {
this.trailingEnabledUi = false
}
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
this.currentStep++
})
} else if (this.currentStep === 1) {
// Step 2: backtest-like configs are optional; proceed directly.
// Sync UI states from form (Ant Form values are not always reactive)
try {
const execMode = this.form.getFieldValue('execution_mode') || 'signal'
this.executionModeUi = execMode
const chans = this.form.getFieldValue('notify_channels') || ['browser']
this.notifyChannelsUi = Array.isArray(chans) ? chans : ['browser']
} catch (e) {}
this.currentStep++
}
},
handlePrev () {
if (this.currentStep > 0) {
this.currentStep--
}
},
async handleSubmit () {
this.form.validateFields(async (err, values) => {
if (!err) {
try {
this.saving = true
const isLive = this.isCryptoMarket && values.execution_mode === 'live'
if (isLive) {
const testResult = this.testResult
if (!testResult) {
this.$message.warning(this.$t('trading-assistant.validation.testConnectionRequired'))
this.saving = false
return
}
if (!testResult.success) {
this.$message.warning(this.$t('trading-assistant.validation.testConnectionFailed'))
this.saving = false
return
}
}
const notificationConfig = {
channels: values.notify_channels || [],
targets: {
email: values.notify_email || '',
phone: values.notify_phone || '',
telegram: values.notify_telegram || '',
discord: values.notify_discord || '',
webhook: values.notify_webhook || ''
}
}
if (!notificationConfig.channels || notificationConfig.channels.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.notifyChannelRequired'))
this.saving = false
return
}
// Indicator strategy submit logic (local mode)
const indicatorIdStr = String(values.indicator_id)
const indicator = this.availableIndicators.find(ind => String(ind.id) === indicatorIdStr)
if (!indicator) {
this.$message.error(this.$t('trading-assistant.validation.indicatorRequired'))
this.saving = false
return
}
// AI filter values: source of truth is the reactive UI state to avoid rc-form edge cases.
const enableAiFilter = !!this.aiFilterEnabledUi
const marketType = (values.market_type === 'futures' ? 'swap' : (values.market_type || 'swap'))
let leverage = values.leverage || 1
let tradeDirection = values.trade_direction || 'long'
if (marketType === 'spot') {
leverage = 1
tradeDirection = 'long'
this.$message.info(this.$t('trading-assistant.messages.spotLimitations'))
} else {
if (leverage < 1) leverage = 1
if (leverage > 125) leverage = 125
}
// Watchlist-style symbol value: "Market:SYMBOL"
let parsedMarketCategory = (values.market_category || this.selectedMarketCategory || 'Crypto')
let parsedSymbol = values.symbol
if (typeof parsedSymbol === 'string' && parsedSymbol.includes(':')) {
const idx = parsedSymbol.indexOf(':')
parsedMarketCategory = parsedSymbol.slice(0, idx) || parsedMarketCategory
parsedSymbol = parsedSymbol.slice(idx + 1)
}
const payload = {
strategy_name: values.strategy_name,
market_category: parsedMarketCategory,
execution_mode: values.execution_mode || 'signal',
notification_config: notificationConfig,
indicator_config: {
indicator_id: indicator.id,
indicator_name: indicator.name,
indicator_code: indicator.code || ''
},
exchange_config: isLive ? {
exchange_id: values.exchange_id,
credential_id: values.credential_id,
api_key: values.api_key,
secret_key: values.secret_key,
passphrase: this.needsPassphrase ? values.passphrase : undefined
} : undefined,
trading_config: {
symbol: parsedSymbol,
initial_capital: values.initial_capital,
leverage: leverage,
trade_direction: tradeDirection,
timeframe: values.timeframe,
market_type: marketType,
// Preset order params (advanced settings removed from UI)
order_mode: 'maker',
margin_mode: 'cross',
signal_mode: 'confirmed',
// Backtest-like configs
take_profit_pct: values.take_profit_pct || 0,
stop_loss_pct: values.stop_loss_pct || 0,
trailing_enabled: !!values.trailing_enabled,
trailing_stop_pct: values.trailing_stop_pct || 0,
trailing_activation_pct: values.trailing_activation_pct || 0,
trend_add_enabled: !!values.trend_add_enabled,
trend_add_step_pct: values.trend_add_step_pct || 0,
trend_add_size_pct: values.trend_add_size_pct || 0,
trend_add_max_times: values.trend_add_max_times || 0,
dca_add_enabled: !!values.dca_add_enabled,
dca_add_step_pct: values.dca_add_step_pct || 0,
dca_add_size_pct: values.dca_add_size_pct || 0,
dca_add_max_times: values.dca_add_max_times || 0,
trend_reduce_enabled: !!values.trend_reduce_enabled,
trend_reduce_step_pct: values.trend_reduce_step_pct || 0,
trend_reduce_size_pct: values.trend_reduce_size_pct || 0,
trend_reduce_max_times: values.trend_reduce_max_times || 0,
adverse_reduce_enabled: !!values.adverse_reduce_enabled,
adverse_reduce_step_pct: values.adverse_reduce_step_pct || 0,
adverse_reduce_size_pct: values.adverse_reduce_size_pct || 0,
adverse_reduce_max_times: values.adverse_reduce_max_times || 0,
entry_pct: (values.entry_pct === 0 || values.entry_pct) ? values.entry_pct : 100,
commission: values.commission || 0,
slippage: values.slippage || 0,
// AI智能决策过滤
enable_ai_filter: enableAiFilter
}
}
let res
if (this.editingStrategy) {
res = await updateStrategy(this.editingStrategy.id, payload)
} else {
payload.user_id = 1
payload.strategy_type = 'IndicatorStrategy'
res = await createStrategy(payload)
}
if (res.code === 1) {
this.$message.success(this.isEditMode ? this.$t('trading-assistant.messages.updateSuccess') : this.$t('trading-assistant.messages.createSuccess'))
if (isLive && values.save_credential) {
// Save credential to vault (best-effort)
try {
await createExchangeCredential({
user_id: 1,
name: values.credential_name || '',
exchange_id: values.exchange_id,
api_key: values.api_key,
secret_key: values.secret_key,
passphrase: values.passphrase || ''
})
this.loadExchangeCredentials()
} catch (e) {
// Silent fail: vault is optional.
}
}
this.handleRefresh()
} else {
this.$message.error(res.msg || (this.isEditMode ? this.$t('trading-assistant.messages.updateFailed') : this.$t('trading-assistant.messages.createFailed')))
}
} catch (error) {
this.$message.error(this.isEditMode ? this.$t('trading-assistant.messages.updateFailed') : this.$t('trading-assistant.messages.createFailed'))
} finally {
this.saving = false
}
}
})
},
getFormValues () {
return new Promise((resolve, reject) => {
this.form.validateFields((err, values) => {
if (err) {
reject(err)
} else {
resolve(values)
}
})
})
},
getDropdownContainer (triggerNode) {
// 移动端将下拉菜单挂载到body,避免被父容器裁剪
return this.isMobile ? document.body : triggerNode.parentNode
}
}
}
</script>
<style lang="less" scoped>
// 主色调变量
@primary-color: #1890ff;
@primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@success-color: #0ecb81;
@danger-color: #f6465d;
@warning-color: #f0b90b;
@card-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
@card-shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.12);
@border-radius-lg: 16px;
@border-radius-md: 12px;
@border-radius-sm: 8px;
.trading-assistant {
padding: 0px;
height: calc(100vh - 120px);
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
.strategy-layout {
height: calc(100vh - 120px);
}
// 移动端适配
@media (max-width: 768px) {
min-height: auto;
margin: -24px;
.strategy-layout {
height: auto;
min-height: calc(100vh - 120px);
}
.strategy-list-col {
margin-bottom: 12px;
height: auto;
max-height: 50vh;
.strategy-list-card {
height: auto;
max-height: 50vh;
.card-title {
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
span {
font-size: 14px;
font-weight: 600;
}
.ant-btn {
font-size: 12px;
padding: 0 10px;
height: 28px;
line-height: 28px;
}
}
/deep/ .ant-card-body {
max-height: calc(50vh - 60px);
overflow-y: auto;
padding: 8px;
-webkit-overflow-scrolling: touch;
}
/deep/ .ant-card-head {
padding: 0;
min-height: 48px;
}
.strategy-list-item {
padding: 12px 8px;
margin-bottom: 4px;
border-radius: 8px;
/deep/ .ant-list-item-meta {
width: 100%;
}
/deep/ .ant-list-item-action {
margin-left: 8px;
}
.strategy-item-header {
width: 100%;
.strategy-name-wrapper {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
flex-wrap: wrap;
.strategy-type-tag {
font-size: 10px;
padding: 2px 6px;
line-height: 1.4;
margin: 0;
}
.exchange-tag {
font-size: 10px;
padding: 2px 6px;
line-height: 1.4;
margin: 0;
.anticon {
font-size: 10px;
margin-right: 2px;
}
}
.strategy-name {
font-size: 14px;
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
/deep/ .ant-list-item-meta-description {
.strategy-item-info {
display: flex !important;
flex-direction: row;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 11px;
flex-wrap: wrap;
.info-item {
display: flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
font-size: 11px;
.anticon {
font-size: 11px;
}
}
.status-label {
font-size: 10px;
padding: 2px 6px;
line-height: 1.4;
}
}
}
}
}
}
.strategy-detail-col {
height: auto;
min-height: calc(50vh - 60px);
.strategy-detail-panel {
gap: 12px;
.strategy-header-card {
/deep/ .ant-card-body {
padding: 16px 12px;
}
/deep/ .ant-card-head {
padding: 0;
}
.strategy-header {
flex-direction: column;
gap: 12px;
.header-left {
width: 100%;
.strategy-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
line-height: 1.4;
word-break: break-word;
}
.strategy-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-items: start;
/deep/ .ant-tag {
grid-column: 1 / -1;
justify-self: start;
margin: 0;
font-size: 11px;
padding: 4px 8px;
}
.meta-item {
display: flex;
align-items: flex-start;
gap: 4px;
font-size: 12px;
line-height: 1.5;
word-break: break-word;
.anticon {
font-size: 12px;
flex-shrink: 0;
margin-top: 2px;
}
// 确保文本可换行
& > span {
word-break: break-word;
line-height: 1.5;
flex: 1;
}
// 对于包含数值的span
span:not(.anticon) {
display: inline;
word-break: break-word;
overflow-wrap: break-word;
}
}
// 如果参数过多,使用单列布局
@media (max-width: 480px) {
grid-template-columns: 1fr;
/deep/ .ant-tag {
grid-column: 1;
}
}
}
}
.header-right {
width: 100%;
display: flex;
justify-content: flex-start;
gap: 8px;
flex-wrap: wrap;
.ant-btn {
flex: 1;
min-width: 100px;
font-size: 13px;
padding: 0 12px;
height: 36px;
line-height: 36px;
.anticon {
margin-right: 4px;
}
}
}
}
}
.strategy-content-card {
/deep/ .ant-card-body {
padding: 12px 8px;
overflow-x: hidden;
}
/deep/ .ant-card-head {
padding: 0 8px;
}
/deep/ .ant-tabs {
.ant-tabs-nav {
padding: 0 4px;
.ant-tabs-tab {
font-size: 13px;
padding: 8px 10px;
margin: 0 2px;
white-space: nowrap;
}
}
.ant-tabs-content {
padding-top: 12px;
overflow-x: hidden;
.ant-tabs-tabpane {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
}
}
}
}
}
// 超小屏幕适配
@media (max-width: 480px) {
.strategy-list-col {
max-height: 45vh;
.strategy-list-card {
max-height: 45vh;
/deep/ .ant-card-body {
max-height: calc(45vh - 60px);
}
}
}
.strategy-detail-col {
.strategy-detail-panel {
.strategy-header-card {
.strategy-header {
.header-left {
.strategy-meta {
grid-template-columns: 1fr;
gap: 6px;
.meta-item {
font-size: 11px;
line-height: 1.6;
}
}
}
.header-right {
.ant-btn {
width: 100%;
flex: none;
}
}
}
}
}
}
}
.strategy-list-col {
height: 100%;
.strategy-list-card {
height: 100%;
display: flex;
flex-direction: column;
border-radius: @border-radius-lg;
box-shadow: @card-shadow;
border: none;
overflow: hidden;
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: @card-shadow-hover;
}
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 16px;
font-weight: 700;
background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.ant-btn-primary {
border-radius: @border-radius-sm;
background: linear-gradient(135deg, @primary-color 0%, #40a9ff 100%);
border: none;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.35);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.45);
}
}
}
/deep/ .ant-card-body {
flex: 1;
overflow-y: auto;
background: #fff;
padding: 12px;
}
/deep/ .ant-card-head {
background: linear-gradient(180deg, #fff 0%, #fafbfc 100%);
border-bottom: 1px solid #f0f0f0;
}
.strategy-list-item {
cursor: pointer;
padding: 14px 16px;
border-radius: @border-radius-md;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
margin-bottom: 8px;
background: #fafbfc;
border: 1px solid transparent;
&:hover {
background: linear-gradient(135deg, #f0f7ff 0%, #f5f9ff 100%);
border-color: rgba(24, 144, 255, 0.2);
transform: translateX(4px);
box-shadow: 0 2px 12px rgba(24, 144, 255, 0.1);
}
&.active {
background: linear-gradient(135deg, #e6f4ff 0%, #f0f9ff 100%);
border-color: @primary-color;
border-left: 4px solid @primary-color;
box-shadow: 0 4px 16px rgba(24, 144, 255, 0.15);
}
// 移动端优化点击区域
@media (max-width: 768px) {
padding: 12px 8px;
margin: 0 4px 4px 4px;
&.active {
border-left-width: 2px;
}
}
.strategy-item-header {
display: flex;
justify-content: space-between;
align-items: center;
.strategy-name-wrapper {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
.strategy-name {
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
color: #1e3a5f;
transition: color 0.2s ease;
}
.exchange-tag {
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 11px;
line-height: 1.5;
padding: 2px 8px;
border-radius: 6px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border: 1px solid rgba(102, 126, 234, 0.2);
color: #667eea;
transition: all 0.2s ease;
.anticon {
font-size: 11px;
}
}
.strategy-type-tag {
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 10px;
line-height: 1.5;
margin-left: 4px;
padding: 2px 8px;
border-radius: 6px;
background: linear-gradient(135deg, rgba(156, 39, 176, 0.1) 0%, rgba(103, 58, 183, 0.1) 100%);
border: 1px solid rgba(156, 39, 176, 0.2);
.anticon {
font-size: 10px;
margin-right: 3px;
}
}
}
/deep/ .status-stopped {
color: @danger-color !important;
border-color: @danger-color !important;
}
}
/deep/ .ant-list-item-meta-description {
max-width: calc(100% - 20px); // 留出空间给右侧操作按钮和选中边框
overflow: hidden;
.strategy-item-info {
display: flex !important;
gap: 12px; // 减小间距
margin-top: 8px;
font-size: 12px;
color: var(--text-color-secondary, #8c8c8c);
align-items: center;
flex-wrap: nowrap; // 禁止换行
max-width: 100%;
overflow: hidden;
.status-label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 16px;
font-size: 11px;
font-weight: 600;
line-height: 1;
border: 1px solid transparent;
flex-shrink: 0;
background: linear-gradient(135deg, #f0f2f5 0%, #e8eaed 100%);
color: #595959;
transition: all 0.2s ease;
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
}
.status-running {
background: linear-gradient(135deg, rgba(14, 203, 129, 0.15) 0%, rgba(14, 203, 129, 0.08) 100%);
color: @success-color;
border-color: rgba(14, 203, 129, 0.3);
box-shadow: 0 2px 8px rgba(14, 203, 129, 0.2);
&::before {
animation: statusPulse 2s infinite;
box-shadow: 0 0 8px @success-color;
}
}
.status-stopped {
background: linear-gradient(135deg, rgba(246, 70, 93, 0.15) 0%, rgba(246, 70, 93, 0.08) 100%);
color: @danger-color;
border-color: rgba(246, 70, 93, 0.3);
}
.status-error {
background: linear-gradient(135deg, rgba(255, 77, 79, 0.15) 0%, rgba(255, 77, 79, 0.08) 100%);
color: #ff4d4f;
border-color: rgba(255, 77, 79, 0.3);
}
@keyframes statusPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.info-item {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
}
.strategy-detail-col {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
.empty-detail {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.9) 100%);
border-radius: @border-radius-lg;
border: 2px dashed #e0e6ed;
transition: all 0.3s ease;
&:hover {
border-color: @primary-color;
background: linear-gradient(135deg, rgba(24,144,255,0.02) 0%, rgba(24,144,255,0.05) 100%);
}
/deep/ .ant-empty-image {
opacity: 0.6;
}
/deep/ .ant-empty-description {
color: #8c8c8c;
font-size: 14px;
}
}
.strategy-detail-panel {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
.strategy-header-card {
flex-shrink: 0; // 防止头部被压缩
background: linear-gradient(135deg, #fff 0%, #f8fafc 100%);
border: none;
border-radius: @border-radius-lg;
box-shadow: @card-shadow;
transition: all 0.3s ease;
&:hover {
box-shadow: @card-shadow-hover;
}
/deep/ .ant-card-head {
background: transparent;
border-bottom: none;
}
/deep/ .ant-card-body {
background: transparent;
padding: 20px;
}
.strategy-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
.header-left {
flex: 1;
min-width: 0;
.strategy-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
.strategy-title {
font-size: 20px;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
transition: all 0.3s ease;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
&.status-running {
background: linear-gradient(135deg, rgba(14, 203, 129, 0.15) 0%, rgba(14, 203, 129, 0.08) 100%);
color: @success-color;
border: 1px solid rgba(14, 203, 129, 0.3);
.status-dot {
background: @success-color;
box-shadow: 0 0 12px @success-color;
}
}
&.status-stopped {
background: linear-gradient(135deg, rgba(246, 70, 93, 0.15) 0%, rgba(246, 70, 93, 0.08) 100%);
color: @danger-color;
border: 1px solid rgba(246, 70, 93, 0.3);
.status-dot {
background: @danger-color;
animation: none;
}
}
&.status-error {
background: linear-gradient(135deg, rgba(255, 77, 79, 0.15) 0%, rgba(255, 77, 79, 0.08) 100%);
color: #ff4d4f;
border: 1px solid rgba(255, 77, 79, 0.3);
.status-dot {
background: #ff4d4f;
animation: none;
}
}
}
}
// 关键数据卡片网格
.key-stats-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
.stat-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #fff;
border-radius: @border-radius-sm;
border: 1px solid #f0f0f0;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.stat-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
&.investment {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&.equity {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: #fff;
}
&.pnl {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: #fff;
}
}
.stat-content {
flex: 1;
min-width: 0;
.stat-label {
font-size: 11px;
color: #8c8c8c;
margin-bottom: 2px;
}
.stat-value {
font-size: 15px;
font-weight: 700;
color: #1e3a5f;
line-height: 1.2;
.pnl-percent {
font-size: 12px;
font-weight: 500;
opacity: 0.8;
}
}
}
&.pnl-card {
&.profit .stat-content .stat-value {
color: @success-color;
}
&.loss .stat-content .stat-value {
color: @danger-color;
}
}
}
}
// 策略标签
.strategy-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
.tag-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #f5f7fa;
border-radius: 14px;
font-size: 12px;
color: #5a6872;
border: 1px solid #e8ecf1;
.anticon {
font-size: 12px;
color: @primary-color;
}
}
}
}
.header-right {
flex-shrink: 0;
.action-btn {
min-width: 120px;
height: 38px;
border-radius: @border-radius-sm;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
.anticon {
font-size: 16px;
}
&.start-btn {
background: linear-gradient(135deg, @success-color 0%, #26d87d 100%);
border: none;
box-shadow: 0 2px 8px rgba(14, 203, 129, 0.3);
&:hover {
box-shadow: 0 4px 12px rgba(14, 203, 129, 0.4);
}
}
&.stop-btn {
background: linear-gradient(135deg, @danger-color 0%, #ff6b7a 100%);
border: none;
box-shadow: 0 2px 8px rgba(246, 70, 93, 0.3);
&:hover {
box-shadow: 0 4px 12px rgba(246, 70, 93, 0.4);
}
}
}
}
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
.strategy-content-card {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
border: none;
border-radius: @border-radius-lg;
box-shadow: @card-shadow;
transition: all 0.3s ease;
min-height: 400px; // 确保有足够的最小高度
&:hover {
box-shadow: @card-shadow-hover;
}
/deep/ .ant-card-head {
background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
/deep/ .ant-card-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
background: #fff;
.ant-tabs {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-bar {
flex-shrink: 0;
}
.ant-tabs-content {
flex: 1;
}
.ant-tabs-tabpane {
.trading-records,
.position-records {
width: 100%;
}
}
}
}
}
}
}
&.theme-dark {
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
color: var(--dark-text-color, #fff);
// 左侧策略列表卡片
.strategy-list-col {
.strategy-list-card {
background: linear-gradient(180deg, #1e222d 0%, #1a1e28 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
.card-title span {
background: linear-gradient(135deg, #e0e6ed 0%, #c5ccd6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/deep/ .ant-card-head {
background: linear-gradient(180deg, #252a36 0%, #1e222d 100%);
border-bottom-color: rgba(255, 255, 255, 0.06);
.ant-card-head-title {
color: #d1d4dc;
}
}
/deep/ .ant-card-body {
background: transparent;
}
/deep/ .ant-empty-description {
color: #868993;
}
/deep/ .ant-list {
.ant-list-item {
border-bottom-color: rgba(255, 255, 255, 0.06);
.ant-list-item-meta-title {
color: #d1d4dc;
}
.ant-list-item-meta-description {
color: #868993;
}
}
}
}
.strategy-list-item {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
&:hover {
background: linear-gradient(135deg, rgba(24, 144, 255, 0.08) 0%, rgba(24, 144, 255, 0.04) 100%);
border-color: rgba(24, 144, 255, 0.2);
}
&.active {
background: linear-gradient(135deg, rgba(24, 144, 255, 0.12) 0%, rgba(24, 144, 255, 0.06) 100%);
border-color: @primary-color;
box-shadow: 0 4px 20px rgba(24, 144, 255, 0.2);
}
.strategy-name {
color: #e0e6ed;
}
.strategy-item-info {
color: #868993;
}
}
}
// 右侧策略详情卡片
.strategy-detail-col {
.strategy-header-card {
background: linear-gradient(135deg, #1e222d 0%, #1a1e28 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
/deep/ .ant-card-head {
background: transparent;
border-bottom: none;
}
/deep/ .ant-card-body {
background: transparent;
}
.strategy-title-row {
.strategy-title {
background: linear-gradient(135deg, #e0e6ed 0%, #c5ccd6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.key-stats-grid {
.stat-card {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
&:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
.stat-content {
.stat-label {
color: #868993;
}
.stat-value {
color: #e0e6ed;
}
}
}
}
.strategy-tags {
.tag-item {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
color: #a0a8b3;
&:hover {
background: rgba(24, 144, 255, 0.1);
border-color: rgba(24, 144, 255, 0.3);
}
}
}
}
.strategy-content-card {
background: linear-gradient(180deg, #1e222d 0%, #1a1e28 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
/deep/ .ant-card-head {
background: rgba(255, 255, 255, 0.02);
border-bottom-color: rgba(255, 255, 255, 0.06);
.ant-card-head-title {
color: #d1d4dc;
}
}
/deep/ .ant-card-body {
background: transparent;
}
/deep/ .ant-tabs {
.ant-tabs-nav {
.ant-tabs-tab {
color: #868993;
&:hover {
color: #d1d4dc;
}
&.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: #1890ff;
}
}
}
.ant-tabs-ink-bar {
background: linear-gradient(90deg, @primary-color 0%, #40a9ff 100%);
}
}
.ant-tabs-content {
color: #d1d4dc;
}
}
/deep/ .ant-empty-description {
color: #868993;
}
}
}
// 空状态
.empty-detail {
/deep/ .ant-empty-description {
color: #868993;
}
}
}
}
/* AI filter box (Step 2) */
.ai-filter-box {
margin-top: 12px;
padding: 14px 14px 12px;
border: 1px solid #e8e8e8;
border-radius: 10px;
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
}
.ai-filter-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ai-filter-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #262626;
}
.ai-filter-title .anticon {
color: #1890ff;
}
.ai-filter-hint {
margin-top: 6px;
font-size: 12px;
line-height: 1.5;
color: #8c8c8c;
}
.symbol-option {
display: flex;
align-items: center;
}
.symbol-name {
font-weight: 600;
color: #8f8d8d;
margin-right: 8px;
}
.symbol-name-extra {
font-size: 12px;
color: #999;
margin-left: 4px;
}
// 弹窗样式
.steps-container {
margin-bottom: 24px;
@media (max-width: 768px) {
margin-bottom: 16px;
/deep/ .ant-steps-item-title {
font-size: 12px;
}
/deep/ .ant-steps-item-icon {
width: 24px;
height: 24px;
line-height: 24px;
font-size: 12px;
}
}
}
.form-container {
min-height: 400px;
padding: 24px 0;
.step-content {
animation: fadeIn 0.3s;
}
@media (max-width: 768px) {
min-height: 300px;
padding: 16px 0;
/deep/ .ant-form-item-label {
padding-bottom: 4px;
label {
font-size: 13px;
}
}
/deep/ .ant-input,
/deep/ .ant-input-number,
/deep/ .ant-select {
font-size: 14px;
}
/deep/ .ant-radio-group {
display: flex;
flex-direction: column;
gap: 8px;
.ant-radio-wrapper {
font-size: 13px;
}
}
}
@media (max-width: 480px) {
min-height: 250px;
padding: 12px 0;
/deep/ .ant-form-item-label label {
font-size: 12px;
}
/deep/ .ant-input,
/deep/ .ant-input-number,
/deep/ .ant-select {
font-size: 13px;
}
}
}
.strategy-type-selector {
padding: 16px 0;
.market-category-selector {
margin-bottom: 16px;
.selector-label {
font-weight: 600;
margin-bottom: 8px;
color: #262626;
}
/deep/ .ant-radio-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.strategy-type-card {
cursor: pointer;
transition: all 0.3s;
height: 100%;
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
}
&.selected {
border-color: #1890ff;
background-color: #e6f7ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
}
.strategy-type-content {
text-align: center;
padding: 16px;
.strategy-type-icon {
font-size: 48px;
color: #1890ff;
margin-bottom: 16px;
}
h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: #1f1f1f;
}
p {
font-size: 14px;
color: #8c8c8c;
margin: 0;
line-height: 1.6;
}
}
}
}
.indicator-option {
display: flex;
justify-content: space-between;
align-items: center;
.indicator-name {
flex: 1;
}
}
.indicator-description {
padding: 12px;
background-color: var(--bg-color-secondary, #f5f5f5);
border-radius: 4px;
color: var(--text-color, #666);
font-size: 14px;
line-height: 1.6;
}
.form-item-hint {
margin-top: 4px;
font-size: 12px;
color: var(--text-color-secondary, #8c8c8c);
}
.test-result {
margin-top: 8px;
padding: 8px;
border-radius: 4px;
font-size: 14px;
&.success {
background-color: #f6ffed;
border: 1px solid #b7eb8f;
color: #52c41a;
}
}
.ip-whitelist-tip {
margin-top: 12px;
padding: 12px;
background-color: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 4px;
font-size: 13px;
color: #1890ff;
line-height: 1.6;
.anticon {
margin-right: 6px;
color: #1890ff;
}
.ip-list {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
.ant-tag {
margin: 0;
font-family: 'Courier New', monospace;
font-size: 12px;
}
}
&.error {
background-color: #fff2f0;
border: 1px solid #ffccc7;
color: #ff4d4f;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/deep/ .danger-item {
color: #ff4d4f;
}
// 移动端弹窗样式
/deep/ .mobile-modal {
.ant-modal {
top: 20px;
padding-bottom: 0;
}
.ant-modal-content {
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
}
.ant-modal-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
}
.ant-modal-footer {
padding: 12px 16px;
border-top: 1px solid #e8e8e8;
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
.ant-btn {
font-size: 13px;
padding: 0 12px;
height: 32px;
}
}
}
</style>