@dshtrading/connector-okx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +75 -0
- package/lib/dataplane.d.ts +7 -0
- package/lib/dataplane.js +41 -0
- package/lib/index.d.ts +227 -0
- package/lib/index.js +869 -0
- package/lib/rest.d.ts +238 -0
- package/lib/rest.js +716 -0
- package/package.json +41 -0
package/lib/rest.js
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
//#region src/rest.ts
|
|
3
|
+
/**
|
|
4
|
+
* OKX REST 客户端(dsh-trading okx 切片 R1-R3)。
|
|
5
|
+
*
|
|
6
|
+
* 独立于插件 glue:仅依赖 @dshtrading/api 类型词汇 + node:crypto(HMAC-SHA256),
|
|
7
|
+
* 无 cordis/dsh-tools 运行时依赖;fetch 可注入,便于单测与脚本直接消费。
|
|
8
|
+
*
|
|
9
|
+
* 数据面(docs/okx-integration.md §2/§3,2026-08-31 调研):
|
|
10
|
+
* - REST base = https://openapi.okx.com(生产与模拟盘同 host;demo 完全靠
|
|
11
|
+
* `x-simulated-trading: 1` 请求头区分,REST 层无独立域名)。
|
|
12
|
+
* - 限频口径为「每 2 秒 N 次」,本客户端不主动限速,只做 10s 超时与结构化错误映射。
|
|
13
|
+
*
|
|
14
|
+
* 签名(调研 §1):
|
|
15
|
+
* - prehash = timestamp + METHOD + requestPath + body(字符串直连);
|
|
16
|
+
* - OK-ACCESS-SIGN = Base64(HMAC-SHA256(secret, prehash));
|
|
17
|
+
* - timestamp = UTC ISO 8601 毫秒精度(如 2020-12-08T09:08:57.715Z);
|
|
18
|
+
* - GET 的 query string 属于 requestPath(body 参与签名同理,JSON 原样);
|
|
19
|
+
* - 时差 >30s 即 50102:首个签名请求前先 GET /api/v5/public/time 对时并缓存偏移,
|
|
20
|
+
* 收到 50102 时重对时并重试一次。
|
|
21
|
+
*
|
|
22
|
+
* @module @dshtrading/connector-okx/rest
|
|
23
|
+
*/
|
|
24
|
+
/** api 包 TradingError 契约的运行时 Error 实现(connector-binance 同款)。 */
|
|
25
|
+
var TradingServiceError = class extends Error {
|
|
26
|
+
code;
|
|
27
|
+
constructor(code, message, cause) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "TradingServiceError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
if (cause !== void 0) this.cause = cause;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* 拼接签名 prehash:`timestamp + METHOD + requestPath + body`(调研 §1)。
|
|
36
|
+
* GET 的 query 必须已并入 requestPath;无 body 时省略(传 undefined)。
|
|
37
|
+
*/
|
|
38
|
+
function signaturePrehash(timestamp, method, requestPath, body) {
|
|
39
|
+
return timestamp + method + requestPath + (body ?? "");
|
|
40
|
+
}
|
|
41
|
+
/** OK-ACCESS-SIGN:Base64(HMAC-SHA256(secret, prehash))。 */
|
|
42
|
+
function signPayload(secret, prehash) {
|
|
43
|
+
return createHmac("sha256", secret).update(prehash, "utf8").digest("base64");
|
|
44
|
+
}
|
|
45
|
+
/** OK-ACCESS-TIMESTAMP:UTC ISO 8601 毫秒精度(Date.toISOString 即该形态)。 */
|
|
46
|
+
function isoTimestamp(epochMs) {
|
|
47
|
+
return new Date(epochMs).toISOString();
|
|
48
|
+
}
|
|
49
|
+
/** 四头 + 模拟盘头的构造(导出供单测断言头部集合)。 */
|
|
50
|
+
function buildAuthHeaders(auth, timestamp, method, requestPath, body) {
|
|
51
|
+
const headers = {
|
|
52
|
+
"OK-ACCESS-KEY": auth.credentials.key,
|
|
53
|
+
"OK-ACCESS-SIGN": signPayload(auth.credentials.secret, signaturePrehash(timestamp, method, requestPath, body)),
|
|
54
|
+
"OK-ACCESS-TIMESTAMP": timestamp,
|
|
55
|
+
"OK-ACCESS-PASSPHRASE": auth.credentials.passphrase
|
|
56
|
+
};
|
|
57
|
+
if (auth.simulated) headers["x-simulated-trading"] = "1";
|
|
58
|
+
return headers;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* api Interval(Binance 词汇)→ OKX bar 词汇。
|
|
62
|
+
*
|
|
63
|
+
* **1d → 1Dutc(口径裁决,回应调研待验证 #3)**:OKX `1D` 按 UTC+8 零点开盘
|
|
64
|
+
* (用户实证记忆:1D 日线边界对齐 UTC+8),而本仓 `Interval` 的 `1d` 语义继承自
|
|
65
|
+
* Binance = UTC 零点。crypto 24/7 交易、UTC 是跨所通用口径,取 `1Dutc` 才能与
|
|
66
|
+
* connector-binance 的日线对齐同一日界;`1D`(UTC+8)会导致同一标的跨连接器日 K
|
|
67
|
+
* 错位 8 小时,故不取。6h/12h/3d/1w/1M 同理取 utc 变体。
|
|
68
|
+
*
|
|
69
|
+
* **8h 无映射**:OKX bar 词汇没有 8 小时档(1m..4H、6Hutc、12Hutc、1Dutc、2Dutc、
|
|
70
|
+
* 3Dutc、1Wutc、1Mutc、3Mutc),8h 请求返回 TRADING_UNSUPPORTED_INTERVAL。
|
|
71
|
+
*/
|
|
72
|
+
const BAR_MAP = {
|
|
73
|
+
"1m": "1m",
|
|
74
|
+
"3m": "3m",
|
|
75
|
+
"5m": "5m",
|
|
76
|
+
"15m": "15m",
|
|
77
|
+
"30m": "30m",
|
|
78
|
+
"1h": "1H",
|
|
79
|
+
"2h": "2H",
|
|
80
|
+
"4h": "4H",
|
|
81
|
+
"6h": "6Hutc",
|
|
82
|
+
"12h": "12Hutc",
|
|
83
|
+
"1d": "1Dutc",
|
|
84
|
+
"3d": "3Dutc",
|
|
85
|
+
"1w": "1Wutc",
|
|
86
|
+
"1M": "1Mutc"
|
|
87
|
+
};
|
|
88
|
+
/** 支持的 interval 词汇(工具 enum 用;8h 刻意缺席,见 BAR_MAP 注释)。 */
|
|
89
|
+
const OKX_INTERVAL_VOCABULARY = Object.keys(BAR_MAP);
|
|
90
|
+
/** bar → 毫秒时长(closeTime 补算用;UTC 变体与本地变体时长相同)。 */
|
|
91
|
+
function barDurationMs(bar) {
|
|
92
|
+
const m = /^(\d+)(m|H|D|W|M)(?:utc)?$/.exec(bar);
|
|
93
|
+
if (!m) throw new TradingServiceError("TRADING_UNSUPPORTED_INTERVAL", `OKX: unknown bar ${bar}`);
|
|
94
|
+
const amount = Number(m[1]);
|
|
95
|
+
switch (m[2]) {
|
|
96
|
+
case "m": return amount * 6e4;
|
|
97
|
+
case "H": return amount * 36e5;
|
|
98
|
+
case "D": return amount * 864e5;
|
|
99
|
+
case "W": return amount * 7 * 864e5;
|
|
100
|
+
case "M": return amount * 30 * 864e5;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Interval → OKX bar;不支持(含 8h)抛 TRADING_UNSUPPORTED_INTERVAL。 */
|
|
104
|
+
function toBar(interval) {
|
|
105
|
+
const bar = BAR_MAP[interval];
|
|
106
|
+
if (bar === void 0) throw new TradingServiceError("TRADING_UNSUPPORTED_INTERVAL", `OKX klines: unsupported interval ${String(interval)} — OKX bar vocabulary has no 8h candle; supported: ${OKX_INTERVAL_VOCABULARY.join("/")}`);
|
|
107
|
+
return bar;
|
|
108
|
+
}
|
|
109
|
+
const DEFAULT_BASE_URL = "https://openapi.okx.com";
|
|
110
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
111
|
+
/**
|
|
112
|
+
* OKX code → api 词汇已知映射(调研 §5)。51000/51400/51603 及其余 5xxxx 走
|
|
113
|
+
* TRADING_EXCHANGE_ERROR 兜底;HTTP 层 5xx → TRADING_NETWORK、429 → RATE_LIMITED、
|
|
114
|
+
* 401/403 → AUTH_FAILED(envelope code 命中本表时优先于 HTTP 状态,更具体)。
|
|
115
|
+
*/
|
|
116
|
+
const OKX_CODE_MAP = /* @__PURE__ */ new Map([
|
|
117
|
+
["50011", "TRADING_RATE_LIMITED"],
|
|
118
|
+
["50013", "TRADING_RATE_LIMITED"],
|
|
119
|
+
["50103", "TRADING_CREDENTIALS_MISSING"],
|
|
120
|
+
["50104", "TRADING_CREDENTIALS_MISSING"],
|
|
121
|
+
["50111", "TRADING_CREDENTIALS_MISSING"],
|
|
122
|
+
["50105", "TRADING_AUTH_FAILED"],
|
|
123
|
+
["50102", "TRADING_AUTH_FAILED"],
|
|
124
|
+
["50112", "TRADING_AUTH_FAILED"],
|
|
125
|
+
["50113", "TRADING_AUTH_FAILED"],
|
|
126
|
+
["50114", "TRADING_AUTH_FAILED"],
|
|
127
|
+
["50110", "TRADING_AUTH_FAILED"],
|
|
128
|
+
["51008", "TRADING_INSUFFICIENT_BALANCE"]
|
|
129
|
+
]);
|
|
130
|
+
function num(value) {
|
|
131
|
+
if (value === "") return void 0;
|
|
132
|
+
const n = typeof value === "string" ? Number(value) : typeof value === "number" ? value : NaN;
|
|
133
|
+
return Number.isFinite(n) ? n : void 0;
|
|
134
|
+
}
|
|
135
|
+
function str(value) {
|
|
136
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
137
|
+
}
|
|
138
|
+
/** envelope/HTTP → TradingServiceError(msg/sCode 原文进 message,envelope 原样进 cause)。
|
|
139
|
+
* body 已由调用方解析时直接传入(Response 体只能读一次,重复 json() 会拿到 undefined)。 */
|
|
140
|
+
async function envelopeToError(res, path, parsedBody) {
|
|
141
|
+
let body = parsedBody;
|
|
142
|
+
if (body === void 0) try {
|
|
143
|
+
body = await res.json();
|
|
144
|
+
} catch {
|
|
145
|
+
body = void 0;
|
|
146
|
+
}
|
|
147
|
+
const env = body ?? {};
|
|
148
|
+
const code = str(env.code);
|
|
149
|
+
const msg = str(env.msg) ?? "";
|
|
150
|
+
const first = Array.isArray(env.data) ? env.data[0] : void 0;
|
|
151
|
+
const sCode = str(first?.sCode);
|
|
152
|
+
const sMsg = str(first?.sMsg);
|
|
153
|
+
let tradingCode;
|
|
154
|
+
const meaningfulCode = code === "1" || code === "2" ? sCode !== void 0 && sCode !== "0" ? sCode : void 0 : code !== void 0 && code !== "0" ? code : sCode !== void 0 && sCode !== "0" ? sCode : void 0;
|
|
155
|
+
if (meaningfulCode !== void 0 && OKX_CODE_MAP.has(meaningfulCode)) tradingCode = OKX_CODE_MAP.get(meaningfulCode);
|
|
156
|
+
else if (meaningfulCode !== void 0) tradingCode = "TRADING_EXCHANGE_ERROR";
|
|
157
|
+
else if (res.status === 429 || res.status === 418) tradingCode = "TRADING_RATE_LIMITED";
|
|
158
|
+
else if (res.status === 401 || res.status === 403) tradingCode = "TRADING_AUTH_FAILED";
|
|
159
|
+
else if (res.status >= 500) tradingCode = "TRADING_NETWORK";
|
|
160
|
+
else tradingCode = "TRADING_EXCHANGE_ERROR";
|
|
161
|
+
const detail = [
|
|
162
|
+
res.status,
|
|
163
|
+
code !== void 0 && code !== "0" ? `code=${code}` : void 0,
|
|
164
|
+
sCode !== void 0 && sCode !== "0" ? `sCode=${sCode}` : void 0,
|
|
165
|
+
msg || sMsg || res.statusText
|
|
166
|
+
].filter((part) => part !== void 0 && part !== "").join(" ");
|
|
167
|
+
return new TradingServiceError(tradingCode, `OKX ${path}: ${detail}`, body);
|
|
168
|
+
}
|
|
169
|
+
/** 把 api 语义的 base 币数量换算成 OKX sz(本地精度校验:minSz/lotSz,省一次 51000 往返)。 */
|
|
170
|
+
function normalizeSize(instId, instrument, quantityCoins) {
|
|
171
|
+
const isSwap = instrument.instType === "SWAP";
|
|
172
|
+
const amountInExchangeUnit = isSwap ? quantityCoins / (instrument.ctVal ?? NaN) : quantityCoins;
|
|
173
|
+
if (!Number.isFinite(amountInExchangeUnit) || amountInExchangeUnit <= 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX ${instId}: quantity ${quantityCoins} does not convert to a positive exchange amount` + (isSwap ? ` (ctVal=${String(instrument.ctVal)})` : ""));
|
|
174
|
+
if (amountInExchangeUnit < instrument.minSz) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX ${instId}: quantity ${quantityCoins} coins = ${amountInExchangeUnit} ${isSwap ? "contracts" : "base units"} is below minSz ${instrument.minSz}`);
|
|
175
|
+
const step = instrument.lotSz;
|
|
176
|
+
const units = Math.floor(amountInExchangeUnit / step + 1e-9) * step;
|
|
177
|
+
if (units <= 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX ${instId}: quantity ${quantityCoins} coins rounds down to 0 at lotSz ${step}`);
|
|
178
|
+
const sz = trimNumber(units);
|
|
179
|
+
return isSwap ? { sz } : {
|
|
180
|
+
sz,
|
|
181
|
+
tgtCcy: "base_ccy"
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** 输出无尾随浮点噪声的数量字符串(最多 12 位有效小数)。 */
|
|
185
|
+
function trimNumber(n) {
|
|
186
|
+
const fixed = n.toFixed(12).replace(/0+$/, "").replace(/\.$/, "");
|
|
187
|
+
return fixed === "" ? "0" : fixed;
|
|
188
|
+
}
|
|
189
|
+
var OkxRestClient = class {
|
|
190
|
+
baseUrl;
|
|
191
|
+
timeoutMs;
|
|
192
|
+
fetchImpl;
|
|
193
|
+
clockSyncEnabled;
|
|
194
|
+
now;
|
|
195
|
+
/** 服务器偏移缓存(server - local,ms);null = 未对时。 */
|
|
196
|
+
clockOffsetMs;
|
|
197
|
+
constructor(options = {}) {
|
|
198
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
199
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
200
|
+
this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
201
|
+
this.clockSyncEnabled = options.clockSync ?? true;
|
|
202
|
+
this.now = options.now ?? (() => Date.now());
|
|
203
|
+
this.clockOffsetMs = options.clockOffsetMs ?? null;
|
|
204
|
+
}
|
|
205
|
+
/** 当前对时后的墙钟(ms)。clockOffsetMs 预置或已缓存时直接用;否则按需对时。 */
|
|
206
|
+
async timestampMs() {
|
|
207
|
+
if (this.clockOffsetMs !== null) return this.now() + this.clockOffsetMs;
|
|
208
|
+
if (!this.clockSyncEnabled) return this.now();
|
|
209
|
+
const serverTs = num((await this.request("/api/v5/public/time"))[0]?.ts);
|
|
210
|
+
this.clockOffsetMs = serverTs !== void 0 ? serverTs - this.now() : 0;
|
|
211
|
+
return this.now() + this.clockOffsetMs;
|
|
212
|
+
}
|
|
213
|
+
/** 使缓存偏移失效(50102 重试路径)。 */
|
|
214
|
+
invalidateClock() {
|
|
215
|
+
this.clockOffsetMs = null;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 统一请求:query 拼 URL 与签名 requestPath;签名头经 buildAuthHeaders;
|
|
219
|
+
* envelope code!=='0'(含 '1' 失败 / '2' 部分成功——本客户端只发单条操作)映射为
|
|
220
|
+
* 结构化错误;50102(时差)重对时重试一次。
|
|
221
|
+
*/
|
|
222
|
+
async request(path, options = {}, allowRetry = true) {
|
|
223
|
+
const method = options.method ?? "GET";
|
|
224
|
+
const query = options.query ? new URLSearchParams(options.query).toString() : "";
|
|
225
|
+
const requestPath = query ? `${path}?${query}` : path;
|
|
226
|
+
const target = `${this.baseUrl}${requestPath}`;
|
|
227
|
+
const headers = {};
|
|
228
|
+
if (options.body !== void 0) headers["Content-Type"] = "application/json";
|
|
229
|
+
if (options.auth !== void 0) {
|
|
230
|
+
const timestamp = isoTimestamp(await this.timestampMs());
|
|
231
|
+
Object.assign(headers, buildAuthHeaders(options.auth, timestamp, method, requestPath, options.body));
|
|
232
|
+
}
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
const timer = setTimeout(() => controller.abort(new DOMException(`request timed out after ${this.timeoutMs}ms`, "TimeoutError")), this.timeoutMs);
|
|
235
|
+
let res;
|
|
236
|
+
try {
|
|
237
|
+
res = await this.fetchImpl(target, {
|
|
238
|
+
method,
|
|
239
|
+
headers,
|
|
240
|
+
...options.body !== void 0 ? { body: options.body } : {},
|
|
241
|
+
signal: controller.signal
|
|
242
|
+
});
|
|
243
|
+
} catch (cause) {
|
|
244
|
+
const timedOut = controller.signal.aborted;
|
|
245
|
+
throw new TradingServiceError("TRADING_NETWORK", timedOut ? `OKX ${path}: request timed out after ${this.timeoutMs}ms` : `OKX ${path}: network error`, cause);
|
|
246
|
+
} finally {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return await this.processEnvelope(res, path);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (allowRetry && options.auth !== void 0 && error instanceof TradingServiceError && error.code === "TRADING_AUTH_FAILED" && /\b50102\b/.test(error.message)) {
|
|
253
|
+
this.invalidateClock();
|
|
254
|
+
return this.request(path, options, false);
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** envelope/HTTP 状态处理:code==='0' → data;单行 trade 端点的 sCode 非 0 → 失败。 */
|
|
260
|
+
async processEnvelope(res, path) {
|
|
261
|
+
if (!res.ok) throw await envelopeToError(res, path);
|
|
262
|
+
let parsed;
|
|
263
|
+
try {
|
|
264
|
+
parsed = await res.json();
|
|
265
|
+
} catch (cause) {
|
|
266
|
+
throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX ${path}: invalid JSON response`, cause);
|
|
267
|
+
}
|
|
268
|
+
const env = parsed;
|
|
269
|
+
if (str(env.code) !== "0" || !Array.isArray(env.data)) throw await envelopeToError(res, path, parsed);
|
|
270
|
+
const data = env.data;
|
|
271
|
+
const first = data[0];
|
|
272
|
+
const sCode = str(first?.sCode);
|
|
273
|
+
if (sCode !== void 0 && sCode !== "0") throw await envelopeToError(res, path, parsed);
|
|
274
|
+
return data;
|
|
275
|
+
}
|
|
276
|
+
/** 最新行情:GET /api/v5/market/ticker。 */
|
|
277
|
+
async getTicker(instId) {
|
|
278
|
+
const id = normalizeOkxSymbol(instId);
|
|
279
|
+
const d = (await this.request("/api/v5/market/ticker", { query: { instId: id } }))[0];
|
|
280
|
+
const price = num(d?.last);
|
|
281
|
+
if (price === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX ticker for ${id}: missing/invalid last price`);
|
|
282
|
+
const volume = id.endsWith("-SWAP") ? num(d?.volCcy24h) ?? num(d?.vol24h) : num(d?.vol24h);
|
|
283
|
+
const prevClose = num(d?.open24h) ?? num(d?.sodUtc0);
|
|
284
|
+
const changePercent = price !== void 0 && prevClose !== void 0 && prevClose > 0 ? (price - prevClose) / prevClose * 100 : void 0;
|
|
285
|
+
return {
|
|
286
|
+
symbol: toCanonicalOkxSymbol(str(d?.instId) ?? id),
|
|
287
|
+
price,
|
|
288
|
+
timestamp: num(d?.ts) ?? Date.now(),
|
|
289
|
+
...num(d?.bidPx) !== void 0 ? { bid: num(d?.bidPx) } : {},
|
|
290
|
+
...num(d?.askPx) !== void 0 ? { ask: num(d?.askPx) } : {},
|
|
291
|
+
...volume !== void 0 ? { volume } : {},
|
|
292
|
+
...prevClose !== void 0 ? { prevClose } : {},
|
|
293
|
+
...changePercent !== void 0 ? { changePercent } : {}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/** K 线:GET /api/v5/market/candles(单请求上限 300,超出走 after 游标翻页;响应新→旧,翻转为旧→新)。 */
|
|
297
|
+
async getKlines(instId, interval, limit = 100) {
|
|
298
|
+
const id = normalizeOkxSymbol(instId);
|
|
299
|
+
const bar = toBar(interval);
|
|
300
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1e3) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX klines: limit must be an integer within 1..1000, got ${limit}`);
|
|
301
|
+
const collected = [];
|
|
302
|
+
const seenOpenTimes = /* @__PURE__ */ new Set();
|
|
303
|
+
let cursor;
|
|
304
|
+
while (collected.length < limit) {
|
|
305
|
+
const pageSize = Math.min(limit - collected.length, 300);
|
|
306
|
+
const query = {
|
|
307
|
+
instId: id,
|
|
308
|
+
bar,
|
|
309
|
+
limit: String(pageSize)
|
|
310
|
+
};
|
|
311
|
+
if (cursor !== void 0) query.after = String(cursor);
|
|
312
|
+
const rows = await this.request("/api/v5/market/candles", { query });
|
|
313
|
+
if (!Array.isArray(rows)) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX klines for ${id}: unexpected response shape`);
|
|
314
|
+
if (rows.length === 0) break;
|
|
315
|
+
for (const row of rows) {
|
|
316
|
+
if (!Array.isArray(row) || row.length < 6) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX klines: malformed row for ${id}`);
|
|
317
|
+
const openTime = num(row[0]);
|
|
318
|
+
const open = num(row[1]);
|
|
319
|
+
const high = num(row[2]);
|
|
320
|
+
const low = num(row[3]);
|
|
321
|
+
const close = num(row[4]);
|
|
322
|
+
const volume = num(row[5]);
|
|
323
|
+
if (openTime === void 0 || open === void 0 || high === void 0 || low === void 0 || close === void 0 || volume === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX klines: malformed row values for ${id}`);
|
|
324
|
+
if (seenOpenTimes.has(openTime)) continue;
|
|
325
|
+
seenOpenTimes.add(openTime);
|
|
326
|
+
collected.push({
|
|
327
|
+
openTime,
|
|
328
|
+
open,
|
|
329
|
+
high,
|
|
330
|
+
low,
|
|
331
|
+
close,
|
|
332
|
+
volume,
|
|
333
|
+
closeTime: openTime + barDurationMs(bar) - 1
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (rows.length < pageSize) break;
|
|
337
|
+
const oldest = num(rows[rows.length - 1][0]);
|
|
338
|
+
if (oldest === void 0) break;
|
|
339
|
+
cursor = oldest;
|
|
340
|
+
}
|
|
341
|
+
return collected.reverse();
|
|
342
|
+
}
|
|
343
|
+
/** 资金费率:GET /api/v5/public/funding-rate(仅 SWAP;10 次/2s)。 */
|
|
344
|
+
async getFundingRate(instId) {
|
|
345
|
+
const id = normalizeOkxSymbol(instId);
|
|
346
|
+
if (!id.endsWith("-SWAP")) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `OKX funding rate requires a perpetual swap instId (e.g. BTC-USDT-SWAP), got ${id}`);
|
|
347
|
+
const d = (await this.request("/api/v5/public/funding-rate", { query: { instId: id } }))[0];
|
|
348
|
+
const fundingRate = num(d?.fundingRate);
|
|
349
|
+
const fundingTime = num(d?.fundingTime);
|
|
350
|
+
if (fundingRate === void 0 || fundingTime === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX funding rate for ${id}: missing/invalid fields`);
|
|
351
|
+
const nextFundingRate = num(d?.nextFundingRate);
|
|
352
|
+
const nextFundingTime = num(d?.nextFundingTime);
|
|
353
|
+
return {
|
|
354
|
+
instId: str(d?.instId) ?? id,
|
|
355
|
+
fundingRate,
|
|
356
|
+
fundingTime,
|
|
357
|
+
...nextFundingRate !== void 0 ? { nextFundingRate } : {},
|
|
358
|
+
...nextFundingTime !== void 0 ? { nextFundingTime } : {}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/** 标记价格:GET /api/v5/public/mark-price(仅 SWAP;2026-09-03 真实网络实证)。 */
|
|
362
|
+
async getMarkPrice(instId) {
|
|
363
|
+
const id = normalizeOkxSymbol(instId);
|
|
364
|
+
const d = (await this.request("/api/v5/public/mark-price", { query: {
|
|
365
|
+
instType: "SWAP",
|
|
366
|
+
instId: id
|
|
367
|
+
} }))[0];
|
|
368
|
+
const markPrice = num(d?.markPx);
|
|
369
|
+
if (markPrice === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX mark price for ${id}: missing/invalid markPx`);
|
|
370
|
+
return {
|
|
371
|
+
instId: str(d?.instId) ?? id,
|
|
372
|
+
markPrice,
|
|
373
|
+
ts: num(d?.ts) ?? Date.now()
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* 指数价格:GET /api/v5/market/index-tickers(现货指数成分 instId,如 HYPE-USDT;
|
|
378
|
+
* 与永续 markPx 配对算基差。2026-09-03 真实网络实证)。
|
|
379
|
+
*/
|
|
380
|
+
async getIndexPrice(instId) {
|
|
381
|
+
const id = normalizeOkxSymbol(instId);
|
|
382
|
+
const d = (await this.request("/api/v5/market/index-tickers", { query: { instId: id } }))[0];
|
|
383
|
+
const indexPrice = num(d?.idxPx);
|
|
384
|
+
if (indexPrice === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX index price for ${id}: missing/invalid idxPx`);
|
|
385
|
+
return {
|
|
386
|
+
instId: str(d?.instId) ?? id,
|
|
387
|
+
indexPrice,
|
|
388
|
+
ts: num(d?.ts) ?? Date.now()
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/** 资金费率历史:GET /api/v5/public/funding-rate-history(响应新→旧 → 反转为升序)。 */
|
|
392
|
+
async getFundingRateHistory(instId, limit = 30) {
|
|
393
|
+
const id = normalizeOkxSymbol(instId);
|
|
394
|
+
const capped = Math.max(1, Math.min(Math.floor(limit) || 30, 100));
|
|
395
|
+
const rows = await this.request("/api/v5/public/funding-rate-history", { query: {
|
|
396
|
+
instId: id,
|
|
397
|
+
limit: String(capped)
|
|
398
|
+
} });
|
|
399
|
+
const points = [];
|
|
400
|
+
for (const row of rows) {
|
|
401
|
+
const d = row;
|
|
402
|
+
const time = num(d.fundingTime);
|
|
403
|
+
const value = num(d.fundingRate);
|
|
404
|
+
if (time !== void 0 && value !== void 0) points.push({
|
|
405
|
+
time,
|
|
406
|
+
value
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return points.reverse();
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* OI 历史:GET /api/v5/rubik/stat/contracts/open-interest-history(period=1D)。
|
|
413
|
+
* 响应是时间序列行 `[ts, oi张, oiCcy币, oiUsd]`(字符串数值,新→旧;
|
|
414
|
+
* 2026-09-03 真实网络实证);取值列与快照同纪律——优先币数(oiCcy),退张数。
|
|
415
|
+
*/
|
|
416
|
+
async getOpenInterestHistory(instId, limit = 30) {
|
|
417
|
+
const id = normalizeOkxSymbol(instId);
|
|
418
|
+
const capped = Math.max(1, Math.min(Math.floor(limit) || 30, 100));
|
|
419
|
+
const rows = await this.request("/api/v5/rubik/stat/contracts/open-interest-history", { query: {
|
|
420
|
+
instId: id,
|
|
421
|
+
period: "1D",
|
|
422
|
+
limit: String(capped)
|
|
423
|
+
} });
|
|
424
|
+
const points = [];
|
|
425
|
+
for (const row of rows) {
|
|
426
|
+
if (!Array.isArray(row)) continue;
|
|
427
|
+
const time = num(row[0]);
|
|
428
|
+
const value = num(row[2]) ?? num(row[1]);
|
|
429
|
+
if (time !== void 0 && value !== void 0) points.push({
|
|
430
|
+
time,
|
|
431
|
+
value
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return points.reverse();
|
|
435
|
+
}
|
|
436
|
+
/** books 档位行 [price, size, liqOrders, numOrders] → OrderbookLevel。 */
|
|
437
|
+
#parseBookRow(row) {
|
|
438
|
+
if (!Array.isArray(row) || row.length < 2) return void 0;
|
|
439
|
+
const price = num(row[0]);
|
|
440
|
+
const amount = num(row[1]);
|
|
441
|
+
if (price === void 0 || amount === void 0 || price <= 0 || amount <= 0) return void 0;
|
|
442
|
+
return {
|
|
443
|
+
price,
|
|
444
|
+
amount
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/** 盘口快照:GET /api/v5/market/books(sz=20 档;bids 降序 / asks 升序,OKX 原生序)。 */
|
|
448
|
+
async getOrderbook(instId) {
|
|
449
|
+
const id = normalizeOkxSymbol(instId);
|
|
450
|
+
const d = (await this.request("/api/v5/market/books", { query: {
|
|
451
|
+
instId: id,
|
|
452
|
+
sz: "20"
|
|
453
|
+
} }))[0];
|
|
454
|
+
if (d === void 0 || !Array.isArray(d.bids) || !Array.isArray(d.asks)) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX books for ${id}: unexpected response shape`);
|
|
455
|
+
const bids = d.bids.map((row) => this.#parseBookRow(row)).filter((l) => l !== void 0);
|
|
456
|
+
const asks = d.asks.map((row) => this.#parseBookRow(row)).filter((l) => l !== void 0);
|
|
457
|
+
const ts = num(d.ts) ?? Date.now();
|
|
458
|
+
return {
|
|
459
|
+
symbol: toCanonicalOkxSymbol(id),
|
|
460
|
+
bids,
|
|
461
|
+
asks,
|
|
462
|
+
timestamp: ts
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
/** trades 行 → TradeTick(side 即 taker 方向;OKX 响应新→旧,反转为升序)。 */
|
|
466
|
+
#parseTradeRow(row, symbol) {
|
|
467
|
+
const d = row;
|
|
468
|
+
const price = num(d.px);
|
|
469
|
+
const amount = num(d.sz);
|
|
470
|
+
if (price === void 0 || amount === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX trades for ${symbol}: malformed trade row`);
|
|
471
|
+
const side = d.side === "buy" || d.side === "sell" ? d.side : "unknown";
|
|
472
|
+
return {
|
|
473
|
+
id: str(d.tradeId) ?? "",
|
|
474
|
+
symbol,
|
|
475
|
+
price,
|
|
476
|
+
amount,
|
|
477
|
+
side,
|
|
478
|
+
timestamp: num(d.ts) ?? Date.now()
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/** 最近逐笔成交:GET /api/v5/market/trades(响应新→旧 → 反转为时间升序)。 */
|
|
482
|
+
async getRecentTrades(instId, limit = 50) {
|
|
483
|
+
const id = normalizeOkxSymbol(instId);
|
|
484
|
+
const capped = Math.max(1, Math.min(Math.floor(limit) || 50, 100));
|
|
485
|
+
const rows = await this.request("/api/v5/market/trades", { query: {
|
|
486
|
+
instId: id,
|
|
487
|
+
limit: String(capped)
|
|
488
|
+
} });
|
|
489
|
+
const symbol = toCanonicalOkxSymbol(id);
|
|
490
|
+
return rows.map((row) => this.#parseTradeRow(row, symbol)).reverse();
|
|
491
|
+
}
|
|
492
|
+
/** 未平仓合约量:GET /api/v5/public/open-interest(仅 SWAP;oi=张、oiCcy=币、oiUsd=USD)。 */ async getOpenInterest(instId) {
|
|
493
|
+
const id = normalizeOkxSymbol(instId);
|
|
494
|
+
if (!id.endsWith("-SWAP")) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `OKX open interest requires a perpetual swap instId (e.g. BTC-USDT-SWAP), got ${id}`);
|
|
495
|
+
const d = (await this.request("/api/v5/public/open-interest", { query: {
|
|
496
|
+
instType: "SWAP",
|
|
497
|
+
instId: id
|
|
498
|
+
} }))[0];
|
|
499
|
+
const oi = num(d?.oi);
|
|
500
|
+
if (oi === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX open interest for ${id}: missing/invalid oi`);
|
|
501
|
+
const oiCcy = num(d?.oiCcy);
|
|
502
|
+
const oiUsd = num(d?.oiUsd);
|
|
503
|
+
const ts = num(d?.ts) ?? Date.now();
|
|
504
|
+
return {
|
|
505
|
+
instId: str(d?.instId) ?? id,
|
|
506
|
+
oi,
|
|
507
|
+
...oiCcy !== void 0 ? { oiCcy } : {},
|
|
508
|
+
...oiUsd !== void 0 ? { oiUsd } : {},
|
|
509
|
+
ts
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* 多空账户人数比:GET /api/v5/rubik/stat/contracts/long-short-account-ratio(ccy=base 资产,period=1H)。
|
|
514
|
+
* 响应是时间序列行 `[ts, ratio]`(字符串数值,新→旧;2026-09-02 真实网络实证,
|
|
515
|
+
* spikes/impl-crypto-derivatives),取最新一行。
|
|
516
|
+
*/
|
|
517
|
+
async getLongShortAccountRatio(ccy) {
|
|
518
|
+
const row = (await this.request("/api/v5/rubik/stat/contracts/long-short-account-ratio", { query: {
|
|
519
|
+
ccy,
|
|
520
|
+
period: "1H"
|
|
521
|
+
} }))[0];
|
|
522
|
+
const ratio = Array.isArray(row) ? num(row[1]) : void 0;
|
|
523
|
+
if (ratio === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX long/short account ratio for ${ccy}: missing/invalid ratio`);
|
|
524
|
+
const ts = Array.isArray(row) ? num(row[0]) : void 0;
|
|
525
|
+
return {
|
|
526
|
+
ccy,
|
|
527
|
+
ratio,
|
|
528
|
+
...ts !== void 0 ? { ts } : {}
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* 合约主动买卖量:GET /api/v5/rubik/stat/taker-volume(ccy=base 资产,instType=CONTRACTS)。
|
|
533
|
+
* 响应是时间序列行 `[ts, buyVol, sellVol]`(字符串数值,新→旧;2026-09-02 真实网络
|
|
534
|
+
* 实证,spikes/impl-crypto-derivatives),取最新一行。
|
|
535
|
+
*/
|
|
536
|
+
async getContractTakerVolume(ccy) {
|
|
537
|
+
const row = (await this.request("/api/v5/rubik/stat/taker-volume", { query: {
|
|
538
|
+
ccy,
|
|
539
|
+
instType: "CONTRACTS"
|
|
540
|
+
} }))[0];
|
|
541
|
+
const buyVol = Array.isArray(row) ? num(row[1]) : void 0;
|
|
542
|
+
const sellVol = Array.isArray(row) ? num(row[2]) : void 0;
|
|
543
|
+
if (buyVol === void 0 || sellVol === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX taker volume for ${ccy}: missing/invalid buyVol/sellVol`);
|
|
544
|
+
return {
|
|
545
|
+
ccy,
|
|
546
|
+
buyVol,
|
|
547
|
+
sellVol
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/** 合约/币对规格:GET /api/v5/public/instruments(sz 纪律的 ctVal/lotSz/minSz 来源)。 */
|
|
551
|
+
async getInstruments(instType, instId) {
|
|
552
|
+
return (await this.request("/api/v5/public/instruments", { query: instId !== void 0 ? {
|
|
553
|
+
instType,
|
|
554
|
+
instId
|
|
555
|
+
} : { instType } })).map((row) => {
|
|
556
|
+
const d = row;
|
|
557
|
+
const id = str(d.instId);
|
|
558
|
+
const lotSz = num(d.lotSz);
|
|
559
|
+
const minSz = num(d.minSz);
|
|
560
|
+
const tickSz = num(d.tickSz);
|
|
561
|
+
if (id === void 0 || lotSz === void 0 || minSz === void 0 || tickSz === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX instruments: malformed row`);
|
|
562
|
+
const ctVal = num(d.ctVal);
|
|
563
|
+
return {
|
|
564
|
+
instId: id,
|
|
565
|
+
instType: str(d.instType) ?? instType,
|
|
566
|
+
lotSz,
|
|
567
|
+
minSz,
|
|
568
|
+
tickSz,
|
|
569
|
+
...ctVal !== void 0 ? { ctVal } : {},
|
|
570
|
+
...str(d.ctValCcy) !== void 0 ? { ctValCcy: str(d.ctValCcy) } : {},
|
|
571
|
+
...str(d.settleCcy) !== void 0 ? { settleCcy: str(d.settleCcy) } : {},
|
|
572
|
+
...str(d.baseCcy) !== void 0 ? { baseCcy: str(d.baseCcy) } : {},
|
|
573
|
+
...str(d.quoteCcy) !== void 0 ? { quoteCcy: str(d.quoteCcy) } : {}
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* 全部可交易现货标的名册(GET /api/v5/public/instruments?instType=SPOT,Issue #15)。
|
|
579
|
+
* 输出 symbol 归一化为市场规范形(BTC-USDT → BTCUSDT),name 为 baseCcy/quoteCcy。
|
|
580
|
+
*/
|
|
581
|
+
async listInstruments() {
|
|
582
|
+
return (await this.getInstruments("SPOT")).map((inst) => {
|
|
583
|
+
const canonical = toCanonicalOkxSymbol(inst.instId);
|
|
584
|
+
const name = inst.baseCcy && inst.quoteCcy ? `${inst.baseCcy}/${inst.quoteCcy}` : void 0;
|
|
585
|
+
return {
|
|
586
|
+
symbol: canonical,
|
|
587
|
+
...name ? { name } : {}
|
|
588
|
+
};
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/** 只读余额:GET /api/v5/account/balance(ccy 可选,逗号分隔 ≤20)。 */
|
|
592
|
+
async getBalance(auth, ccy) {
|
|
593
|
+
return this.request("/api/v5/account/balance", {
|
|
594
|
+
query: ccy !== void 0 ? { ccy } : void 0,
|
|
595
|
+
auth
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
/** 只读持仓:GET /api/v5/account/positions(instId 可选过滤)。 */
|
|
599
|
+
async getPositions(auth, instId) {
|
|
600
|
+
return this.request("/api/v5/account/positions", {
|
|
601
|
+
query: instId !== void 0 ? { instId } : void 0,
|
|
602
|
+
auth
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
/** 下单:POST /api/v5/trade/order(60 次/2s)。 */
|
|
606
|
+
async placeOrder(params, auth) {
|
|
607
|
+
return this.request("/api/v5/trade/order", {
|
|
608
|
+
method: "POST",
|
|
609
|
+
body: JSON.stringify(params),
|
|
610
|
+
auth
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
/** 撤单:POST /api/v5/trade/cancel-order(ordId 优先于 clOrdId,本切片只支持 ordId)。 */
|
|
614
|
+
async cancelOrder(instId, ordId, auth) {
|
|
615
|
+
return this.request("/api/v5/trade/cancel-order", {
|
|
616
|
+
method: "POST",
|
|
617
|
+
body: JSON.stringify({
|
|
618
|
+
instId,
|
|
619
|
+
ordId
|
|
620
|
+
}),
|
|
621
|
+
auth
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/** 查单:GET /api/v5/trade/order(query 属于签名 requestPath)。 */
|
|
625
|
+
async getOrder(instId, ordId, auth) {
|
|
626
|
+
return this.request("/api/v5/trade/order", {
|
|
627
|
+
query: {
|
|
628
|
+
instId,
|
|
629
|
+
ordId
|
|
630
|
+
},
|
|
631
|
+
auth
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/** 当前挂单:GET /api/v5/trade/orders-pending(instId 可选过滤;issue #40 交易台)。 */
|
|
635
|
+
async listPendingOrders(instId, auth) {
|
|
636
|
+
return this.request("/api/v5/trade/orders-pending", {
|
|
637
|
+
query: instId !== void 0 ? { instId } : void 0,
|
|
638
|
+
auth
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
/** 最近成交明细:GET /api/v5/trade/fills-history(instId/limit 可选;issue #40 交易台)。 */
|
|
642
|
+
async listFillsHistory(instId, limit, auth) {
|
|
643
|
+
const query = {};
|
|
644
|
+
if (instId !== void 0) query.instId = instId;
|
|
645
|
+
if (limit !== void 0) query.limit = String(limit);
|
|
646
|
+
return this.request("/api/v5/trade/fills-history", {
|
|
647
|
+
query: Object.keys(query).length > 0 ? query : void 0,
|
|
648
|
+
auth
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
/** instId 校验(R3 词汇:SPOT `BASE-QUOTE` 与 SWAP `BASE-QUOTE-SWAP`,OKX 原生连字符)。 */
|
|
653
|
+
const INST_ID_PATTERN = /^[A-Z0-9]{1,20}-[A-Z0-9]{1,20}(-SWAP)?$/;
|
|
654
|
+
/**
|
|
655
|
+
* 规范形(crypto canonical):BASEQUOTE 大写无分隔(BTCUSDT),衍生品预留
|
|
656
|
+
* BASEQUOTE-SWAP。OKX 原生形:BTC-USDT / BTC-USDT-SWAP。
|
|
657
|
+
*/
|
|
658
|
+
/**
|
|
659
|
+
* 已知 quote 货币后缀表(规范形拆 base/quote 的依据;最长匹配优先)。
|
|
660
|
+
* 表是连接器私有实现——新 quote 货币上线在此增补(规范只管词汇形态)。
|
|
661
|
+
*/
|
|
662
|
+
const KNOWN_QUOTES = [
|
|
663
|
+
"USDT",
|
|
664
|
+
"USDC",
|
|
665
|
+
"USD",
|
|
666
|
+
"EUR",
|
|
667
|
+
"BTC",
|
|
668
|
+
"ETH",
|
|
669
|
+
"OKB"
|
|
670
|
+
];
|
|
671
|
+
/**
|
|
672
|
+
* 输入归一 → OKX 原生 instId。接受规范形(BTCUSDT / BTCUSDT-SWAP)与原生形
|
|
673
|
+
*(BTC-USDT / BTC-USDT-SWAP);都解析不出才报 TRADING_UNSUPPORTED_SYMBOL。
|
|
674
|
+
*/
|
|
675
|
+
function normalizeOkxSymbol(input) {
|
|
676
|
+
const id = typeof input === "string" ? input.trim().toUpperCase() : "";
|
|
677
|
+
const parts = id.split("-");
|
|
678
|
+
const splitCanonicalPair = (pair, swapSuffix) => {
|
|
679
|
+
if (!/^[A-Z0-9]{2,24}$/.test(pair)) return void 0;
|
|
680
|
+
for (const quote of KNOWN_QUOTES) if (pair.length > quote.length && pair.endsWith(quote)) return pair.slice(0, -quote.length) + "-" + quote + swapSuffix;
|
|
681
|
+
};
|
|
682
|
+
if (parts.length === 3) {
|
|
683
|
+
if (INST_ID_PATTERN.test(id)) return id;
|
|
684
|
+
} else if (parts.length === 2) {
|
|
685
|
+
if (parts[1] === "SWAP") {
|
|
686
|
+
const translated = splitCanonicalPair(parts[0] ?? "", "-SWAP");
|
|
687
|
+
if (translated !== void 0) return translated;
|
|
688
|
+
} else if (INST_ID_PATTERN.test(id)) return id;
|
|
689
|
+
} else if (parts.length === 1) {
|
|
690
|
+
const translated = splitCanonicalPair(id, "");
|
|
691
|
+
if (translated !== void 0) return translated;
|
|
692
|
+
}
|
|
693
|
+
throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", "OKX: cannot parse symbol " + JSON.stringify(input) + " — use market-canonical vocabulary (BTCUSDT / BTCUSDT-SWAP) or OKX native (BTC-USDT / BTC-USDT-SWAP)");
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* 输出归一 → 规范形(下游永远看到市场规范词汇)。原生 BTC-USDT → BTCUSDT;
|
|
697
|
+
* BTC-USDT-SWAP → BTCUSDT-SWAP;已是规范形则原样返回。
|
|
698
|
+
*/
|
|
699
|
+
function toCanonicalOkxSymbol(symbol) {
|
|
700
|
+
const id = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
|
701
|
+
if (!id.includes("-")) return id;
|
|
702
|
+
const parts = id.split("-");
|
|
703
|
+
if (parts[parts.length - 1] === "SWAP") return parts.slice(0, -1).join("") + "-SWAP";
|
|
704
|
+
return parts.join("");
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* 衍生品端点输入归一:现货与合约输入一律升到永续 SWAP instId——
|
|
708
|
+
* BTCUSDT / BTC-USDT / BTCUSDT-SWAP / BTC-USDT-SWAP → BTC-USDT-SWAP。
|
|
709
|
+
* (GUI 选中的现货标的也要能看到对应合约的衍生品指标,issue #38。)
|
|
710
|
+
*/
|
|
711
|
+
function toOkxSwapInstId(input) {
|
|
712
|
+
const id = normalizeOkxSymbol(input);
|
|
713
|
+
return id.endsWith("-SWAP") ? id : `${id}-SWAP`;
|
|
714
|
+
}
|
|
715
|
+
//#endregion
|
|
716
|
+
export { BAR_MAP, OKX_INTERVAL_VOCABULARY, OkxRestClient, TradingServiceError, barDurationMs, buildAuthHeaders, isoTimestamp, normalizeOkxSymbol, normalizeSize, signPayload, signaturePrehash, toBar, toCanonicalOkxSymbol, toOkxSwapInstId };
|