Files
codex_jxs_code/bb_trade.py
2026-02-25 02:09:23 +08:00

547 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
布林带均值回归策略 — 实盘交易
BB(10, 2.5) | 5分钟K线 | ETH | 50x杠杆 | 每单权益1%
逻辑:
- 价格触及上布林带 → 平多(如有) + 开空
- 价格触及下布林带 → 平空(如有) + 开多
- 始终持仓(多空翻转)
使用 BitMart Futures API 进行开平仓
"""
import time
import uuid
import numpy as np
from datetime import datetime
from pathlib import Path
from loguru import logger
from bitmart.api_contract import APIContract
# ---------------------------------------------------------------------------
# 配置
# ---------------------------------------------------------------------------
class BBTradeConfig:
# API 凭证
API_KEY = "6104088c65a68d7e53df5d9395b67d78e555293a"
SECRET_KEY = "a8b14312330d8e6b9b09acfd972b34e32022fdfa9f2b06f0a0a31723b873fd01"
MEMO = "me"
# 合约
CONTRACT_SYMBOL = "ETHUSDT"
# 布林带参数
BB_PERIOD = 10 # 10根5分钟K线 = 50分钟回看
BB_STD = 2.5 # 标准差倍数
# 仓位管理
LEVERAGE = 50 # 杠杆倍数
OPEN_TYPE = "cross" # 全仓模式
MARGIN_PCT = 0.01 # 每单用权益的1%作为保证金
# 风控
MAX_DAILY_LOSS = 50.0 # 日最大亏损(U),达到后停止交易
COOLDOWN_SECONDS = 30 # 两次交易之间最小间隔(秒)
# 运行
POLL_INTERVAL = 5 # 主循环轮询间隔(秒)
KLINE_STEP = 5 # K线周期(分钟)
KLINE_HOURS = 2 # 获取最近多少小时K线(需覆盖BB_PERIOD)
# ---------------------------------------------------------------------------
# 布林带计算
# ---------------------------------------------------------------------------
def calc_bollinger(closes: list, period: int, n_std: float):
"""计算布林带,返回 (mid, upper, lower) 或 None数据不足时"""
if len(closes) < period:
return None
arr = np.array(closes[-period:], dtype=float)
mid = arr.mean()
std = arr.std(ddof=0)
upper = mid + n_std * std
lower = mid - n_std * std
return mid, upper, lower
# ---------------------------------------------------------------------------
# 交易主类
# ---------------------------------------------------------------------------
class BBTrader:
def __init__(self, cfg: BBTradeConfig = None):
self.cfg = cfg or BBTradeConfig()
self.api = APIContract(
self.cfg.API_KEY, self.cfg.SECRET_KEY, self.cfg.MEMO,
timeout=(5, 15)
)
# 持仓状态: -1=空, 0=无, 1=多
self.position = 0
self.open_avg_price = None
self.current_amount = None
# 风控
self.daily_pnl = 0.0
self.daily_stopped = False
self.current_date = None
self.last_trade_time = 0.0
# 日志
self.log_dir = Path(__file__).resolve().parent
logger.add(
self.log_dir / "bb_trade_{time:YYYY-MM-DD}.log",
rotation="1 day", retention="30 days",
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"
)
# ------------------------------------------------------------------
# API 封装
# ------------------------------------------------------------------
def get_klines(self) -> list | None:
"""获取最近N小时的5分钟K线返回 [{id, open, high, low, close}, ...]"""
try:
end_time = int(time.time())
start_time = end_time - 3600 * self.cfg.KLINE_HOURS
resp = self.api.get_kline(
contract_symbol=self.cfg.CONTRACT_SYMBOL,
step=self.cfg.KLINE_STEP,
start_time=start_time,
end_time=end_time
)[0]
if resp.get("code") != 1000:
logger.error(f"获取K线失败: {resp}")
return None
data = resp["data"]
klines = []
for k in data:
klines.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"]),
})
klines.sort(key=lambda x: x["id"])
return klines
except Exception as e:
logger.error(f"获取K线异常: {e}")
return None
def get_current_price(self) -> float | None:
"""获取当前最新价格最近1分钟K线收盘价"""
try:
end_time = int(time.time())
resp = self.api.get_kline(
contract_symbol=self.cfg.CONTRACT_SYMBOL,
step=1,
start_time=end_time - 300,
end_time=end_time
)[0]
if resp.get("code") == 1000 and resp["data"]:
return float(resp["data"][-1]["close_price"])
return None
except Exception as e:
logger.error(f"获取价格异常: {e}")
return None
def get_balance(self) -> float | None:
"""获取合约账户可用余额"""
try:
resp = self.api.get_assets_detail()[0]
if resp.get("code") == 1000:
data = resp["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) -> bool:
"""查询当前持仓,更新 self.position / open_avg_price / current_amount"""
try:
resp = self.api.get_position(contract_symbol=self.cfg.CONTRACT_SYMBOL)[0]
if resp.get("code") != 1000:
logger.error(f"查询持仓失败: {resp}")
return False
positions = resp["data"]
if not positions:
self.position = 0
self.open_avg_price = None
self.current_amount = None
return True
pos = positions[0]
self.position = 1 if pos["position_type"] == 1 else -1
self.open_avg_price = float(pos["open_avg_price"])
self.current_amount = float(pos["current_amount"])
unrealized = float(pos.get("unrealized_value", 0))
logger.debug(f"持仓: dir={self.position} price={self.open_avg_price} "
f"amt={self.current_amount} upnl={unrealized:.2f}")
return True
except Exception as e:
logger.error(f"查询持仓异常: {e}")
return False
def set_leverage(self) -> bool:
"""设置杠杆和全仓模式"""
try:
resp = self.api.post_submit_leverage(
contract_symbol=self.cfg.CONTRACT_SYMBOL,
leverage=str(self.cfg.LEVERAGE),
open_type=self.cfg.OPEN_TYPE
)[0]
if resp.get("code") == 1000:
logger.success(f"杠杆设置成功: {self.cfg.LEVERAGE}x {self.cfg.OPEN_TYPE}")
return True
else:
logger.error(f"杠杆设置失败: {resp}")
return False
except Exception as e:
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}")
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
except Exception as e:
logger.error(f"下单异常: {e}")
return False
# ------------------------------------------------------------------
# 仓位操作
# ------------------------------------------------------------------
def calc_order_size(self, price: float) -> int:
"""
根据当前权益的1%计算开仓张数
BitMart ETH合约: 1张 = 0.01 ETH
保证金 = equity * margin_pct
名义价值 = margin * leverage
数量(ETH) = 名义价值 / price
张数 = 数量 / 0.01
"""
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)
else:
# 平空: side=2
size = int(self.current_amount)
return self.submit_order(side=2, size=size)
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 check_daily_reset(self):
"""每日重置(UTC+8 00:00 = UTC 16:00)"""
now = datetime.utcnow()
# 用UTC日期做简单日切
today = now.date()
if self.current_date != today:
if self.current_date is not None:
logger.info(f"日切: {self.current_date}{today}, 日PnL={self.daily_pnl:.2f}")
self.current_date = today
self.daily_pnl = 0.0
self.daily_stopped = False
def can_trade(self) -> bool:
"""检查是否可交易"""
if self.daily_stopped:
return False
now = time.time()
if now - self.last_trade_time < self.cfg.COOLDOWN_SECONDS:
remain = self.cfg.COOLDOWN_SECONDS - (now - self.last_trade_time)
logger.debug(f"交易冷却中,剩余 {remain:.0f}s")
return False
return True
# ------------------------------------------------------------------
# 日志
# ------------------------------------------------------------------
def write_trade_log(self, action: str, price: float, bb_upper: float,
bb_mid: float, bb_lower: float, reason: str):
"""写入交易日志文件"""
try:
date_str = datetime.now().strftime("%Y%m%d")
log_file = self.log_dir / f"bb_trade_log_{date_str}.txt"
time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
block = (
f"\n{'='*60}\n"
f"时间: {time_str}\n"
f"操作: {action}\n"
f"价格: {price:.2f}\n"
f"BB上轨: {bb_upper:.2f} | 中轨: {bb_mid:.2f} | 下轨: {bb_lower:.2f}\n"
f"原因: {reason}\n"
f"{'='*60}\n"
)
with open(log_file, "a", encoding="utf-8") as f:
f.write(block)
except Exception as e:
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" 合约: {self.cfg.CONTRACT_SYMBOL} | {self.cfg.LEVERAGE}x {self.cfg.OPEN_TYPE}")
logger.info(f" 每单: 权益×{self.cfg.MARGIN_PCT:.0%}")
logger.info("=" * 60)
# 设置杠杆
if not self.set_leverage():
logger.error("杠杆设置失败,退出")
return
# 初始持仓同步
if not self.get_position_status():
logger.error("初始持仓查询失败,退出")
return
logger.info(f"初始持仓状态: {self.position}")
last_kline_id = None # 避免同一根K线重复触发
while True:
try:
self.check_daily_reset()
if self.daily_stopped:
logger.info(f"日亏损已达限制({self.daily_pnl:.2f}),等待日切")
time.sleep(60)
continue
# 1. 获取K线
klines = self.get_klines()
if not klines or len(klines) < self.cfg.BB_PERIOD:
logger.warning(f"K线数据不足({len(klines) if klines else 0}根),等待...")
time.sleep(self.cfg.POLL_INTERVAL)
continue
# 当前K线 = 最后一根未收盘信号用已收盘的K线
# 使用倒数第二根及之前的收盘价算BB已收盘的K线
closed_klines = klines[:-1] # 已收盘的K线
current_kline = klines[-1] # 当前未收盘K线
if len(closed_klines) < self.cfg.BB_PERIOD:
time.sleep(self.cfg.POLL_INTERVAL)
continue
# 2. 计算布林带
closes = [k["close"] for k in closed_klines]
bb = calc_bollinger(closes, self.cfg.BB_PERIOD, self.cfg.BB_STD)
if bb is None:
time.sleep(self.cfg.POLL_INTERVAL)
continue
bb_mid, bb_upper, bb_lower = bb
# 3. 获取当前价格
current_price = self.get_current_price()
if current_price is None:
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
logger.info(
f"价格={current_price:.2f} | "
f"BB: {bb_lower:.2f} / {bb_mid:.2f} / {bb_upper:.2f} | "
f"H={cur_high:.2f} L={cur_low:.2f} | "
f"触上={touched_upper} 触下={touched_lower} | "
f"仓位={self.position}"
)
# 4. 同步持仓状态
if not self.get_position_status():
time.sleep(self.cfg.POLL_INTERVAL)
continue
# 5. 信号判断 + 执行
# 同一根K线只触发一次
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)
continue
action = None
reason = ""
if touched_upper:
# 触及上轨 → 开空 / 翻转为空
if not self.can_trade():
time.sleep(self.cfg.POLL_INTERVAL)
continue
reason = (f"价格最高{cur_high:.2f}触及上轨{bb_upper:.2f}"
f"BB({self.cfg.BB_PERIOD},{self.cfg.BB_STD})")
if self.position == 1:
action = "翻转: 平多→开空"
success = self.flip_to_short(current_price)
elif self.position == 0:
action = "开空"
success = self.open_short(current_price)
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} 执行成功")
elif touched_lower:
# 触及下轨 → 开多 / 翻转为多
if not self.can_trade():
time.sleep(self.cfg.POLL_INTERVAL)
continue
reason = (f"价格最低{cur_low:.2f}触及下轨{bb_lower:.2f}"
f"BB({self.cfg.BB_PERIOD},{self.cfg.BB_STD})")
if self.position == -1:
action = "翻转: 平空→开多"
success = self.flip_to_long(current_price)
elif self.position == 0:
action = "开多"
success = self.open_long(current_price)
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} 执行成功")
time.sleep(self.cfg.POLL_INTERVAL)
except KeyboardInterrupt:
logger.info("用户中断,程序退出")
break
except Exception as e:
logger.error(f"主循环异常: {e}")
time.sleep(10)
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
if __name__ == "__main__":
trader = BBTrader()
trader.run()