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)

72
bb_trade_log_20260225.txt Normal file
View File

@@ -0,0 +1,72 @@
============================================================
时间: 2026-02-25 02:15:20
操作: 开多
价格: 1845.39
BB上轨: 1859.97 | 中轨: 1852.40 | 下轨: 1844.82
原因: 价格最低1844.82触及下轨1844.82BB(10,2.5)
============================================================
============================================================
时间: 2026-02-25 02:19:03
操作: 开多
价格: 1848.84
BB上轨: 1859.97 | 中轨: 1852.40 | 下轨: 1844.82
原因: 价格最低1844.50触及下轨1844.82BB(10,2.5)
============================================================
============================================================
时间: 2026-02-25 02:41:34
操作: 翻转: 平多→开空
价格: 1858.71
BB上轨: 1858.17 | 中轨: 1851.64 | 下轨: 1845.11
原因: 价格最高1859.14触及上轨1858.17BB(10,2.5)
============================================================
============================================================
时间: 2026-02-25 03:14:27
操作: 翻转: 平空→开多
价格: 1848.69
BB上轨: 1860.75 | 中轨: 1854.64 | 下轨: 1848.52
原因: 价格最低1848.42触及下轨1848.52BB(10,2.5)
============================================================
============================================================
时间: 2026-02-25 04:16:34
操作: 开空
价格: 1855.43
BB上轨: 1854.87 | 中轨: 1851.18 | 下轨: 1847.48
原因: 价格最高1855.43触及上轨1854.87BB(10,2.5)
============================================================
============================================================
时间: 2026-02-25 05:02:18
操作: 翻转: 平空→开多
价格: 1850.52
BB上轨: 1861.45 | 中轨: 1856.03 | 下轨: 1850.62
原因: 价格最低1850.51触及下轨1850.62BB(10,2.5)
============================================================
============================================================
时间: 2026-02-25 06:02:28
操作: 翻转: 平多→开空
价格: 1856.46
BB上轨: 1855.53 | 中轨: 1850.22 | 下轨: 1844.90
原因: 价格最高1856.46触及上轨1855.53BB(10,2.5)
============================================================
============================================================
时间: 2026-02-25 06:05:10
操作: 加仓空#1
价格: 1859.51
BB上轨: 1859.33 | 中轨: 1850.90 | 下轨: 1842.47
原因: 价格最高1859.51触及上轨1859.33BB(10,2.5) (加仓#1/3)
============================================================
============================================================
时间: 2026-02-25 06:14:11
操作: 加仓空#2
价格: 1862.50
BB上轨: 1862.15 | 中轨: 1852.06 | 下轨: 1841.96
原因: 价格最高1862.50触及上轨1862.15BB(10,2.5) (加仓#2/3)
============================================================

262
bit_tools.py Normal file
View File

@@ -0,0 +1,262 @@
import json
import random
import requests
from peewee import fn
from models.ips import Ips
from models.xstart import Xstart
from models.xtoken import XToken
url = "http://127.0.0.1:54345"
headers = {'Content-Type': 'application/json'}
tg_url = "https://web.telegram.org/a/"
def createBrowser(
groupId=None,
host=None,
port=None,
proxyUserName=None,
proxyPassword=None,
name='google',
proxyType="socks5"
): # 创建或者更新窗口,指纹参数 browserFingerPrint 如没有特定需求,只需要指定下内核即可,如果需要更详细的参数,请参考文档
json_data = {
"groupId": groupId, # 分组id
'name': name, # 窗口名称
'remark': '', # 备注
'proxyMethod': 1, # 代理方式 2自定义 3 提取IP
# 代理类型 ['noproxy', 'http', 'https', 'socks5', 'ssh']
'proxyType': proxyType,
'host': host, # 代理主机EE
'port': port, # 代理端口
'proxyUserName': proxyUserName, # 代理账号
'proxyPassword': proxyPassword, # 代理账号
"browserFingerPrint": { # 指纹对象
'coreVersion': '138' # 内核版本注意win7/win8/winserver 2012 已经不支持112及以上内核了无法打开
}
}
res = requests.post(f"{url}/browser/update",
data=json.dumps(json_data), headers=headers).json()
print(res)
browserId = res['data']['id']
return browserId
def updateBrowser(): # 更新窗口支持批量更新和按需更新ids 传入数组单独更新只传一个id即可只传入需要修改的字段即可比如修改备注具体字段请参考文档browserFingerPrint指纹对象不修改则无需传入
json_data = {'ids': ['93672cf112a044f08b653cab691216f0'],
'remark': '我是一个备注', 'browserFingerPrint': {}}
res = requests.post(f"{url}/browser/update/partial",
data=json.dumps(json_data), headers=headers).json()
print(res)
def openBrowser(id): # 直接指定ID打开窗口也可以使用 createBrowser 方法返回的ID
json_data = {"id": f'{id}', "args": [
# "--disable-application-cache",
# "--disable-cache",
# "--disable-gpu-shader-disk-cache",
# "--media-cache-size=1",
# "--disk-cache-size=1",
# "--incognito"
]}
res = requests.post(f"{url}/browser/open",
data=json.dumps(json_data), headers=headers).json()
print(res)
return res["data"]["http"].split(":")[1]
def closeBrowser(id): # 关闭窗口
json_data = {'id': f'{id}'}
res = requests.post(f"{url}/browser/close",
data=json.dumps(json_data), headers=headers)
return res.json()
def deleteBrowser(id): # 删除窗口
json_data = {'id': f'{id}'}
print(requests.post(f"{url}/browser/delete",
data=json.dumps(json_data), headers=headers).json())
def query_bit_browser(page, page_size):
data = {"page": page, "pageSize": page_size, 'sort': 'asc'}
res = requests.post(f'{url}/browser/list', data=json.dumps(data), headers=headers)
return res.json()["data"]["list"]
def update_proxy_Browser(
id,
host,
port,
proxyType="socks5",
proxyUserName="",
proxyPassword=""
):
json_data = {
"ids": [id],
# "ipCheckService": "ip123in",
"proxyMethod": 2,
"proxyType": proxyType,
"host": host,
"port": port,
"proxyUserName": proxyUserName,
"proxyPassword": proxyPassword
}
res = requests.post(f'{url}/browser/proxy/update', data=json.dumps(json_data), headers=headers)
print(res.json())
return res.json()
def get_group_lists_Browser():
json_data = {
"page": 0,
"pageSize": 100,
"all": True
}
res = requests.post(f'{url}/group/list', data=json.dumps(json_data), headers=headers)
return res.json()["data"]["list"]
def get_browser_lists_Browser(id, page=0):
json_data = {
"groupId": id,
"page": page,
"pageSize": 100
}
res = requests.post(f'{url}/browser/list', data=json.dumps(json_data), headers=headers)
return res.json()["data"]["list"]
def get_group_lists(): # 获取全部分组的信息
# url = "/group/list"
json_data = {
"page": 0,
"pageSize": 100,
"all": True
}
res = requests.post(f'{url}/group/list', data=json.dumps(json_data), headers=headers)
data = {}
for i in res.json()["data"]["list"]:
data[i["groupName"]] = i["id"]
return data
def group_add(groupName):
json_data = {
"groupName": groupName,
"sortNum": 0
}
res = requests.post(f'{url}/group/add', data=json.dumps(json_data), headers=headers)
return res.json()
def browser_detail(id):
json_data = {
"id": id
}
res = requests.post(f'{url}/browser/detail', data=json.dumps(json_data), headers=headers)
return res.json()
def group_update(groupId, browserIds):
# json_data = {
# "groupId": "41notc1202sr8gu5o6emb9ihaqbzbkic",
# "browserIds": ["af25e626167f4870b8f257e697bb4f05", "3baa6e990fee4e839c72722c8dc18019"]
# }
json_data = {
"groupId": groupId,
"browserIds": browserIds
}
res = requests.post(f'{url}/browser/group/update', data=json.dumps(json_data), headers=headers)
return res.json()
if __name__ == '__main__':
# for i in Xstart.select().where(
# Xstart.x_id.is_null()
# ):
# ips_info = Ips.select().where(Ips.start == 1, Ips.country == "法国").order_by(fn.Rand()).get()
#
# update_proxy_Browser(
# id=i.bit_id,
# host=ips_info.host,
# port=ips_info.port,
# proxyUserName=ips_info.username,
# proxyPassword=ips_info.password
# )
# fz_datas = get_group_lists()
# # fz_datas['推特']
#
# for i in range(10):
# for i in get_browser_lists_Browser(id=fz_datas['推特'], page=i):
# x_start_info = Xstart.get_or_none(
# Xstart.bit_id == i["id"]
# )
#
# if not x_start_info:
# deleteBrowser(id=i["id"])
#
# continue
#
# if x_start_info.start:
# continue
#
# deleteBrowser(id=i["id"])
#
# x_start_info.bit_id = None
# x_start_info.save()
# for i in Xstart.select():
# res = browser_detail(id=i.bit_id)
# print(res)
#
# if not res["success"]:
# i.bit_id = None
# i.save()
# print(browser_detail(id="532651f5330e4caa917e644f9b676b"))
# 批量修改代理
for i in Xstart.select().where(Xstart.start == 1):
update_proxy_Browser(id=i.bit_id, proxyType="http", host="127.0.0.1", port=random.randint(42000, 42089), )
# fz_datas = get_group_lists()
# print(fz_datas)
# bit_id_list = []
# for i in XToken.select().where(XToken.account_start == 2):
# sql_info = Xstart.get_or_none(
# Xstart.x_id == i.id
# )
#
# bit_id_list.append(sql_info.bit_id)
#
# print(len(bit_id_list))
# print(bit_id_list)
#
# print(group_update(fz_datas["西班牙语"], bit_id_list))

View File

@@ -49,6 +49,14 @@ class BBConfig:
rebate_pct: float = 0.0 # e.g. 0.70 = 70% rebate
rebate_hour_utc: int = 0 # hour in UTC when rebate arrives (0 = 8am UTC+8)
# Pyramid (加仓): add to position on repeated same-direction BB touch
pyramid_enabled: bool = False
pyramid_decay: float = 0.99 # each add uses margin * decay^n (n = add count)
pyramid_max: int = 10 # max number of adds (0 = unlimited)
# Increment mode: each add uses equity * (margin_pct + pyramid_step * n)
# e.g. step=0.01 → 1st open=1%, 1st add=2%, 2nd add=3% ...
pyramid_step: float = 0.0 # 0 = use decay mode; >0 = use increment mode
@dataclass
class BBTrade:
@@ -105,6 +113,8 @@ def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
entry_time = None
entry_margin = 0.0
entry_qty = 0.0
pyramid_count = 0 # number of adds so far
last_add_margin = 0.0 # margin used in last open/add
trades: List[BBTrade] = []
total_fee = 0.0
@@ -136,6 +146,7 @@ def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
def close_position(exit_price, exit_idx):
nonlocal balance, position, entry_price, entry_time, entry_margin, entry_qty
nonlocal total_fee, total_rebate, day_pnl, today_fees
nonlocal pyramid_count, last_add_margin
if position == 0:
return
@@ -174,12 +185,23 @@ def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
entry_time = None
entry_margin = 0.0
entry_qty = 0.0
pyramid_count = 0
last_add_margin = 0.0
def open_position(side, price, idx):
def open_position(side, price, idx, is_add=False):
nonlocal position, entry_price, entry_time, entry_margin, entry_qty
nonlocal balance, total_fee, day_pnl, today_fees
nonlocal pyramid_count, last_add_margin
if cfg.margin_pct > 0:
if is_add and cfg.pyramid_step > 0:
# 递增加仓: margin = equity * (margin_pct + step * (count+1))
equity = balance + unrealised(price)
pct = cfg.margin_pct + cfg.pyramid_step * (pyramid_count + 1)
margin = equity * pct
elif is_add:
# 衰减加仓: margin = last_add_margin * decay
margin = last_add_margin * cfg.pyramid_decay
elif cfg.margin_pct > 0:
equity = balance + unrealised(price) if position != 0 else balance
margin = equity * cfg.margin_pct
else:
@@ -196,11 +218,24 @@ def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
today_fees += fee
day_pnl -= fee
position = 1 if side == "long" else -1
entry_price = price
entry_time = ts_index[idx]
entry_margin = margin
entry_qty = qty
if is_add and position != 0:
# 加仓: weighted average entry price
old_notional = entry_qty * entry_price
new_notional = qty * price
entry_qty += qty
entry_price = (old_notional + new_notional) / entry_qty
entry_margin += margin
pyramid_count += 1
last_add_margin = margin
else:
# 新开仓
position = 1 if side == "long" else -1
entry_price = price
entry_time = ts_index[idx]
entry_margin = margin
entry_qty = qty
pyramid_count = 0
last_add_margin = margin
# Main loop
for i in range(n):
@@ -268,17 +303,27 @@ def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
if position == 1:
# Close long at upper BB price
close_position(arr_upper[i], i)
if position != -1:
if position == 0:
# Open short
open_position("short", arr_upper[i], i)
elif position == -1 and cfg.pyramid_enabled:
# Already short → add to short (pyramid)
can_add = cfg.pyramid_max <= 0 or pyramid_count < cfg.pyramid_max
if can_add:
open_position("short", arr_upper[i], i, is_add=True)
elif touched_lower:
# Price touched lower BB → go long
if position == -1:
# Close short at lower BB price
close_position(arr_lower[i], i)
if position != 1:
if position == 0:
# Open long
open_position("long", arr_lower[i], i)
elif position == 1 and cfg.pyramid_enabled:
# Already long → add to long (pyramid)
can_add = cfg.pyramid_max <= 0 or pyramid_count < cfg.pyramid_max
if can_add:
open_position("long", arr_lower[i], i, is_add=True)
# Record equity
out_equity[i] = balance + unrealised(arr_close[i])

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

View File

@@ -0,0 +1,147 @@
"""
BB(10, 2.5) 均值回归策略回测 — 2020-2025 逐年
完全复现 bb_trade.py 的参数: BB(10,2.5) | 50x | 1%权益/单 | 1000U初始资金
测试两种手续费场景:
A) 0.06% taker (无返佣)
B) 0.025% maker + 返佣 (模拟浏览器下单)
"""
import sys, time
sys.stdout.reconfigure(line_buffering=True)
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1]))
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from pathlib import Path
from strategy.bb_backtest import BBConfig, run_bb_backtest
from strategy.data_loader import load_klines
out_dir = Path(__file__).resolve().parent / "results"
out_dir.mkdir(parents=True, exist_ok=True)
# ============================================================
# 加载数据
# ============================================================
YEARS = list(range(2020, 2027))
data = {}
print("加载 5 分钟 K 线数据...")
t0 = time.time()
for y in YEARS:
df = load_klines('5m', f'{y}-01-01', f'{y+1}-01-01')
data[y] = df
print(f" {y}: {len(df):>7,} 条 ({df.index[0]} ~ {df.index[-1]})")
print(f"数据加载完成 ({time.time()-t0:.1f}s)\n")
# ============================================================
# 配置 (完全匹配 bb_trade.py)
# ============================================================
BASE_KWARGS = dict(
bb_period=10,
bb_std=2.5,
leverage=50,
initial_capital=200.0,
margin_pct=0.01, # 1% 权益/单
max_daily_loss=50.0,
fee_rate=0.0005, # 万五 (0.05%) 每侧
rebate_rate=0.0,
rebate_pct=0.90, # 90% 手续费次日返还
rebate_hour_utc=0, # UTC 0点 = 北京时间早上8点
)
configs = {
"A) 原版(不加仓)": BBConfig(**BASE_KWARGS, pyramid_enabled=False),
"B) 衰减加仓 decay=0.99 max=10": BBConfig(**BASE_KWARGS, pyramid_enabled=True, pyramid_decay=0.99, pyramid_max=10),
"C) 衰减加仓 decay=0.99 max=3": BBConfig(**BASE_KWARGS, pyramid_enabled=True, pyramid_decay=0.99, pyramid_max=3),
"D) 递增加仓 +1%/次 max=3": BBConfig(**BASE_KWARGS, pyramid_enabled=True, pyramid_step=0.01, pyramid_max=3),
"E) 递增加仓 +1%/次 max=10": BBConfig(**BASE_KWARGS, pyramid_enabled=True, pyramid_step=0.01, pyramid_max=10),
}
# ============================================================
# 运行回测
# ============================================================
all_results = {}
for label, cfg in configs.items():
print("=" * 100)
print(f" {label}")
print(f" BB({cfg.bb_period}, {cfg.bb_std}) | {cfg.leverage}x | margin_pct={cfg.margin_pct:.0%} | fee={cfg.fee_rate:.4%}")
print("=" * 100)
print(f" {'年份':>6s} {'最终权益':>10s} {'收益率':>8s} {'日均PnL':>8s} {'交易次数':>8s} {'胜率':>6s} "
f"{'最大回撤':>10s} {'总手续费':>10s} {'总返佣':>10s} {'Sharpe':>7s}")
print("-" * 100)
year_results = {}
for y in YEARS:
r = run_bb_backtest(data[y], cfg)
d = r.daily_stats
pnl = d["pnl"].astype(float)
eq = d["equity"].astype(float)
dd = float((eq - eq.cummax()).min())
final_eq = float(eq.iloc[-1])
ret_pct = (final_eq - cfg.initial_capital) / cfg.initial_capital * 100
n_trades = len(r.trades)
win_rate = sum(1 for t in r.trades if t.net_pnl > 0) / max(n_trades, 1) * 100
avg_daily = float(pnl.mean())
sharpe = float(pnl.mean() / pnl.std()) * np.sqrt(365) if pnl.std() > 0 else 0
year_results[y] = r
print(f" {y:>6d} {final_eq:>10.1f} {ret_pct:>+7.1f}% {avg_daily:>+7.2f}U "
f"{n_trades:>8d} {win_rate:>5.1f}% {dd:>+10.1f} "
f"{r.total_fee:>10.1f} {r.total_rebate:>10.1f} {sharpe:>7.2f}")
all_results[label] = year_results
print()
# ============================================================
# 生成图表
# ============================================================
fig, axes = plt.subplots(len(configs), 1, figsize=(18, 6 * len(configs)), dpi=120)
if len(configs) == 1:
axes = [axes]
colors = plt.cm.tab10(np.linspace(0, 1, len(YEARS)))
for ax, (label, year_results) in zip(axes, all_results.items()):
for i, y in enumerate(YEARS):
r = year_results[y]
eq = r.equity_curve["equity"].dropna()
# 归一化到天数 (x轴)
days = (eq.index - eq.index[0]).total_seconds() / 86400
ax.plot(days, eq.values, label=f"{y}", color=colors[i], linewidth=0.8)
ax.set_title(f"BB(10, 2.5) 50x 1%权益 — {label}", fontsize=13, fontweight="bold")
ax.set_xlabel("天数")
ax.set_ylabel("权益 (USDT)")
ax.axhline(y=1000, color="gray", linestyle="--", alpha=0.5)
ax.legend(loc="upper left", fontsize=9)
ax.grid(True, alpha=0.3)
plt.tight_layout()
chart_path = out_dir / "bb_trade_2020_2025_report.png"
plt.savefig(chart_path, bbox_inches="tight")
print(f"\n图表已保存: {chart_path}")
# ============================================================
# 汇总表
# ============================================================
print("\n" + "=" * 80)
print(" 汇总: 各场景各年度日均 PnL (U/day)")
print("=" * 80)
header = f" {'场景':<35s}" + "".join(f"{y:>10d}" for y in YEARS)
print(header)
print("-" * 80)
for label, year_results in all_results.items():
vals = []
for y in YEARS:
r = year_results[y]
avg = float(r.daily_stats["pnl"].astype(float).mean())
vals.append(avg)
row = f" {label:<35s}" + "".join(f"{v:>+10.2f}" for v in vals)
print(row)
print("=" * 80)

View File

@@ -0,0 +1,686 @@
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.pbar = tqdm(total=30, desc="等待K线", ncols=80) # 可选:用于长时间等待时展示进度
self.last_kline_time = None # 上一次处理的K线时间戳用于判断是否是新K线
# 反手频率控制
self.reverse_cooldown_seconds = 1.5 * 60 # 反手冷却时间(秒)
self.reverse_min_move_pct = 0.05 # 反手最小价差过滤(百分比)
self.last_reverse_time = None # 上次反手时间
# 开仓频率控制
self.open_cooldown_seconds = 60 # 开仓冷却时间(秒),两次开仓至少间隔此时长
self.last_open_time = None # 上次开仓时间
self.last_open_kline_id = None # 上次开仓所在 K 线 id同一根 K 线只允许开仓一次
self.leverage = "100" # 高杠杆(全仓模式下可开更大仓位)
self.open_type = "cross" # 全仓模式
self.risk_percent = 0 # 未使用;若启用则可为每次开仓占可用余额的百分比
self.take_profit_usd = 5 # 仓位盈利达到此金额(美元)时平仓止盈
self.stop_loss_usd = -3 # 固定止损:亏损达到 3 美元平仓
self.trailing_activation_usd = 2 # 盈利达到此金额后启动移动止损
self.trailing_distance_usd = 1.5 # 从最高盈利回撤此金额则平仓
self.max_unrealized_pnl_seen = None # 持仓期间见过的最大盈利(用于移动止损)
self.open_avg_price = None # 开仓价格
self.current_amount = None # 持仓量
self.bit_id = bit_id
self.default_order_size = 25 # 开仓/反手张数,统一在此修改
# 策略相关变量
self.prev_kline = None # 上一根K线
self.current_kline = None # 当前K线
self.prev_entity = None # 上一根K线实体大小
self.current_open = None # 当前K线开盘价
def get_klines(self):
"""获取最近2根K线当前K线和上一根K线"""
try:
end_time = int(time.time())
# 获取足够多的条目确保有最新的K线
response = self.contractAPI.get_kline(
contract_symbol=self.contract_symbol,
step=5, # 5分钟
start_time=end_time - 3600 * 3, # 取最近3小时
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'])
# 返回最近2根K线倒数第二根上一根和最后一根当前
if len(formatted) >= 2:
return formatted[-2], formatted[-1]
return None, None
except Exception as e:
logger.error(f"获取K线异常: {e}")
self.ding(text="获取K线异常", error=True)
return None, 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 * 1, # 取最近1小时
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
self.open_avg_price = None
self.current_amount = None
self.unrealized_pnl = None
return True
pos = positions[0]
self.start = 1 if pos['position_type'] == 1 else -1
self.open_avg_price = float(pos['open_avg_price'])
self.current_amount = float(pos['current_amount'])
self.position_cross = pos["position_cross"]
# 直接从API获取未实现盈亏Bitmart返回的是 unrealized_value 字段)
self.unrealized_pnl = float(pos.get('unrealized_value', 0))
logger.debug(f"持仓详情: 方向={self.start}, 开仓均价={self.open_avg_price}, "
f"持仓量={self.current_amount}, 未实现盈亏={self.unrealized_pnl:.2f}")
return True
else:
return False
except Exception as e:
logger.error(f"持仓查询异常: {e}")
return False
def get_unrealized_pnl_usd(self):
"""
获取当前持仓未实现盈亏美元直接使用API返回值
"""
if self.start == 0 or self.unrealized_pnl is None:
return None
return self.unrealized_pnl
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 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(by_js=True)
return True
except:
return False
def 平仓(self):
"""平仓操作"""
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(vals=size, clear=True)
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(vals=size, clear=True)
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):
"""日志通知"""
if error:
logger.error(text)
else:
logger.info(text)
def calculate_entity(self, kline):
"""计算K线实体大小绝对值"""
return abs(kline['close'] - kline['open'])
def calculate_upper_shadow(self, kline):
"""计算上阴线(上影线)涨幅百分比"""
# 上阴线 = (最高价 - max(开盘价, 收盘价)) / max(开盘价, 收盘价)
body_top = max(kline['open'], kline['close'])
if body_top == 0:
return 0
return (kline['high'] - body_top) / body_top * 100
def calculate_lower_shadow(self, kline):
"""计算下阴线(下影线)跌幅百分比"""
# 下阴线 = (min(开盘价, 收盘价) - 最低价) / min(开盘价, 收盘价)
body_bottom = min(kline['open'], kline['close'])
if body_bottom == 0:
return 0
return (body_bottom - kline['low']) / body_bottom * 100
def get_entity_edge(self, kline):
"""获取K线实体边收盘价或开盘价取决于是阳线还是阴线"""
# 阳线(收盘>开盘):实体上边=收盘价,实体下边=开盘价
# 阴线(收盘<开盘):实体上边=开盘价,实体下边=收盘价
return {
'upper': max(kline['open'], kline['close']), # 实体上边
'lower': min(kline['open'], kline['close']) # 实体下边
}
def check_signal(self, current_price, prev_kline, current_kline):
"""
检查交易信号
返回: ('long', trigger_price) / ('short', trigger_price) / None
"""
# 计算上一根K线实体
prev_entity = self.calculate_entity(prev_kline)
# 实体过小不交易(实体 < 0.1
if prev_entity < 0.1:
logger.info(f"上一根K线实体过小: {prev_entity:.4f},跳过信号检测")
return None
# 获取上一根K线的实体上下边
prev_entity_edge = self.get_entity_edge(prev_kline)
prev_entity_upper = prev_entity_edge['upper'] # 实体上边
prev_entity_lower = prev_entity_edge['lower'] # 实体下边
# 优化:以下两种情况以当前这根的开盘价作为计算基准
# 1) 上一根阳线 且 当前开盘价 > 上一根收盘价(跳空高开)
# 2) 上一根阴线 且 当前开盘价 < 上一根收盘价(跳空低开)
prev_is_bullish_for_calc = prev_kline['close'] > prev_kline['open']
prev_is_bearish_for_calc = prev_kline['close'] < prev_kline['open']
current_open_above_prev_close = current_kline['open'] > prev_kline['close']
current_open_below_prev_close = current_kline['open'] < prev_kline['close']
use_current_open_as_base = (prev_is_bullish_for_calc and current_open_above_prev_close) or (prev_is_bearish_for_calc and current_open_below_prev_close)
if use_current_open_as_base:
# 以当前K线开盘价为基准计算跳空时用当前开盘价参与计算
calc_lower = current_kline['open']
calc_upper = current_kline['open'] # 同一基准,上下四分之一对称
long_trigger = calc_lower + prev_entity / 4
short_trigger = calc_upper - prev_entity / 4
long_breakout = calc_upper + prev_entity / 4
short_breakout = calc_lower - prev_entity / 4
else:
# 原有计算方式
long_trigger = prev_entity_lower + prev_entity / 4 # 做多触发价 = 实体下边 + 实体/4下四分之一处
short_trigger = prev_entity_upper - prev_entity / 4 # 做空触发价 = 实体上边 - 实体/4上四分之一处
long_breakout = prev_entity_upper + prev_entity / 4 # 做多突破价 = 实体上边 + 实体/4
short_breakout = prev_entity_lower - prev_entity / 4 # 做空突破价 = 实体下边 - 实体/4
# 上一根阴线 + 当前阳线做多形态不按上一根K线上三分之一做空
prev_is_bearish = prev_kline['close'] < prev_kline['open']
current_is_bullish = current_kline['close'] > current_kline['open']
skip_short_by_upper_third = prev_is_bearish and current_is_bullish
# 上一根阳线 + 当前阴线做空形态不按上一根K线下三分之一做多
prev_is_bullish = prev_kline['close'] > prev_kline['open']
current_is_bearish = current_kline['close'] < current_kline['open']
skip_long_by_lower_third = prev_is_bullish and current_is_bearish
if use_current_open_as_base:
if prev_is_bullish_for_calc and current_open_above_prev_close:
logger.info(f"上一根阳线且当前开盘价({current_kline['open']:.2f})>上一根收盘价({prev_kline['close']:.2f}),以当前开盘价为基准计算")
else:
logger.info(f"上一根阴线且当前开盘价({current_kline['open']:.2f})<上一根收盘价({prev_kline['close']:.2f}),以当前开盘价为基准计算")
logger.info(f"当前价格: {current_price:.2f}, 上一根实体: {prev_entity:.4f}")
logger.info(f"上一根实体上边: {prev_entity_upper:.2f}, 下边: {prev_entity_lower:.2f}")
logger.info(f"做多触发价(下1/4): {long_trigger:.2f}, 做空触发价(上1/4): {short_trigger:.2f}")
logger.info(f"突破做多价(上1/4外): {long_breakout:.2f}, 突破做空价(下1/4外): {short_breakout:.2f}")
if skip_short_by_upper_third:
logger.info("上一根阴线+当前阳线(做多形态),不按上四分之一做空")
if skip_long_by_lower_third:
logger.info("上一根阳线+当前阴线(做空形态),不按下四分之一做多")
# 无持仓时检查开仓信号
if self.start == 0:
if current_price >= long_breakout and not skip_long_by_lower_third:
logger.info(f"触发做多信号!价格 {current_price:.2f} >= 突破价(上1/4外) {long_breakout:.2f}")
return ('long', long_breakout)
elif current_price <= short_breakout and not skip_short_by_upper_third:
logger.info(f"触发做空信号!价格 {current_price:.2f} <= 突破价(下1/4外) {short_breakout:.2f}")
return ('short', short_breakout)
# 持仓时检查反手信号
elif self.start == 1: # 持多仓
# 反手条件1: 价格跌到上一根K线的上三分之一处做空触发价上一根阴线+当前阳线做多时跳过
if current_price <= short_trigger and not skip_short_by_upper_third:
logger.info(f"持多反手做空!价格 {current_price:.2f} <= 触发价(上1/4) {short_trigger:.2f}")
return ('reverse_short', short_trigger)
# 反手条件2: 上一根K线上阴线涨幅>0.01%,当前跌到上一根实体下边
upper_shadow_pct = self.calculate_upper_shadow(prev_kline)
if upper_shadow_pct > 0.01 and current_price <= prev_entity_lower:
logger.info(f"持多反手做空!上阴线涨幅 {upper_shadow_pct:.4f}% > 0.01%"
f"价格 {current_price:.2f} <= 实体下边 {prev_entity_lower:.2f}")
return ('reverse_short', prev_entity_lower)
elif self.start == -1: # 持空仓
# 反手条件1: 价格涨到上一根K线的下三分之一处做多触发价上一根阳线+当前阴线做空时跳过
if current_price >= long_trigger and not skip_long_by_lower_third:
logger.info(f"持空反手做多!价格 {current_price:.2f} >= 触发价(下1/4) {long_trigger:.2f}")
return ('reverse_long', long_trigger)
# 反手条件2: 上一根K线下阴线跌幅>0.01%,当前涨到上一根实体上边
lower_shadow_pct = self.calculate_lower_shadow(prev_kline)
if lower_shadow_pct > 0.01 and current_price >= prev_entity_upper:
logger.info(f"持空反手做多!下阴线跌幅 {lower_shadow_pct:.4f}% > 0.01%"
f"价格 {current_price:.2f} >= 实体上边 {prev_entity_upper:.2f}")
return ('reverse_long', prev_entity_upper)
return None
def can_open(self, current_kline_id):
"""开仓前过滤:同一根 K 线只开一次 + 开仓冷却时间。仅用于 long/short 新开仓。"""
now = time.time()
if self.last_open_kline_id is not None and self.last_open_kline_id == current_kline_id:
logger.info(f"开仓频率控制:本 K 线({current_kline_id})已开过仓,跳过")
return False
if self.last_open_time is not None and now - self.last_open_time < self.open_cooldown_seconds:
remain = self.open_cooldown_seconds - (now - self.last_open_time)
logger.info(f"开仓冷却中,剩余 {remain:.0f}")
return False
return True
def can_reverse(self, current_price, trigger_price):
"""反手前过滤:冷却时间 + 最小价差"""
now = time.time()
if self.last_reverse_time and now - self.last_reverse_time < self.reverse_cooldown_seconds:
remain = self.reverse_cooldown_seconds - (now - self.last_reverse_time)
logger.info(f"反手冷却中,剩余 {remain:.0f}")
return False
if trigger_price and trigger_price > 0:
move_pct = abs(current_price - trigger_price) / trigger_price * 100
if move_pct < self.reverse_min_move_pct:
logger.info(f"反手价差不足: {move_pct:.4f}% < {self.reverse_min_move_pct}%")
return False
return True
def verify_no_position(self, max_retries=5, retry_interval=3):
"""
验证当前无持仓
返回: True 表示无持仓可以开仓False 表示有持仓不能开仓
"""
for i in range(max_retries):
if self.get_position_status():
if self.start == 0:
logger.info(f"确认无持仓,可以开仓")
return True
else:
logger.warning(
f"仍有持仓 (方向: {self.start}),等待 {retry_interval} 秒后重试 ({i + 1}/{max_retries})")
time.sleep(retry_interval)
else:
logger.warning(f"查询持仓状态失败,等待 {retry_interval} 秒后重试 ({i + 1}/{max_retries})")
time.sleep(retry_interval)
logger.error(f"经过 {max_retries} 次重试仍有持仓或查询失败,放弃开仓")
return False
def verify_position_direction(self, expected_direction):
"""
验证当前持仓方向是否与预期一致
expected_direction: 1 多仓, -1 空仓
返回: True 表示持仓方向正确False 表示不正确
"""
if self.get_position_status():
if self.start == expected_direction:
logger.info(f"持仓方向验证成功: {self.start}")
return True
else:
logger.warning(f"持仓方向不符: 期望 {expected_direction}, 实际 {self.start}")
return False
else:
logger.error("查询持仓状态失败")
return False
def execute_trade(self, signal, size=None):
"""执行交易。size 不传或为 None 时使用 default_order_size。"""
signal_type, trigger_price = signal
size = self.default_order_size if size is None else size
if signal_type == 'long':
# 开多前先确认无持仓
logger.info(f"准备开多,触发价: {trigger_price:.2f}")
if not self.get_position_status():
logger.error("开仓前查询持仓状态失败,放弃开仓")
return False
if self.start != 0:
logger.warning(f"开多前发现已有持仓 (方向: {self.start}),放弃开仓避免双向持仓")
return False
logger.info(f"确认无持仓,执行开多")
self.开单(marketPriceLongOrder=1, size=size)
time.sleep(3) # 等待订单执行
# 验证开仓是否成功
if self.verify_position_direction(1):
self.max_unrealized_pnl_seen = None # 新仓位重置移动止损记录
self.last_open_time = time.time()
self.last_open_kline_id = getattr(self, "_current_kline_id_for_open", None)
logger.success("开多成功")
return True
else:
logger.error("开多后持仓验证失败")
return False
elif signal_type == 'short':
# 开空前先确认无持仓
logger.info(f"准备开空,触发价: {trigger_price:.2f}")
if not self.get_position_status():
logger.error("开仓前查询持仓状态失败,放弃开仓")
return False
if self.start != 0:
logger.warning(f"开空前发现已有持仓 (方向: {self.start}),放弃开仓避免双向持仓")
return False
logger.info(f"确认无持仓,执行开空")
self.开单(marketPriceLongOrder=-1, size=size)
time.sleep(3) # 等待订单执行
# 验证开仓是否成功
if self.verify_position_direction(-1):
self.max_unrealized_pnl_seen = None # 新仓位重置移动止损记录
self.last_open_time = time.time()
self.last_open_kline_id = getattr(self, "_current_kline_id_for_open", None)
logger.success("开空成功")
return True
else:
logger.error("开空后持仓验证失败")
return False
elif signal_type == 'reverse_long':
# 平空 + 开多(反手做多):先平仓,确认无仓后再开多,避免双向持仓
logger.info(f"执行反手做多,触发价: {trigger_price:.2f}")
self.平仓()
time.sleep(1) # 给交易所处理平仓的时间
# 轮询确认已无持仓再开多(最多等约 10 秒)
for _ in range(10):
if self.get_position_status() and self.start == 0:
break
time.sleep(1)
if self.start != 0:
logger.warning("反手做多:平仓后仍有持仓,放弃本次开多")
return False
logger.info("已确认无持仓,执行开多")
self.开单(marketPriceLongOrder=1, size=size)
time.sleep(3)
if self.verify_position_direction(1):
self.max_unrealized_pnl_seen = None
logger.success("反手做多成功")
self.last_reverse_time = time.time()
time.sleep(20)
return True
else:
logger.error("反手做多后持仓验证失败")
return False
elif signal_type == 'reverse_short':
# 平多 + 开空(反手做空):先平仓,确认无仓后再开空
logger.info(f"执行反手做空,触发价: {trigger_price:.2f}")
self.平仓()
time.sleep(1)
for _ in range(10):
if self.get_position_status() and self.start == 0:
break
time.sleep(1)
if self.start != 0:
logger.warning("反手做空:平仓后仍有持仓,放弃本次开空")
return False
logger.info("已确认无持仓,执行开空")
self.开单(marketPriceLongOrder=-1, size=size)
time.sleep(3)
if self.verify_position_direction(-1):
self.max_unrealized_pnl_seen = None
logger.success("反手做空成功")
self.last_reverse_time = time.time()
time.sleep(20)
return True
else:
logger.error("反手做空后持仓验证失败")
return False
return False
def action(self):
"""主循环"""
logger.info("开始运行四分之一策略交易...")
# 启动时设置全仓高杠杆
if not self.set_leverage():
logger.error("杠杆设置失败,程序继续运行但可能下单失败")
return
page_start = True
while True:
if page_start:
# 打开浏览器
for i in range(5):
if self.openBrowser():
logger.info("浏览器打开成功")
break
else:
self.ding("打开浏览器失败!", error=True)
return
# 进入交易页面
self.page.get("https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT")
self.click_safe('x://button[normalize-space(text()) ="市价"]')
self.page.ele('x://*[@id="size_0"]').input(vals=25, clear=True)
page_start = False
try:
# 1. 获取K线数据当前K线和上一根K线
prev_kline, current_kline = self.get_klines()
if not prev_kline or not current_kline:
logger.warning("获取K线失败等待重试...")
time.sleep(5)
continue
# 记录进入新的K线
current_kline_time = current_kline['id']
if self.last_kline_time != current_kline_time:
self.last_kline_time = current_kline_time
logger.info(f"进入新K线: {current_kline_time}")
# 2. 获取当前价格
current_price = self.get_current_price()
if not current_price:
logger.warning("获取价格失败,等待重试...")
time.sleep(2)
continue
# 3. 每次循环都通过SDK获取真实持仓状态避免状态不同步导致双向持仓
if not self.get_position_status():
logger.warning("获取持仓状态失败,等待重试...")
time.sleep(2)
continue
logger.debug(f"当前持仓状态: {self.start} (0=无, 1=多, -1=空)")
# 3.5 止损/止盈/移动止损
if self.start != 0:
pnl_usd = self.get_unrealized_pnl_usd()
if pnl_usd is not None:
# 固定止损:亏损达到 3 美元平仓
if pnl_usd <= self.stop_loss_usd:
logger.info(f"仓位亏损 {pnl_usd:.2f} 美元 <= 止损 {self.stop_loss_usd} 美元,执行止损平仓")
self.平仓()
self.max_unrealized_pnl_seen = None
time.sleep(3)
continue
# 更新持仓期间最大盈利(用于移动止损)
if self.max_unrealized_pnl_seen is None:
self.max_unrealized_pnl_seen = pnl_usd
else:
self.max_unrealized_pnl_seen = max(self.max_unrealized_pnl_seen, pnl_usd)
# 移动止损:盈利曾达到 activation 后,从最高盈利回撤 trailing_distance 则平仓
if self.max_unrealized_pnl_seen >= self.trailing_activation_usd:
if pnl_usd < self.max_unrealized_pnl_seen - self.trailing_distance_usd:
logger.info(f"移动止损:当前盈利 {pnl_usd:.2f} 从最高 {self.max_unrealized_pnl_seen:.2f} 回撤 >= {self.trailing_distance_usd} 美元,平仓")
self.平仓()
self.max_unrealized_pnl_seen = None
time.sleep(3)
continue
# 止盈:盈利达到 take_profit_usd 平仓
if pnl_usd >= self.take_profit_usd:
logger.info(f"仓位盈利 {pnl_usd:.2f} 美元 >= {self.take_profit_usd} 美元,执行止盈平仓")
self.平仓()
self.max_unrealized_pnl_seen = None
time.sleep(3)
continue
# 4. 检查信号
signal = self.check_signal(current_price, prev_kline, current_kline)
# 5. 反手过滤:冷却时间 + 最小价差
if signal and signal[0].startswith('reverse_'):
if not self.can_reverse(current_price, signal[1]):
signal = None
# 5.5 开仓频率过滤:同一根 K 线只开一次 + 开仓冷却
if signal and signal[0] in ('long', 'short'):
if not self.can_open(current_kline_time):
signal = None
else:
self._current_kline_id_for_open = current_kline_time # 供 execute_trade 成功后记录
# 6. 有信号则执行交易
if signal:
trade_success = self.execute_trade(signal)
if trade_success:
logger.success(f"交易执行完成: {signal[0]}, 当前持仓状态: {self.start}")
page_start = True
else:
logger.warning(f"交易执行失败或被阻止: {signal[0]}")
# 短暂等待后继续循环同一根K线遇到信号就操作
time.sleep(0.1)
if page_start:
self.page.close()
time.sleep(5)
except KeyboardInterrupt:
logger.info("用户中断,程序退出")
break
except Exception as e:
logger.error(f"主循环异常: {e}")
time.sleep(5)
if __name__ == '__main__':
BitmartFuturesTransaction(bit_id="f2320f57e24c45529a009e1541e25961").action()