feat: Add cross-sectional strategy support
- Add cross-sectional strategy type (single vs cross-sectional) - Support multi-symbol portfolio management with automatic ranking - Add portfolio size, long ratio, and rebalance frequency configuration - Implement parallel order execution for cross-sectional strategies - Add frontend UI for strategy type selection and configuration - Add i18n support (Chinese and English) for cross-sectional features - Fix decimal precision issues in exchange order quantities - Add last_rebalance_at field to database schema - Add comprehensive documentation and examples Database migration required: Add last_rebalance_at column to qd_strategies_trading table
This commit is contained in:
@@ -1324,6 +1324,21 @@ const locale = {
|
||||
'trading-assistant.form.symbols': 'Trading Pairs (Multi-select)',
|
||||
'trading-assistant.form.symbolHint': 'Symbol format depends on the selected market',
|
||||
'trading-assistant.form.symbolsHint': 'Select multiple pairs to create strategies for each',
|
||||
'trading-assistant.form.strategyType': 'Strategy Type',
|
||||
'trading-assistant.form.strategyTypeSingle': 'Single Symbol Strategy',
|
||||
'trading-assistant.form.strategyTypeCrossSectional': 'Cross-Sectional Strategy',
|
||||
'trading-assistant.form.strategyTypeHint': 'Single Symbol: Trade a single symbol; Cross-Sectional: Manage a portfolio of multiple symbols',
|
||||
'trading-assistant.form.symbolList': 'Symbol List',
|
||||
'trading-assistant.form.symbolListHint': 'Select multiple symbols, strategy will rank and rebalance based on indicator scores',
|
||||
'trading-assistant.form.portfolioSize': 'Portfolio Size',
|
||||
'trading-assistant.form.portfolioSizeHint': 'Number of symbols to hold simultaneously',
|
||||
'trading-assistant.form.longRatio': 'Long Ratio',
|
||||
'trading-assistant.form.longRatioHint': 'Proportion of long positions (0-1), e.g. 0.5 means 50% long, 50% short',
|
||||
'trading-assistant.form.rebalanceFrequency': 'Rebalance Frequency',
|
||||
'trading-assistant.form.rebalanceDaily': 'Daily',
|
||||
'trading-assistant.form.rebalanceWeekly': 'Weekly',
|
||||
'trading-assistant.form.rebalanceMonthly': 'Monthly',
|
||||
'trading-assistant.form.rebalanceFrequencyHint': 'Frequency of portfolio rebalancing',
|
||||
'trading-assistant.form.addSymbol': 'Add Symbol',
|
||||
'trading-assistant.form.addSymbolTitle': 'Add Symbol to Watchlist',
|
||||
'trading-assistant.form.searchSymbolPlaceholder': 'Search by code, e.g. BTC, AAPL',
|
||||
@@ -1439,6 +1454,8 @@ const locale = {
|
||||
'trading-assistant.validation.mt5ServerRequired': 'Please enter MT5 server',
|
||||
'trading-assistant.validation.mt5LoginRequired': 'Please enter MT5 account number',
|
||||
'trading-assistant.validation.mt5PasswordRequired': 'Please enter MT5 password',
|
||||
'trading-assistant.validation.portfolioSizeRequired': 'Please enter portfolio size',
|
||||
'trading-assistant.validation.longRatioRequired': 'Please enter long ratio',
|
||||
'trading-assistant.exchange.mt5ConnectionSuccess': 'MT5 connected successfully',
|
||||
'trading-assistant.exchange.mt5ConnectionFailed': 'MT5 connection failed. Please check if terminal is running.',
|
||||
'trading-assistant.form.notifyChannels': 'Notification Channels',
|
||||
|
||||
@@ -1228,6 +1228,21 @@ const locale = {
|
||||
'trading-assistant.form.symbolHint': '目前仅支持加密货币交易对',
|
||||
'trading-assistant.form.symbolHintCrypto': '加密货币:使用BTC/USDT等交易对格式',
|
||||
'trading-assistant.form.symbolsHint': '选择多个交易对,将自动创建多个策略',
|
||||
'trading-assistant.form.strategyType': '策略类型',
|
||||
'trading-assistant.form.strategyTypeSingle': '单标的策略',
|
||||
'trading-assistant.form.strategyTypeCrossSectional': '截面策略',
|
||||
'trading-assistant.form.strategyTypeHint': '单标的策略:针对单个标的进行交易;截面策略:同时管理多个标的的组合',
|
||||
'trading-assistant.form.symbolList': '标的列表',
|
||||
'trading-assistant.form.symbolListHint': '选择多个标的,策略将根据指标评分进行排序和调仓',
|
||||
'trading-assistant.form.portfolioSize': '持仓组合大小',
|
||||
'trading-assistant.form.portfolioSizeHint': '策略同时持有的标的数量',
|
||||
'trading-assistant.form.longRatio': '做多比例',
|
||||
'trading-assistant.form.longRatioHint': '持仓中做多标的的比例(0-1),例如0.5表示50%做多,50%做空',
|
||||
'trading-assistant.form.rebalanceFrequency': '调仓频率',
|
||||
'trading-assistant.form.rebalanceDaily': '每日',
|
||||
'trading-assistant.form.rebalanceWeekly': '每周',
|
||||
'trading-assistant.form.rebalanceMonthly': '每月',
|
||||
'trading-assistant.form.rebalanceFrequencyHint': '策略重新调整持仓组合的频率',
|
||||
'trading-assistant.form.addSymbol': '添加交易对',
|
||||
'trading-assistant.form.addSymbolTitle': '添加交易对到自选列表',
|
||||
'trading-assistant.form.searchSymbolPlaceholder': '输入代码搜索,如 BTC、AAPL',
|
||||
@@ -1317,6 +1332,8 @@ const locale = {
|
||||
'trading-assistant.validation.mt5ServerRequired': '请输入 MT5 服务器',
|
||||
'trading-assistant.validation.mt5LoginRequired': '请输入 MT5 账户号',
|
||||
'trading-assistant.validation.mt5PasswordRequired': '请输入 MT5 密码',
|
||||
'trading-assistant.validation.portfolioSizeRequired': '请输入持仓组合大小',
|
||||
'trading-assistant.validation.longRatioRequired': '请输入做多比例',
|
||||
'trading-assistant.exchange.mt5ConnectionSuccess': 'MT5 连接成功',
|
||||
'trading-assistant.exchange.mt5ConnectionFailed': 'MT5 连接失败,请检查终端是否运行',
|
||||
'trading-assistant.form.notifyChannels': '通知渠道',
|
||||
|
||||
@@ -160,7 +160,12 @@ export default {
|
||||
if (!isFinite(lev) || lev <= 0) lev = 1
|
||||
if (mt === 'spot') lev = 1
|
||||
|
||||
const entryPrice = parseFloat(position.entry_price || position.entryPrice || '0') || 0
|
||||
// 处理 entry_price:如果为 0 或 null,尝试使用 current_price 作为后备
|
||||
let entryPrice = parseFloat(position.entry_price || position.entryPrice || 0)
|
||||
if (!entryPrice || entryPrice <= 0) {
|
||||
// 如果 entry_price 无效,尝试使用 current_price(可能是新开仓,价格相同)
|
||||
entryPrice = parseFloat(position.current_price || position.currentPrice || 0)
|
||||
}
|
||||
const size = parseFloat(position.size || '0') || 0
|
||||
const pnl = parseFloat(position.unrealized_pnl || position.unrealizedPnl || '0') || 0
|
||||
let pnlPercent = parseFloat(position.pnl_percent || position.pnlPercent || '0') || 0
|
||||
@@ -177,8 +182,8 @@ export default {
|
||||
id: position.id || index,
|
||||
symbol: position.symbol || '',
|
||||
side: position.side || 'long',
|
||||
size: position.size || '0',
|
||||
entry_price: position.entry_price || position.entryPrice || '0',
|
||||
size: size > 0 ? size.toString() : '0',
|
||||
entry_price: entryPrice > 0 ? entryPrice.toString() : (position.entry_price || position.entryPrice || '0'),
|
||||
current_price: position.current_price || position.currentPrice || '0',
|
||||
unrealized_pnl: position.unrealized_pnl || position.unrealizedPnl || '0',
|
||||
pnl_percent: pnlPercent,
|
||||
|
||||
@@ -467,7 +467,103 @@
|
||||
:placeholder="$t('trading-assistant.placeholders.inputStrategyName')" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- 策略类型选择 -->
|
||||
<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>
|
||||
|
||||
<!-- 截面策略配置 -->
|
||||
<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>
|
||||
|
||||
<!-- 单标的策略:原有的标的选择 -->
|
||||
<a-form-item
|
||||
v-if="form.getFieldValue('cs_strategy_type') !== 'cross_sectional'"
|
||||
:label="isEditMode ? $t('trading-assistant.form.symbol') : $t('trading-assistant.form.symbols')">
|
||||
<!-- 编辑模式:单选 -->
|
||||
<a-select
|
||||
@@ -1378,7 +1474,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getStrategyList, startStrategy, stopStrategy, deleteStrategy, updateStrategy, testExchangeConnection, getStrategyEquityCurve, batchCreateStrategies, batchStartStrategies, batchStopStrategies, batchDeleteStrategies } from '@/api/strategy'
|
||||
import { getStrategyList, startStrategy, stopStrategy, deleteStrategy, updateStrategy, createStrategy, testExchangeConnection, getStrategyEquityCurve, 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'
|
||||
@@ -1785,6 +1881,8 @@ export default {
|
||||
suppressApiClearOnce: false,
|
||||
// 多币种选择(创建模式)
|
||||
selectedSymbols: [],
|
||||
// 截面策略标的列表
|
||||
crossSectionalSymbols: [],
|
||||
// 策略组折叠状态
|
||||
collapsedGroups: {},
|
||||
// 分组模式: 'strategy' 或 'symbol'
|
||||
@@ -2090,6 +2188,36 @@ export default {
|
||||
}
|
||||
this.handleMultiSymbolChange(vals)
|
||||
},
|
||||
handleStrategyTypeChange (e) {
|
||||
const strategyType = e.target.value
|
||||
// 当切换到单标的策略时,清空截面策略的标的列表
|
||||
if (strategyType === 'single') {
|
||||
this.crossSectionalSymbols = []
|
||||
}
|
||||
},
|
||||
handleCrossSectionalSymbolChange (vals) {
|
||||
// 检查是否点击了"添加"选项
|
||||
if (vals && vals.includes('__add_symbol_option__')) {
|
||||
// 从选中列表中移除特殊选项
|
||||
this.crossSectionalSymbols = vals.filter(v => v !== '__add_symbol_option__')
|
||||
// 打开添加弹窗
|
||||
this.showAddSymbolModal = true
|
||||
// 加载热门标的
|
||||
this.loadHotSymbols(this.addSymbolMarket)
|
||||
return
|
||||
}
|
||||
this.crossSectionalSymbols = vals || []
|
||||
|
||||
// 更新市场类型基于选中的标的
|
||||
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',
|
||||
@@ -2585,6 +2713,38 @@ export default {
|
||||
const scaleObj = (tc.scale && typeof tc.scale === 'object') ? tc.scale : null
|
||||
const posObj = (tc.position && typeof tc.position === 'object') ? tc.position : null
|
||||
|
||||
// 加载截面策略配置
|
||||
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'
|
||||
})
|
||||
// 加载标的列表
|
||||
if (tc.symbol_list && Array.isArray(tc.symbol_list)) {
|
||||
this.crossSectionalSymbols = tc.symbol_list
|
||||
} else if (strategy.symbol_list) {
|
||||
// 如果trading_config中没有,尝试从主表字段读取
|
||||
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
|
||||
@@ -3437,9 +3597,19 @@ export default {
|
||||
|
||||
// 创建模式:验证多币种选择
|
||||
if (!this.isEditMode) {
|
||||
if (!this.selectedSymbols || this.selectedSymbols.length === 0) {
|
||||
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
|
||||
return
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3620,7 +3790,13 @@ export default {
|
||||
// AI智能决策过滤
|
||||
enable_ai_filter: enableAiFilter,
|
||||
// 指标参数(外部传递)
|
||||
indicator_params: this.indicatorParamValues
|
||||
indicator_params: this.indicatorParamValues,
|
||||
// 截面策略配置
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3636,19 +3812,35 @@ export default {
|
||||
basePayload.trading_config.symbol = parsedSymbol
|
||||
res = await updateStrategy(this.editingStrategy.id, basePayload)
|
||||
} else {
|
||||
// 创建模式:批量创建策略
|
||||
// 创建模式:批量创建策略或截面策略
|
||||
basePayload.user_id = 1
|
||||
basePayload.strategy_type = 'IndicatorStrategy'
|
||||
basePayload.symbols = this.selectedSymbols // 多币种数组
|
||||
|
||||
res = await batchCreateStrategies(basePayload)
|
||||
// 如果是截面策略,只创建一个策略(使用 createStrategy)
|
||||
if (values.cs_strategy_type === 'cross_sectional') {
|
||||
// 截面策略:只创建一个策略,标的列表存储在 trading_config 中
|
||||
basePayload.strategy_type = 'IndicatorStrategy' // 保持为 IndicatorStrategy,截面类型在 trading_config 中
|
||||
// 截面策略不需要设置 symbol,因为它是多标的的
|
||||
basePayload.trading_config.symbol = null
|
||||
// 使用 createStrategy 创建单个策略
|
||||
res = await createStrategy(basePayload)
|
||||
} else {
|
||||
// 单标的策略:批量创建多个策略(每个标的一个策略)
|
||||
basePayload.symbols = this.selectedSymbols // 多币种数组
|
||||
res = await batchCreateStrategies(basePayload)
|
||||
}
|
||||
}
|
||||
|
||||
if (res.code === 1) {
|
||||
if (this.isEditMode) {
|
||||
this.$message.success(this.$t('trading-assistant.messages.updateSuccess'))
|
||||
} else {
|
||||
const totalCreated = res.data?.total_created || this.selectedSymbols.length
|
||||
// 根据策略类型计算创建的策略数量
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user