Files
DinQuant/quantdinger_vue/src/views/trading-assistant/index.vue
T
2026-01-13 04:01:42 +08:00

4724 lines
171 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')" />
<div v-else class="strategy-grouped-list">
<!-- 策略组列表 -->
<div
v-for="group in groupedStrategies.groups"
:key="group.id"
class="strategy-group"
>
<!-- 策略组头部 -->
<div class="strategy-group-header" @click="toggleGroup(group.id)">
<div class="group-header-left">
<a-icon :type="collapsedGroups[group.id] ? 'right' : 'down'" class="collapse-icon" />
<a-icon type="folder" class="group-icon" />
<span class="group-name">{{ group.baseName }}</span>
<a-tag size="small" color="blue">{{ group.strategies.length }} {{ $t('trading-assistant.symbolCount') }}</a-tag>
</div>
<div class="group-header-right" @click.stop>
<span v-if="group.runningCount > 0" class="group-status running">
{{ group.runningCount }} {{ $t('trading-assistant.status.running') }}
</span>
<span v-if="group.stoppedCount > 0" class="group-status stopped">
{{ group.stoppedCount }} {{ $t('trading-assistant.status.stopped') }}
</span>
<a-dropdown :getPopupContainer="getDropdownContainer" :trigger="['click']">
<a-menu slot="overlay" @click="({ key }) => handleGroupMenuClick(key, group)">
<a-menu-item key="startAll">
<a-icon type="play-circle" />
{{ $t('trading-assistant.startAll') }}
</a-menu-item>
<a-menu-item key="stopAll">
<a-icon type="pause-circle" />
{{ $t('trading-assistant.stopAll') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="deleteAll" class="danger-item">
<a-icon type="delete" />
{{ $t('trading-assistant.deleteAll') }}
</a-menu-item>
</a-menu>
<a-button type="link" icon="more" size="small" />
</a-dropdown>
</div>
</div>
<!-- 策略组内的策略列表可折叠 -->
<div v-show="!collapsedGroups[group.id]" class="strategy-group-content">
<div
v-for="item in group.strategies"
:key="item.id"
:class="['strategy-list-item', { active: selectedStrategy && selectedStrategy.id === item.id }]"
@click="handleSelectStrategy(item)"
>
<div class="strategy-item-content">
<div class="strategy-item-header">
<div class="strategy-name-wrapper">
<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>
</div>
</div>
<div class="strategy-item-actions" @click.stop>
<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>
</div>
</div>
</div>
</div>
<!-- 未分组的策略列表 -->
<div
v-for="item in groupedStrategies.ungrouped"
:key="item.id"
:class="['strategy-list-item', { active: selectedStrategy && selectedStrategy.id === item.id }]"
@click="handleSelectStrategy(item)"
>
<div class="strategy-item-content">
<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>
<div class="strategy-item-info">
<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>
</div>
<div class="strategy-item-actions" @click.stop>
<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>
</div>
</div>
</div>
</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="isEditMode ? $t('trading-assistant.form.symbol') : $t('trading-assistant.form.symbols')">
<!-- 编辑模式单选 -->
<a-select
v-if="isEditMode"
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>
<!-- 创建模式多选 -->
<a-select
v-else
v-model="selectedSymbols"
mode="multiple"
:placeholder="$t('trading-assistant.placeholders.selectSymbols')"
show-search
:filter-option="filterWatchlistOption"
:loading="loadingWatchlist"
@change="handleMultiSymbolChange"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
:maxTagCount="3"
>
<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">
{{ isEditMode ? $t('trading-assistant.form.symbolHintCrypto') : $t('trading-assistant.form.symbolsHint') }}
</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="!canUseLiveTrading"
@change="onExecutionModeChange"
>
<a-radio value="signal">{{ $t('trading-assistant.form.executionModeSignal') }}</a-radio>
<a-radio value="live" :disabled="!canUseLiveTrading">{{ $t('trading-assistant.form.executionModeLive') }}</a-radio>
</a-radio-group>
<div v-if="!canUseLiveTrading" class="form-item-hint" style="color: #ff9800;">
{{ $t('trading-assistant.form.liveTradingNotSupportedHint') }}
</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' && canUseLiveTrading" />
<!-- Live trading: exchange credentials -->
<div v-if="executionModeUi === 'live' && canUseLiveTrading">
<a-alert
type="info"
show-icon
style="margin-bottom: 12px;"
:message="$t('trading-assistant.form.liveTradingConfigTitle')"
:description="$t('trading-assistant.form.liveTradingConfigHint')"
/>
<!-- ========== Broker Configuration (US/HK Stocks) ========== -->
<template v-if="isIBKRMarket">
<a-form-item :label="$t('trading-assistant.form.broker')">
<a-select
v-decorator="['broker_id', {
initialValue: 'ibkr',
rules: [{ required: true, message: $t('trading-assistant.validation.brokerRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.selectBroker')"
:getPopupContainer="getModalPopupContainer"
@change="handleBrokerSelectChange"
>
<a-select-option
v-for="broker in brokerOptions"
:key="broker.value"
:value="broker.value"
>
{{ broker.displayName }}
</a-select-option>
</a-select>
</a-form-item>
<!-- IBKR specific configuration -->
<template v-if="currentBrokerId === 'ibkr'">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px;"
:message="$t('trading-assistant.form.ibkrConnectionTitle')"
:description="$t('trading-assistant.form.ibkrConnectionHint')"
/>
<a-form-item :label="$t('trading-assistant.form.ibkrHost')">
<a-input
v-decorator="['ibkr_host', { initialValue: '127.0.0.1' }]"
placeholder="127.0.0.1"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.ibkrPort')">
<a-input-number
v-decorator="['ibkr_port', { initialValue: 7497 }]"
placeholder="7497"
:min="1"
:max="65535"
style="width: 100%"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.ibkrPortHint') }}</div>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.ibkrClientId')">
<a-input-number
v-decorator="['ibkr_client_id', { initialValue: 1 }]"
placeholder="1"
:min="1"
:max="999"
style="width: 100%"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.ibkrAccount')">
<a-input
v-decorator="['ibkr_account', { initialValue: '' }]"
:placeholder="$t('trading-assistant.placeholders.ibkrAccount')"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.ibkrAccountHint') }}</div>
</a-form-item>
</template>
<!-- Future broker configurations can be added here -->
<!-- <template v-else-if="currentBrokerId === 'futu'">...</template> -->
</template>
<!-- ========== MT5/Forex Broker Configuration ========== -->
<template v-else-if="isMT5Market">
<a-form-item :label="$t('trading-assistant.form.forexBroker')">
<a-select
v-decorator="['forex_broker_id', {
initialValue: 'mt5',
rules: [{ required: true, message: $t('trading-assistant.validation.brokerRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.selectBroker')"
:getPopupContainer="getModalPopupContainer"
@change="handleForexBrokerSelectChange"
>
<a-select-option
v-for="broker in forexBrokerOptions"
:key="broker.value"
:value="broker.value"
>
{{ broker.displayName }}
</a-select-option>
</a-select>
</a-form-item>
<!-- MT5 specific configuration -->
<template v-if="currentBrokerId === 'mt5'">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px;"
:message="$t('trading-assistant.form.mt5ConnectionTitle')"
:description="$t('trading-assistant.form.mt5ConnectionHint')"
/>
<a-form-item :label="$t('trading-assistant.form.mt5Server')">
<a-input
v-decorator="['mt5_server', {
rules: [{ required: true, message: $t('trading-assistant.validation.mt5ServerRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.mt5Server')"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.mt5ServerHint') }}</div>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.mt5Login')">
<a-input-number
v-decorator="['mt5_login', {
rules: [{ required: true, message: $t('trading-assistant.validation.mt5LoginRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.mt5Login')"
:min="1"
style="width: 100%"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.mt5Password')">
<a-input-password
v-decorator="['mt5_password', {
rules: [{ required: true, message: $t('trading-assistant.validation.mt5PasswordRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.mt5Password')"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.mt5TerminalPath')">
<a-input
v-decorator="['mt5_terminal_path']"
:placeholder="$t('trading-assistant.placeholders.mt5TerminalPath')"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.mt5TerminalPathHint') }}</div>
</a-form-item>
</template>
<!-- Future forex broker configurations can be added here -->
<!-- <template v-else-if="currentBrokerId === 'mt4'">...</template> -->
</template>
<!-- ========== Crypto Exchange Configuration ========== -->
<template v-else>
<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 cryptoExchangeOptions"
: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>
</template>
<!-- Test Connection Button (shared by both IBKR and Crypto) -->
<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, updateStrategy, testExchangeConnection, getStrategyEquityCurve, batchCreateStrategies, batchStartStrategies, batchStopStrategies, batchDeleteStrategies } 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'
]
// Crypto exchange options
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' }
]
// Traditional broker options (US/HK stocks) - extensible for future brokers
const BROKER_OPTIONS = [
{ value: 'ibkr', labelKey: 'ibkr', name: 'Interactive Brokers' }
// Future brokers can be added here:
// { value: 'td', labelKey: 'td', name: 'TD Ameritrade' },
// { value: 'schwab', labelKey: 'schwab', name: 'Charles Schwab' },
// { value: 'futu', labelKey: 'futu', name: 'Futu (富途)' },
// { value: 'tiger', labelKey: 'tiger', name: 'Tiger Brokers (老虎证券)' },
]
// Forex broker options
const FOREX_BROKER_OPTIONS = [
{ value: 'mt5', labelKey: 'mt5', name: 'MetaTrader 5' }
// Future forex brokers can be added here:
// { value: 'mt4', labelKey: 'mt4', name: 'MetaTrader 4' },
// { value: 'ctrader', labelKey: 'ctrader', name: 'cTrader' },
]
export default {
name: 'TradingAssistant',
mixins: [baseMixin],
components: {
TradingRecords,
PositionRecords
},
computed: {
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
needsPassphrase () {
// Exchanges that require passphrase
return ['okx', 'okex', 'coinbaseexchange', 'kucoin', 'bitget'].includes(this.currentExchangeId)
},
// Check if current market uses IBKR (US Stock / HK Stock)
isIBKRMarket () {
return ['USStock', 'HShare'].includes(this.selectedMarketCategory)
},
// Check if current market uses MT5 (Forex)
isMT5Market () {
return this.selectedMarketCategory === 'Forex'
},
// Check if current market uses any broker (not crypto exchange)
isBrokerMarket () {
return this.isIBKRMarket || this.isMT5Market
},
// 预处理交易所列表,包含显示名称,提升性能
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'
},
// Check if selected market supports live trading (Crypto, USStock/HShare with IBKR, or Forex with MT5)
canUseLiveTrading () {
const cat = this.selectedMarketCategory || 'Crypto'
// Crypto always supports live trading via crypto exchanges
if (String(cat).toLowerCase() === 'crypto') {
return true
}
// USStock/HShare can use IBKR for live trading
if (['USStock', 'HShare'].includes(cat)) {
return true
}
// Forex can use MT5 for live trading
if (cat === 'Forex') {
return true
}
return false
},
// Check if current market + exchange combination supports live trading
isLiveTradingAvailable () {
const cat = this.selectedMarketCategory || 'Crypto'
const exchangeId = this.currentExchangeId || ''
// Crypto markets use crypto exchanges
if (String(cat).toLowerCase() === 'crypto') {
return ['binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange', 'kraken', 'kucoin', 'gate', 'bitfinex'].includes(exchangeId)
}
// USStock/HShare use IBKR
if (['USStock', 'HShare'].includes(cat)) {
return this.currentBrokerId === 'ibkr'
}
// Forex uses MT5
if (cat === 'Forex') {
return this.currentBrokerId === 'mt5'
}
return false
},
// Broker options for US/HK stocks (with i18n support)
brokerOptions () {
return BROKER_OPTIONS.map(broker => {
let label = ''
try {
const translationKey = `trading-assistant.brokerNames.${broker.labelKey}`
const translated = this.$t(translationKey)
if (translated !== translationKey) {
label = translated
}
} catch (e) {}
if (!label) {
label = broker.name || broker.value.toUpperCase()
}
return {
...broker,
displayName: label
}
})
},
// Forex broker options (with i18n support)
forexBrokerOptions () {
return FOREX_BROKER_OPTIONS.map(broker => {
let label = ''
try {
const translationKey = `trading-assistant.brokerNames.${broker.labelKey}`
const translated = this.$t(translationKey)
if (translated !== translationKey) {
label = translated
}
} catch (e) {}
if (!label) {
label = broker.name || broker.value.toUpperCase()
}
return {
...broker,
displayName: label
}
})
},
// Crypto exchange options only
cryptoExchangeOptions () {
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
}
})
},
// 策略分组显示
groupedStrategies () {
const groups = {}
const ungrouped = []
for (const s of this.strategies) {
const groupId = s.strategy_group_id
if (groupId && groupId.trim()) {
if (!groups[groupId]) {
groups[groupId] = {
id: groupId,
baseName: s.group_base_name || s.strategy_name.split('-')[0],
strategies: [],
// 统计信息
runningCount: 0,
stoppedCount: 0
}
}
groups[groupId].strategies.push(s)
if (s.status === 'running') {
groups[groupId].runningCount++
} else {
groups[groupId].stoppedCount++
}
} else {
ungrouped.push(s)
}
}
// 转换为数组,按创建时间排序
const groupList = Object.values(groups).sort((a, b) => {
const aTime = Math.max(...a.strategies.map(s => s.created_at || 0))
const bTime = Math.max(...b.strategies.map(s => s.created_at || 0))
return bTime - aTime
})
return { groups: groupList, ungrouped }
}
},
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: '',
currentBrokerId: 'ibkr',
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,
// 多币种选择(创建模式)
selectedSymbols: [],
// 策略组折叠状态
collapsedGroups: {}
// 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'
// Auto-set broker ID based on market category
if (this.selectedMarketCategory === 'Forex') {
this.currentBrokerId = 'mt5'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ forex_broker_id: 'mt5' })
} catch (e) {}
} else if (['USStock', 'HShare'].includes(this.selectedMarketCategory)) {
this.currentBrokerId = 'ibkr'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ broker_id: 'ibkr' })
} catch (e) {}
}
// Markets without live trading support: force back to signal mode
// Crypto, USStock, HShare, Forex support live trading; others do not
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare', 'Forex'].includes(this.selectedMarketCategory)
if (!supportsLiveTrading) {
this.executionModeUi = 'signal'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
} catch (e) {}
}
// Clear exchange selection when market changes (different markets use different exchanges)
this.currentExchangeId = ''
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ exchange_id: undefined })
} catch (e) {}
},
handleMultiSymbolChange (vals) {
// vals: array like ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
this.selectedSymbols = vals || []
// Update market type based on selected symbols
if (vals && vals.length > 0) {
const firstVal = vals[0]
if (typeof firstVal === 'string' && firstVal.includes(':')) {
const idx = firstVal.indexOf(':')
const market = firstVal.slice(0, idx)
this.selectedMarketCategory = market || 'Crypto'
}
}
// Auto-set broker ID based on market category
if (this.selectedMarketCategory === 'Forex') {
this.currentBrokerId = 'mt5'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ forex_broker_id: 'mt5' })
} catch (e) {}
} else if (['USStock', 'HShare'].includes(this.selectedMarketCategory)) {
this.currentBrokerId = 'ibkr'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ broker_id: 'ibkr' })
} catch (e) {}
}
// Markets without live trading support: force back to signal mode
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare', 'Forex'].includes(this.selectedMarketCategory)
if (!supportsLiveTrading) {
this.executionModeUi = 'signal'
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
} catch (e) {}
}
// Clear exchange selection when market changes
this.currentExchangeId = ''
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ exchange_id: undefined })
} 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 market doesn't support live trading, force signal mode
if (!this.canUseLiveTrading && 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.currentBrokerId = 'ibkr'
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.selectedSymbols = []
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)
})
}
}
// Load exchange/broker configuration
if (strategy.exchange_config) {
const exchangeId = strategy.exchange_config.exchange_id || ''
const isLive = this.executionModeUi === 'live'
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare', 'Forex'].includes(this.selectedMarketCategory)
const isBrokerMarket = ['USStock', 'HShare'].includes(this.selectedMarketCategory)
const isForexMarket = this.selectedMarketCategory === 'Forex'
if (isLive && supportsLiveTrading) {
if (isBrokerMarket) {
// Broker configuration (US/HK stocks)
this.currentBrokerId = exchangeId || 'ibkr'
this.form.setFieldsValue({
broker_id: exchangeId || 'ibkr',
ibkr_host: strategy.exchange_config.ibkr_host || '127.0.0.1',
ibkr_port: strategy.exchange_config.ibkr_port || 7497,
ibkr_client_id: strategy.exchange_config.ibkr_client_id || 1,
ibkr_account: strategy.exchange_config.ibkr_account || ''
})
} else if (isForexMarket) {
// MT5 configuration (Forex)
this.currentBrokerId = exchangeId || 'mt5'
this.form.setFieldsValue({
forex_broker_id: exchangeId || 'mt5',
mt5_server: strategy.exchange_config.mt5_server || '',
mt5_login: strategy.exchange_config.mt5_login || '',
mt5_password: strategy.exchange_config.mt5_password || '',
mt5_terminal_path: strategy.exchange_config.mt5_terminal_path || ''
})
} else {
// Crypto exchange configuration
this.currentExchangeId = exchangeId
this.suppressApiClearOnce = true
this.form.setFieldsValue({
exchange_id: exchangeId,
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
const credId = strategy.exchange_config.credential_id
if (credId) {
await this.handleCredentialSelectChange(credId)
}
}
}
// Update UI state
if (isBrokerMarket || isForexMarket) {
this.currentBrokerId = exchangeId || (isForexMarket ? 'mt5' : 'ibkr')
} else {
this.currentExchangeId = exchangeId
}
}
// 加载交易配置
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
}
},
toggleGroup (groupId) {
this.$set(this.collapsedGroups, groupId, !this.collapsedGroups[groupId])
},
async handleGroupMenuClick (key, group) {
const strategyIds = group.strategies.map(s => s.id)
switch (key) {
case 'startAll':
await this.handleBatchStartStrategies(strategyIds, group.baseName)
break
case 'stopAll':
await this.handleBatchStopStrategies(strategyIds, group.baseName)
break
case 'deleteAll':
await this.handleBatchDeleteStrategies(strategyIds, group.baseName)
break
}
},
async handleBatchStartStrategies (strategyIds, groupName) {
try {
const res = await batchStartStrategies({ strategy_ids: strategyIds })
if (res.code === 1) {
const count = res.data?.success_ids?.length || strategyIds.length
this.$message.success(this.$t('trading-assistant.messages.batchStartSuccess', { count }))
this.loadStrategies()
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.batchStartFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.batchStartFailed'))
}
},
async handleBatchStopStrategies (strategyIds, groupName) {
try {
const res = await batchStopStrategies({ strategy_ids: strategyIds })
if (res.code === 1) {
const count = res.data?.success_ids?.length || strategyIds.length
this.$message.success(this.$t('trading-assistant.messages.batchStopSuccess', { count }))
this.loadStrategies()
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.batchStopFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.batchStopFailed'))
}
},
async handleBatchDeleteStrategies (strategyIds, groupName) {
const confirmText = this.$t('trading-assistant.messages.batchDeleteConfirm', {
count: strategyIds.length,
name: groupName
})
this.$confirm({
title: this.$t('trading-assistant.deleteAll'),
content: confirmText,
okText: this.$t('trading-assistant.deleteAll'),
okType: 'danger',
cancelText: this.$t('trading-assistant.form.cancel'),
onOk: async () => {
try {
const res = await batchDeleteStrategies({ strategy_ids: strategyIds })
if (res.code === 1) {
const count = res.data?.success_ids?.length || strategyIds.length
this.$message.success(this.$t('trading-assistant.messages.batchDeleteSuccess', { count }))
// 如果删除的策略包含当前选中的策略,清空选中状态
if (this.selectedStrategy && strategyIds.includes(this.selectedStrategy.id)) {
this.selectedStrategy = null
this.stopEquityPolling()
}
this.loadStrategies()
} else {
this.$message.error(res.msg || this.$t('trading-assistant.messages.batchDeleteFailed'))
}
} catch (error) {
this.$message.error(this.$t('trading-assistant.messages.batchDeleteFailed'))
}
}
})
},
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',
ibkr: 'green'
}
return colorMap[exchangeId] || 'default'
},
handleApiConfigChange () {
// 当API配置字段变化时,清空测试结果,需要重新测试
this.testResult = null
this.connectionTestResult = null
},
getModalPopupContainer () {
// Return document.body for Select dropdown to avoid modal scroll issues
return window.document.body
},
handleBrokerSelectChange (value) {
this.currentBrokerId = value || 'ibkr'
this.testResult = null
this.connectionTestResult = null
},
handleForexBrokerSelectChange (value) {
this.currentBrokerId = value || 'mt5'
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 {
// IBKR uses different connection test (host/port instead of api_key/secret)
if (this.isIBKRMarket) {
const values = this.form.getFieldsValue(['ibkr_host', 'ibkr_port', 'ibkr_client_id', 'ibkr_account'])
const host = values.ibkr_host || '127.0.0.1'
const port = values.ibkr_port || 7497
const clientId = values.ibkr_client_id || 1
const account = values.ibkr_account || ''
try {
// Call IBKR connect API
const res = await this.$http.post('/api/ibkr/connect', {
host: host,
port: parseInt(port),
clientId: parseInt(clientId),
account: account
})
if (res.data && res.data.success) {
this.testResult = {
success: true,
message: this.$t('trading-assistant.exchange.ibkrConnectionSuccess')
}
this.$message.success(this.$t('trading-assistant.exchange.ibkrConnectionSuccess'))
} else {
this.testResult = {
success: false,
message: res.data?.error || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
}
this.$message.error(this.testResult.message)
}
} catch (error) {
this.testResult = {
success: false,
message: error.response?.data?.error || error.message || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
}
this.$message.error(this.testResult.message)
} finally {
this.testing = false
}
return
}
// MT5 uses different connection test (server/login/password)
if (this.isMT5Market) {
const values = this.form.getFieldsValue(['mt5_server', 'mt5_login', 'mt5_password', 'mt5_terminal_path'])
const server = values.mt5_server || ''
const login = values.mt5_login || ''
const password = values.mt5_password || ''
const terminal_path = values.mt5_terminal_path || ''
if (!server || !login || !password) {
this.testResult = {
success: false,
message: this.$t('trading-assistant.exchange.fillComplete')
}
this.$message.error(this.testResult.message)
this.testing = false
return
}
try {
// Call MT5 connect API
const res = await this.$http.post('/api/mt5/connect', {
server: server,
login: parseInt(login),
password: password,
terminal_path: terminal_path
})
if (res.data && res.data.success) {
this.testResult = {
success: true,
message: this.$t('trading-assistant.exchange.mt5ConnectionSuccess')
}
this.$message.success(this.$t('trading-assistant.exchange.mt5ConnectionSuccess'))
} else {
this.testResult = {
success: false,
message: res.data?.error || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
}
this.$message.error(this.testResult.message)
}
} catch (error) {
this.testResult = {
success: false,
message: error.response?.data?.error || error.message || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
}
this.$message.error(this.testResult.message)
} finally {
this.testing = false
}
return
}
// Crypto exchanges: validate api_key/secret_key fields
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,
market_type: String(marketType || 'swap')
}
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)
// 编辑模式验证 symbol,创建模式验证 selectedSymbols(多选)
const fieldsToValidate = [
'indicator_id',
'strategy_name',
'initial_capital',
'market_type',
'leverage',
'trade_direction',
'timeframe'
]
// 编辑模式需要验证 symbol 字段
if (this.isEditMode) {
fieldsToValidate.push('symbol')
}
this.form.validateFields(fieldsToValidate, (err, values) => {
if (err) return
// 创建模式:验证多币种选择
if (!this.isEditMode) {
if (!this.selectedSymbols || this.selectedSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
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.canUseLiveTrading && 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
}
// 构建基础 payload
const basePayload = {
strategy_name: values.strategy_name,
market_category: this.selectedMarketCategory || 'Crypto',
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 ? (this.isIBKRMarket ? {
// Broker configuration (US/HK stocks)
exchange_id: values.broker_id || this.currentBrokerId || 'ibkr',
// IBKR specific fields
ibkr_host: values.ibkr_host || '127.0.0.1',
ibkr_port: values.ibkr_port || 7497,
ibkr_client_id: values.ibkr_client_id || 1,
ibkr_account: values.ibkr_account || ''
} : this.isMT5Market ? {
// MT5/Forex broker configuration
exchange_id: values.forex_broker_id || this.currentBrokerId || 'mt5',
// MT5 specific fields
mt5_server: values.mt5_server || '',
mt5_login: values.mt5_login || '',
mt5_password: values.mt5_password || '',
mt5_terminal_path: values.mt5_terminal_path || ''
} : {
// Crypto exchange configuration
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: {
initial_capital: values.initial_capital,
leverage: leverage,
trade_direction: tradeDirection,
timeframe: values.timeframe,
market_type: marketType,
// Order execution settings moved to backend env config (ORDER_MODE, MAKER_WAIT_SEC, MAKER_OFFSET_BPS)
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) {
// 编辑模式:更新单个策略
let parsedSymbol = values.symbol
if (typeof parsedSymbol === 'string' && parsedSymbol.includes(':')) {
const idx = parsedSymbol.indexOf(':')
basePayload.market_category = parsedSymbol.slice(0, idx) || basePayload.market_category
parsedSymbol = parsedSymbol.slice(idx + 1)
}
basePayload.trading_config.symbol = parsedSymbol
res = await updateStrategy(this.editingStrategy.id, basePayload)
} else {
// 创建模式:批量创建策略
basePayload.user_id = 1
basePayload.strategy_type = 'IndicatorStrategy'
basePayload.symbols = this.selectedSymbols // 多币种数组
res = await batchCreateStrategies(basePayload)
}
if (res.code === 1) {
if (this.isEditMode) {
this.$message.success(this.$t('trading-assistant.messages.updateSuccess'))
} else {
const totalCreated = res.data?.total_created || this.selectedSymbols.length
this.$message.success(this.$t('trading-assistant.messages.batchCreateSuccess', { count: totalCreated }))
}
// Save credential to vault (crypto exchanges only, IBKR/MT5 don't need this)
if (isLive && values.save_credential && !this.isIBKRMarket && !this.isMT5Market) {
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 document.body
}
}
}
</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-grouped-list {
.strategy-group {
margin-bottom: 12px;
background: #fff;
border-radius: @border-radius-md;
border: 1px solid #e8ecf1;
overflow: hidden;
.strategy-group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: linear-gradient(135deg, #e8f4fd 0%, #e3f0fc 100%);
}
.group-header-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
.collapse-icon {
font-size: 12px;
color: #8c8c8c;
transition: transform 0.2s ease;
}
.group-icon {
font-size: 16px;
color: @primary-color;
}
.group-name {
font-weight: 600;
font-size: 14px;
color: #1e3a5f;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.group-header-right {
display: flex;
align-items: center;
gap: 8px;
.group-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
&.running {
background: rgba(14, 203, 129, 0.1);
color: @success-color;
}
&.stopped {
background: rgba(246, 70, 93, 0.1);
color: @danger-color;
}
}
}
}
.strategy-group-content {
padding: 4px 8px 8px;
.strategy-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
margin-bottom: 4px;
margin-left: 20px;
border-left: 2px solid #e8ecf1;
background: #fafbfc;
border-radius: 0 @border-radius-sm @border-radius-sm 0;
&:last-child {
margin-bottom: 0;
}
&:hover {
background: #f0f7ff;
border-left-color: @primary-color;
}
&.active {
background: #e6f4ff;
border-left-color: @primary-color;
border-left-width: 3px;
}
.strategy-item-content {
flex: 1;
min-width: 0;
}
.strategy-item-actions {
flex-shrink: 0;
}
}
}
}
}
.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>