Files
DinQuant/frontend_vue/src/views/trading-assistant/index.vue
T
dienakdz c3cf230104 feat(trading-assistant): refactor strategy creation and enhance script mode functionality
- Removed conditional rendering for the assistant guide bar.
- Simplified strategy overview and strategy list item components.
- Introduced a new modal for selecting strategy mode.
- Enhanced strategy creation modal to support script strategies with a dedicated editor.
- Updated form handling for script strategies, including validation and submission logic.
- Improved user experience with better messaging and streamlined UI components.
- Updated translations for better clarity in Chinese.
2026-04-08 07:27:26 +07:00

6602 lines
248 KiB
Vue

<template>
<div class="trading-assistant" :class="{ 'theme-dark': isDarkTheme }">
<div class="assistant-guide-bar">
<div class="assistant-guide-copy">
<div class="assistant-guide-eyebrow">{{ tt('trading-assistant.guide.eyebrow', 'Trading Workflow') }}</div>
<div class="assistant-guide-title">{{ tt('trading-assistant.guide.title', 'Build, launch, and monitor strategies from one workspace') }}</div>
<div class="assistant-guide-desc">{{ tt('trading-assistant.guide.desc', 'The latest private frontend adds a clearer entry flow for strategy creation and management. This screen now mirrors that structure more closely.') }}</div>
</div>
<div class="assistant-guide-steps">
<div class="assistant-step-card">
<div class="assistant-step-index">1</div>
<div class="assistant-step-body">
<div class="assistant-step-title">{{ tt('trading-assistant.guide.step1Title', 'Choose an indicator') }}</div>
<div class="assistant-step-desc">{{ tt('trading-assistant.guide.step1Desc', 'Start from your own or purchased indicator, then define market and symbol scope.') }}</div>
</div>
</div>
<div class="assistant-step-card">
<div class="assistant-step-index">2</div>
<div class="assistant-step-body">
<div class="assistant-step-title">{{ tt('trading-assistant.guide.step2Title', 'Configure execution') }}</div>
<div class="assistant-step-desc">{{ tt('trading-assistant.guide.step2Desc', 'Set notifications, risk controls, and optional live-trading credentials.') }}</div>
</div>
</div>
<div class="assistant-step-card">
<div class="assistant-step-index">3</div>
<div class="assistant-step-body">
<div class="assistant-step-title">{{ tt('trading-assistant.guide.step3Title', 'Track runtime data') }}</div>
<div class="assistant-step-desc">{{ tt('trading-assistant.guide.step3Desc', 'Review positions, trade history, performance, and runtime logs after launch.') }}</div>
</div>
</div>
</div>
<div class="assistant-guide-actions">
<a-button @click="goToStrategyTab">
<a-icon type="appstore" />
{{ tt('trading-assistant.guide.secondary', 'Open Strategy Manager') }}
</a-button>
<a-button type="primary" @click="openCreateStrategyFromGuide">
<a-icon type="plus" />
{{ tt('trading-assistant.guide.primary', 'Create Strategy') }}
</a-button>
</div>
</div>
<a-tabs v-model="topTab" class="top-level-tabs" :animated="false">
<a-tab-pane key="overview">
<span slot="tab">{{ tt('trading-assistant.tabs.overview', 'Overview') }}</span>
<dashboard-overview v-if="topTab === 'overview'" />
</a-tab-pane>
<a-tab-pane key="strategy">
<span slot="tab">{{ tt('trading-assistant.tabs.strategyManage', 'Strategy Manager') }}</span>
<a-row :gutter="24" class="strategy-layout">
<!-- Left side: strategy list -->
<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>
<!-- Grouping mode switch -->
<div class="group-mode-switch">
<span class="group-mode-label">{{ $t('trading-assistant.groupBy') }}:</span>
<a-radio-group v-model="groupByMode" size="small" button-style="solid">
<a-radio-button value="strategy">
<a-icon type="folder" />
{{ $t('trading-assistant.groupByStrategy') }}
</a-radio-button>
<a-radio-button value="symbol">
<a-icon type="stock" />
{{ $t('trading-assistant.groupBySymbol') }}
</a-radio-button>
</a-radio-group>
</div>
<a-spin :spinning="loading">
<div v-if="!loading && strategies.length === 0" class="strategy-empty-state">
<a-empty :description="tt('trading-assistant.empty.title', 'No strategy yet')" />
<div class="strategy-empty-desc">
{{ tt('trading-assistant.empty.desc', 'Create a strategy from one of your indicators or open a prefilled strategy from indicator-analysis.') }}
</div>
<div class="strategy-empty-path">
{{ tt('trading-assistant.empty.path', 'Indicator Analysis -> Strategy Rocket -> Trading Assistant') }}
</div>
<a-button type="primary" @click="openCreateStrategyFromGuide">
<a-icon type="plus" />
{{ tt('trading-assistant.empty.primary', 'Create your first strategy') }}
</a-button>
</div>
<div v-else class="strategy-grouped-list">
<!-- Strategy groups -->
<div v-for="group in groupedStrategies.groups" :key="group.id" class="strategy-group">
<!-- Group header -->
<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="groupByMode === 'symbol' ? 'stock' : 'folder'" class="group-icon" />
<span class="group-name">{{ group.baseName }}</span>
<a-tag size="small" color="blue">{{ group.strategies.length }} {{
groupByMode === 'symbol' ? $t('trading-assistant.strategyCount') : $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>
<!-- Strategies inside the group (collapsible) -->
<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">
<template v-if="groupByMode === 'strategy'">
<span class="info-item" v-if="item.trading_config && item.trading_config.symbol">
<a-icon type="dollar" />
{{ item.trading_config.symbol }}
</span>
</template>
<template v-else>
<span class="info-item strategy-name-text">
<a-icon type="thunderbolt" />
{{ item.displayInfo ? item.displayInfo.strategyName : item.strategy_name }}
</span>
<a-tag size="small" color="cyan" v-if="item.displayInfo && item.displayInfo.timeframe">
<a-icon type="clock-circle" style="margin-right: 2px;" />
{{ item.displayInfo.timeframe }}
</a-tag>
<a-tag size="small" color="purple" v-if="item.displayInfo && item.displayInfo.indicatorName && item.displayInfo.indicatorName !== '-'">
<a-icon type="line-chart" style="margin-right: 2px;" />
{{ item.displayInfo.indicatorName }}
</a-tag>
</template>
<a-tag v-if="item.strategy_mode === 'script'" size="small" color="green" style="margin-left: 4px;">
<a-icon type="code" style="margin-right: 2px;" />
{{ tt('trading-assistant.strategyMode.script', 'Script Strategy') }}
</a-tag>
<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-item key="backtest">
<a-icon type="experiment" />
{{ $t('dashboard.indicator.action.backtest') }}
</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>
<!-- Ungrouped strategies -->
<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-item key="backtest">
<a-icon type="experiment" />
{{ $t('dashboard.indicator.action.backtest') }}
</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>
<!-- Right side: strategy details and runtime records -->
<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">
<!-- Strategy header -->
<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>
<!-- Key stats -->
<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>
<!-- Strategy tags -->
<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
size="large"
class="action-btn"
@click="goToBacktestCenter(selectedStrategy)">
<a-icon type="experiment" />
{{ $t('dashboard.indicator.action.backtest') }}
</a-button>
<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>
<!-- Strategy detail tabs -->
<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"
:is-dark="isDarkTheme" />
</a-tab-pane>
<a-tab-pane key="trades" :tab="$t('trading-assistant.tabs.tradingRecords')">
<trading-records :strategy-id="selectedStrategy.id" :loading="loadingRecords" :is-dark="isDarkTheme" />
</a-tab-pane>
<a-tab-pane key="performance" :tab="tt('trading-assistant.tabs.performance', 'Performance')">
<performance-analysis :strategy-id="selectedStrategy.id" :is-dark="isDarkTheme" />
</a-tab-pane>
<a-tab-pane key="logs" :tab="tt('trading-assistant.tabs.logs', 'Logs')">
<strategy-logs :strategy-id="selectedStrategy.id" :is-dark="isDarkTheme" />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
<a-modal
class="mode-selector-modal"
:visible="showModeSelector"
:title="tt('trading-assistant.selectMode', 'Select Strategy Mode')"
:width="isMobile ? '95%' : 700"
:footer="null"
:maskClosable="true"
:bodyStyle="{ padding: '16px 24px' }"
@cancel="showModeSelector = false">
<strategy-type-selector
:selected="strategyMode"
:is-dark="isDarkTheme"
@select="handleModeSelect"
@use-template="handleUseTemplate" />
</a-modal>
<!-- Create/edit strategy modal -->
<a-modal
:visible="showFormModal"
:title="editingStrategy ? $t('trading-assistant.editStrategy') : `${$t('trading-assistant.createStrategy')}${strategyMode === 'script' ? ` - ${tt('trading-assistant.strategyMode.script', 'Script Strategy')}` : ''}`"
:width="isMobile ? '95%' : 1120"
:confirmLoading="saving"
@ok="handleSubmit"
@cancel="handleCloseModal"
:maskClosable="false"
:wrapClassName="strategyFormWrapClass"
:bodyStyle="{ maxHeight: '76vh', overflowY: 'auto', paddingBottom: '8px' }">
<a-spin :spinning="loadingIndicators">
<!-- Simple / Advanced mode toggle -->
<div class="creation-mode-toggle" v-if="!editingStrategy && strategyMode !== 'script'">
<div class="mode-meta">
<div class="mode-title">{{ tt('trading-assistant.selectMode', 'Select Strategy Mode') }}</div>
<div class="mode-hint">{{ isSimpleMode ? $t('trading-assistant.form.simpleModeHint') : $t('trading-assistant.form.advancedModeHint') }}</div>
</div>
<a-radio-group v-model="creationMode" size="small" button-style="solid">
<a-radio-button value="simple">
<a-icon type="rocket" /> {{ $t('trading-assistant.form.simpleMode') }}
</a-radio-button>
<a-radio-button value="advanced">
<a-icon type="setting" /> {{ $t('trading-assistant.form.advancedMode') }}
</a-radio-button>
</a-radio-group>
</div>
<a-steps :current="displayCurrentStep" class="steps-container">
<template v-if="strategyMode === 'script'">
<a-step :title="$t('trading-assistant.form.simpleStep1')" />
<a-step :title="tt('trading-assistant.editor.title', 'Script Editor')" />
<a-step :title="$t('trading-assistant.form.step3Signal')" />
</template>
<template v-else>
<a-step :title="isSimpleMode && !editingStrategy ? $t('trading-assistant.form.simpleStep1') : $t('trading-assistant.form.step1')" />
<a-step v-if="isAdvancedMode || editingStrategy" :title="$t('trading-assistant.form.step2Params')" />
<a-step :title="isSimpleMode && !editingStrategy ? $t('trading-assistant.form.simpleStep2') : $t('trading-assistant.form.step3Signal')" />
</template>
</a-steps>
<div class="form-container">
<!-- Step 1: indicator selection or base strategy configuration -->
<div v-show="currentStep === 0" class="step-content">
<div v-if="strategyMode === 'script'">
<a-form :form="form" layout="vertical">
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<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-col>
<a-col :xs="24" :md="12">
<a-form-item :label="$t('trading-assistant.form.symbol')">
<a-select
v-decorator="['symbol', { rules: [{ required: true, message: $t('trading-assistant.validation.symbolRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.selectSymbol')"
show-search
:filter-option="filterWatchlistOption"
:loading="loadingWatchlist"
@change="handleWatchlistSymbolChange"
:getPopupContainer="(triggerNode) => triggerNode.parentNode">
<a-select-option
v-for="item in watchlist"
:key="`${item.market}:${item.symbol}`"
:value="`${item.market}:${item.symbol}`">
<div class="symbol-option">
<a-tag :color="getMarketColor(item.market)" style="margin-right: 8px; margin-bottom: 0;">
{{ item.market }}
</a-tag>
<span class="symbol-name">{{ item.symbol }}</span>
<span v-if="item.name" class="symbol-name-extra">{{ item.name }}</span>
</div>
</a-select-option>
<a-select-option key="__add_symbol_option__" value="__add_symbol_option__" class="add-symbol-option">
<div style="width: 100%; text-align: center; padding: 4px 0; color: #1890ff; cursor: pointer;">
<a-icon type="plus" style="margin-right: 4px;" />
<span>{{ $t('trading-assistant.form.addSymbol') }}</span>
</div>
</a-select-option>
</a-select>
<div class="form-item-hint">
{{ tt('trading-assistant.editor.symbolHint', 'Script strategies currently run on a single watchlist symbol.') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px;"
:message="tt('trading-assistant.editor.scriptModeTitle', 'Direct Python strategy mode')"
:description="tt('trading-assistant.editor.scriptModeDesc', 'The next step opens a full code editor with starter templates, API reference, and backend verification.')"
/>
<a-row :gutter="16">
<a-col :xs="24" :md="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" :md="12">
<a-form-item :label="$t('trading-assistant.form.klinePeriod')">
<a-select
v-decorator="['timeframe', { initialValue: '15m', 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-row>
<a-row :gutter="16">
<a-col :xs="24" :md="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>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item :label="`${$t('trading-assistant.form.leverage')} (x)`">
<a-input-number
v-decorator="['leverage', { initialValue: 1 }]"
:min="1"
:max="form.getFieldValue('market_type') === 'spot' ? 1 : 125"
:step="1"
style="width: 100%"
:disabled="form.getFieldValue('market_type') === 'spot'" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<!-- Indicator strategy: choose the technical indicator -->
<div v-else-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
optionLabelProp="label"
: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)"
:label="getIndicatorOptionLabel(indicator)">
<div class="indicator-option">
<div class="indicator-option-main">
<span class="indicator-name">{{ indicator.name }}</span>
<span v-if="indicator.description" class="indicator-option-desc">{{ indicator.description }}</span>
</div>
<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-alert
v-if="!loadingIndicators && availableIndicators.length === 0"
style="margin-top: 12px;"
type="info"
show-icon
:message="tt('trading-assistant.indicatorEmpty.title', 'No indicator available yet')"
:description="tt('trading-assistant.indicatorEmpty.desc', 'Create or purchase an indicator first, then come back to build a strategy from it.')">
<template slot="action">
<a-button type="primary" size="small" @click="goToIndicatorAnalysisCreate">
<a-icon type="rocket" />
{{ tt('trading-assistant.indicatorEmpty.cta', 'Open Indicator Analysis') }}
</a-button>
</template>
</a-alert>
</a-form-item>
<a-form-item v-if="selectedIndicator" :label="$t('trading-assistant.form.indicatorDescription')">
<div class="selected-indicator-card">
<div class="selected-indicator-header">
<span class="selected-indicator-name">{{ selectedIndicator.name }}</span>
<a-tag v-if="selectedIndicator.type" size="small" :color="getIndicatorTypeColor(selectedIndicator.type)">
{{ getIndicatorTypeName(selectedIndicator.type) }}
</a-tag>
</div>
<div class="indicator-description">
{{ selectedIndicator.description || $t('trading-assistant.form.noDescription') }}
</div>
</div>
</a-form-item>
<!-- Indicator parameter configuration -->
<a-form-item v-if="indicatorParams.length > 0" :label="$t('trading-assistant.form.indicatorParams')">
<div class="indicator-params-form">
<a-row :gutter="16">
<a-col v-for="param in indicatorParams" :key="param.name" :xs="24" :sm="12" :md="8">
<div class="param-item">
<label class="param-label">
{{ param.name }}
<a-tooltip v-if="param.description" :title="param.description">
<a-icon type="question-circle" style="margin-left: 4px; color: #999;" />
</a-tooltip>
</label>
<!-- Integer type -->
<a-input-number
v-if="param.type === 'int'"
v-model="indicatorParamValues[param.name]"
:precision="0"
style="width: 100%;"
size="small" />
<!-- Float type -->
<a-input-number
v-else-if="param.type === 'float'"
v-model="indicatorParamValues[param.name]"
:precision="4"
style="width: 100%;"
size="small" />
<!-- Boolean type -->
<a-switch
v-else-if="param.type === 'bool'"
v-model="indicatorParamValues[param.name]"
size="small" />
<!-- String type -->
<a-input
v-else
v-model="indicatorParamValues[param.name]"
size="small" />
</div>
</a-col>
</a-row>
<div class="form-item-hint" style="margin-top: 8px;">
{{ $t('trading-assistant.form.indicatorParamsHint') }}
</div>
</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>
<!-- ===== Simple mode: show defaults summary + toggle ===== -->
<div v-if="isSimpleMode && !editingStrategy" class="simple-defaults-summary">
<a-alert type="info" show-icon style="margin-bottom: 12px;">
<template #message>
<span>{{ $t('trading-assistant.form.simpleDefaultsHint') }}</span>
</template>
<template #description>
<span>
{{ $t('trading-assistant.form.klinePeriod') }}: <b>15m</b> ·
{{ $t('trading-assistant.form.leverage') }}: <b>5x</b> ·
{{ $t('trading-assistant.form.marketType') }}: <b>{{ $t('trading-assistant.form.marketTypeFutures') }}</b> ·
{{ $t('dashboard.indicator.backtest.field.stopLossPct') }}: <b>3%</b> ·
{{ $t('dashboard.indicator.backtest.field.takeProfitPct') }}: <b>6%</b>
</span>
</template>
</a-alert>
<a-button type="link" size="small" @click="showAdvancedSettings = !showAdvancedSettings" style="padding: 0; margin-bottom: 12px;">
<a-icon :type="showAdvancedSettings ? 'up' : 'down'" />
{{ showAdvancedSettings ? $t('trading-assistant.form.hideAdvancedSettings') : $t('trading-assistant.form.showAdvancedSettings') }}
</a-button>
</div>
<!-- ===== Strategy type: only show in advanced mode ===== -->
<div v-show="isAdvancedMode || editingStrategy || showAdvancedSettings">
<!-- Strategy type selection -->
<a-form-item :label="$t('trading-assistant.form.strategyType')">
<a-radio-group
v-decorator="['cs_strategy_type', { initialValue: 'single' }]"
@change="handleStrategyTypeChange">
<a-radio value="single">{{ $t('trading-assistant.form.strategyTypeSingle') }}</a-radio>
<a-radio value="cross_sectional">{{ $t('trading-assistant.form.strategyTypeCrossSectional') }}</a-radio>
</a-radio-group>
<div class="form-item-hint">
{{ $t('trading-assistant.form.strategyTypeHint') }}
</div>
</a-form-item>
</div>
<!-- Cross-sectional strategy configuration -->
<template v-if="form.getFieldValue('cs_strategy_type') === 'cross_sectional'">
<a-form-item :label="$t('trading-assistant.form.symbolList')">
<a-select
v-model="crossSectionalSymbols"
mode="multiple"
:placeholder="$t('trading-assistant.placeholders.selectSymbols')"
show-search
:filter-option="filterWatchlistOptionWithAdd"
:loading="loadingWatchlist"
@change="handleCrossSectionalSymbolChange"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
:maxTagCount="5">
<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-option key="__add_symbol_option__" value="__add_symbol_option__" class="add-symbol-option">
<div style="width: 100%; text-align: center; padding: 4px 0; color: #1890ff; cursor: pointer;">
<a-icon type="plus" style="margin-right: 4px;" />
<span>{{ $t('trading-assistant.form.addSymbol') }}</span>
</div>
</a-select-option>
</a-select>
<div class="form-item-hint">
{{ $t('trading-assistant.form.symbolListHint') }}
</div>
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :sm="12">
<a-form-item :label="$t('trading-assistant.form.portfolioSize')">
<a-input-number
v-decorator="['portfolio_size', { initialValue: 10, rules: [{ required: true, message: $t('trading-assistant.validation.portfolioSizeRequired') }] }]"
:min="1"
:max="100"
:step="1"
style="width: 100%" />
<div class="form-item-hint">
{{ $t('trading-assistant.form.portfolioSizeHint') }}
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item :label="$t('trading-assistant.form.longRatio')">
<a-input-number
v-decorator="['long_ratio', { initialValue: 0.5, rules: [{ required: true, message: $t('trading-assistant.validation.longRatioRequired') }] }]"
:min="0"
:max="1"
:step="0.1"
:precision="2"
style="width: 100%" />
<div class="form-item-hint">
{{ $t('trading-assistant.form.longRatioHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="$t('trading-assistant.form.rebalanceFrequency')">
<a-select
v-decorator="['rebalance_frequency', { initialValue: 'daily' }]"
style="width: 100%">
<a-select-option value="daily">{{ $t('trading-assistant.form.rebalanceDaily') }}</a-select-option>
<a-select-option value="weekly">{{ $t('trading-assistant.form.rebalanceWeekly') }}</a-select-option>
<a-select-option value="monthly">{{ $t('trading-assistant.form.rebalanceMonthly') }}</a-select-option>
</a-select>
<div class="form-item-hint">
{{ $t('trading-assistant.form.rebalanceFrequencyHint') }}
</div>
</a-form-item>
</template>
<!-- Single-symbol strategy: legacy symbol selector -->
<a-form-item
v-if="form.getFieldValue('cs_strategy_type') !== 'cross_sectional'"
:label="isEditMode ? $t('trading-assistant.form.symbol') : $t('trading-assistant.form.symbols')">
<!-- Edit mode: single select -->
<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>
<!-- Add-symbol option -->
<a-select-option key="__add_symbol_option__" value="__add_symbol_option__" class="add-symbol-option">
<div style="width: 100%; text-align: center; padding: 4px 0; color: #1890ff; cursor: pointer;">
<a-icon type="plus" style="margin-right: 4px;" />
<span>{{ $t('trading-assistant.form.addSymbol') }}</span>
</div>
</a-select-option>
</a-select>
<!-- Create mode: multi-select -->
<a-select
v-else
v-model="selectedSymbols"
mode="multiple"
:placeholder="$t('trading-assistant.placeholders.selectSymbols')"
show-search
:filter-option="filterWatchlistOptionWithAdd"
:loading="loadingWatchlist"
@change="handleMultiSymbolChangeWithAdd"
: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>
<!-- Add-symbol option -->
<a-select-option key="__add_symbol_option__" value="__add_symbol_option__" class="add-symbol-option">
<div style="width: 100%; text-align: center; padding: 4px 0; color: #1890ff; cursor: pointer;">
<a-icon type="plus" style="margin-right: 4px;" />
<span>{{ $t('trading-assistant.form.addSymbol') }}</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>
<!-- ===== Advanced trading params (capital/leverage/direction/timeframe etc.) ===== -->
<div v-show="isAdvancedMode || editingStrategy || showAdvancedSettings">
<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: 5, 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: '15m', 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>
</div><!-- / v-show advanced trading params wrapper -->
</a-form>
</div>
<!-- Local mode: indicator strategy only (no AI strategy) -->
</div>
<!-- Step 2: params (backtest-like / trading params) -->
<div v-show="currentStep === 1 || (isSimpleMode && currentStep === 0 && showAdvancedSettings)" class="step-content">
<script-strategy-editor
v-if="strategyMode === 'script' && currentStep === 1"
v-model="strategyCode"
:active="currentStep === 1"
:is-dark="isDarkTheme"
:initial-template-key="scriptInitialTemplateKey"
:template-apply-nonce="scriptTemplateApplyNonce" />
<!-- Indicator strategy: strategy parameters -->
<div v-else-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: 3 }]"
: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: 6 }]"
: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>
<!-- Live trading disclaimer: must accept before configuring live trading -->
<a-form-item v-if="executionModeUi === 'live' && canUseLiveTrading" class="live-disclaimer-item">
<a-alert
type="warning"
showIcon
style="margin-bottom: 8px"
:message="$t('trading-assistant.liveDisclaimer.title')"
:description="$t('trading-assistant.liveDisclaimer.content')" />
<a-checkbox
v-decorator="['live_disclaimer_ack', { valuePropName: 'checked', initialValue: false }]"
@change="onLiveDisclaimerAckChange">
{{ $t('trading-assistant.liveDisclaimer.agree') }}
</a-checkbox>
</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="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 value="phone">{{ $t('trading-assistant.notify.phone') }}</a-checkbox>
</a-checkbox-group>
<div class="form-item-hint">{{ $t('trading-assistant.form.notifyChannelsHint') }}</div>
</a-form-item>
<!-- Notification settings hint -->
<a-alert
v-if="unconfiguredChannels.length > 0"
type="warning"
showIcon
style="margin-bottom: 16px">
<template #message>
<span>
{{ $t('trading-assistant.form.notificationConfigMissing', { channels: unconfiguredChannels.join(', ') }) }}
<router-link to="/profile" style="margin-left: 8px">
<a-icon type="setting" /> {{ $t('trading-assistant.form.goToProfile') }}
</router-link>
</span>
</template>
</a-alert>
<a-alert
v-else-if="notifyChannelsUi.length > 0 && !notifyChannelsUi.includes('browser') || (notifyChannelsUi.length > 1)"
type="info"
showIcon
style="margin-bottom: 16px">
<template #message>
<span>
{{ $t('trading-assistant.form.notificationFromProfile') }}
<router-link to="/profile" style="margin-left: 8px">
<a-icon type="setting" /> {{ $t('trading-assistant.form.goToProfile') }}
</router-link>
</span>
</template>
</a-alert>
<a-divider v-if="executionModeUi === 'live' && canUseLiveTrading" />
<!-- Live trading: exchange credentials -->
<a-alert
v-if="executionModeUi === 'live' && canUseLiveTrading && !liveDisclaimerAckUi"
type="warning"
showIcon
style="margin-bottom: 12px"
:message="$t('trading-assistant.liveDisclaimer.blockTitle')"
:description="$t('trading-assistant.liveDisclaimer.blockDesc')" />
<div v-if="executionModeUi === 'live' && canUseLiveTrading && liveDisclaimerAckUi">
<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 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="warning"
show-icon
style="margin-bottom: 16px;"
:message="$t('trading-assistant.form.localDeploymentRequired')"
:description="$t('trading-assistant.form.localDeploymentHint')" />
<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="warning"
show-icon
style="margin-bottom: 16px;"
:message="$t('trading-assistant.form.localDeploymentRequired')"
:description="$t('trading-assistant.form.localDeploymentHint')" />
<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
v-if="showDemoTradingSwitch"
:label="`${getExchangeDisplayName(currentExchangeId)} Demo Trading`"
key="demo-trading-switch">
<a-switch v-decorator="['enable_demo_trading', { valuePropName: 'checked', initialValue: false }]">
<a-icon slot="checkedChildren" type="check" />
<a-icon slot="unCheckedChildren" type="close" />
</a-switch>
</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>
<!-- Add-symbol modal -->
<a-modal
:title="$t('trading-assistant.form.addSymbolTitle')"
:visible="showAddSymbolModal"
@ok="handleConfirmAddSymbol"
@cancel="handleCloseAddSymbolModal"
:confirmLoading="addingSymbol"
width="600px"
:okText="$t('trading-assistant.form.confirmAdd')"
:cancelText="$t('trading-assistant.form.cancel')"
:maskClosable="false"
:keyboard="false">
<div class="add-symbol-modal-content">
<!-- Market tabs -->
<a-tabs v-model="addSymbolMarket" @change="handleAddSymbolMarketChange" class="market-tabs">
<a-tab-pane
v-for="marketType in addSymbolMarketTypes"
:key="marketType.value"
:tab="$t(marketType.i18nKey || `dashboard.analysis.market.${marketType.value}`)">
</a-tab-pane>
</a-tabs>
<!-- Search input -->
<div class="symbol-search-section">
<a-input-search
v-model="addSymbolKeyword"
:placeholder="$t('dashboard.analysis.modal.addStock.searchOrInputPlaceholder')"
@search="handleSearchSymbol"
@change="handleSymbolSearchInputChange"
:loading="searchingSymbol"
size="large"
allow-clear>
<a-button slot="enterButton" type="primary" icon="search">
{{ $t('dashboard.analysis.modal.addStock.search') }}
</a-button>
</a-input-search>
</div>
<!-- Search results -->
<div v-if="symbolSearchResults.length > 0" class="search-results-section">
<div class="section-title">
<a-icon type="search" style="margin-right: 4px;" />
{{ $t('dashboard.analysis.modal.addStock.searchResults') }}
</div>
<a-list
:data-source="symbolSearchResults"
:loading="searchingSymbol"
size="small"
class="symbol-list">
<a-list-item slot="renderItem" slot-scope="item" class="symbol-list-item" @click="handleSelectAddSymbol(item)">
<a-list-item-meta>
<template slot="title">
<div class="symbol-item-content">
<span class="symbol-code">{{ item.symbol }}</span>
<span class="symbol-name">{{ item.name }}</span>
<a-tag v-if="item.exchange" size="small" color="blue" style="margin-left: 8px;">
{{ item.exchange }}
</a-tag>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</div>
<!-- Hot symbols -->
<div class="hot-symbols-section">
<div class="section-title">
<a-icon type="fire" style="color: #ff4d4f; margin-right: 4px;" />
{{ $t('dashboard.analysis.modal.addStock.hotSymbols') }}
</div>
<a-spin :spinning="loadingHotSymbols">
<a-list
v-if="hotSymbols.length > 0"
:data-source="hotSymbols"
size="small"
class="symbol-list">
<a-list-item slot="renderItem" slot-scope="item" class="symbol-list-item" @click="handleSelectAddSymbol(item)">
<a-list-item-meta>
<template slot="title">
<div class="symbol-item-content">
<span class="symbol-code">{{ item.symbol }}</span>
<span class="symbol-name">{{ item.name }}</span>
<a-tag v-if="item.exchange" size="small" color="orange" style="margin-left: 8px;">
{{ item.exchange }}
</a-tag>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
<a-empty v-else :description="$t('dashboard.analysis.modal.addStock.noHotSymbols')" :image="false" />
</a-spin>
</div>
<!-- Selected symbol preview -->
<div v-if="selectedAddSymbol" class="selected-symbol-section">
<div class="section-title">
<a-icon type="check-circle" style="color: #52c41a; margin-right: 4px;" />
{{ $t('dashboard.analysis.modal.addStock.selectedSymbol') }}
</div>
<div class="selected-symbol-info">
<a-tag :color="getMarketColor(addSymbolMarket)" style="margin-right: 8px;">{{ addSymbolMarket }}</a-tag>
<span class="symbol-code">{{ selectedAddSymbol.symbol }}</span>
<span v-if="selectedAddSymbol.name" class="symbol-name">{{ selectedAddSymbol.name }}</span>
</div>
</div>
</div>
</a-modal>
</div>
</template>
<script>
import { getStrategyList, startStrategy, stopStrategy, deleteStrategy, updateStrategy, createStrategy, testExchangeConnection, getStrategyEquityCurve, getStrategyPositions, batchCreateStrategies, batchStartStrategies, batchStopStrategies, batchDeleteStrategies } from '@/api/strategy'
import { getWatchlist, addWatchlist, searchSymbols, getHotSymbols } from '@/api/market'
import { listExchangeCredentials, getExchangeCredential, createExchangeCredential } from '@/api/credentials'
import { getNotificationSettings } from '@/api/user'
import { baseMixin } from '@/store/app-mixin'
import request from '@/utils/request'
import DashboardOverview from '@/views/dashboard'
import TradingRecords from './components/TradingRecords.vue'
import PositionRecords from './components/PositionRecords.vue'
import PerformanceAnalysis from './components/PerformanceAnalysis.vue'
import StrategyLogs from './components/StrategyLogs.vue'
import StrategyTypeSelector from './components/StrategyTypeSelector.vue'
import ScriptStrategyEditor from './components/ScriptStrategyEditor.vue'
// Common crypto trading pairs
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' },
{ value: 'deepcoin', labelKey: 'deepcoin' }
]
// Traditional broker options (US 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: {
DashboardOverview,
TradingRecords,
PositionRecords,
PerformanceAnalysis,
StrategyLogs,
StrategyTypeSelector,
ScriptStrategyEditor
},
computed: {
isAdvancedMode () {
return this.creationMode === 'advanced'
},
isSimpleMode () {
return this.creationMode === 'simple'
},
displayCurrentStep () {
if (this.strategyMode === 'script') {
return this.currentStep
}
if (this.isSimpleMode && !this.editingStrategy) {
return this.currentStep === 0 ? 0 : 1
}
return this.currentStep
},
strategyFormWrapClass () {
return this.isMobile ? 'mobile-modal strategy-form-modal' : 'strategy-form-modal'
},
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
needsPassphrase () {
// Exchanges that require passphrase
return ['okx', 'okex', 'coinbaseexchange', 'kucoin', 'bitget', 'deepcoin'].includes(this.currentExchangeId)
},
// Check if current market uses IBKR (US Stock)
isIBKRMarket () {
return this.selectedMarketCategory === 'USStock'
},
// 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
},
// Precompute exchange options with display names to avoid repeated translation work.
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) {
// Ignore translation lookup failures and fall back to the raw id.
}
if (!label) {
label = exchange.value.charAt(0).toUpperCase() + exchange.value.slice(1)
}
return {
...exchange,
displayName: label
}
})
},
totalPnl () {
const initialCapital = this.selectedStrategy?.initial_capital || this.selectedStrategy?.trading_config?.initial_capital
if (this.currentEquity === null || !this.selectedStrategy || !initialCapital) {
return null
}
return this.currentEquity - initialCapital
},
totalPnlPercent () {
const initialCapital = this.selectedStrategy?.initial_capital || this.selectedStrategy?.trading_config?.initial_capital
if (this.totalPnl === null || !this.selectedStrategy || !initialCapital) {
return null
}
if (initialCapital === 0) return 0
return (this.totalPnl / initialCapital) * 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 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 can use IBKR for live trading
if (cat === 'USStock') {
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 uses IBKR
if (cat === 'USStock') {
return this.currentBrokerId === 'ibkr'
}
// Forex uses MT5
if (cat === 'Forex') {
return this.currentBrokerId === 'mt5'
}
return false
},
// Whether to show the demo trading switch.
showDemoTradingSwitch () {
// Demo trading is currently available only for Binance.
return this.currentExchangeId && this.currentExchangeId.toLowerCase() === 'binance'
},
// Broker options for US 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
}
})
},
// Strategy grouping display
groupedStrategies () {
if (this.groupByMode === 'symbol') {
return this.groupedBySymbol
}
return this.groupedByStrategy
},
// Group by strategy (existing grouping logic).
groupedByStrategy () {
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: [],
// Aggregate counters
runningCount: 0,
stoppedCount: 0
}
}
groups[groupId].strategies.push(s)
if (s.status === 'running') {
groups[groupId].runningCount++
} else {
groups[groupId].stoppedCount++
}
} else {
ungrouped.push(s)
}
}
// Convert to an array and sort by creation time.
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 }
},
// Group by symbol
groupedBySymbol () {
const groups = {}
const ungrouped = []
for (const s of this.strategies) {
const tc = s.trading_config || {}
const symbol = tc.symbol
if (symbol && symbol.trim()) {
if (!groups[symbol]) {
groups[symbol] = {
id: `symbol_${symbol}`,
baseName: symbol,
strategies: [],
runningCount: 0,
stoppedCount: 0
}
}
// Attach detail metadata used by the grouped list UI.
const strategyInfo = {
...s,
displayInfo: {
strategyName: s.strategy_name || s.group_base_name || 'Unnamed',
timeframe: tc.timeframe || '-',
indicatorName: s.indicator_name || (s.indicator_config && s.indicator_config.name) || '-'
}
}
groups[symbol].strategies.push(strategyInfo)
if (s.status === 'running') {
groups[symbol].runningCount++
} else {
groups[symbol].stoppedCount++
}
} else {
ungrouped.push(s)
}
}
// Convert to an array and sort by symbol name.
const groupList = Object.values(groups).sort((a, b) => {
return a.baseName.localeCompare(b.baseName)
})
return { groups: groupList, ungrouped }
},
// Check if selected channels are configured in user profile
unconfiguredChannels () {
const missing = []
if (this.notifyChannelsUi.includes('telegram')) {
// Check if telegram token or chat id is missing
if (!this.userNotificationSettings.telegram_bot_token && !this.userNotificationSettings.telegram_chat_id) {
missing.push('Telegram')
}
}
if (this.notifyChannelsUi.includes('email')) {
if (!this.userNotificationSettings.email) {
missing.push('Email')
}
}
if (this.notifyChannelsUi.includes('discord')) {
if (!this.userNotificationSettings.discord_webhook) {
missing.push('Discord')
}
}
if (this.notifyChannelsUi.includes('webhook')) {
if (!this.userNotificationSettings.webhook_url) {
missing.push('Webhook')
}
}
// Phone/SMS check if needed
// if (this.notifyChannelsUi.includes('phone') && !this.userNotificationSettings.phone) { ... }
return missing
}
},
data () {
return {
topTab: 'overview',
loading: false,
loadingRecords: false,
strategies: [],
selectedStrategy: null,
showModeSelector: false,
showFormModal: false,
creationMode: 'simple', // 'simple' or 'advanced'
showAdvancedSettings: false,
strategyType: 'indicator',
strategyMode: 'indicator',
selectedMarketCategory: 'Crypto', // USStock / Crypto / Forex / Futures
currentStep: 0,
saving: false,
strategyCode: '',
scriptInitialTemplateKey: 'ma_cross',
scriptTemplateApplyNonce: 0,
loadingIndicators: false,
availableIndicators: [],
selectedIndicator: null,
indicatorParams: [], // Indicator parameter declaration.
indicatorParamValues: {}, // User-configured indicator values.
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, // Whether indicators have already been loaded.
editingStrategy: null, // Strategy currently being edited.
currentEquity: null, // Current equity value.
equityPollingTimer: null, // Equity polling timer.
// Backtest-like UI state (same as indicator-analysis BacktestModal step1)
backtestCollapseKeys: ['risk'],
trailingEnabledUi: false,
entryPctMaxUi: 100,
aiFilterEnabledUi: false,
isEditMode: false, // Whether the form is in edit mode.
supportedIPs: [], // Whitelisted IPs for exchange APIs.
executionModeUi: 'signal',
liveDisclaimerAckUi: false,
notifyChannelsUi: ['browser'],
// User's notification settings from profile
userNotificationSettings: {
default_channels: ['browser'],
telegram_bot_token: '',
telegram_chat_id: '',
email: '',
phone: '',
discord_webhook: '',
webhook_url: '',
webhook_token: ''
},
// Exchange credentials vault
loadingExchangeCredentials: false,
exchangeCredentials: [],
saveCredentialUi: false,
suppressApiClearOnce: false,
// Multi-symbol selection in create mode.
selectedSymbols: [],
// Symbol list for cross-sectional strategy mode.
crossSectionalSymbols: [],
// Collapsed state per strategy group.
collapsedGroups: {},
// Grouping mode: 'strategy' or 'symbol'.
groupByMode: 'strategy',
// Add-symbol modal state.
showAddSymbolModal: false,
addSymbolMarket: 'Crypto',
addSymbolMarketTypes: [
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' }
],
addSymbolKeyword: '',
searchingSymbol: false,
symbolSearchResults: [],
selectedAddSymbol: null,
hasSearchedSymbol: false,
addingSymbol: false,
hotSymbols: [],
loadingHotSymbols: false,
searchTimer: null,
lastAutoStrategyName: ''
// Market category is inferred from Step 1 watchlist symbol ("Market:SYMBOL").
}
},
beforeCreate () {
this.form = this.$form.createForm(this)
},
mounted () {
if (
this.$route &&
(
(this.$route.query && this.$route.query.tab === 'strategy') ||
(this.$route.query && this.$route.query.open === 'from-indicator') ||
(this.$route.query && this.$route.query.mode === 'create')
)
) {
this.topTab = 'strategy'
}
this.loadStrategies()
this.loadUserNotificationSettings()
this.handleRouteCreateStrategyPrefill()
},
beforeDestroy () {
this.stopEquityPolling()
},
methods: {
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
},
goToStrategyTab () {
this.topTab = 'strategy'
},
openCreateStrategyFromGuide () {
this.topTab = 'strategy'
this.$nextTick(() => {
this.handleCreateStrategy()
})
},
openStrategyForm (mode = 'indicator', options = {}) {
this.isEditMode = false
this.editingStrategy = null
this.strategyType = 'indicator'
this.strategyMode = mode
this.creationMode = mode === 'script' ? 'advanced' : 'simple'
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.crossSectionalSymbols = []
this.showAdvancedSettings = false
this.showModeSelector = false
this.strategyCode = ''
this.scriptInitialTemplateKey = options.templateKey || 'ma_cross'
this.scriptTemplateApplyNonce = options.autoApplyTemplate ? Date.now() : 0
const defaultName = mode === 'script'
? this.tt('trading-assistant.editor.defaultStrategyName', 'Script Strategy')
: this.buildStrategyDefaultName()
this.lastAutoStrategyName = defaultName
this.form.resetFields()
this.form.setFieldsValue({
strategy_name: defaultName,
execution_mode: 'signal',
notify_channels: ['browser'],
save_credential: false,
live_disclaimer_ack: false,
initial_capital: 1000,
market_type: 'swap',
leverage: mode === 'script' ? 1 : 5,
trade_direction: 'long',
timeframe: '15m',
cs_strategy_type: 'single'
})
this.liveDisclaimerAckUi = false
this.showFormModal = true
this.$nextTick(() => {
this.loadWatchlist()
this.loadExchangeCredentials()
if (mode !== 'script') {
this.loadIndicators()
}
if (options.symbol) {
this.form.setFieldsValue({ symbol: options.symbol })
this.handleWatchlistSymbolChange(options.symbol)
}
})
},
handleModeSelect (mode) {
this.openStrategyForm(mode || 'indicator')
},
handleUseTemplate (payload = {}) {
this.openStrategyForm(payload.mode || 'script', {
templateKey: payload.templateKey || 'ma_cross',
autoApplyTemplate: true
})
},
goToIndicatorAnalysisCreate () {
this.$router.push('/indicator-analysis')
},
async loadUserNotificationSettings () {
// Load user's default notification settings from profile
try {
const res = await getNotificationSettings()
if (res.code === 1 && res.data) {
this.userNotificationSettings = {
default_channels: res.data.default_channels || ['browser'],
telegram_bot_token: res.data.telegram_bot_token || '',
telegram_chat_id: res.data.telegram_chat_id || '',
email: res.data.email || '',
phone: res.data.phone || '',
discord_webhook: res.data.discord_webhook || '',
webhook_url: res.data.webhook_url || '',
webhook_token: res.data.webhook_token || ''
}
}
} catch (e) {
// Silently fail, use default values
}
},
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
}
},
// ====== Add-symbol modal helpers ======
handleCloseAddSymbolModal () {
this.showAddSymbolModal = false
this.addSymbolKeyword = ''
this.symbolSearchResults = []
this.selectedAddSymbol = null
this.hasSearchedSymbol = false
},
handleAddSymbolMarketChange (market) {
this.addSymbolMarket = market
this.addSymbolKeyword = ''
this.symbolSearchResults = []
this.selectedAddSymbol = null
this.hasSearchedSymbol = false
// Load hot symbols for the selected market.
this.loadHotSymbols(market)
},
// Debounce symbol search input changes.
handleSymbolSearchInputChange (e) {
const keyword = e.target.value
this.addSymbolKeyword = keyword
// Clear the previous debounce timer.
if (this.searchTimer) {
clearTimeout(this.searchTimer)
}
// Reset search state when the input is empty.
if (!keyword || keyword.trim() === '') {
this.symbolSearchResults = []
this.hasSearchedSymbol = false
this.selectedAddSymbol = null
return
}
// Run the search 500ms after typing stops.
this.searchTimer = setTimeout(() => {
this.searchSymbolsInModal(keyword)
}, 500)
},
// Search first, or add directly when nothing matches.
async handleSearchSymbol (keyword) {
if (!keyword || !keyword.trim()) {
return
}
if (!this.addSymbolMarket) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
// If there are search results already, wait for explicit user selection.
if (this.symbolSearchResults.length > 0) {
return
}
// If no result was found, offer direct add.
if (this.hasSearchedSymbol && this.symbolSearchResults.length === 0) {
this.handleDirectAdd()
} else {
// Trigger the search.
this.searchSymbolsInModal(keyword)
}
},
// Search symbols inside the add-symbol modal.
async searchSymbolsInModal (keyword) {
if (!keyword || keyword.trim() === '') {
this.symbolSearchResults = []
this.hasSearchedSymbol = false
return
}
if (!this.addSymbolMarket) {
return
}
this.searchingSymbol = true
this.hasSearchedSymbol = true
try {
const res = await searchSymbols({
market: this.addSymbolMarket,
keyword: keyword.trim()
})
if (res && res.code === 1 && Array.isArray(res.data)) {
this.symbolSearchResults = res.data
} else {
this.symbolSearchResults = []
}
} catch (e) {
this.symbolSearchResults = []
} finally {
this.searchingSymbol = false
}
},
// Add the typed symbol directly when search returns no results.
handleDirectAdd () {
if (!this.addSymbolKeyword || !this.addSymbolKeyword.trim()) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseEnterSymbol'))
return
}
if (!this.addSymbolMarket) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
// Keep the manual symbol selection; the backend can resolve its display name later.
this.selectedAddSymbol = {
market: this.addSymbolMarket,
symbol: this.addSymbolKeyword.trim().toUpperCase(),
name: '' // The backend resolves the display name if available.
}
},
handleSelectAddSymbol (item) {
this.selectedAddSymbol = {
market: this.addSymbolMarket,
symbol: item.symbol,
name: item.name || ''
}
},
// Load the market's hot symbols.
async loadHotSymbols (market) {
if (!market) {
market = this.addSymbolMarket || 'Crypto'
}
if (!market) {
return
}
this.loadingHotSymbols = true
try {
const res = await getHotSymbols({
market: market,
limit: 10
})
if (res && res.code === 1 && res.data) {
this.hotSymbols = res.data
} else {
this.hotSymbols = []
}
} catch (error) {
this.hotSymbols = []
} finally {
this.loadingHotSymbols = false
}
},
async handleConfirmAddSymbol () {
// Resolve the target symbol to add.
let market = ''
let symbol = ''
// Accept either a selected symbol or the manual input value.
if (this.selectedAddSymbol) {
market = this.selectedAddSymbol.market
symbol = this.selectedAddSymbol.symbol.toUpperCase()
} else if (this.addSymbolKeyword && this.addSymbolKeyword.trim()) {
// Fall back to the typed value when nothing was selected.
if (!this.addSymbolMarket) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
market = this.addSymbolMarket
symbol = this.addSymbolKeyword.trim().toUpperCase()
} else {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol'))
return
}
this.addingSymbol = true
try {
// Call the watchlist API.
const res = await addWatchlist({
userid: 1,
market: market,
symbol: symbol
})
if (res && res.code === 1) {
this.$message.success(this.$t('dashboard.analysis.message.addStockSuccess'))
// Reload the watchlist.
await this.loadWatchlist()
// Select the newly added symbol automatically.
const newValue = `${market}:${symbol}`
if (this.isEditMode) {
this.form.setFieldsValue({ symbol: newValue })
this.handleWatchlistSymbolChange(newValue)
} else {
// In multi-select mode, append it to the current selection.
if (!this.selectedSymbols.includes(newValue)) {
this.selectedSymbols = [...this.selectedSymbols, newValue]
}
this.handleMultiSymbolChange(this.selectedSymbols)
}
// Close the modal.
this.handleCloseAddSymbolModal()
} else {
this.$message.error(res?.msg || this.$t('dashboard.analysis.message.addStockFailed'))
}
} catch (e) {
const errorMsg = e?.response?.data?.msg || e?.message || this.$t('dashboard.analysis.message.addStockFailed')
this.$message.error(errorMsg)
} finally {
this.addingSymbol = false
}
},
// ====== End add-symbol modal helpers ======
filterWatchlistOption (input, option) {
const value = option.componentOptions?.propsData?.value || ''
// Always keep the special "add symbol" option visible.
if (value === '__add_symbol_option__') return true
return String(value).toLowerCase().includes(String(input || '').toLowerCase())
},
filterWatchlistOptionWithAdd (input, option) {
const value = option.componentOptions?.propsData?.value || ''
// Always keep the special "add symbol" option visible.
if (value === '__add_symbol_option__') return true
return String(value).toLowerCase().includes(String(input || '').toLowerCase())
},
handleMultiSymbolChangeWithAdd (vals) {
// Check whether the special "add symbol" option was clicked.
if (vals && vals.includes('__add_symbol_option__')) {
// Remove the helper option from the selected values.
this.selectedSymbols = vals.filter(v => v !== '__add_symbol_option__')
// Open the add-symbol modal.
this.showAddSymbolModal = true
// Load hot symbols for the current market.
this.loadHotSymbols(this.addSymbolMarket)
return
}
this.handleMultiSymbolChange(vals)
},
handleStrategyTypeChange (e) {
const strategyType = e.target.value
// Clear the basket selection when switching back to a single-symbol strategy.
if (strategyType === 'single') {
this.crossSectionalSymbols = []
}
},
handleCrossSectionalSymbolChange (vals) {
// Check whether the special "add symbol" option was clicked.
if (vals && vals.includes('__add_symbol_option__')) {
// Remove the helper option from the selected values.
this.crossSectionalSymbols = vals.filter(v => v !== '__add_symbol_option__')
// Open the add-symbol modal.
this.showAddSymbolModal = true
// Load hot symbols for the current market.
this.loadHotSymbols(this.addSymbolMarket)
return
}
this.crossSectionalSymbols = vals || []
// Keep the market category aligned with the first selected symbol.
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'
}
}
},
getMarketColor (market) {
const colors = {
USStock: 'green',
Crypto: 'purple',
Forex: 'gold',
Futures: 'cyan'
}
return colors[market] || 'default'
},
handleWatchlistSymbolChange (val) {
// Check whether the special "add symbol" option was clicked.
if (val === '__add_symbol_option__') {
// Reset the field value without keeping the helper option selected.
this.$nextTick(() => {
this.form.setFieldsValue({ symbol: undefined })
})
// Open the add-symbol modal.
this.showAddSymbolModal = true
// Load hot symbols for the current market.
this.loadHotSymbols(this.addSymbolMarket)
return
}
// 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 (this.selectedMarketCategory === 'USStock') {
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, Forex support live trading; others do not
const supportsLiveTrading = ['Crypto', 'USStock', '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 (this.selectedMarketCategory === 'USStock') {
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', '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'
// Reset disclaimer ack when switching execution mode
this.liveDisclaimerAckUi = false
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ live_disclaimer_ack: false })
} catch (err) { }
// 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) { }
}
},
onLiveDisclaimerAckChange (e) {
const checked = !!(e && e.target && e.target.checked)
this.liveDisclaimerAckUi = checked
try {
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ live_disclaimer_ack: checked })
} 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) {
// Show all strategies returned by the backend.
const allStrategies = res.data.strategies || []
this.strategies = allStrategies
// Refresh the currently selected strategy reference if possible.
if (this.selectedStrategy) {
const updated = this.strategies.find(s => s.id === this.selectedStrategy.id)
if (updated) {
this.selectedStrategy = updated
} else {
// Clear the selection if the strategy no longer exists.
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 () {
this.strategyMode = 'indicator'
this.showModeSelector = true
},
async handleRouteCreateStrategyPrefill () {
const query = (this.$route && this.$route.query) || {}
const isIndicatorPrefill = query.open === 'from-indicator'
const isCreateMode = query.mode === 'create'
if (!isIndicatorPrefill && !isCreateMode) {
return
}
this.topTab = 'strategy'
this.openStrategyForm('indicator')
await this.$nextTick()
await Promise.all([
this.loadIndicators(),
this.loadWatchlist(),
this.loadExchangeCredentials()
])
const indicatorId = query.indicator_id ? String(query.indicator_id) : ''
const market = query.market || 'Crypto'
const symbol = query.symbol || ''
const timeframe = query.timeframe || ''
const fullSymbol = symbol ? (String(symbol).includes(':') ? String(symbol) : `${market}:${symbol}`) : ''
this.selectedMarketCategory = market
if (fullSymbol) {
this.handleMultiSymbolChange([fullSymbol])
}
const fieldValues = {}
if (timeframe) {
fieldValues.timeframe = timeframe
}
if (indicatorId) {
fieldValues.indicator_id = indicatorId
}
this.form.setFieldsValue(fieldValues)
if (indicatorId) {
await this.handleIndicatorChange(indicatorId)
const indicator = this.availableIndicators.find(ind => String(ind.id) === indicatorId)
if (indicator) {
const strategyName = fullSymbol ? `${indicator.name} ${symbol}` : indicator.name
this.form.setFieldsValue({
strategy_name: strategyName
})
this.lastAutoStrategyName = strategyName
}
}
this.$router.replace({ path: this.$route.path, query: {} }).catch(() => {})
},
handleEditStrategy (strategy) {
// Running strategies must be stopped before editing.
if (strategy.status === 'running') {
this.$message.warning(this.$t('trading-assistant.messages.runningWarning'))
return
}
const isScriptStrategy = strategy.strategy_mode === 'script' || strategy.strategy_type === 'ScriptStrategy'
this.strategyType = 'indicator'
this.strategyMode = isScriptStrategy ? 'script' : (strategy.strategy_mode || 'indicator')
this.creationMode = 'advanced'
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
this.showAdvancedSettings = false
this.executionModeUi = 'signal'
this.notifyChannelsUi = ['browser']
this.saveCredentialUi = false
this.liveDisclaimerAckUi = false
this.strategyCode = strategy.strategy_code || ''
this.scriptInitialTemplateKey = 'ma_cross'
this.scriptTemplateApplyNonce = 0
// 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()
if (!isScriptStrategy) {
this.loadIndicators()
}
this.loadExchangeCredentials()
await this.loadStrategyDataToForm(strategy)
})
},
async loadStrategyDataToForm (strategy) {
// Ensure the indicator catalog is available before filling the form.
if (this.strategyMode !== 'script' && !this.indicatorsLoaded) {
await this.loadIndicators()
}
// Wait for the modal form fields to mount before writing values.
await this.$nextTick()
// Market / execution / notification defaults (backward compatible)
this.selectedMarketCategory = strategy.market_category || 'Crypto'
const executionMode = strategy.execution_mode || 'signal'
this.executionModeUi = executionMode
// Editing an existing live strategy: default as acknowledged to avoid blocking edits
this.liveDisclaimerAckUi = executionMode === 'live'
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,
live_disclaimer_ack: this.liveDisclaimerAckUi,
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 (this.strategyMode === 'script') {
this.strategyCode = strategy.strategy_code || ''
}
// Restore indicator selection and saved parameter values.
if (this.strategyMode !== 'script' && strategy.indicator_config && strategy.indicator_config.indicator_id) {
// Match by stringified id to avoid number/string mismatch issues.
const targetId = strategy.indicator_config.indicator_id
const indicator = this.availableIndicators.find(ind => {
return String(ind.id) === String(targetId)
})
if (indicator) {
// Reuse the normalized id from the loaded indicator list.
const finalId = String(indicator.id)
this.form.setFieldsValue({
indicator_id: finalId
})
await this.handleIndicatorChange(finalId)
// Restore saved indicator parameter values while keeping reactivity.
const savedParams = strategy.trading_config?.indicator_params
if (savedParams && typeof savedParams === 'object') {
Object.keys(savedParams).forEach(key => {
if (key in this.indicatorParamValues) {
this.$set(this.indicatorParamValues, key, savedParams[key])
}
})
}
} else {
// Keep the raw id if the catalog entry cannot be resolved.
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', 'Forex'].includes(this.selectedMarketCategory)
const isBrokerMarket = this.selectedMarketCategory === 'USStock'
const isForexMarket = this.selectedMarketCategory === 'Forex'
if (isLive && supportsLiveTrading) {
if (isBrokerMarket) {
// Broker configuration (US 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
}
}
// Restore trading configuration.
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
// Restore cross-sectional settings if this is a basket strategy.
const strategyType = tc.strategy_type || strategy.strategy_type || 'single'
if (strategyType === 'cross_sectional') {
this.form.setFieldsValue({
cs_strategy_type: 'cross_sectional',
portfolio_size: tc.portfolio_size || 10,
long_ratio: tc.long_ratio || 0.5,
rebalance_frequency: tc.rebalance_frequency || 'daily'
})
// Restore the basket symbol list.
if (tc.symbol_list && Array.isArray(tc.symbol_list)) {
this.crossSectionalSymbols = tc.symbol_list
} else if (strategy.symbol_list) {
// Fall back to the top-level field if trading_config has no symbol list.
try {
const symbolList = typeof strategy.symbol_list === 'string' ? JSON.parse(strategy.symbol_list) : strategy.symbol_list
if (Array.isArray(symbolList)) {
this.crossSectionalSymbols = symbolList
}
} catch (e) {
this.crossSectionalSymbols = []
}
} else {
this.crossSectionalSymbols = []
}
} else {
this.form.setFieldsValue({
cs_strategy_type: 'single'
})
this.crossSectionalSymbols = []
}
// 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 decision filter
enable_ai_filter: aiFilterEnabled
})
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
}
},
handleSelectStrategy (strategy) {
this.selectedStrategy = strategy
this.currentEquity = null // Reset the live equity display.
this.loadStrategyDetails()
this.startEquityPolling() // Start polling the live equity display.
},
async loadStrategyDetails () {
if (!this.selectedStrategy) {
return Promise.resolve()
}
try {
const [equityRes, positionsRes] = await Promise.all([
getStrategyEquityCurve(this.selectedStrategy.id),
getStrategyPositions(this.selectedStrategy.id)
])
let baseEquity = null
if (equityRes.code === 1 && equityRes.data) {
if (Array.isArray(equityRes.data) && equityRes.data.length > 0) {
const last = equityRes.data[equityRes.data.length - 1]
baseEquity = last.equity
} else {
baseEquity = this.selectedStrategy.trading_config?.initial_capital || this.selectedStrategy.initial_capital || null
}
}
let unrealizedPnl = 0
if (positionsRes.code === 1 && positionsRes.data) {
const positions = Array.isArray(positionsRes.data)
? positionsRes.data
: (positionsRes.data.positions || positionsRes.data.items || [])
positions.forEach(position => {
unrealizedPnl += parseFloat(position.unrealized_pnl || position.unrealizedPnl || 0)
})
}
this.currentEquity = baseEquity !== null ? baseEquity + unrealizedPnl : null
} catch (error) {}
},
startEquityPolling () {
this.stopEquityPolling()
if (!this.selectedStrategy) return
// Load once immediately before starting the interval.
this.loadStrategyDetails()
// Dist polls more aggressively to keep the detail header responsive.
this.equityPollingTimer = setInterval(() => {
this.loadStrategyDetails()
}, 10000)
},
stopEquityPolling () {
if (this.equityPollingTimer) {
clearInterval(this.equityPollingTimer)
this.equityPollingTimer = null
}
},
handleCloseModal () {
this.showModeSelector = false
this.showFormModal = false
this.editingStrategy = null
this.isEditMode = false
this.strategyType = 'indicator'
this.strategyMode = '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.showAdvancedSettings = false
this.executionModeUi = 'signal'
this.liveDisclaimerAckUi = false
this.lastAutoStrategyName = ''
this.strategyCode = ''
this.scriptInitialTemplateKey = 'ma_cross'
this.scriptTemplateApplyNonce = 0
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 'backtest':
this.goToBacktestCenter(strategy)
break
case 'delete':
this.handleDeleteStrategy(strategy)
break
}
},
goToBacktestCenter (strategy) {
const target = strategy || this.selectedStrategy
if (!target || !target.id) return
this.$router.push({
path: '/backtest-center',
query: {
tab: 'strategy',
strategy_id: String(target.id)
}
})
},
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 }))
// Clear the detail panel if the deleted batch contained the selected strategy.
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()
// Update the selected strategy state immediately for a smoother UI.
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()
// Update the selected strategy state immediately for a smoother UI.
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() // Stop detail polling after deleting the selected strategy.
}
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') || 'Long',
short: this.$t('trading-assistant.form.tradeDirectionShort') || 'Short',
both: this.$t('trading-assistant.form.tradeDirectionBoth') || 'Both'
}
return directionMap[direction] || direction
},
// Indicator helpers
async handleIndicatorSelectFocus () {
// Lazy-load indicators only when the select is first focused.
if (!this.indicatorsLoaded && !this.loadingIndicators) {
await this.loadIndicators()
}
},
async loadIndicators () {
if (this.loadingIndicators) {
// Wait for the current indicator request to finish.
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this.loadingIndicators) {
clearInterval(checkInterval)
resolve()
}
}, 100)
})
}
if (this.indicatorsLoaded) {
return Promise.resolve()
}
// Resolve the current user id with a local-mode fallback.
const userInfo = this.$store.getters.userInfo || {}
const userId = userInfo.id || 1
this.loadingIndicators = true
try {
// Reuse the same endpoint as indicator-analysis.
const res = await request({
url: '/api/indicator/getIndicators',
method: 'get',
params: {
userid: userId
}
})
if (res.code === 1 && res.data) {
// Merge all indicators, including purchased and custom items.
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
}
},
async handleIndicatorChange (indicatorId) {
const idStr = String(indicatorId)
this.selectedIndicator = this.availableIndicators.find(ind => String(ind.id) === idStr)
this.applyAutoStrategyName(this.selectedIndicator)
// Load the indicator parameter declaration.
this.indicatorParams = []
this.indicatorParamValues = {}
if (indicatorId) {
try {
const res = await this.$http.get('/api/indicator/getIndicatorParams', {
params: { indicator_id: indicatorId }
})
// The response interceptor already returns response.data.
if (res && res.code === 1 && Array.isArray(res.data)) {
this.indicatorParams = res.data
// Build the full object before assignment so Vue keeps it reactive.
const paramValues = {}
res.data.forEach(p => {
paramValues[p.name] = p.default
})
this.indicatorParamValues = paramValues
}
} catch (err) {
console.warn('Failed to load indicator params:', err)
}
}
},
handleMarketTypeChange (e) {
const marketType = e.target.value
// Spot mode only supports long direction with 1x leverage.
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 label = option.componentOptions?.propsData?.label || ''
return String(label).toLowerCase().indexOf(String(input || '').toLowerCase()) >= 0
},
getIndicatorOptionLabel (indicator) {
if (!indicator) return ''
return indicator.description ? `${indicator.name} - ${indicator.description}` : indicator.name
},
buildStrategyDefaultName (indicator) {
const suffix = this.tt('trading-assistant.form.defaultStrategySuffix', 'Strategy')
if (indicator && indicator.name) {
return `${indicator.name} ${suffix}`.trim()
}
return this.tt('trading-assistant.form.defaultStrategyName', 'New Strategy')
},
applyAutoStrategyName (indicator) {
if (this.editingStrategy || !this.form) return
const nextName = this.buildStrategyDefaultName(indicator)
const currentName = this.form.getFieldValue('strategy_name')
if (currentName && currentName !== this.lastAutoStrategyName) {
return
}
this.form.setFieldsValue({ strategy_name: nextName })
this.lastAutoStrategyName = nextName
},
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: 'cyan',
python: 'geekblue',
pine: 'magenta'
}
return colors[type] || 'cyan'
},
getIndicatorTypeName (type) {
return this.$t(`trading-assistant.indicatorType.${type}`) || type
},
// Exchange helpers
getExchangeName (exchange) {
if (exchange.labelKey) {
const translationKey = `trading-assistant.exchangeNames.${exchange.labelKey}`
const translated = this.$t(translationKey)
// Fall back to the raw exchange id when no translation exists.
if (translated === translationKey) {
// Format the exchange id with a capitalized first letter.
return exchange.value.charAt(0).toUpperCase() + exchange.value.slice(1)
}
return translated
}
return exchange.label || exchange.value
},
getExchangeDisplayName (exchangeId) {
if (!exchangeId) return ''
// Look up the matching exchange option first.
const exchange = this.exchangeOptions.find(ex => ex.value === exchangeId)
if (exchange) {
return this.getExchangeName(exchange)
}
// Fall back to a formatted exchange id.
return exchangeId.charAt(0).toUpperCase() + exchangeId.slice(1)
},
getExchangeTagColor (exchangeId) {
// Use distinct tag colors for different exchanges.
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 () {
// Clear the connection test result when API fields change.
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"
// to auto-fill api_key/secret_key. User must re-enter if changing exchange.
this.$nextTick(() => {
const fieldsToClear = {
api_key: undefined,
secret_key: undefined,
passphrase: undefined,
enable_demo_trading: false // Reset demo switch too
}
setTimeout(() => {
this.form.setFieldsValue(fieldsToClear)
}, 100)
})
},
getPlaceholder (fieldType) {
const placeholders = {
okx: {
api_key: 'Enter API Key',
secret_key: 'Enter Secret Key',
passphrase: 'Enter the passphrase used when creating the API key'
},
okex: {
api_key: 'Enter API Key',
secret_key: 'Enter Secret Key',
passphrase: 'Enter the passphrase used when creating the API key'
},
binance: {
api_key: 'Enter API Key',
secret_key: 'Enter Secret Key'
},
coinbaseexchange: {
api_key: 'Enter API Key (or Key Name)',
secret_key: 'Enter API Secret (or Private Key)',
passphrase: 'Enter passphrase (required for Legacy Pro API)'
},
kucoin: {
api_key: 'Enter API Key',
secret_key: 'Enter Secret Key',
passphrase: 'Enter passphrase'
},
gate: {
api_key: 'Enter API Key',
secret_key: 'Enter Secret Key'
},
mexc: {
api_key: 'Enter Access Key',
secret_key: 'Enter Secret Key'
},
kraken: {
api_key: 'Enter API Key',
secret_key: 'Enter Secret Key'
},
bitfinex: {
api_key: 'Enter API Key',
secret_key: 'Enter Secret Key'
},
bybit: {
api_key: 'Enter API Key',
secret_key: 'Enter Secret Key'
},
bitget: {
api_key: 'Enter API Key',
secret_key: 'Enter Secret Key',
passphrase: 'Enter passphrase (required for Legacy Pro API)'
}
}
const exchangePlaceholders = placeholders[this.currentExchangeId] || {}
if (exchangePlaceholders[fieldType]) {
return exchangePlaceholders[fieldType]
}
// Use localized fallback placeholders by default.
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
})
// Note: request.js interceptor returns response.data directly, so res is the JSON object
if (res && res.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?.error || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
}
this.$message.error(this.testResult.message)
}
} catch (error) {
const baseError = error.response?.data?.error || error?.error || error.message || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
this.testResult = {
success: false,
message: `${baseError} - ${this.$t('trading-assistant.exchange.checkLocalDeployment')}`
}
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 terminalPath = 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: terminalPath
})
// Note: request.js interceptor returns response.data directly, so res is the JSON object
if (res && res.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?.error || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
}
this.$message.error(this.testResult.message)
}
} catch (error) {
const baseError = error.response?.data?.error || error?.error || error.message || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
this.testResult = {
success: false,
message: `${baseError} - ${this.$t('trading-assistant.exchange.checkLocalDeployment')}`
}
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'),
enableDemoTrading: !!this.form.getFieldValue('enable_demo_trading')
}
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
}
},
// Form step control
handleNext () {
if (this.strategyMode === 'script') {
if (this.currentStep === 0) {
this.form.validateFields(['strategy_name', 'symbol', 'initial_capital', 'timeframe'], (err) => {
if (!err) {
this.currentStep = 1
}
})
} else if (this.currentStep === 1) {
if (!this.strategyCode || this.strategyCode.trim().length < 20) {
this.$message.warning(this.tt('trading-assistant.editor.codeHint', 'Please add a valid strategy before continuing'))
return
}
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 = 2
}
return
}
if (this.currentStep === 0) {
const fieldsToValidate = ['indicator_id', 'strategy_name']
if (this.isAdvancedMode || this.editingStrategy) {
fieldsToValidate.push('initial_capital', 'market_type', 'leverage', 'trade_direction', 'timeframe')
}
if (this.isEditMode) {
fieldsToValidate.push('symbol')
}
this.form.validateFields(fieldsToValidate, (err, values) => {
if (err) return
if (!this.isEditMode) {
const strategyType = this.form.getFieldValue('cs_strategy_type') || 'single'
if (strategyType === 'cross_sectional') {
if (!this.crossSectionalSymbols || this.crossSectionalSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
return
}
} else if (!this.selectedSymbols || this.selectedSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
return
}
}
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) { }
this.backtestCollapseKeys = ['risk']
try {
this.trailingEnabledUi = !!this.form.getFieldValue('trailing_enabled')
} catch (e) {
this.trailingEnabledUi = false
}
this.$nextTick(() => {
this.recalcEntryPctMaxUi()
this.normalizeEntryPct()
})
if (this.isSimpleMode && !this.editingStrategy) {
this.currentStep = 2
} else {
this.currentStep++
}
})
} else if (this.currentStep === 1) {
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) {
if (this.strategyMode === 'script') {
this.currentStep--
return
}
if (this.isSimpleMode && !this.editingStrategy && this.currentStep === 2) {
this.currentStep = 0
} else {
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 && !values.live_disclaimer_ack) {
this.$message.warning(this.$t('trading-assistant.liveDisclaimer.required'))
this.saving = false
return
}
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
}
}
// Use user's notification settings from profile for targets
const notificationConfig = {
channels: values.notify_channels || [],
targets: {
email: this.userNotificationSettings.email || '',
phone: this.userNotificationSettings.phone || '',
telegram: this.userNotificationSettings.telegram_chat_id || '',
telegram_bot_token: this.userNotificationSettings.telegram_bot_token || '',
discord: this.userNotificationSettings.discord_webhook || '',
webhook: this.userNotificationSettings.webhook_url || '',
webhook_token: this.userNotificationSettings.webhook_token || ''
}
}
if (!notificationConfig.channels || notificationConfig.channels.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.notifyChannelRequired'))
this.saving = false
return
}
if (this.strategyMode === 'script') {
const rawSymbolValue = values.symbol || ''
let marketCategory = this.selectedMarketCategory || 'Crypto'
let symbol = rawSymbolValue
if (typeof rawSymbolValue === 'string' && rawSymbolValue.includes(':')) {
const separatorIndex = rawSymbolValue.indexOf(':')
marketCategory = rawSymbolValue.slice(0, separatorIndex) || marketCategory
symbol = rawSymbolValue.slice(separatorIndex + 1)
}
const marketType = (values.market_type === 'futures' ? 'swap' : (values.market_type || 'swap'))
const leverage = marketType === 'spot' ? 1 : (values.leverage || 1)
const exchangeConfig = isLive
? (this.isIBKRMarket
? {
exchange_id: values.broker_id || this.currentBrokerId || 'ibkr',
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
? {
exchange_id: values.forex_broker_id || this.currentBrokerId || 'mt5',
mt5_server: values.mt5_server || '',
mt5_login: values.mt5_login || '',
mt5_password: values.mt5_password || '',
mt5_terminal_path: values.mt5_terminal_path || ''
}
: {
exchange_id: values.exchange_id,
credential_id: values.credential_id,
api_key: values.api_key,
secret_key: values.secret_key,
enableDemoTrading: !!values.enable_demo_trading,
passphrase: this.needsPassphrase ? values.passphrase : undefined
})
: undefined
const payload = {
strategy_name: values.strategy_name,
strategy_type: 'ScriptStrategy',
strategy_mode: 'script',
strategy_code: this.strategyCode,
market_category: marketCategory,
execution_mode: values.execution_mode || 'signal',
notification_config: notificationConfig,
exchange_config: exchangeConfig,
trading_config: {
initial_capital: values.initial_capital || 1000,
leverage: leverage,
timeframe: values.timeframe || '15m',
market_type: marketType,
symbol: symbol
}
}
let res
if (this.editingStrategy) {
res = await updateStrategy(this.editingStrategy.id, payload)
} else {
payload.user_id = 1
res = await createStrategy(payload)
}
if (res.code === 1) {
this.$message.success(this.$t(this.editingStrategy ? 'trading-assistant.messages.updateSuccess' : 'trading-assistant.messages.createSuccess'))
this.handleRefresh()
} else {
this.$message.error(res.msg || this.$t(this.editingStrategy ? 'trading-assistant.messages.updateFailed' : 'trading-assistant.messages.createFailed'))
}
this.saving = false
return
}
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
}
// Build the shared payload shape used by create and update requests.
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 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,
enableDemoTrading: !!values.enable_demo_trading,
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 decision filter
enable_ai_filter: enableAiFilter,
// Indicator parameters collected from the UI.
indicator_params: this.indicatorParamValues,
// Cross-sectional strategy settings.
strategy_type: values.cs_strategy_type || 'single',
symbol_list: values.cs_strategy_type === 'cross_sectional' ? this.crossSectionalSymbols : undefined,
portfolio_size: values.cs_strategy_type === 'cross_sectional' ? (values.portfolio_size || 10) : undefined,
long_ratio: values.cs_strategy_type === 'cross_sectional' ? (values.long_ratio || 0.5) : undefined,
rebalance_frequency: values.cs_strategy_type === 'cross_sectional' ? (values.rebalance_frequency || 'daily') : undefined
}
}
let res
if (this.editingStrategy) {
// Edit mode updates one existing strategy.
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 {
// Create mode either batches single-symbol strategies or creates one basket strategy.
basePayload.user_id = 1
basePayload.strategy_type = 'IndicatorStrategy'
// Cross-sectional mode stores the basket in trading_config and creates one strategy only.
if (values.cs_strategy_type === 'cross_sectional') {
basePayload.strategy_type = 'IndicatorStrategy' // Keep IndicatorStrategy; basket mode is encoded in trading_config.
// Basket strategies do not use a single symbol field.
basePayload.trading_config.symbol = null
// Use createStrategy for the single basket record.
res = await createStrategy(basePayload)
} else {
// Single-symbol mode creates one strategy per selected symbol.
basePayload.symbols = this.selectedSymbols // Multi-symbol array
res = await batchCreateStrategies(basePayload)
}
}
if (res.code === 1) {
if (this.isEditMode) {
this.$message.success(this.$t('trading-assistant.messages.updateSuccess'))
} else {
// Derive the created strategy count from the strategy mode.
const strategyType = values.cs_strategy_type || 'single'
const symbolCount = strategyType === 'cross_sectional'
? this.crossSectionalSymbols.length
: this.selectedSymbols.length
const totalCreated = res.data?.total_created || symbolCount
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) {
// Always mount dropdowns under body to avoid clipping inside parent containers.
return document.body
}
}
}
</script>
<style lang="less" scoped>
// Primary color tokens
@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;
min-height: calc(100vh - 120px);
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
.assistant-guide-bar {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) auto;
gap: 18px;
align-items: stretch;
margin-bottom: 18px;
padding: 20px 22px;
border: 1px solid #dce7f3;
border-radius: 24px;
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 38%),
radial-gradient(circle at bottom right, rgba(16, 185, 129, 0.12), transparent 34%),
linear-gradient(135deg, #ffffff 0%, #f8fbff 55%, #eef7ff 100%);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}
.assistant-guide-copy {
min-width: 0;
}
.assistant-guide-eyebrow {
color: #2563eb;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.assistant-guide-title {
margin-top: 8px;
color: #0f172a;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.assistant-guide-desc {
margin-top: 10px;
color: #475569;
font-size: 14px;
line-height: 1.7;
}
.assistant-guide-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.assistant-step-card {
display: flex;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(148, 163, 184, 0.18);
backdrop-filter: blur(8px);
}
.assistant-step-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex: 0 0 30px;
border-radius: 999px;
background: linear-gradient(135deg, #2563eb 0%, #10b981 100%);
color: #fff;
font-size: 14px;
font-weight: 700;
}
.assistant-step-body {
min-width: 0;
}
.assistant-step-title {
color: #0f172a;
font-size: 14px;
font-weight: 600;
}
.assistant-step-desc {
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
.assistant-guide-actions {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
min-width: 220px;
}
.assistant-guide-close {
align-self: flex-end;
width: 36px;
height: 36px;
padding: 0;
border-radius: 999px;
color: #64748b;
border-color: rgba(148, 163, 184, 0.28);
background: rgba(255, 255, 255, 0.72);
}
.top-level-tabs {
display: flex;
flex-direction: column;
min-height: calc(100vh - 360px);
/deep/ .ant-tabs-bar {
margin-bottom: 18px;
}
/deep/ .ant-tabs-content {
height: 100%;
}
/deep/ .ant-tabs-tabpane-active {
height: 100%;
}
}
.strategy-layout {
height: calc(100vh - 420px);
min-height: 680px;
}
.strategy-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 32px 18px 36px;
text-align: center;
}
.strategy-empty-desc {
max-width: 320px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
.strategy-empty-path {
color: #2563eb;
font-size: 12px;
font-weight: 600;
}
// Mobile layout adjustments
@media (max-width: 768px) {
min-height: auto;
margin: -24px;
.assistant-guide-bar {
grid-template-columns: 1fr;
padding: 18px;
border-radius: 0 0 24px 24px;
}
.assistant-guide-title {
font-size: 22px;
}
.assistant-guide-steps {
grid-template-columns: 1fr;
}
.assistant-guide-actions {
min-width: 0;
}
.top-level-tabs {
min-height: auto;
}
.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;
}
// Allow long values to wrap cleanly.
&>span {
word-break: break-word;
line-height: 1.5;
flex: 1;
}
// Keep numeric spans readable when wrapping.
span:not(.anticon) {
display: inline;
word-break: break-word;
overflow-wrap: break-word;
}
}
// Collapse to one column when metadata gets too dense.
@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;
}
}
}
}
}
}
}
// Extra-small screen adjustments
@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);
}
}
}
// Grouping mode switch
.group-mode-switch {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 12px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
.group-mode-label {
font-size: 13px;
color: #8c8c8c;
font-weight: 500;
}
/deep/ .ant-radio-group {
.ant-radio-button-wrapper {
font-size: 12px;
padding: 0 10px;
height: 26px;
line-height: 24px;
border-radius: 4px;
&:first-child {
border-radius: 4px 0 0 4px;
}
&:last-child {
border-radius: 0 4px 4px 0;
}
.anticon {
margin-right: 4px;
}
}
}
}
/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;
}
// Grouped strategy list
.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);
}
// Increase the tappable area on mobile.
@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); // Keep room for action buttons and the active border.
overflow: hidden;
.strategy-item-info {
display: flex !important;
gap: 12px; // Slightly tighter spacing works better here.
margin-top: 8px;
font-size: 12px;
color: var(--text-color-secondary, #8c8c8c);
align-items: center;
flex-wrap: nowrap; // Keep the metadata row on a single line.
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-name-text {
font-weight: 500;
color: #1e3a5f;
max-width: 120px;
}
}
/deep/ .ant-tag {
margin-right: 0;
font-size: 11px;
line-height: 18px;
padding: 0 6px;
}
}
}
}
}
}
.indicator-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.indicator-option-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.indicator-option-desc {
margin-top: 2px;
color: #8c8c8c;
font-size: 12px;
line-height: 1.4;
white-space: normal;
}
.selected-indicator-card {
padding: 14px 16px;
border-radius: 14px;
border: 1px solid #e5eef7;
background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%);
}
.selected-indicator-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.selected-indicator-name {
color: #1e3a5f;
font-size: 15px;
font-weight: 700;
}
.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; // Prevent the header card from being squeezed.
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 metric cards
.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
.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; // Keep enough vertical space for the tab content.
&: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);
.assistant-guide-bar {
border-color: rgba(59, 130, 246, 0.16);
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.26), transparent 36%),
radial-gradient(circle at bottom right, rgba(16, 185, 129, 0.14), transparent 34%),
linear-gradient(135deg, #161b22 0%, #111827 58%, #0f172a 100%);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.25);
}
.assistant-guide-eyebrow {
color: #60a5fa;
}
.assistant-guide-title,
.assistant-step-title {
color: #f8fafc;
}
.assistant-guide-desc,
.assistant-step-desc {
color: #94a3b8;
}
.assistant-step-card {
background: rgba(15, 23, 42, 0.62);
border-color: rgba(148, 163, 184, 0.12);
}
.assistant-guide-close {
color: #cbd5e1;
border-color: rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.56);
}
.strategy-empty-desc {
color: #94a3b8;
}
.strategy-empty-path {
color: #60a5fa;
}
.creation-mode-toggle {
background: rgba(24, 144, 255, 0.08);
border-color: rgba(24, 144, 255, 0.2);
.mode-title {
color: rgba(255, 255, 255, 0.88);
}
.mode-hint {
color: rgba(255, 255, 255, 0.45);
}
}
// Left strategy list card
.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;
}
}
}
// Right strategy detail card
.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 state
.empty-detail {
/deep/ .ant-empty-description {
color: #868993;
}
}
.selected-indicator-card {
border-color: rgba(59, 130, 246, 0.16);
background: linear-gradient(180deg, rgba(37, 99, 235, 0.08) 0%, rgba(15, 23, 42, 0.24) 100%);
}
.selected-indicator-name {
color: #f8fafc;
}
.indicator-option-desc {
color: #94a3b8;
}
}
}
/* 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;
}
// Simple/Advanced mode toggle
.creation-mode-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding: 8px 12px;
background: rgba(24, 144, 255, 0.04);
border-radius: 8px;
border: 1px solid rgba(24, 144, 255, 0.12);
.mode-meta {
min-width: 0;
}
.mode-title {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.mode-hint {
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
margin-top: 2px;
}
}
.simple-defaults-summary {
margin-top: 8px;
}
// Modal styles
.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;
}
.indicator-params-form {
padding: 12px;
background-color: var(--bg-color-secondary, #f5f7fa);
border-radius: 6px;
border: 1px dashed var(--border-color, #e0e0e0);
}
.indicator-params-form .param-item {
margin-bottom: 12px;
}
.indicator-params-form .param-label {
display: block;
font-size: 13px;
color: var(--text-color, #666);
margin-bottom: 4px;
font-weight: 500;
}
.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;
}
// Mobile modal styles
/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;
}
}
}
// Add-symbol modal styles
.add-symbol-modal-content {
.market-tabs {
margin-bottom: 16px;
}
.symbol-search-section {
margin-bottom: 16px;
}
.section-title {
font-weight: 500;
margin-bottom: 8px;
color: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
}
.search-results-section,
.hot-symbols-section {
margin-bottom: 16px;
}
.symbol-list {
.symbol-list-item {
cursor: pointer;
transition: background-color 0.3s;
padding: 8px 12px;
border-radius: 4px;
&:hover {
background-color: #f5f5f5;
}
}
}
.symbol-item-content {
display: flex;
align-items: center;
.symbol-code {
font-weight: 500;
margin-right: 8px;
}
.symbol-name {
color: rgba(0, 0, 0, 0.45);
}
}
.selected-symbol-section {
padding: 12px;
background-color: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 4px;
margin-top: 16px;
.selected-symbol-info {
display: flex;
align-items: center;
margin-top: 8px;
.symbol-code {
font-weight: 500;
margin-right: 8px;
}
.symbol-name {
color: rgba(0, 0, 0, 0.45);
}
}
}
}
</style>