Store demo API key/secret encrypted in a single-row trading_account table, expose get/put/delete/test endpoints under /api/settings/exchange, and add a settings card to enter and test the key. The client is hard-wired to demo-fapi.binance.com.
84 lines
3.5 KiB
Python
84 lines
3.5 KiB
Python
"""币安 U 本位合约模拟盘(Demo Trading)签名客户端
|
||
|
||
只连模拟盘:地址写死,没有切换实盘的参数,避免误下真单。
|
||
"""
|
||
|
||
import hashlib
|
||
import hmac
|
||
import time
|
||
from typing import Any
|
||
from urllib.parse import urlencode
|
||
|
||
import httpx
|
||
|
||
BASE_URL = "https://demo-fapi.binance.com"
|
||
RECV_WINDOW_MS = 5000
|
||
TIMEOUT_SECONDS = 10
|
||
|
||
|
||
class ExchangeError(Exception):
|
||
"""交易所或网络错误;code 为币安错误码(如 -2021),网络层错误时 status、code 为空"""
|
||
|
||
def __init__(self, status: int | None, code: int | None, message: str) -> None:
|
||
super().__init__(f"{status} {code}: {message}")
|
||
self.status = status
|
||
self.code = code
|
||
self.message = message
|
||
|
||
@property
|
||
def retryable(self) -> bool:
|
||
# 只有限流、服务端错误、-1001(内部断连)可以重试;业务错误重试也不会成功
|
||
return self.status in (418, 429) or (self.status or 0) >= 500 or self.code == -1001
|
||
|
||
def __str__(self) -> str:
|
||
if self.status is None:
|
||
return self.message
|
||
return f"币安返回 {self.status}({self.code}):{self.message}"
|
||
|
||
|
||
class DemoClient:
|
||
def __init__(self, api_key: str, api_secret: str) -> None:
|
||
self.secret = api_secret.encode()
|
||
self.http = httpx.AsyncClient(base_url=BASE_URL, timeout=TIMEOUT_SECONDS, headers={"X-MBX-APIKEY": api_key})
|
||
# 本地时钟与服务器的偏差(毫秒),遇到 -1021 时校准
|
||
self.time_offset = 0
|
||
|
||
async def __aenter__(self) -> "DemoClient":
|
||
return self
|
||
|
||
async def __aexit__(self, *_: object) -> None:
|
||
await self.http.aclose()
|
||
|
||
async def sync_time(self) -> None:
|
||
server_time = (await self.request("GET", "/fapi/v1/time", signed=False))["serverTime"]
|
||
self.time_offset = server_time - int(time.time() * 1000)
|
||
|
||
async def request(self, method: str, path: str, params: dict[str, Any] | None = None, signed: bool = True) -> Any:
|
||
"""不自动重试:下单类请求重发可能重复成交,由调用方按 clientOrderId 查询后决定"""
|
||
for attempt in range(2):
|
||
# 币安要求布尔参数为小写字符串
|
||
query = {k: str(v).lower() if isinstance(v, bool) else v for k, v in (params or {}).items() if v is not None}
|
||
if signed:
|
||
query["timestamp"] = int(time.time() * 1000) + self.time_offset
|
||
query["recvWindow"] = RECV_WINDOW_MS
|
||
encoded = urlencode(query)
|
||
if signed:
|
||
encoded += "&signature=" + hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
|
||
try:
|
||
resp = await self.http.request(method, f"{path}?{encoded}" if encoded else path)
|
||
except httpx.TransportError as e:
|
||
raise ExchangeError(None, None, f"无法连接币安模拟盘:{e!r}") from e
|
||
if resp.status_code < 400:
|
||
return resp.json()
|
||
try:
|
||
body = resp.json()
|
||
except ValueError:
|
||
body = {}
|
||
code = body.get("code") if isinstance(body, dict) else None
|
||
# 时间戳超出接收窗口时请求未被受理,校准时钟后重发是安全的
|
||
if code == -1021 and attempt == 0:
|
||
await self.sync_time()
|
||
continue
|
||
raise ExchangeError(resp.status_code, code, body.get("msg", resp.text[:200]) if isinstance(body, dict) else resp.text[:200])
|
||
raise AssertionError("unreachable")
|