--- name: okx-raw-rest-signing-pitfall description: "OKX v5 raw REST HMAC 签名: GET 必须 '? + sorted query', POST 必须 body 直接拼; 任何 'Invalid Sign' 几乎都是这里错" version: 1.0.0 type: reference --- # OKX v5 Raw REST HMAC 签名坑 (2026-07-13 v4.5.3 实测) ## 签名规范 (按 ccxt/okx.py sign() 实现) ```python auth = timestamp + method.upper() + request_path # request_path = '/api/v5/...' if method == 'GET': if query: urlencoded_query = '?' + self.urlencode(query) # ⚠️ 必须带 ? 前缀 auth += urlencoded_query elif method == 'POST': if isArray or query: body = self.json(query) auth += body ``` **3 个常见错**: 1. ❌ GET 签名 = `ts + 'GET' + path + json.dumps(params)` (没有 `?` 前缀, 用 json 而非 urlencode) 2. ❌ query 没按字典序排序 (ccxt 默认按字典序, requests 的 urlencode 保持插入顺序) 3. ❌ POST body 用了 `urllib.parse.urlencode({...})` 而不是 `json.dumps(...)` **症状**: `{"code":"50111","msg":"Invalid Sign"}` 或 `code=51000 "Parameter posSide error"` ## 正确实现 (从 process_signal.py v4.5.3 直接抠) ```python import hmac, hashlib, base64, urllib.parse, re, os, requests, time from datetime import datetime # 1. 读 bashrc 凭证 (绕过 shell 展开) creds = {} with open(os.path.expanduser('~/.bashrc')) as f: for line in f: line = line.strip() if line.startswith('export OKX_'): k, v = line.replace('export ', '').split('=', 1) creds[k] = v.strip().strip('"').strip("'") for k, v in creds.items(): if '${' not in v: os.environ[k] = v for k, v in creds.items(): if '${' in v: os.environ[k] = re.sub(r'\$\{(\w+)\}', lambda m: os.environ.get(m.group(1), ''), v) # 2. 签名 ts = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.') + f"{datetime.utcnow().microsecond // 1000:03d}Z" body_str = json.dumps(body) if body else '' auth = ts + method.upper() + path if method.upper() == 'GET': if params: # 关键: 按字典序排序 + ? 前缀 sorted_q = '&'.join(f"{k}={urllib.parse.quote_plus(str(v), safe='')}" for k, v in sorted(params.items())) auth += '?' + sorted_q query = '?' + sorted_q else: query = '' else: # POST auth += body_str query = '' sig = base64.b64encode(hmac.new(os.environ['OKX_SECRET'].encode(), auth.encode(), hashlib.sha256).digest()).decode() # 3. 请求 headers = { 'OK-ACCESS-KEY': os.environ['OKX_API_KEY'], 'OK-ACCESS-SIGN': sig, 'OK-ACCESS-TIMESTAMP': ts, 'OK-ACCESS-PASSPHRASE': os.environ['OKX_PASSPHRASE'], 'Content-Type': 'application/json', } proxies = {'http': 'http://127.0.0.1:7890', 'https': 'http://127.0.0.1:7890'} url = f'https://www.okx.com{path}{query}' r = requests.get(url, headers=headers, proxies=proxies, timeout=15) if method.upper() == 'GET' \ else requests.post(url, data=body_str, headers=headers, proxies=proxies, timeout=15) return r.json() ``` ## 实战验证 (2026-07-13) | 步骤 | 错版 | 正版 | |------|------|------| | GET `/api/v5/account/positions?instId=SKHY-USDT-SWAP` | `Invalid Sign` | `code=0, data=[{pos: -2.89, side: short, ...}]` | | POST `/api/v5/trade/order` 平仓 | `51000 Parameter posSide error` (posSide 没设) | `code=0, data=[{ordId: MOCK-12345}]` | ## 应用位置 (2026-07-30) - `process_signal.py:close_position_raw()` (v4.5.3 平仓信号自动跟平) - `signal_inbox.py` 不直接调 raw REST, 但通过 `process_signal.py` 触发 ## 老的错版 (在 references/okx-rest-fallback.md) ```python # ❌ 这种写法会一直 Invalid Sign ts = time.strftime('%Y-%m-%dT%H:%M:%S.000Z', time.gmtime()) msg = ts + 'GET' + path + (json.dumps(params) if params else '') ``` **下次 session 看老 reference 复制会再次踩坑**。如果改 okx-rest-fallback.md, 用上面"正确实现"替换那个函数。 ## 给下次 session 的指令 任何 raw REST 调用 OKX 时: 1. **复制上面的"正确实现"**, 不要复制 `references/okx-rest-fallback.md` 里的版本 2. 跑通后再封装成函数, 别一边写一边 debug 签名 3. 看到 `Invalid Sign` 第一反应 = 检查 `?` 前缀和字典序