- 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.
6602 lines
248 KiB
Vue
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>
|