加入一个回测,
This commit is contained in:
310
backtest_outputs/charts/bb_backtest_2026_full_visualization.html
Normal file
310
backtest_outputs/charts/bb_backtest_2026_full_visualization.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
17941
backtest_outputs/logs/backtest_result.txt
Normal file
17941
backtest_outputs/logs/backtest_result.txt
Normal file
File diff suppressed because it is too large
Load Diff
142073
backtest_outputs/reports/bb_sweep_results.csv
Normal file
142073
backtest_outputs/reports/bb_sweep_results.csv
Normal file
File diff suppressed because it is too large
Load Diff
43
backtest_outputs/reports/bb_sweep_results_snapshot.csv
Normal file
43
backtest_outputs/reports/bb_sweep_results_snapshot.csv
Normal file
@@ -0,0 +1,43 @@
|
||||
178.5,305.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,305.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,306.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,306.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,307.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,307.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,308.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,308.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,309.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,309.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,310.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,310.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,311.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,311.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,312.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,312.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,313.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,313.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,314.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,314.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,315.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,315.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,316.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,316.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,317.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,317.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,318.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,318.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,319.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,319.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,320.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,320.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,321.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,321.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,322.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,322.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,323.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,323.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,324.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,324.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,325.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,325.5,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
178.5,326.0,5.2791790570783465e+19,2.6395895285391733e+19,33680,56.24109263657957,21.175874581988747,4.8783963655773225e+19
|
||||
|
|
Can't render this file because it is too large.
|
|
Can't render this file because it is too large.
|
17941
backtest_outputs/trades/bb_backtest_20260101_20261231_trades.csv
Normal file
17941
backtest_outputs/trades/bb_backtest_20260101_20261231_trades.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -87,12 +87,12 @@ class BollingerBandBacktest:
|
||||
self.current_run_label = "default"
|
||||
|
||||
def calculate_bollinger_bands(self, df):
|
||||
"""计算布林带"""
|
||||
"""计算布林带(整体右移1根,避免使用当前K收盘价)"""
|
||||
df['sma'] = df['close'].rolling(window=self.bb_period).mean()
|
||||
df['std'] = df['close'].rolling(window=self.bb_period).std()
|
||||
df['upper'] = df['sma'] + self.bb_std * df['std']
|
||||
df['lower'] = df['sma'] - self.bb_std * df['std']
|
||||
df['middle'] = df['sma']
|
||||
df['upper'] = (df['sma'] + self.bb_std * df['std']).shift(1)
|
||||
df['lower'] = (df['sma'] - self.bb_std * df['std']).shift(1)
|
||||
df['middle'] = df['sma'].shift(1)
|
||||
return df
|
||||
|
||||
def get_rebate_amount(self, fee):
|
||||
@@ -288,52 +288,54 @@ class BollingerBandBacktest:
|
||||
self.clear_delay_reversal()
|
||||
return True
|
||||
|
||||
def check_and_execute_delay_reversal(self, i, row, prev_row, timestamp):
|
||||
"""执行延迟反转三段判断逻辑"""
|
||||
def check_delay_reversal_signal(self, i, row, prev_row):
|
||||
"""检查延迟反转是否在当前收盘K成立(仅返回信号,不直接执行)"""
|
||||
if self.position == 0 or self.delay_reverse_price is None or self.delay_reverse_kline_index is None:
|
||||
return False
|
||||
return None
|
||||
|
||||
offset = i - self.delay_reverse_kline_index
|
||||
# 禁止同K确认,最早次K确认
|
||||
if offset <= 0:
|
||||
return None
|
||||
|
||||
high = row['high']
|
||||
low = row['low']
|
||||
|
||||
if self.delay_reverse_type == 'long_to_short':
|
||||
# 情况1/2:触上轨后,同K或下一K回调到记录上轨价
|
||||
if offset <= 1 and low <= self.delay_reverse_price:
|
||||
tag = "同K回调确认" if offset == 0 else "次K回调确认"
|
||||
return self.reverse_position(self.delay_reverse_price, 'short', timestamp, f"延迟反转-{tag}")
|
||||
# 情况1:触上轨后,次K回调到记录上轨价
|
||||
if offset == 1 and low <= self.delay_reverse_price:
|
||||
return 'short', "延迟反转-次K回调确认", self.delay_reverse_price
|
||||
|
||||
# 情况3:持续等待,动态追踪上一根K线条件
|
||||
# 情况2:持续等待,动态追踪上一根K线条件
|
||||
if offset >= 2 and prev_row is not None:
|
||||
prev_touch_upper = prev_row['high'] >= prev_row['upper']
|
||||
prev_upper = prev_row['upper']
|
||||
prev_touch_upper = pd.notna(prev_upper) and prev_row['high'] >= prev_upper
|
||||
if prev_touch_upper:
|
||||
ref_price = prev_row['upper']
|
||||
if low <= ref_price:
|
||||
return self.reverse_position(ref_price, 'short', timestamp, "延迟反转-上一根触上轨后回调确认")
|
||||
if low <= prev_upper:
|
||||
return 'short', "延迟反转-上一根触上轨后回调确认", prev_upper
|
||||
else:
|
||||
prev_body_low = min(prev_row['open'], prev_row['close'])
|
||||
if low <= prev_body_low:
|
||||
return self.reverse_position(prev_body_low, 'short', timestamp, "延迟反转-跌破上一根实体确认")
|
||||
return 'short', "延迟反转-跌破上一根实体确认", prev_body_low
|
||||
|
||||
elif self.delay_reverse_type == 'short_to_long':
|
||||
# 情况1/2:触下轨后,同K或下一K反弹到记录下轨价
|
||||
if offset <= 1 and high >= self.delay_reverse_price:
|
||||
tag = "同K反弹确认" if offset == 0 else "次K反弹确认"
|
||||
return self.reverse_position(self.delay_reverse_price, 'long', timestamp, f"延迟反转-{tag}")
|
||||
# 情况1:触下轨后,次K反弹到记录下轨价
|
||||
if offset == 1 and high >= self.delay_reverse_price:
|
||||
return 'long', "延迟反转-次K反弹确认", self.delay_reverse_price
|
||||
|
||||
# 情况3:持续等待,动态追踪上一根K线条件
|
||||
# 情况2:持续等待,动态追踪上一根K线条件
|
||||
if offset >= 2 and prev_row is not None:
|
||||
prev_touch_lower = prev_row['low'] <= prev_row['lower']
|
||||
prev_lower = prev_row['lower']
|
||||
prev_touch_lower = pd.notna(prev_lower) and prev_row['low'] <= prev_lower
|
||||
if prev_touch_lower:
|
||||
ref_price = prev_row['lower']
|
||||
if high >= ref_price:
|
||||
return self.reverse_position(ref_price, 'long', timestamp, "延迟反转-上一根触下轨后反弹确认")
|
||||
if high >= prev_lower:
|
||||
return 'long', "延迟反转-上一根触下轨后反弹确认", prev_lower
|
||||
else:
|
||||
prev_body_high = max(prev_row['open'], prev_row['close'])
|
||||
if high >= prev_body_high:
|
||||
return self.reverse_position(prev_body_high, 'long', timestamp, "延迟反转-突破上一根实体确认")
|
||||
return 'long', "延迟反转-突破上一根实体确认", prev_body_high
|
||||
|
||||
return False
|
||||
return None
|
||||
|
||||
def open_position(self, price, direction, timestamp, reason):
|
||||
"""开仓或加仓"""
|
||||
@@ -459,8 +461,8 @@ class BollingerBandBacktest:
|
||||
f"可用资金: {self.capital:.4f}U | {reason}")
|
||||
return True
|
||||
|
||||
def check_stop_loss(self, high, low, timestamp):
|
||||
"""检查止损"""
|
||||
def check_stop_loss(self, high, low):
|
||||
"""检查当前收盘K是否触发止损信号"""
|
||||
if self.position == 0:
|
||||
return False
|
||||
|
||||
@@ -470,16 +472,10 @@ class BollingerBandBacktest:
|
||||
else:
|
||||
unrealized_pnl = abs(self.position) * (self.entry_price - stop_price)
|
||||
|
||||
# 止损条件:亏损达到保证金的50%
|
||||
if unrealized_pnl <= -self.total_margin * self.stop_loss_ratio:
|
||||
logger.warning(f"触发止损!浮亏: {unrealized_pnl:.2f}U | 保证金: {self.total_margin:.2f}U")
|
||||
self.close_position(stop_price, 1.0, timestamp, "止损")
|
||||
return True
|
||||
|
||||
return False
|
||||
return unrealized_pnl <= -self.total_margin * self.stop_loss_ratio
|
||||
|
||||
def run_backtest(self, start_date, end_date):
|
||||
"""运行回测"""
|
||||
"""运行回测(开仓当K触发,平仓/止损仍按下一根K开盘执行)"""
|
||||
# 重置状态,支持同一实例重复回测
|
||||
self.capital = self.initial_capital
|
||||
self.position = 0
|
||||
@@ -533,66 +529,101 @@ class BollingerBandBacktest:
|
||||
logger.info(f"加载数据: {len(df)} 根K线")
|
||||
logger.info(f"时间范围: {df['datetime'].min()} ~ {df['datetime'].max()}")
|
||||
|
||||
# 加载对应时间范围的1分钟数据,用于中轨平半确认
|
||||
one_min_start = int(df['timestamp'].min())
|
||||
one_min_end = int(df['timestamp'].max()) + 300000
|
||||
self.load_one_minute_cache(one_min_start, one_min_end)
|
||||
|
||||
# 计算布林带
|
||||
df = self.calculate_bollinger_bands(df)
|
||||
|
||||
# 逐根K线回测
|
||||
for i in range(self.bb_period, len(df)):
|
||||
if len(df) <= self.bb_period + 1:
|
||||
logger.error("数据不足,无法执行回测")
|
||||
return None
|
||||
|
||||
# 逐根K线回测(开仓当K触发,平仓/止损下一K开盘执行)
|
||||
for i in range(self.bb_period, len(df) - 1):
|
||||
row = df.iloc[i]
|
||||
prev_row = df.iloc[i-1] if i > 0 else None
|
||||
next_row = df.iloc[i + 1]
|
||||
|
||||
timestamp = row['datetime'].strftime('%Y-%m-%d %H:%M')
|
||||
signal_dt = row['datetime']
|
||||
signal_ts = signal_dt.strftime('%Y-%m-%d %H:%M')
|
||||
five_min_ts = int(row['timestamp'])
|
||||
execute_ts = next_row['datetime'].strftime('%Y-%m-%d %H:%M')
|
||||
next_open = float(next_row['open']) if pd.notna(next_row['open']) else None
|
||||
|
||||
high = row['high']
|
||||
low = row['low']
|
||||
upper = row['upper']
|
||||
lower = row['lower']
|
||||
middle = row['middle']
|
||||
|
||||
# 先处理当前时刻返佣到账
|
||||
self.apply_pending_rebates(row['datetime'])
|
||||
# 先处理当前收盘时刻返佣到账
|
||||
self.apply_pending_rebates(signal_dt)
|
||||
|
||||
# 检查止损
|
||||
if self.check_stop_loss(high, low, timestamp):
|
||||
if pd.isna(upper) or pd.isna(lower) or pd.isna(middle):
|
||||
continue
|
||||
if next_open is None:
|
||||
continue
|
||||
|
||||
# 已处于延迟反转状态时,先执行确认逻辑
|
||||
# 检查止损(收盘确认,下一K开盘平仓)
|
||||
if self.check_stop_loss(high, low):
|
||||
logger.warning(f"[{signal_ts}] 触发止损信号,下一K开盘执行")
|
||||
self.close_position(next_open, 1.0, execute_ts, f"止损-收盘确认({signal_ts})")
|
||||
continue
|
||||
|
||||
# 已处于延迟反转状态时,先检查确认逻辑
|
||||
if self.delay_reverse_price is not None:
|
||||
if self.check_and_execute_delay_reversal(i, row, prev_row, timestamp):
|
||||
reversal_signal = self.check_delay_reversal_signal(i, row, prev_row)
|
||||
if reversal_signal is not None and self.position != 0:
|
||||
new_direction, reason, reversal_price = reversal_signal
|
||||
if reversal_price is None or pd.isna(reversal_price):
|
||||
reversal_price = float(row['close'])
|
||||
close_side = "多" if self.position > 0 else "空"
|
||||
open_side = "多" if new_direction == 'long' else "空"
|
||||
self.close_position(
|
||||
reversal_price,
|
||||
1.0,
|
||||
signal_ts,
|
||||
f"{reason}-当K确认({signal_ts})-平{close_side}"
|
||||
)
|
||||
open_price = self.apply_entry_slippage(reversal_price, new_direction)
|
||||
self.open_position(
|
||||
open_price,
|
||||
new_direction,
|
||||
signal_ts,
|
||||
f"{reason}-当K确认({signal_ts})-开{open_side}"
|
||||
)
|
||||
self.mid_closed_half = False
|
||||
self.clear_delay_reversal()
|
||||
continue
|
||||
|
||||
# === 中轨平仓逻辑 ===
|
||||
if self.position != 0:
|
||||
had_mid_closed_half = self.mid_closed_half
|
||||
if self.position > 0: # 多仓
|
||||
# 1m顺序确认:先上方再回踩中轨,平50%
|
||||
if not self.mid_closed_half and low <= middle <= high:
|
||||
should_close_half, reason_1m = self.should_close_half_by_1m_trend(
|
||||
five_min_ts, middle, 'long'
|
||||
)
|
||||
if should_close_half:
|
||||
self.close_position(middle, 0.5, timestamp, f"触中轨平50%-{reason_1m}")
|
||||
self.mid_closed_half = True
|
||||
# 继续回落到开仓均价全平
|
||||
if self.position != 0 and self.mid_closed_half and low <= self.entry_price:
|
||||
self.close_position(self.entry_price, 1.0, timestamp, "回开仓价全平")
|
||||
# 回到开仓价全平+反手(仅在此前已平半的前提下触发)
|
||||
if had_mid_closed_half and low <= self.entry_price:
|
||||
self.close_position(next_open, 1.0, execute_ts, f"回开仓价全平-收盘确认({signal_ts})")
|
||||
entry_price = self.apply_entry_slippage(next_open, 'short')
|
||||
self.open_position(entry_price, 'short', execute_ts, f"回开仓价反手开空-收盘确认({signal_ts})")
|
||||
self.mid_closed_half = False
|
||||
continue
|
||||
# 触中轨平半(收盘确认)
|
||||
if not had_mid_closed_half and low <= middle <= high:
|
||||
self.close_position(next_open, 0.5, execute_ts, f"触中轨平50%-收盘确认({signal_ts})")
|
||||
self.mid_closed_half = True
|
||||
continue
|
||||
|
||||
else: # 空仓
|
||||
# 1m顺序确认:先下方再反抽中轨,平50%
|
||||
if not self.mid_closed_half and low <= middle <= high:
|
||||
should_close_half, reason_1m = self.should_close_half_by_1m_trend(
|
||||
five_min_ts, middle, 'short'
|
||||
)
|
||||
if should_close_half:
|
||||
self.close_position(middle, 0.5, timestamp, f"触中轨平50%-{reason_1m}")
|
||||
self.mid_closed_half = True
|
||||
# 继续反弹到开仓均价全平
|
||||
if self.position != 0 and self.mid_closed_half and high >= self.entry_price:
|
||||
self.close_position(self.entry_price, 1.0, timestamp, "回开仓价全平")
|
||||
# 回到开仓价全平+反手(仅在此前已平半的前提下触发)
|
||||
if had_mid_closed_half and high >= self.entry_price:
|
||||
self.close_position(next_open, 1.0, execute_ts, f"回开仓价全平-收盘确认({signal_ts})")
|
||||
entry_price = self.apply_entry_slippage(next_open, 'long')
|
||||
self.open_position(entry_price, 'long', execute_ts, f"回开仓价反手开多-收盘确认({signal_ts})")
|
||||
self.mid_closed_half = False
|
||||
continue
|
||||
# 触中轨平半(收盘确认)
|
||||
if not had_mid_closed_half and low <= middle <= high:
|
||||
self.close_position(next_open, 0.5, execute_ts, f"触中轨平50%-收盘确认({signal_ts})")
|
||||
self.mid_closed_half = True
|
||||
continue
|
||||
|
||||
# === 开仓与加仓逻辑 ===
|
||||
if self.position == 0: # 空仓
|
||||
@@ -601,24 +632,22 @@ class BollingerBandBacktest:
|
||||
# 触上轨开空
|
||||
if high >= upper:
|
||||
entry_price = self.get_touch_entry_price(five_min_ts, 'short', upper)
|
||||
self.open_position(entry_price, 'short', timestamp, "触上轨开空")
|
||||
self.open_position(entry_price, 'short', signal_ts, f"触上轨开空-当K触发({signal_ts})")
|
||||
|
||||
# 触下轨开多
|
||||
elif low <= lower:
|
||||
entry_price = self.get_touch_entry_price(five_min_ts, 'long', lower)
|
||||
self.open_position(entry_price, 'long', timestamp, "触下轨开多")
|
||||
self.open_position(entry_price, 'long', signal_ts, f"触下轨开多-当K触发({signal_ts})")
|
||||
continue
|
||||
|
||||
# 有持仓:先检查是否触发延迟反转(核心)
|
||||
if self.position > 0 and high >= upper:
|
||||
self.mark_delay_reversal('long_to_short', upper, i, timestamp)
|
||||
if self.check_and_execute_delay_reversal(i, row, prev_row, timestamp):
|
||||
continue
|
||||
self.mark_delay_reversal('long_to_short', upper, i, signal_ts)
|
||||
continue
|
||||
|
||||
elif self.position < 0 and low <= lower:
|
||||
self.mark_delay_reversal('short_to_long', lower, i, timestamp)
|
||||
if self.check_and_execute_delay_reversal(i, row, prev_row, timestamp):
|
||||
continue
|
||||
self.mark_delay_reversal('short_to_long', lower, i, signal_ts)
|
||||
continue
|
||||
|
||||
# 进入延迟反转等待后,不再执行加仓
|
||||
if self.delay_reverse_price is not None:
|
||||
@@ -628,10 +657,13 @@ class BollingerBandBacktest:
|
||||
if self.position_count == 1:
|
||||
if self.position > 0 and low <= lower:
|
||||
entry_price = self.get_touch_entry_price(five_min_ts, 'long', lower)
|
||||
self.open_position(entry_price, 'long', timestamp, "触下轨加多")
|
||||
self.open_position(entry_price, 'long', signal_ts, f"触下轨加多-当K触发({signal_ts})")
|
||||
elif self.position < 0 and high >= upper:
|
||||
entry_price = self.get_touch_entry_price(five_min_ts, 'short', upper)
|
||||
self.open_position(entry_price, 'short', timestamp, "触上轨加空")
|
||||
self.open_position(entry_price, 'short', signal_ts, f"触上轨加空-当K触发({signal_ts})")
|
||||
|
||||
# 回测末尾再处理一次返佣到账
|
||||
self.apply_pending_rebates(df.iloc[-1]['datetime'])
|
||||
|
||||
# 最后平仓
|
||||
if self.position != 0:
|
||||
@@ -680,7 +712,9 @@ class BollingerBandBacktest:
|
||||
|
||||
# 保存交易记录
|
||||
trades_df = pd.DataFrame(self.trades)
|
||||
output_file = f'bb_backtest_{self.current_run_label}_trades.csv'
|
||||
output_dir = Path(__file__).parent / 'backtest_outputs' / 'trades'
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_file = output_dir / f'bb_backtest_{self.current_run_label}_trades.csv'
|
||||
trades_df.to_csv(output_file, index=False, encoding='utf-8-sig')
|
||||
logger.info(f"\n交易记录已保存到: {output_file}")
|
||||
|
||||
@@ -697,7 +731,7 @@ class BollingerBandBacktest:
|
||||
'total_trades': total_trades,
|
||||
'win_trades': win_trades,
|
||||
'loss_trades': loss_trades,
|
||||
'trades_file': output_file,
|
||||
'trades_file': str(output_file),
|
||||
'trades': self.trades
|
||||
}
|
||||
|
||||
@@ -710,8 +744,8 @@ if __name__ == '__main__':
|
||||
# 创建回测实例
|
||||
backtest = BollingerBandBacktest()
|
||||
|
||||
# 运行回测(2026年3月)
|
||||
result = backtest.run_backtest('2026-03-01', '2026-03-31')
|
||||
# 运行回测(2026年全年)
|
||||
result = backtest.run_backtest('2026-01-01', '2026-12-31')
|
||||
|
||||
if result:
|
||||
logger.success(f"\n回测完成!最终收益率: {result['roi']:.2f}%")
|
||||
|
||||
601
bb_delay_reversal_trade.py
Normal file
601
bb_delay_reversal_trade.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
布林带延迟反转策略 - 实盘交易
|
||||
基于回测策略 bb_backtest_march_2026.py
|
||||
使用API查询 + 浏览器自动化交易
|
||||
"""
|
||||
import time
|
||||
import numpy as np
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from bitmart.api_contract import APIContract
|
||||
from bit_tools import openBrowser
|
||||
from DrissionPage import ChromiumPage, ChromiumOptions
|
||||
|
||||
|
||||
class BBDelayReversalConfig:
|
||||
"""策略配置"""
|
||||
# API凭证
|
||||
API_KEY = "6104088c65a68d7e53df5d9395b67d78e555293a"
|
||||
SECRET_KEY = "a8b14312330d8e6b9b09acfd972b34e32022fdfa9f2b06f0a0a31723b873fd01"
|
||||
MEMO = "me"
|
||||
|
||||
# 合约
|
||||
CONTRACT_SYMBOL = "ETHUSDT"
|
||||
TRADE_URL = "https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT"
|
||||
|
||||
# 浏览器ID
|
||||
BIT_ID = "62f9107d0c674925972084e282df55b3"
|
||||
|
||||
# 布林带参数
|
||||
BB_PERIOD = 10
|
||||
BB_STD = 2.5
|
||||
|
||||
# 仓位管理
|
||||
LEVERAGE = 50
|
||||
OPEN_TYPE = "isolated"
|
||||
MARGIN_PCT = 0.01 # 首次开仓1%
|
||||
|
||||
# 运行参数
|
||||
POLL_INTERVAL = 5
|
||||
KLINE_STEP = 5
|
||||
KLINE_HOURS = 2
|
||||
|
||||
|
||||
class BBDelayReversalTrader:
|
||||
"""布林带延迟反转交易器"""
|
||||
|
||||
def __init__(self, cfg: BBDelayReversalConfig = None):
|
||||
self.cfg = cfg or BBDelayReversalConfig()
|
||||
self.api = APIContract(
|
||||
self.cfg.API_KEY, self.cfg.SECRET_KEY, self.cfg.MEMO,
|
||||
timeout=(5, 15)
|
||||
)
|
||||
|
||||
# 浏览器
|
||||
self.page: ChromiumPage | None = None
|
||||
self.page_start = True
|
||||
self.last_page_open_time = 0.0
|
||||
self.PAGE_REFRESH_INTERVAL = 1800
|
||||
|
||||
# 持仓状态
|
||||
self.position = 0 # -1空, 0无, 1多
|
||||
self.position_count = 0 # 0空仓, 1首次, 2加仓
|
||||
self.entry_price = 0
|
||||
self.current_amount = 0
|
||||
self.total_margin = 0
|
||||
|
||||
# 延迟反转状态
|
||||
self.delay_reverse_price = None
|
||||
self.delay_reverse_type = None # 'long_to_short' 或 'short_to_long'
|
||||
self.delay_reverse_kline_id = None
|
||||
|
||||
# 中轨平仓
|
||||
self.mid_closed_half = False
|
||||
|
||||
# 交易控制
|
||||
self.last_trade_time = 0.0
|
||||
self.last_kline_id = None
|
||||
|
||||
# 日志
|
||||
self.log_dir = Path(__file__).resolve().parent
|
||||
logger.add(
|
||||
self.log_dir / "bb_delay_trade_{time:YYYY-MM-DD}.log",
|
||||
rotation="1 day", retention="30 days",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"
|
||||
)
|
||||
|
||||
# ========== API查询方法 ==========
|
||||
|
||||
def get_klines(self) -> list | None:
|
||||
"""获取5分钟K线"""
|
||||
try:
|
||||
end_time = int(time.time())
|
||||
start_time = end_time - 3600 * self.cfg.KLINE_HOURS
|
||||
resp = self.api.get_kline(
|
||||
contract_symbol=self.cfg.CONTRACT_SYMBOL,
|
||||
step=self.cfg.KLINE_STEP,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)[0]
|
||||
if resp.get("code") != 1000:
|
||||
logger.error(f"获取K线失败: {resp}")
|
||||
return None
|
||||
data = resp["data"]
|
||||
klines = []
|
||||
for k in data:
|
||||
klines.append({
|
||||
"id": int(k["timestamp"]),
|
||||
"open": float(k["open_price"]),
|
||||
"high": float(k["high_price"]),
|
||||
"low": float(k["low_price"]),
|
||||
"close": float(k["close_price"]),
|
||||
})
|
||||
klines.sort(key=lambda x: x["id"])
|
||||
return klines
|
||||
except Exception as e:
|
||||
logger.error(f"获取K线异常: {e}")
|
||||
return None
|
||||
|
||||
def get_current_price(self) -> float | None:
|
||||
"""获取当前价格"""
|
||||
try:
|
||||
end_time = int(time.time())
|
||||
resp = self.api.get_kline(
|
||||
contract_symbol=self.cfg.CONTRACT_SYMBOL,
|
||||
step=1,
|
||||
start_time=end_time - 300,
|
||||
end_time=end_time
|
||||
)[0]
|
||||
if resp.get("code") == 1000 and resp["data"]:
|
||||
return float(resp["data"][-1]["close_price"])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取价格异常: {e}")
|
||||
return None
|
||||
|
||||
def get_balance(self) -> float | None:
|
||||
"""获取可用余额"""
|
||||
try:
|
||||
resp = self.api.get_assets_detail()[0]
|
||||
if resp.get("code") == 1000:
|
||||
data = resp["data"]
|
||||
if isinstance(data, dict):
|
||||
return float(data.get("available_balance", 0))
|
||||
elif isinstance(data, list):
|
||||
for asset in data:
|
||||
if asset.get("currency") == "USDT":
|
||||
return float(asset.get("available_balance", 0))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"查询余额异常: {e}")
|
||||
return None
|
||||
|
||||
def get_position_status(self) -> bool:
|
||||
"""查询持仓状态"""
|
||||
try:
|
||||
resp = self.api.get_position(contract_symbol=self.cfg.CONTRACT_SYMBOL)[0]
|
||||
if resp.get("code") != 1000:
|
||||
logger.error(f"查询持仓失败: {resp}")
|
||||
return False
|
||||
positions = resp["data"]
|
||||
if not positions:
|
||||
self.position = 0
|
||||
self.position_count = 0
|
||||
self.entry_price = 0
|
||||
self.current_amount = 0
|
||||
return True
|
||||
pos = positions[0]
|
||||
self.position = 1 if pos["position_type"] == 1 else -1
|
||||
self.entry_price = float(pos["open_avg_price"])
|
||||
self.current_amount = float(pos["current_amount"])
|
||||
logger.debug(f"持仓: {'多' if self.position > 0 else '空'} | "
|
||||
f"价格={self.entry_price:.2f} | 数量={self.current_amount:.4f}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"查询持仓异常: {e}")
|
||||
return False
|
||||
|
||||
def set_leverage(self) -> bool:
|
||||
"""设置杠杆"""
|
||||
try:
|
||||
resp = self.api.post_submit_leverage(
|
||||
contract_symbol=self.cfg.CONTRACT_SYMBOL,
|
||||
leverage=str(self.cfg.LEVERAGE),
|
||||
open_type=self.cfg.OPEN_TYPE
|
||||
)[0]
|
||||
if resp.get("code") == 1000:
|
||||
logger.success(f"杠杆设置成功: {self.cfg.LEVERAGE}x {self.cfg.OPEN_TYPE}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"杠杆设置失败: {resp}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"设置杠杆异常: {e}")
|
||||
return False
|
||||
|
||||
# ========== 布林带计算 ==========
|
||||
|
||||
def calc_bollinger(self, closes: list):
|
||||
"""计算布林带"""
|
||||
if len(closes) < self.cfg.BB_PERIOD:
|
||||
return None
|
||||
arr = np.array(closes[-self.cfg.BB_PERIOD:], dtype=float)
|
||||
mid = arr.mean()
|
||||
std = arr.std(ddof=0)
|
||||
upper = mid + self.cfg.BB_STD * std
|
||||
lower = mid - self.cfg.BB_STD * std
|
||||
return mid, upper, lower
|
||||
|
||||
# ========== 浏览器自动化 ==========
|
||||
|
||||
def open_browser(self) -> bool:
|
||||
"""打开浏览器"""
|
||||
try:
|
||||
bit_port = openBrowser(id=self.cfg.BIT_ID)
|
||||
co = ChromiumOptions()
|
||||
co.set_local_port(port=bit_port)
|
||||
self.page = ChromiumPage(addr_or_opts=co)
|
||||
self.last_page_open_time = time.time()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"打开浏览器失败: {e}")
|
||||
return False
|
||||
|
||||
def click_safe(self, xpath, sleep=0.5) -> bool:
|
||||
"""安全点击"""
|
||||
try:
|
||||
ele = self.page.ele(xpath)
|
||||
if not ele:
|
||||
return False
|
||||
ele.click(by_js=True)
|
||||
time.sleep(sleep)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"点击失败: {e}")
|
||||
return False
|
||||
|
||||
def browser_open_position(self, direction: str, usdt_amount: float) -> bool:
|
||||
"""浏览器开仓"""
|
||||
try:
|
||||
logger.info(f"浏览器操作: 开{'多' if direction == 'long' else '空'} {usdt_amount}U")
|
||||
|
||||
# 点击市价
|
||||
self.click_safe('x://button[normalize-space(text()) ="市价"]')
|
||||
|
||||
# 输入金额
|
||||
self.page.ele('x://*[@id="size_0"]').input(vals=usdt_amount, clear=True)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 点击开仓按钮
|
||||
if direction == 'long':
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
else:
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
|
||||
time.sleep(2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"浏览器开仓失败: {e}")
|
||||
return False
|
||||
|
||||
def browser_close_position(self, ratio: float = 1.0) -> bool:
|
||||
"""浏览器平仓"""
|
||||
try:
|
||||
logger.info(f"浏览器操作: 平仓{int(ratio*100)}%")
|
||||
|
||||
if ratio >= 0.99:
|
||||
# 全平
|
||||
self.click_safe('x://span[normalize-space(text()) ="市价"]')
|
||||
else:
|
||||
# 平半
|
||||
self.click_safe('x://button[normalize-space(text()) ="平仓"]')
|
||||
if ratio == 0.5:
|
||||
self.click_safe('x://*[@id="futureTradeForm"]/div[5]/div[3]/div[3]/span[3]')
|
||||
|
||||
if self.position > 0:
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/平多"]')
|
||||
else:
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/平空"]')
|
||||
|
||||
time.sleep(2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"浏览器平仓失败: {e}")
|
||||
return False
|
||||
|
||||
# ========== 延迟反转逻辑 ==========
|
||||
|
||||
def mark_delay_reversal(self, reverse_type: str, trigger_price: float, kline_id: int):
|
||||
"""标记延迟反转"""
|
||||
self.delay_reverse_type = reverse_type
|
||||
self.delay_reverse_price = trigger_price
|
||||
self.delay_reverse_kline_id = kline_id
|
||||
logger.info(f"触发延迟反转: {reverse_type} @ {trigger_price:.2f}")
|
||||
|
||||
def clear_delay_reversal(self):
|
||||
"""清除延迟反转状态"""
|
||||
self.delay_reverse_price = None
|
||||
self.delay_reverse_type = None
|
||||
self.delay_reverse_kline_id = None
|
||||
|
||||
def check_delay_reversal(self, current_kline, prev_kline, kline_index) -> tuple | None:
|
||||
"""检查延迟反转确认"""
|
||||
if self.position == 0 or self.delay_reverse_price is None:
|
||||
return None
|
||||
|
||||
if self.delay_reverse_kline_id is None:
|
||||
return None
|
||||
|
||||
offset = kline_index - self.delay_reverse_kline_id
|
||||
if offset <= 0:
|
||||
return None
|
||||
|
||||
high = current_kline['high']
|
||||
low = current_kline['low']
|
||||
|
||||
if self.delay_reverse_type == 'long_to_short':
|
||||
# 多转空: 回调到记录价格
|
||||
if offset == 1 and low <= self.delay_reverse_price:
|
||||
return 'short', self.delay_reverse_price, "次K回调确认"
|
||||
|
||||
if offset >= 2 and prev_kline:
|
||||
prev_body_low = min(prev_kline['open'], prev_kline['close'])
|
||||
if low <= prev_body_low:
|
||||
return 'short', prev_body_low, "跌破上一根实体"
|
||||
|
||||
elif self.delay_reverse_type == 'short_to_long':
|
||||
# 空转多: 反弹到记录价格
|
||||
if offset == 1 and high >= self.delay_reverse_price:
|
||||
return 'long', self.delay_reverse_price, "次K反弹确认"
|
||||
|
||||
if offset >= 2 and prev_kline:
|
||||
prev_body_high = max(prev_kline['open'], prev_kline['close'])
|
||||
if high >= prev_body_high:
|
||||
return 'long', prev_body_high, "突破上一根实体"
|
||||
|
||||
return None
|
||||
|
||||
# ========== 主循环 ==========
|
||||
|
||||
def run(self):
|
||||
"""策略主循环"""
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"布林带延迟反转策略启动")
|
||||
logger.info(f"BB({self.cfg.BB_PERIOD}, {self.cfg.BB_STD}) | "
|
||||
f"{self.cfg.LEVERAGE}x {self.cfg.OPEN_TYPE}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 设置杠杆
|
||||
if not self.set_leverage():
|
||||
logger.error("杠杆设置失败,退出")
|
||||
return
|
||||
|
||||
# 初始持仓
|
||||
if not self.get_position_status():
|
||||
logger.error("初始持仓查询失败,退出")
|
||||
return
|
||||
|
||||
logger.info(f"初始持仓: {self.position}")
|
||||
|
||||
page_start = True
|
||||
kline_history = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
# ===== 浏览器管理 =====
|
||||
if page_start:
|
||||
for i in range(5):
|
||||
if self.open_browser():
|
||||
logger.info("浏览器打开成功")
|
||||
break
|
||||
else:
|
||||
logger.error("打开浏览器失败")
|
||||
return
|
||||
|
||||
self.page.get(self.cfg.TRADE_URL)
|
||||
time.sleep(2)
|
||||
self.click_safe('x://button[normalize-space(text()) ="市价"]')
|
||||
page_start = False
|
||||
|
||||
# 定期刷新浏览器
|
||||
if time.time() - self.last_page_open_time >= self.PAGE_REFRESH_INTERVAL:
|
||||
logger.info("浏览器刷新")
|
||||
try:
|
||||
self.page.close()
|
||||
except:
|
||||
pass
|
||||
page_start = True
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
# ===== 获取K线 =====
|
||||
klines = self.get_klines()
|
||||
if not klines or len(klines) < self.cfg.BB_PERIOD + 1:
|
||||
logger.warning(f"K线数据不足: {len(klines) if klines else 0}")
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# 使用已收盘K线计算BB
|
||||
closed_klines = klines[:-1]
|
||||
current_kline = klines[-1]
|
||||
|
||||
if len(closed_klines) < self.cfg.BB_PERIOD:
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# 计算布林带
|
||||
closes = [k['close'] for k in closed_klines]
|
||||
bb = self.calc_bollinger(closes)
|
||||
if bb is None:
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
bb_mid, bb_upper, bb_lower = bb
|
||||
|
||||
# 获取当前价格
|
||||
current_price = self.get_current_price()
|
||||
if current_price is None:
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
cur_high = current_kline['high']
|
||||
cur_low = current_kline['low']
|
||||
kline_id = current_kline['id']
|
||||
|
||||
# 触轨判断
|
||||
touched_upper = cur_high >= bb_upper
|
||||
touched_lower = cur_low <= bb_lower
|
||||
touched_middle = cur_low <= bb_mid <= cur_high
|
||||
|
||||
logger.info(
|
||||
f"价格={current_price:.2f} | "
|
||||
f"BB: {bb_lower:.2f}/{bb_mid:.2f}/{bb_upper:.2f} | "
|
||||
f"触上={touched_upper} 触下={touched_lower} 触中={touched_middle} | "
|
||||
f"仓位={self.position}"
|
||||
)
|
||||
|
||||
# 同步持仓
|
||||
if not self.get_position_status():
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# 避免同一K线重复触发
|
||||
if kline_id == self.last_kline_id:
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# ===== 延迟反转确认 =====
|
||||
if self.delay_reverse_price is not None and len(kline_history) > 0:
|
||||
prev_kline = kline_history[-1] if len(kline_history) > 0 else None
|
||||
reversal = self.check_delay_reversal(
|
||||
current_kline, prev_kline, len(kline_history)
|
||||
)
|
||||
|
||||
if reversal:
|
||||
new_direction, reversal_price, reason = reversal
|
||||
logger.info(f"延迟反转确认: {reason} @ {reversal_price:.2f}")
|
||||
|
||||
# 平仓
|
||||
self.browser_close_position(1.0)
|
||||
time.sleep(2)
|
||||
self.get_position_status()
|
||||
|
||||
if self.position == 0:
|
||||
# 反向开仓
|
||||
balance = self.get_balance()
|
||||
if balance:
|
||||
usdt_amount = round(balance * self.cfg.MARGIN_PCT, 2)
|
||||
self.browser_open_position(new_direction, usdt_amount)
|
||||
time.sleep(2)
|
||||
self.get_position_status()
|
||||
|
||||
if self.position != 0:
|
||||
self.position_count = 1
|
||||
self.mid_closed_half = False
|
||||
self.clear_delay_reversal()
|
||||
self.last_kline_id = kline_id
|
||||
|
||||
continue
|
||||
|
||||
# ===== 中轨平仓 =====
|
||||
if self.position != 0 and touched_middle:
|
||||
if not self.mid_closed_half:
|
||||
logger.info("触中轨,平50%")
|
||||
self.browser_close_position(0.5)
|
||||
time.sleep(2)
|
||||
self.mid_closed_half = True
|
||||
self.last_kline_id = kline_id
|
||||
continue
|
||||
|
||||
elif self.mid_closed_half:
|
||||
# 回到开仓价全平+反手
|
||||
if (self.position > 0 and cur_low <= self.entry_price) or \
|
||||
(self.position < 0 and cur_high >= self.entry_price):
|
||||
logger.info("回到开仓价,全平+反手")
|
||||
|
||||
old_direction = 'long' if self.position > 0 else 'short'
|
||||
new_direction = 'short' if old_direction == 'long' else 'long'
|
||||
|
||||
self.browser_close_position(1.0)
|
||||
time.sleep(2)
|
||||
self.get_position_status()
|
||||
|
||||
if self.position == 0:
|
||||
balance = self.get_balance()
|
||||
if balance:
|
||||
usdt_amount = round(balance * self.cfg.MARGIN_PCT, 2)
|
||||
self.browser_open_position(new_direction, usdt_amount)
|
||||
time.sleep(2)
|
||||
self.get_position_status()
|
||||
|
||||
if self.position != 0:
|
||||
self.position_count = 1
|
||||
self.mid_closed_half = False
|
||||
self.last_kline_id = kline_id
|
||||
|
||||
continue
|
||||
|
||||
# ===== 开仓与加仓 =====
|
||||
if self.position == 0:
|
||||
self.clear_delay_reversal()
|
||||
|
||||
balance = self.get_balance()
|
||||
if not balance:
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
usdt_amount = round(balance * self.cfg.MARGIN_PCT, 2)
|
||||
|
||||
if touched_upper:
|
||||
logger.info(f"空仓触上轨,开空 {usdt_amount}U")
|
||||
self.browser_open_position('short', usdt_amount)
|
||||
time.sleep(2)
|
||||
self.get_position_status()
|
||||
if self.position == -1:
|
||||
self.position_count = 1
|
||||
self.last_kline_id = kline_id
|
||||
|
||||
elif touched_lower:
|
||||
logger.info(f"空仓触下轨,开多 {usdt_amount}U")
|
||||
self.browser_open_position('long', usdt_amount)
|
||||
time.sleep(2)
|
||||
self.get_position_status()
|
||||
if self.position == 1:
|
||||
self.position_count = 1
|
||||
self.last_kline_id = kline_id
|
||||
|
||||
# ===== 延迟反转触发 =====
|
||||
elif self.position > 0 and touched_upper:
|
||||
logger.info("多仓触上轨,标记延迟反转")
|
||||
self.mark_delay_reversal('long_to_short', bb_upper, len(kline_history))
|
||||
self.last_kline_id = kline_id
|
||||
|
||||
elif self.position < 0 and touched_lower:
|
||||
logger.info("空仓触下轨,标记延迟反转")
|
||||
self.mark_delay_reversal('short_to_long', bb_lower, len(kline_history))
|
||||
self.last_kline_id = kline_id
|
||||
|
||||
# ===== 加仓 =====
|
||||
elif self.position_count == 1 and self.delay_reverse_price is None:
|
||||
balance = self.get_balance()
|
||||
if balance:
|
||||
usdt_amount = round(balance * self.cfg.MARGIN_PCT, 2)
|
||||
|
||||
if self.position > 0 and touched_lower:
|
||||
logger.info(f"多仓触下轨,加仓 {usdt_amount}U")
|
||||
self.browser_open_position('long', usdt_amount)
|
||||
time.sleep(2)
|
||||
self.get_position_status()
|
||||
if self.position == 1:
|
||||
self.position_count = 2
|
||||
self.last_kline_id = kline_id
|
||||
|
||||
elif self.position < 0 and touched_upper:
|
||||
logger.info(f"空仓触上轨,加仓 {usdt_amount}U")
|
||||
self.browser_open_position('short', usdt_amount)
|
||||
time.sleep(2)
|
||||
self.get_position_status()
|
||||
if self.position == -1:
|
||||
self.position_count = 2
|
||||
self.last_kline_id = kline_id
|
||||
|
||||
# 更新K线历史
|
||||
kline_history.append(current_kline)
|
||||
if len(kline_history) > 100:
|
||||
kline_history = kline_history[-100:]
|
||||
|
||||
time.sleep(self.cfg.POLL_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("用户中断")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"主循环异常: {e}")
|
||||
page_start = True
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
trader = BBDelayReversalTrader()
|
||||
trader.run()
|
||||
38864
bb_sweep_results.csv
38864
bb_sweep_results.csv
File diff suppressed because it is too large
Load Diff
267
bitmart/框架.py
Normal file
267
bitmart/框架.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import time
|
||||
|
||||
from tqdm import tqdm
|
||||
from loguru import logger
|
||||
from bit_tools import openBrowser
|
||||
from DrissionPage import ChromiumPage
|
||||
from DrissionPage import ChromiumOptions
|
||||
|
||||
from bitmart.api_contract import APIContract
|
||||
|
||||
|
||||
class BitmartFuturesTransaction:
|
||||
def __init__(self, bit_id):
|
||||
|
||||
self.page: ChromiumPage | None = None
|
||||
|
||||
self.api_key = "a0fb7b98464fd9bcce67e7c519d58ec10d0c38a8"
|
||||
self.secret_key = "4eaeba78e77aeaab1c2027f846a276d164f264a44c2c1bb1c5f3be50c8de1ca5"
|
||||
self.memo = "合约交易"
|
||||
|
||||
self.contract_symbol = "ETHUSDT"
|
||||
|
||||
self.contractAPI = APIContract(self.api_key, self.secret_key, self.memo, timeout=(5, 15))
|
||||
|
||||
self.start = 0 # 持仓状态: -1 空, 0 无, 1 多
|
||||
self.direction = None
|
||||
|
||||
self.pbar = tqdm(total=30, desc="等待K线", ncols=80)
|
||||
|
||||
self.last_kline_time = None
|
||||
|
||||
self.leverage = "100" # 高杠杆(全仓模式下可开更大仓位)
|
||||
self.open_type = "cross" # 全仓模式(你的“成本开仓”需求)
|
||||
self.risk_percent = 0.01 # 每次开仓使用可用余额的 1%
|
||||
|
||||
self.open_avg_price = None # 开仓价格
|
||||
self.current_amount = None # 持仓量
|
||||
|
||||
self.bit_id = bit_id
|
||||
|
||||
def get_klines(self):
|
||||
"""获取最近3根30分钟K线(step=30)"""
|
||||
try:
|
||||
end_time = int(time.time())
|
||||
# 获取足够多的条目确保有最新3根
|
||||
response = self.contractAPI.get_kline(
|
||||
contract_symbol=self.contract_symbol,
|
||||
step=30, # 30分钟
|
||||
start_time=end_time - 3600 * 10, # 取最近10小时
|
||||
end_time=end_time
|
||||
)[0]["data"]
|
||||
|
||||
# 每根: [timestamp, open, high, low, close, volume]
|
||||
formatted = []
|
||||
for k in response:
|
||||
formatted.append({
|
||||
'id': int(k["timestamp"]),
|
||||
'open': float(k["open_price"]),
|
||||
'high': float(k["high_price"]),
|
||||
'low': float(k["low_price"]),
|
||||
'close': float(k["close_price"])
|
||||
})
|
||||
formatted.sort(key=lambda x: x['id'])
|
||||
return formatted # 最近3根: kline_1 (最老), kline_2, kline_3 (最新)
|
||||
except Exception as e:
|
||||
logger.error(f"获取K线异常: {e}")
|
||||
self.ding(error=True, msg="获取K线异常")
|
||||
return None
|
||||
|
||||
def get_current_price(self):
|
||||
"""获取当前最新价格,用于计算张数"""
|
||||
try:
|
||||
end_time = int(time.time())
|
||||
response = self.contractAPI.get_kline(
|
||||
contract_symbol=self.contract_symbol,
|
||||
step=1, # 1分钟
|
||||
start_time=end_time - 3600 * 3, # 取最近10小时
|
||||
end_time=end_time
|
||||
)[0]
|
||||
if response['code'] == 1000:
|
||||
return float(response['data'][-1]["close_price"])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取价格异常: {e}")
|
||||
return None
|
||||
|
||||
def get_available_balance(self):
|
||||
"""获取合约账户可用USDT余额"""
|
||||
try:
|
||||
response = self.contractAPI.get_assets_detail()[0]
|
||||
if response['code'] == 1000:
|
||||
data = response['data']
|
||||
if isinstance(data, dict):
|
||||
return float(data.get('available_balance', 0))
|
||||
elif isinstance(data, list):
|
||||
for asset in data:
|
||||
if asset.get('currency') == 'USDT':
|
||||
return float(asset.get('available_balance', 0))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"余额查询异常: {e}")
|
||||
return None
|
||||
|
||||
# 获取当前持仓方向
|
||||
def get_position_status(self):
|
||||
"""获取当前持仓方向"""
|
||||
try:
|
||||
response = self.contractAPI.get_position(contract_symbol=self.contract_symbol)[0]
|
||||
if response['code'] == 1000:
|
||||
positions = response['data']
|
||||
if not positions:
|
||||
self.start = 0
|
||||
return True
|
||||
self.start = 1 if positions[0]['position_type'] == 1 else -1
|
||||
self.open_avg_price = positions[0]['open_avg_price']
|
||||
self.current_amount = positions[0]['current_amount']
|
||||
self.position_cross = positions[0]["position_cross"]
|
||||
return True
|
||||
|
||||
else:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"持仓查询异常: {e}")
|
||||
return False
|
||||
|
||||
# 设置杠杆和全仓
|
||||
def set_leverage(self):
|
||||
"""程序启动时设置全仓 + 高杠杆"""
|
||||
try:
|
||||
response = self.contractAPI.post_submit_leverage(
|
||||
contract_symbol=self.contract_symbol,
|
||||
leverage=self.leverage,
|
||||
open_type=self.open_type
|
||||
)[0]
|
||||
if response['code'] == 1000:
|
||||
logger.success(f"全仓模式 + {self.leverage}x 杠杆设置成功")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"杠杆设置失败: {response}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"设置杠杆异常: {e}")
|
||||
return False
|
||||
|
||||
def openBrowser(self):
|
||||
"""打开 TGE 对应浏览器实例"""
|
||||
try:
|
||||
bit_port = openBrowser(id=self.bit_id)
|
||||
co = ChromiumOptions()
|
||||
co.set_local_port(port=bit_port)
|
||||
self.page = ChromiumPage(addr_or_opts=co)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def take_over_browser(self):
|
||||
"""接管浏览器"""
|
||||
try:
|
||||
co = ChromiumOptions()
|
||||
co.set_local_port(self.tge_port)
|
||||
self.page = ChromiumPage(addr_or_opts=co)
|
||||
self.page.set.window.max()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def close_extra_tabs(self):
|
||||
"""关闭多余 tab"""
|
||||
try:
|
||||
for idx, tab in enumerate(self.page.get_tabs()):
|
||||
if idx > 0:
|
||||
tab.close()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def click_safe(self, xpath, sleep=0.5):
|
||||
"""安全点击"""
|
||||
try:
|
||||
ele = self.page.ele(xpath)
|
||||
if not ele:
|
||||
return False
|
||||
ele.scroll.to_see(center=True)
|
||||
time.sleep(sleep)
|
||||
ele.click()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def 全平仓(self):
|
||||
self.click_safe('x://span[normalize-space(text()) ="市价"]')
|
||||
|
||||
def 平一半多仓(self):
|
||||
self.click_safe('x://button[normalize-space(text()) ="平仓"]')
|
||||
self.click_safe('x://*[@id="futureTradeForm"]/div[5]/div[3]/div[3]/span[3]')
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/平多"]')
|
||||
|
||||
def 平一半空仓(self):
|
||||
self.click_safe('x://button[normalize-space(text()) ="平仓"]')
|
||||
self.click_safe('x://*[@id="futureTradeForm"]/div[5]/div[3]/div[3]/span[3]')
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/平空"]')
|
||||
|
||||
def 开单(self, marketPriceLongOrder=0, limitPriceShortOrder=0, size=None, price=None):
|
||||
"""
|
||||
marketPriceLongOrder 市价最多或者做空,1是做多,-1是做空
|
||||
limitPriceShortOrder 限价最多或者做空
|
||||
"""
|
||||
|
||||
if marketPriceLongOrder == -1:
|
||||
self.click_safe('x://button[normalize-space(text()) ="市价"]')
|
||||
self.page.ele('x://*[@id="size_0"]').input(size)
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
elif marketPriceLongOrder == 1:
|
||||
self.click_safe('x://button[normalize-space(text()) ="市价"]')
|
||||
self.page.ele('x://*[@id="size_0"]').input(size)
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
|
||||
if limitPriceShortOrder == -1:
|
||||
self.click_safe('x://button[normalize-space(text()) ="限价"]')
|
||||
self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True)
|
||||
time.sleep(1)
|
||||
self.page.ele('x://*[@id="size_0"]').input(1)
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
elif limitPriceShortOrder == 1:
|
||||
self.click_safe('x://button[normalize-space(text()) ="限价"]')
|
||||
self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True)
|
||||
time.sleep(1)
|
||||
self.page.ele('x://*[@id="size_0"]').input(1)
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
|
||||
def ding(self, text, error=False):
|
||||
logger.info(text)
|
||||
|
||||
def action(self):
|
||||
# 启动时设置全仓高杠杆
|
||||
if not self.set_leverage():
|
||||
logger.error("杠杆设置失败,程序继续运行但可能下单失败")
|
||||
return
|
||||
|
||||
# 1. 打开浏览器
|
||||
if not self.openBrowser():
|
||||
self.ding("打开 TGE 失败!", error=True)
|
||||
return
|
||||
logger.info("TGE 端口获取成功")
|
||||
|
||||
self.get_klines()
|
||||
|
||||
# self.close_extra_tabs()
|
||||
# self.page.get("https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT")
|
||||
#
|
||||
self.click_safe('x://button[normalize-space(text()) ="市价"]')
|
||||
# self.click_safe('x://button[normalize-space(text()) ="限价"]')
|
||||
#
|
||||
# self.page.ele('x://*[@id="price_0"]').input(vals=3000, clear=True)
|
||||
# self.page.ele('x://*[@id="size_0"]').input(1)
|
||||
# self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
|
||||
# self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
|
||||
|
||||
self.click_safe('x://button[normalize-space(text()) ="平仓"]')
|
||||
self.click_safe('x://span[normalize-space(text()) ="买入/平空"]')
|
||||
self.click_safe('x://span[normalize-space(text()) ="卖出/平多"]')
|
||||
self.click_safe('x://*[@id="futureTradeForm"]/div[5]/div[3]/div[3]/span[3]')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
BitmartFuturesTransaction(bit_id="f2320f57e24c45529a009e1541e25961").action()
|
||||
@@ -28,12 +28,12 @@ class BitMartETH5M(Model):
|
||||
|
||||
|
||||
def calculate_bollinger_bands(df, period=10, std_dev=2.5):
|
||||
"""计算布林带"""
|
||||
"""计算布林带(右移1根,与回测口径一致)"""
|
||||
df['sma'] = df['close'].rolling(window=period).mean()
|
||||
df['std'] = df['close'].rolling(window=period).std()
|
||||
df['bb_upper'] = df['sma'] + std_dev * df['std']
|
||||
df['bb_mid'] = df['sma']
|
||||
df['bb_lower'] = df['sma'] - std_dev * df['std']
|
||||
df['bb_upper'] = (df['sma'] + std_dev * df['std']).shift(1)
|
||||
df['bb_mid'] = df['sma'].shift(1)
|
||||
df['bb_lower'] = (df['sma'] - std_dev * df['std']).shift(1)
|
||||
return df
|
||||
|
||||
|
||||
@@ -504,20 +504,24 @@ if __name__ == '__main__':
|
||||
try:
|
||||
print("正在生成回测可视化图表...")
|
||||
|
||||
output_dir = Path(__file__).parent / 'backtest_outputs' / 'charts'
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成图表数据
|
||||
chart_data, trades_markers = generate_chart_data(
|
||||
start_date='2026-03-01',
|
||||
end_date='2026-03-03',
|
||||
trades_file='bb_backtest_march_2026_trades.csv'
|
||||
trades_file=str(Path(__file__).parent / 'backtest_outputs' / 'trades' / 'bb_backtest_march_2026_trades.csv')
|
||||
)
|
||||
|
||||
print(f"📊 K线数据: {len(chart_data)} 根")
|
||||
print(f"📍 交易标记: {len(trades_markers)} 个")
|
||||
|
||||
# 生成HTML
|
||||
generate_html(chart_data, trades_markers, 'bb_backtest_visualization.html')
|
||||
output_file = output_dir / 'bb_backtest_visualization.html'
|
||||
generate_html(chart_data, trades_markers, str(output_file))
|
||||
|
||||
print("\n🎉 完成!请在浏览器中打开 bb_backtest_visualization.html 查看图表")
|
||||
print(f"\n🎉 完成!请在浏览器中打开 {output_file} 查看图表")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user