haha
This commit is contained in:
377
bb_trade.py
377
bb_trade.py
@@ -1,22 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
布林带均值回归策略 — 实盘交易
|
布林带均值回归策略 — 实盘交易 (D方案: 递增加仓)
|
||||||
BB(10, 2.5) | 5分钟K线 | ETH | 50x杠杆 | 每单权益1%
|
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 time
|
||||||
import uuid
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from bitmart.api_contract import APIContract
|
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"
|
CONTRACT_SYMBOL = "ETHUSDT"
|
||||||
|
TRADE_URL = "https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT"
|
||||||
|
|
||||||
|
# 浏览器
|
||||||
|
BIT_ID = "62f9107d0c674925972084e282df55b3"
|
||||||
|
|
||||||
# 布林带参数
|
# 布林带参数
|
||||||
BB_PERIOD = 10 # 10根5分钟K线 = 50分钟回看
|
BB_PERIOD = 10 # 10根5分钟K线 = 50分钟回看
|
||||||
@@ -38,7 +44,11 @@ class BBTradeConfig:
|
|||||||
# 仓位管理
|
# 仓位管理
|
||||||
LEVERAGE = 50 # 杠杆倍数
|
LEVERAGE = 50 # 杠杆倍数
|
||||||
OPEN_TYPE = "cross" # 全仓模式
|
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),达到后停止交易
|
MAX_DAILY_LOSS = 50.0 # 日最大亏损(U),达到后停止交易
|
||||||
@@ -76,11 +86,20 @@ class BBTrader:
|
|||||||
timeout=(5, 15)
|
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=多
|
# 持仓状态: -1=空, 0=无, 1=多
|
||||||
self.position = 0
|
self.position = 0
|
||||||
self.open_avg_price = None
|
self.open_avg_price = None
|
||||||
self.current_amount = None
|
self.current_amount = None
|
||||||
|
|
||||||
|
# 加仓状态
|
||||||
|
self.pyramid_count = 0 # 当前已加仓次数 (0=仅首次开仓)
|
||||||
|
|
||||||
# 风控
|
# 风控
|
||||||
self.daily_pnl = 0.0
|
self.daily_pnl = 0.0
|
||||||
self.daily_stopped = False
|
self.daily_stopped = False
|
||||||
@@ -205,133 +224,77 @@ class BBTrader:
|
|||||||
logger.error(f"设置杠杆异常: {e}")
|
logger.error(f"设置杠杆异常: {e}")
|
||||||
return False
|
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:
|
def open_browser(self) -> bool:
|
||||||
"""
|
"""打开浏览器并进入交易页面"""
|
||||||
提交市价单
|
|
||||||
side: 1=买入开多, 2=买入平空, 3=卖出平多, 4=卖出开空
|
|
||||||
size: 张数
|
|
||||||
"""
|
|
||||||
side_names = {1: "买入开多", 2: "买入平空", 3: "卖出平多", 4: "卖出开空"}
|
|
||||||
logger.info(f"下单: {side_names.get(side, side)} {size}张")
|
|
||||||
try:
|
try:
|
||||||
resp = self.api.post_submit_order(
|
bit_port = openBrowser(id=self.cfg.BIT_ID)
|
||||||
contract_symbol=self.cfg.CONTRACT_SYMBOL,
|
co = ChromiumOptions()
|
||||||
client_order_id=self._gen_client_order_id(),
|
co.set_local_port(port=bit_port)
|
||||||
side=side,
|
self.page = ChromiumPage(addr_or_opts=co)
|
||||||
mode=1, # GTC
|
self.last_page_open_time = time.time()
|
||||||
type="market",
|
return True
|
||||||
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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"下单异常: {e}")
|
logger.error(f"打开浏览器失败: {e}")
|
||||||
return False
|
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%计算开仓张数
|
计算开仓/加仓金额(U)
|
||||||
BitMart ETH合约: 1张 = 0.01 ETH
|
首次开仓: 余额 × MARGIN_PCT (1%)
|
||||||
保证金 = equity * margin_pct
|
加仓: 余额 × (MARGIN_PCT + PYRAMID_STEP × (pyramid_count+1))
|
||||||
名义价值 = margin * leverage
|
例: 开仓1%, 第1次加仓2%, 第2次加仓3%, 第3次加仓4%
|
||||||
数量(ETH) = 名义价值 / price
|
|
||||||
张数 = 数量 / 0.01
|
|
||||||
"""
|
"""
|
||||||
balance = self.get_balance()
|
balance = self.get_balance()
|
||||||
if balance is None or balance <= 0:
|
if balance is None or balance <= 0:
|
||||||
logger.warning(f"余额不足或查询失败: {balance}")
|
logger.warning(f"余额不足或查询失败: {balance}")
|
||||||
return 0
|
return 0
|
||||||
margin = balance * self.cfg.MARGIN_PCT
|
if is_add:
|
||||||
notional = margin * self.cfg.LEVERAGE
|
pct = self.cfg.MARGIN_PCT + self.cfg.PYRAMID_STEP * (self.pyramid_count + 1)
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
# 平空: side=2
|
pct = self.cfg.MARGIN_PCT
|
||||||
size = int(self.current_amount)
|
order_usdt = round(balance * pct, 2)
|
||||||
return self.submit_order(side=2, size=size)
|
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:
|
def verify_position(self, expected: int) -> bool:
|
||||||
"""开多"""
|
"""验证持仓方向"""
|
||||||
size = self.calc_order_size(price)
|
if self.get_position_status():
|
||||||
if size <= 0:
|
if self.position == expected:
|
||||||
return False
|
return True
|
||||||
return self.submit_order(side=1, size=size)
|
logger.warning(f"持仓方向不符: 期望{expected}, 实际{self.position}")
|
||||||
|
return False
|
||||||
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 check_daily_reset(self):
|
def check_daily_reset(self):
|
||||||
"""每日重置(UTC+8 00:00 = UTC 16:00)"""
|
"""每日重置(UTC+8 00:00 = UTC 16:00)"""
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
# 用UTC日期做简单日切
|
# 用UTC日期做简单日切
|
||||||
today = now.date()
|
today = now.date()
|
||||||
if self.current_date != today:
|
if self.current_date != today:
|
||||||
@@ -377,14 +340,14 @@ class BBTrader:
|
|||||||
logger.warning(f"写入日志失败: {e}")
|
logger.warning(f"写入日志失败: {e}")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 主循环
|
# 主循环(浏览器流程与四分之一代码一致)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def run(self):
|
def run(self):
|
||||||
"""策略主循环"""
|
"""策略主循环"""
|
||||||
logger.info("=" * 60)
|
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.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)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
# 设置杠杆
|
# 设置杠杆
|
||||||
@@ -399,9 +362,50 @@ class BBTrader:
|
|||||||
logger.info(f"初始持仓状态: {self.position}")
|
logger.info(f"初始持仓状态: {self.position}")
|
||||||
|
|
||||||
last_kline_id = None # 避免同一根K线重复触发
|
last_kline_id = None # 避免同一根K线重复触发
|
||||||
|
page_start = True # 需要打开浏览器
|
||||||
|
|
||||||
while 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:
|
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()
|
self.check_daily_reset()
|
||||||
|
|
||||||
if self.daily_stopped:
|
if self.daily_stopped:
|
||||||
@@ -416,10 +420,8 @@ class BBTrader:
|
|||||||
time.sleep(self.cfg.POLL_INTERVAL)
|
time.sleep(self.cfg.POLL_INTERVAL)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 当前K线 = 最后一根(未收盘),信号用已收盘的K线
|
closed_klines = klines[:-1]
|
||||||
# 使用倒数第二根及之前的收盘价算BB(已收盘的K线)
|
current_kline = klines[-1]
|
||||||
closed_klines = klines[:-1] # 已收盘的K线
|
|
||||||
current_kline = klines[-1] # 当前未收盘K线
|
|
||||||
|
|
||||||
if len(closed_klines) < self.cfg.BB_PERIOD:
|
if len(closed_klines) < self.cfg.BB_PERIOD:
|
||||||
time.sleep(self.cfg.POLL_INTERVAL)
|
time.sleep(self.cfg.POLL_INTERVAL)
|
||||||
@@ -439,11 +441,11 @@ class BBTrader:
|
|||||||
time.sleep(self.cfg.POLL_INTERVAL)
|
time.sleep(self.cfg.POLL_INTERVAL)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 用当前K线的 high/low 判断是否触及布林带
|
|
||||||
cur_high = current_kline["high"]
|
cur_high = current_kline["high"]
|
||||||
cur_low = current_kline["low"]
|
cur_low = current_kline["low"]
|
||||||
touched_upper = cur_high >= bb_upper
|
# 容错: K线high/low + 当前实时价格,任一触及即算触碰
|
||||||
touched_lower = cur_low <= bb_lower
|
touched_upper = cur_high >= bb_upper or current_price >= bb_upper
|
||||||
|
touched_lower = cur_low <= bb_lower or current_price <= bb_lower
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"价格={current_price:.2f} | "
|
f"价格={current_price:.2f} | "
|
||||||
@@ -458,15 +460,12 @@ class BBTrader:
|
|||||||
time.sleep(self.cfg.POLL_INTERVAL)
|
time.sleep(self.cfg.POLL_INTERVAL)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 5. 信号判断 + 执行
|
# 5. 信号判断
|
||||||
# 同一根K线只触发一次
|
|
||||||
kline_id = current_kline["id"]
|
kline_id = current_kline["id"]
|
||||||
if kline_id == last_kline_id:
|
if kline_id == last_kline_id:
|
||||||
# 已在这根K线触发过,不重复操作
|
|
||||||
time.sleep(self.cfg.POLL_INTERVAL)
|
time.sleep(self.cfg.POLL_INTERVAL)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 同时触及上下轨(极端波动)→ 跳过
|
|
||||||
if touched_upper and touched_lower:
|
if touched_upper and touched_lower:
|
||||||
logger.warning("同时触及上下轨,跳过")
|
logger.warning("同时触及上下轨,跳过")
|
||||||
time.sleep(self.cfg.POLL_INTERVAL)
|
time.sleep(self.cfg.POLL_INTERVAL)
|
||||||
@@ -474,9 +473,10 @@ class BBTrader:
|
|||||||
|
|
||||||
action = None
|
action = None
|
||||||
reason = ""
|
reason = ""
|
||||||
|
success = False
|
||||||
|
|
||||||
|
# ===== 触及上轨 → 开空 / 翻转为空 / 加仓空 =====
|
||||||
if touched_upper:
|
if touched_upper:
|
||||||
# 触及上轨 → 开空 / 翻转为空
|
|
||||||
if not self.can_trade():
|
if not self.can_trade():
|
||||||
time.sleep(self.cfg.POLL_INTERVAL)
|
time.sleep(self.cfg.POLL_INTERVAL)
|
||||||
continue
|
continue
|
||||||
@@ -486,24 +486,52 @@ class BBTrader:
|
|||||||
|
|
||||||
if self.position == 1:
|
if self.position == 1:
|
||||||
action = "翻转: 平多→开空"
|
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:
|
elif self.position == 0:
|
||||||
action = "开空"
|
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:
|
else:
|
||||||
# 已经是空仓,不操作
|
logger.info(f"已持空仓,加仓已达上限({self.pyramid_count}/{self.cfg.PYRAMID_MAX})")
|
||||||
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} 执行成功")
|
|
||||||
|
|
||||||
|
# ===== 触及下轨 → 开多 / 翻转为多 / 加仓多 =====
|
||||||
elif touched_lower:
|
elif touched_lower:
|
||||||
# 触及下轨 → 开多 / 翻转为多
|
|
||||||
if not self.can_trade():
|
if not self.can_trade():
|
||||||
time.sleep(self.cfg.POLL_INTERVAL)
|
time.sleep(self.cfg.POLL_INTERVAL)
|
||||||
continue
|
continue
|
||||||
@@ -513,20 +541,62 @@ class BBTrader:
|
|||||||
|
|
||||||
if self.position == -1:
|
if self.position == -1:
|
||||||
action = "翻转: 平空→开多"
|
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:
|
elif self.position == 0:
|
||||||
action = "开多"
|
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:
|
else:
|
||||||
logger.info("已持多仓,触下轨无需操作")
|
logger.info(f"已持多仓,加仓已达上限({self.pyramid_count}/{self.cfg.PYRAMID_MAX})")
|
||||||
success = False
|
|
||||||
|
|
||||||
if success:
|
# ===== 交易成功后处理 =====
|
||||||
last_kline_id = kline_id
|
if success and action:
|
||||||
self.last_trade_time = time.time()
|
last_kline_id = kline_id
|
||||||
self.write_trade_log(action, current_price,
|
self.last_trade_time = time.time()
|
||||||
bb_upper, bb_mid, bb_lower, reason)
|
self.write_trade_log(action, current_price,
|
||||||
logger.success(f"{action} 执行成功")
|
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)
|
time.sleep(self.cfg.POLL_INTERVAL)
|
||||||
|
|
||||||
@@ -535,6 +605,7 @@ class BBTrader:
|
|||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"主循环异常: {e}")
|
logger.error(f"主循环异常: {e}")
|
||||||
|
page_start = True
|
||||||
time.sleep(10)
|
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_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)
|
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
|
@dataclass
|
||||||
class BBTrade:
|
class BBTrade:
|
||||||
@@ -105,6 +113,8 @@ def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
|
|||||||
entry_time = None
|
entry_time = None
|
||||||
entry_margin = 0.0
|
entry_margin = 0.0
|
||||||
entry_qty = 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] = []
|
trades: List[BBTrade] = []
|
||||||
total_fee = 0.0
|
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):
|
def close_position(exit_price, exit_idx):
|
||||||
nonlocal balance, position, entry_price, entry_time, entry_margin, entry_qty
|
nonlocal balance, position, entry_price, entry_time, entry_margin, entry_qty
|
||||||
nonlocal total_fee, total_rebate, day_pnl, today_fees
|
nonlocal total_fee, total_rebate, day_pnl, today_fees
|
||||||
|
nonlocal pyramid_count, last_add_margin
|
||||||
|
|
||||||
if position == 0:
|
if position == 0:
|
||||||
return
|
return
|
||||||
@@ -174,12 +185,23 @@ def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
|
|||||||
entry_time = None
|
entry_time = None
|
||||||
entry_margin = 0.0
|
entry_margin = 0.0
|
||||||
entry_qty = 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 position, entry_price, entry_time, entry_margin, entry_qty
|
||||||
nonlocal balance, total_fee, day_pnl, today_fees
|
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
|
equity = balance + unrealised(price) if position != 0 else balance
|
||||||
margin = equity * cfg.margin_pct
|
margin = equity * cfg.margin_pct
|
||||||
else:
|
else:
|
||||||
@@ -196,11 +218,24 @@ def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
|
|||||||
today_fees += fee
|
today_fees += fee
|
||||||
day_pnl -= fee
|
day_pnl -= fee
|
||||||
|
|
||||||
position = 1 if side == "long" else -1
|
if is_add and position != 0:
|
||||||
entry_price = price
|
# 加仓: weighted average entry price
|
||||||
entry_time = ts_index[idx]
|
old_notional = entry_qty * entry_price
|
||||||
entry_margin = margin
|
new_notional = qty * price
|
||||||
entry_qty = qty
|
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
|
# Main loop
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
@@ -268,17 +303,27 @@ def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
|
|||||||
if position == 1:
|
if position == 1:
|
||||||
# Close long at upper BB price
|
# Close long at upper BB price
|
||||||
close_position(arr_upper[i], i)
|
close_position(arr_upper[i], i)
|
||||||
if position != -1:
|
if position == 0:
|
||||||
# Open short
|
# Open short
|
||||||
open_position("short", arr_upper[i], i)
|
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:
|
elif touched_lower:
|
||||||
# Price touched lower BB → go long
|
# Price touched lower BB → go long
|
||||||
if position == -1:
|
if position == -1:
|
||||||
# Close short at lower BB price
|
# Close short at lower BB price
|
||||||
close_position(arr_lower[i], i)
|
close_position(arr_lower[i], i)
|
||||||
if position != 1:
|
if position == 0:
|
||||||
# Open long
|
# Open long
|
||||||
open_position("long", arr_lower[i], i)
|
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
|
# Record equity
|
||||||
out_equity[i] = balance + unrealised(arr_close[i])
|
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