This commit is contained in:
27942
2026-02-25 06:21:49 +08:00
parent c8fb43a700
commit 905ce34aa7
7 changed files with 1445 additions and 162 deletions

View File

@@ -1,22 +1,24 @@
"""
布林带均值回归策略 — 实盘交易
BB(10, 2.5) | 5分钟K线 | ETH | 50x杠杆 | 每单权益1%
布林带均值回归策略 — 实盘交易 (D方案: 递增加仓)
BB(10, 2.5) | 5分钟K线 | ETH | 50x杠杆 | 递增加仓+1%/次 max=3
逻辑:
- 价格触及上布林带 → 平多(如有) + 开空
- 价格触及下布林带 → 平空(如有) + 开多
- 始终持仓(多空翻转)
- 价格触及上布林带 → 平多(如有) + 开空; 已持空则加仓
- 价格触及下布林带 → 平空(如有) + 开多; 已持多则加仓
- 始终持仓(多空翻转 + 同向加仓
- 加仓比例: 开仓1%, 第1次加仓2%, 第2次3%, 第3次4%, 最多加仓3次
使用 BitMart Futures API 进行开平仓
使用浏览器自动化进行开平仓有手续费返佣API仅用于查询数据
"""
import time
import uuid
import numpy as np
from datetime import datetime
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
# ---------------------------------------------------------------------------
@@ -30,6 +32,10 @@ class BBTradeConfig:
# 合约
CONTRACT_SYMBOL = "ETHUSDT"
TRADE_URL = "https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT"
# 浏览器
BIT_ID = "62f9107d0c674925972084e282df55b3"
# 布林带参数
BB_PERIOD = 10 # 10根5分钟K线 = 50分钟回看
@@ -38,7 +44,11 @@ class BBTradeConfig:
# 仓位管理
LEVERAGE = 50 # 杠杆倍数
OPEN_TYPE = "cross" # 全仓模式
MARGIN_PCT = 0.01 # 每单用权益的1%作为保证金
MARGIN_PCT = 0.01 # 首次开仓用权益的1%作为保证金
# 递增加仓 (D方案)
PYRAMID_STEP = 0.01 # 每次加仓增加1%权益比例 (1%→2%→3%→4%)
PYRAMID_MAX = 3 # 最多加仓3次 (首次开仓不算)
# 风控
MAX_DAILY_LOSS = 50.0 # 日最大亏损(U),达到后停止交易
@@ -76,11 +86,20 @@ class BBTrader:
timeout=(5, 15)
)
# 浏览器
self.page: ChromiumPage | None = None
self.page_start = True # 需要(重新)打开浏览器
self.last_page_open_time = 0.0 # 上次打开浏览器的时间
self.PAGE_REFRESH_INTERVAL = 180 # 每3分钟关闭重开浏览器
# 持仓状态: -1=空, 0=无, 1=多
self.position = 0
self.open_avg_price = None
self.current_amount = None
# 加仓状态
self.pyramid_count = 0 # 当前已加仓次数 (0=仅首次开仓)
# 风控
self.daily_pnl = 0.0
self.daily_stopped = False
@@ -205,133 +224,77 @@ class BBTrader:
logger.error(f"设置杠杆异常: {e}")
return False
def _gen_client_order_id(self) -> str:
return f"BB_{uuid.uuid4().hex[:12]}"
def submit_order(self, side: int, size: int) -> bool:
"""
提交市价单
side: 1=买入开多, 2=买入平空, 3=卖出平多, 4=卖出开空
size: 张数
"""
side_names = {1: "买入开多", 2: "买入平空", 3: "卖出平多", 4: "卖出开空"}
logger.info(f"下单: {side_names.get(side, side)} {size}")
# ------------------------------------------------------------------
# 浏览器自动化
# ------------------------------------------------------------------
def open_browser(self) -> bool:
"""打开浏览器并进入交易页面"""
try:
resp = self.api.post_submit_order(
contract_symbol=self.cfg.CONTRACT_SYMBOL,
client_order_id=self._gen_client_order_id(),
side=side,
mode=1, # GTC
type="market",
leverage=str(self.cfg.LEVERAGE),
open_type=self.cfg.OPEN_TYPE,
size=size,
)[0]
if resp.get("code") == 1000:
logger.success(f"下单成功: {side_names.get(side)} {size}张 resp={resp}")
return True
else:
logger.error(f"下单失败: {resp}")
return False
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}")
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"点击失败 [{xpath}]: {e}")
return False
def browser_close_position(self) -> bool:
"""浏览器点击市价全平"""
logger.info("浏览器操作: 市价平仓")
return self.click_safe('x://span[normalize-space(text()) ="市价"]')
# ------------------------------------------------------------------
# 仓位操作
# ------------------------------------------------------------------
def calc_order_size(self, price: float) -> int:
def calc_order_usdt(self, is_add: bool = False) -> float:
"""
根据当前权益的1%计算开仓张数
BitMart ETH合约: 1张 = 0.01 ETH
保证金 = equity * margin_pct
名义价值 = margin * leverage
数量(ETH) = 名义价值 / price
张数 = 数量 / 0.01
计算开仓/加仓金额(U)
首次开仓: 余额 × MARGIN_PCT (1%)
加仓: 余额 × (MARGIN_PCT + PYRAMID_STEP × (pyramid_count+1))
例: 开仓1%, 第1次加仓2%, 第2次加仓3%, 第3次加仓4%
"""
balance = self.get_balance()
if balance is None or balance <= 0:
logger.warning(f"余额不足或查询失败: {balance}")
return 0
margin = balance * self.cfg.MARGIN_PCT
notional = margin * self.cfg.LEVERAGE
qty_eth = notional / price
size = max(1, int(qty_eth / 0.01)) # 1张=0.01ETH
logger.info(f"仓位计算: 余额={balance:.2f} 保证金={margin:.2f} "
f"名义={notional:.2f} 数量={qty_eth:.4f}ETH = {size}")
return size
def close_current_position(self) -> bool:
"""平掉当前持仓"""
if not self.get_position_status():
return False
if self.position == 0:
logger.info("无持仓,无需平仓")
return True
if self.position == 1:
# 平多: side=3
size = int(self.current_amount)
return self.submit_order(side=3, size=size)
if is_add:
pct = self.cfg.MARGIN_PCT + self.cfg.PYRAMID_STEP * (self.pyramid_count + 1)
else:
# 平空: side=2
size = int(self.current_amount)
return self.submit_order(side=2, size=size)
pct = self.cfg.MARGIN_PCT
order_usdt = round(balance * pct, 2)
logger.info(f"仓位计算: 余额={balance:.2f} × {pct:.0%} = {order_usdt} U"
f" ({'加仓#' + str(self.pyramid_count+1) if is_add else '首次开仓'})")
return order_usdt
def open_long(self, price: float) -> bool:
"""开多"""
size = self.calc_order_size(price)
if size <= 0:
return False
return self.submit_order(side=1, size=size)
def open_short(self, price: float) -> bool:
"""开空"""
size = self.calc_order_size(price)
if size <= 0:
return False
return self.submit_order(side=4, size=size)
def flip_to_long(self, price: float) -> bool:
"""平空 → 开多"""
logger.info("=== 翻转为多 ===")
if self.position == -1:
if not self.close_current_position():
logger.error("平空失败,放弃开多")
return False
time.sleep(2)
# 确认已无仓
for _ in range(5):
if self.get_position_status() and self.position == 0:
break
time.sleep(1)
if self.position != 0:
logger.warning(f"平仓后仍有持仓({self.position}),放弃开多")
return False
return self.open_long(price)
def flip_to_short(self, price: float) -> bool:
"""平多 → 开空"""
logger.info("=== 翻转为空 ===")
if self.position == 1:
if not self.close_current_position():
logger.error("平多失败,放弃开空")
return False
time.sleep(2)
for _ in range(5):
if self.get_position_status() and self.position == 0:
break
time.sleep(1)
if self.position != 0:
logger.warning(f"平仓后仍有持仓({self.position}),放弃开空")
return False
return self.open_short(price)
def verify_position(self, expected: int) -> bool:
"""验证持仓方向"""
if self.get_position_status():
if self.position == expected:
return True
logger.warning(f"持仓方向不符: 期望{expected}, 实际{self.position}")
return False
# ------------------------------------------------------------------
# 风控
# ------------------------------------------------------------------
def check_daily_reset(self):
"""每日重置(UTC+8 00:00 = UTC 16:00)"""
now = datetime.utcnow()
now = datetime.now(timezone.utc)
# 用UTC日期做简单日切
today = now.date()
if self.current_date != today:
@@ -377,14 +340,14 @@ class BBTrader:
logger.warning(f"写入日志失败: {e}")
# ------------------------------------------------------------------
# 主循环
# 主循环(浏览器流程与四分之一代码一致)
# ------------------------------------------------------------------
def run(self):
"""策略主循环"""
logger.info("=" * 60)
logger.info(f" BB策略启动: BB({self.cfg.BB_PERIOD},{self.cfg.BB_STD})")
logger.info(f" BB策略启动(D方案): BB({self.cfg.BB_PERIOD},{self.cfg.BB_STD})")
logger.info(f" 合约: {self.cfg.CONTRACT_SYMBOL} | {self.cfg.LEVERAGE}x {self.cfg.OPEN_TYPE}")
logger.info(f" 每单: 权益×{self.cfg.MARGIN_PCT:.0%}")
logger.info(f" 首次开仓: 权益×{self.cfg.MARGIN_PCT:.0%} | 递增加仓: +{self.cfg.PYRAMID_STEP:.0%}/次 | 最多{self.cfg.PYRAMID_MAX}")
logger.info("=" * 60)
# 设置杠杆
@@ -399,9 +362,50 @@ class BBTrader:
logger.info(f"初始持仓状态: {self.position}")
last_kline_id = None # 避免同一根K线重复触发
page_start = True # 需要打开浏览器
while True:
# ===== 浏览器管理 =====
# page_start时: 打开浏览器 → 导航 → 点市价 → 输入张数
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()) ="市价"]')
time.sleep(0.5)
# 计算并预输入开仓金额(U)
current_price = self.get_current_price()
if current_price:
order_usdt = self.calc_order_usdt()
if order_usdt > 0:
self.page.ele('x://*[@id="size_0"]').input(vals=order_usdt, clear=True)
logger.info(f"预输入开仓金额: {order_usdt} U")
page_start = False
try:
# 每3分钟关闭浏览器重新打开
if time.time() - self.last_page_open_time >= self.PAGE_REFRESH_INTERVAL:
logger.info("浏览器已打开超过3分钟关闭刷新")
try:
self.page.close()
except Exception:
pass
self.page = None
page_start = True
time.sleep(3)
continue
self.check_daily_reset()
if self.daily_stopped:
@@ -416,10 +420,8 @@ class BBTrader:
time.sleep(self.cfg.POLL_INTERVAL)
continue
# 当前K线 = 最后一根未收盘信号用已收盘的K线
# 使用倒数第二根及之前的收盘价算BB已收盘的K线
closed_klines = klines[:-1] # 已收盘的K线
current_kline = klines[-1] # 当前未收盘K线
closed_klines = klines[:-1]
current_kline = klines[-1]
if len(closed_klines) < self.cfg.BB_PERIOD:
time.sleep(self.cfg.POLL_INTERVAL)
@@ -439,11 +441,11 @@ class BBTrader:
time.sleep(self.cfg.POLL_INTERVAL)
continue
# 用当前K线的 high/low 判断是否触及布林带
cur_high = current_kline["high"]
cur_low = current_kline["low"]
touched_upper = cur_high >= bb_upper
touched_lower = cur_low <= bb_lower
# 容错: K线high/low + 当前实时价格,任一触及即算触碰
touched_upper = cur_high >= bb_upper or current_price >= bb_upper
touched_lower = cur_low <= bb_lower or current_price <= bb_lower
logger.info(
f"价格={current_price:.2f} | "
@@ -458,15 +460,12 @@ class BBTrader:
time.sleep(self.cfg.POLL_INTERVAL)
continue
# 5. 信号判断 + 执行
# 同一根K线只触发一次
# 5. 信号判断
kline_id = current_kline["id"]
if kline_id == last_kline_id:
# 已在这根K线触发过不重复操作
time.sleep(self.cfg.POLL_INTERVAL)
continue
# 同时触及上下轨(极端波动)→ 跳过
if touched_upper and touched_lower:
logger.warning("同时触及上下轨,跳过")
time.sleep(self.cfg.POLL_INTERVAL)
@@ -474,9 +473,10 @@ class BBTrader:
action = None
reason = ""
success = False
# ===== 触及上轨 → 开空 / 翻转为空 / 加仓空 =====
if touched_upper:
# 触及上轨 → 开空 / 翻转为空
if not self.can_trade():
time.sleep(self.cfg.POLL_INTERVAL)
continue
@@ -486,24 +486,52 @@ class BBTrader:
if self.position == 1:
action = "翻转: 平多→开空"
success = self.flip_to_short(current_price)
# 在当前页面点市价平仓
self.browser_close_position()
time.sleep(1)
# 等待确认平仓
for _ in range(10):
if self.get_position_status() and self.position == 0:
break
time.sleep(1)
if self.position != 0:
logger.warning(f"平仓后仍有持仓({self.position}),放弃开空")
time.sleep(self.cfg.POLL_INTERVAL)
continue
# 翻转时重置加仓计数
self.pyramid_count = 0
# 平仓后在同一页面直接点卖出/做空
logger.info("平仓完成,直接开空")
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
time.sleep(3)
if self.verify_position(-1):
success = True
elif self.position == 0:
action = "开空"
success = self.open_short(current_price)
self.pyramid_count = 0
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
time.sleep(3)
if self.verify_position(-1):
success = True
elif self.position == -1 and self.pyramid_count < self.cfg.PYRAMID_MAX:
# 已持空仓 + 再次触上轨 → 加仓做空
action = f"加仓空#{self.pyramid_count+1}"
reason += f" (加仓#{self.pyramid_count+1}/{self.cfg.PYRAMID_MAX})"
# 重新计算加仓金额并输入
add_usdt = self.calc_order_usdt(is_add=True)
if add_usdt > 0:
self.page.ele('x://*[@id="size_0"]').input(vals=add_usdt, clear=True)
time.sleep(0.5)
self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]')
time.sleep(3)
if self.verify_position(-1):
self.pyramid_count += 1
success = True
else:
# 已经是空仓,不操作
logger.info("已持空仓,触上轨无需操作")
success = False
if success:
last_kline_id = kline_id
self.last_trade_time = time.time()
self.write_trade_log(action, current_price,
bb_upper, bb_mid, bb_lower, reason)
logger.success(f"{action} 执行成功")
logger.info(f"已持空仓,加仓已达上限({self.pyramid_count}/{self.cfg.PYRAMID_MAX})")
# ===== 触及下轨 → 开多 / 翻转为多 / 加仓多 =====
elif touched_lower:
# 触及下轨 → 开多 / 翻转为多
if not self.can_trade():
time.sleep(self.cfg.POLL_INTERVAL)
continue
@@ -513,20 +541,62 @@ class BBTrader:
if self.position == -1:
action = "翻转: 平空→开多"
success = self.flip_to_long(current_price)
self.browser_close_position()
time.sleep(1)
for _ in range(10):
if self.get_position_status() and self.position == 0:
break
time.sleep(1)
if self.position != 0:
logger.warning(f"平仓后仍有持仓({self.position}),放弃开多")
time.sleep(self.cfg.POLL_INTERVAL)
continue
# 翻转时重置加仓计数
self.pyramid_count = 0
logger.info("平仓完成,直接开多")
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
time.sleep(3)
if self.verify_position(1):
success = True
elif self.position == 0:
action = "开多"
success = self.open_long(current_price)
self.pyramid_count = 0
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
time.sleep(3)
if self.verify_position(1):
success = True
elif self.position == 1 and self.pyramid_count < self.cfg.PYRAMID_MAX:
# 已持多仓 + 再次触下轨 → 加仓做多
action = f"加仓多#{self.pyramid_count+1}"
reason += f" (加仓#{self.pyramid_count+1}/{self.cfg.PYRAMID_MAX})"
# 重新计算加仓金额并输入
add_usdt = self.calc_order_usdt(is_add=True)
if add_usdt > 0:
self.page.ele('x://*[@id="size_0"]').input(vals=add_usdt, clear=True)
time.sleep(0.5)
self.click_safe('x://span[normalize-space(text()) ="买入/做多"]')
time.sleep(3)
if self.verify_position(1):
self.pyramid_count += 1
success = True
else:
logger.info("已持多仓,触下轨无需操作")
success = False
logger.info(f"已持多仓,加仓已达上限({self.pyramid_count}/{self.cfg.PYRAMID_MAX})")
if success:
last_kline_id = kline_id
self.last_trade_time = time.time()
self.write_trade_log(action, current_price,
bb_upper, bb_mid, bb_lower, reason)
logger.success(f"{action} 执行成功")
# ===== 交易成功后处理 =====
if success and action:
last_kline_id = kline_id
self.last_trade_time = time.time()
self.write_trade_log(action, current_price,
bb_upper, bb_mid, bb_lower, reason)
logger.success(f"{action} 执行成功")
# 交易完成后关闭浏览器,下轮重新打开
page_start = True
try:
self.page.close()
except Exception:
pass
self.page = None
time.sleep(5)
time.sleep(self.cfg.POLL_INTERVAL)
@@ -535,6 +605,7 @@ class BBTrader:
break
except Exception as e:
logger.error(f"主循环异常: {e}")
page_start = True
time.sleep(10)