Add Binance demo trading key settings and signed demo client

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.
This commit is contained in:
eezd committed 2026-10-15 09:24:58 +08:00
1 parent 438d6a00d9
commit 20fd5a98b2
8 files changed
+384 -5

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
"""币安 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")