diff --git a/backend_api_python/app/routes/strategy.py b/backend_api_python/app/routes/strategy.py index 17d7df5..f1ae410 100644 --- a/backend_api_python/app/routes/strategy.py +++ b/backend_api_python/app/routes/strategy.py @@ -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) diff --git a/backend_api_python/app/services/live_trading/binance.py b/backend_api_python/app/services/live_trading/binance.py index 17618d7..b0496f6 100644 --- a/backend_api_python/app/services/live_trading/binance.py +++ b/backend_api_python/app/services/live_trading/binance.py @@ -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 diff --git a/backend_api_python/app/services/live_trading/binance_spot.py b/backend_api_python/app/services/live_trading/binance_spot.py index f8ab3e8..3eb2d30 100644 --- a/backend_api_python/app/services/live_trading/binance_spot.py +++ b/backend_api_python/app/services/live_trading/binance_spot.py @@ -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 diff --git a/backend_api_python/app/services/live_trading/bitget.py b/backend_api_python/app/services/live_trading/bitget.py index aa59fb7..862b8ed 100644 --- a/backend_api_python/app/services/live_trading/bitget.py +++ b/backend_api_python/app/services/live_trading/bitget.py @@ -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: diff --git a/backend_api_python/app/services/live_trading/bitget_spot.py b/backend_api_python/app/services/live_trading/bitget_spot.py index 1afac2d..e8b972f 100644 --- a/backend_api_python/app/services/live_trading/bitget_spot.py +++ b/backend_api_python/app/services/live_trading/bitget_spot.py @@ -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: diff --git a/backend_api_python/app/services/live_trading/bybit.py b/backend_api_python/app/services/live_trading/bybit.py index 5a59d31..eb46072 100644 --- a/backend_api_python/app/services/live_trading/bybit.py +++ b/backend_api_python/app/services/live_trading/bybit.py @@ -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: diff --git a/backend_api_python/app/services/live_trading/deepcoin.py b/backend_api_python/app/services/live_trading/deepcoin.py index e869e4d..761073d 100644 --- a/backend_api_python/app/services/live_trading/deepcoin.py +++ b/backend_api_python/app/services/live_trading/deepcoin.py @@ -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: diff --git a/backend_api_python/app/services/live_trading/okx.py b/backend_api_python/app/services/live_trading/okx.py index 84d16e3..abbb830 100644 --- a/backend_api_python/app/services/live_trading/okx.py +++ b/backend_api_python/app/services/live_trading/okx.py @@ -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: diff --git a/backend_api_python/app/services/strategy.py b/backend_api_python/app/services/strategy.py index 6c59d50..8172f8f 100644 --- a/backend_api_python/app/services/strategy.py +++ b/backend_api_python/app/services/strategy.py @@ -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') diff --git a/backend_api_python/app/services/trading_executor.py b/backend_api_python/app/services/trading_executor.py index 77b9f03..0258de0 100644 --- a/backend_api_python/app/services/trading_executor.py +++ b/backend_api_python/app/services/trading_executor.py @@ -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 \ No newline at end of file diff --git a/backend_api_python/migrations/init.sql b/backend_api_python/migrations/init.sql index 7562e93..6c1f96f 100644 --- a/backend_api_python/migrations/init.sql +++ b/backend_api_python/migrations/init.sql @@ -162,6 +162,18 @@ CREATE INDEX IF NOT EXISTS idx_strategies_user_id ON qd_strategies_trading(user_ CREATE INDEX IF NOT EXISTS idx_strategies_status ON qd_strategies_trading(status); CREATE INDEX IF NOT EXISTS idx_strategies_group_id ON qd_strategies_trading(strategy_group_id); +-- Add last_rebalance_at column for cross-sectional strategies (if not exists) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'qd_strategies_trading' AND column_name = 'last_rebalance_at' + ) THEN + ALTER TABLE qd_strategies_trading ADD COLUMN last_rebalance_at TIMESTAMP; + RAISE NOTICE 'Added last_rebalance_at column to qd_strategies_trading'; + END IF; +END $$; + -- ============================================================================= -- 3. Strategy Positions -- ============================================================================= diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 46121dd..f88f142 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,79 @@ This document records version updates, new features, bug fixes, and database mig --- +## V2.1.3 (2026-02-XX) + +### 🚀 New Features + +#### Cross-Sectional Strategy Support +- **Multi-Symbol Portfolio Management** - Added support for cross-sectional strategies that manage a portfolio of multiple symbols simultaneously + - Strategy type selection: Single Symbol vs Cross-Sectional + - Symbol list configuration: Select multiple symbols for portfolio management + - Portfolio size: Configure the number of symbols to hold simultaneously + - Long/Short ratio: Set the proportion of long vs short positions (0-1) + - Rebalance frequency: Daily, Weekly, or Monthly portfolio rebalancing + - Indicator execution: Indicators receive a `data` dictionary (symbol -> DataFrame) for cross-symbol analysis + - Signal generation: Automatic buy/sell/close signals based on indicator rankings + - Parallel execution: Multiple orders executed concurrently for efficiency +- **Backend Implementation** + - Cross-sectional configurations stored in `trading_config` JSON field + - New `_run_cross_sectional_strategy_loop` method in TradingExecutor + - Automatic rebalancing based on configured frequency + - Support for both long and short positions in the same portfolio +- **Frontend UI** + - Strategy type selector in strategy creation/editing form + - Conditional display of single-symbol vs cross-sectional configuration fields + - Multi-select symbol picker for cross-sectional strategies + - Full i18n support (Chinese and English) + +See `docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md` or `docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md` for detailed usage instructions. + +### 🐛 Bug Fixes +- Fixed decimal precision issues in exchange order quantities (Binance Spot LOT_SIZE filter errors) +- Improved `_dec_str` method across all exchange clients for accurate quantity formatting +- Enhanced quantity normalization to respect exchange precision requirements +- Fixed validation logic for cross-sectional strategies (now validates correct symbol list field) +- Fixed success message to show correct strategy count for cross-sectional strategies + +### 📋 Database Migration + +**Run the following SQL on your PostgreSQL database before deploying V2.1.3:** + +```sql +-- ============================================================ +-- QuantDinger V2.1.3 Database Migration +-- Cross-Sectional Strategy Support +-- ============================================================ + +-- Add last_rebalance_at column to track rebalancing time for cross-sectional strategies +-- Note: Cross-sectional strategy configurations (symbol_list, portfolio_size, long_ratio, rebalance_frequency) +-- are stored in the trading_config JSON field, not as separate database columns. +-- This migration only adds the last_rebalance_at timestamp field which is needed for rebalancing logic. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'qd_strategies_trading' + AND column_name = 'last_rebalance_at' + ) THEN + ALTER TABLE qd_strategies_trading + ADD COLUMN last_rebalance_at TIMESTAMP; + RAISE NOTICE 'Added last_rebalance_at column to qd_strategies_trading'; + ELSE + RAISE NOTICE 'Column last_rebalance_at already exists'; + END IF; +END $$; +``` + +**Migration Notes:** +- This migration is safe to run multiple times (uses IF NOT EXISTS check) +- Cross-sectional strategy configurations are stored in the `trading_config` JSON field, so no additional columns are needed +- The `last_rebalance_at` field is used to track when the last rebalancing occurred for cross-sectional strategies +- If you don't run this migration, cross-sectional strategies will still work, but rebalancing frequency checks may not function correctly + +--- + ## V2.1.2 (2026-02-01) ### 🚀 New Features diff --git a/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md b/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md new file mode 100644 index 0000000..dc7acfc --- /dev/null +++ b/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md @@ -0,0 +1,223 @@ +# 截面策略使用指南 + +## 概述 + +截面策略(Cross-Sectional Strategy)是一种同时交易多个标的的策略类型。它根据某些因子对所有标的进行评分和排序,然后做多排名靠前的标的,做空排名靠后的标的。 + +## 功能特点 + +1. **多标的支持**:可以同时交易多个标的(股票、币种等) +2. **自动排序**:根据指标计算的评分自动排序标的 +3. **组合管理**:自动管理持仓组合,保持做多/做空比例 +4. **定期调仓**:支持每日/每周/每月调仓频率 +5. **批量执行**:并行执行多个标的的交易,提高效率 + +## 配置说明 + +### 策略配置参数 + +在创建或编辑策略时,需要在 `trading_config` 中添加以下参数: + +```json +{ + "cs_strategy_type": "cross_sectional", // 策略类型:'single' 或 'cross_sectional' + "symbol_list": [ // 标的列表 + "Crypto:BTC/USDT", + "Crypto:ETH/USDT", + "Crypto:BNB/USDT" + ], + "portfolio_size": 10, // 持仓组合大小(做多+做空的总数) + "long_ratio": 0.5, // 做多比例(0-1之间,0.5表示50%做多,50%做空) + "rebalance_frequency": "daily" // 调仓频率:'daily' | 'weekly' | 'monthly' +} +``` + +### 参数说明 + +- **cs_strategy_type**: + - `'single'`: 单标的策略(默认,原有功能) + - `'cross_sectional'`: 截面策略 + +- **symbol_list**: + - 标的列表,格式为 `["Market:SYMBOL", ...]` + - 例如:`["Crypto:BTC/USDT", "Crypto:ETH/USDT"]` + +- **portfolio_size**: + - 持仓组合大小,即同时持有的标的数量 + - 例如:10 表示同时持有10个标的 + +- **long_ratio**: + - 做多比例,0-1之间的浮点数 + - 例如:0.5 表示50%做多,50%做空 + - 例如:1.0 表示100%做多(不做空) + +- **rebalance_frequency**: + - 调仓频率 + - `'daily'`: 每日调仓 + - `'weekly'`: 每周调仓 + - `'monthly'`: 每月调仓 + +## 指标代码编写 + +截面策略的指标代码需要返回所有标的的评分和排序。 + +### 指标代码模板 + +```python +# 截面策略指标模板 +# 输入:data = {symbol1: df1, symbol2: df2, ...} +# 输出:scores = {symbol1: score1, symbol2: score2, ...} +# rankings = [symbol1, symbol2, ...] # 可选,如果不提供会根据scores自动排序 + +scores = {} +for symbol, df in data.items(): + # 计算每个标的的因子值 + # 例如:动量因子 + momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 + + # 例如:RSI指标 + def calculate_rsi(prices, period=14): + delta = prices.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + return rsi.iloc[-1] + + rsi = calculate_rsi(df['close'], 14) + + # 综合评分(可以根据需要调整权重) + score = momentum * 0.6 + (100 - rsi) * 0.4 + scores[symbol] = score + +# 可选:手动指定排序(如果不提供,系统会根据scores自动排序) +# rankings = sorted(scores.keys(), key=lambda x: scores[x], reverse=True) +``` + +### 指标代码环境变量 + +在指标代码执行时,可以使用以下变量: + +- `symbols`: 标的列表 `['Crypto:BTC/USDT', 'Crypto:ETH/USDT', ...]` +- `data`: 所有标的的K线数据 `{symbol: df, ...}` +- `scores`: 用于存储评分的字典(需要在代码中填充) +- `rankings`: 用于存储排序的列表(可选,如果不提供会根据scores自动排序) +- `np`: numpy +- `pd`: pandas +- `trading_config`: 交易配置 +- `config`: 交易配置(别名) + +### 输出要求 + +指标代码需要填充 `scores` 字典: + +```python +scores[symbol] = score_value # score_value 可以是任意数值 +``` + +可选:填充 `rankings` 列表(如果不提供,系统会根据scores自动排序): + +```python +rankings = [symbol1, symbol2, ...] # 按评分从高到低排序 +``` + +## 信号生成逻辑 + +系统会根据以下逻辑自动生成交易信号: + +1. **排序标的**:根据指标计算的评分对所有标的进行排序 +2. **选择持仓**: + - 排名靠前的 `portfolio_size * long_ratio` 个标的 → 做多 + - 排名靠后的 `portfolio_size * (1 - long_ratio)` 个标的 → 做空 +3. **生成信号**: + - 新增标的:如果标的不在当前持仓中,生成开仓信号 + - 移除标的:如果标的不在目标持仓中,生成平仓信号 + - 方向变更:如果标的需要从多转空或从空转多,先生成平仓信号,再生成开仓信号 + +## 使用示例 + +### 1. 创建截面策略 + +通过API创建策略时,在请求体中包含: + +```json +{ + "strategy_name": "动量截面策略", + "trading_config": { + "cs_strategy_type": "cross_sectional", + "symbol_list": [ + "Crypto:BTC/USDT", + "Crypto:ETH/USDT", + "Crypto:BNB/USDT", + "Crypto:ADA/USDT", + "Crypto:SOL/USDT" + ], + "portfolio_size": 5, + "long_ratio": 0.6, + "rebalance_frequency": "daily", + "timeframe": "1H", + "initial_capital": 10000, + "leverage": 1, + "market_type": "swap" + }, + "indicator_config": { + "indicator_id": 123, + "indicator_code": "..." + } +} +``` + +### 2. 指标代码示例 + +```python +# 动量+RSI综合评分 +scores = {} +for symbol, df in data.items(): + # 20周期动量 + momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 + + # RSI + delta = df['close'].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + rsi_value = rsi.iloc[-1] + + # 综合评分 + score = momentum * 0.7 + (100 - rsi_value) * 0.3 + scores[symbol] = score +``` + +## 注意事项 + +1. **数据获取**:系统会为每个标的获取K线数据,如果某个标的数据获取失败,会跳过该标的 +2. **调仓频率**:系统会根据 `rebalance_frequency` 设置检查是否需要调仓,未到调仓时间时不会执行交易 +3. **批量执行**:所有交易信号会并行执行,最多同时执行10个交易 +4. **持仓管理**:系统会自动管理持仓,确保持仓组合符合配置要求 +5. **兼容性**:截面策略功能不影响现有的单标的策略,两者可以共存 + +## 数据库迁移 + +如果需要使用数据库字段存储截面策略配置(可选),可以运行迁移脚本: + +```sql +-- 运行 migrations/add_cross_sectional_strategy.sql +``` + +如果不运行迁移脚本,截面策略配置会存储在 `trading_config` JSON字段中,功能完全正常。 + +## 故障排查 + +1. **策略不执行**: + - 检查 `cs_strategy_type` 是否为 `'cross_sectional'` + - 检查 `symbol_list` 是否不为空 + - 检查调仓频率是否已到时间 + +2. **指标执行失败**: + - 检查指标代码是否正确填充 `scores` 字典 + - 检查所有标的的数据是否都能正常获取 + +3. **信号不生成**: + - 检查 `portfolio_size` 是否小于等于 `symbol_list` 的长度 + - 检查评分是否有效 diff --git a/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md b/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md new file mode 100644 index 0000000..e735cfc --- /dev/null +++ b/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md @@ -0,0 +1,223 @@ +# Cross-Sectional Strategy Guide + +## Overview + +Cross-Sectional Strategy is a strategy type that trades multiple symbols simultaneously. It scores and ranks all symbols based on certain factors, then goes long on top-ranked symbols and short on bottom-ranked symbols. + +## Features + +1. **Multi-Symbol Support**: Can trade multiple symbols (stocks, cryptocurrencies, etc.) simultaneously +2. **Automatic Ranking**: Automatically ranks symbols based on indicator-calculated scores +3. **Portfolio Management**: Automatically manages portfolio positions, maintaining long/short ratios +4. **Periodic Rebalancing**: Supports daily/weekly/monthly rebalancing frequencies +5. **Batch Execution**: Executes trades for multiple symbols in parallel for improved efficiency + +## Configuration + +### Strategy Configuration Parameters + +When creating or editing a strategy, add the following parameters to `trading_config`: + +```json +{ + "cs_strategy_type": "cross_sectional", // Strategy type: 'single' or 'cross_sectional' + "symbol_list": [ // Symbol list + "Crypto:BTC/USDT", + "Crypto:ETH/USDT", + "Crypto:BNB/USDT" + ], + "portfolio_size": 10, // Portfolio size (total of long + short positions) + "long_ratio": 0.5, // Long ratio (0-1, 0.5 means 50% long, 50% short) + "rebalance_frequency": "daily" // Rebalancing frequency: 'daily' | 'weekly' | 'monthly' +} +``` + +### Parameter Description + +- **cs_strategy_type**: + - `'single'`: Single-symbol strategy (default, original functionality) + - `'cross_sectional'`: Cross-sectional strategy + +- **symbol_list**: + - List of symbols, format: `["Market:SYMBOL", ...]` + - Example: `["Crypto:BTC/USDT", "Crypto:ETH/USDT"]` + +- **portfolio_size**: + - Portfolio size, i.e., the number of symbols to hold simultaneously + - Example: 10 means holding 10 symbols at the same time + +- **long_ratio**: + - Long ratio, a float between 0 and 1 + - Example: 0.5 means 50% long, 50% short + - Example: 1.0 means 100% long (no short positions) + +- **rebalance_frequency**: + - Rebalancing frequency + - `'daily'`: Daily rebalancing + - `'weekly'`: Weekly rebalancing + - `'monthly'`: Monthly rebalancing + +## Indicator Code Writing + +Cross-sectional strategy indicator code needs to return scores and rankings for all symbols. + +### Indicator Code Template + +```python +# Cross-sectional strategy indicator template +# Input: data = {symbol1: df1, symbol2: df2, ...} +# Output: scores = {symbol1: score1, symbol2: score2, ...} +# rankings = [symbol1, symbol2, ...] # Optional, auto-sorted by scores if not provided + +scores = {} +for symbol, df in data.items(): + # Calculate factor values for each symbol + # Example: Momentum factor + momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 + + # Example: RSI indicator + def calculate_rsi(prices, period=14): + delta = prices.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + return rsi.iloc[-1] + + rsi = calculate_rsi(df['close'], 14) + + # Composite score (adjust weights as needed) + score = momentum * 0.6 + (100 - rsi) * 0.4 + scores[symbol] = score + +# Optional: Manually specify ranking (if not provided, system will auto-sort by scores) +# rankings = sorted(scores.keys(), key=lambda x: scores[x], reverse=True) +``` + +### Indicator Code Environment Variables + +The following variables are available when indicator code executes: + +- `symbols`: Symbol list `['Crypto:BTC/USDT', 'Crypto:ETH/USDT', ...]` +- `data`: K-line data for all symbols `{symbol: df, ...}` +- `scores`: Dictionary for storing scores (needs to be populated in code) +- `rankings`: List for storing rankings (optional, auto-sorted by scores if not provided) +- `np`: numpy +- `pd`: pandas +- `trading_config`: Trading configuration +- `config`: Trading configuration (alias) + +### Output Requirements + +Indicator code needs to populate the `scores` dictionary: + +```python +scores[symbol] = score_value # score_value can be any numeric value +``` + +Optional: Populate the `rankings` list (if not provided, system will auto-sort by scores): + +```python +rankings = [symbol1, symbol2, ...] # Sorted by score from high to low +``` + +## Signal Generation Logic + +The system automatically generates trading signals based on the following logic: + +1. **Rank Symbols**: Rank all symbols based on indicator-calculated scores +2. **Select Positions**: + - Top `portfolio_size * long_ratio` symbols → Long + - Bottom `portfolio_size * (1 - long_ratio)` symbols → Short +3. **Generate Signals**: + - New symbols: If a symbol is not in current positions, generate open signal + - Remove symbols: If a symbol is not in target positions, generate close signal + - Direction change: If a symbol needs to change from long to short or vice versa, first generate close signal, then open signal + +## Usage Examples + +### 1. Create Cross-Sectional Strategy + +When creating a strategy via API, include in the request body: + +```json +{ + "strategy_name": "Momentum Cross-Sectional Strategy", + "trading_config": { + "cs_strategy_type": "cross_sectional", + "symbol_list": [ + "Crypto:BTC/USDT", + "Crypto:ETH/USDT", + "Crypto:BNB/USDT", + "Crypto:ADA/USDT", + "Crypto:SOL/USDT" + ], + "portfolio_size": 5, + "long_ratio": 0.6, + "rebalance_frequency": "daily", + "timeframe": "1H", + "initial_capital": 10000, + "leverage": 1, + "market_type": "swap" + }, + "indicator_config": { + "indicator_id": 123, + "indicator_code": "..." + } +} +``` + +### 2. Indicator Code Example + +```python +# Momentum + RSI Composite Score +scores = {} +for symbol, df in data.items(): + # 20-period momentum + momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 + + # RSI + delta = df['close'].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + rsi_value = rsi.iloc[-1] + + # Composite score + score = momentum * 0.7 + (100 - rsi_value) * 0.3 + scores[symbol] = score +``` + +## Notes + +1. **Data Retrieval**: The system retrieves K-line data for each symbol. If data retrieval fails for a symbol, that symbol will be skipped. +2. **Rebalancing Frequency**: The system checks if rebalancing is needed based on `rebalance_frequency` settings. No trades will be executed if it's not time to rebalance. +3. **Batch Execution**: All trading signals are executed in parallel, with a maximum of 10 concurrent trades. +4. **Position Management**: The system automatically manages positions to ensure the portfolio meets configuration requirements. +5. **Compatibility**: Cross-sectional strategy functionality does not affect existing single-symbol strategies. Both can coexist. + +## Database Migration + +If you need to store cross-sectional strategy configuration in database fields (optional), you can run the migration script: + +```sql +-- Run migrations/add_cross_sectional_strategy.sql +``` + +If you don't run the migration script, cross-sectional strategy configuration will be stored in the `trading_config` JSON field, and functionality will work normally. + +## Troubleshooting + +1. **Strategy Not Executing**: + - Check if `cs_strategy_type` is `'cross_sectional'` + - Check if `symbol_list` is not empty + - Check if rebalancing frequency time has been reached + +2. **Indicator Execution Failed**: + - Check if indicator code correctly populates the `scores` dictionary + - Check if data for all symbols can be retrieved normally + +3. **Signals Not Generated**: + - Check if `portfolio_size` is less than or equal to the length of `symbol_list` + - Check if scores are valid diff --git a/docs/examples/cross_sectional_momentum_rsi.py b/docs/examples/cross_sectional_momentum_rsi.py new file mode 100644 index 0000000..30af6bf --- /dev/null +++ b/docs/examples/cross_sectional_momentum_rsi.py @@ -0,0 +1,67 @@ +# ============================================================ +# 截面策略指标示例 - 动量+RSI综合评分 +# Cross-Sectional Strategy Indicator Example +# Momentum + RSI Composite Score +# ============================================================ +# +# 使用方法: +# 1. 在交易助手中创建截面策略 +# 2. 选择此指标作为策略指标 +# 3. 配置标的列表、持仓大小、做多比例等参数 +# +# 评分逻辑: +# - 动量因子 (20周期): 价格变化率,越高越好 +# - RSI指标 (14周期): 反转RSI值,越低越好(100 - RSI) +# - 综合评分: 70% 动量 + 30% RSI反转值 +# +# ============================================================ + +# 截面策略指标 +# 输入: data = {symbol1: df1, symbol2: df2, ...} +# 输出: scores = {symbol1: score1, symbol2: score2, ...} + +scores = {} + +# Iterate through all symbols +for symbol, df in data.items(): + # Ensure we have enough data + if len(df) < 20: + scores[symbol] = 0 + continue + + # === 1. 计算动量因子 (20周期) === + # 动量 = (当前价格 / 20周期前价格 - 1) * 100 + momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 + + # === 2. 计算RSI指标 (14周期) === + def calculate_rsi(prices, period=14): + """计算RSI指标""" + delta = prices.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + return rsi.iloc[-1] + + rsi_value = calculate_rsi(df['close'], 14) + + # === 3. 综合评分 === + # 动量越高 = 评分越高 + # RSI越低(超卖)= 评分越高(100 - RSI) + # 权重: 70% 动量 + 30% RSI反转值 + momentum_score = momentum + rsi_score = 100 - rsi_value # 反转RSI(RSI越低,评分越高) + + composite_score = momentum_score * 0.7 + rsi_score * 0.3 + + scores[symbol] = composite_score + +# === 可选: 手动指定排序 === +# 如果不提供,系统会根据scores自动排序 +# rankings = sorted(scores.keys(), key=lambda x: scores[x], reverse=True) + +# === 系统自动处理逻辑 === +# 1. 根据评分对所有标的进行排序(从高到低) +# 2. 选择排名靠前的N个标的做多(基于 portfolio_size * long_ratio) +# 3. 选择排名靠后的N个标的做空(基于 portfolio_size * (1 - long_ratio)) +# 4. 自动生成买入/卖出/平仓信号 diff --git a/quantdinger_vue/src/locales/lang/en-US.js b/quantdinger_vue/src/locales/lang/en-US.js index a538184..46fe4a9 100644 --- a/quantdinger_vue/src/locales/lang/en-US.js +++ b/quantdinger_vue/src/locales/lang/en-US.js @@ -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', diff --git a/quantdinger_vue/src/locales/lang/zh-CN.js b/quantdinger_vue/src/locales/lang/zh-CN.js index c22a742..a8aa5dc 100644 --- a/quantdinger_vue/src/locales/lang/zh-CN.js +++ b/quantdinger_vue/src/locales/lang/zh-CN.js @@ -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': '通知渠道', diff --git a/quantdinger_vue/src/views/trading-assistant/components/PositionRecords.vue b/quantdinger_vue/src/views/trading-assistant/components/PositionRecords.vue index 10a6339..690a134 100644 --- a/quantdinger_vue/src/views/trading-assistant/components/PositionRecords.vue +++ b/quantdinger_vue/src/views/trading-assistant/components/PositionRecords.vue @@ -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, diff --git a/quantdinger_vue/src/views/trading-assistant/index.vue b/quantdinger_vue/src/views/trading-assistant/index.vue index be9b7f1..2c0d921 100644 --- a/quantdinger_vue/src/views/trading-assistant/index.vue +++ b/quantdinger_vue/src/views/trading-assistant/index.vue @@ -467,7 +467,103 @@ :placeholder="$t('trading-assistant.placeholders.inputStrategyName')" /> + + + + {{ $t('trading-assistant.form.strategyTypeSingle') }} + {{ $t('trading-assistant.form.strategyTypeCrossSectional') }} + +
+ {{ $t('trading-assistant.form.strategyTypeHint') }} +
+
+ + + + +