328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""
|
||
WEEX ETH-USDT 永续合约交易框架
|
||
与 bitmart/框架.py 结构对应:浏览器 + API(K线/余额/持仓)+ 市价开平仓,供具体策略复用。
|
||
"""
|
||
import time
|
||
from typing import Optional, Dict, List, Tuple
|
||
|
||
from tqdm import tqdm
|
||
from loguru import logger
|
||
from bit_tools import openBrowser
|
||
from DrissionPage import ChromiumPage, ChromiumOptions
|
||
from curl_cffi import requests
|
||
|
||
|
||
# ==================== 配置 ====================
|
||
class Config:
|
||
TGE_URL = "http://127.0.0.1:50326"
|
||
TGE_AUTHORIZATION = "Bearer asp_174003986c9b0799677c5b2c1adb76e402735d753bc91a91"
|
||
|
||
CONTRACT_ID = "10000002"
|
||
PRODUCT_CODE = "cmt_ethusdt"
|
||
KLINE_TYPE = "MINUTE_30"
|
||
KLINE_LIMIT = 300
|
||
TRADING_URL = "https://www.weex.com/zh-CN/futures/ETH-USDT"
|
||
|
||
MAX_RETRY_ATTEMPTS = 3
|
||
RETRY_DELAY = 1
|
||
|
||
|
||
# ==================== 主框架类 ====================
|
||
class WeexFuturesTransaction:
|
||
"""WEEX 永续合约交易框架:K线、余额、持仓、浏览器、市价开平仓"""
|
||
|
||
def __init__(self, tge_id):
|
||
self.page: Optional[ChromiumPage] = None
|
||
self.tge_port: Optional[int] = None
|
||
self.tge_id = tge_id
|
||
|
||
self.session = requests.Session()
|
||
self.headers: Optional[Dict] = None
|
||
|
||
self.start = 0 # 持仓: -1 空, 0 无, 1 多
|
||
self.open_avg_price = None
|
||
self.current_amount = None
|
||
self.position_data: Optional[List] = None
|
||
|
||
self.pbar = tqdm(total=30, desc="等待K线", ncols=80)
|
||
self.last_kline_time = None
|
||
|
||
# ------------------------- Token(请求 API 前需先取 token)-------------------------
|
||
def _get_token(self) -> bool:
|
||
"""从浏览器请求交易页时抓取 U-TOKEN,写入 session headers"""
|
||
if not self.page:
|
||
return False
|
||
tab = self.page.new_tab()
|
||
tab.listen.start("/user/security/getLanguageType")
|
||
try:
|
||
for attempt in range(Config.MAX_RETRY_ATTEMPTS):
|
||
try:
|
||
tab.get(url=Config.TRADING_URL)
|
||
res = tab.listen.wait(timeout=5)
|
||
if res.request.headers.get("U-TOKEN"):
|
||
self.headers = dict(res.request.headers)
|
||
self.session.headers.update(self.headers)
|
||
logger.success("获取 token 成功")
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"获取 token 第 {attempt + 1} 次失败: {e}")
|
||
if attempt < Config.MAX_RETRY_ATTEMPTS - 1:
|
||
time.sleep(Config.RETRY_DELAY)
|
||
return False
|
||
finally:
|
||
tab.close()
|
||
|
||
# ------------------------- API -------------------------
|
||
def get_klines(
|
||
self,
|
||
contract_id: str = Config.CONTRACT_ID,
|
||
product_code: str = Config.PRODUCT_CODE,
|
||
kline_type: str = Config.KLINE_TYPE,
|
||
limit: int = Config.KLINE_LIMIT,
|
||
) -> Optional[List[Dict]]:
|
||
"""获取 K 线,返回 [{'id', 'open', 'high', 'low', 'close'}, ...],按 id 升序。请求前需已取 token。"""
|
||
# if not self._get_token():
|
||
# logger.error("获取 token 失败,无法拉取 K 线")
|
||
# return None
|
||
params = {
|
||
"contractId": contract_id,
|
||
"productCode": product_code,
|
||
"priceType": "LAST_PRICE",
|
||
"klineType": kline_type,
|
||
"limit": str(limit),
|
||
"timeZone": "string",
|
||
"languageType": "1",
|
||
"sign": "SIGN",
|
||
}
|
||
for attempt in range(Config.MAX_RETRY_ATTEMPTS):
|
||
try:
|
||
response = self.session.get(
|
||
"https://http-gateway2.elconvo.com/api/v1/public/quote/v1/getKlineV2",
|
||
params=params,
|
||
timeout=15,
|
||
)
|
||
if response.status_code != 200:
|
||
if attempt < Config.MAX_RETRY_ATTEMPTS - 1:
|
||
time.sleep(Config.RETRY_DELAY)
|
||
continue
|
||
data = response.json()
|
||
if "data" not in data or "dataList" not in data["data"]:
|
||
continue
|
||
result = data["data"]["dataList"]
|
||
kline_data = []
|
||
for item in result:
|
||
# [close, high, low, open, id]
|
||
kline_data.append({
|
||
"id": int(item[4]),
|
||
"open": float(item[3]),
|
||
"high": float(item[1]),
|
||
"low": float(item[2]),
|
||
"close": float(item[0]),
|
||
})
|
||
kline_data.sort(key=lambda x: x["id"])
|
||
return kline_data
|
||
except Exception as e:
|
||
logger.error(f"获取 K 线异常: {e}")
|
||
if attempt < Config.MAX_RETRY_ATTEMPTS - 1:
|
||
time.sleep(Config.RETRY_DELAY)
|
||
return None
|
||
|
||
def get_current_price(self) -> Optional[float]:
|
||
"""用最近一根 K 线收盘价作为当前价"""
|
||
klines = self.get_klines(limit=3)
|
||
if klines:
|
||
return float(klines[-1]["close"])
|
||
return None
|
||
|
||
def get_available_balance(self) -> Optional[float]:
|
||
"""合约账户可用余额"""
|
||
if not self._get_token():
|
||
return None
|
||
for attempt in range(Config.MAX_RETRY_ATTEMPTS):
|
||
try:
|
||
response = self.session.post(
|
||
"https://gateway2.ngsvsfx.cn/v1/gw/assetsWithBalance/new",
|
||
timeout=15,
|
||
)
|
||
return float(response.json()["data"]["newContract"]["balanceList"][0]["available"])
|
||
except Exception as e:
|
||
logger.error(f"获取余额异常: {e}")
|
||
if attempt < Config.MAX_RETRY_ATTEMPTS - 1:
|
||
time.sleep(Config.RETRY_DELAY)
|
||
return None
|
||
|
||
def get_position_status(self) -> bool:
|
||
"""从成交历史解析持仓方向,更新 self.start (-1/0/1),成功返回 True"""
|
||
# if not self._get_token():
|
||
# return False
|
||
payload = {
|
||
"filterContractIdList": [10000002],
|
||
"limit": 100,
|
||
"languageType": 0,
|
||
"sign": "SIGN",
|
||
"timeZone": "string",
|
||
}
|
||
for attempt in range(Config.MAX_RETRY_ATTEMPTS):
|
||
try:
|
||
response = self.session.post(
|
||
"https://http-gateway2.janapw.com/api/v1/private/order/v2/getHistoryOrderFillTransactionPage",
|
||
json=payload,
|
||
timeout=15,
|
||
)
|
||
datas = response.json().get("data", {}).get("dataList")
|
||
self.position_data = datas or None
|
||
if not datas:
|
||
self.start = 0
|
||
return True
|
||
rev = list(datas)
|
||
rev.reverse()
|
||
start, start1 = 0, 0
|
||
for i in rev:
|
||
d = i.get("legacyOrderDirection")
|
||
if d == "CLOSE_SHORT":
|
||
start = 0
|
||
elif d == "CLOSE_LONG":
|
||
start1 = 0
|
||
elif d == "OPEN_SHORT":
|
||
start -= 1
|
||
elif d == "OPEN_LONG":
|
||
start1 += 1
|
||
if start1:
|
||
self.start = 1
|
||
elif start:
|
||
self.start = -1
|
||
else:
|
||
self.start = 0
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"获取持仓异常: {e}")
|
||
if attempt < Config.MAX_RETRY_ATTEMPTS - 1:
|
||
time.sleep(Config.RETRY_DELAY)
|
||
return False
|
||
|
||
# ------------------------- 浏览器 -------------------------
|
||
def openBrowser(self) -> bool:
|
||
"""打开 TGE 对应浏览器实例"""
|
||
try:
|
||
bit_port = openBrowser(id=self.tge_id)
|
||
co = ChromiumOptions()
|
||
co.set_local_port(port=bit_port)
|
||
self.page = ChromiumPage(addr_or_opts=co)
|
||
self.tge_port = bit_port
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def take_over_browser(self) -> bool:
|
||
"""接管已有浏览器"""
|
||
if not self.tge_port:
|
||
return False
|
||
try:
|
||
co = ChromiumOptions()
|
||
co.set_local_port(self.tge_port)
|
||
self.page = ChromiumPage(addr_or_opts=co)
|
||
self.page.set.window.max()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def close_extra_tabs(self) -> bool:
|
||
"""关闭多余标签页,只保留第一个"""
|
||
if not self.page:
|
||
return False
|
||
try:
|
||
for idx, tab in enumerate(self.page.get_tabs()):
|
||
if idx > 0:
|
||
tab.close()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def click_safe(self, xpath: str, sleep: float = 0.5) -> bool:
|
||
"""安全点击"""
|
||
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 Exception:
|
||
return False
|
||
|
||
# ------------------------- 交易 -------------------------
|
||
def 平仓(self) -> bool:
|
||
"""市价平仓(闪电平仓)"""
|
||
try:
|
||
self.page.ele('x:(//span[normalize-space(text()) = "闪电平仓"])').scroll.to_see(center=True)
|
||
time.sleep(0.5)
|
||
self.page.ele('x:(//span[normalize-space(text()) = "闪电平仓"])').click(by_js=True)
|
||
time.sleep(2)
|
||
logger.info("执行平仓")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"平仓异常: {e}")
|
||
return False
|
||
|
||
def 开单(self, marketPriceLongOrder: int = 0, size: Optional[float] = None) -> bool:
|
||
"""
|
||
市价开仓
|
||
marketPriceLongOrder: 1 做多, -1 做空
|
||
size: 数量(金额)
|
||
"""
|
||
if size is None or size <= 0:
|
||
logger.warning("开单数量无效")
|
||
return False
|
||
try:
|
||
self.click_safe('x:(//button[normalize-space(text()) = "市价"])')
|
||
time.sleep(0.5)
|
||
self.page.ele('x://input[@placeholder="请输入数量"]').input(size)
|
||
time.sleep(0.5)
|
||
if marketPriceLongOrder == -1:
|
||
self.page.ele('x://*[normalize-space(text()) ="卖出开空"]').click(by_js=True)
|
||
elif marketPriceLongOrder == 1:
|
||
self.page.ele('x://*[normalize-space(text()) ="买入开多"]').click(by_js=True)
|
||
else:
|
||
return False
|
||
logger.info(f"市价{'做空' if marketPriceLongOrder == -1 else '做多'} 数量={size}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"开单异常: {e}")
|
||
return False
|
||
|
||
def ding(self, text: str, error: bool = False) -> None:
|
||
"""日志/通知入口,可在此接入钉钉等"""
|
||
if error:
|
||
logger.error(text)
|
||
else:
|
||
logger.info(text)
|
||
|
||
def action(self) -> None:
|
||
"""框架入口:打开浏览器 -> 交易页 -> 取 token -> 拉 K 线 -> 切市价(示例)"""
|
||
if not self.openBrowser():
|
||
self.ding("打开 TGE 失败!", error=True)
|
||
return
|
||
logger.info("TGE 浏览器已打开")
|
||
|
||
self.close_extra_tabs()
|
||
self.page.get(url=Config.TRADING_URL)
|
||
time.sleep(2)
|
||
|
||
if not self._get_token():
|
||
self.ding("获取 token 失败", error=True)
|
||
return
|
||
|
||
klines = self.get_klines()
|
||
if klines:
|
||
logger.info(f"获取到 {len(klines)} 根 K 线,最新收盘 {klines[-1]['close']}")
|
||
|
||
self.click_safe('x:(//button[normalize-space(text()) = "市价"])')
|
||
# 此处可接具体策略循环:get_klines -> 信号 -> 开单/平仓
|
||
# self.平仓()
|
||
self.开单(marketPriceLongOrder=1, size=100)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
WeexFuturesTransaction(tge_id="86837a981aba4576be6916a0ef6ad785").action()
|