diff --git a/bb_trade.py b/bb_trade.py index 157b1af..da4bb0f 100644 --- a/bb_trade.py +++ b/bb_trade.py @@ -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) diff --git a/bb_trade_log_20260225.txt b/bb_trade_log_20260225.txt new file mode 100644 index 0000000..933d4e6 --- /dev/null +++ b/bb_trade_log_20260225.txt @@ -0,0 +1,72 @@ + +============================================================ +时间: 2026-02-25 02:15:20 +操作: 开多 +价格: 1845.39 +BB上轨: 1859.97 | 中轨: 1852.40 | 下轨: 1844.82 +原因: 价格最低1844.82触及下轨1844.82,BB(10,2.5) +============================================================ + +============================================================ +时间: 2026-02-25 02:19:03 +操作: 开多 +价格: 1848.84 +BB上轨: 1859.97 | 中轨: 1852.40 | 下轨: 1844.82 +原因: 价格最低1844.50触及下轨1844.82,BB(10,2.5) +============================================================ + +============================================================ +时间: 2026-02-25 02:41:34 +操作: 翻转: 平多→开空 +价格: 1858.71 +BB上轨: 1858.17 | 中轨: 1851.64 | 下轨: 1845.11 +原因: 价格最高1859.14触及上轨1858.17,BB(10,2.5) +============================================================ + +============================================================ +时间: 2026-02-25 03:14:27 +操作: 翻转: 平空→开多 +价格: 1848.69 +BB上轨: 1860.75 | 中轨: 1854.64 | 下轨: 1848.52 +原因: 价格最低1848.42触及下轨1848.52,BB(10,2.5) +============================================================ + +============================================================ +时间: 2026-02-25 04:16:34 +操作: 开空 +价格: 1855.43 +BB上轨: 1854.87 | 中轨: 1851.18 | 下轨: 1847.48 +原因: 价格最高1855.43触及上轨1854.87,BB(10,2.5) +============================================================ + +============================================================ +时间: 2026-02-25 05:02:18 +操作: 翻转: 平空→开多 +价格: 1850.52 +BB上轨: 1861.45 | 中轨: 1856.03 | 下轨: 1850.62 +原因: 价格最低1850.51触及下轨1850.62,BB(10,2.5) +============================================================ + +============================================================ +时间: 2026-02-25 06:02:28 +操作: 翻转: 平多→开空 +价格: 1856.46 +BB上轨: 1855.53 | 中轨: 1850.22 | 下轨: 1844.90 +原因: 价格最高1856.46触及上轨1855.53,BB(10,2.5) +============================================================ + +============================================================ +时间: 2026-02-25 06:05:10 +操作: 加仓空#1 +价格: 1859.51 +BB上轨: 1859.33 | 中轨: 1850.90 | 下轨: 1842.47 +原因: 价格最高1859.51触及上轨1859.33,BB(10,2.5) (加仓#1/3) +============================================================ + +============================================================ +时间: 2026-02-25 06:14:11 +操作: 加仓空#2 +价格: 1862.50 +BB上轨: 1862.15 | 中轨: 1852.06 | 下轨: 1841.96 +原因: 价格最高1862.50触及上轨1862.15,BB(10,2.5) (加仓#2/3) +============================================================ diff --git a/bit_tools.py b/bit_tools.py new file mode 100644 index 0000000..34664ac --- /dev/null +++ b/bit_tools.py @@ -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)) diff --git a/strategy/bb_backtest.py b/strategy/bb_backtest.py index 63baf46..90918c2 100644 --- a/strategy/bb_backtest.py +++ b/strategy/bb_backtest.py @@ -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]) diff --git a/strategy/results/bb_trade_2020_2025_report.png b/strategy/results/bb_trade_2020_2025_report.png new file mode 100644 index 0000000..c4d0aaf Binary files /dev/null and b/strategy/results/bb_trade_2020_2025_report.png differ diff --git a/strategy/run_bb_2020_2025.py b/strategy/run_bb_2020_2025.py new file mode 100644 index 0000000..9bab985 --- /dev/null +++ b/strategy/run_bb_2020_2025.py @@ -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) diff --git a/四分之一,五分钟,反手条件充足.py b/四分之一,五分钟,反手条件充足.py new file mode 100644 index 0000000..18d6a1d --- /dev/null +++ b/四分之一,五分钟,反手条件充足.py @@ -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()