diff --git a/backend_api_python/app/routes/backtest.py b/backend_api_python/app/routes/backtest.py index bd21ecd..bd0522a 100644 --- a/backend_api_python/app/routes/backtest.py +++ b/backend_api_python/app/routes/backtest.py @@ -82,6 +82,46 @@ def _normalize_lang(lang: str | None) -> str: return l2 if l2 in supported else "zh-CN" +@backtest_bp.route('/backtest/precision-info', methods=['POST']) +def get_precision_info(): + """ + 获取回测精度信息(用于前端提示) + + Params: + market: 市场类型 + startDate: 开始日期 (YYYY-MM-DD) + endDate: 结束日期 (YYYY-MM-DD) + + Returns: + 精度信息,包含推荐的执行时间框架和预估K线数量 + """ + try: + data = request.get_json() + if not data: + return jsonify({'code': 0, 'msg': 'Request body is required'}), 400 + + market = data.get('market', 'crypto') + start_date_str = data.get('startDate', '') + end_date_str = data.get('endDate', '') + + if not start_date_str or not end_date_str: + return jsonify({'code': 0, 'msg': 'startDate and endDate are required'}), 400 + + start_date = datetime.strptime(start_date_str, '%Y-%m-%d') + end_date = datetime.strptime(end_date_str, '%Y-%m-%d') + + exec_tf, precision_info = backtest_service.get_execution_timeframe(start_date, end_date, market) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': precision_info + }) + except Exception as e: + logger.error(f"Get precision info failed: {e}") + return jsonify({'code': 0, 'msg': str(e)}), 400 + + @backtest_bp.route('/backtest', methods=['POST']) def run_backtest(): """ @@ -97,6 +137,7 @@ def run_backtest(): endDate: End date (YYYY-MM-DD) initialCapital: Initial capital (default 10000) commission: Commission rate (default 0.001) + enableMtf: Enable multi-timeframe backtest (default true, only for crypto) """ try: data = request.get_json() @@ -122,6 +163,10 @@ def run_backtest(): leverage = int(data.get('leverage', 1)) trade_direction = data.get('tradeDirection', 'long') # long, short, both strategy_config = data.get('strategyConfig') or {} + # 多时间框架回测开关(默认开启,仅加密货币市场有效) + enable_mtf = data.get('enableMtf', True) + if isinstance(enable_mtf, str): + enable_mtf = enable_mtf.lower() in ['true', '1', 'yes'] # (Debug) log received params if needed @@ -178,21 +223,46 @@ def run_backtest(): }), 400 - # 执行回测 - result = backtest_service.run( - indicator_code=indicator_code, - market=market, - symbol=symbol, - timeframe=timeframe, - start_date=start_date, - end_date=end_date, - initial_capital=initial_capital, - commission=commission, - slippage=slippage, - leverage=leverage, - trade_direction=trade_direction, - strategy_config=strategy_config - ) + # 执行回测(支持多时间框架高精度回测) + # 加密货币市场且启用MTF时,使用多时间框架回测 + if enable_mtf and market.lower() in ['crypto', 'cryptocurrency']: + result = backtest_service.run_multi_timeframe( + indicator_code=indicator_code, + market=market, + symbol=symbol, + timeframe=timeframe, + start_date=start_date, + end_date=end_date, + initial_capital=initial_capital, + commission=commission, + slippage=slippage, + leverage=leverage, + trade_direction=trade_direction, + strategy_config=strategy_config, + enable_mtf=True + ) + else: + result = backtest_service.run( + indicator_code=indicator_code, + market=market, + symbol=symbol, + timeframe=timeframe, + start_date=start_date, + end_date=end_date, + initial_capital=initial_capital, + commission=commission, + slippage=slippage, + leverage=leverage, + trade_direction=trade_direction, + strategy_config=strategy_config + ) + # 添加标准回测的精度信息 + result['precision_info'] = { + 'enabled': False, + 'timeframe': timeframe, + 'precision': 'standard', + 'message': '使用标准K线回测' + } # Persist backtest run for AI optimization / history run_id = None diff --git a/backend_api_python/app/routes/settings.py b/backend_api_python/app/routes/settings.py index 36e28f2..d9999e2 100644 --- a/backend_api_python/app/routes/settings.py +++ b/backend_api_python/app/routes/settings.py @@ -877,6 +877,82 @@ def save_settings(): return jsonify({'code': 0, 'msg': f'Save failed: {str(e)}'}) +@settings_bp.route('/openrouter-balance', methods=['GET']) +def get_openrouter_balance(): + """查询 OpenRouter 账户余额""" + try: + import requests + from app.config.api_keys import APIKeys + + api_key = APIKeys.OPENROUTER_API_KEY + if not api_key: + return jsonify({ + 'code': 0, + 'msg': 'OpenRouter API Key 未配置', + 'data': None + }) + + # 调用 OpenRouter API 查询余额 + # https://openrouter.ai/docs#limits + resp = requests.get( + 'https://openrouter.ai/api/v1/auth/key', + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json' + }, + timeout=10 + ) + + if resp.status_code == 200: + data = resp.json() + # OpenRouter 返回格式: {"data": {"label": "...", "usage": 0.0, "limit": null, ...}} + key_data = data.get('data', {}) + usage = key_data.get('usage', 0) # 已使用金额 + limit = key_data.get('limit') # 限额(可能为null表示无限制) + limit_remaining = key_data.get('limit_remaining') # 剩余额度 + is_free_tier = key_data.get('is_free_tier', False) + rate_limit = key_data.get('rate_limit', {}) + + return jsonify({ + 'code': 1, + 'msg': 'success', + 'data': { + 'usage': round(usage, 4), # 已使用(美元) + 'limit': limit, # 总限额 + 'limit_remaining': round(limit_remaining, 4) if limit_remaining is not None else None, # 剩余额度 + 'is_free_tier': is_free_tier, + 'rate_limit': rate_limit, + 'label': key_data.get('label', '') + } + }) + elif resp.status_code == 401: + return jsonify({ + 'code': 0, + 'msg': 'API Key 无效或已过期', + 'data': None + }) + else: + return jsonify({ + 'code': 0, + 'msg': f'查询失败: HTTP {resp.status_code}', + 'data': None + }) + + except requests.exceptions.Timeout: + return jsonify({ + 'code': 0, + 'msg': '请求超时,请检查网络连接', + 'data': None + }) + except Exception as e: + logger.error(f"Get OpenRouter balance failed: {e}") + return jsonify({ + 'code': 0, + 'msg': f'查询失败: {str(e)}', + 'data': None + }) + + @settings_bp.route('/test-connection', methods=['POST']) def test_connection(): """测试API连接""" diff --git a/backend_api_python/app/services/backtest.py b/backend_api_python/app/services/backtest.py index 8327da3..88b084f 100644 --- a/backend_api_python/app/services/backtest.py +++ b/backend_api_python/app/services/backtest.py @@ -1,5 +1,5 @@ """ -回测服务 +Backtest Service """ import math import traceback @@ -16,14 +16,904 @@ logger = get_logger(__name__) class BacktestService: - """回测服务""" + """Backtest Service""" - # 时间周期秒数 + # Timeframe in seconds TIMEFRAME_SECONDS = { '1m': 60, '5m': 300, '15m': 900, '30m': 1800, '1H': 3600, '4H': 14400, '1D': 86400, '1W': 604800 } + # Multi-timeframe backtest threshold configuration + # 1m backtest: max 1 month (~43,200 candles) + # 5m backtest: max 1 year (~105,120 candles) + MTF_CONFIG = { + 'max_1m_days': 30, # Max days for 1-minute backtest + 'max_5m_days': 365, # Max days for 5-minute backtest + 'default_exec_tf': '1m', # Default execution timeframe + 'fallback_exec_tf': '5m', # Fallback execution timeframe + } + + @staticmethod + def _infer_candle_path(open_: float, high: float, low: float, close: float) -> List[float]: + """ + Infer the price path within a candle. + + Determines the order of price movement based on open/close relationship: + - Bullish candle (close >= open): Open -> Low -> High -> Close (dip then rally) + - Bearish candle (close < open): Open -> High -> Low -> Close (rally then dip) + + Returns: + Price path list [price1, price2, price3, price4] + """ + if close >= open_: + # Bullish: dip first then rally + return [open_, low, high, close] + else: + # Bearish: rally first then dip + return [open_, high, low, close] + + def get_execution_timeframe(self, start_date: datetime, end_date: datetime, market: str = 'crypto') -> tuple: + """ + Automatically select execution timeframe based on backtest date range. + + Args: + start_date: Start date + end_date: End date + market: Market type + + Returns: + (execution_timeframe, precision_info) + - execution_timeframe: '1m' or '5m' + - precision_info: Precision info dict for frontend display + """ + days_diff = (end_date - start_date).days + + # Only crypto market supports high-precision backtest + if market.lower() not in ['crypto', 'cryptocurrency']: + return None, { + 'enabled': False, + 'reason': 'only_crypto', + 'message': 'High-precision backtest only supports cryptocurrency market' + } + + if days_diff <= self.MTF_CONFIG['max_1m_days']: + # Within 1 month: use 1-minute precision + estimated_candles = days_diff * 24 * 60 + return '1m', { + 'enabled': True, + 'timeframe': '1m', + 'days': days_diff, + 'estimated_candles': estimated_candles, + 'precision': 'high', + 'message': f'Using 1-minute precision backtest (~{estimated_candles:,} candles)' + } + elif days_diff <= self.MTF_CONFIG['max_5m_days']: + # 1 month to 1 year: use 5-minute precision + estimated_candles = days_diff * 24 * 12 + return '5m', { + 'enabled': True, + 'timeframe': '5m', + 'days': days_diff, + 'estimated_candles': estimated_candles, + 'precision': 'medium', + 'message': f'Range exceeds 30 days, using 5-minute precision (~{estimated_candles:,} candles)' + } + else: + # Over 1 year: high-precision backtest not supported + return None, { + 'enabled': False, + 'reason': 'too_long', + 'days': days_diff, + 'max_days': self.MTF_CONFIG['max_5m_days'], + 'message': f'Backtest range {days_diff} days exceeds max limit {self.MTF_CONFIG["max_5m_days"]} days' + } + + def run_multi_timeframe( + self, + indicator_code: str, + market: str, + symbol: str, + timeframe: str, + start_date: datetime, + end_date: datetime, + initial_capital: float = 10000.0, + commission: float = 0.001, + slippage: float = 0.0, + leverage: int = 1, + trade_direction: str = 'long', + strategy_config: Optional[Dict[str, Any]] = None, + enable_mtf: bool = True + ) -> Dict[str, Any]: + """ + Multi-timeframe backtest. + + Uses strategy timeframe for signal generation and execution timeframe (1m/5m) + for precise trade simulation. + + Args: + indicator_code: Indicator code + market: Market type + symbol: Trading symbol + timeframe: Strategy timeframe (for signal generation) + start_date: Start date + end_date: End date + initial_capital: Initial capital + commission: Commission rate + slippage: Slippage + leverage: Leverage + trade_direction: Trade direction + strategy_config: Strategy configuration + enable_mtf: Whether to enable multi-timeframe backtest + + Returns: + Backtest result with precision info + """ + # Get execution timeframe + exec_tf, precision_info = self.get_execution_timeframe(start_date, end_date, market) + + if not enable_mtf or not precision_info.get('enabled'): + # Fallback to standard candle backtest + result = self.run( + indicator_code=indicator_code, + market=market, + symbol=symbol, + timeframe=timeframe, + start_date=start_date, + end_date=end_date, + initial_capital=initial_capital, + commission=commission, + slippage=slippage, + leverage=leverage, + trade_direction=trade_direction, + strategy_config=strategy_config + ) + result['precision_info'] = precision_info or { + 'enabled': False, + 'timeframe': timeframe, + 'precision': 'standard', + 'message': 'Using standard candle backtest' + } + return result + + logger.info(f"Multi-timeframe backtest: strategy_tf={timeframe}, exec_tf={exec_tf}, range={start_date} ~ {end_date}") + + # 1. Fetch strategy timeframe candles (for signal generation) + df_signal = self._fetch_kline_data(market, symbol, timeframe, start_date, end_date) + if df_signal.empty: + raise ValueError("No candle data available in the backtest date range") + + # 2. Execute indicator code to get signals + backtest_params = { + 'leverage': leverage, + 'initial_capital': initial_capital, + 'commission': commission, + 'trade_direction': trade_direction + } + signals = self._execute_indicator(indicator_code, df_signal, backtest_params) + + # 3. Fetch execution timeframe candles (for precise trade simulation) + df_exec = self._fetch_kline_data(market, symbol, exec_tf, start_date, end_date) + if df_exec.empty: + logger.warning(f"Cannot fetch {exec_tf} candles, falling back to standard backtest") + result = self.run( + indicator_code=indicator_code, + market=market, + symbol=symbol, + timeframe=timeframe, + start_date=start_date, + end_date=end_date, + initial_capital=initial_capital, + commission=commission, + slippage=slippage, + leverage=leverage, + trade_direction=trade_direction, + strategy_config=strategy_config + ) + result['precision_info'] = { + 'enabled': False, + 'reason': 'data_unavailable', + 'message': f'Cannot fetch {exec_tf} data, using standard backtest' + } + return result + + logger.info(f"Data fetched: signal_candles={len(df_signal)}, exec_candles={len(df_exec)}") + + # 4. Use execution timeframe for precise trade simulation + equity_curve, trades, total_commission = self._simulate_trading_mtf( + df_signal=df_signal, + df_exec=df_exec, + signals=signals, + initial_capital=initial_capital, + commission=commission, + slippage=slippage, + leverage=leverage, + trade_direction=trade_direction, + strategy_config=strategy_config, + signal_timeframe=timeframe, + exec_timeframe=exec_tf + ) + + # 5. Calculate metrics + metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission) + + # 6. Format result + result = self._format_result(metrics, equity_curve, trades) + result['precision_info'] = precision_info + result['execution_timeframe'] = exec_tf + result['signal_candles'] = len(df_signal) + result['execution_candles'] = len(df_exec) + + return result + + def _simulate_trading_mtf( + self, + df_signal: pd.DataFrame, + df_exec: pd.DataFrame, + signals: dict, + initial_capital: float, + commission: float, + slippage: float, + leverage: int, + trade_direction: str, + strategy_config: Optional[Dict[str, Any]], + signal_timeframe: str, + exec_timeframe: str + ) -> tuple: + """ + Multi-timeframe trading simulation. + + Simulates trades candle by candle on execution timeframe, + using inferred candle price path to determine trigger order. + """ + equity_curve = [] + trades = [] + total_commission_paid = 0.0 + is_liquidated = False + min_capital_to_trade = 1.0 + + capital = initial_capital + position = 0 + entry_price = 0.0 + position_type = None # 'long' or 'short' + + # Parse strategy config + cfg = strategy_config or {} + risk_cfg = cfg.get('risk') or {} + stop_loss_pct = float(risk_cfg.get('stopLossPct') or 0.0) + take_profit_pct = float(risk_cfg.get('takeProfitPct') or 0.0) + trailing_cfg = risk_cfg.get('trailing') or {} + trailing_enabled = bool(trailing_cfg.get('enabled')) + trailing_pct = float(trailing_cfg.get('pct') or 0.0) + trailing_activation_pct = float(trailing_cfg.get('activationPct') or 0.0) + + lev = max(int(leverage or 1), 1) + stop_loss_pct_eff = stop_loss_pct / lev if stop_loss_pct > 0 else 0 + take_profit_pct_eff = take_profit_pct / lev if take_profit_pct > 0 else 0 + trailing_pct_eff = trailing_pct / lev if trailing_pct > 0 else 0 + trailing_activation_pct_eff = trailing_activation_pct / lev if trailing_activation_pct > 0 else 0 + + # If trailing stop enabled but no activation threshold set, use take profit threshold + if trailing_enabled and trailing_pct_eff > 0: + if trailing_activation_pct_eff <= 0 and take_profit_pct_eff > 0: + trailing_activation_pct_eff = take_profit_pct_eff + + # Entry percentage + pos_cfg = cfg.get('position') or {} + raw_entry_pct = pos_cfg.get('entryPct') + # If entryPct is None, 0, or not provided, default to 1.0 (100%) + if raw_entry_pct is None or raw_entry_pct == 0: + entry_pct_cfg = 1.0 + else: + entry_pct_cfg = float(raw_entry_pct) + if entry_pct_cfg > 1: + entry_pct_cfg = entry_pct_cfg / 100.0 + entry_pct_cfg = max(0.01, min(entry_pct_cfg, 1.0)) # Minimum 1% to avoid 0 position + + logger.info(f"Trading params: capital={capital}, leverage={lev}, entry_pct={entry_pct_cfg}, strategy_config={cfg}") + + highest_since_entry = None + lowest_since_entry = None + + # Normalize signal format + if not isinstance(signals, dict): + raise ValueError("signals must be a dict") + + # Debug: check signal index compatibility + signal_keys = list(signals.keys()) + logger.info(f"Signal keys: {signal_keys}") + if signal_keys: + first_key = signal_keys[0] + if hasattr(signals[first_key], 'index'): + sig_index = signals[first_key].index + df_index = df_signal.index + logger.info(f"Signal index len={len(sig_index)}, df_signal index len={len(df_index)}") + if len(sig_index) > 0 and len(df_index) > 0: + logger.info(f"Signal index first={sig_index[0]}, df_signal index first={df_index[0]}") + # Check if indices match + if not sig_index.equals(df_index): + logger.warning("Signal index does NOT match df_signal index! This may cause signal lookup failures.") + + # Check if trade_direction is 'both' mode + is_both_mode = str(trade_direction or 'both').lower() == 'both' + + if all(k in signals for k in ['open_long', 'close_long', 'open_short', 'close_short']): + norm_signals = signals + norm_signals['_both_mode'] = False # Explicit 4-signal mode, not both mode + elif all(k in signals for k in ['buy', 'sell']): + buy = signals['buy'].fillna(False).astype(bool) + sell = signals['sell'].fillna(False).astype(bool) + td = str(trade_direction or 'both').lower() + if td == 'long': + norm_signals = { + 'open_long': buy, 'close_long': sell, + 'open_short': pd.Series([False] * len(df_signal), index=df_signal.index), + 'close_short': pd.Series([False] * len(df_signal), index=df_signal.index), + } + elif td == 'short': + norm_signals = { + 'open_long': pd.Series([False] * len(df_signal), index=df_signal.index), + 'close_long': pd.Series([False] * len(df_signal), index=df_signal.index), + 'open_short': sell, 'close_short': buy, + } + else: + # Both mode: buy signal triggers long entry (close short if any, then open long) + # sell signal triggers short entry (close long if any, then open short) + # We use special signal types 'enter_long' and 'enter_short' to indicate + # that the signal should auto-close opposing position before opening + norm_signals = { + 'open_long': buy, 'close_long': pd.Series([False] * len(df_signal), index=df_signal.index), + 'open_short': sell, 'close_short': pd.Series([False] * len(df_signal), index=df_signal.index), + '_both_mode': True # Flag to indicate both mode for special handling + } + else: + raise ValueError("Invalid signal format") + + # Map signals to execution timeframe + # Strategy timeframe seconds (e.g. 1H=3600, 1D=86400) + signal_tf_seconds = self.TIMEFRAME_SECONDS.get(signal_timeframe, 3600) + exec_tf_seconds = self.TIMEFRAME_SECONDS.get(exec_timeframe, 60) + + logger.info(f"Signal timeframe: {signal_timeframe} ({signal_tf_seconds}s), Exec timeframe: {exec_timeframe} ({exec_tf_seconds}s)") + + # Preprocessing: create signal queue sorted by effective time + # Each signal executes at the open of the next execution candle after its candle closes + signal_queue = [] # [(effective_time, signal_type, signal_bar_time), ...] + + # Debug: check signal values + debug_signal_counts = {'open_long': 0, 'close_long': 0, 'open_short': 0, 'close_short': 0} + + for sig_time in df_signal.index: + # Signal candle end time = start time + period + sig_end = sig_time + timedelta(seconds=signal_tf_seconds) + + # Check if this signal candle has signals + # Use .loc[] instead of .get() to be more explicit + try: + ol = bool(norm_signals['open_long'].loc[sig_time]) if sig_time in norm_signals['open_long'].index else False + cl = bool(norm_signals['close_long'].loc[sig_time]) if sig_time in norm_signals['close_long'].index else False + os = bool(norm_signals['open_short'].loc[sig_time]) if sig_time in norm_signals['open_short'].index else False + cs = bool(norm_signals['close_short'].loc[sig_time]) if sig_time in norm_signals['close_short'].index else False + except Exception as e: + logger.warning(f"Error accessing signal at {sig_time}: {e}") + continue + + if ol: + signal_queue.append((sig_end, 'open_long', sig_time)) + debug_signal_counts['open_long'] += 1 + if cl: + signal_queue.append((sig_end, 'close_long', sig_time)) + debug_signal_counts['close_long'] += 1 + if os: + signal_queue.append((sig_end, 'open_short', sig_time)) + debug_signal_counts['open_short'] += 1 + if cs: + signal_queue.append((sig_end, 'close_short', sig_time)) + debug_signal_counts['close_short'] += 1 + + logger.info(f"Debug signal counts from queue building: {debug_signal_counts}") + + # Sort by effective time + signal_queue.sort(key=lambda x: x[0]) + signal_queue_idx = 0 # Current signal queue pointer + + logger.info(f"Signal queue built: total {len(signal_queue)} signals") + if signal_queue: + logger.info(f"First signal: {signal_queue[0][1]} @ {signal_queue[0][0]} (from {signal_queue[0][2]})") + logger.info(f"Last signal: {signal_queue[-1][1]} @ {signal_queue[-1][0]} (from {signal_queue[-1][2]})") + + # Count signals by type + signal_counts = {} + for _, sig_type, _ in signal_queue: + signal_counts[sig_type] = signal_counts.get(sig_type, 0) + 1 + logger.info(f"Signal counts: {signal_counts}") + + # Log execution data range + if len(df_exec) > 0: + exec_start = df_exec.index[0] + exec_end = df_exec.index[-1] + logger.info(f"Exec data range: {exec_start} ~ {exec_end}") + # Check first few candles for data validity + first_row = df_exec.iloc[0] + logger.info(f"First exec candle: open={first_row['open']}, high={first_row['high']}, low={first_row['low']}, close={first_row['close']}") + + # Current pending signal to execute + pending_signal = None # ('open_long', 'close_long', 'open_short', 'close_short') + pending_signal_time = None # Signal effective time + executed_trades_count = 0 # Debug counter + + for i, (timestamp, row) in enumerate(df_exec.iterrows()): + # 爆仓后直接停止回测,输出结果 + if is_liquidated: + break + + if position == 0 and capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + continue + + open_ = row['open'] + high = row['high'] + low = row['low'] + close = row['close'] + + # Use inferred candle price path to determine trigger order + price_path = self._infer_candle_path(open_, high, low, close) + + # Check if new signal becomes effective + # Signal executes at the first execution candle open after its candle closes + while signal_queue_idx < len(signal_queue): + sig_effective_time, sig_type, sig_bar_time = signal_queue[signal_queue_idx] + + # Debug: log first few signal checks + if i < 10 and signal_queue_idx < len(signal_queue): + logger.debug(f"[i={i}] Checking signal #{signal_queue_idx}: {sig_type} @ {sig_effective_time}, exec_time={timestamp}, position={position}") + + # If current exec candle time >= signal effective time, signal can execute + if timestamp >= sig_effective_time: + # Check if signal can execute (based on current position) + # In both mode, open_long can execute even with short position (will auto-close first) + # Similarly, open_short can execute even with long position + can_execute = False + both_mode_active = norm_signals.get('_both_mode', False) + + if sig_type == 'open_long': + if position == 0: + can_execute = True + elif both_mode_active and position < 0: + # Both mode: have short position, will close short then open long + can_execute = True + elif sig_type == 'close_long' and position > 0: + can_execute = True + elif sig_type == 'open_short': + if position == 0: + can_execute = True + elif both_mode_active and position > 0: + # Both mode: have long position, will close long then open short + can_execute = True + elif sig_type == 'close_short' and position < 0: + can_execute = True + + if can_execute: + pending_signal = sig_type + pending_signal_time = sig_effective_time + signal_queue_idx += 1 + if executed_trades_count < 5: + logger.info(f"Signal ready: {sig_type} @ {timestamp}, will execute at open price (both_mode={both_mode_active})") + break + else: + # Signal doesn't meet execution conditions, skip + if signal_queue_idx < 5: + logger.info(f"Skipping signal #{signal_queue_idx}: {sig_type} (position={position}, can_execute=False)") + signal_queue_idx += 1 + continue + else: + # Not yet at signal effective time + break + + # Check trigger conditions along price path + for path_price in price_path: + if is_liquidated: + break + + # 1. Check stop-loss/take-profit/trailing stop (highest priority) + if position != 0 and position_type in ['long', 'short']: + triggered = False + + if position_type == 'long' and position > 0: + if highest_since_entry is None: + highest_since_entry = entry_price + highest_since_entry = max(highest_since_entry, path_price) + + # Stop loss + if stop_loss_pct_eff > 0: + sl_price = entry_price * (1 - stop_loss_pct_eff) + if path_price <= sl_price: + exec_price = sl_price * (1 - slippage) + commission_fee = position * exec_price * commission + profit = (exec_price - entry_price) * position - commission_fee + capital += profit + if capital < 0: + capital = 0 + is_liquidated = True + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long_stop', + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + highest_since_entry = None + lowest_since_entry = None + triggered = True + + # Trailing stop + if not triggered and trailing_enabled and trailing_pct_eff > 0: + trail_active = True + if trailing_activation_pct_eff > 0: + trail_active = highest_since_entry >= entry_price * (1 + trailing_activation_pct_eff) + if trail_active: + tr_price = highest_since_entry * (1 - trailing_pct_eff) + if path_price <= tr_price: + exec_price = tr_price * (1 - slippage) + commission_fee = position * exec_price * commission + profit = (exec_price - entry_price) * position - commission_fee + capital += profit + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long_trailing', + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + highest_since_entry = None + lowest_since_entry = None + triggered = True + + # Fixed take profit (disabled when trailing stop is enabled) + if not triggered and not trailing_enabled and take_profit_pct_eff > 0: + tp_price = entry_price * (1 + take_profit_pct_eff) + if path_price >= tp_price: + exec_price = tp_price * (1 - slippage) + commission_fee = position * exec_price * commission + profit = (exec_price - entry_price) * position - commission_fee + capital += profit + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long_profit', + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + highest_since_entry = None + lowest_since_entry = None + triggered = True + + elif position_type == 'short' and position < 0: + shares = abs(position) + if lowest_since_entry is None: + lowest_since_entry = entry_price + lowest_since_entry = min(lowest_since_entry, path_price) + + # Stop loss + if stop_loss_pct_eff > 0: + sl_price = entry_price * (1 + stop_loss_pct_eff) + if path_price >= sl_price: + exec_price = sl_price * (1 + slippage) + commission_fee = shares * exec_price * commission + profit = (entry_price - exec_price) * shares - commission_fee + if capital + profit <= 0: + capital = 0 + is_liquidated = True + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + else: + capital += profit + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short_stop', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + highest_since_entry = None + lowest_since_entry = None + triggered = True + + # Trailing stop + if not triggered and trailing_enabled and trailing_pct_eff > 0: + trail_active = True + if trailing_activation_pct_eff > 0: + trail_active = lowest_since_entry <= entry_price * (1 - trailing_activation_pct_eff) + if trail_active: + tr_price = lowest_since_entry * (1 + trailing_pct_eff) + if path_price >= tr_price: + exec_price = tr_price * (1 + slippage) + commission_fee = shares * exec_price * commission + profit = (entry_price - exec_price) * shares - commission_fee + if capital + profit <= 0: + capital = 0 + is_liquidated = True + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'liquidation', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(-initial_capital, 2), + 'balance': 0 + }) + else: + capital += profit + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short_trailing', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + highest_since_entry = None + lowest_since_entry = None + triggered = True + + # Fixed take profit + if not triggered and not trailing_enabled and take_profit_pct_eff > 0: + tp_price = entry_price * (1 - take_profit_pct_eff) + if path_price <= tp_price: + exec_price = tp_price * (1 + slippage) + commission_fee = shares * exec_price * commission + profit = (entry_price - exec_price) * shares - commission_fee + capital += profit + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short_profit', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + highest_since_entry = None + lowest_since_entry = None + triggered = True + + if triggered: + pending_signal = None + continue + + # 2. Execute pending signal (at open price) + if pending_signal and path_price == open_: + both_mode_active = norm_signals.get('_both_mode', False) + + # open_long: In both mode, first close short if any, then open long + if pending_signal == 'open_long' and (position == 0 or (both_mode_active and position < 0)): + exec_price = open_ * (1 + slippage) + + # If in both mode and have short position, close it first + if both_mode_active and position < 0: + shares_to_close = abs(position) + close_price = open_ * (1 + slippage) + close_commission = shares_to_close * close_price * commission + close_profit = (entry_price - close_price) * shares_to_close - close_commission + capital += close_profit + if capital < 0: + capital = 0 + total_commission_paid += close_commission + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short', + 'price': round(close_price, 4), + 'amount': round(shares_to_close, 4), + 'profit': round(close_profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + executed_trades_count += 1 + if executed_trades_count <= 10: + logger.info(f"Trade #{executed_trades_count}: close_short (before open_long) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}") + # 检查是否爆仓 + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + pending_signal = None + continue + + # Now open long + use_capital = capital * entry_pct_cfg + if exec_price > 0: + shares = (use_capital * lev) / exec_price + else: + logger.warning(f"Invalid exec_price={exec_price} at {timestamp}, skipping open_long") + pending_signal = None + continue + commission_fee = shares * exec_price * commission + capital -= commission_fee + total_commission_paid += commission_fee + position = shares + entry_price = exec_price + position_type = 'long' + highest_since_entry = exec_price + lowest_since_entry = exec_price + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_long', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(max(0, capital), 2) + }) + executed_trades_count += 1 + if executed_trades_count <= 10: + logger.info(f"Trade #{executed_trades_count}: open_long @ {timestamp}, price={exec_price:.4f}, shares={shares:.4f}") + pending_signal = None + + elif pending_signal == 'close_long' and position > 0: + exec_price = open_ * (1 - slippage) + commission_fee = position * exec_price * commission + profit = (exec_price - entry_price) * position - commission_fee + capital += profit + if capital < 0: + capital = 0 + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long', + 'price': round(exec_price, 4), + 'amount': round(position, 4), + 'profit': round(profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + highest_since_entry = None + lowest_since_entry = None + pending_signal = None + # 检查是否爆仓 + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + + # open_short: In both mode, first close long if any, then open short + elif pending_signal == 'open_short' and (position == 0 or (both_mode_active and position > 0)): + exec_price = open_ * (1 - slippage) + + # If in both mode and have long position, close it first + if both_mode_active and position > 0: + close_price = open_ * (1 - slippage) + close_commission = position * close_price * commission + close_profit = (close_price - entry_price) * position - close_commission + capital += close_profit + if capital < 0: + capital = 0 + total_commission_paid += close_commission + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long', + 'price': round(close_price, 4), + 'amount': round(position, 4), + 'profit': round(close_profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + executed_trades_count += 1 + if executed_trades_count <= 10: + logger.info(f"Trade #{executed_trades_count}: close_long (before open_short) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}") + # 检查是否爆仓 + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + pending_signal = None + continue + + # Now open short + use_capital = capital * entry_pct_cfg + if exec_price > 0: + shares = (use_capital * lev) / exec_price + else: + logger.warning(f"Invalid exec_price={exec_price} at {timestamp}, skipping open_short") + pending_signal = None + continue + commission_fee = shares * exec_price * commission + capital -= commission_fee + total_commission_paid += commission_fee + position = -shares + entry_price = exec_price + position_type = 'short' + highest_since_entry = exec_price + lowest_since_entry = exec_price + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'open_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': 0, + 'balance': round(max(0, capital), 2) + }) + executed_trades_count += 1 + if executed_trades_count <= 10: + logger.info(f"Trade #{executed_trades_count}: open_short @ {timestamp}, price={exec_price:.4f}, shares={shares:.4f}") + pending_signal = None + + elif pending_signal == 'close_short' and position < 0: + shares = abs(position) + exec_price = open_ * (1 + slippage) + commission_fee = shares * exec_price * commission + profit = (entry_price - exec_price) * shares - commission_fee + capital += profit + if capital < 0: + capital = 0 + total_commission_paid += commission_fee + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short', + 'price': round(exec_price, 4), + 'amount': round(shares, 4), + 'profit': round(profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + highest_since_entry = None + lowest_since_entry = None + pending_signal = None + # 检查是否爆仓 + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + + # Calculate current equity + if position > 0: + unrealized = (close - entry_price) * position + current_equity = capital + unrealized + elif position < 0: + shares = abs(position) + unrealized = (entry_price - close) * shares + current_equity = capital + unrealized + else: + current_equity = capital + + equity_curve.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'value': round(max(0, current_equity), 2) + }) + + # Summary log + logger.info(f"MTF simulation complete: executed_trades={executed_trades_count}, total_trades_recorded={len(trades)}, final_capital={capital:.2f}") + if len(trades) == 0: + logger.warning(f"No trades executed! signal_queue_idx={signal_queue_idx}, total_signals={len(signal_queue)}") + + return equity_curve, trades, total_commission_paid + def run_code_strategy( self, code: str, @@ -32,29 +922,29 @@ class BacktestService: limit: int = 1000 ) -> Dict[str, Any]: """ - 运行策略代码并返回代码中定义的 'output' 变量。 - 用于信号机器人的预览功能。 + Run strategy code and return the 'output' variable defined in code. + Used for signal bot preview functionality. """ - # 1. 计算时间范围 + # 1. Calculate time range end_date = datetime.now() tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 3600) start_date = end_date - timedelta(seconds=tf_seconds * limit) - # 2. 获取数据 (假设 market='crypto',后续可优化) + # 2. Fetch data (assuming market='crypto', can be optimized later) df = self._fetch_kline_data('crypto', symbol, timeframe, start_date, end_date) if df.empty: return {"error": "No data found"} - # 3. 准备执行环境 + # 3. Prepare execution environment local_vars = { 'df': df.copy(), 'np': np, 'pd': pd, - 'output': {} # 默认空输出 + 'output': {} # Default empty output } - # 4. 执行代码 + # 4. Execute code try: import builtins def safe_import(name, *args, **kwargs): @@ -89,36 +979,36 @@ class BacktestService: end_date: datetime, initial_capital: float = 10000.0, commission: float = 0.001, - slippage: float = 0.0, # 理想回测环境,不考虑滑点 + slippage: float = 0.0, # Ideal backtest environment, no slippage leverage: int = 1, trade_direction: str = 'long', strategy_config: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ - 运行回测 + Run backtest. Args: - indicator_code: 指标代码 - market: 市场类型 - symbol: 交易标的 - timeframe: 时间周期 - start_date: 开始日期 - end_date: 结束日期 - initial_capital: 初始资金 - commission: 手续费率 - slippage: 滑点 + indicator_code: Indicator code + market: Market type + symbol: Trading symbol + timeframe: Timeframe + start_date: Start date + end_date: End date + initial_capital: Initial capital + commission: Commission rate + slippage: Slippage Returns: - 回测结果 + Backtest result """ - # 1. 获取K线数据 + # 1. Fetch candle data df = self._fetch_kline_data(market, symbol, timeframe, start_date, end_date) if df.empty: - raise ValueError("回测日期范围内没有K线数据") + raise ValueError("No candle data available in the backtest date range") - # 2. 执行指标代码获取信号(传入回测参数) + # 2. Execute indicator code to get signals (pass backtest params) backtest_params = { 'leverage': leverage, 'initial_capital': initial_capital, @@ -127,15 +1017,15 @@ class BacktestService: } signals = self._execute_indicator(indicator_code, df, backtest_params) - # 3. 模拟交易 + # 3. Simulate trading equity_curve, trades, total_commission = self._simulate_trading( df, signals, initial_capital, commission, slippage, leverage, trade_direction, strategy_config ) - # 4. 计算指标 + # 4. Calculate metrics metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission) - # 5. 格式化结果 + # 5. Format result return self._format_result(metrics, equity_curve, trades) def _fetch_kline_data( @@ -146,17 +1036,17 @@ class BacktestService: start_date: datetime, end_date: datetime ) -> pd.DataFrame: - """获取K线数据并转换为DataFrame""" - # 计算需要的K线数量 + """Fetch candle data and convert to DataFrame""" + # Calculate required candle count total_seconds = (end_date - start_date).total_seconds() tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 86400) limit = math.ceil(total_seconds / tf_seconds) + 200 - # 计算before_time(结束日期+1天) + # Calculate before_time (end date + 1 day) before_time = int((end_date + timedelta(days=1)).timestamp()) - # 获取数据 + # Fetch data kline_data = DataSourceFactory.get_kline( market=market, symbol=symbol, @@ -166,14 +1056,14 @@ class BacktestService: ) if not kline_data: - logger.warning("未获取到K线数据") + logger.warning("No candle data retrieved") return pd.DataFrame() if kline_data: first_time = datetime.fromtimestamp(kline_data[0]['time']) last_time = datetime.fromtimestamp(kline_data[-1]['time']) - # 转换为DataFrame + # Convert to DataFrame df = pd.DataFrame(kline_data) df['time'] = pd.to_datetime(df['time'], unit='s') df = df.set_index('time') @@ -181,7 +1071,7 @@ class BacktestService: if len(df) > 0: pass - # 过滤日期范围 + # Filter date range df = df[(df.index >= start_date) & (df.index <= end_date)].copy() if len(df) > 0: @@ -190,12 +1080,12 @@ class BacktestService: return df def _execute_indicator(self, code: str, df: pd.DataFrame, backtest_params: dict = None): - """执行指标代码获取信号 + """Execute indicator code to get signals. Args: - code: 指标代码 - df: K线数据 - backtest_params: 回测参数字典(leverage, initial_capital, commission, trade_direction) + code: Indicator code + df: Candle data + backtest_params: Backtest parameters dict (leverage, initial_capital, commission, trade_direction) """ # Supported indicator signal formats: # - Preferred (simple): df['buy'], df['sell'] as boolean @@ -203,7 +1093,7 @@ class BacktestService: signals = pd.Series(0, index=df.index) try: - # 准备执行环境 + # Prepare execution environment local_vars = { 'df': df.copy(), 'open': df['open'], @@ -216,7 +1106,7 @@ class BacktestService: 'pd': pd, } - # 添加回测参数到执行环境(如果提供了) + # Add backtest params to execution environment (if provided) if backtest_params: local_vars['backtest_params'] = backtest_params local_vars['leverage'] = backtest_params.get('leverage', 1) @@ -224,20 +1114,20 @@ class BacktestService: local_vars['commission'] = backtest_params.get('commission', 0.0002) local_vars['trade_direction'] = backtest_params.get('trade_direction', 'both') - # 添加技术指标函数 + # Add technical indicator functions local_vars.update(self._get_indicator_functions()) - # 添加安全的内置函数(保留完整的 builtins 以支持 lambda 等语法) - # 但移除危险的函数如 eval, exec, open 等 + # Add safe builtins (keep full builtins to support lambda etc.) + # but remove dangerous functions like eval, exec, open etc. import builtins - # 创建受限的 __import__ 函数,只允许导入已经加载的安全模块 + # Create restricted __import__ that only allows safe modules def safe_import(name, *args, **kwargs): - """只允许导入 numpy, pandas, math, json 等安全模块""" + """Only allow importing numpy, pandas, math, json etc.""" allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time'] if name in allowed_modules or name.split('.')[0] in allowed_modules: return builtins.__import__(name, *args, **kwargs) - raise ImportError(f"不允许导入模块: {name}") + raise ImportError(f"Import not allowed: {name}") safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) if not k.startswith('_') and k not in [ @@ -246,39 +1136,39 @@ class BacktestService: 'copyright', 'credits', 'license' ]} - # 添加受限的 __import__ + # Add restricted __import__ safe_builtins['__import__'] = safe_import - # 创建统一的执行环境(globals 和 locals 使用同一个字典) - # 这样函数内部才能访问到 np, pd 等变量 + # Create unified execution environment (globals and locals use same dict) + # This allows functions to access np, pd etc. exec_env = local_vars.copy() exec_env['__builtins__'] = safe_builtins - # 预执行 import 语句,确保 np 和 pd 可用 + # Pre-execute import statements to ensure np and pd are available pre_import_code = """ import numpy as np import pandas as pd """ exec(pre_import_code, exec_env) - # 安全检查:验证代码不包含危险操作 + # Security check: validate code doesn't contain dangerous operations from app.utils.safe_exec import validate_code_safety is_safe, error_msg = validate_code_safety(code) if not is_safe: - logger.error(f"回测代码安全检查失败: {error_msg}") - raise ValueError(f"代码包含不安全操作: {error_msg}") + logger.error(f"Backtest code security check failed: {error_msg}") + raise ValueError(f"Code contains unsafe operations: {error_msg}") - # 安全执行用户代码(带超时) + # Execute user code safely (with timeout) from app.utils.safe_exec import safe_exec_code exec_result = safe_exec_code( code=code, exec_globals=exec_env, exec_locals=exec_env, - timeout=60 # 回测允许更长时间(60秒) + timeout=60 # Backtest allows longer time (60 seconds) ) if not exec_result['success']: - raise RuntimeError(f"代码执行失败: {exec_result['error']}") + raise RuntimeError(f"Code execution failed: {exec_result['error']}") # Get the executed df executed_df = exec_env.get('df', df) @@ -320,13 +1210,13 @@ import pandas as pd ) except Exception as e: - logger.error(f"指标代码执行错误: {e}") + logger.error(f"Indicator code execution error: {e}") logger.error(traceback.format_exc()) return signals def _get_indicator_functions(self) -> Dict: - """获取技术指标函数""" + """Get technical indicator functions""" def SMA(series, period): return series.rolling(window=period).mean() @@ -391,14 +1281,14 @@ import pandas as pd strategy_config: Optional[Dict[str, Any]] = None ) -> tuple: """ - 模拟交易 + Simulate trading. Args: - signals: 信号,可以是 pd.Series (旧格式) 或 dict (新格式四种信号) - trade_direction: 交易方向 - - 'long': 只做多 (buy->sell) - - 'short': 只做空 (sell->buy, 收益反向) - - 'both': 双向 (buy->sell做多 + sell->buy做空) + signals: Signals, can be pd.Series (old format) or dict (new 4-way format) + trade_direction: Trade direction + - 'long': Long only (buy->sell) + - 'short': Short only (sell->buy, reversed PnL) + - 'both': Both directions (buy->sell long + sell->buy short) """ # Normalize supported signal formats into 4-way signals. if not isinstance(signals, dict): @@ -432,13 +1322,17 @@ import pandas as pd 'close_long': pd.Series([False] * len(df), index=df.index), 'open_short': sell, 'close_short': buy, + '_both_mode': False, } else: + # Both mode: buy signal opens long (auto-close short first) + # sell signal opens short (auto-close long first) norm = { 'open_long': buy, - 'close_long': sell, + 'close_long': pd.Series([False] * len(df), index=df.index), # Disabled, handled by open_short 'open_short': sell, - 'close_short': buy, + 'close_short': pd.Series([False] * len(df), index=df.index), # Disabled, handled by open_long + '_both_mode': True, # Flag to indicate auto-close opposing position } else: raise ValueError("signals dict must contain either 4-way keys or buy/sell keys.") @@ -457,26 +1351,26 @@ import pandas as pd strategy_config: Optional[Dict[str, Any]] = None ) -> tuple: """ - 使用新格式四种信号进行交易模拟(支持仓位管理和加仓) + Simulate trading with 4-way signal format (supports position management and scaling). Args: - trade_direction: 交易方向 ('long', 'short', 'both') + trade_direction: Trade direction ('long', 'short', 'both') """ equity_curve = [] trades = [] total_commission_paid = 0 is_liquidated = False liquidation_price = 0 - min_capital_to_trade = 1.0 # 余额低于该值则视为赔光,不再开新单 + min_capital_to_trade = 1.0 # Below this balance, consider wiped out, no new orders capital = initial_capital - position = 0 # 正数=多头持仓,负数=空头持仓 - entry_price = 0 # 平均开仓价格 + position = 0 # Positive=long, Negative=short + entry_price = 0 # Average entry price position_type = None # 'long' or 'short' - # 仓位管理相关 + # Position management related has_position_management = 'add_long' in signals and 'add_short' in signals - position_batches = [] # 存储每批持仓:[{'price': xxx, 'amount': xxx}, ...] + position_batches = [] # Store each position batch: [{'price': xxx, 'amount': xxx}, ...] # --- Strategy config: signals + parameters = strategy (sent from BacktestModal as strategyConfig) --- cfg = strategy_config or {} @@ -554,8 +1448,8 @@ import pandas as pd adverse_reduce_size_pct = float(adverse_reduce_cfg.get('sizePct') or 0.0) adverse_reduce_max_times = int(adverse_reduce_cfg.get('maxTimes') or 0) - # 触发百分比按“杠杆后的保证金阈值”理解:换算为价格触发阈值需要除以杠杆倍数 - # 例如 10x + 5% 触发,意味着约 0.5% 的价格波动触发 + # Trigger pct as post-leverage margin threshold: divide by leverage for price trigger + # e.g. 10x + 5% trigger means ~0.5% price movement trend_add_step_pct_eff = trend_add_step_pct / lev dca_add_step_pct_eff = dca_add_step_pct / lev trend_reduce_step_pct_eff = trend_reduce_step_pct / lev @@ -573,7 +1467,7 @@ import pandas as pd last_trend_reduce_anchor = None last_adverse_reduce_anchor = None - # 转换信号为数组 + # Convert signals to arrays open_long_arr = signals['open_long'].values close_long_arr = signals['close_long'].values open_short_arr = signals['open_short'].values @@ -587,51 +1481,48 @@ import pandas as pd open_short_arr = np.insert(open_short_arr[:-1], 0, False) close_short_arr = np.insert(close_short_arr[:-1], 0, False) - # 根据交易方向过滤信号 + # Filter signals by trade direction if trade_direction == 'long': - # 只做多:禁用所有做空信号 + # Long only: disable all short signals open_short_arr = np.zeros(len(df), dtype=bool) close_short_arr = np.zeros(len(df), dtype=bool) elif trade_direction == 'short': - # 只做空:禁用所有做多信号 + # Short only: disable all long signals open_long_arr = np.zeros(len(df), dtype=bool) close_long_arr = np.zeros(len(df), dtype=bool) else: pass - # 加仓信号 + # Add position signals if has_position_management: add_long_arr = signals['add_long'].values add_short_arr = signals['add_short'].values position_size_arr = signals.get('position_size', pd.Series([0.0] * len(df))).values - # 根据交易方向过滤加仓信号 + # Filter add signals by trade direction if trade_direction == 'long': add_short_arr = np.zeros(len(df), dtype=bool) elif trade_direction == 'short': add_long_arr = np.zeros(len(df), dtype=bool) - # 开仓触发价格(如果指标提供了精确开仓价格) + # Entry trigger price (if indicator provides) open_long_price_arr = signals.get('open_long_price', pd.Series([0.0] * len(df))).values open_short_price_arr = signals.get('open_short_price', pd.Series([0.0] * len(df))).values - # 平仓目标价格(如果指标提供了精确平仓价格) + # Exit target price (if indicator provides) close_long_price_arr = signals.get('close_long_price', pd.Series([0.0] * len(df))).values close_short_price_arr = signals.get('close_short_price', pd.Series([0.0] * len(df))).values - # 加仓目标价格(如果指标提供了精确加仓价格) + # Add position price (if indicator provides) add_long_price_arr = signals.get('add_long_price', pd.Series([0.0] * len(df))).values add_short_price_arr = signals.get('add_short_price', pd.Series([0.0] * len(df))).values for i, (timestamp, row) in enumerate(df.iterrows()): + # 爆仓后直接停止回测,输出结果 if is_liquidated: - equity_curve.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'value': 0 - }) - continue + break - # 若已无持仓且余额过低,视为赔光并停止后续交易 + # If no position and balance low, stop trading if position == 0 and capital < min_capital_to_trade: is_liquidated = True capital = 0 @@ -644,7 +1535,7 @@ import pandas as pd 'balance': 0 }) equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) - continue + break # 直接停止 # Use OHLC to evaluate triggers. high = row['high'] @@ -659,7 +1550,7 @@ import pandas as pd # --- Risk controls: SL / TP / trailing exit (highest priority) --- if position != 0 and position_type in ['long', 'short']: - # 更新持仓期间极值(用于移动止盈止损) + # Update extreme prices for trailing stop if position_type == 'long': if highest_since_entry is None: highest_since_entry = entry_price @@ -675,9 +1566,9 @@ import pandas as pd lowest_since_entry = min(lowest_since_entry, low) highest_since_entry = max(highest_since_entry, high) - # 收集同一根K线内触发的强制平仓点 - # 回测为K线级别,无法确定同一根K线内的真实触发顺序;这里按“确定性优先级”处理: - # 止损 > 移动止盈(回撤) > 固定止盈 + # Collect forced exit points in same candle + # Backtest is candle-level, cannot determine exact trigger order; using priority: + # StopLoss > TrailingStop > TakeProfit candidates = [] # [(trade_type, trigger_price)] if position_type == 'long' and position > 0: if stop_loss_pct_eff > 0: @@ -699,12 +1590,12 @@ import pandas as pd candidates.append(('close_long_trailing', tr_price)) if candidates: - # 按优先级选择触发点:止损 > 移动止盈 > 止盈 + # Select by priority: SL > Trailing > TP pri = {'close_long_stop': 0, 'close_long_trailing': 1, 'close_long_profit': 2} trade_type, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), x[1]))[0] exec_price_close = trigger_price * (1 - slippage) commission_fee_close = position * exec_price_close * commission - # 开仓手续费已在开仓时扣除,这里只扣平仓手续费 + # Entry commission deducted, only deduct exit commission profit = (exec_price_close - entry_price) * position - commission_fee_close capital += profit total_commission_paid += commission_fee_close @@ -715,7 +1606,7 @@ import pandas as pd 'price': round(exec_price_close, 4), 'amount': round(position, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 @@ -750,12 +1641,12 @@ import pandas as pd candidates.append(('close_short_trailing', tr_price)) if candidates: - # 按优先级选择触发点:止损 > 移动止盈 > 止盈 + # Select by priority: SL > Trailing > TP pri = {'close_short_stop': 0, 'close_short_trailing': 1, 'close_short_profit': 2} trade_type, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), -x[1]))[0] exec_price_close = trigger_price * (1 + slippage) commission_fee_close = shares * exec_price_close * commission - # 开仓手续费已在开仓时扣除,这里只扣平仓手续费 + # Entry commission deducted, only deduct exit commission profit = (entry_price - exec_price_close) * shares - commission_fee_close if capital + profit <= 0: @@ -784,7 +1675,7 @@ import pandas as pd 'price': round(exec_price_close, 4), 'amount': round(shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 @@ -798,9 +1689,9 @@ import pandas as pd equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) continue - # 处理平仓信号(优先处理,包括止损/止盈) + # Handle exit signals (priority, SL/TP) if position > 0 and close_long_arr[i]: - # 平多:使用指标提供的目标价格(如果有),否则使用收盘价 + # Close long: use indicator price or close if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: target_price = open_ else: @@ -823,7 +1714,7 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(position, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 @@ -834,7 +1725,7 @@ import pandas as pd trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None - # 平仓后余额过低则停止交易(避免同K线反手开仓) + # Stop if balance too low after exit if capital < min_capital_to_trade: is_liquidated = True capital = 0 @@ -848,7 +1739,7 @@ import pandas as pd }) elif position < 0 and close_short_arr[i]: - # 平空:使用指标提供的目标价格(如果有),否则使用收盘价 + # Close short: use indicator price or close if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: target_price = open_ else: @@ -859,7 +1750,7 @@ import pandas as pd profit = (entry_price - exec_price) * shares - commission_fee if capital + profit <= 0: - logger.warning(f"平空时资金不足爆仓") + logger.warning(f"Insufficient funds when closing short - liquidation") capital = 0 is_liquidated = True trades.append({ @@ -887,7 +1778,7 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 @@ -921,7 +1812,7 @@ import pandas as pd # - Trend reduce: long reduces on rise; short reduces on fall # - Adverse reduce: long reduces on fall; short reduces on rise if (not main_signal_on_bar) and position != 0 and position_type in ['long', 'short'] and capital >= min_capital_to_trade: - # 做多 + # Long if position_type == 'long' and position > 0: # Trend scale-in (trigger on higher price) if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): @@ -932,7 +1823,7 @@ import pandas as pd if order_pct > 0: exec_price_add = trigger * (1 + slippage) use_capital = capital * order_pct - # 手续费按成交名义价值扣除;下单数量不再除以(1+commission) + # Commission from notional value shares_add = (use_capital * leverage) / exec_price_add commission_fee = shares_add * exec_price_add * commission @@ -954,7 +1845,7 @@ import pandas as pd 'price': round(exec_price_add, 4), 'amount': round(shares_add, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Mean-reversion DCA (trigger on lower price) @@ -987,7 +1878,7 @@ import pandas as pd 'price': round(exec_price_add, 4), 'amount': round(shares_add, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Trend reduce (trigger on higher price) @@ -1020,7 +1911,7 @@ import pandas as pd 'price': round(exec_price_reduce, 4), 'amount': round(reduce_shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Adverse reduce (trigger on lower price) @@ -1053,10 +1944,10 @@ import pandas as pd 'price': round(exec_price_reduce, 4), 'amount': round(reduce_shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - # 做空 + # Short if position_type == 'short' and position < 0: shares_total = abs(position) @@ -1067,7 +1958,7 @@ import pandas as pd if low <= trigger: order_pct = trend_add_size_pct if order_pct > 0: - exec_price_add = trigger * (1 - slippage) # 卖出加空,滑点不利 + exec_price_add = trigger * (1 - slippage) # Sell to add short, slippage unfavorable use_capital = capital * order_pct shares_add = (use_capital * leverage) / exec_price_add commission_fee = shares_add * exec_price_add * commission @@ -1091,7 +1982,7 @@ import pandas as pd 'price': round(exec_price_add, 4), 'amount': round(shares_add, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Mean-reversion DCA (trigger on higher price) @@ -1125,7 +2016,7 @@ import pandas as pd 'price': round(exec_price_add, 4), 'amount': round(shares_add, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Trend reduce (trigger on lower price) @@ -1136,7 +2027,7 @@ import pandas as pd reduce_pct = max(trend_reduce_size_pct, 0.0) reduce_shares = shares_total * reduce_pct if reduce_shares > 0: - exec_price_reduce = trigger * (1 + slippage) # 回补更贵 + exec_price_reduce = trigger * (1 + slippage) # Cover more expensive commission_fee = reduce_shares * exec_price_reduce * commission profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee capital += profit @@ -1159,7 +2050,7 @@ import pandas as pd 'price': round(exec_price_reduce, 4), 'amount': round(reduce_shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Adverse reduce (trigger on higher price) @@ -1193,23 +2084,23 @@ import pandas as pd 'price': round(exec_price_reduce, 4), 'amount': round(reduce_shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - # 处理加仓信号(仓位管理模式) + # Handle add position signals if has_position_management and (not main_signal_on_bar): if position > 0 and add_long_arr[i] and capital >= min_capital_to_trade: - # 加多仓:使用指标提供的目标价格(如果有),否则使用收盘价 + # Add long: use indicator price or close target_price = add_long_price_arr[i] if add_long_price_arr[i] > 0 else close exec_price = target_price * (1 + slippage) - # 使用指定比例的资金加仓 + # Use specified pct to add position_pct = position_size_arr[i] if position_size_arr[i] > 0 else 0.1 use_capital = capital * position_pct shares = (use_capital * leverage) / exec_price commission_fee = shares * exec_price * commission - # 更新平均成本 + # Update average cost total_cost_before = position * entry_price total_cost_after = total_cost_before + shares * exec_price position += shares @@ -1218,7 +2109,7 @@ import pandas as pd capital -= commission_fee total_commission_paid += commission_fee - # 重新计算爆仓线 + # Recalculate liquidation price liquidation_price = entry_price * (1 - 1.0 / leverage) trades.append({ @@ -1227,32 +2118,32 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) elif position < 0 and add_short_arr[i] and capital >= min_capital_to_trade: - # 加空仓:使用指标提供的目标价格(如果有),否则使用收盘价 + # Add short: use indicator price or close target_price = add_short_price_arr[i] if add_short_price_arr[i] > 0 else close exec_price = target_price * (1 - slippage) - # 使用指定比例的资金加仓 + # Use specified pct to add position_pct = position_size_arr[i] if position_size_arr[i] > 0 else 0.1 use_capital = capital * position_pct shares = (use_capital * leverage) / exec_price commission_fee = shares * exec_price * commission - # 更新平均成本 + # Update average cost current_shares = abs(position) total_cost_before = current_shares * entry_price total_cost_after = total_cost_before + shares * exec_price - position -= shares # 空头是负数 + position -= shares # Short is negative current_shares = abs(position) entry_price = total_cost_after / current_shares capital -= commission_fee total_commission_paid += commission_fee - # 重新计算爆仓线 + # Recalculate liquidation price liquidation_price = entry_price * (1 + 1.0 / leverage) trades.append({ @@ -1261,20 +2152,56 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - # 处理开仓信号 - # 注意:code6.py已经处理了反转(先平后开),所以这里只需要处理position==0的情况 - if open_long_arr[i] and position == 0 and capital >= min_capital_to_trade: - # 使用指标提供的开仓触发价格(如果有),否则使用收盘价 + # Handle entry signals + # In both mode, open_long/open_short can auto-close opposing position first + both_mode_active = signals.get('_both_mode', False) + + # open_long: can execute when position==0, OR when both_mode and position<0 (auto-close short first) + if open_long_arr[i] and (position == 0 or (both_mode_active and position < 0)) and capital >= min_capital_to_trade: + # In both mode with short position, close it first + if both_mode_active and position < 0: + shares_to_close = abs(position) + close_price = open_ * (1 + slippage) + close_commission = shares_to_close * close_price * commission + close_profit = (entry_price - close_price) * shares_to_close - close_commission + capital += close_profit + if capital < 0: + capital = 0 + total_commission_paid += close_commission + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_short', + 'price': round(close_price, 4), + 'amount': round(shares_to_close, 4), + 'profit': round(close_profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + # 检查是否爆仓 + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + continue + + # Now open long (position is guaranteed to be 0 here) + # Use indicator entry price or close if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: base_price = open_ else: base_price = open_long_price_arr[i] if open_long_price_arr[i] > 0 else close exec_price = base_price * (1 + slippage) - # 使用指定比例的资金开仓(优先采用回测弹窗的 entryPct;其次采用指标提供的 position_size;否则全仓) + # Use specified pct (entryPct > position_size > full) position_pct = None if entry_pct_cfg and entry_pct_cfg > 0: position_pct = entry_pct_cfg @@ -1307,7 +2234,7 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Strict intrabar stop-loss / liquidation check right after entry (closer to live trading). @@ -1346,7 +2273,7 @@ import pandas as pd 'price': round(exec_price_close, 4), 'amount': round(position, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 @@ -1357,15 +2284,48 @@ import pandas as pd equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) continue - elif open_short_arr[i] and position == 0 and capital >= min_capital_to_trade: - # 使用指标提供的开仓触发价格(如果有),否则使用收盘价 + # open_short: can execute when position==0, OR when both_mode and position>0 (auto-close long first) + elif open_short_arr[i] and (position == 0 or (both_mode_active and position > 0)) and capital >= min_capital_to_trade: + # In both mode with long position, close it first + if both_mode_active and position > 0: + close_price = open_ * (1 - slippage) + close_commission = position * close_price * commission + close_profit = (close_price - entry_price) * position - close_commission + capital += close_profit + if capital < 0: + capital = 0 + total_commission_paid += close_commission + trades.append({ + 'time': timestamp.strftime('%Y-%m-%d %H:%M'), + 'type': 'close_long', + 'price': round(close_price, 4), + 'amount': round(position, 4), + 'profit': round(close_profit, 2), + 'balance': round(max(0, capital), 2) + }) + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + # 检查是否爆仓 + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + continue + + # Now open short (position is guaranteed to be 0 here) + # Use indicator entry price or close if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: base_price = open_ else: base_price = open_short_price_arr[i] if open_short_price_arr[i] > 0 else close exec_price = base_price * (1 - slippage) - # 使用指定比例的资金开仓(优先采用回测弹窗的 entryPct;其次采用指标提供的 position_size;否则全仓) + # Use specified pct (entryPct > position_size > full) position_pct = None if entry_pct_cfg and entry_pct_cfg > 0: position_pct = entry_pct_cfg @@ -1398,7 +2358,7 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Strict intrabar stop-loss / liquidation check right after entry (closer to live trading). @@ -1436,7 +2396,7 @@ import pandas as pd 'price': round(exec_price_close, 4), 'amount': round(shares_close, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 @@ -1447,18 +2407,18 @@ import pandas as pd equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) continue - # 检测持仓期间是否触及爆仓线(作为兜底保护) - # 注意:这个检查在所有主动平仓信号处理之后 - # 如果触及爆仓线,检查是否有止损信号,止损优先 + # Check if liquidation hit (safety net) + # Note: check after all active exit signals + # If liquidation hit, check SL signal first if position != 0 and not is_liquidated: if position_type == 'long' and low <= liquidation_price: - # 做多触及爆仓线:检查是否有止损信号 + # Long触及爆仓线:检查是否有止损信号 has_stop_loss = close_long_arr[i] and close_long_price_arr[i] > 0 stop_loss_price = close_long_price_arr[i] if has_stop_loss else 0 - # 判断先触发止损还是爆仓 + # Determine SL or liquidation first if has_stop_loss and stop_loss_price > liquidation_price: - # 止损在爆仓前触发,使用止损价平仓 + # SL triggers before liquidation exec_price_close = stop_loss_price * (1 - slippage) commission_fee_close = position * exec_price_close * commission profit = (exec_price_close - entry_price) * position - commission_fee_close @@ -1471,11 +2431,11 @@ import pandas as pd 'price': round(exec_price_close, 4), 'amount': round(position, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) else: - # 止损不够严格或无止损,触发爆仓 - logger.warning(f"做多爆仓!开仓价={entry_price:.2f}, 最低价={low:.2f}, " + # SL not strict enough, liquidation triggered + logger.warning(f"Long liquidation! entry={entry_price:.2f}, low={low:.2f}, " f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}") is_liquidated = True capital = 0 @@ -1494,16 +2454,16 @@ import pandas as pd continue elif position_type == 'short' and high >= liquidation_price: - # 做空触及爆仓线:检查是否有止损信号 + # Short触及爆仓线:检查是否有止损信号 has_stop_loss = close_short_arr[i] and close_short_price_arr[i] > 0 stop_loss_price = close_short_price_arr[i] if has_stop_loss else 0 - logger.warning(f"[K线{i}] 做空触及爆仓线!开仓={entry_price:.2f}, 最高={high:.2f}, 爆仓线={liquidation_price:.2f}, " + logger.warning(f"[candle {i}] Short hit liquidation! entry={entry_price:.2f}, high={high:.2f}, liq_price={liquidation_price:.2f}, " f"止损信号={close_short_arr[i]}, 止损价={stop_loss_price:.4f}, 时间={timestamp}") - # 判断先触发止损还是爆仓 + # Determine SL or liquidation first if has_stop_loss and stop_loss_price < liquidation_price: - # 止损在爆仓前触发,使用止损价平仓 + # SL triggers before liquidation exec_price_close = stop_loss_price * (1 + slippage) shares_close = abs(position) commission_fee_close = shares_close * exec_price_close * commission @@ -1517,11 +2477,11 @@ import pandas as pd 'price': round(exec_price_close, 4), 'amount': round(shares_close, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) else: - # 止损不够严格或无止损,触发爆仓 - logger.warning(f"做空爆仓!开仓价={entry_price:.2f}, 最高价={high:.2f}, " + # SL not strict enough, liquidation triggered + logger.warning(f"Short liquidation! entry={entry_price:.2f}, high={high:.2f}, " f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}") is_liquidated = True capital = 0 @@ -1539,7 +2499,7 @@ import pandas as pd equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': capital}) continue - # 记录权益(使用收盘价计算未实现盈亏) + # Record equity (unrealized PnL from close) if position_type == 'long': unrealized_pnl = (close - entry_price) * position total_value = capital + unrealized_pnl @@ -1558,12 +2518,12 @@ import pandas as pd 'value': round(total_value, 2) }) - # 回测结束时强制平仓 + # Force exit at backtest end if position != 0: timestamp = df.index[-1] final_close = df.iloc[-1]['close'] - if position > 0: # 平多 + if position > 0: # Close long exec_price = final_close * (1 - slippage) commission_fee = position * exec_price * commission profit = (exec_price - entry_price) * position - commission_fee @@ -1576,16 +2536,16 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(position, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - else: # 平空 + else: # Close short exec_price = final_close * (1 + slippage) shares = abs(position) commission_fee = shares * exec_price * commission profit = (entry_price - exec_price) * shares - commission_fee if capital + profit <= 0: - logger.warning(f"回测结束爆仓!") + logger.warning(f"Liquidation at backtest end!") capital = 0 is_liquidated = True trades.append({ @@ -1605,7 +2565,7 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) if equity_curve: @@ -1629,13 +2589,13 @@ import pandas as pd """ equity_curve = [] trades = [] - total_commission_paid = 0 # 累计手续费 - is_liquidated = False # 爆仓标志 - liquidation_price = 0 # 爆仓价格 - min_capital_to_trade = 1.0 # 余额低于该值则视为赔光,不再开新单 + total_commission_paid = 0 # Accumulated commission + is_liquidated = False # Liquidation flag + liquidation_price = 0 # Liquidation price + min_capital_to_trade = 1.0 # Below this balance, consider wiped out capital = initial_capital - position = 0 # 正数=多头持仓,负数=空头持仓 + position = 0 # Positive=long, Negative=short entry_price = 0 position_type = None # 'long' or 'short' @@ -1697,7 +2657,7 @@ import pandas as pd adverse_reduce_size_pct = float(adverse_reduce_cfg.get('sizePct') or 0.0) adverse_reduce_max_times = int(adverse_reduce_cfg.get('maxTimes') or 0) - # 触发百分比按杠杆后换算为价格阈值 + # Trigger pct to price threshold with leverage trend_add_step_pct_eff = trend_add_step_pct / lev dca_add_step_pct_eff = dca_add_step_pct / lev trend_reduce_step_pct_eff = trend_reduce_step_pct / lev @@ -1723,16 +2683,11 @@ import pandas as pd signals_exec = signals for i, (timestamp, row) in enumerate(df.iterrows()): - # 如果已爆仓,停止交易 + # 爆仓后直接停止回测,输出结果 if is_liquidated: - # 记录爆仓后的权益(保持为0) - equity_curve.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'value': 0 - }) - continue + break - # 若已无持仓且余额过低,视为赔光并停止后续交易 + # If no position and balance low, stop trading if position == 0 and capital < min_capital_to_trade: is_liquidated = True capital = 0 @@ -1753,7 +2708,7 @@ import pandas as pd price = row['close'] open_ = row.get('open', price) - # 强制平仓(止盈止损/移动止盈)优先于信号 + # Forced exit (TP/SL/trailing) over signals if position != 0 and position_type in ['long', 'short']: if position_type == 'long' and position > 0: if highest_since_entry is None: @@ -1777,12 +2732,12 @@ import pandas as pd if low <= tr_price: candidates.append(('trailing', tr_price)) if candidates: - # 止损 > 移动止盈(回撤) > 止盈 + # SL > TrailingStop > TP pri = {'stop': 0, 'trailing': 1, 'profit': 2} reason, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), x[1]))[0] exec_price = trigger_price * (1 - slippage) commission_fee = position * exec_price * commission - # 开仓手续费已在开仓时扣除,这里只扣平仓手续费 + # Entry commission deducted, only deduct exit commission profit = (exec_price - entry_price) * position - commission_fee capital += profit total_commission_paid += commission_fee @@ -1792,7 +2747,7 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(position, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 position_type = None @@ -1825,12 +2780,12 @@ import pandas as pd if high >= tr_price: candidates.append(('trailing', tr_price)) if candidates: - # 止损 > 移动止盈(回撤) > 止盈 + # SL > TrailingStop > TP pri = {'stop': 0, 'trailing': 1, 'profit': 2} reason, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), -x[1]))[0] exec_price = trigger_price * (1 + slippage) commission_fee = shares * exec_price * commission - # 开仓手续费已在开仓时扣除,这里只扣平仓手续费 + # Entry commission deducted, only deduct exit commission profit = (entry_price - exec_price) * shares - commission_fee if capital + profit <= 0: capital = 0 @@ -1856,7 +2811,7 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 position_type = None @@ -1867,11 +2822,11 @@ import pandas as pd continue # --- Parameterized scaling rules (also for old-format strategies) --- - # 说明:旧格式只有 buy/sell 信号,但回测弹窗的“顺势/逆势加减仓、最小下单比例”等参数仍应生效。 - # 触发百分比按杠杆后阈值理解(已除以 leverage)。 + # Note: old format only has buy/sell, but scaling params should work. + # Trigger pct as post-leverage threshold. # IMPORTANT: if this candle has a main buy/sell signal, do NOT apply any scale-in/scale-out. if signal == 0 and position != 0 and position_type in ['long', 'short'] and capital >= min_capital_to_trade: - # 做多 + # Long if position_type == 'long' and position > 0: # Trend add(顺势加仓:上涨触发) if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): @@ -1903,7 +2858,7 @@ import pandas as pd 'price': round(exec_price_add, 4), 'amount': round(shares_add, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # DCA add(逆势加仓:下跌触发) @@ -1936,7 +2891,7 @@ import pandas as pd 'price': round(exec_price_add, 4), 'amount': round(shares_add, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Trend reduce(顺势减仓:上涨触发) @@ -1971,7 +2926,7 @@ import pandas as pd 'price': round(exec_price_reduce, 4), 'amount': round(reduce_shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Adverse reduce(逆势减仓:下跌触发) @@ -2006,10 +2961,10 @@ import pandas as pd 'price': round(exec_price_reduce, 4), 'amount': round(reduce_shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - # 做空 + # Short if position_type == 'short' and position < 0: shares_total = abs(position) @@ -2044,7 +2999,7 @@ import pandas as pd 'price': round(exec_price_add, 4), 'amount': round(shares_add, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # DCA add(逆势加空:上涨触发) @@ -2078,7 +3033,7 @@ import pandas as pd 'price': round(exec_price_add, 4), 'amount': round(shares_add, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Trend reduce(顺势减空:下跌触发,回补一部分) @@ -2114,7 +3069,7 @@ import pandas as pd 'price': round(exec_price_reduce, 4), 'amount': round(reduce_shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) # Adverse reduce(逆势减空:上涨触发) @@ -2150,18 +3105,18 @@ import pandas as pd 'price': round(exec_price_reduce, 4), 'amount': round(reduce_shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - # 处理不同的交易方向 + # Handle different trade directions if trade_direction == 'long': - # 只做多模式 - if signal == 1 and position == 0 and capital >= min_capital_to_trade: # 买入开多 - logger.debug(f"[做多模式] 买入开多: 时间={timestamp}, 价格={price}, 杠杆={leverage}x") + # Long only mode + if signal == 1 and position == 0 and capital >= min_capital_to_trade: # Buy to open long + logger.debug(f"[Long mode] Buy to open long: time={timestamp}, price={price}, leverage={leverage}x") base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price exec_price = base_price * (1 + slippage) - # 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格 - # 使用指定比例的资金开仓(entryPct 优先;否则全仓) + # With leverage: position = capital * leverage / price + # Use specified pct (entryPct preferred; else full) position_pct = None if entry_pct_cfg is not None and entry_pct_cfg > 0: position_pct = entry_pct_cfg @@ -2170,19 +3125,19 @@ import pandas as pd shares = (use_capital * leverage) / exec_price else: shares = (capital * leverage) / exec_price - # 保证金(手续费从本金扣除) + # Margin (commission from capital) margin = capital commission_fee = shares * exec_price * commission position = shares entry_price = exec_price position_type = 'long' - capital -= commission_fee # 只扣手续费,不扣全部成本 + capital -= commission_fee # Only deduct commission total_commission_paid += commission_fee - # 计算爆仓线:做多时,价格跌到 entry_price × (1 - 1/leverage) 就爆仓 + # Long liquidation when price drops to entry * (1 - 1/leverage) liquidation_price = entry_price * (1 - 1.0 / leverage) - logger.debug(f"做多爆仓线: {liquidation_price:.2f}") + logger.debug(f"Long liquidation price: {liquidation_price:.2f}") # init scaling anchors last_trend_add_anchor = entry_price @@ -2196,19 +3151,19 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - elif signal == -1 and position > 0: # 卖出平多 - logger.debug(f"[做多模式] 卖出平多: 时间={timestamp}, 价格={price}") + elif signal == -1 and position > 0: # Sell to close long + logger.debug(f"[Long mode] Sell to close long: time={timestamp}, price={price}") base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price exec_price = base_price * (1 - slippage) - # 盈亏 = (平仓价 - 开仓价) × 股数 - 手续费 + # PnL = (exit - entry) * shares - commission commission_fee = position * exec_price * commission profit = (exec_price - entry_price) * position - commission_fee capital += profit total_commission_paid += commission_fee - liquidation_price = 0 # 清除爆仓线 + liquidation_price = 0 # Clear liquidation price trades.append({ 'time': timestamp.strftime('%Y-%m-%d %H:%M'), @@ -2216,7 +3171,7 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(position, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 @@ -2236,12 +3191,12 @@ import pandas as pd }) elif trade_direction == 'short': - # 只做空模式 - if signal == -1 and position == 0 and capital >= min_capital_to_trade: # 卖出开空 - logger.debug(f"[做空模式] 卖出开空: 时间={timestamp}, 价格={price}, 杠杆={leverage}x") + # Short only mode + if signal == -1 and position == 0 and capital >= min_capital_to_trade: # Sell to open short + logger.debug(f"[Short mode] Sell to open short: time={timestamp}, price={price}, leverage={leverage}x") base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price exec_price = base_price * (1 - slippage) - # 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格 + # With leverage: position = capital * leverage / price position_pct = None if entry_pct_cfg is not None and entry_pct_cfg > 0: position_pct = entry_pct_cfg @@ -2252,15 +3207,15 @@ import pandas as pd shares = (capital * leverage) / exec_price commission_fee = shares * exec_price * commission - position = -shares # 负数表示空头(欠股票) + position = -shares # Negative = short (owe shares) entry_price = exec_price position_type = 'short' - capital -= commission_fee # 只扣手续费 + capital -= commission_fee # Only deduct commission total_commission_paid += commission_fee - # 计算爆仓线:做空时,价格涨到 entry_price × (1 + 1/leverage) 就爆仓 + # Short liquidation when price rises to entry * (1 + 1/leverage) liquidation_price = entry_price * (1 + 1.0 / leverage) - logger.debug(f"做空爆仓线: {liquidation_price:.2f}") + logger.debug(f"Short liquidation price: {liquidation_price:.2f}") last_trend_add_anchor = entry_price last_dca_add_anchor = entry_price @@ -2273,21 +3228,21 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - elif signal == 1 and position < 0: # 买入平空 - logger.debug(f"[做空模式] 买入平空: 时间={timestamp}, 价格={price}") + elif signal == 1 and position < 0: # Buy to close short + logger.debug(f"[Short mode] Buy to close short: time={timestamp}, price={price}") base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price exec_price = base_price * (1 + slippage) - shares = abs(position) # 需要买回的股数 - # 盈亏 = (开仓价 - 平仓价) × 股数 - 手续费 + shares = abs(position) # Shares to buy back + # PnL = (entry - exit) * shares - commission commission_fee = shares * exec_price * commission profit = (entry_price - exec_price) * shares - commission_fee - # 检查是否爆仓 + # Check for liquidation if capital + profit <= 0: - logger.warning(f"平空时资金不足爆仓: 本金={capital:.2f}, 亏损={-profit:.2f}") + logger.warning(f"Insufficient funds when closing short - liquidation: capital={capital:.2f}, loss={-profit:.2f}") capital = 0 is_liquidated = True trades.append({ @@ -2308,12 +3263,12 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) position = 0 position_type = None - liquidation_price = 0 # 清除爆仓线 + liquidation_price = 0 # Clear liquidation price last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 if capital < min_capital_to_trade and not is_liquidated: @@ -2329,12 +3284,12 @@ import pandas as pd }) elif trade_direction == 'both': - # 双向模式 - if signal == 1 and position == 0 and capital >= min_capital_to_trade: # 买入开多 - logger.debug(f"[双向模式] 买入开多: 时间={timestamp}, 价格={price}, 杠杆={leverage}x") + # Both directions mode + if signal == 1 and position == 0 and capital >= min_capital_to_trade: # Buy to open long + logger.debug(f"[Both mode] Buy to open long: time={timestamp}, price={price}, leverage={leverage}x") base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price exec_price = base_price * (1 + slippage) - # 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格 + # With leverage: position = capital * leverage / price position_pct = None if entry_pct_cfg is not None and entry_pct_cfg > 0: position_pct = entry_pct_cfg @@ -2348,12 +3303,12 @@ import pandas as pd position = shares entry_price = exec_price position_type = 'long' - capital -= commission_fee # 只扣手续费 + capital -= commission_fee # Only deduct commission total_commission_paid += commission_fee - # 计算爆仓线 + # Calculate liquidation price liquidation_price = entry_price * (1 - 1.0 / leverage) - logger.debug(f"做多爆仓线: {liquidation_price:.2f}") + logger.debug(f"Long liquidation price: {liquidation_price:.2f}") last_trend_add_anchor = entry_price last_dca_add_anchor = entry_price @@ -2366,14 +3321,14 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - elif signal == -1 and position == 0 and capital >= min_capital_to_trade: # 卖出开空 - logger.debug(f"[双向模式] 卖出开空: 时间={timestamp}, 价格={price}, 杠杆={leverage}x") + elif signal == -1 and position == 0 and capital >= min_capital_to_trade: # Sell to open short + logger.debug(f"[Both mode] Sell to open short: time={timestamp}, price={price}, leverage={leverage}x") base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price exec_price = base_price * (1 - slippage) - # 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格 + # With leverage: position = capital * leverage / price position_pct = None if entry_pct_cfg is not None and entry_pct_cfg > 0: position_pct = entry_pct_cfg @@ -2390,9 +3345,9 @@ import pandas as pd capital -= commission_fee total_commission_paid += commission_fee - # 计算爆仓线 + # Calculate liquidation price liquidation_price = entry_price * (1 + 1.0 / leverage) - logger.debug(f"做空爆仓线: {liquidation_price:.2f}") + logger.debug(f"Short liquidation price: {liquidation_price:.2f}") last_trend_add_anchor = entry_price last_dca_add_anchor = entry_price @@ -2405,12 +3360,12 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - elif signal == -1 and position > 0: # 平多开空 - logger.debug(f"[双向模式] 平多开空: 时间={timestamp}, 价格={price}") - # 先平多 + elif signal == -1 and position > 0: # Close long open short + logger.debug(f"[Both mode] Close long open short: time={timestamp}, price={price}") + # First close long base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price exec_price = base_price * (1 - slippage) commission_fee_close = position * exec_price * commission @@ -2424,10 +3379,10 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(position, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - # 若平仓后余额过低则停止(避免同K线反手开仓) + # Stop if balance too low after exit if capital < min_capital_to_trade or is_liquidated: is_liquidated = True capital = 0 @@ -2458,9 +3413,9 @@ import pandas as pd capital -= commission_fee_open total_commission_paid += commission_fee_open - # 计算爆仓线 + # Calculate liquidation price liquidation_price = entry_price * (1 + 1.0 / leverage) - logger.debug(f"做空爆仓线: {liquidation_price:.2f}") + logger.debug(f"Short liquidation price: {liquidation_price:.2f}") trades.append({ 'time': timestamp.strftime('%Y-%m-%d %H:%M'), @@ -2468,21 +3423,21 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - elif signal == 1 and position < 0: # 平空开多 - logger.debug(f"[双向模式] 平空开多: 时间={timestamp}, 价格={price}") - # 先平空 + elif signal == 1 and position < 0: # Close short open long + logger.debug(f"[Both mode] Close short open long: time={timestamp}, price={price}") + # First close short base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price exec_price = base_price * (1 + slippage) shares = abs(position) commission_fee_close = shares * exec_price * commission profit = (entry_price - exec_price) * shares - commission_fee_close - # 检查是否爆仓 + # Check for liquidation if capital + profit <= 0: - logger.warning(f"平空时资金不足爆仓: 本金={capital:.2f}, 亏损={-profit:.2f}") + logger.warning(f"Insufficient funds when closing short - liquidation: capital={capital:.2f}, loss={-profit:.2f}") capital = 0 is_liquidated = True trades.append({ @@ -2495,7 +3450,7 @@ import pandas as pd }) position = 0 position_type = None - continue # 爆仓后不再开新仓 + continue # No new positions after liquidation capital += profit total_commission_paid += commission_fee_close @@ -2506,7 +3461,7 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) if capital < min_capital_to_trade or is_liquidated: @@ -2539,9 +3494,9 @@ import pandas as pd capital -= commission_fee_open total_commission_paid += commission_fee_open - # 计算爆仓线 + # Calculate liquidation price liquidation_price = entry_price * (1 - 1.0 / leverage) - logger.debug(f"做多爆仓线: {liquidation_price:.2f}") + logger.debug(f"Long liquidation price: {liquidation_price:.2f}") trades.append({ 'time': timestamp.strftime('%Y-%m-%d %H:%M'), @@ -2549,16 +3504,16 @@ import pandas as pd 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': 0, - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - # 检测持仓期间是否触及爆仓线(作为兜底保护,仅在没有主动平仓的情况下检查) - # 注意:这个检查在所有信号处理之后,确保止损/止盈优先执行 + # Check if liquidation hit (safety net, only when no active exit) + # Note: check after all signals, SL/TP takes priority if position != 0 and not is_liquidated: if position_type == 'long': - # 做多爆仓:价格跌破爆仓线 + # Long爆仓:价格跌破爆仓线 if price <= liquidation_price: - logger.warning(f"做多爆仓!开仓价={entry_price:.2f}, 当前价={price:.2f}, 爆仓线={liquidation_price:.2f}") + logger.warning(f"Long liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}") is_liquidated = True capital = 0 trades.append({ @@ -2577,9 +3532,9 @@ import pandas as pd }) continue elif position_type == 'short': - # 做空爆仓:价格涨破爆仓线 + # Short爆仓:价格涨破爆仓线 if price >= liquidation_price: - logger.warning(f"做空爆仓!开仓价={entry_price:.2f}, 当前价={price:.2f}, 爆仓线={liquidation_price:.2f}") + logger.warning(f"Short liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}") is_liquidated = True capital = 0 trades.append({ @@ -2598,22 +3553,22 @@ import pandas as pd }) continue - # 记录权益 + # Record equity if position_type == 'long': - # 多头权益 = 现金 + 未实现盈亏 - # 未实现盈亏 = (当前价 - 开仓价) × 股数 + # Long equity = cash + unrealized PnL + # Unrealized PnL = (current - entry) * shares unrealized_pnl = (price - entry_price) * position total_value = capital + unrealized_pnl elif position_type == 'short': - # 空头权益 = 现金 + 未实现盈亏 - # 未实现盈亏 = (开仓价 - 当前价) × 股数 + # Short equity = cash + unrealized PnL + # Unrealized PnL = (entry - current) * shares shares = abs(position) unrealized_pnl = (entry_price - price) * shares total_value = capital + unrealized_pnl else: total_value = capital - # 确保权益不为负(如果已经爆仓,在前面已经处理了) + # Ensure equity is not negative (liquidation already handled) if total_value < 0: total_value = 0 @@ -2622,36 +3577,36 @@ import pandas as pd 'value': round(total_value, 2) }) - # 回测结束时强制平仓 + # Force exit at backtest end if position != 0: timestamp = df.index[-1] price = df.iloc[-1]['close'] - if position > 0: # 平多 + if position > 0: # Close long exec_price = price * (1 - slippage) commission_fee = position * exec_price * commission profit = (exec_price - entry_price) * position - commission_fee capital += profit total_commission_paid += commission_fee - # 记录平多交易 + # Record close long trade trades.append({ 'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'type': 'close_long', 'price': round(exec_price, 4), 'amount': round(position, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - else: # 平空 + else: # Close short exec_price = price * (1 + slippage) shares = abs(position) commission_fee = shares * exec_price * commission profit = (entry_price - exec_price) * shares - commission_fee - # 检查是否爆仓 + # Check for liquidation if capital + profit <= 0: - logger.warning(f"回测结束爆仓!平空亏损过大: 本金={capital:.2f}, 亏损={-profit:.2f}") + logger.warning(f"Liquidation at backtest end! Close short loss too large: capital={capital:.2f}, loss={-profit:.2f}") is_liquidated = True trades.append({ 'time': timestamp.strftime('%Y-%m-%d %H:%M'), @@ -2666,17 +3621,17 @@ import pandas as pd capital += profit total_commission_paid += commission_fee - # 记录平空交易 + # Record close short trade trades.append({ 'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'type': 'close_short', 'price': round(exec_price, 4), 'amount': round(shares, 4), 'profit': round(profit, 2), - 'balance': round(capital, 2) + 'balance': round(max(0, capital), 2) }) - # 更新权益曲线的最后一个值,包含强制平仓后的资金 + # Update last equity curve value with capital after forced exit if equity_curve: equity_curve[-1]['value'] = round(capital, 2) @@ -2699,36 +3654,36 @@ import pandas as pd final_value = equity_curve[-1]['value'] total_return = (final_value - initial_capital) / initial_capital * 100 - # 计算年化收益:使用简单年化而不是复利年化 - # 对于高收益率策略,复利年化会产生天文数字,不具备参考价值 + # Calculate annualized return: simple, not compound + # For high-return strategies, compound annualization produces unrealistic numbers actual_days = (end_date - start_date).total_seconds() / 86400 years = actual_days / 365.0 - # 简单年化:年化收益率 = 总收益率 / 年数 + # Simple annualization: annualized return = total return / years if years > 0: annual_return = total_return / years else: annual_return = 0 - # 计算最大回撤 + # Calculate max drawdown values = [e['value'] for e in equity_curve] max_drawdown = self._calculate_max_drawdown(values) - # 计算夏普比率 + # Calculate Sharpe ratio sharpe = self._calculate_sharpe(values, timeframe) - # 计算总盈亏:用最终权益减去初始资金(最准确) + # Calculate total PnL: final equity - initial capital (most accurate) total_profit = final_value - initial_capital - # 计算胜率(包含所有平仓操作) - # 平仓操作:profit != 0 的交易 + # Calculate win rate (all exit trades) + # Exit trades: trades with profit != 0 closing_trades = [t for t in trades if t.get('profit', 0) != 0] win_trades = [t for t in closing_trades if t['profit'] > 0] loss_trades = [t for t in closing_trades if t['profit'] < 0] total_trades = len(closing_trades) win_rate = len(win_trades) / total_trades * 100 if total_trades > 0 else 0 - # 计算盈亏比(Profit Factor = 总盈利 / 总亏损) + # Calculate profit factor (= total profit / total loss) total_wins = sum(t['profit'] for t in win_trades) total_losses = abs(sum(t['profit'] for t in loss_trades)) profit_factor = total_wins / total_losses if total_losses > 0 else (total_wins if total_wins > 0 else 0) @@ -2774,46 +3729,46 @@ import pandas as pd if len(values) < 2: return 0 - # 过滤掉0值(爆仓后的数据),避免除以0 + # Filter out zero values (post-liquidation data), avoid division by 0 valid_values = [v for v in values if v > 0] if len(valid_values) < 2: return 0 - # 根据时间周期确定年化系数 + # Determine annualization factor by timeframe annualization_factor = { - '1m': 252 * 24 * 60, # 分钟K:约362,880 + '1m': 252 * 24 * 60, # 1m candle: ~362,880 '5m': 252 * 24 * 12, # 5分钟K:约72,576 '15m': 252 * 24 * 4, # 15分钟K:约24,192 '30m': 252 * 24 * 2, # 30分钟K:约12,096 - '1H': 252 * 24, # 小时K:6,048 + '1H': 252 * 24, # 1H candle: 6,048 '4H': 252 * 6, # 4小时K:1,512 - '1D': 252, # 日K:252 - '1W': 52 # 周K:52 + '1D': 252, # 1D candle: 252 + '1W': 52 # 1W candle: 52 }.get(timeframe, 252) try: - # 计算周期收益率 + # Calculate period returns returns = np.diff(valid_values) / valid_values[:-1] - # 过滤无效值 + # Filter invalid values returns = returns[np.isfinite(returns)] if len(returns) == 0: return 0 - # 年化平均收益率 + # Annualized mean return avg_return = np.mean(returns) * annualization_factor - # 年化标准差(波动率) + # Annualized std (volatility) std_return = np.std(returns) * np.sqrt(annualization_factor) if std_return == 0 or not np.isfinite(std_return): return 0 - # 夏普比率 = (年化收益 - 无风险利率) / 年化波动率 + # Sharpe ratio = (annualized return - risk-free rate) / annualized volatility sharpe = (avg_return - risk_free_rate) / std_return return sharpe if np.isfinite(sharpe) else 0 except Exception as e: - logger.warning(f"夏普比率计算失败: {e}") + logger.warning(f"Sharpe ratio calculation failed: {e}") return 0 def _format_result( @@ -2823,13 +3778,13 @@ import pandas as pd trades: List ) -> Dict[str, Any]: """格式化回测结果""" - # 精简权益曲线 + # Simplify equity curve max_points = 500 if len(equity_curve) > max_points: step = len(equity_curve) // max_points equity_curve = equity_curve[::step] - # 清理数据中的NaN、Inf值,确保可以被JSON序列化 + # Clean NaN/Inf values for JSON serialization def clean_value(value): """清理数值,将NaN/Inf转换为0""" if isinstance(value, float): @@ -2837,12 +3792,12 @@ import pandas as pd return 0 return value - # 清理metrics + # Clean metrics cleaned_metrics = {} for key, value in metrics.items(): cleaned_metrics[key] = clean_value(value) - # 清理equity_curve + # Clean equity_curve cleaned_curve = [] for item in equity_curve: cleaned_curve.append({ @@ -2850,9 +3805,9 @@ import pandas as pd 'value': clean_value(item['value']) }) - # 清理trades + # Clean trades cleaned_trades = [] - # 不截断交易记录:有多少条就返回多少条(前端可自行分页展示) + # Don't truncate trades: return all (frontend can paginate) for trade in trades: cleaned_trade = {} for key, value in trade.items(): diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py index fb3cd42..bc0454a 100644 --- a/backend_api_python/app/services/pending_order_worker.py +++ b/backend_api_python/app/services/pending_order_worker.py @@ -202,7 +202,7 @@ class PendingOrderWorker: # instId: BTC-USDT-SWAP -> BTC/USDT hb_sym = inst_id.replace("-SWAP", "").replace("-", "/") side = "long" if pos_side == "long" else ("short" if pos_side == "short" else ("long" if pos > 0 else "short")) - # IMPORTANT: OKX swap positions `pos` is in contracts (张数), but our system uses base-asset quantity. + # IMPORTANT: OKX swap positions `pos` is in contracts, but our system uses base-asset quantity. # Convert contracts -> base using ctVal when available. qty_base = abs(float(pos)) try: diff --git a/backend_api_python/app/services/strategy.py b/backend_api_python/app/services/strategy.py index 19eef83..153b9d9 100644 --- a/backend_api_python/app/services/strategy.py +++ b/backend_api_python/app/services/strategy.py @@ -14,7 +14,7 @@ logger = get_logger(__name__) class StrategyService: """Strategy service.""" - # 类变量:限制连接测试并发数 + # Class variable: limit connection test concurrency _connection_test_semaphore = threading.Semaphore(5) def __init__(self): @@ -22,7 +22,7 @@ class StrategyService: pass def get_running_strategies(self) -> List[Dict[str, Any]]: - """获取所有运行中的策略(仅ID)""" + """Get all running strategies (ID only)""" try: with get_db_connection() as db: cursor = db.cursor() @@ -36,13 +36,13 @@ class StrategyService: return [] def get_running_strategies_with_type(self) -> List[Dict[str, Any]]: - """获取所有运行中的策略(包含类型信息)""" + """Get all running strategies (with type info)""" try: with get_db_connection() as db: cursor = db.cursor() - # 假设 qd_strategies_trading 表中有 strategy_type 字段 - # 如果没有,可能需要关联查询或者根据其他字段判断 - # 这里假设表结构已更新 + # Assume qd_strategies_trading table has strategy_type field + # If not, may need join query or determine from other fields + # Here we assume table structure is updated query = "SELECT id, strategy_type FROM qd_strategies_trading WHERE status = 'running'" cursor.execute(query) results = cursor.fetchall() @@ -58,14 +58,14 @@ class StrategyService: def get_exchange_symbols(self, exchange_config: Dict[str, Any]) -> Dict[str, Any]: """ - 获取交易所交易对列表 (无需API Key) + Get exchange trading pairs (no API Key required) """ try: exchange_id = exchange_config.get('exchange_id', '') proxies = exchange_config.get('proxies') if not exchange_id: - return {'success': False, 'message': '请选择交易所', 'symbols': []} + return {'success': False, 'message': 'Please select an exchange', 'symbols': []} # For these exchanges, prefer direct REST (no ccxt), aligned with local live-trading design. ex = str(exchange_id or "").strip().lower() @@ -97,7 +97,7 @@ class StrategyService: if sym.endswith("USDT") and len(sym) > 4: symbols.append(f"{sym[:-4]}/USDT") symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} if ex in ("coinbaseexchange", "coinbase_exchange"): base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.exchange.coinbase.com").rstrip("/") @@ -113,7 +113,7 @@ class StrategyService: if quote_ccy == "USDT" and base_ccy: symbols.append(f"{base_ccy}/USDT") symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} if ex == "kraken": if market_type == "spot": @@ -142,7 +142,7 @@ class StrategyService: if sym and ("perpetual" in typ or typ.startswith("pf") or sym.startswith("PF_")): symbols.append(sym) symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} if ex == "kucoin": if market_type == "spot": @@ -177,7 +177,7 @@ class StrategyService: if base_ccy: symbols.append(f"{base_ccy}/USDT") symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} if ex == "gate": base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.gateio.ws").rstrip("/") @@ -203,7 +203,7 @@ class StrategyService: if name and name.upper().endswith("_USDT"): symbols.append(name.replace("_", "/")) symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} if ex == "bitfinex": j = _req_json("https://api-pub.bitfinex.com/v2/conf/pub:list:pair:exchange") if market_type == "spot" else _req_json( @@ -225,19 +225,19 @@ class StrategyService: elif s.endswith("USDT") and len(s) > 4: symbols.append(f"{s[:-4]}/USDT") symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} - return {'success': True, 'message': '获取成功', 'symbols': symbols} + return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} + return {'success': True, 'message': 'Success', 'symbols': symbols} import ccxt - # 创建交易所实例 (public only) + # Create exchange instance (public only) exchange_class = getattr(ccxt, exchange_id, None) if not exchange_class: - return {'success': False, 'message': f'不支持的交易所: {exchange_id}', 'symbols': []} + return {'success': False, 'message': f'Unsupported exchange: {exchange_id}', 'symbols': []} exchange_config_dict = { 'enableRateLimit': True, - 'options': {'defaultType': 'swap'} # 默认为 swap + 'options': {'defaultType': 'swap'} # Default to swap } if proxies: exchange_config_dict['proxies'] = proxies @@ -251,11 +251,11 @@ class StrategyService: symbols.append(symbol) symbols.sort() - return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} except Exception as e: logger.error(f"Failed to fetch symbols: {str(e)}") - return {'success': False, 'message': f'获取交易对失败: {str(e)}', 'symbols': []} + return {'success': False, 'message': f'Failed to get trading pairs: {str(e)}', 'symbols': []} def test_exchange_connection(self, exchange_config: Dict[str, Any]) -> Dict[str, Any]: """ @@ -535,7 +535,7 @@ class StrategyService: trading_config = payload.get('trading_config') or {} exchange_config = payload.get('exchange_config') or {} - # 策略组字段 + # Strategy group fields strategy_group_id = payload.get('strategy_group_id') or '' group_base_name = payload.get('group_base_name') or '' @@ -588,10 +588,10 @@ class StrategyService: def batch_create_strategies(self, payload: Dict[str, Any]) -> Dict[str, Any]: """ - 批量创建策略(多币种) + Batch create strategies (multi-symbol) Args: - payload: 包含 symbols(数组)和其他策略配置 + payload: Contains symbols (array) and other strategy config Returns: { @@ -609,7 +609,7 @@ class StrategyService: if not base_name: raise ValueError("strategy_name is required") - # 生成策略组ID + # Generate strategy group ID strategy_group_id = str(uuid.uuid4())[:8] created_ids = [] @@ -617,10 +617,10 @@ class StrategyService: for symbol in symbols: try: - # 为每个币种创建单独的策略 + # Create individual strategy for each symbol single_payload = dict(payload) - # 解析 symbol(可能是 "Market:SYMBOL" 格式) + # Parse symbol (may be "Market:SYMBOL" format) if isinstance(symbol, str) and ':' in symbol: parts = symbol.split(':', 1) market_category = parts[0] @@ -629,13 +629,13 @@ class StrategyService: market_category = payload.get('market_category') or 'Crypto' symbol_name = symbol - # 策略名称加币种后缀 + # Strategy name with symbol suffix single_payload['strategy_name'] = f"{base_name}-{symbol_name}" single_payload['strategy_group_id'] = strategy_group_id single_payload['group_base_name'] = base_name single_payload['market_category'] = market_category - # 更新 trading_config 中的 symbol + # Update symbol in trading_config trading_config = dict(single_payload.get('trading_config') or {}) trading_config['symbol'] = symbol_name single_payload['trading_config'] = trading_config @@ -658,7 +658,7 @@ class StrategyService: } def batch_start_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]: - """批量启动策略""" + """Batch start strategies""" success_ids = [] failed_ids = [] @@ -677,7 +677,7 @@ class StrategyService: } def batch_stop_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]: - """批量停止策略""" + """Batch stop strategies""" success_ids = [] failed_ids = [] @@ -696,7 +696,7 @@ class StrategyService: } def batch_delete_strategies(self, strategy_ids: List[int]) -> Dict[str, Any]: - """批量删除策略""" + """Batch delete strategies""" success_ids = [] failed_ids = [] @@ -715,7 +715,7 @@ class StrategyService: } def get_strategies_by_group(self, strategy_group_id: str) -> List[Dict[str, Any]]: - """获取策略组内的所有策略""" + """Get all strategies in a group""" try: with get_db_connection() as db: cur = db.cursor() diff --git a/quantdinger_vue/src/api/settings.js b/quantdinger_vue/src/api/settings.js index 2c6642d..f721e94 100644 --- a/quantdinger_vue/src/api/settings.js +++ b/quantdinger_vue/src/api/settings.js @@ -44,3 +44,13 @@ export function testConnection (service, params = {}) { data: { service, ...params } }) } + +/** + * 查询 OpenRouter 账户余额 + */ +export function getOpenRouterBalance () { + return request({ + url: '/api/settings/openrouter-balance', + method: 'get' + }) +} diff --git a/quantdinger_vue/src/locales/lang/en-US.js b/quantdinger_vue/src/locales/lang/en-US.js index ec148db..6d6b29c 100644 --- a/quantdinger_vue/src/locales/lang/en-US.js +++ b/quantdinger_vue/src/locales/lang/en-US.js @@ -729,6 +729,7 @@ const locale = { 'dashboard.indicator.backtest.commission': 'Commission Rate (%)', 'dashboard.indicator.backtest.commissionHint': 'Charged on notional value (incl. leverage) per trade; both entry & exit are deducted from balance.', 'dashboard.indicator.backtest.leverage': 'Leverage', + 'dashboard.indicator.backtest.timeframe': 'Timeframe', 'dashboard.indicator.backtest.tradeDirection': 'Trade Direction', 'dashboard.indicator.backtest.longOnly': 'Long Only', 'dashboard.indicator.backtest.shortOnly': 'Short Only', @@ -737,6 +738,11 @@ const locale = { 'dashboard.indicator.backtest.rerun': 'Run Again', 'dashboard.indicator.backtest.close': 'Close', 'dashboard.indicator.backtest.running': 'Running indicator backtest...', + 'dashboard.indicator.backtest.loadingTip1': 'Fetching historical K-line data...', + 'dashboard.indicator.backtest.loadingTip2': 'Executing strategy signal calculation...', + 'dashboard.indicator.backtest.loadingTip3': 'Simulating trade execution...', + 'dashboard.indicator.backtest.loadingTip4': 'Calculating backtest metrics...', + 'dashboard.indicator.backtest.loadingTip5': 'Almost done, please wait...', 'dashboard.indicator.backtest.results': 'Backtest Results', 'dashboard.indicator.backtest.totalReturn': 'Total Return', 'dashboard.indicator.backtest.annualReturn': 'Annual Return', @@ -782,6 +788,15 @@ const locale = { 'dashboard.indicator.backtest.profit': 'P&L', 'dashboard.indicator.backtest.success': 'Backtest completed', 'dashboard.indicator.backtest.failed': 'Backtest failed, please try again later', + 'dashboard.indicator.backtest.quickSelect': 'Quick Select', + // Multi-timeframe backtest precision hints + 'dashboard.indicator.backtest.precisionMode': 'Backtest Precision Mode', + 'dashboard.indicator.backtest.estimatedCandles': 'Est. {count} candles to process', + 'dashboard.indicator.backtest.highPrecisionDesc': 'Using 1-minute candles for high-precision backtest, stop-loss/take-profit triggers are more accurate', + 'dashboard.indicator.backtest.mediumPrecisionDesc': 'Range exceeds 30 days, using 5-minute candles to balance precision and performance', + 'dashboard.indicator.backtest.standardModeWarning': 'Using Standard Backtest Mode', + 'dashboard.indicator.backtest.standardModeDesc': 'Current config does not support high-precision backtest, using strategy timeframe', + 'dashboard.indicator.backtest.onlyCryptoSupported': 'High-precision backtest only supports cryptocurrency market', 'dashboard.indicator.backtest.noIndicatorCode': 'This indicator has no backtestable code', 'dashboard.indicator.backtest.noSymbol': 'Please select a trading symbol first', 'dashboard.indicator.backtest.dateRangeExceeded': 'Backtest date range exceeded: {timeframe} timeframe allows max {maxRange}', @@ -1892,6 +1907,14 @@ const locale = { 'settings.saveSuccess': 'Settings saved successfully', 'settings.saveFailed': 'Failed to save settings', 'settings.loadFailed': 'Failed to load settings', + 'settings.openrouterBalance': 'OpenRouter Account Balance', + 'settings.queryBalance': 'Query Balance', + 'settings.balanceUsage': 'Used', + 'settings.balanceRemaining': 'Remaining', + 'settings.balanceLimit': 'Total Limit', + 'settings.balanceQuerySuccess': 'Balance query successful', + 'settings.balanceQueryFailed': 'Failed to query balance', + 'settings.balanceNotQueried': 'Click "Query Balance" to get account info', 'settings.default': 'Default', 'settings.pleaseSelect': 'Please select', 'settings.inputApiKey': 'Enter key', diff --git a/quantdinger_vue/src/locales/lang/zh-CN.js b/quantdinger_vue/src/locales/lang/zh-CN.js index 0007973..3b3472b 100644 --- a/quantdinger_vue/src/locales/lang/zh-CN.js +++ b/quantdinger_vue/src/locales/lang/zh-CN.js @@ -638,6 +638,7 @@ const locale = { 'dashboard.indicator.backtest.commission': '手续费率(%)', 'dashboard.indicator.backtest.commissionHint': '按名义价值(含杠杆)收取;每笔成交(开/平仓)都会从余额扣除。', 'dashboard.indicator.backtest.leverage': '杠杆倍率', +'dashboard.indicator.backtest.timeframe': 'K线周期', 'dashboard.indicator.backtest.tradeDirection': '交易方向', 'dashboard.indicator.backtest.longOnly': '做多', 'dashboard.indicator.backtest.shortOnly': '做空', @@ -646,6 +647,11 @@ const locale = { 'dashboard.indicator.backtest.rerun': '重新回测', 'dashboard.indicator.backtest.close': '关闭', 'dashboard.indicator.backtest.running': '正在进行指标回测...', +'dashboard.indicator.backtest.loadingTip1': '正在获取历史K线数据...', +'dashboard.indicator.backtest.loadingTip2': '正在执行策略信号计算...', +'dashboard.indicator.backtest.loadingTip3': '正在模拟交易执行...', +'dashboard.indicator.backtest.loadingTip4': '正在计算回测指标...', +'dashboard.indicator.backtest.loadingTip5': '即将完成,请稍候...', 'dashboard.indicator.backtest.results': '回测结果', 'dashboard.indicator.backtest.totalReturn': '总收益率', 'dashboard.indicator.backtest.annualReturn': '年化收益', @@ -687,6 +693,15 @@ const locale = { 'dashboard.indicator.backtest.profit': '盈亏', 'dashboard.indicator.backtest.success': '回测完成', 'dashboard.indicator.backtest.failed': '回测失败,请稍后重试', +'dashboard.indicator.backtest.quickSelect': '快速选择', +// 多时间框架回测精度提示 +'dashboard.indicator.backtest.precisionMode': '回测精度模式', +'dashboard.indicator.backtest.estimatedCandles': '预计处理约 {count} 根K线', +'dashboard.indicator.backtest.highPrecisionDesc': '使用1分钟级别K线进行高精度回测,止损止盈触发更精确', +'dashboard.indicator.backtest.mediumPrecisionDesc': '回测区间超过30天,使用5分钟级别K线以平衡精度与性能', +'dashboard.indicator.backtest.standardModeWarning': '使用标准回测模式', +'dashboard.indicator.backtest.standardModeDesc': '当前配置不支持高精度回测,将使用策略K线进行回测', +'dashboard.indicator.backtest.onlyCryptoSupported': '高精度回测仅支持加密货币市场', 'dashboard.indicator.backtest.noIndicatorCode': '该指标没有可回测的代码', 'dashboard.indicator.backtest.noSymbol': '请先选择交易标的', 'dashboard.indicator.backtest.dateRangeExceeded': '回测时间范围超出限制:{timeframe}周期最多可回测{maxRange}', @@ -1631,6 +1646,14 @@ const locale = { 'settings.saveSuccess': '设置保存成功', 'settings.saveFailed': '保存设置失败', 'settings.loadFailed': '加载配置失败', +'settings.openrouterBalance': 'OpenRouter 账户余额', +'settings.queryBalance': '查询余额', +'settings.balanceUsage': '已使用', +'settings.balanceRemaining': '剩余额度', +'settings.balanceLimit': '总限额', +'settings.balanceQuerySuccess': '余额查询成功', +'settings.balanceQueryFailed': '余额查询失败', +'settings.balanceNotQueried': '点击"查询余额"获取账户信息', 'settings.default': '默认', 'settings.pleaseSelect': '请选择', 'settings.inputApiKey': '请输入密钥', diff --git a/quantdinger_vue/src/locales/lang/zh-TW.js b/quantdinger_vue/src/locales/lang/zh-TW.js index bea7f95..9b5b113 100644 --- a/quantdinger_vue/src/locales/lang/zh-TW.js +++ b/quantdinger_vue/src/locales/lang/zh-TW.js @@ -638,6 +638,7 @@ const locale = { 'dashboard.indicator.backtest.commission': '手續費率(%)', 'dashboard.indicator.backtest.commissionHint': '按名義價值(含槓桿)收取;每筆成交(開/平倉)都會從餘額扣除。', 'dashboard.indicator.backtest.leverage': '槓杆倍率', + 'dashboard.indicator.backtest.timeframe': 'K線週期', 'dashboard.indicator.backtest.tradeDirection': '交易方向', 'dashboard.indicator.backtest.longOnly': '做多', 'dashboard.indicator.backtest.shortOnly': '做空', @@ -646,6 +647,11 @@ const locale = { 'dashboard.indicator.backtest.rerun': '重新回測', 'dashboard.indicator.backtest.close': '關閉', 'dashboard.indicator.backtest.running': '正在進行指標回測...', + 'dashboard.indicator.backtest.loadingTip1': '正在獲取歷史K線數據...', + 'dashboard.indicator.backtest.loadingTip2': '正在執行策略信號計算...', + 'dashboard.indicator.backtest.loadingTip3': '正在模擬交易執行...', + 'dashboard.indicator.backtest.loadingTip4': '正在計算回測指標...', + 'dashboard.indicator.backtest.loadingTip5': '即將完成,請稍候...', 'dashboard.indicator.backtest.results': '回測結果', 'dashboard.indicator.backtest.totalReturn': '總收益率', 'dashboard.indicator.backtest.annualReturn': '年化收益', @@ -687,6 +693,15 @@ const locale = { 'dashboard.indicator.backtest.profit': '盈虧', 'dashboard.indicator.backtest.success': '回測完成', 'dashboard.indicator.backtest.failed': '回測失敗,請稍後重試', + 'dashboard.indicator.backtest.quickSelect': '快速選擇', + // 多時間框架回測精度提示 + 'dashboard.indicator.backtest.precisionMode': '回測精度模式', + 'dashboard.indicator.backtest.estimatedCandles': '預計處理約 {count} 根K線', + 'dashboard.indicator.backtest.highPrecisionDesc': '使用1分鐘級別K線進行高精度回測,止損止盈觸發更精確', + 'dashboard.indicator.backtest.mediumPrecisionDesc': '回測區間超過30天,使用5分鐘級別K線以平衡精度與性能', + 'dashboard.indicator.backtest.standardModeWarning': '使用標準回測模式', + 'dashboard.indicator.backtest.standardModeDesc': '當前配置不支持高精度回測,將使用策略K線進行回測', + 'dashboard.indicator.backtest.onlyCryptoSupported': '高精度回測僅支持加密貨幣市場', 'dashboard.indicator.backtest.noIndicatorCode': '該指標沒有可回測的代碼', 'dashboard.indicator.backtest.noSymbol': '請先選擇交易標的', 'dashboard.indicator.backtest.dateRangeExceeded': '回測時間範圍超出限制:{timeframe}周期最多可回測{maxRange}', @@ -1631,6 +1646,14 @@ const locale = { 'settings.saveSuccess': '設置保存成功', 'settings.saveFailed': '保存設置失敗', 'settings.loadFailed': '加載配置失敗', + 'settings.openrouterBalance': 'OpenRouter 賬戶餘額', + 'settings.queryBalance': '查詢餘額', + 'settings.balanceUsage': '已使用', + 'settings.balanceRemaining': '剩餘額度', + 'settings.balanceLimit': '總限額', + 'settings.balanceQuerySuccess': '餘額查詢成功', + 'settings.balanceQueryFailed': '餘額查詢失敗', + 'settings.balanceNotQueried': '點擊"查詢餘額"獲取賬戶信息', 'settings.default': '默認', 'settings.pleaseSelect': '請選擇', 'settings.inputApiKey': '請輸入密鑰', diff --git a/quantdinger_vue/src/views/indicator-analysis/components/BacktestModal.vue b/quantdinger_vue/src/views/indicator-analysis/components/BacktestModal.vue index 7abcb13..0bd6c47 100644 --- a/quantdinger_vue/src/views/indicator-analysis/components/BacktestModal.vue +++ b/quantdinger_vue/src/views/indicator-analysis/components/BacktestModal.vue @@ -309,12 +309,61 @@
+ + > + + + + + +
+ {{ $t('dashboard.indicator.backtest.quickSelect') || '快速选择' }}: + + {{ preset.label }} + +
@@ -324,6 +373,7 @@ style="width: 100%" :disabled-date="disabledStartDate" :placeholder="$t('dashboard.indicator.backtest.selectStartDate')" + @change="onDateChange" /> @@ -334,6 +384,7 @@ style="width: 100%" :disabled-date="disabledEndDate" :placeholder="$t('dashboard.indicator.backtest.selectEndDate')" + @change="onDateChange" /> @@ -413,7 +464,24 @@ - + + + + 1m + 5m + 15m + 30m + 1H + 4H + 1D + 1W + + +
@@ -501,12 +569,21 @@ - +
- - - -
{{ $t('dashboard.indicator.backtest.running') }}
+
+
+
+
+
+
+
+
+
+
+
{{ $t('dashboard.indicator.backtest.running') }}
+
{{ loadingTip }}
+
@@ -581,6 +658,8 @@ export default { return { form: this.$form.createForm(this), loading: false, + loadingTip: '', + loadingTimer: null, currentStep: 0, hasResult: false, backtestRunId: null, @@ -588,6 +667,9 @@ export default { // Step1 UI state (Ant Form getFieldValue is not reactive) trailingEnabledUi: false, entryPctMaxUi: 100, + precisionInfo: null, // 回测精度信息 + selectedDatePreset: null, // 当前选中的快捷日期 + selectedTimeframe: '1D', // 用户选择的时间周期(默认使用props传入的值) result: { totalReturn: 0, annualReturn: 0, @@ -609,26 +691,67 @@ export default { // 根据周期计算最大回测时间范围 maxBacktestRange () { // 1分钟线:最多1个月 - if (this.timeframe === '1m') { - return { months: 1, label: '1个月' } + const tf = this.selectedTimeframe || this.timeframe || '1D' + if (tf === '1m') { + return { days: 30, label: '1个月' } } // 5分钟线:最多6个月 - if (this.timeframe === '5m') { - return { months: 6, label: '6个月' } + if (tf === '5m') { + return { days: 180, label: '6个月' } } // 15分钟和30分钟:最多1年 - if (['15m', '30m'].includes(this.timeframe)) { - return { years: 1, label: '1年' } + if (['15m', '30m'].includes(tf)) { + return { days: 365, label: '1年' } } // 1小时及以上:最多3年 - return { years: 3, label: '3年' } + return { days: 1095, label: '3年' } + }, + // 根据时间周期推荐的默认日期范围 - 统一默认30天 + recommendedRange () { + return { days: 30, label: '30天', key: '30d' } + }, + // 合并提示框的类型 + combinedAlertType () { + if (this.precisionInfo && this.precisionInfo.enabled) { + return this.precisionInfo.precision === 'high' ? 'success' : 'info' + } + if (this.precisionInfo && !this.precisionInfo.enabled && this.market && this.market.toLowerCase() === 'crypto') { + return 'warning' + } + return 'info' + }, + // 快捷日期选项 - 所有周期都包含30天作为默认选项 + datePresets () { + const presets = [] + const tf = this.selectedTimeframe || this.timeframe || '1D' + // 根据时间周期动态生成合理的快捷选项 + // 使用国际通用格式:7D, 14D, 30D, 3M, 6M, 1Y + if (tf === '1m') { + presets.push({ key: '7d', days: 7, label: '7D' }) + presets.push({ key: '14d', days: 14, label: '14D' }) + presets.push({ key: '30d', days: 30, label: '30D' }) + } else if (tf === '5m') { + presets.push({ key: '14d', days: 14, label: '14D' }) + presets.push({ key: '30d', days: 30, label: '30D' }) + presets.push({ key: '90d', days: 90, label: '3M' }) + presets.push({ key: '180d', days: 180, label: '6M' }) + } else if (['15m', '30m'].includes(tf)) { + presets.push({ key: '30d', days: 30, label: '30D' }) + presets.push({ key: '90d', days: 90, label: '3M' }) + presets.push({ key: '180d', days: 180, label: '6M' }) + presets.push({ key: '365d', days: 365, label: '1Y' }) + } else { + // 1H, 4H, 1D, 1W + presets.push({ key: '30d', days: 30, label: '30D' }) + presets.push({ key: '90d', days: 90, label: '3M' }) + presets.push({ key: '180d', days: 180, label: '6M' }) + presets.push({ key: '365d', days: 365, label: '1Y' }) + } + return presets }, defaultStartDate () { - // 默认开始日期:根据周期限制 - if (this.maxBacktestRange.months) { - return moment().subtract(this.maxBacktestRange.months, 'months') - } - return moment().subtract(1, 'years') // 默认1年 + // 默认开始日期:使用推荐范围 + return moment().subtract(this.recommendedRange.days, 'days') }, defaultEndDate () { // 默认结束日期:今天 @@ -636,10 +759,7 @@ export default { }, // 最早可选日期 earliestDate () { - if (this.maxBacktestRange.months) { - return moment().subtract(this.maxBacktestRange.months, 'months') - } - return moment().subtract(this.maxBacktestRange.years, 'years') + return moment().subtract(this.maxBacktestRange.days, 'days') }, labelCol () { // Wider label area in Step 1 to avoid overlap with inputs @@ -662,6 +782,9 @@ export default { this.step1CollapseKeys = ['risk'] this.trailingEnabledUi = false this.entryPctMaxUi = 100 + this.precisionInfo = null + this.selectedDatePreset = null + this.selectedTimeframe = this.timeframe || '1D' // 初始化为props传入的时间周期 this.result = { totalReturn: 0, annualReturn: 0, @@ -681,6 +804,10 @@ export default { // Sync non-reactive form values into UI state this.trailingEnabledUi = !!this.form.getFieldValue('trailingEnabled') this.recalcEntryPctMaxUi() + // 默认选中30天 + this.selectedDatePreset = '30d' + // 弹窗打开时立即获取精度信息(使用默认日期范围) + this.fetchPrecisionInfo() } }) } else { @@ -873,29 +1000,93 @@ export default { // 如果已选择开始日期,限制结束日期不能超过开始日期+最大回测范围 const startDate = this.form.getFieldValue('startDate') if (startDate) { - const maxDays = this.maxBacktestRange.months - ? Math.floor(this.maxBacktestRange.months * 30.44) - : (this.maxBacktestRange.years * 365) + const maxDays = this.maxBacktestRange.days || 365 const maxEndDate = moment(startDate).add(maxDays, 'days') if (current > maxEndDate.endOf('day')) return true } return false }, + // 应用快捷日期选择 + applyDatePreset (preset) { + this.selectedDatePreset = preset.key + const endDate = moment() + const startDate = moment().subtract(preset.days, 'days') + this.form.setFieldsValue({ + startDate: startDate, + endDate: endDate + }) + // 更新精度信息 + this.fetchPrecisionInfo(startDate, endDate) + }, + // 获取精度信息 + async fetchPrecisionInfo (startDate, endDate) { + // 如果没有传入日期,尝试从表单获取或使用默认值 + if (!startDate || !endDate) { + startDate = this.form ? this.form.getFieldValue('startDate') : null + endDate = this.form ? this.form.getFieldValue('endDate') : null + } + // 如果还是没有,使用默认值 + if (!startDate) startDate = this.defaultStartDate + if (!endDate) endDate = this.defaultEndDate + + // 仅加密货币市场支持高精度回测 + if (!this.market || this.market.toLowerCase() !== 'crypto') { + this.precisionInfo = { + enabled: false, + reason: 'only_crypto', + message: this.$t('dashboard.indicator.backtest.onlyCryptoSupported') + } + return + } + + try { + const response = await request({ + url: '/api/indicator/backtest/precision-info', + method: 'post', + data: { + market: this.market, + startDate: startDate.format('YYYY-MM-DD'), + endDate: endDate.format('YYYY-MM-DD') + } + }) + + if (response.code === 1 && response.data) { + this.precisionInfo = response.data + } + } catch (e) { + // 静默失败,不影响正常使用 + this.precisionInfo = null + } + }, + // 时间周期变化时重新获取精度信息和更新快捷日期选项 + onTimeframeChange () { + // 重置日期选择为默认30天 + this.selectedDatePreset = '30d' + const endDate = moment() + const startDate = moment().subtract(30, 'days') + this.form.setFieldsValue({ + startDate: startDate, + endDate: endDate + }) + // 重新获取精度信息 + this.fetchPrecisionInfo(startDate, endDate) + }, + // 日期变化时获取精度信息 + onDateChange () { + this.selectedDatePreset = null // 清除快捷选择状态 + this.$nextTick(() => { + this.fetchPrecisionInfo() + }) + }, // 验证日期范围 validateDateRange (startDate, endDate) { if (!startDate || !endDate) return true const diffDays = endDate.diff(startDate, 'days') - let maxDays = 0 - if (this.maxBacktestRange.months) { - // 对于月份限制,使用实际月份天数(约30.44天/月) - maxDays = Math.floor(this.maxBacktestRange.months * 30.44) - } else if (this.maxBacktestRange.years) { - maxDays = this.maxBacktestRange.years * 365 - } + const maxDays = this.maxBacktestRange.days || 365 if (diffDays > maxDays) { this.$message.error(this.$t('dashboard.indicator.backtest.dateRangeExceededDays', { - timeframe: this.timeframe, + timeframe: this.selectedTimeframe || this.timeframe, maxRange: this.maxBacktestRange.label, maxDays })) @@ -941,6 +1132,29 @@ export default { this.hasResult = false this.backtestRunId = null }, + // 加载动画提示轮播 + startLoadingAnimation () { + const tips = [ + this.$t('dashboard.indicator.backtest.loadingTip1') || '正在获取历史K线数据...', + this.$t('dashboard.indicator.backtest.loadingTip2') || '正在执行策略信号计算...', + this.$t('dashboard.indicator.backtest.loadingTip3') || '正在模拟交易执行...', + this.$t('dashboard.indicator.backtest.loadingTip4') || '正在计算回测指标...', + this.$t('dashboard.indicator.backtest.loadingTip5') || '即将完成,请稍候...' + ] + let idx = 0 + this.loadingTip = tips[0] + this.loadingTimer = setInterval(() => { + idx = (idx + 1) % tips.length + this.loadingTip = tips[idx] + }, 2000) + }, + stopLoadingAnimation () { + if (this.loadingTimer) { + clearInterval(this.loadingTimer) + this.loadingTimer = null + } + this.loadingTip = '' + }, async handleRunBacktest () { // Only validate Step 2 fields (dates/capital/fees/etc.) const step2Fields = ['startDate', 'endDate', 'initialCapital', 'commission', 'leverage', 'tradeDirection', 'slippage'] @@ -970,6 +1184,7 @@ export default { this.loading = true this.hasResult = false + this.startLoadingAnimation() try { const pct = (v) => Number(v || 0) / 100 @@ -1019,16 +1234,18 @@ export default { indicatorId: this.indicator.id, symbol: this.symbol, market: this.market, - timeframe: this.timeframe, + timeframe: this.selectedTimeframe || this.timeframe, startDate: values.startDate.format('YYYY-MM-DD'), - endDate: values.endDate.format('YYYY-MM-DD'), - initialCapital: values.initialCapital, - commission: pct(values.commission || 0), - slippage: pct(values.slippage || 0), - leverage: values.leverage || 1, - tradeDirection: values.tradeDirection || 'long', - strategyConfig - } + endDate: values.endDate.format('YYYY-MM-DD'), + initialCapital: values.initialCapital, + commission: pct(values.commission || 0), + slippage: pct(values.slippage || 0), + leverage: values.leverage || 1, + tradeDirection: values.tradeDirection || 'long', + strategyConfig, + // 启用多时间框架高精度回测(加密货币市场) + enableMtf: this.market && this.market.toLowerCase() === 'crypto' + } const response = await request({ url: '/api/indicator/backtest', @@ -1054,6 +1271,7 @@ export default { } catch (error) { this.$message.error(this.$t('dashboard.indicator.backtest.failed')) } finally { + this.stopLoadingAnimation() this.loading = false } }) @@ -1249,6 +1467,22 @@ export default { margin-bottom: 24px; } +.precision-info { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.date-quick-select { + display: flex; + align-items: center; + padding: 8px 12px; + background: #fafafa; + border-radius: 6px; + border: 1px solid #f0f0f0; +} + .result-section { margin-top: 24px; } @@ -1344,6 +1578,7 @@ export default { left: 0; right: 0; bottom: 0; + z-index: 100; background: rgba(255, 255, 255, 0.9); display: flex; flex-direction: column; @@ -1352,10 +1587,56 @@ export default { z-index: 100; border-radius: 8px; + .loading-content { + text-align: center; + } + + .loading-animation { + margin-bottom: 20px; + } + + .chart-bars { + display: flex; + justify-content: center; + align-items: flex-end; + height: 60px; + gap: 6px; + } + + .bar { + width: 8px; + background: linear-gradient(180deg, #1890ff 0%, #52c41a 100%); + border-radius: 4px; + animation: barPulse 1.2s ease-in-out infinite; + } + + .bar1 { height: 20px; animation-delay: 0s; } + .bar2 { height: 35px; animation-delay: 0.1s; } + .bar3 { height: 50px; animation-delay: 0.2s; } + .bar4 { height: 35px; animation-delay: 0.3s; } + .bar5 { height: 20px; animation-delay: 0.4s; } + + @keyframes barPulse { + 0%, 100% { transform: scaleY(1); opacity: 0.7; } + 50% { transform: scaleY(1.5); opacity: 1; } + } + .loading-text { - margin-top: 16px; - font-size: 14px; + font-size: 16px; + font-weight: 500; color: #1890ff; + margin-bottom: 8px; + } + + .loading-subtext { + font-size: 13px; + color: #666; + animation: fadeInOut 2s ease-in-out infinite; + } + + @keyframes fadeInOut { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } } } diff --git a/quantdinger_vue/src/views/settings/index.vue b/quantdinger_vue/src/views/settings/index.vue index 863fbf3..459bf70 100644 --- a/quantdinger_vue/src/views/settings/index.vue +++ b/quantdinger_vue/src/views/settings/index.vue @@ -36,6 +36,59 @@ + +
+ +
+ + + {{ $t('settings.openrouterBalance') || 'OpenRouter 账户余额' }} + + + + {{ $t('settings.queryBalance') || '查询余额' }} + +
+
+ + + + + + + + + + + +
+ Free Tier +
+
+
+ + {{ $t('settings.balanceNotQueried') || '点击"查询余额"获取账户信息' }} +
+
+
+