Python调用OKX Web API实战:签名认证、现货与杠杆交易及历史数据存储
2026/9/15 23:29:13 网站建设 项目流程

简介:面向加密货币量化交易与自动化脚本开发者的 OKEx Web API 调用应用资源包,围绕杠杆交易、现货交易、历史记录与历史数据获取等常见场景,提供适合初、中级 Python 开发者直接参考和改写的脚本集合。资源共 12 个文件,全部为 Python 脚本,压缩包仅 11KB,代码结构精简,覆盖现货、合约、杠杆、账户、WebSocket 等接口封装,可帮助快速理解 OKEx API 的签名、请求与数据处理流程。目前已有 187 人浏览学习,说明其对同类交易接口开发有一定参考价值。通过该资源,读者可以获得一套从工具函数、常量定义到各业务模块调用的完整脚本框架,既能用于交易策略验证、历史 K 线与成交数据获取,也能作为后续扩展多交易所 API 对接的基础模板。整体来看,资源具象地展示了 Python 在加密货币交易自动化中的应用方式,适合希望快速上手真实交易所接口的开发者。

1. 先从盘口 API 说起:为什么现货、杠杆、历史记录共用一套签名逻辑

如果你同时接过几个交易平台的接口,会发现 OKX 比较特殊:它把现货、杠杆、合约、历史数据全部收进同一套 REST API(v5 版本),路径前缀统一是/api/v5/,连签名算法都不分模块。换句话说,标题里那个压缩包无论里面装了什么,最终落地时第一件事必然是搭一个带签名能力的 HTTP 请求层,然后所有业务——下单、撤单、查持仓、拉 K 线——都只是在这个请求层上换路径和参数。

我见过不少人在这一步走偏:现货用一个封装库,杠杆又单独拉一个 SDK,历史数据再自己拼请求,结果三套代码里维护了三份时间戳处理和签名逻辑,一旦某个接口要求调整签名内容,改到崩溃。其实 OKX 的 REST API 设计得很统一,核心就三件事:构造请求头、拼参数、解析响应。把这三件事收敛成一层,后面无论做现货还是杠杆,每加一个接口只需要十几行代码。下面直接从签名层开始,这是所有调用的地基,也是 401 报错最集中的地方。

2. 用 Python 搭出 OKX Web API 的公共调用层:签名、时间戳、请求头

2.1 REST API 的公共端点和前置条件

OKX v5 API 的基地址是https://www.okx.com,私有接口(下单、查账户、撤单)需要在请求头里带上三样东西:OK-ACCESS-KEY(API Key)、OK-ACCESS-PASSPHRASE(创建 API Key 时设置的密码短语)、OK-ACCESS-SIGN(签名值),外加一个OK-ACCESS-TIMESTAMP。公共接口(K 线、深度、交易品种信息)不需要签名,但建议也走同一个请求封装,这样控制超时和重试时只要改一处。

注意,创建 API Key 时权限要勾选“读取”和“交易”,如果只勾了读取,后面发单会直接提示权限不足。另外,OKX 的 API Key 在创建后会展示一次 Secret Key,之后不再显示,丢了只能重新生成。

2.2 HMAC-SHA256 签名的 Python 实现

签名规则是:将请求时间戳 + 请求方法(大写) + 请求路径 + 请求体拼成一个字符串,用 Secret Key 做 HMAC-SHA256 计算,再把结果转 Base64。这里有一个容易踩的坑:请求体为空时也要拼一个空字符串,而且时间戳必须是 ISO 格式(带毫秒),不是 Unix 时间戳(秒)。

import base64 import hashlib import hmac import time from datetime import datetime, timezone import requests API_KEY = "your_api_key" SECRET_KEY = "your_secret_key" PASSPHRASE = "your_passphrase" BASE_URL = "https://www.okx.com" def build_sign(timestamp: str, method: str, request_path: str, body: str = "") -> str: """构造 OKX API 签名""" # 规则:时间戳 + 方法 + 路径 + 请求体(按此顺序拼接,缺一不可) message = timestamp + method.upper() + request_path + body mac = hmac.new( SECRET_KEY.encode("utf-8"), message.encode("utf-8"), digestmod=hashlib.sha256, ) return base64.b64encode(mac.digest()).decode("utf-8") def get_timestamp() -> str: """OKX 要求毫秒级 ISO 时间戳,例如 2024-01-01T00:00:00.000Z""" now = datetime.now(timezone.utc) return now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" def request(method: str, path: str, body: dict = None): """带签名的统一请求封装""" body = body or {} body_str = "" if method.upper() in ("POST", "PUT", "PATCH"): import json as _json body_str = _json.dumps(body) # 请求体序列化成 JSON 字符串,签名用 timestamp = get_timestamp() sign = build_sign(timestamp, method, path, body_str) headers = { "OK-ACCESS-KEY": API_KEY, "OK-ACCESS-SIGN": sign, "OK-ACCESS-TIMESTAMP": timestamp, "OK-ACCESS-PASSPHRASE": PASSPHRASE, "Content-Type": "application/json", } url = BASE_URL + path if method.upper() == "GET": resp = requests.get(url, headers=headers, params=body, timeout=10) else: resp = requests.post(url, headers=headers, data=body_str, timeout=10) return resp.json()

代码里有几个地方要特别注意。get_timestamp()截取毫秒的逻辑是:strftime默认会输出六位微秒,用[:-3]截成三位毫秒。很多人直接int(time.time())转成秒级时间戳,签名计算时 OKX 拿到的时间和你本地偏差超过 30 秒就会直接拒掉。另外,请求体只对POST/PUT/PATCH做序列化,不是dumps({})之后拼到 message 里——空请求体就是一个空字符串,这个细节直接决定签名是否一致。request()里 GET 请求把参数放在params,POST 放在data,OKX 对 GET 的查询参数只按原始字符串参与签名,所以 queries 的顺序不能随意调整,最好在调用时把参数按字母序排好。

2.3 时间戳偏差与常见 401 排查顺序

签名报 401 时的排查顺序不是先去看 KEY 有没有写错,而是先核对时间戳。本地时钟和服务器相差超过 30 秒是最高发的错误,尤其是运行在云服务器上的程序,系统时间漂移很常见。我见过一个上线一个月没出问题的脚本,某天批量 401,最后发现是宿主机 NTP 服务停了。应对办法是启动时用公共接口拿一次服务器时间,算好偏移量再参与签名:

def get_server_time_offset() -> float: """用公共接口获取 OKX 服务器时间,计算本地偏移(单位:秒)""" resp = requests.get(BASE_URL + "/api/v5/public/time", timeout=5).json() # 返回格式:{"code":"0","data":[{"ts":"1710000000000"}]},ts 是毫秒级 server_ms = int(resp["data"][0]["ts"]) server_sec = server_ms / 1000.0 return server_sec - time.time() OFFSET = get_server_time_offset() def get_timestamp_with_offset() -> str: """带偏移量的时间戳,避免本地时钟漂移导致签名失败""" now = datetime.now(timezone.utc).timestamp() + OFFSET dt = datetime.fromtimestamp(now, tz=timezone.utc) return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

用偏移量修正后,把request()里的get_timestamp()换成get_timestamp_with_offset()即可。注意public/time是极少数不需要签名的标准接口,返回的ts是毫秒级 Unix 时间戳。此排查顺序排查完时间戳,再检查是否复制了多余的换行或空格进SECRET_KEY,最后才是权限配置问题。

3. 现货交易从下单到撤单:最小闭环的 Python 实现

3.1 下单接口的参数位点与代码示例

现货下单走POST /api/v5/trade/order,核心参数有instId(交易对,如BTC-USDT)、tdMode(现货固定为cash)、sidebuysell)、ordTypemarket市价单、limit限价单)、sz(数量)和px(价格,市价单不传)。有一个容易混淆的地方:市价单里sz的含义取决于买卖方向——买单时sz代表要花多少计价币(如 USDT),卖单时sz才代表卖出多少基础币(如 BTC)。限价单则无论买卖,sz都是基础币数量。

def place_order(inst_id: str, side: str, ord_type: str, sz: str, px: str = "", td_mode: str = "cash"): """现货下单,返回订单 ID""" body = { "instId": inst_id, "tdMode": td_mode, "side": side, "ordType": ord_type, "sz": sz, } if px: body["px"] = px resp = request("POST", "/api/v5/trade/order", body) if resp["code"] != "0": raise RuntimeError(f"下单失败: {resp['data'][0]['sMsg']}") return resp["data"][0]["ordId"]

szpx都用字符串而不是浮点数,这是 OKX 的硬性要求——浮点3.6可能被序列化成3.6000000000000001,导致交易所拒绝或产生精度异常。另一个值得关注的是tdMode,现货固定传cash,这个参数在杠杆交易里会变成cross(全仓)或isolated(逐仓),如果你把订单模板复用过去,这个字段是从现货代码改到杠杆时最常漏掉的地方。返回体里data[0]除了ordId还有clOrdId,这是客户端自定义订单 ID,建议每次都传一个唯一值,方便后续对账和防重。

3.2 查询持仓与可用余额

现货下单后需要确认是否成交。查询订单状态用GET /api/v5/trade/order?instId=BTC-USDT&ordId=xxx,返回的state字段值filled表示完全成交,partially_filled是部分成交,live是挂单中。如果只关心账户里还剩多少钱,用GET /api/v5/account/balance,它会返回所有币种的可用余额和冻结余额。

def get_balance(ccy: str = "USDT"): """查询指定币种的可用余额,返回字符串金额""" resp = request("GET", "/api/v5/account/balance") for detail in resp["data"][0]["details"]: if detail["ccy"] == ccy: return detail["availBal"] return "0"

这里有个细节:availBal是可用余额,frozenBal是冻结余额。冻结余额的出现通常有两种情况——挂单未成交占用了本金,或者开了杠杆仓位后占用了保证金。很多人在下单后立刻查余额发现“钱没少”,其实是availBal没变,变的是frozenBal,这个字段一定要和订单状态串起来看,别只看一个值。

3.3 撤单与订单状态判断

撤单接口是POST /api/v5/trade/cancel-order,参数只需要instIdordId。撤单只有“挂单中”的状态才能撤销,已成交的订单会返回错误码51401,意思是“订单已无法撤销”。所以安全的流程是先查一次订单状态,再决定是否调用撤单,而不是无脑撤。

def cancel_order(inst_id: str, ord_id: str): """撤销挂单""" body = {"instId": inst_id, "ordId": ord_id} resp = request("POST", "/api/v5/trade/cancel-order", body) if resp["code"] != "0": # 已有成交的订单撤不了,状态码 51401 直接忽略 if resp["data"][0]["sCode"] == "51401": print("订单已成交,无需撤单") return False raise RuntimeError(f"撤单失败: {resp['data'][0]['sMsg']}") return True

注意返回结构里code是顶层状态,data[0].sCode是每个订单的具体状态码,两者分开判断。同时撤多个订单时,u/cancel-order支持批量,参数改成[{...}, {...}]数组形式,一个请求最多撤 20 个。这里预留一个思考:撤单之后订单可能在我们发出请求的前一毫秒刚好成交,这种边界场景用“客户端自定义订单 ID + 查询最终状态”才能兜住,而不是相信撤单响应里的提示。

4. 杠杆交易的参数差异:跨币种保证金与逐仓怎么选

4.1 杠杆与现货在 API 层唯一的本质区别

杠杆交易与现货在 API 层面的差异只体现在三个字段上:tdMode不再是cash,而是cross(全仓)或isolated(逐仓);可选的posSide用来区分多空方向(longshort);请求下单时如果不传posSide,交易所会默认按你当前持仓方向来执行,没有持仓时默认开多。可如果同一个交易对你既有空单又有多单,就必须要传。这个字段是杠杆下单报错的高频原因——报posSide不匹配,十有八九是前一笔持仓方向和这一笔相反。

我想强调一个更根本的区别:现货交易里sz是“你实际有的币的数量”,杠杆交易里sz是在杠杆倍数作用后的“持仓规模”。比如你有 100 USDT,开 3 倍杠杆做多,下单数量按 300 USDT 等值的 BTC 来计算,而不是 100。这个数量本质上是名义持仓量,不是你的本金。如果对这一点没有感知,容易在后面的保证金计算上翻车。

4.2 设置杠杆倍数的接口与风险边界

下单前一般先调用POST /api/v5/account/set-leverage设置杠杆倍数。参数包括instIdlever(倍数,字符串)、mgnModecrossisolated),全仓模式下可选的posSide默认是net(净持仓),逐仓必须明确传longshort

def set_leverage(inst_id: str, lever: str, mgn_mode: str = "cross", pos_side: str = ""): """设置杠杆倍数,mgn_mode=cross 全仓,isolated 逐仓""" body = {"instId": inst_id, "lever": lever, "mgnMode": mgn_mode} if pos_side: body["posSide"] = pos_side resp = request("POST", "/api/v5/account/set-leverage", body) if resp["code"] != "0": raise RuntimeError(f"设置杠杆失败: {resp['data'][0]['sMsg']}") return resp["data"]

杠杆倍数的上限不是统一的,不同币种、不同保证金模式有各自的限制。比如 BTC-USDT 逐仓最高可能到 100 倍,但某些小币种可能只到 20 倍。设置时会直接返回leverage的实际生效值,建议代码里回读并打印出来,不要假设输入多少就是多少。全仓和逐仓的杠杆是分开设置的——同一交易对,全仓设置了 5 倍,逐仓还是默认的 1 倍,这个不能共享记忆,必须分别调用。

4.3 杠杆订单的爆仓价与维持保证金查询

杠杆仓位最关心的数据是爆仓价和维持保证金率。查询接口是GET /api/v5/public/position-risk?instId=BTC-USDT,也可以直接查GET /api/v5/account/positions看自己的实时仓位,后者返回的数据里带有liqPx(预估强平价)、pos(仓位张数)、availPos(可平仓位)、margin(保证金)等字段。

def get_positions(inst_id: str = ""): """查询持仓信息,可指定交易对""" path = "/api/v5/account/positions" if inst_id: path += f"?instId={inst_id}" resp = request("GET", path) positions = resp["data"] for p in positions: print(f"{p['instId']} 仓位:{p['pos']} 保证金:{p['margin']} 预估爆仓价:{p['liqPx']}") return positions

这里的liqPx(爆仓价)是交易所根据当前仓位、保证金、维持保证金率实时估算的,会随价格波动和资金费率变化。逐仓模式的爆仓价只影响当前仓位,全仓模式下如果账户里还有其他仓位,爆仓价会联动整个保证金池子,强平顺序也完全不同。所以做自动化交易时,风控逻辑最好不要依赖liqPx一个值,而是结合marginRatio(保证金率)来监控,保证金率越接近 100%,离强平越近。

5. 历史记录与历史数据:K 线分页、成交明细去重、增量归档

5.1 历史 K 线的分页参数与循环拉取

历史数据集中在行情类接口,不需要签名。最常用的GET /api/v5/market/history-candles,一次最多返回 100 根 K 线,超过就要用分页参数afterbefore往前翻。after是请求此时间戳之前的 K 线,before是请求此时间戳之后的。注意 OKX 的分页参数方向和其他交易所相反,这里一定要用after来倒退着拉历史。K 线参数表如下:

参数类型说明
instIdstring交易对,如BTC-USDT
barstringK 线周期,1m/15m/1H/1D
afterstring请求此时间戳(毫秒)之前的 K 线
beforestring请求此时间戳之后的 K 线
limitstring单次返回数量,最大 100
返回数据array每根 K 线是数组:[ts, o, h, l, c, vol, volCcy]
def fetch_candles(inst_id: str, bar: str = "1H", limit: int = 100, after: str = ""): """拉取 K 线,返回原始数组列表""" params = {"instId": inst_id, "bar": bar, "limit": limit} if after: params["after"] = after resp = requests.get(BASE_URL + "/api/v5/market/history-candles", params=params, timeout=10).json() if resp["code"] != "0": raise RuntimeError(f"K线拉取失败: {resp['msg']}") return resp["data"] # 每条数据后一个元素是时间戳,之前的元素依次是开高低收量 # 循环拉取最近 1000 根 15 分钟线 all_data = [] last_ts = "" for _ in range(10): batch = fetch_candles("BTC-USDT", bar="15m", limit=100, after=last_ts) if not batch: break all_data.extend(batch) last_ts = batch[-1][0] # 最后一根K线的时间戳作为下次的 after

注意返回的 K 线数据是按时间倒序排列的,最新的在最前面。batch[-1][0]是这批数据里最早的一根,用它作为下一次请求的after值来往前翻。另外,K 线数组的元素类型全部是字符串——开盘价、最高价、最低价、收盘价、成交量——在写入数据库前如果是浮点运算,需要先float()转一下,字符串类型的数值直接做数学运算会得到一个 TypeError。

5.2 账户成交明细与账单流水的区别

历史记录分成两类:一类是市场数据,K 线、成交记录,代表市场行为;另一类是账户资产数据,成交明细、充值提现、资金划转,代表你的账户行为,这类接口必须带签名。GET /api/v5/trade/fills返回每笔成交的订单,GET /api/v5/account/bills返回的是账户资金流水,后者包含更多类型(如资金费率、强平扣款、划转),不仅仅是成交。

def fetch_fills(inst_id: str = "", begin: str = "", end: str = "", limit: int = 100): """查询最近成交明细""" params = {"limit": limit} if inst_id: params["instId"] = inst_id if begin: params["begin"] = begin if end: params["end"] = end resp = request("GET", "/api/v5/trade/fills", params) return resp["data"]

fills接口里的每条记录包含tradeIdordIdsidepxszfeets等字段,fee是手续费(正数为收取,负数为返还),ts是成交时间戳。账单流水和成交明细之间是 1 对多关系——一笔订单可能分多笔成交,产生多条fill记录,但账单流水里是按订单聚合后的记录。如果要做收益统计,建议以fills为主表,按tradeId做幂等去重,因为重复拉取同一个时间窗口时,个别成交记录可能因为延迟被更新。

5.3 SQLite 增量存储与幂等去重方案

历史数据落地最轻量可靠的方案是 SQLite 单文件,不需要额外起服务。核心设计是给fills表建tradeId的唯一索引,candles表建(instId, bar, ts)的联合唯一索引,这样重复插入同一条数据时会自动冲突,实现天然的幂等。

import sqlite3 conn = sqlite3.connect("okx_data.db") conn.execute(""" CREATE TABLE IF NOT EXISTS candles ( inst_id TEXT NOT NULL, bar TEXT NOT NULL, ts INTEGER NOT NULL, o REAL, h REAL, l REAL, c REAL, vol REAL, PRIMARY KEY (inst_id, bar, ts) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS fills ( trade_id TEXT PRIMARY KEY, inst_id TEXT NOT NULL, ord_id TEXT, side TEXT, px REAL, sz REAL, fee REAL, ts INTEGER ) """) conn.commit() def save_candles(inst_id: str, bar: str, data: list): """增量写入 K 线,冲突即跳过""" sql = """ INSERT OR IGNORE INTO candles (inst_id, bar, ts, o, h, l, c, vol) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """ for row in data: ts = int(row[0]) conn.execute(sql, (inst_id, bar, ts, float(row[1]), float(row[2]), float(row[3]), float(row[4]), float(row[5]))) conn.commit()

INSERT OR IGNORE在有唯一约束的前提下,遇到重复时间戳或重复tradeId会自动跳过,不需要先查再插,效率和处理逻辑都更简洁。增量拉取的策略是:每次拉完记录当前区间最大时间戳,下一次只请求这个时间点之后的数据。这样做的好处是断点续传时只补缺口,不需要整窗重拉。当然,SQLite 只适合个人或小体量项目,数据规模到了千万级以后,建议切到 ClickHouse 或 TimescaleDB,但表结构和主键设计思路可以直接迁移。

6. 限频与重试:429 之后的指数退避和幂等保护

OKX v5 API 的限频是按请求路径来分组的,不同接口有单独的规则。最常见的报错是 HTTP 429,响应头里会带重试时间,但程序不能依赖人去看,必须自动处理。我一般会在请求封装里实现三层退避:第一次 429 后等 1 秒重试,第二次等 2 秒,第三次等 4 秒,最多重试 4 次。如果 4 次仍然失败,就放弃本次请求并告警,而不是无限重试把账户请求额度彻底打满。

import time import random def request_with_retry(method: str, path: str, body: dict = None, max_retries: int = 4): """带退避重试的请求封装,针对 HTTP 429 做指数退避""" delay = 1 for attempt in range(max_retries): try: resp = request(method, path, body) except requests.exceptions.Timeout: # 超时不代表请求失败,可能是响应慢,重试时要注意幂等 pass else: if resp.get("code") == "0": return resp # 返回错误码但可能是业务错误,如参数不对,不重试直接抛错 if resp["code"] not in ("50011", "50013"): return resp if attempt < max_retries - 1: sleep_time = delay * (2 ** attempt) + random.uniform(0, 0.5) time.sleep(sleep_time) raise RuntimeError(f"请求重试多次仍失败: {path}")

指数退避里加一个 0 到 0.5 秒的随机抖动,是为了避免多个脚本实例同时重试,形成共振把服务器打挂。这里处理了一个更隐蔽的问题:5001150013是 OKX 的限频错误码,它们以code字段的形式出现在响应 JSON 里,而不是 HTTP 状态码。所以只判断 HTTP 429 是不够的,必须同时捕获业务错误码。另外,对于限价单下单这种操作,重试时一定要给自己一个唯一 ID:

cl_oid = f"auto_{int(time.time() * 1000)}" body = { "instId": "BTC-USDT", "tdMode": "cash", "side": "buy", "ordType": "limit", "px": "50000", "sz": "0.001", "clOrdId": cl_oid, # 重试时用同一个 clOrdId,交易所会拒绝重复单 }

clOrdId是天然的幂等键。同样的clOrdId在订单未成交前再次提交,交易所会拒绝或返回原单,不会重复下单;如果第一次请求超时了但实际已下单成功,重试不会生成第二笔订单,这是做自动化交易最值得培养的习惯。刚入门的开发者最容易忽略这层设计,直接在循环里调下单接口,产生大量重复单。

最后留一个验证思路:手动跑一个拉历史 K 线和查持仓的小脚本,跑满 5 分钟观察请求日志里的状态码分布——全部是 200 正常,偶尔 429 且重试生效,就可以认为限频处理合格。如果日志里 429 出现频率远高于预期,需要压缩请求频率,而不是扩大重试次数,因为重试本身也在消耗配额。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询