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
@@ -427,6 +427,11 @@ def get_positions():
pct = _calc_pnl_percent(entry, size, pnl)
rr = dict(r)
# 确保 entry_price 有值(如果数据库中是 NULL,使用计算出的 entry 值)
if not rr.get("entry_price") or float(rr.get("entry_price") or 0.0) <= 0:
rr["entry_price"] = float(entry or 0.0)
else:
rr["entry_price"] = float(rr.get("entry_price") or 0.0)
rr["current_price"] = float(cp or 0.0)
rr["unrealized_pnl"] = float(pnl)
rr["pnl_percent"] = float(pct)
@@ -47,11 +47,51 @@ class BinanceFuturesClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
# Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize()
# Convert to string using fixed-point notation
# Use a reasonable max_decimals to avoid excessive precision
# Binance typically uses 8 decimal places for most symbols
s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed
# This ensures we don't send "0.02874400" when "0.028744" is sufficient
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
# Fallback: try to convert safely
try:
# If Decimal conversion fails, try float with limited precision
f = float(d)
if f == 0:
return "0"
# Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
# Last resort: convert to string
s = str(d)
# Try to remove scientific notation if present
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -237,12 +277,34 @@ class BinanceFuturesClient(BaseRestClient):
if step > 0:
q = self._floor_to_step(q, step)
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
# First try to get precision from metadata
qty_precision = None
try:
meta = fdict.get("_meta") or {}
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
if isinstance(meta, dict):
qty_precision = meta.get("quantityPrecision")
except Exception:
pass
# If precision not available, infer from stepSize
if qty_precision is None and step > 0:
try:
# stepSize like "0.001" means 3 decimal places
step_str = str(step).rstrip('0')
if '.' in step_str:
qty_precision = len(step_str.split('.')[1])
else:
# If stepSize is 1 or larger, precision is 0
qty_precision = 0
except Exception:
pass
# Apply precision limit
if qty_precision is not None:
q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty:
return Decimal("0")
return q
@@ -38,11 +38,51 @@ class BinanceSpotClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
# Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize()
# Convert to string using fixed-point notation
# Use a reasonable max_decimals to avoid excessive precision
# Binance typically uses 8 decimal places for most symbols
s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed
# This ensures we don't send "0.02874400" when "0.028744" is sufficient
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
# Fallback: try to convert safely
try:
# If Decimal conversion fails, try float with limited precision
f = float(d)
if f == 0:
return "0"
# Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
# Last resort: convert to string
s = str(d)
# Try to remove scientific notation if present
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -217,12 +257,34 @@ class BinanceSpotClient(BaseRestClient):
if step > 0:
q = self._floor_to_step(q, step)
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
# First try to get precision from metadata
qty_precision = None
try:
meta = fdict.get("_meta") or {}
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
if isinstance(meta, dict):
qty_precision = meta.get("quantityPrecision")
except Exception:
pass
# If precision not available, infer from stepSize
if qty_precision is None and step > 0:
try:
# stepSize like "0.001" means 3 decimal places
step_str = str(step).rstrip('0')
if '.' in step_str:
qty_precision = len(step_str.split('.')[1])
else:
# If stepSize is 1 or larger, precision is 0
qty_precision = 0
except Exception:
pass
# Apply precision limit
if qty_precision is not None:
q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty:
return Decimal("0")
return q
@@ -54,11 +54,39 @@ class BitgetMixClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Bitget requires quantities to match sizeStep/sizePlace precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -54,11 +54,39 @@ class BitgetSpotClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Bitget requires quantities to match quantityStep/quantityScale precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -61,11 +61,39 @@ class BybitClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Bybit requires quantities to match qtyStep precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -74,11 +74,39 @@ class DeepcoinClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Deepcoin requires quantities to match lotSz/qtyStep precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -52,14 +52,41 @@ class OkxClient(BaseRestClient):
self._lev_cache_ttl_sec = 60.0
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to a non-scientific string (OKX expects plain decimal strings).
Convert Decimal to a non-scientific string with controlled precision.
OKX expects plain decimal strings matching lotSz precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
# Normalize to remove unnecessary trailing zeros
normalized = d.normalize()
# Format with max_decimals and remove trailing zeros
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _to_dec(x: Any) -> Decimal:
@@ -559,6 +559,21 @@ class StrategyService:
initial_capital = (trading_config or {}).get('initial_capital') or payload.get('initial_capital') or 1000
leverage = (trading_config or {}).get('leverage') or 1
market_type = (trading_config or {}).get('market_type') or 'swap'
# Cross-sectional strategy fields (store in trading_config to avoid DB schema changes)
cs_strategy_type = payload.get('cs_strategy_type') or trading_config.get('cs_strategy_type') or 'single'
symbol_list = payload.get('symbol_list') or trading_config.get('symbol_list') or []
portfolio_size = payload.get('portfolio_size') or trading_config.get('portfolio_size') or 10
long_ratio = float(payload.get('long_ratio') or trading_config.get('long_ratio') or 0.5)
rebalance_frequency = payload.get('rebalance_frequency') or trading_config.get('rebalance_frequency') or 'daily'
# Store cross-sectional config in trading_config
if cs_strategy_type == 'cross_sectional':
trading_config['cs_strategy_type'] = cs_strategy_type
trading_config['symbol_list'] = symbol_list
trading_config['portfolio_size'] = portfolio_size
trading_config['long_ratio'] = long_ratio
trading_config['rebalance_frequency'] = rebalance_frequency
with get_db_connection() as db:
cur = db.cursor()
@@ -764,6 +779,18 @@ class StrategyService:
trading_config = payload.get('trading_config') if payload.get('trading_config') is not None else (existing.get('trading_config') or {})
exchange_config = payload.get('exchange_config') if payload.get('exchange_config') is not None else (existing.get('exchange_config') or {})
ai_model_config = payload.get('ai_model_config') if payload.get('ai_model_config') is not None else (existing.get('ai_model_config') or {})
# Handle cross-sectional strategy config updates
if payload.get('cs_strategy_type') is not None:
trading_config['cs_strategy_type'] = payload.get('cs_strategy_type')
if payload.get('symbol_list') is not None:
trading_config['symbol_list'] = payload.get('symbol_list')
if payload.get('portfolio_size') is not None:
trading_config['portfolio_size'] = payload.get('portfolio_size')
if payload.get('long_ratio') is not None:
trading_config['long_ratio'] = payload.get('long_ratio')
if payload.get('rebalance_frequency') is not None:
trading_config['rebalance_frequency'] = payload.get('rebalance_frequency')
symbol = (trading_config or {}).get('symbol')
timeframe = (trading_config or {}).get('timeframe')
@@ -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