haha
This commit is contained in:
377
bb_trade.py
377
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)
|
||||
|
||||
|
||||
|
||||
72
bb_trade_log_20260225.txt
Normal file
72
bb_trade_log_20260225.txt
Normal file
@@ -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)
|
||||
============================================================
|
||||
262
bit_tools.py
Normal file
262
bit_tools.py
Normal 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))
|
||||
@@ -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])
|
||||
|
||||
BIN
strategy/results/bb_trade_2020_2025_report.png
Normal file
BIN
strategy/results/bb_trade_2020_2025_report.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 624 KiB |
147
strategy/run_bb_2020_2025.py
Normal file
147
strategy/run_bb_2020_2025.py
Normal 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)
|
||||
686
四分之一,五分钟,反手条件充足.py
Normal file
686
四分之一,五分钟,反手条件充足.py
Normal 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()
|
||||
Reference in New Issue
Block a user