Python调用OKX Web API实战:签名认证、现货与杠杆交易及历史数据存储
简介面向加密货币量化交易与自动化脚本开发者的 OKEx Web API 调用应用资源包围绕杠杆交易、现货交易、历史记录与历史数据获取等常见场景提供适合初、中级 Python 开发者直接参考和改写的脚本集合。资源共 12 个文件全部为 Python 脚本压缩包仅 11KB代码结构精简覆盖现货、合约、杠杆、账户、WebSocket 等接口封装可帮助快速理解 OKEx API 的签名、请求与数据处理流程。目前已有 187 人浏览学习说明其对同类交易接口开发有一定参考价值。通过该资源读者可以获得一套从工具函数、常量定义到各业务模块调用的完整脚本框架既能用于交易策略验证、历史 K 线与成交数据获取也能作为后续扩展多交易所 API 对接的基础模板。整体来看资源具象地展示了 Python 在加密货币交易自动化中的应用方式适合希望快速上手真实交易所接口的开发者。1. 先从盘口 API 说起为什么现货、杠杆、历史记录共用一套签名逻辑如果你同时接过几个交易平台的接口会发现 OKX 比较特殊它把现货、杠杆、合约、历史数据全部收进同一套 REST APIv5 版本路径前缀统一是/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-KEYAPI 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), digestmodhashlib.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, headersheaders, paramsbody, timeout10) else: resp requests.post(url, headersheaders, databody_str, timeout10) return resp.json()代码里有几个地方要特别注意。get_timestamp()截取毫秒的逻辑是strftime默认会输出六位微秒用[:-3]截成三位毫秒。很多人直接int(time.time())转成秒级时间戳签名计算时 OKX 拿到的时间和你本地偏差超过 30 秒就会直接拒掉。另外请求体只对POST/PUT/PATCH做序列化不是dumps({})之后拼到 message 里——空请求体就是一个空字符串这个细节直接决定签名是否一致。request()里 GET 请求把参数放在paramsPOST 放在dataOKX 对 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, timeout5).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, tztimezone.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、sidebuy或sell、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]sz和px都用字符串而不是浮点数这是 OKX 的硬性要求——浮点3.6可能被序列化成3.6000000000000001导致交易所拒绝或产生精度异常。另一个值得关注的是tdMode现货固定传cash这个参数在杠杆交易里会变成cross全仓或isolated逐仓如果你把订单模板复用过去这个字段是从现货代码改到杠杆时最常漏掉的地方。返回体里data[0]除了ordId还有clOrdId这是客户端自定义订单 ID建议每次都传一个唯一值方便后续对账和防重。3.2 查询持仓与可用余额现货下单后需要确认是否成交。查询订单状态用GET /api/v5/trade/order?instIdBTC-USDTordIdxxx返回的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参数只需要instId和ordId。撤单只有“挂单中”的状态才能撤销已成交的订单会返回错误码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用来区分多空方向long或short请求下单时如果不传posSide交易所会默认按你当前持仓方向来执行没有持仓时默认开多。可如果同一个交易对你既有空单又有多单就必须要传。这个字段是杠杆下单报错的高频原因——报posSide不匹配十有八九是前一笔持仓方向和这一笔相反。我想强调一个更根本的区别现货交易里sz是“你实际有的币的数量”杠杆交易里sz是在杠杆倍数作用后的“持仓规模”。比如你有 100 USDT开 3 倍杠杆做多下单数量按 300 USDT 等值的 BTC 来计算而不是 100。这个数量本质上是名义持仓量不是你的本金。如果对这一点没有感知容易在后面的保证金计算上翻车。4.2 设置杠杆倍数的接口与风险边界下单前一般先调用POST /api/v5/account/set-leverage设置杠杆倍数。参数包括instId、lever倍数字符串、mgnModecross或isolated全仓模式下可选的posSide默认是net净持仓逐仓必须明确传long或short。def set_leverage(inst_id: str, lever: str, mgn_mode: str cross, pos_side: str ): 设置杠杆倍数mgn_modecross 全仓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?instIdBTC-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 线超过就要用分页参数after和before往前翻。after是请求此时间戳之前的 K 线before是请求此时间戳之后的。注意 OKX 的分页参数方向和其他交易所相反这里一定要用after来倒退着拉历史。K 线参数表如下参数类型说明instIdstring交易对如BTC-USDTbarstringK 线周期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, paramsparams, timeout10).json() if resp[code] ! 0: raise RuntimeError(fK线拉取失败: {resp[msg]}) return resp[data] # 每条数据后一个元素是时间戳之前的元素依次是开高低收量 # 循环拉取最近 1000 根 15 分钟线 all_data [] last_ts for _ in range(10): batch fetch_candles(BTC-USDT, bar15m, limit100, afterlast_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接口里的每条记录包含tradeId、ordId、side、px、sz、fee、ts等字段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 秒的随机抖动是为了避免多个脚本实例同时重试形成共振把服务器打挂。这里处理了一个更隐蔽的问题50011和50013是 OKX 的限频错误码它们以code字段的形式出现在响应 JSON 里而不是 HTTP 状态码。所以只判断 HTTP 429 是不够的必须同时捕获业务错误码。另外对于限价单下单这种操作重试时一定要给自己一个唯一 IDcl_oid fauto_{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 出现频率远高于预期需要压缩请求频率而不是扩大重试次数因为重试本身也在消耗配额。本文还有配套的精品资源点击获取