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:18:45 +08:00
parent a89cc9bee9
commit a51184497d
19 changed files with 1517 additions and 36 deletions
@@ -561,6 +561,18 @@ class TradingExecutor:
market_category = (strategy.get('market_category') or 'Crypto').strip()
logger.info(f"Strategy {strategy_id} market_category: {market_category}")
# Check if this is a cross-sectional strategy
cs_strategy_type = trading_config.get('cs_strategy_type', 'single')
if cs_strategy_type == 'cross_sectional':
# Run cross-sectional strategy loop
self._run_cross_sectional_strategy_loop(
strategy_id, strategy, trading_config, indicator_config,
ai_model_config, execution_mode, notification_config,
strategy_name, market_category, market_type, leverage,
initial_capital, indicator_code, indicator_id
)
return
# 初始化交易所连接(信号模式下无需真实连接)
exchange = None
@@ -2687,3 +2699,348 @@ class TradingExecutor:
return result['code'] if result else None
except:
return None
def _get_all_positions(self, strategy_id: int) -> List[Dict[str, Any]]:
"""获取策略的所有持仓(截面策略使用)"""
try:
with get_db_connection() as db:
cursor = db.cursor()
cursor.execute("""
SELECT id, symbol, side, size, entry_price, current_price, highest_price, lowest_price
FROM qd_strategy_positions
WHERE strategy_id = %s
""", (strategy_id,))
return cursor.fetchall() or []
except Exception as e:
logger.error(f"Failed to get all positions: {e}")
return []
def _should_rebalance(self, strategy_id: int, rebalance_frequency: str) -> bool:
"""检查是否应该调仓"""
try:
with get_db_connection() as db:
cursor = db.cursor()
cursor.execute("""
SELECT last_rebalance_at FROM qd_strategies_trading WHERE id = %s
""", (strategy_id,))
result = cursor.fetchone()
if not result or not result.get('last_rebalance_at'):
return True
last_rebalance = result['last_rebalance_at']
if isinstance(last_rebalance, str):
from datetime import datetime
last_rebalance = datetime.fromisoformat(last_rebalance.replace('Z', '+00:00'))
now = datetime.now()
delta = now - last_rebalance
if rebalance_frequency == 'daily':
return delta.days >= 1
elif rebalance_frequency == 'weekly':
return delta.days >= 7
elif rebalance_frequency == 'monthly':
return delta.days >= 30
return True
except Exception as e:
logger.error(f"Failed to check rebalance: {e}")
return True
def _update_last_rebalance(self, strategy_id: int):
"""更新上次调仓时间"""
try:
with get_db_connection() as db:
cursor = db.cursor()
# Try to update, if column doesn't exist, ignore
try:
cursor.execute("""
UPDATE qd_strategies_trading
SET last_rebalance_at = NOW()
WHERE id = %s
""", (strategy_id,))
db.commit()
except Exception:
# Column may not exist, that's OK
pass
cursor.close()
except Exception as e:
logger.warning(f"Failed to update last_rebalance_at: {e}")
def _execute_cross_sectional_indicator(
self,
indicator_code: str,
symbols: List[str],
trading_config: Dict[str, Any],
market_category: str,
timeframe: str
) -> Optional[Dict[str, Any]]:
"""
执行截面策略指标,返回所有标的的评分和排序
"""
try:
# 获取所有标的的K线数据
all_data = {}
for symbol in symbols:
try:
klines = self._fetch_latest_kline(symbol, timeframe, limit=200, market_category=market_category)
if klines and len(klines) >= 2:
df = self._klines_to_dataframe(klines)
if len(df) > 0:
all_data[symbol] = df
except Exception as e:
logger.warning(f"Failed to fetch data for {symbol}: {e}")
continue
if not all_data:
logger.error("No data available for cross-sectional strategy")
return None
# 准备执行环境
exec_env = {
'symbols': list(all_data.keys()),
'data': all_data, # {symbol: df}
'scores': {}, # 用于存储评分
'rankings': [], # 用于存储排序
'np': np,
'pd': pd,
'trading_config': trading_config,
'config': trading_config,
}
# 执行指标代码
import builtins
safe_builtins = {k: getattr(builtins, k) for k in dir(builtins)
if not k.startswith('_') and k not in [
'eval', 'exec', 'compile', 'open', 'input',
'help', 'exit', 'quit', '__import__',
]}
exec_env['__builtins__'] = safe_builtins
pre_import_code = "import numpy as np\nimport pandas as pd\n"
exec(pre_import_code, exec_env)
exec(indicator_code, exec_env)
scores = exec_env.get('scores', {})
rankings = exec_env.get('rankings', [])
# 如果没有提供rankings,根据scores排序
if not rankings and scores:
rankings = sorted(scores.keys(), key=lambda x: scores.get(x, 0), reverse=True)
return {
'scores': scores,
'rankings': rankings
}
except Exception as e:
logger.error(f"Failed to execute cross-sectional indicator: {e}")
logger.error(traceback.format_exc())
return None
def _generate_cross_sectional_signals(
self,
strategy_id: int,
rankings: List[str],
scores: Dict[str, float],
trading_config: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""
根据排序结果生成截面策略信号
"""
portfolio_size = trading_config.get('portfolio_size', 10)
long_ratio = float(trading_config.get('long_ratio', 0.5))
# 选择持仓标的
long_count = int(portfolio_size * long_ratio)
short_count = portfolio_size - long_count
long_symbols = set(rankings[:long_count]) if long_count > 0 else set()
short_symbols = set(rankings[-short_count:]) if short_count > 0 and len(rankings) >= short_count else set()
# 获取当前持仓
current_positions = self._get_all_positions(strategy_id)
current_long = {p['symbol'] for p in current_positions if p.get('side') == 'long'}
current_short = {p['symbol'] for p in current_positions if p.get('side') == 'short'}
signals = []
# 生成做多信号
for symbol in long_symbols:
if symbol not in current_long:
# 如果当前没有多仓,开多
if symbol in current_short:
# 如果当前是空仓,先平空再开多
signals.append({
'symbol': symbol,
'type': 'close_short',
'score': scores.get(symbol, 0)
})
signals.append({
'symbol': symbol,
'type': 'open_long',
'score': scores.get(symbol, 0)
})
# 平掉不在做多列表中的多仓
for symbol in current_long:
if symbol not in long_symbols:
signals.append({
'symbol': symbol,
'type': 'close_long',
'score': scores.get(symbol, 0)
})
# 生成做空信号
for symbol in short_symbols:
if symbol not in current_short:
# 如果当前没有空仓,开空
if symbol in current_long:
# 如果当前是多仓,先平多再开空
signals.append({
'symbol': symbol,
'type': 'close_long',
'score': scores.get(symbol, 0)
})
signals.append({
'symbol': symbol,
'type': 'open_short',
'score': scores.get(symbol, 0)
})
# 平掉不在做空列表中的空仓
for symbol in current_short:
if symbol not in short_symbols:
signals.append({
'symbol': symbol,
'type': 'close_short',
'score': scores.get(symbol, 0)
})
return signals
def _run_cross_sectional_strategy_loop(
self,
strategy_id: int,
strategy: Dict[str, Any],
trading_config: Dict[str, Any],
indicator_config: Dict[str, Any],
ai_model_config: Dict[str, Any],
execution_mode: str,
notification_config: Dict[str, Any],
strategy_name: str,
market_category: str,
market_type: str,
leverage: float,
initial_capital: float,
indicator_code: str,
indicator_id: Optional[int]
):
"""
截面策略执行循环
"""
logger.info(f"Starting cross-sectional strategy loop for strategy {strategy_id}")
symbol_list = trading_config.get('symbol_list', [])
if not symbol_list:
logger.error(f"Strategy {strategy_id} has no symbol_list for cross-sectional strategy")
return
timeframe = trading_config.get('timeframe', '1H')
rebalance_frequency = trading_config.get('rebalance_frequency', 'daily')
tick_interval_sec = int(trading_config.get('decide_interval', 300))
last_tick_time = 0
last_rebalance_time = 0
while True:
try:
# 检查策略状态
if not self._is_strategy_running(strategy_id):
logger.info(f"Cross-sectional strategy {strategy_id} stopped")
break
current_time = time.time()
# Sleep until next tick
if last_tick_time > 0:
sleep_sec = (last_tick_time + tick_interval_sec) - current_time
if sleep_sec > 0:
time.sleep(min(sleep_sec, 1.0))
continue
last_tick_time = current_time
# 检查是否需要调仓
if not self._should_rebalance(strategy_id, rebalance_frequency):
continue
logger.info(f"Cross-sectional strategy {strategy_id} rebalancing...")
# 执行截面指标
result = self._execute_cross_sectional_indicator(
indicator_code, symbol_list, trading_config, market_category, timeframe
)
if not result:
logger.warning(f"Cross-sectional indicator returned no result")
continue
# 生成信号
signals = self._generate_cross_sectional_signals(
strategy_id, result['rankings'], result['scores'], trading_config
)
if not signals:
logger.info(f"No rebalancing needed for strategy {strategy_id}")
self._update_last_rebalance(strategy_id)
continue
logger.info(f"Generated {len(signals)} signals for cross-sectional strategy {strategy_id}")
# 批量执行交易
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=min(10, len(signals))) as executor:
futures = {}
for signal in signals:
future = executor.submit(
self._execute_signal,
strategy_id=strategy_id,
strategy_name=strategy_name,
exchange=None, # Signal mode
symbol=signal['symbol'],
current_price=0.0, # Will be fetched in _execute_signal
signal_type=signal['type'],
position_size=None,
current_positions=[],
trade_direction='both',
leverage=leverage,
initial_capital=initial_capital,
market_type=market_type,
market_category=market_category,
margin_mode='cross',
stop_loss_price=None,
take_profit_price=None,
execution_mode=execution_mode,
notification_config=notification_config,
trading_config=trading_config,
ai_model_config=ai_model_config,
signal_ts=int(current_time)
)
futures[future] = signal
# 等待所有交易完成
for future in as_completed(futures):
signal = futures[future]
try:
result = future.result(timeout=30)
if result:
logger.info(f"Successfully executed signal: {signal['symbol']} {signal['type']}")
except Exception as e:
logger.error(f"Failed to execute signal {signal['symbol']} {signal['type']}: {e}")
# 更新调仓时间
self._update_last_rebalance(strategy_id)
last_rebalance_time = current_time
except Exception as e:
logger.error(f"Cross-sectional strategy loop error: {e}")
logger.error(traceback.format_exc())
time.sleep(5) # Wait before retrying