905 lines
44 KiB
Python
905 lines
44 KiB
Python
import time
|
||
import json
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
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
|
||
|
||
|
||
PRECOMPUTED_TRADE_PARAMS = {
|
||
# 你已经算好参数时,把 enabled 改成 True,并在下面填入你的值。
|
||
# 直接运行本文件即可生效,不需要任何命令行参数。
|
||
"enabled": False,
|
||
"params": {}
|
||
}
|
||
|
||
|
||
class BitmartFuturesTransaction:
|
||
def __init__(self, bit_id):
|
||
|
||
self.page: ChromiumPage | None = None
|
||
|
||
self.api_key = "6104088c65a68d7e53df5d9395b67d78e555293a"
|
||
self.secret_key = "a8b14312330d8e6b9b09acfd972b34e32022fdfa9f2b06f0a0a31723b873fd01"
|
||
self.memo = "me"
|
||
|
||
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.last_reverse_time = None # 上次反手时间
|
||
self.last_reverse_kline_id = None # 已反手过的 K 线 id,该 K 线内不再操作仓位
|
||
|
||
# 开仓频率控制
|
||
self.open_cooldown_seconds = 60 # 开仓冷却时间(秒),两次开仓至少间隔此时长
|
||
self.last_open_time = None # 上次开仓时间
|
||
self.last_open_kline_id = None # 上次开仓所在 K 线 id,同一根 K 线只允许开仓一次
|
||
|
||
self.leverage = "40" # 高杠杆(全仓模式下可开更大仓位)
|
||
self.open_type = "cross" # 全仓模式
|
||
self.risk_percent = 0 # 未使用;若启用则可为每次开仓占可用余额的百分比
|
||
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线开盘价
|
||
|
||
# 策略优化参数
|
||
self.min_prev_entity_pct = 0.1 # 上一根K线实体涨幅(%),仅当实体涨幅 > 此值才用作“上一根”
|
||
|
||
# 自动止盈:基于当前K线振幅四等分(多仓用 open~high,空仓用 open~low)
|
||
self.take_profit_close_quartile = 3 # 达到极值后,回到 3/4 位置平仓
|
||
self.take_profit_reentry_quartile = 2 # 止盈后,继续回到 2/4 位置同向再开仓
|
||
self.take_profit_triggered_kline_id = None # 多仓:本K线内是否出现过 open~high 的极值触发
|
||
self.take_profit_reentry_threshold = None # 多仓止盈平仓后,价格 <= 此值则开多(同向)
|
||
self.take_profit_triggered_kline_id_short = None # 空仓:本K线内是否出现过 open~low 的极值触发
|
||
self.take_profit_reentry_threshold_short = None # 空仓止盈平仓后,价格 >= 此值则开空(同向)
|
||
self.last_take_profit_kline_id = None # 本K线已止盈一次(不区分多空)则不再止盈
|
||
|
||
self.optimized_params_file = Path(__file__).resolve().parent / "atr_best_params.json"
|
||
self.strategy_log_dir = Path(__file__).resolve().parent # 策略开仓日志目录
|
||
self.apply_precomputed_params()
|
||
self.load_optimized_params()
|
||
|
||
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=15, # 15分钟
|
||
start_time=end_time - 3600 * 9, # 取最近9小时(与原5分钟*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'])
|
||
|
||
# 返回最近多根K线列表,供主循环按「实体涨幅>0.1%」向前选取上一根
|
||
if len(formatted) >= 2:
|
||
return formatted
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"获取K线异常: {e}")
|
||
self.ding(text="获取K线异常", error=True)
|
||
return 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 _log_take_profit_action(self, operation: str, reason: str):
|
||
"""止盈相关操作日志:时间、操作、原因"""
|
||
time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
logger.info(f"[止盈日志] 时间={time_str} | 操作={operation} | 原因={reason}")
|
||
|
||
def _write_open_log(self, operation: str, reason: str, current_kline: dict, prev_kline: dict | None):
|
||
"""写入策略开仓日志文件:时间、参考的两根K线、操作、开仓原因"""
|
||
try:
|
||
date_str = datetime.now().strftime("%Y%m%d")
|
||
log_file = self.strategy_log_dir / f"strategy_log_{date_str}.txt"
|
||
time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
cur = current_kline
|
||
cur_line = f"id={cur['id']} open={cur['open']:.2f} high={cur['high']:.2f} low={cur['low']:.2f} close={cur['close']:.2f}"
|
||
if prev_kline:
|
||
prev = prev_kline
|
||
prev_line = f"id={prev['id']} open={prev['open']:.2f} high={prev['high']:.2f} low={prev['low']:.2f} close={prev['close']:.2f}"
|
||
else:
|
||
prev_line = "(无)"
|
||
block = (
|
||
f"\n{'='*60}\n"
|
||
f"时间: {time_str}\n"
|
||
f"操作: {operation}\n"
|
||
f"参考K线:\n 当前K线: {cur_line}\n 上一根K线: {prev_line}\n"
|
||
f"开仓原因: {reason}\n"
|
||
f"{'='*60}\n"
|
||
)
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(block)
|
||
logger.info(f"已写入开仓日志: {log_file.name}")
|
||
except Exception as e:
|
||
logger.warning(f"写入开仓日志失败: {e}")
|
||
|
||
def load_optimized_params(self):
|
||
"""从本地优化结果文件加载参数(可选)。"""
|
||
try:
|
||
if PRECOMPUTED_TRADE_PARAMS.get("enabled"):
|
||
logger.info("已启用 PRECOMPUTED_TRADE_PARAMS,跳过 atr_best_params.json 加载")
|
||
return
|
||
if not self.optimized_params_file.exists():
|
||
return
|
||
data = json.loads(self.optimized_params_file.read_text(encoding="utf-8"))
|
||
if data.get("apply_live") is not True:
|
||
logger.info(f"检测到优化参数文件但 apply_live != true,跳过加载: {self.optimized_params_file}")
|
||
return
|
||
params = data.get("params_for_trade_py", data)
|
||
allow_keys = {"min_prev_entity_pct"}
|
||
applied = []
|
||
for key, val in params.items():
|
||
if key in allow_keys and hasattr(self, key):
|
||
setattr(self, key, val)
|
||
applied.append(key)
|
||
if applied:
|
||
logger.info(f"已加载优化参数文件: {self.optimized_params_file},字段数: {len(applied)}")
|
||
except Exception as e:
|
||
logger.warning(f"加载优化参数文件失败,将继续使用默认参数: {e}")
|
||
|
||
def apply_precomputed_params(self):
|
||
"""
|
||
直接应用本文件内预计算参数(不依赖命令)。
|
||
当 PRECOMPUTED_TRADE_PARAMS.enabled=True 时生效。
|
||
"""
|
||
try:
|
||
conf = PRECOMPUTED_TRADE_PARAMS or {}
|
||
if conf.get("enabled") is not True:
|
||
return
|
||
params = conf.get("params") or {}
|
||
if not isinstance(params, dict):
|
||
logger.warning("PRECOMPUTED_TRADE_PARAMS.params 不是字典,忽略")
|
||
return
|
||
|
||
allow_keys = {"min_prev_entity_pct"}
|
||
applied = []
|
||
for key, val in params.items():
|
||
if key in allow_keys and hasattr(self, key):
|
||
setattr(self, key, val)
|
||
applied.append(key)
|
||
logger.info(f"已应用文件内预计算参数,字段数: {len(applied)}")
|
||
except Exception as e:
|
||
logger.warning(f"应用文件内预计算参数失败,将继续使用默认参数: {e}")
|
||
|
||
def calculate_entity(self, kline):
|
||
"""计算K线实体大小(绝对值)"""
|
||
return abs(kline['close'] - kline['open'])
|
||
|
||
def get_entity_edge(self, kline):
|
||
"""获取K线实体边(收盘价或开盘价,取决于是阳线还是阴线)"""
|
||
# 阳线(收盘>开盘):实体上边=收盘价,实体下边=开盘价
|
||
# 阴线(收盘<开盘):实体上边=开盘价,实体下边=收盘价
|
||
return {
|
||
'upper': max(kline['open'], kline['close']), # 实体上边
|
||
'lower': min(kline['open'], kline['close']) # 实体下边
|
||
}
|
||
|
||
def calc_long_take_profit_levels(self, current_kline):
|
||
"""
|
||
多仓止盈阈值(自动四等分):
|
||
基于当前K线 open~high 计算:
|
||
- extreme: high(4/4 极值)
|
||
- close: open + 3/4 * (high-open)
|
||
- reentry: open + 2/4 * (high-open)
|
||
"""
|
||
k_open = float(current_kline['open'])
|
||
k_high = float(current_kline['high'])
|
||
if k_high <= k_open:
|
||
return None
|
||
|
||
move = k_high - k_open
|
||
close_threshold = k_open + move * (self.take_profit_close_quartile / 4)
|
||
reentry_threshold = k_open + move * (self.take_profit_reentry_quartile / 4)
|
||
return {
|
||
"open": k_open,
|
||
"extreme": k_high,
|
||
"close_threshold": close_threshold,
|
||
"reentry_threshold": reentry_threshold
|
||
}
|
||
|
||
def calc_short_take_profit_levels(self, current_kline):
|
||
"""
|
||
空仓止盈阈值(自动四等分):
|
||
基于当前K线 open~low 计算:
|
||
- extreme: low(4/4 极值)
|
||
- close: open - 3/4 * (open-low)
|
||
- reentry: open - 2/4 * (open-low)
|
||
"""
|
||
k_open = float(current_kline['open'])
|
||
k_low = float(current_kline['low'])
|
||
if k_low >= k_open:
|
||
return None
|
||
|
||
move = k_open - k_low
|
||
close_threshold = k_open - move * (self.take_profit_close_quartile / 4)
|
||
reentry_threshold = k_open - move * (self.take_profit_reentry_quartile / 4)
|
||
return {
|
||
"open": k_open,
|
||
"extreme": k_low,
|
||
"close_threshold": close_threshold,
|
||
"reentry_threshold": reentry_threshold
|
||
}
|
||
|
||
def check_signal(self, current_price, prev_kline, current_kline):
|
||
"""
|
||
检查交易信号
|
||
返回: ('long', trigger_price) / ('short', trigger_price) / None
|
||
"""
|
||
# 计算上一根K线实体(主循环已保证所选 prev 实体涨幅 > 0.1%)
|
||
prev_entity = self.calculate_entity(prev_kline)
|
||
|
||
# 获取上一根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 / 3
|
||
short_trigger = calc_upper - prev_entity / 3
|
||
long_breakout = calc_upper + prev_entity / 3
|
||
short_breakout = calc_lower - prev_entity / 3
|
||
else:
|
||
# 原有计算方式
|
||
long_trigger = prev_entity_lower + prev_entity / 3 # 做多触发价 = 实体下边 + 实体/3(下三分之一处)
|
||
short_trigger = prev_entity_upper - prev_entity / 3 # 做空触发价 = 实体上边 - 实体/3(上三分之一处)
|
||
long_breakout = prev_entity_upper + prev_entity / 3 # 做多突破价 = 实体上边 + 实体/3
|
||
short_breakout = prev_entity_lower - prev_entity / 3 # 做空突破价 = 实体下边 - 实体/3
|
||
|
||
# 上一根阴线 + 当前阳线:做多形态,不按上一根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/3): {long_trigger:.2f}, 做空触发价(上1/3): {short_trigger:.2f}")
|
||
logger.info(f"突破做多价(上1/3外): {long_breakout:.2f}, 突破做空价(下1/3外): {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/3外) {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/3外) {short_breakout:.2f}"
|
||
)
|
||
return ('short', short_breakout)
|
||
|
||
# 持仓时检查反手信号
|
||
elif self.start == 1: # 持多仓
|
||
# 反手条件: 价格跌到上一根K线的上三分之一处(做空触发价);上一根阴线+当前阳线做多时跳过
|
||
if current_price <= short_trigger and not skip_short_by_upper_third:
|
||
logger.info(f"持多反手做空!价格 {current_price:.2f} <= 触发价(上1/3) {short_trigger:.2f}")
|
||
return ('reverse_short', short_trigger)
|
||
|
||
elif self.start == -1: # 持空仓
|
||
# 反手条件: 价格涨到上一根K线的下三分之一处(做多触发价);上一根阳线+当前阴线做空时跳过
|
||
if current_price >= long_trigger and not skip_long_by_lower_third:
|
||
logger.info(f"持空反手做多!价格 {current_price:.2f} >= 触发价(下1/3) {long_trigger:.2f}")
|
||
return ('reverse_long', long_trigger)
|
||
|
||
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
|
||
|
||
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.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.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):
|
||
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):
|
||
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("开始运行三分之一策略交易(15分钟K线)...")
|
||
|
||
# 启动时设置全仓高杠杆
|
||
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线=最后一根;上一根=从后往前第一根实体涨幅>0.1%的K线
|
||
formatted = self.get_klines()
|
||
if not formatted or len(formatted) < 2:
|
||
logger.warning("获取K线失败,等待重试...")
|
||
time.sleep(5)
|
||
continue
|
||
|
||
current_kline = formatted[-1]
|
||
prev_kline = None
|
||
for i in range(len(formatted) - 2, -1, -1):
|
||
k = formatted[i]
|
||
entity = abs(k['close'] - k['open'])
|
||
entity_pct = entity / k['open'] * 100 if k['open'] else 0
|
||
if entity_pct > self.min_prev_entity_pct:
|
||
prev_kline = k
|
||
break
|
||
if prev_kline is None:
|
||
logger.info(f"没有实体涨幅>{self.min_prev_entity_pct:.3f}%的上一根K线,跳过信号检测")
|
||
time.sleep(0.1)
|
||
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}")
|
||
# 新K线内重新判断多/空止盈触发
|
||
if self.start == 1:
|
||
self.take_profit_triggered_kline_id = None
|
||
if self.start == -1:
|
||
self.take_profit_triggered_kline_id_short = None
|
||
|
||
# 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=空)")
|
||
|
||
signal = None
|
||
|
||
# 3.5 多仓止盈(自动四等分):open~high 达到极值后,回到 3/4 平仓
|
||
if self.start == 1:
|
||
# 同一根K线多仓只允许止盈一次,避免“止盈->再开->再止盈”循环
|
||
if self.last_take_profit_kline_id == current_kline_time:
|
||
signal = self.check_signal(current_price, prev_kline, current_kline)
|
||
else:
|
||
long_levels = self.calc_long_take_profit_levels(current_kline)
|
||
if long_levels is not None:
|
||
retrace_close_threshold = long_levels["close_threshold"]
|
||
# 使用当前K线 high 判定是否已触发到 4/4 极值,避免依赖分钟收盘价漏判
|
||
if long_levels["extreme"] > long_levels["open"]:
|
||
self.take_profit_triggered_kline_id = current_kline_time
|
||
if (
|
||
self.take_profit_triggered_kline_id == current_kline_time
|
||
and current_price <= retrace_close_threshold
|
||
):
|
||
reason = (
|
||
f"当前K线开盘价={long_levels['open']:.2f},最高价={long_levels['extreme']:.2f}(4/4);"
|
||
f"现价{current_price:.2f}已回调至≤{retrace_close_threshold:.2f}(3/4),按规则止盈平多"
|
||
)
|
||
self._log_take_profit_action("止盈平多仓", reason)
|
||
self.平仓()
|
||
self.take_profit_reentry_threshold = long_levels["reentry_threshold"]
|
||
self.take_profit_triggered_kline_id = None
|
||
self.last_take_profit_kline_id = current_kline_time
|
||
page_start = True
|
||
time.sleep(1)
|
||
else:
|
||
signal = self.check_signal(current_price, prev_kline, current_kline)
|
||
else:
|
||
signal = self.check_signal(current_price, prev_kline, current_kline)
|
||
# 3.6 止盈平多后:价格继续回调到 2/4 则同向开多
|
||
elif self.start == 0 and self.take_profit_reentry_threshold is not None:
|
||
if current_price <= self.take_profit_reentry_threshold:
|
||
reason = (
|
||
f"止盈平仓后价格继续回调至≤{self.take_profit_reentry_threshold:.2f}(2/4),按规则同向开多"
|
||
)
|
||
self._log_take_profit_action("止盈后同向开多", reason)
|
||
self.开单(marketPriceLongOrder=1, size=self.default_order_size)
|
||
time.sleep(3)
|
||
if self.verify_position_direction(1):
|
||
self.last_open_time = time.time()
|
||
self.last_open_kline_id = current_kline_time
|
||
self._write_open_log("止盈后同向开多", reason, current_kline, prev_kline)
|
||
logger.success("止盈后再开多成功")
|
||
page_start = True
|
||
else:
|
||
logger.error("止盈后再开多验证失败")
|
||
self.take_profit_reentry_threshold = None
|
||
else:
|
||
signal = self.check_signal(current_price, prev_kline, current_kline)
|
||
# 3.7 空仓止盈(自动四等分):open~low 达到极值后,回到 3/4 平仓
|
||
elif self.start == -1:
|
||
# 同一根K线空仓只允许止盈一次,避免“止盈->再开->再止盈”循环
|
||
if self.last_take_profit_kline_id == current_kline_time:
|
||
signal = self.check_signal(current_price, prev_kline, current_kline)
|
||
else:
|
||
short_levels = self.calc_short_take_profit_levels(current_kline)
|
||
if short_levels is not None:
|
||
retrace_close_threshold_short = short_levels["close_threshold"]
|
||
# 使用当前K线 low 判定是否已触发到 4/4 极值,避免依赖分钟收盘价漏判
|
||
if short_levels["extreme"] < short_levels["open"]:
|
||
self.take_profit_triggered_kline_id_short = current_kline_time
|
||
if (
|
||
self.take_profit_triggered_kline_id_short == current_kline_time
|
||
and current_price >= retrace_close_threshold_short
|
||
):
|
||
reason = (
|
||
f"当前K线开盘价={short_levels['open']:.2f},最低价={short_levels['extreme']:.2f}(4/4);"
|
||
f"现价{current_price:.2f}已反弹至≥{retrace_close_threshold_short:.2f}(3/4),按规则止盈平空"
|
||
)
|
||
self._log_take_profit_action("止盈平空仓", reason)
|
||
self.平仓()
|
||
self.take_profit_reentry_threshold_short = short_levels["reentry_threshold"]
|
||
self.take_profit_triggered_kline_id_short = None
|
||
self.last_take_profit_kline_id = current_kline_time
|
||
page_start = True
|
||
time.sleep(1)
|
||
else:
|
||
signal = self.check_signal(current_price, prev_kline, current_kline)
|
||
else:
|
||
signal = self.check_signal(current_price, prev_kline, current_kline)
|
||
# 3.8 止盈平空后:价格继续反弹到 2/4 则同向开空
|
||
elif self.start == 0 and self.take_profit_reentry_threshold_short is not None:
|
||
if current_price >= self.take_profit_reentry_threshold_short:
|
||
reason = (
|
||
f"止盈平空后价格继续反弹至≥{self.take_profit_reentry_threshold_short:.2f}(2/4),按规则同向开空"
|
||
)
|
||
self._log_take_profit_action("止盈后同向开空", reason)
|
||
self.开单(marketPriceLongOrder=-1, size=self.default_order_size)
|
||
time.sleep(3)
|
||
if self.verify_position_direction(-1):
|
||
self.last_open_time = time.time()
|
||
self.last_open_kline_id = current_kline_time
|
||
self._write_open_log("止盈后同向开空", reason, current_kline, prev_kline)
|
||
logger.success("止盈后再开空成功")
|
||
page_start = True
|
||
else:
|
||
logger.error("止盈后再开空验证失败")
|
||
self.take_profit_reentry_threshold_short = None
|
||
else:
|
||
signal = self.check_signal(current_price, prev_kline, current_kline)
|
||
else:
|
||
# 4. 检查信号
|
||
signal = self.check_signal(current_price, prev_kline, current_kline)
|
||
|
||
# 6. 反手过滤:冷却时间
|
||
if signal and signal[0].startswith('reverse_'):
|
||
if not self.can_reverse(current_price, signal[1]):
|
||
signal = None
|
||
|
||
# 6.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.6 当前 K 线已反手过则本 K 线内不再操作仓位
|
||
if signal and self.last_reverse_kline_id == current_kline_time:
|
||
logger.info(f"本 K 线({current_kline_time})已反手过,本 K 线内不再操作仓位")
|
||
signal = None
|
||
|
||
# 7. 有信号则执行交易
|
||
if signal:
|
||
trade_success = self.execute_trade(signal)
|
||
if trade_success:
|
||
if signal[0] in ('reverse_long', 'reverse_short'):
|
||
self.last_reverse_kline_id = current_kline_time # 本 K 线已反手,本 K 线内不再操作
|
||
# 写入开仓日志:参考的两根K线 + 开仓原因
|
||
sig_type, trigger_price = signal
|
||
op_name = {"long": "开多", "short": "开空", "reverse_long": "反手做多", "reverse_short": "反手做空"}.get(sig_type, sig_type)
|
||
if sig_type == "long":
|
||
open_reason = f"三分之一策略:当前价{current_price:.2f}>=突破做多价{trigger_price:.2f}(实体上边+1/3)"
|
||
elif sig_type == "short":
|
||
open_reason = f"三分之一策略:当前价{current_price:.2f}<=突破做空价{trigger_price:.2f}(实体下边-1/3)"
|
||
elif sig_type == "reverse_long":
|
||
open_reason = f"持空反手做多:当前价{current_price:.2f}>=做多触发价{trigger_price:.2f}(下1/3)"
|
||
else:
|
||
open_reason = f"持多反手做空:当前价{current_price:.2f}<=做空触发价{trigger_price:.2f}(上1/3)"
|
||
self._write_open_log(op_name, open_reason, current_kline, prev_kline)
|
||
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="62f9107d0c674925972084e282df55b3").action()
|