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:
TIANHE
2026-02-10 15:19:20 +08:00
parent a89cc9bee9
commit a51184497d
19 changed files with 1517 additions and 36 deletions
+17
View File
@@ -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',
+17
View File
@@ -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)