Files
DinQuant/backend_api_python/app/services/backtest.py
T
TIANHE f43312a858 creat
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2025-12-29 03:06:49 +08:00

2868 lines
146 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
回测服务
"""
import math
import traceback
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
import pandas as pd
import numpy as np
from app.data_sources import DataSourceFactory
from app.utils.logger import get_logger
logger = get_logger(__name__)
class BacktestService:
"""回测服务"""
# 时间周期秒数
TIMEFRAME_SECONDS = {
'1m': 60, '5m': 300, '15m': 900, '30m': 1800,
'1H': 3600, '4H': 14400, '1D': 86400, '1W': 604800
}
def run_code_strategy(
self,
code: str,
symbol: str,
timeframe: str,
limit: int = 1000
) -> Dict[str, Any]:
"""
运行策略代码并返回代码中定义的 'output' 变量。
用于信号机器人的预览功能。
"""
# 1. 计算时间范围
end_date = datetime.now()
tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 3600)
start_date = end_date - timedelta(seconds=tf_seconds * limit)
# 2. 获取数据 (假设 market='crypto',后续可优化)
df = self._fetch_kline_data('crypto', symbol, timeframe, start_date, end_date)
if df.empty:
return {"error": "No data found"}
# 3. 准备执行环境
local_vars = {
'df': df.copy(),
'np': np,
'pd': pd,
'output': {} # 默认空输出
}
# 4. 执行代码
try:
import builtins
def safe_import(name, *args, **kwargs):
allowed = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time']
if name in allowed or name.split('.')[0] in allowed:
return builtins.__import__(name, *args, **kwargs)
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 ['eval', 'exec', 'compile', 'open', 'input', 'exit']}
safe_builtins['__import__'] = safe_import
exec_env = local_vars.copy()
exec_env['__builtins__'] = safe_builtins
exec(code, exec_env)
return exec_env.get('output', {})
except Exception as e:
logger.error(f"Strategy execution failed: {e}")
logger.error(traceback.format_exc())
return {"error": str(e)}
def run(
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
) -> Dict[str, Any]:
"""
运行回测
Args:
indicator_code: 指标代码
market: 市场类型
symbol: 交易标的
timeframe: 时间周期
start_date: 开始日期
end_date: 结束日期
initial_capital: 初始资金
commission: 手续费率
slippage: 滑点
Returns:
回测结果
"""
# 1. 获取K线数据
df = self._fetch_kline_data(market, symbol, timeframe, start_date, end_date)
if df.empty:
raise ValueError("回测日期范围内没有K线数据")
# 2. 执行指标代码获取信号(传入回测参数)
backtest_params = {
'leverage': leverage,
'initial_capital': initial_capital,
'commission': commission,
'trade_direction': trade_direction
}
signals = self._execute_indicator(indicator_code, df, backtest_params)
# 3. 模拟交易
equity_curve, trades, total_commission = self._simulate_trading(
df, signals, initial_capital, commission, slippage, leverage, trade_direction, strategy_config
)
# 4. 计算指标
metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission)
# 5. 格式化结果
return self._format_result(metrics, equity_curve, trades)
def _fetch_kline_data(
self,
market: str,
symbol: str,
timeframe: str,
start_date: datetime,
end_date: datetime
) -> pd.DataFrame:
"""获取K线数据并转换为DataFrame"""
# 计算需要的K线数量
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天)
before_time = int((end_date + timedelta(days=1)).timestamp())
# 获取数据
kline_data = DataSourceFactory.get_kline(
market=market,
symbol=symbol,
timeframe=timeframe,
limit=limit,
before_time=before_time
)
if not kline_data:
logger.warning("未获取到K线数据")
return pd.DataFrame()
if kline_data:
first_time = datetime.fromtimestamp(kline_data[0]['time'])
last_time = datetime.fromtimestamp(kline_data[-1]['time'])
# 转换为DataFrame
df = pd.DataFrame(kline_data)
df['time'] = pd.to_datetime(df['time'], unit='s')
df = df.set_index('time')
if len(df) > 0:
pass
# 过滤日期范围
df = df[(df.index >= start_date) & (df.index <= end_date)].copy()
if len(df) > 0:
pass
return df
def _execute_indicator(self, code: str, df: pd.DataFrame, backtest_params: dict = None):
"""执行指标代码获取信号
Args:
code: 指标代码
df: K线数据
backtest_params: 回测参数字典(leverage, initial_capital, commission, trade_direction
"""
# Supported indicator signal formats:
# - Preferred (simple): df['buy'], df['sell'] as boolean
# - Backtest/internal (4-way): df['open_long'], df['close_long'], df['open_short'], df['close_short'] as boolean
signals = pd.Series(0, index=df.index)
try:
# 准备执行环境
local_vars = {
'df': df.copy(),
'open': df['open'],
'high': df['high'],
'low': df['low'],
'close': df['close'],
'volume': df['volume'],
'signals': signals,
'np': np,
'pd': pd,
}
# 添加回测参数到执行环境(如果提供了)
if backtest_params:
local_vars['backtest_params'] = backtest_params
local_vars['leverage'] = backtest_params.get('leverage', 1)
local_vars['initial_capital'] = backtest_params.get('initial_capital', 10000)
local_vars['commission'] = backtest_params.get('commission', 0.0002)
local_vars['trade_direction'] = backtest_params.get('trade_direction', 'both')
# 添加技术指标函数
local_vars.update(self._get_indicator_functions())
# 添加安全的内置函数(保留完整的 builtins 以支持 lambda 等语法)
# 但移除危险的函数如 eval, exec, open 等
import builtins
# 创建受限的 __import__ 函数,只允许导入已经加载的安全模块
def safe_import(name, *args, **kwargs):
"""只允许导入 numpy, pandas, math, json 等安全模块"""
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}")
safe_builtins = {k: getattr(builtins, k) for k in dir(builtins)
if not k.startswith('_') and k not in [
'eval', 'exec', 'compile', 'open', 'input',
'help', 'exit', 'quit',
'copyright', 'credits', 'license'
]}
# 添加受限的 __import__
safe_builtins['__import__'] = safe_import
# 创建统一的执行环境(globals 和 locals 使用同一个字典)
# 这样函数内部才能访问到 np, pd 等变量
exec_env = local_vars.copy()
exec_env['__builtins__'] = safe_builtins
# 预执行 import 语句,确保 np 和 pd 可用
pre_import_code = """
import numpy as np
import pandas as pd
"""
exec(pre_import_code, exec_env)
# 安全检查:验证代码不包含危险操作
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}")
# 安全执行用户代码(带超时)
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秒)
)
if not exec_result['success']:
raise RuntimeError(f"代码执行失败: {exec_result['error']}")
# Get the executed df
executed_df = exec_env.get('df', df)
# Validation: if chart signals are provided, df['buy']/df['sell'] must exist for backtest normalization.
# This keeps indicator scripts simple and consistent (chart=buy/sell, execution=normalized in backend).
output_obj = exec_env.get('output')
has_output_signals = isinstance(output_obj, dict) and isinstance(output_obj.get('signals'), list) and len(output_obj.get('signals')) > 0
if has_output_signals and not all(col in executed_df.columns for col in ['buy', 'sell']):
raise ValueError(
"Invalid indicator script: output['signals'] is provided, but df['buy'] and df['sell'] are missing. "
"Please set df['buy'] and df['sell'] as boolean columns (len == len(df))."
)
# Extract signals from executed df
if all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']):
signals = {
'open_long': executed_df['open_long'].fillna(False).astype(bool),
'close_long': executed_df['close_long'].fillna(False).astype(bool),
'open_short': executed_df['open_short'].fillna(False).astype(bool),
'close_short': executed_df['close_short'].fillna(False).astype(bool)
}
# Convention: backtest uses 4-way signals only.
# Position sizing, TP/SL, trailing, etc must be handled by strategy_config / strategy logic.
elif all(col in executed_df.columns for col in ['buy', 'sell']):
# Simple buy/sell signals (recommended for indicator authors)
signals = {
'buy': executed_df['buy'].fillna(False).astype(bool),
'sell': executed_df['sell'].fillna(False).astype(bool)
}
else:
raise ValueError(
"Indicator must define either 4-way columns "
"(df['open_long'], df['close_long'], df['open_short'], df['close_short']) "
"or simple columns (df['buy'], df['sell'])."
)
except Exception as e:
logger.error(f"指标代码执行错误: {e}")
logger.error(traceback.format_exc())
return signals
def _get_indicator_functions(self) -> Dict:
"""获取技术指标函数"""
def SMA(series, period):
return series.rolling(window=period).mean()
def EMA(series, period):
return series.ewm(span=period, adjust=False).mean()
def RSI(series, period=14):
delta = series.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
return 100 - (100 / (1 + rs))
def MACD(series, fast=12, slow=26, signal=9):
exp1 = series.ewm(span=fast, adjust=False).mean()
exp2 = series.ewm(span=slow, adjust=False).mean()
macd = exp1 - exp2
macd_signal = macd.ewm(span=signal, adjust=False).mean()
macd_hist = macd - macd_signal
return macd, macd_signal, macd_hist
def BOLL(series, period=20, std_dev=2):
middle = series.rolling(window=period).mean()
std = series.rolling(window=period).std()
upper = middle + std_dev * std
lower = middle - std_dev * std
return upper, middle, lower
def ATR(high, low, close, period=14):
tr1 = high - low
tr2 = abs(high - close.shift())
tr3 = abs(low - close.shift())
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
return tr.rolling(window=period).mean()
def CROSSOVER(series1, series2):
return (series1 > series2) & (series1.shift(1) <= series2.shift(1))
def CROSSUNDER(series1, series2):
return (series1 < series2) & (series1.shift(1) >= series2.shift(1))
return {
'SMA': SMA,
'EMA': EMA,
'RSI': RSI,
'MACD': MACD,
'BOLL': BOLL,
'ATR': ATR,
'CROSSOVER': CROSSOVER,
'CROSSUNDER': CROSSUNDER,
}
def _simulate_trading(
self,
df: pd.DataFrame,
signals,
initial_capital: float,
commission: float,
slippage: float,
leverage: int = 1,
trade_direction: str = 'long',
strategy_config: Optional[Dict[str, Any]] = None
) -> tuple:
"""
模拟交易
Args:
signals: 信号,可以是 pd.Series (旧格式) 或 dict (新格式四种信号)
trade_direction: 交易方向
- 'long': 只做多 (buy->sell)
- 'short': 只做空 (sell->buy, 收益反向)
- 'both': 双向 (buy->sell做多 + sell->buy做空)
"""
# Normalize supported signal formats into 4-way signals.
if not isinstance(signals, dict):
raise ValueError("signals must be a dict (either 4-way or buy/sell).")
if all(k in signals for k in ['open_long', 'close_long', 'open_short', 'close_short']):
norm = signals
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 = (trade_direction or 'both')
td = str(td).lower()
if td not in ['long', 'short', 'both']:
td = 'both'
# Mapping rules:
# - long: buy=open_long, sell=close_long
# - short: sell=open_short, buy=close_short
# - both: buy=open_long+close_short, sell=open_short+close_long
if td == 'long':
norm = {
'open_long': buy,
'close_long': sell,
'open_short': pd.Series([False] * len(df), index=df.index),
'close_short': pd.Series([False] * len(df), index=df.index),
}
elif td == 'short':
norm = {
'open_long': pd.Series([False] * len(df), index=df.index),
'close_long': pd.Series([False] * len(df), index=df.index),
'open_short': sell,
'close_short': buy,
}
else:
norm = {
'open_long': buy,
'close_long': sell,
'open_short': sell,
'close_short': buy,
}
else:
raise ValueError("signals dict must contain either 4-way keys or buy/sell keys.")
return self._simulate_trading_new_format(df, norm, initial_capital, commission, slippage, leverage, trade_direction, strategy_config)
def _simulate_trading_new_format(
self,
df: pd.DataFrame,
signals: dict,
initial_capital: float,
commission: float,
slippage: float,
leverage: int = 1,
trade_direction: str = 'both',
strategy_config: Optional[Dict[str, Any]] = None
) -> tuple:
"""
使用新格式四种信号进行交易模拟(支持仓位管理和加仓)
Args:
trade_direction: 交易方向 ('long', 'short', 'both')
"""
equity_curve = []
trades = []
total_commission_paid = 0
is_liquidated = False
liquidation_price = 0
min_capital_to_trade = 1.0 # 余额低于该值则视为赔光,不再开新单
capital = initial_capital
position = 0 # 正数=多头持仓,负数=空头持仓
entry_price = 0 # 平均开仓价格
position_type = None # 'long' or 'short'
# 仓位管理相关
has_position_management = 'add_long' in signals and 'add_short' in signals
position_batches = [] # 存储每批持仓:[{'price': xxx, 'amount': xxx}, ...]
# --- Strategy config: signals + parameters = strategy (sent from BacktestModal as strategyConfig) ---
cfg = strategy_config or {}
exec_cfg = cfg.get('execution') or {}
# Signal confirmation / execution timing:
# - bar_close: execute on the same bar close (more aggressive)
# - next_bar_open: execute on next bar open after signal is confirmed on bar close (recommended, closer to live)
signal_timing = str(exec_cfg.get('signalTiming') or 'next_bar_open').strip().lower()
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)
# Risk percentages are defined on margin PnL; convert to price move thresholds by leverage.
lev = max(int(leverage or 1), 1)
stop_loss_pct_eff = stop_loss_pct / lev
take_profit_pct_eff = take_profit_pct / lev
trailing_pct_eff = trailing_pct / lev
trailing_activation_pct_eff = trailing_activation_pct / lev
# Conflict rule (TP vs trailing):
# - If trailing is enabled, it takes precedence.
# - If activationPct is not provided, reuse takeProfitPct as the trailing activation threshold.
# - When trailing is enabled, fixed take-profit exits are disabled to avoid ambiguity.
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
# IMPORTANT: risk percentages are defined on margin PnL (user expectation):
# e.g. 10x leverage + 5% SL means ~0.5% adverse price move.
lev = max(int(leverage or 1), 1)
stop_loss_pct_eff = stop_loss_pct / lev
take_profit_pct_eff = take_profit_pct / lev
trailing_pct_eff = trailing_pct / lev
trailing_activation_pct_eff = trailing_activation_pct / lev
pos_cfg = cfg.get('position') or {}
entry_pct_cfg = float(pos_cfg.get('entryPct') or 1.0) # expected 0~1
# Accept both 0~1 and 0~100 inputs (some clients may send percent units).
if entry_pct_cfg > 1:
entry_pct_cfg = entry_pct_cfg / 100.0
entry_pct_cfg = max(0.0, min(entry_pct_cfg, 1.0))
scale_cfg = cfg.get('scale') or {}
trend_add_cfg = scale_cfg.get('trendAdd') or {}
dca_add_cfg = scale_cfg.get('dcaAdd') or {}
trend_reduce_cfg = scale_cfg.get('trendReduce') or {}
adverse_reduce_cfg = scale_cfg.get('adverseReduce') or {}
trend_add_enabled = bool(trend_add_cfg.get('enabled'))
trend_add_step_pct = float(trend_add_cfg.get('stepPct') or 0.0)
trend_add_size_pct = float(trend_add_cfg.get('sizePct') or 0.0)
trend_add_max_times = int(trend_add_cfg.get('maxTimes') or 0)
dca_add_enabled = bool(dca_add_cfg.get('enabled'))
dca_add_step_pct = float(dca_add_cfg.get('stepPct') or 0.0)
dca_add_size_pct = float(dca_add_cfg.get('sizePct') or 0.0)
dca_add_max_times = int(dca_add_cfg.get('maxTimes') or 0)
# Prevent logical conflict: trend scale-in and mean-reversion scale-in should not run together.
# Otherwise both may trigger in the same candle (high/low both hit), causing double scaling unexpectedly.
if trend_add_enabled and dca_add_enabled:
dca_add_enabled = False
trend_reduce_enabled = bool(trend_reduce_cfg.get('enabled'))
trend_reduce_step_pct = float(trend_reduce_cfg.get('stepPct') or 0.0)
trend_reduce_size_pct = float(trend_reduce_cfg.get('sizePct') or 0.0)
trend_reduce_max_times = int(trend_reduce_cfg.get('maxTimes') or 0)
adverse_reduce_enabled = bool(adverse_reduce_cfg.get('enabled'))
adverse_reduce_step_pct = float(adverse_reduce_cfg.get('stepPct') or 0.0)
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% 的价格波动触发
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
adverse_reduce_step_pct_eff = adverse_reduce_step_pct / lev
# State: used for trailing exits and scale-in/scale-out anchor levels
highest_since_entry = None
lowest_since_entry = None
trend_add_times = 0
dca_add_times = 0
trend_reduce_times = 0
adverse_reduce_times = 0
last_trend_add_anchor = None
last_dca_add_anchor = None
last_trend_reduce_anchor = None
last_adverse_reduce_anchor = None
# 转换信号为数组
open_long_arr = signals['open_long'].values
close_long_arr = signals['close_long'].values
open_short_arr = signals['open_short'].values
close_short_arr = signals['close_short'].values
# Apply execution timing to avoid look-ahead bias:
# If signals are computed using bar close, realistic execution is next bar open.
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
open_long_arr = np.insert(open_long_arr[:-1], 0, False)
close_long_arr = np.insert(close_long_arr[:-1], 0, False)
open_short_arr = np.insert(open_short_arr[:-1], 0, False)
close_short_arr = np.insert(close_short_arr[:-1], 0, False)
# 根据交易方向过滤信号
if trade_direction == 'long':
# 只做多:禁用所有做空信号
open_short_arr = np.zeros(len(df), dtype=bool)
close_short_arr = np.zeros(len(df), dtype=bool)
elif trade_direction == 'short':
# 只做空:禁用所有做多信号
open_long_arr = np.zeros(len(df), dtype=bool)
close_long_arr = np.zeros(len(df), dtype=bool)
else:
pass
# 加仓信号
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
# 根据交易方向过滤加仓信号
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)
# 开仓触发价格(如果指标提供了精确开仓价格)
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
# 平仓目标价格(如果指标提供了精确平仓价格)
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_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
# 若已无持仓且余额过低,视为赔光并停止后续交易
if position == 0 and capital < min_capital_to_trade:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(float(row.get('close', 0) or 0), 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
# Use OHLC to evaluate triggers.
high = row['high']
low = row['low']
close = row['close']
open_ = row.get('open', close)
# Default execution price depends on timing mode
# - bar_close: close
# - next_bar_open: open (this bar is the next bar for a prior signal)
exec_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else close
# --- Risk controls: SL / TP / trailing exit (highest priority) ---
if position != 0 and position_type in ['long', 'short']:
# 更新持仓期间极值(用于移动止盈止损)
if position_type == 'long':
if highest_since_entry is None:
highest_since_entry = entry_price
if lowest_since_entry is None:
lowest_since_entry = entry_price
highest_since_entry = max(highest_since_entry, high)
lowest_since_entry = min(lowest_since_entry, low)
else: # short
if lowest_since_entry is None:
lowest_since_entry = entry_price
if highest_since_entry is None:
highest_since_entry = entry_price
lowest_since_entry = min(lowest_since_entry, low)
highest_since_entry = max(highest_since_entry, high)
# 收集同一根K线内触发的强制平仓点
# 回测为K线级别,无法确定同一根K线内的真实触发顺序;这里按“确定性优先级”处理:
# 止损 > 移动止盈(回撤) > 固定止盈
candidates = [] # [(trade_type, trigger_price)]
if position_type == 'long' and position > 0:
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 - stop_loss_pct_eff)
if low <= sl_price:
candidates.append(('close_long_stop', sl_price))
# Fixed take-profit exit is disabled when trailing is enabled (see conflict rule above).
if (not trailing_enabled) and take_profit_pct_eff > 0:
tp_price = entry_price * (1 + take_profit_pct_eff)
if high >= tp_price:
candidates.append(('close_long_profit', tp_price))
if trailing_enabled and trailing_pct_eff > 0 and highest_since_entry is not None:
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 low <= tr_price:
candidates.append(('close_long_trailing', tr_price))
if candidates:
# 按优先级选择触发点:止损 > 移动止盈 > 止盈
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
# 开仓手续费已在开仓时扣除,这里只扣平仓手续费
profit = (exec_price_close - entry_price) * position - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': trade_type,
'price': round(exec_price_close, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(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
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
if position_type == 'short' and position < 0:
shares = abs(position)
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 + stop_loss_pct_eff)
if high >= sl_price:
candidates.append(('close_short_stop', sl_price))
# Fixed take-profit exit is disabled when trailing is enabled (see conflict rule above).
if (not trailing_enabled) and take_profit_pct_eff > 0:
tp_price = entry_price * (1 - take_profit_pct_eff)
if low <= tp_price:
candidates.append(('close_short_profit', tp_price))
if trailing_enabled and trailing_pct_eff > 0 and lowest_since_entry is not None:
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 high >= tr_price:
candidates.append(('close_short_trailing', tr_price))
if candidates:
# 按优先级选择触发点:止损 > 移动止盈 > 止盈
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
# 开仓手续费已在开仓时扣除,这里只扣平仓手续费
profit = (entry_price - exec_price_close) * shares - commission_fee_close
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_close, 4),
'amount': round(shares, 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
liquidation_price = 0
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': trade_type,
'price': round(exec_price_close, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(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
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
# 处理平仓信号(优先处理,包括止损/止盈)
if position > 0 and close_long_arr[i]:
# 平多:使用指标提供的目标价格(如果有),否则使用收盘价
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
target_price = open_
else:
target_price = close_long_price_arr[i] if close_long_price_arr[i] > 0 else close
exec_price = target_price * (1 - slippage)
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
total_commission_paid += commission_fee
# NOTE:
# This is a "signal close" (not a forced stop-loss/take-profit/trailing exit).
# Do NOT label it as *_stop/*_profit based on PnL sign, otherwise it looks like a stop-loss happened
# even when risk controls are disabled (stopLossPct/takeProfitPct == 0).
trade_type = 'close_long'
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': trade_type,
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(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
# 平仓后余额过低则停止交易(避免同K线反手开仓)
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
elif position < 0 and close_short_arr[i]:
# 平空:使用指标提供的目标价格(如果有),否则使用收盘价
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
target_price = open_
else:
target_price = close_short_price_arr[i] if close_short_price_arr[i] > 0 else close
exec_price = target_price * (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"平空时资金不足爆仓")
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(-capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
capital += profit
total_commission_paid += commission_fee
# Signal close (not forced TP/SL/trailing).
trade_type = 'close_short'
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': trade_type,
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(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
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
# If this candle has a main strategy signal (open/close long/short),
# we must NOT apply any scale-in/scale-out actions on the same candle.
main_signal_on_bar = bool(open_long_arr[i] or open_short_arr[i] or close_long_arr[i] or close_short_arr[i])
# --- Parameterized scaling rules (no strategy code needed) ---
# Rules:
# - Trend scale-in: long triggers when price rises stepPct from anchor; short triggers when price falls stepPct from anchor
# - Mean-reversion DCA: long triggers when price falls stepPct from anchor; short triggers when price rises stepPct from anchor
# - 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:
# 做多
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):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 + trend_add_step_pct_eff)
if high >= trigger:
order_pct = trend_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 + slippage)
use_capital = capital * order_pct
# 手续费按成交名义价值扣除;下单数量不再除以(1+commission)
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position += shares_add
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
trend_add_times += 1
last_trend_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(capital, 2)
})
# Mean-reversion DCA (trigger on lower price)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 - dca_add_step_pct_eff)
if low <= trigger:
order_pct = dca_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 + slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position += shares_add
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
dca_add_times += 1
last_dca_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(capital, 2)
})
# Trend reduce (trigger on higher price)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 + trend_reduce_step_pct_eff)
if high >= trigger:
reduce_pct = max(trend_reduce_size_pct, 0.0)
reduce_shares = position * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 - slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position -= reduce_shares
if position <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
else:
liquidation_price = entry_price * (1 - 1.0 / leverage)
trend_reduce_times += 1
last_trend_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_long',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
# Adverse reduce (trigger on lower price)
if position_type == 'long' and position > 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 - adverse_reduce_step_pct_eff)
if low <= trigger:
reduce_pct = max(adverse_reduce_size_pct, 0.0)
reduce_shares = position * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 - slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position -= reduce_shares
if position <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
else:
liquidation_price = entry_price * (1 - 1.0 / leverage)
adverse_reduce_times += 1
last_adverse_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_long',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
# 做空
if position_type == 'short' and position < 0:
shares_total = abs(position)
# Trend scale-in (trigger on lower 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):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 - trend_add_step_pct_eff)
if low <= trigger:
order_pct = trend_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 - slippage) # 卖出加空,滑点不利
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = shares_total * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position -= shares_add
shares_total = abs(position)
entry_price = total_cost_after / shares_total
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
trend_add_times += 1
last_trend_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(capital, 2)
})
# Mean-reversion DCA (trigger on higher price)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 + dca_add_step_pct_eff)
if high >= trigger:
order_pct = dca_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 - slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = shares_total * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position -= shares_add
shares_total = abs(position)
entry_price = total_cost_after / shares_total
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
dca_add_times += 1
last_dca_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(capital, 2)
})
# Trend reduce (trigger on lower price)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 - trend_reduce_step_pct_eff)
if low <= trigger:
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) # 回补更贵
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position += reduce_shares
shares_total = abs(position)
if shares_total <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
else:
liquidation_price = entry_price * (1 + 1.0 / leverage)
trend_reduce_times += 1
last_trend_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_short',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
# Adverse reduce (trigger on higher price)
if position_type == 'short' and position < 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 + adverse_reduce_step_pct_eff)
if high >= trigger:
reduce_pct = max(adverse_reduce_size_pct, 0.0)
reduce_shares = shares_total * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 + slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position += reduce_shares
shares_total = abs(position)
if shares_total <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
else:
liquidation_price = entry_price * (1 + 1.0 / leverage)
adverse_reduce_times += 1
last_adverse_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_short',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
# 处理加仓信号(仓位管理模式)
if has_position_management and (not main_signal_on_bar):
if position > 0 and add_long_arr[i] and capital >= min_capital_to_trade:
# 加多仓:使用指标提供的目标价格(如果有),否则使用收盘价
target_price = add_long_price_arr[i] if add_long_price_arr[i] > 0 else close
exec_price = target_price * (1 + slippage)
# 使用指定比例的资金加仓
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
# 更新平均成本
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares * exec_price
position += shares
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
# 重新计算爆仓线
liquidation_price = entry_price * (1 - 1.0 / leverage)
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(capital, 2)
})
elif position < 0 and add_short_arr[i] and capital >= min_capital_to_trade:
# 加空仓:使用指标提供的目标价格(如果有),否则使用收盘价
target_price = add_short_price_arr[i] if add_short_price_arr[i] > 0 else close
exec_price = target_price * (1 - slippage)
# 使用指定比例的资金加仓
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
# 更新平均成本
current_shares = abs(position)
total_cost_before = current_shares * entry_price
total_cost_after = total_cost_before + shares * exec_price
position -= shares # 空头是负数
current_shares = abs(position)
entry_price = total_cost_after / current_shares
capital -= commission_fee
total_commission_paid += commission_fee
# 重新计算爆仓线
liquidation_price = entry_price * (1 + 1.0 / leverage)
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(capital, 2)
})
# 处理开仓信号
# 注意:code6.py已经处理了反转(先平后开),所以这里只需要处理position==0的情况
if open_long_arr[i] and position == 0 and capital >= min_capital_to_trade:
# 使用指标提供的开仓触发价格(如果有),否则使用收盘价
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;否则全仓)
position_pct = None
if entry_pct_cfg and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
elif has_position_management and position_size_arr[i] > 0:
position_pct = position_size_arr[i]
if position_pct is not None and position_pct > 0 and position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = shares
entry_price = exec_price
position_type = 'long'
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
highest_since_entry = entry_price
lowest_since_entry = entry_price
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_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(capital, 2)
})
# Strict intrabar stop-loss / liquidation check right after entry (closer to live trading).
# If this bar touches stop-loss price, close immediately at stop price (with slippage).
# If this bar also touches liquidation price, assume stop-loss triggers first only if it is above liquidation.
if position_type == 'long' and position > 0:
sl_price = entry_price * (1 - stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None
hit_sl = (sl_price is not None) and (low <= sl_price)
hit_liq = liquidation_price > 0 and (low <= liquidation_price)
if hit_sl or hit_liq:
if hit_liq and (not hit_sl or (sl_price is not None and sl_price <= liquidation_price)):
# Liquidation happens before stop-loss (or stop-loss not configured).
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(position, 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
else:
# Stop-loss triggers first.
exec_price_close = sl_price * (1 - slippage)
commission_fee_close = position * exec_price_close * commission
profit = (exec_price_close - entry_price) * position - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
if capital <= 0:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long_stop',
'price': round(exec_price_close, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
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:
# 使用指标提供的开仓触发价格(如果有),否则使用收盘价
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;否则全仓)
position_pct = None
if entry_pct_cfg and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
elif has_position_management and position_size_arr[i] > 0:
position_pct = position_size_arr[i]
if position_pct is not None and position_pct > 0 and position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = -shares
entry_price = exec_price
position_type = 'short'
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
highest_since_entry = entry_price
lowest_since_entry = entry_price
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_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(capital, 2)
})
# Strict intrabar stop-loss / liquidation check right after entry (closer to live trading).
if position_type == 'short' and position < 0:
sl_price = entry_price * (1 + stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None
hit_sl = (sl_price is not None) and (high >= sl_price)
hit_liq = liquidation_price > 0 and (high >= liquidation_price)
if hit_sl or hit_liq:
if hit_liq and (not hit_sl or (sl_price is not None and sl_price >= liquidation_price)):
# Liquidation happens before stop-loss (or stop-loss not configured).
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
else:
# Stop-loss triggers first.
exec_price_close = sl_price * (1 + slippage)
shares_close = abs(position)
commission_fee_close = shares_close * exec_price_close * commission
profit = (entry_price - exec_price_close) * shares_close - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
if capital <= 0:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short_stop',
'price': round(exec_price_close, 4),
'amount': round(shares_close, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
# 检测持仓期间是否触及爆仓线(作为兜底保护)
# 注意:这个检查在所有主动平仓信号处理之后
# 如果触及爆仓线,检查是否有止损信号,止损优先
if position != 0 and not is_liquidated:
if position_type == 'long' and low <= liquidation_price:
# 做多触及爆仓线:检查是否有止损信号
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
# 判断先触发止损还是爆仓
if has_stop_loss and stop_loss_price > liquidation_price:
# 止损在爆仓前触发,使用止损价平仓
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
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long_stop',
'price': round(exec_price_close, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
else:
# 止损不够严格或无止损,触发爆仓
logger.warning(f"做多爆仓!开仓价={entry_price:.2f}, 最低价={low:.2f}, "
f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}")
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': capital})
continue
elif position_type == 'short' and high >= liquidation_price:
# 做空触及爆仓线:检查是否有止损信号
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}, "
f"止损信号={close_short_arr[i]}, 止损价={stop_loss_price:.4f}, 时间={timestamp}")
# 判断先触发止损还是爆仓
if has_stop_loss and stop_loss_price < liquidation_price:
# 止损在爆仓前触发,使用止损价平仓
exec_price_close = stop_loss_price * (1 + slippage)
shares_close = abs(position)
commission_fee_close = shares_close * exec_price_close * commission
profit = (entry_price - exec_price_close) * shares_close - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short_stop',
'price': round(exec_price_close, 4),
'amount': round(shares_close, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
else:
# 止损不够严格或无止损,触发爆仓
logger.warning(f"做空爆仓!开仓价={entry_price:.2f}, 最高价={high:.2f}, "
f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}")
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': capital})
continue
# 记录权益(使用收盘价计算未实现盈亏)
if position_type == 'long':
unrealized_pnl = (close - entry_price) * position
total_value = capital + unrealized_pnl
elif position_type == 'short':
shares = abs(position)
unrealized_pnl = (entry_price - close) * shares
total_value = capital + unrealized_pnl
else:
total_value = capital
if total_value < 0:
total_value = 0
equity_curve.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'value': round(total_value, 2)
})
# 回测结束时强制平仓
if position != 0:
timestamp = df.index[-1]
final_close = df.iloc[-1]['close']
if position > 0: # 平多
exec_price = final_close * (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',
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
else: # 平空
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"回测结束爆仓!")
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(-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',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
if equity_curve:
equity_curve[-1]['value'] = round(capital, 2)
return equity_curve, trades, total_commission_paid
def _simulate_trading_old_format(
self,
df: pd.DataFrame,
signals: pd.Series,
initial_capital: float,
commission: float,
slippage: float,
leverage: int = 1,
trade_direction: str = 'long',
strategy_config: Optional[Dict[str, Any]] = None
) -> tuple:
"""
使用旧格式信号进行交易模拟(保持兼容性)
"""
equity_curve = []
trades = []
total_commission_paid = 0 # 累计手续费
is_liquidated = False # 爆仓标志
liquidation_price = 0 # 爆仓价格
min_capital_to_trade = 1.0 # 余额低于该值则视为赔光,不再开新单
capital = initial_capital
position = 0 # 正数=多头持仓,负数=空头持仓
entry_price = 0
position_type = None # 'long' or 'short'
# Risk controls (also supported for legacy signals): SL / TP / trailing exit
cfg = strategy_config or {}
exec_cfg = cfg.get('execution') or {}
# Signal confirmation / execution timing (legacy mode):
# - bar_close: execute on the same bar close
# - next_bar_open: execute on next bar open after signal is confirmed on bar close (recommended)
signal_timing = str(exec_cfg.get('signalTiming') or 'next_bar_open').strip().lower()
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)
# Risk percentages are defined on margin PnL; convert to price move thresholds by leverage.
lev = max(int(leverage or 1), 1)
stop_loss_pct_eff = stop_loss_pct / lev
take_profit_pct_eff = take_profit_pct / lev
trailing_pct_eff = trailing_pct / lev
trailing_activation_pct_eff = trailing_activation_pct / lev
highest_since_entry = None
lowest_since_entry = None
# --- Position / scaling config (make old-format strategies support the same backtest modal features) ---
pos_cfg = cfg.get('position') or {}
entry_pct_cfg = float(pos_cfg.get('entryPct') if pos_cfg.get('entryPct') is not None else 1.0) # expected 0~1
# Accept both 0~1 and 0~100 inputs (some clients may send percent units).
if entry_pct_cfg > 1:
entry_pct_cfg = entry_pct_cfg / 100.0
entry_pct_cfg = max(0.0, min(entry_pct_cfg, 1.0))
scale_cfg = cfg.get('scale') or {}
trend_add_cfg = scale_cfg.get('trendAdd') or {}
dca_add_cfg = scale_cfg.get('dcaAdd') or {}
trend_reduce_cfg = scale_cfg.get('trendReduce') or {}
adverse_reduce_cfg = scale_cfg.get('adverseReduce') or {}
trend_add_enabled = bool(trend_add_cfg.get('enabled'))
trend_add_step_pct = float(trend_add_cfg.get('stepPct') or 0.0)
trend_add_size_pct = float(trend_add_cfg.get('sizePct') or 0.0)
trend_add_max_times = int(trend_add_cfg.get('maxTimes') or 0)
dca_add_enabled = bool(dca_add_cfg.get('enabled'))
dca_add_step_pct = float(dca_add_cfg.get('stepPct') or 0.0)
dca_add_size_pct = float(dca_add_cfg.get('sizePct') or 0.0)
dca_add_max_times = int(dca_add_cfg.get('maxTimes') or 0)
trend_reduce_enabled = bool(trend_reduce_cfg.get('enabled'))
trend_reduce_step_pct = float(trend_reduce_cfg.get('stepPct') or 0.0)
trend_reduce_size_pct = float(trend_reduce_cfg.get('sizePct') or 0.0)
trend_reduce_max_times = int(trend_reduce_cfg.get('maxTimes') or 0)
adverse_reduce_enabled = bool(adverse_reduce_cfg.get('enabled'))
adverse_reduce_step_pct = float(adverse_reduce_cfg.get('stepPct') or 0.0)
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)
# 触发百分比按杠杆后换算为价格阈值
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
adverse_reduce_step_pct_eff = adverse_reduce_step_pct / lev
# State for scaling
trend_add_times = 0
dca_add_times = 0
trend_reduce_times = 0
adverse_reduce_times = 0
last_trend_add_anchor = None
last_dca_add_anchor = None
last_trend_reduce_anchor = None
last_adverse_reduce_anchor = None
# Apply execution timing to avoid look-ahead bias in legacy signals (buy/sell series):
# If signal is computed on bar close, realistic execution is next bar open.
signals_exec = signals
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
try:
signals_exec = signals.shift(1).fillna(0)
except Exception:
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
# 若已无持仓且余额过低,视为赔光并停止后续交易
if position == 0 and capital < min_capital_to_trade:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(float(row.get('close', 0) or 0), 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
signal = signals_exec.iloc[i] if i < len(signals_exec) else 0
high = row['high']
low = row['low']
price = row['close']
open_ = row.get('open', price)
# 强制平仓(止盈止损/移动止盈)优先于信号
if position != 0 and position_type in ['long', 'short']:
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, high)
candidates = []
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 - stop_loss_pct_eff)
if low <= sl_price:
candidates.append(('stop', sl_price))
if take_profit_pct_eff > 0:
tp_price = entry_price * (1 + take_profit_pct_eff)
if high >= tp_price:
candidates.append(('profit', tp_price))
if 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 low <= tr_price:
candidates.append(('trailing', tr_price))
if candidates:
# 止损 > 移动止盈(回撤) > 止盈
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
# 开仓手续费已在开仓时扣除,这里只扣平仓手续费
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': {'stop': 'close_long_stop', 'profit': 'close_long_profit', 'trailing': 'close_long_trailing'}.get(reason, 'close_long'),
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
if 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, low)
candidates = []
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 + stop_loss_pct_eff)
if high >= sl_price:
candidates.append(('stop', sl_price))
if take_profit_pct_eff > 0:
tp_price = entry_price * (1 - take_profit_pct_eff)
if low <= tp_price:
candidates.append(('profit', tp_price))
if 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 high >= tr_price:
candidates.append(('trailing', tr_price))
if candidates:
# 止损 > 移动止盈(回撤) > 止盈
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
# 开仓手续费已在开仓时扣除,这里只扣平仓手续费
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
})
position = 0
position_type = None
liquidation_price = 0
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': {'stop': 'close_short_stop', 'profit': 'close_short_profit', 'trailing': 'close_short_trailing'}.get(reason, 'close_short'),
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
# --- Parameterized scaling rules (also for old-format strategies) ---
# 说明:旧格式只有 buy/sell 信号,但回测弹窗的“顺势/逆势加减仓、最小下单比例”等参数仍应生效。
# 触发百分比按杠杆后阈值理解(已除以 leverage)。
# 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:
# 做多
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):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 + trend_add_step_pct_eff)
if high >= trigger:
order_pct = trend_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 + slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position += shares_add
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
trend_add_times += 1
last_trend_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(capital, 2)
})
# DCA add(逆势加仓:下跌触发)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 - dca_add_step_pct_eff)
if low <= trigger:
order_pct = dca_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 + slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position += shares_add
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
dca_add_times += 1
last_dca_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(capital, 2)
})
# Trend reduce(顺势减仓:上涨触发)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 + trend_reduce_step_pct_eff)
if high >= trigger:
reduce_pct = max(trend_reduce_size_pct, 0.0)
reduce_shares = position * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 - slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position -= reduce_shares
if position <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
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
else:
liquidation_price = entry_price * (1 - 1.0 / leverage)
trend_reduce_times += 1
last_trend_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_long',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
# Adverse reduce(逆势减仓:下跌触发)
if position_type == 'long' and position > 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 - adverse_reduce_step_pct_eff)
if low <= trigger:
reduce_pct = max(adverse_reduce_size_pct, 0.0)
reduce_shares = position * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 - slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position -= reduce_shares
if position <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
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
else:
liquidation_price = entry_price * (1 - 1.0 / leverage)
adverse_reduce_times += 1
last_adverse_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_long',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
# 做空
if position_type == 'short' and position < 0:
shares_total = abs(position)
# 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):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 - trend_add_step_pct_eff)
if low <= trigger:
order_pct = trend_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 - slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = shares_total * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position -= shares_add
shares_total = abs(position)
entry_price = total_cost_after / shares_total
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
trend_add_times += 1
last_trend_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(capital, 2)
})
# DCA add(逆势加空:上涨触发)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 + dca_add_step_pct_eff)
if high >= trigger:
order_pct = dca_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 - slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = shares_total * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position -= shares_add
shares_total = abs(position)
entry_price = total_cost_after / shares_total
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
dca_add_times += 1
last_dca_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(capital, 2)
})
# Trend reduce(顺势减空:下跌触发,回补一部分)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 - trend_reduce_step_pct_eff)
if low <= trigger:
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)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position += reduce_shares
shares_total = abs(position)
if shares_total <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
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
else:
liquidation_price = entry_price * (1 + 1.0 / leverage)
trend_reduce_times += 1
last_trend_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_short',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
# Adverse reduce(逆势减空:上涨触发)
if position_type == 'short' and position < 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 + adverse_reduce_step_pct_eff)
if high >= trigger:
reduce_pct = max(adverse_reduce_size_pct, 0.0)
reduce_shares = shares_total * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 + slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position += reduce_shares
shares_total = abs(position)
if shares_total <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
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
else:
liquidation_price = entry_price * (1 + 1.0 / leverage)
adverse_reduce_times += 1
last_adverse_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_short',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
# 处理不同的交易方向
if trade_direction == 'long':
# 只做多模式
if signal == 1 and position == 0 and capital >= min_capital_to_trade: # 买入开多
logger.debug(f"[做多模式] 买入开多: 时间={timestamp}, 价格={price}, 杠杆={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 优先;否则全仓)
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
# 保证金(手续费从本金扣除)
margin = capital
commission_fee = shares * exec_price * commission
position = shares
entry_price = exec_price
position_type = 'long'
capital -= commission_fee # 只扣手续费,不扣全部成本
total_commission_paid += commission_fee
# 计算爆仓线:做多时,价格跌到 entry_price × (1 - 1/leverage) 就爆仓
liquidation_price = entry_price * (1 - 1.0 / leverage)
logger.debug(f"做多爆仓线: {liquidation_price:.2f}")
# init scaling anchors
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_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(capital, 2)
})
elif signal == -1 and position > 0: # 卖出平多
logger.debug(f"[做多模式] 卖出平多: 时间={timestamp}, 价格={price}")
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 - slippage)
# 盈亏 = (平仓价 - 开仓价) × 股数 - 手续费
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
total_commission_paid += commission_fee
liquidation_price = 0 # 清除爆仓线
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)
})
position = 0
position_type = None
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:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
elif trade_direction == 'short':
# 只做空模式
if signal == -1 and position == 0 and capital >= min_capital_to_trade: # 卖出开空
logger.debug(f"[做空模式] 卖出开空: 时间={timestamp}, 价格={price}, 杠杆={leverage}x")
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 - slippage)
# 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = -shares # 负数表示空头(欠股票)
entry_price = exec_price
position_type = 'short'
capital -= commission_fee # 只扣手续费
total_commission_paid += commission_fee
# 计算爆仓线:做空时,价格涨到 entry_price × (1 + 1/leverage) 就爆仓
liquidation_price = entry_price * (1 + 1.0 / leverage)
logger.debug(f"做空爆仓线: {liquidation_price:.2f}")
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_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(capital, 2)
})
elif signal == 1 and position < 0: # 买入平空
logger.debug(f"[做空模式] 买入平空: 时间={timestamp}, 价格={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) # 需要买回的股数
# 盈亏 = (开仓价 - 平仓价) × 股数 - 手续费
commission_fee = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee
# 检查是否爆仓
if capital + profit <= 0:
logger.warning(f"平空时资金不足爆仓: 本金={capital:.2f}, 亏损={-profit:.2f}")
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(-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',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(capital, 2)
})
position = 0
position_type = None
liquidation_price = 0 # 清除爆仓线
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:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
elif trade_direction == 'both':
# 双向模式
if signal == 1 and position == 0 and capital >= min_capital_to_trade: # 买入开多
logger.debug(f"[双向模式] 买入开多: 时间={timestamp}, 价格={price}, 杠杆={leverage}x")
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 + slippage)
# 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = shares
entry_price = exec_price
position_type = 'long'
capital -= commission_fee # 只扣手续费
total_commission_paid += commission_fee
# 计算爆仓线
liquidation_price = entry_price * (1 - 1.0 / leverage)
logger.debug(f"做多爆仓线: {liquidation_price:.2f}")
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_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(capital, 2)
})
elif signal == -1 and position == 0 and capital >= min_capital_to_trade: # 卖出开空
logger.debug(f"[双向模式] 卖出开空: 时间={timestamp}, 价格={price}, 杠杆={leverage}x")
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 - slippage)
# 使用杠杆:实际持仓 = 本金 × 杠杆 / 价格
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = -shares
entry_price = exec_price
position_type = 'short'
capital -= commission_fee
total_commission_paid += commission_fee
# 计算爆仓线
liquidation_price = entry_price * (1 + 1.0 / leverage)
logger.debug(f"做空爆仓线: {liquidation_price:.2f}")
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_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(capital, 2)
})
elif signal == -1 and position > 0: # 平多开空
logger.debug(f"[双向模式] 平多开空: 时间={timestamp}, 价格={price}")
# 先平多
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
profit = (exec_price - entry_price) * position - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
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)
})
# 若平仓后余额过低则停止(避免同K线反手开仓)
if capital < min_capital_to_trade or is_liquidated:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
continue
# Re-open short (respects entryPct; default entryPct=100%)
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee_open = shares * exec_price * commission
position = -shares
entry_price = exec_price
position_type = 'short'
capital -= commission_fee_open
total_commission_paid += commission_fee_open
# 计算爆仓线
liquidation_price = entry_price * (1 + 1.0 / leverage)
logger.debug(f"做空爆仓线: {liquidation_price:.2f}")
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(capital, 2)
})
elif signal == 1 and position < 0: # 平空开多
logger.debug(f"[双向模式] 平空开多: 时间={timestamp}, 价格={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)
commission_fee_close = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee_close
# 检查是否爆仓
if capital + profit <= 0:
logger.warning(f"平空时资金不足爆仓: 本金={capital:.2f}, 亏损={-profit:.2f}")
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(-capital, 2),
'balance': 0
})
position = 0
position_type = None
continue # 爆仓后不再开新仓
capital += profit
total_commission_paid += commission_fee_close
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)
})
if capital < min_capital_to_trade or is_liquidated:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
continue
# Re-open long (respects entryPct; default entryPct=100%)
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee_open = shares * exec_price * commission
position = shares
entry_price = exec_price
position_type = 'long'
capital -= commission_fee_open
total_commission_paid += commission_fee_open
# 计算爆仓线
liquidation_price = entry_price * (1 - 1.0 / leverage)
logger.debug(f"做多爆仓线: {liquidation_price:.2f}")
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(capital, 2)
})
# 检测持仓期间是否触及爆仓线(作为兜底保护,仅在没有主动平仓的情况下检查)
# 注意:这个检查在所有信号处理之后,确保止损/止盈优先执行
if position != 0 and not is_liquidated:
if position_type == 'long':
# 做多爆仓:价格跌破爆仓线
if price <= liquidation_price:
logger.warning(f"做多爆仓!开仓价={entry_price:.2f}, 当前价={price:.2f}, 爆仓线={liquidation_price:.2f}")
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'value': 0
})
continue
elif position_type == 'short':
# 做空爆仓:价格涨破爆仓线
if price >= liquidation_price:
logger.warning(f"做空爆仓!开仓价={entry_price:.2f}, 当前价={price:.2f}, 爆仓线={liquidation_price:.2f}")
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'value': 0
})
continue
# 记录权益
if position_type == 'long':
# 多头权益 = 现金 + 未实现盈亏
# 未实现盈亏 = (当前价 - 开仓价) × 股数
unrealized_pnl = (price - entry_price) * position
total_value = capital + unrealized_pnl
elif position_type == 'short':
# 空头权益 = 现金 + 未实现盈亏
# 未实现盈亏 = (开仓价 - 当前价) × 股数
shares = abs(position)
unrealized_pnl = (entry_price - price) * shares
total_value = capital + unrealized_pnl
else:
total_value = capital
# 确保权益不为负(如果已经爆仓,在前面已经处理了)
if total_value < 0:
total_value = 0
equity_curve.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'value': round(total_value, 2)
})
# 回测结束时强制平仓
if position != 0:
timestamp = df.index[-1]
price = df.iloc[-1]['close']
if position > 0: # 平多
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
# 记录平多交易
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)
})
else: # 平空
exec_price = price * (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"回测结束爆仓!平空亏损过大: 本金={capital:.2f}, 亏损={-profit:.2f}")
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(-capital, 2),
'balance': 0
})
capital = 0
else:
capital += profit
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(capital, 2)
})
# 更新权益曲线的最后一个值,包含强制平仓后的资金
if equity_curve:
equity_curve[-1]['value'] = round(capital, 2)
return equity_curve, trades, total_commission_paid
def _calculate_metrics(
self,
equity_curve: List,
trades: List,
initial_capital: float,
timeframe: str,
start_date: datetime,
end_date: datetime,
total_commission: float = 0
) -> Dict:
"""计算回测指标"""
if not equity_curve:
return {}
final_value = equity_curve[-1]['value']
total_return = (final_value - initial_capital) / initial_capital * 100
# 计算年化收益:使用简单年化而不是复利年化
# 对于高收益率策略,复利年化会产生天文数字,不具备参考价值
actual_days = (end_date - start_date).total_seconds() / 86400
years = actual_days / 365.0
# 简单年化:年化收益率 = 总收益率 / 年数
if years > 0:
annual_return = total_return / years
else:
annual_return = 0
# 计算最大回撤
values = [e['value'] for e in equity_curve]
max_drawdown = self._calculate_max_drawdown(values)
# 计算夏普比率
sharpe = self._calculate_sharpe(values, timeframe)
# 计算总盈亏:用最终权益减去初始资金(最准确)
total_profit = final_value - initial_capital
# 计算胜率(包含所有平仓操作)
# 平仓操作: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 = 总盈利 / 总亏损)
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)
return {
'totalReturn': round(total_return, 2),
'annualReturn': round(annual_return, 2),
'maxDrawdown': round(max_drawdown, 2),
'sharpeRatio': round(sharpe, 2),
'winRate': round(win_rate, 2),
'profitFactor': round(profit_factor, 2),
'totalTrades': total_trades,
'totalProfit': round(total_profit, 2),
'totalCommission': round(total_commission, 2)
}
def _calculate_max_drawdown(self, values: List[float]) -> float:
"""计算最大回撤"""
if not values:
return 0
peak = values[0]
max_dd = 0
for value in values:
if value > peak:
peak = value
dd = (peak - value) / peak * 100
if dd > max_dd:
max_dd = dd
return -max_dd
def _calculate_sharpe(self, values: List[float], timeframe: str = '1D', risk_free_rate: float = 0.02) -> float:
"""
计算夏普比率
Args:
values: 权益曲线数值列表
timeframe: 时间周期
risk_free_rate: 无风险收益率(年化)
"""
if len(values) < 2:
return 0
# 过滤掉0值(爆仓后的数据),避免除以0
valid_values = [v for v in values if v > 0]
if len(valid_values) < 2:
return 0
# 根据时间周期确定年化系数
annualization_factor = {
'1m': 252 * 24 * 60, # 分钟K:约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, # 小时K6,048
'4H': 252 * 6, # 4小时K1,512
'1D': 252, # 日K252
'1W': 52 # 周K52
}.get(timeframe, 252)
try:
# 计算周期收益率
returns = np.diff(valid_values) / valid_values[:-1]
# 过滤无效值
returns = returns[np.isfinite(returns)]
if len(returns) == 0:
return 0
# 年化平均收益率
avg_return = np.mean(returns) * annualization_factor
# 年化标准差(波动率)
std_return = np.std(returns) * np.sqrt(annualization_factor)
if std_return == 0 or not np.isfinite(std_return):
return 0
# 夏普比率 = (年化收益 - 无风险利率) / 年化波动率
sharpe = (avg_return - risk_free_rate) / std_return
return sharpe if np.isfinite(sharpe) else 0
except Exception as e:
logger.warning(f"夏普比率计算失败: {e}")
return 0
def _format_result(
self,
metrics: Dict,
equity_curve: List,
trades: List
) -> Dict[str, Any]:
"""格式化回测结果"""
# 精简权益曲线
max_points = 500
if len(equity_curve) > max_points:
step = len(equity_curve) // max_points
equity_curve = equity_curve[::step]
# 清理数据中的NaN、Inf值,确保可以被JSON序列化
def clean_value(value):
"""清理数值,将NaN/Inf转换为0"""
if isinstance(value, float):
if np.isnan(value) or np.isinf(value):
return 0
return value
# 清理metrics
cleaned_metrics = {}
for key, value in metrics.items():
cleaned_metrics[key] = clean_value(value)
# 清理equity_curve
cleaned_curve = []
for item in equity_curve:
cleaned_curve.append({
'time': item['time'],
'value': clean_value(item['value'])
})
# 清理trades
cleaned_trades = []
# 不截断交易记录:有多少条就返回多少条(前端可自行分页展示)
for trade in trades:
cleaned_trade = {}
for key, value in trade.items():
cleaned_trade[key] = clean_value(value)
cleaned_trades.append(cleaned_trade)
return {
**cleaned_metrics,
'equityCurve': cleaned_curve,
'trades': cleaned_trades
}