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:
@@ -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
|
||||
Reference in New Issue
Block a user