@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/lib/index.js ADDED
@@ -0,0 +1,869 @@
1
+ import { BAR_MAP, OKX_INTERVAL_VOCABULARY, OkxRestClient, TradingServiceError, barDurationMs, buildAuthHeaders, isoTimestamp, normalizeOkxSymbol, normalizeSize, signPayload, signaturePrehash, toBar, toCanonicalOkxSymbol, toOkxSwapInstId } from "./rest.js";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import { defineTool } from "@deepseek-ai/dsh-tools";
4
+ import Schema from "@deepseek-ai/schemastery";
5
+ import { createGetIndicatorsTool } from "@dshtrading/indicators/tool";
6
+ //#region src/index.ts
7
+ /**
8
+ * Cordis 插件名 = preset 行 id(TEMPLATES §8):`dsh-trading-crypto-*` 市场命名空间,
9
+ * 全仓唯一,绝不使用 `base` 等官方保留 id(insert-only 铁律 #1)。
10
+ */
11
+ const name = "dsh-trading-crypto-connector-okx";
12
+ const Config = Schema.object({
13
+ enabled: Schema.boolean().default(false),
14
+ env: Schema.union(["demo", "live"]).default("demo"),
15
+ dryRun: Schema.boolean().default(true),
16
+ liveTrading: Schema.boolean().default(false),
17
+ apiKeyRef: Schema.string().default("OKX_API_KEY"),
18
+ secretRef: Schema.string().default("OKX_SECRET_KEY"),
19
+ passphraseRef: Schema.string().default("OKX_PASSPHRASE"),
20
+ demoApiKeyRef: Schema.string().default("OKX_DEMO_API_KEY"),
21
+ demoSecretRef: Schema.string().default("OKX_DEMO_SECRET_KEY"),
22
+ demoPassphraseRef: Schema.string().default("OKX_DEMO_PASSPHRASE")
23
+ });
24
+ /** 需要宿主提供的 Cordis 服务。 */
25
+ const inject = ["tools"];
26
+ /** ctx 服务键(与 @dshtrading/api 的 Context 模块增强一致)。 */
27
+ const TRADING_CRYPTO_MARKET_DATA_KEY = "tradingCryptoMarketData";
28
+ const TRADING_CRYPTO_TRADE_KEY = "tradingCryptoTrade";
29
+ /** credentialRef 语义(DSH credentials 包校验 ^[A-Za-z_][A-Za-z0-9_]*$,镜像镜像)。 */
30
+ const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
31
+ /** env → ref 组:demo 用 demo*Ref(默认 OKX_DEMO_*),live 用 live 组(默认 OKX_*)。 */
32
+ function credentialRefsFor(config, env = config.env) {
33
+ return env === "live" ? {
34
+ apiKeyRef: config.apiKeyRef,
35
+ secretRef: config.secretRef,
36
+ passphraseRef: config.passphraseRef
37
+ } : {
38
+ apiKeyRef: config.demoApiKeyRef,
39
+ secretRef: config.demoSecretRef,
40
+ passphraseRef: config.demoPassphraseRef
41
+ };
42
+ }
43
+ /**
44
+ * 三 ref 凭证解析:每次操作调用(ctx.credentials 的设计意图——换 key 无需重启插件)。
45
+ * 无 credentials seam 时回落启动环境变量(llm-deepseek 同款降级)。
46
+ * 任何一处未命中/无效 → TRADING_CREDENTIALS_MISSING,消息只带 ref 名(绝不带值)。
47
+ */
48
+ async function resolveCredentials(ctx, config) {
49
+ const routerCreds = (ctx.get("tradingMarketRouter") ?? void 0)?.getCredential?.("okx");
50
+ if (routerCreds?.apiKey && (routerCreds?.secretKey || routerCreds?.secret) && routerCreds?.passphrase) return {
51
+ key: routerCreds.apiKey,
52
+ secret: routerCreds.secretKey || routerCreds.secret,
53
+ passphrase: routerCreds.passphrase
54
+ };
55
+ const refs = credentialRefsFor(config);
56
+ for (const ref of [
57
+ refs.apiKeyRef,
58
+ refs.secretRef,
59
+ refs.passphraseRef
60
+ ]) if (!CREDENTIAL_REF_PATTERN.test(ref)) throw new TradingServiceError("TRADING_CREDENTIALS_MISSING", `connector-okx: credential ref ${JSON.stringify(ref)} is not a valid environment-variable name (env=${config.env})`);
61
+ const resolver = ctx.get("credentials") ?? void 0;
62
+ const entries = [
63
+ ["apiKeyRef", refs.apiKeyRef],
64
+ ["secretRef", refs.secretRef],
65
+ ["passphraseRef", refs.passphraseRef]
66
+ ];
67
+ const resolved = await Promise.all(entries.map(async ([slot, ref]) => {
68
+ let value;
69
+ if (resolver !== void 0) value = (await resolver.resolve(ref))?.value;
70
+ if (value === void 0 || value === "") value = process.env[ref];
71
+ return {
72
+ slot,
73
+ ref,
74
+ value
75
+ };
76
+ }));
77
+ const missing = resolved.filter((part) => part.value === void 0 || part.value === "");
78
+ if (missing.length > 0) throw new TradingServiceError("TRADING_CREDENTIALS_MISSING", `connector-okx: missing OKX ${config.env} credentials — provide ` + missing.map((part) => `${part.slot}=${part.ref}`).join(", ") + ` through the credentials service or the launching environment (env=${config.env}; demo and live API keys are separate and not interchangeable)`);
79
+ const [apiKey, secret, passphrase] = resolved.map((part) => part.value);
80
+ return {
81
+ key: apiKey,
82
+ secret,
83
+ passphrase
84
+ };
85
+ }
86
+ const SUBSCRIBE_MIN_MS = 250;
87
+ const SUBSCRIBE_DEFAULT_MS = 5e3;
88
+ var OkxMarketDataService = class extends Service {
89
+ client;
90
+ constructor(ctx, options = {}, client, serviceName = TRADING_CRYPTO_MARKET_DATA_KEY) {
91
+ super(ctx, serviceName);
92
+ this.client = client ?? new OkxRestClient(options);
93
+ }
94
+ getTicker(instId) {
95
+ return this.client.getTicker(instId);
96
+ }
97
+ getKlines(instId, interval, limit) {
98
+ return this.client.getKlines(instId, interval, limit);
99
+ }
100
+ listInstruments() {
101
+ return this.client.listInstruments();
102
+ }
103
+ /** OKX 专属扩展(MarketDataService 契约之外):SWAP 资金费率。 */
104
+ getFundingRate(instId) {
105
+ return this.client.getFundingRate(instId);
106
+ }
107
+ /** 盘口快照(api 可选契约 getOrderbook,issue #39):books 20 档透传。 */
108
+ getOrderbook(symbol) {
109
+ return this.client.getOrderbook(symbol);
110
+ }
111
+ /** 最近逐笔成交(api 可选契约 getRecentTrades,issue #39),时间升序。 */
112
+ getRecentTrades(symbol, limit = 50) {
113
+ return this.client.getRecentTrades(symbol, limit);
114
+ }
115
+ /**
116
+ * 衍生品指标快照(api 可选契约 getDerivatives,issue #38):聚合 OKX 公共端点
117
+ * (funding-rate / open-interest / rubik 多空账户比 / rubik taker 买卖量)。
118
+ * 现货输入经 toOkxSwapInstId 升到对应永续(GUI 选中 BTCUSDT 也能看合约指标)。
119
+ * 任一子查询失败只降级该字段(undefined,面板按缺格隐藏);全部失败才抛
120
+ * 结构化错误(桥层转 ok:false,前端不弹横幅)。
121
+ */
122
+ async getDerivatives(symbol) {
123
+ const swapId = toOkxSwapInstId(symbol);
124
+ const spotId = swapId.replace(/-SWAP$/, "");
125
+ const ccy = swapId.split("-")[0] ?? "";
126
+ const unavailable = [];
127
+ const collect = (label, task) => task.catch((error) => {
128
+ unavailable.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
129
+ });
130
+ const [funding, interest, ratio, taker, mark, index] = await Promise.all([
131
+ collect("funding", this.client.getFundingRate(swapId)),
132
+ collect("open-interest", this.client.getOpenInterest(swapId)),
133
+ ccy === "" ? Promise.resolve(void 0) : collect("long-short-ratio", this.client.getLongShortAccountRatio(ccy)),
134
+ ccy === "" ? Promise.resolve(void 0) : collect("taker-volume", this.client.getContractTakerVolume(ccy)),
135
+ collect("mark-price", this.client.getMarkPrice(swapId)),
136
+ collect("index-price", this.client.getIndexPrice(spotId))
137
+ ]);
138
+ const fundingRate = funding?.fundingRate;
139
+ const nextFundingRate = funding?.nextFundingRate;
140
+ const nextFundingTime = funding?.nextFundingTime;
141
+ const openInterest = interest?.oiCcy ?? interest?.oi;
142
+ const openInterestValue = interest?.oiUsd;
143
+ const longShortRatio = ratio?.ratio;
144
+ const takerBuySellRatio = taker !== void 0 && taker.sellVol > 0 ? taker.buyVol / taker.sellVol : void 0;
145
+ const markPrice = mark?.markPrice;
146
+ const indexPrice = index?.indexPrice;
147
+ if (fundingRate === void 0 && openInterest === void 0 && openInterestValue === void 0 && longShortRatio === void 0 && takerBuySellRatio === void 0 && markPrice === void 0 && indexPrice === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX derivatives for ${swapId}: all sub-queries failed` + (unavailable.length > 0 ? ` (${unavailable.join("; ")})` : ""));
148
+ return {
149
+ symbol: toCanonicalOkxSymbol(swapId),
150
+ source: "okx",
151
+ ...openInterest !== void 0 ? { openInterest } : {},
152
+ ...openInterestValue !== void 0 ? { openInterestValue } : {},
153
+ ...longShortRatio !== void 0 ? { longShortRatio } : {},
154
+ ...takerBuySellRatio !== void 0 ? { takerBuySellRatio } : {},
155
+ ...fundingRate !== void 0 ? { fundingRate } : {},
156
+ ...nextFundingRate !== void 0 ? { nextFundingRate } : {},
157
+ ...nextFundingTime !== void 0 ? { nextFundingTime } : {},
158
+ ...markPrice !== void 0 ? { markPrice } : {},
159
+ ...indexPrice !== void 0 ? { indexPrice } : {},
160
+ timestamp: interest?.ts ?? funding?.fundingTime ?? Date.now()
161
+ };
162
+ }
163
+ /**
164
+ * 衍生品历史序列(api 可选契约 getDerivativesHistory,issue #54):
165
+ * funding-rate-history + rubik open-interest-history 双端点聚合并发拉取,
166
+ * 任一失败只降级该序列(字段缺省 → 对应趋势卡隐藏),全部失败才抛结构化错误。
167
+ */
168
+ async getDerivativesHistory(symbol) {
169
+ const swapId = toOkxSwapInstId(symbol);
170
+ const unavailable = [];
171
+ const collect = (label, task) => task.catch((error) => {
172
+ unavailable.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
173
+ });
174
+ const [fundingRates, openInterest] = await Promise.all([collect("funding-history", this.client.getFundingRateHistory(swapId, 30)), collect("oi-history", this.client.getOpenInterestHistory(swapId, 30))]);
175
+ if (fundingRates === void 0 && openInterest === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX derivatives history for ${swapId}: all sub-queries failed` + (unavailable.length > 0 ? ` (${unavailable.join("; ")})` : ""));
176
+ return {
177
+ symbol: toCanonicalOkxSymbol(swapId),
178
+ source: "okx",
179
+ ...fundingRates !== void 0 && fundingRates.length > 0 ? { fundingRates } : {},
180
+ ...openInterest !== void 0 && openInterest.length > 0 ? { openInterest } : {}
181
+ };
182
+ }
183
+ subscribeTicker(instId, cb, options) {
184
+ const ms = Math.max(options?.intervalMs ?? SUBSCRIBE_DEFAULT_MS, SUBSCRIBE_MIN_MS);
185
+ const tick = () => {
186
+ this.client.getTicker(instId).then(cb, () => {});
187
+ };
188
+ tick();
189
+ const timer = setInterval(tick, ms);
190
+ return { dispose: () => clearInterval(timer) };
191
+ }
192
+ };
193
+ const INSTRUMENT_TTL_MS = 36e5;
194
+ var OkxTradeService = class extends Service {
195
+ client;
196
+ config;
197
+ getCredentials;
198
+ /** instId → 规格 缓存;key 前缀 demo:/live: —— demo 与实盘 ctVal 是否一致未实证(调研待验证 #5),按环境分桶。 */
199
+ instruments = /* @__PURE__ */ new Map();
200
+ constructor(ctx, options, serviceName = TRADING_CRYPTO_TRADE_KEY) {
201
+ super(ctx, serviceName);
202
+ this.client = options.client;
203
+ this.config = options.config;
204
+ this.getCredentials = options.getCredentials;
205
+ }
206
+ get simulated() {
207
+ return this.config.env === "demo";
208
+ }
209
+ auth(credentials) {
210
+ return {
211
+ credentials,
212
+ simulated: this.simulated
213
+ };
214
+ }
215
+ /** 规格(带 TTL 缓存;demo/live 分桶)。查不到 → TRADING_UNSUPPORTED_SYMBOL。 */
216
+ async getInstrument(instId) {
217
+ const bucket = `${this.simulated ? "demo" : "live"}:${instId}`;
218
+ const cached = this.instruments.get(bucket);
219
+ if (cached !== void 0 && Date.now() - cached.at < INSTRUMENT_TTL_MS) return cached.instrument;
220
+ const instType = instId.endsWith("-SWAP") ? "SWAP" : "SPOT";
221
+ const row = (await this.client.getInstruments(instType, instId))[0];
222
+ if (row === void 0) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `OKX: unknown instId ${instId} (instType=${instType})`);
223
+ this.instruments.set(bucket, {
224
+ instrument: row,
225
+ at: Date.now()
226
+ });
227
+ return row;
228
+ }
229
+ /**
230
+ * 下单(api TradeService 契约)。
231
+ *
232
+ * **服务缝闸门(P0 · 铁律 #3 修订版 [S4])**:三态检查以 evaluateOrderGate 同源语义
233
+ * 下推到服务实现内第一步——绕过工具层直调本服务(dsh-tool-cordis 动态包宿主半、
234
+ * 未来任何新消费面)同样 fail-closed;工具层 evaluateOrderGate + base 审批闸门保留
235
+ * (双保险),工具层只做参数预检与富回执。
236
+ *
237
+ * - 闸门 ① reject(dryRun=false 请求实盘而 liveTrading=false)→ 结构化错误抛出
238
+ * (TRADING_LIVE_TRADING_DISABLED,api TradingError 契约);
239
+ * - 闸门 ② simulate(dryRun 缺省/true,或 config.dryRun 强制模拟)→ 本地模拟回执
240
+ * (Order.dryRun=true,不触网;工具层另有带市价参照的富回执);
241
+ * - 闸门 ③ live(dryRun=false 且 liveTrading=true)→ 真实签名下单(env=demo 加模拟盘头)。
242
+ *
243
+ * **sz 单位纪律(调研 §4,实现期最重要的换算)**:
244
+ * - api `OrderRequest.quantity` 语义恒为 base 币数;
245
+ * - SPOT:market 单显式 `tgtCcy: 'base_ccy'` —— OKX 现货市价 buy 缺省按计价币
246
+ * (USDT)金额,若不显式指定,想买 0.01 BTC 却传 0.01 会被当成 0.01 USDT,
247
+ * 这是两所词汇最大的坑;limit 单恒为 base 币数;
248
+ * - SWAP:`sz` 单位是「张」,币数 = sz × ctVal —— 服务层按 instruments 的
249
+ * ctVal/lotSz/minSz 换算并本地校验(向下取整,省一次 51000 往返)。
250
+ */
251
+ async placeOrder(req) {
252
+ const verdict = evaluateOrderGate(this.config, {
253
+ instId: req.symbol,
254
+ side: req.side,
255
+ type: req.type,
256
+ quantity: req.quantity,
257
+ ...req.price !== void 0 ? { price: req.price } : {},
258
+ dryRun: req.dryRun
259
+ });
260
+ if (verdict.action === "reject") throw new TradingServiceError(verdict.code, verdict.message);
261
+ if (verdict.action === "simulate") {
262
+ const instId = normalizeOkxSymbol(req.symbol);
263
+ return {
264
+ id: `dry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
265
+ symbol: toCanonicalOkxSymbol(instId),
266
+ side: req.side,
267
+ type: req.type,
268
+ status: "filled",
269
+ ...req.price !== void 0 ? { price: req.price } : {},
270
+ quantity: req.quantity,
271
+ dryRun: true,
272
+ timestamp: Date.now()
273
+ };
274
+ }
275
+ const instId = normalizeOkxSymbol(req.symbol);
276
+ const instrument = await this.getInstrument(instId);
277
+ const normalized = normalizeSize(instId, instrument, req.quantity);
278
+ const params = {
279
+ instId,
280
+ tdMode: instrument.instType === "SWAP" ? "cross" : "cash",
281
+ side: req.side,
282
+ ordType: req.type,
283
+ sz: normalized.sz,
284
+ ...req.type === "limit" ? { px: String(req.price) } : {},
285
+ ...req.type === "market" && normalized.tgtCcy !== void 0 ? { tgtCcy: normalized.tgtCcy } : {}
286
+ };
287
+ const credentials = await this.getCredentials();
288
+ const first = (await this.client.placeOrder(params, this.auth(credentials)))[0];
289
+ const ordId = typeof first.ordId === "string" && first.ordId !== "" ? first.ordId : void 0;
290
+ if (ordId === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX place order for ${instId}: missing ordId in response`);
291
+ return {
292
+ id: ordId,
293
+ symbol: toCanonicalOkxSymbol(instId),
294
+ side: req.side,
295
+ type: req.type,
296
+ status: "new",
297
+ ...req.price !== void 0 ? { price: req.price } : {},
298
+ quantity: req.quantity,
299
+ dryRun: false,
300
+ timestamp: Date.now()
301
+ };
302
+ }
303
+ /**
304
+ * 撤单。OKX 按 instId + ordId 双键定位,symbol(instId)必填——api 契约的
305
+ * cancelOrder(id) 单参形态对 OKX 不够,扩展第二可选参数(api 包 R3 修订)。
306
+ *
307
+ * 撤单幂等化(调研 §5「实现期定」):51400(订单已成交/已撤/不存在)/51603(订单
308
+ * 不存在)视作终态成功——撤单语义是「确保不再成交」,订单已终态即达成。
309
+ */
310
+ async cancelOrder(id, symbol) {
311
+ if (!this.config.liveTrading || this.config.dryRun) throw new TradingServiceError("TRADING_LIVE_TRADING_DISABLED", "OKX cancelOrder rejected at the service seam: cancel is a live action and requires liveTrading=true with dryRun=false (keep liveTrading=false if the order was not placed through this service).");
312
+ if (symbol === void 0 || symbol === "") throw new TradingServiceError("TRADING_EXCHANGE_ERROR", "OKX cancelOrder requires the instId (symbol) together with the order id — OKX locates orders by (instId, ordId)");
313
+ const instId = normalizeOkxSymbol(symbol);
314
+ const credentials = await this.getCredentials();
315
+ try {
316
+ await this.client.cancelOrder(instId, id, this.auth(credentials));
317
+ } catch (error) {
318
+ if (error instanceof TradingServiceError && error.code === "TRADING_EXCHANGE_ERROR" && /\bsCode=(51400|51603)\b/.test(error.message)) return;
319
+ throw error;
320
+ }
321
+ }
322
+ /** 查单(api TradeService R3 新增成员):state → OrderStatus 映射见 ORDER_STATE_MAP。 */
323
+ async getOrder(symbol, id) {
324
+ const instId = normalizeOkxSymbol(symbol);
325
+ const credentials = await this.getCredentials();
326
+ const d = (await this.client.getOrder(instId, id, this.auth(credentials)))[0];
327
+ const ordId = typeof d.ordId === "string" ? d.ordId : id;
328
+ const side = d.side === "sell" ? "sell" : "buy";
329
+ const ordType = d.ordType === "market" ? "market" : "limit";
330
+ const state = typeof d.state === "string" ? d.state : "";
331
+ const instrument = await this.getInstrument(instId);
332
+ const toCoins = (exchangeAmount) => {
333
+ const n = typeof exchangeAmount === "string" || typeof exchangeAmount === "number" ? Number(exchangeAmount) : NaN;
334
+ if (!Number.isFinite(n)) return void 0;
335
+ return instrument.instType === "SWAP" && instrument.ctVal !== void 0 ? n * instrument.ctVal : n;
336
+ };
337
+ const quantity = toCoins(d.sz);
338
+ if (quantity === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `OKX order ${ordId}: missing/invalid sz`);
339
+ const filledQuantity = toCoins(d.accFillSz);
340
+ const price = typeof d.px === "string" && d.px !== "" ? Number(d.px) : typeof d.avgPx === "string" && d.avgPx !== "" ? Number(d.avgPx) : void 0;
341
+ const timestamp = typeof d.uTime === "string" ? Number(d.uTime) : typeof d.cTime === "string" ? Number(d.cTime) : Date.now();
342
+ return {
343
+ id: ordId,
344
+ symbol: toCanonicalOkxSymbol(instId),
345
+ side,
346
+ type: ordType,
347
+ status: mapOrderState(state),
348
+ ...price !== void 0 && Number.isFinite(price) ? { price } : {},
349
+ quantity,
350
+ ...filledQuantity !== void 0 ? { filledQuantity } : {},
351
+ dryRun: false,
352
+ timestamp: Number.isFinite(timestamp) ? timestamp : Date.now()
353
+ };
354
+ }
355
+ /** 只读持仓(SWAP 的 pos 单位是张 → 经 ctVal 换算成币;net 模式负 pos = short)。 */
356
+ async getPositions() {
357
+ const credentials = await this.getCredentials();
358
+ const rows = await this.client.getPositions(this.auth(credentials));
359
+ const positions = [];
360
+ for (const row of rows) {
361
+ const d = row;
362
+ const instId = typeof d.instId === "string" ? d.instId : void 0;
363
+ const pos = typeof d.pos === "string" || typeof d.pos === "number" ? Number(d.pos) : NaN;
364
+ if (instId === void 0 || !Number.isFinite(pos)) continue;
365
+ let size = Math.abs(pos);
366
+ if (instId.endsWith("-SWAP")) try {
367
+ const instrument = await this.getInstrument(instId);
368
+ if (instrument.ctVal !== void 0) size = size * instrument.ctVal;
369
+ } catch {}
370
+ const posSide = d.posSide === "long" || d.posSide === "short" ? d.posSide : "net";
371
+ const side = posSide === "net" ? pos >= 0 ? "long" : "short" : posSide;
372
+ const entryPrice = typeof d.avgPx === "string" ? Number(d.avgPx) : void 0;
373
+ const markPrice = typeof d.markPx === "string" ? Number(d.markPx) : void 0;
374
+ const unrealizedPnl = typeof d.upl === "string" ? Number(d.upl) : void 0;
375
+ const leverage = typeof d.lever === "string" ? Number(d.lever) : void 0;
376
+ const timestamp = typeof d.uTime === "string" ? Number(d.uTime) : Date.now();
377
+ positions.push({
378
+ symbol: toCanonicalOkxSymbol(instId),
379
+ side,
380
+ size,
381
+ ...entryPrice !== void 0 && Number.isFinite(entryPrice) ? { entryPrice } : {},
382
+ ...markPrice !== void 0 && Number.isFinite(markPrice) ? { markPrice } : {},
383
+ ...unrealizedPnl !== void 0 && Number.isFinite(unrealizedPnl) ? { unrealizedPnl } : {},
384
+ ...leverage !== void 0 && Number.isFinite(leverage) ? { leverage } : {},
385
+ timestamp: Number.isFinite(timestamp) ? timestamp : Date.now()
386
+ });
387
+ }
388
+ return positions;
389
+ }
390
+ /** 只读余额(TradeService 契约外扩展,crypto_get_balance 工具消费)。 */
391
+ async getBalances() {
392
+ const credentials = await this.getCredentials();
393
+ const rows = await this.client.getBalance(this.auth(credentials));
394
+ const balances = [];
395
+ for (const row of rows) {
396
+ const account = row;
397
+ const details = Array.isArray(account.details) ? account.details : [];
398
+ for (const detail of details) {
399
+ const d = detail;
400
+ const asset = typeof d.ccy === "string" ? d.ccy : void 0;
401
+ if (asset === void 0) continue;
402
+ const free = pickNumber(d.availEq, d.availBal, d.eq);
403
+ const locked = pickNumber(d.frozenBal);
404
+ balances.push({
405
+ asset,
406
+ free: free ?? 0,
407
+ locked: locked ?? 0
408
+ });
409
+ }
410
+ }
411
+ return balances;
412
+ }
413
+ /** SWAP 的 sz/accFillSz/fillSz(张)→ base 币数(规格缓存,非 SWAP 原值)。 */
414
+ async toCoins(instId, exchangeAmount) {
415
+ const n = typeof exchangeAmount === "string" || typeof exchangeAmount === "number" ? Number(exchangeAmount) : NaN;
416
+ if (!Number.isFinite(n)) return void 0;
417
+ if (!instId.endsWith("-SWAP")) return n;
418
+ try {
419
+ const instrument = await this.getInstrument(instId);
420
+ return instrument.ctVal !== void 0 ? n * instrument.ctVal : n;
421
+ } catch {
422
+ return n;
423
+ }
424
+ }
425
+ /** 当前挂单(TradeService 可选契约,issue #40;只读、需凭证)。 */
426
+ async listOpenOrders(symbol) {
427
+ const instId = symbol !== void 0 && symbol !== "" ? normalizeOkxSymbol(symbol) : void 0;
428
+ const credentials = await this.getCredentials();
429
+ const rows = await this.client.listPendingOrders(instId, this.auth(credentials));
430
+ const orders = [];
431
+ for (const row of rows) {
432
+ const d = row;
433
+ const ordId = typeof d.ordId === "string" ? d.ordId : void 0;
434
+ const rawInstId = typeof d.instId === "string" ? d.instId : void 0;
435
+ if (ordId === void 0 || rawInstId === void 0) continue;
436
+ const quantity = await this.toCoins(rawInstId, d.sz);
437
+ if (quantity === void 0) continue;
438
+ const filledQuantity = await this.toCoins(rawInstId, d.accFillSz);
439
+ const price = typeof d.px === "string" && d.px !== "" ? Number(d.px) : typeof d.avgPx === "string" && d.avgPx !== "" ? Number(d.avgPx) : void 0;
440
+ const state = typeof d.state === "string" ? d.state : "";
441
+ const timestamp = typeof d.uTime === "string" ? Number(d.uTime) : typeof d.cTime === "string" ? Number(d.cTime) : Date.now();
442
+ orders.push({
443
+ id: ordId,
444
+ symbol: toCanonicalOkxSymbol(rawInstId),
445
+ side: d.side === "sell" ? "sell" : "buy",
446
+ type: d.ordType === "market" ? "market" : "limit",
447
+ status: state === "partially_filled" ? "partially_filled" : "new",
448
+ ...price !== void 0 && Number.isFinite(price) ? { price } : {},
449
+ quantity,
450
+ ...filledQuantity !== void 0 ? { filledQuantity } : {},
451
+ dryRun: false,
452
+ timestamp: Number.isFinite(timestamp) ? timestamp : Date.now()
453
+ });
454
+ }
455
+ return orders;
456
+ }
457
+ /** 最近成交流水(TradeService 可选契约,issue #40;只读、需凭证,时间升序)。 */
458
+ async listTradeFills(symbol, limit = 50) {
459
+ const instId = symbol !== void 0 && symbol !== "" ? normalizeOkxSymbol(symbol) : void 0;
460
+ const capped = Math.max(1, Math.min(Math.floor(limit) || 50, 100));
461
+ const credentials = await this.getCredentials();
462
+ const rows = await this.client.listFillsHistory(instId, capped, this.auth(credentials));
463
+ const fills = [];
464
+ for (const row of rows) {
465
+ const d = row;
466
+ const rawInstId = typeof d.instId === "string" ? d.instId : void 0;
467
+ const price = pickNumber(d.fillPx);
468
+ if (rawInstId === void 0 || price === void 0) continue;
469
+ const amount = await this.toCoins(rawInstId, d.fillSz);
470
+ if (amount === void 0) continue;
471
+ const fee = pickNumber(d.fee);
472
+ const feeAsset = typeof d.feeCcy === "string" && d.feeCcy !== "" ? d.feeCcy : void 0;
473
+ fills.push({
474
+ id: typeof d.billId === "string" ? d.billId : String(d.billId ?? ""),
475
+ symbol: toCanonicalOkxSymbol(rawInstId),
476
+ side: d.side === "sell" ? "sell" : "buy",
477
+ price,
478
+ amount,
479
+ ...fee !== void 0 ? { fee: Math.abs(fee) } : {},
480
+ ...feeAsset !== void 0 ? { feeAsset } : {},
481
+ timestamp: pickNumber(d.ts) ?? Date.now()
482
+ });
483
+ }
484
+ return fills.reverse();
485
+ }
486
+ };
487
+ function pickNumber(...values) {
488
+ for (const value of values) {
489
+ const n = typeof value === "string" && value !== "" ? Number(value) : typeof value === "number" ? value : NaN;
490
+ if (Number.isFinite(n)) return n;
491
+ }
492
+ }
493
+ /** OKX state → api OrderStatus(本切片 vocab:live/partially_filled/filled/canceled)。 */
494
+ function mapOrderState(state) {
495
+ switch (state) {
496
+ case "live": return "new";
497
+ case "partially_filled": return "partially_filled";
498
+ case "filled": return "filled";
499
+ case "canceled":
500
+ case "mmp_canceled": return "canceled";
501
+ default: return "rejected";
502
+ }
503
+ }
504
+ function evaluateOrderGate(config, args) {
505
+ const requestedDryRun = args.dryRun ?? true;
506
+ if (!requestedDryRun && !config.liveTrading) return {
507
+ action: "reject",
508
+ code: "TRADING_LIVE_TRADING_DISABLED",
509
+ message: `crypto_place_order rejected: the call requests real execution (dryRun=${String(args.dryRun)}) but live trading is disabled (liveTrading=false). Ask the user to enable liveTrading explicitly after confirmation, or keep dryRun=true for a simulated fill.`
510
+ };
511
+ if (requestedDryRun || config.dryRun) return { action: "simulate" };
512
+ return {
513
+ action: "live",
514
+ environment: config.env
515
+ };
516
+ }
517
+ /** 参数校验(模型调用问题抛普通 Error;服务故障才用错误词汇,connector-binance 先例)。 */
518
+ function validatePlaceOrderArgs(args) {
519
+ try {
520
+ normalizeOkxSymbol(args.instId);
521
+ } catch {
522
+ throw new Error(`crypto_place_order: invalid instId ${JSON.stringify(args.instId)} — expected market-canonical (BTCUSDT / BTCUSDT-SWAP) or OKX native (BTC-USDT / BTC-USDT-SWAP)`);
523
+ }
524
+ if (args.side !== "buy" && args.side !== "sell") throw new Error(`crypto_place_order: invalid side ${JSON.stringify(args.side)} — expected buy or sell`);
525
+ if (args.type !== "market" && args.type !== "limit") throw new Error(`crypto_place_order: invalid type ${JSON.stringify(args.type)} — expected market or limit`);
526
+ if (typeof args.quantity !== "number" || !Number.isFinite(args.quantity) || args.quantity <= 0) throw new Error(`crypto_place_order: invalid quantity ${JSON.stringify(args.quantity)} — expected a positive base-asset quantity`);
527
+ if (args.type === "limit" && (typeof args.price !== "number" || !Number.isFinite(args.price) || args.price <= 0)) throw new Error("crypto_place_order: LIMIT orders require a positive price");
528
+ }
529
+ function normalizePlaceOrderArgs(raw) {
530
+ const args = raw ?? {};
531
+ const instId = typeof args.instId === "string" ? args.instId.trim().toUpperCase() : void 0;
532
+ return {
533
+ ...args,
534
+ instId
535
+ };
536
+ }
537
+ async function buildDryRunReceipt(args, marketData) {
538
+ let reference;
539
+ try {
540
+ const ticker = await marketData.getTicker(args.instId);
541
+ reference = {
542
+ source: "okx-public-ticker",
543
+ price: ticker.price,
544
+ bid: ticker.bid,
545
+ ask: ticker.ask,
546
+ timestamp: ticker.timestamp
547
+ };
548
+ } catch (error) {
549
+ reference = {
550
+ source: "okx-public-ticker",
551
+ unavailable: error instanceof Error ? error.message : String(error)
552
+ };
553
+ }
554
+ return JSON.stringify({
555
+ status: "filled",
556
+ dryRun: true,
557
+ note: "DRY-RUN — simulated fill; no order was sent to OKX. The reference price is market data only, not a fill price.",
558
+ id: `dry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
559
+ instId: args.instId,
560
+ side: args.side,
561
+ type: args.type,
562
+ quantity: args.quantity,
563
+ quantityUnit: "base-asset coins (SWAP orders would be converted to contracts by ctVal)",
564
+ ...args.type === "limit" ? { price: args.price } : {},
565
+ reference,
566
+ timestamp: Date.now()
567
+ });
568
+ }
569
+ /**
570
+ * crypto_place_order 工具工厂(独立导出便于单测三态闸门矩阵)。
571
+ *
572
+ * 审批不在这里做:dryRun!==true 的调用由 @dshtrading/base 的 gate 插件在
573
+ * `tools/pre-execute` waterfall 统一 ask(headless 下 ask=deny,fail-closed);
574
+ * 工具内不再重复调 ctx.approval。
575
+ */
576
+ function createPlaceOrderTool(deps) {
577
+ return defineTool({
578
+ name: "crypto_place_order",
579
+ description: "Place an OKX spot or perpetual-swap (SWAP) order, or simulate one. instId accepts market-canonical (BTCUSDT, BTCUSDT-SWAP) or OKX native (BTC-USDT, BTC-USDT-SWAP) vocabulary. quantity is in BASE-ASSET coins: spot MARKET orders are sent with tgtCcy=base_ccy (OKX default for buys is quote-currency amount — a known trap), and SWAP quantities are converted to contracts via ctVal automatically. dryRun defaults to true and returns a DRY-RUN simulated fill receipt with the current market price as reference. Real execution (dryRun=false) requires the plugin liveTrading switch plus user approval; with env=demo (default) the order is signed and routed to the OKX demo exchange (simulated trading), env=live is real money.",
580
+ parameters: {
581
+ instId: {
582
+ type: "string",
583
+ required: true,
584
+ description: "Instrument id — market-canonical (BTCUSDT spot / BTCUSDT-SWAP perpetual) or OKX native (BTC-USDT / BTC-USDT-SWAP)"
585
+ },
586
+ side: {
587
+ type: "string",
588
+ enum: ["buy", "sell"],
589
+ required: true,
590
+ description: "Order side (OKX lowercase vocabulary)"
591
+ },
592
+ type: {
593
+ type: "string",
594
+ enum: ["market", "limit"],
595
+ required: true,
596
+ description: "Order type (OKX ordType)"
597
+ },
598
+ quantity: {
599
+ type: "number",
600
+ required: true,
601
+ description: "Base asset quantity in coins (e.g. 0.01 BTC); SWAP orders are converted to contracts internally"
602
+ },
603
+ price: {
604
+ type: "number",
605
+ description: "Limit price; required when type=limit"
606
+ },
607
+ dryRun: {
608
+ type: "boolean",
609
+ description: "true (default) = simulate only and return a DRY-RUN receipt; false = request real execution (gated by liveTrading, env and user approval)",
610
+ default: true
611
+ }
612
+ },
613
+ output: {
614
+ schema: { type: "string" },
615
+ render: (_args, value) => [{
616
+ type: "text",
617
+ text: value
618
+ }]
619
+ },
620
+ async execute(raw) {
621
+ const args = normalizePlaceOrderArgs(raw);
622
+ validatePlaceOrderArgs(args);
623
+ const verdict = evaluateOrderGate(deps.config, args);
624
+ if (verdict.action === "reject") return JSON.stringify({
625
+ status: "rejected",
626
+ code: verdict.code,
627
+ message: verdict.message
628
+ });
629
+ if (verdict.action === "simulate") return buildDryRunReceipt(args, deps.marketData);
630
+ const order = await deps.trade.placeOrder({
631
+ symbol: args.instId,
632
+ side: args.side,
633
+ type: args.type,
634
+ quantity: args.quantity,
635
+ ...args.type === "limit" ? { price: args.price } : {},
636
+ dryRun: false
637
+ });
638
+ return JSON.stringify(order);
639
+ }
640
+ });
641
+ }
642
+ /**
643
+ * 互斥激活的注册面:dsh-tools 对同名重复注册直接抛错(会炸 boot/preset 挂载),
644
+ * 而互斥纪律下「同时至多一个连接器激活」只是配置约定。这里把冲突降级为
645
+ * 「先到先得 + log」:已被占用(binance 或 kit-crypto 先注册)的名字跳过。
646
+ */
647
+ function registerTool(ctx, tool, log) {
648
+ const tools = ctx.tools;
649
+ if (tools.get(tool.name) !== void 0) {
650
+ log.warn("[dsh-trading-crypto-connector-okx] tool %s already registered by another provider — skipped (mutual exclusion: at most one crypto connector/toolset may be active)", tool.name);
651
+ return;
652
+ }
653
+ tools.register(tool);
654
+ }
655
+ function logger(ctx) {
656
+ const service = ctx.logger;
657
+ return typeof service === "function" ? service(name) : console;
658
+ }
659
+ /** 本连接器的路由 provider slug(docs/exchange-routing.md §2.2)。 */
660
+ const ROUTER_PROVIDER = "okx";
661
+ function apply(ctx, config) {
662
+ const log = logger(ctx);
663
+ if (!config.enabled) {
664
+ log.info("[dsh-trading-crypto-connector-okx] not activated (enabled=false) — tradingCryptoMarketData/tradingCryptoTrade and crypto_* tools stay unregistered");
665
+ return;
666
+ }
667
+ const router = ctx.get?.("tradingMarketRouter", false);
668
+ const active = router?.activeProvider("crypto");
669
+ if (router !== void 0 && active !== "okx") {
670
+ log.info("[dsh-trading-crypto-connector-okx] market router selects %s for crypto — not activated; set dshtrading.markets.crypto.provider to okx to use this connector", String(active ?? "(unset)"));
671
+ return;
672
+ }
673
+ const client = new OkxRestClient();
674
+ const marketData = new OkxMarketDataService(ctx, {}, client);
675
+ const trade = new OkxTradeService(ctx, {
676
+ client,
677
+ config,
678
+ getCredentials: () => resolveCredentials(ctx, config)
679
+ });
680
+ ctx.inject(["tradingCryptoMarketData"], () => {
681
+ registerTool(ctx, createGetIndicatorsTool({
682
+ marketData,
683
+ providerLabel: "okx"
684
+ }), log);
685
+ registerTool(ctx, defineTool({
686
+ name: "crypto_get_ticker",
687
+ description: "Get the latest public ticker (last price, bid/ask, 24h volume) for an OKX instrument via the OKX public REST API. instId accepts market-canonical (BTCUSDT spot, BTCUSDT-SWAP perpetual) or OKX native vocabulary. No credentials required.",
688
+ parameters: { instId: {
689
+ type: "string",
690
+ required: true,
691
+ description: "Instrument id — market-canonical (BTCUSDT / BTCUSDT-SWAP) or OKX native (BTC-USDT / BTC-USDT-SWAP)"
692
+ } },
693
+ output: {
694
+ schema: { type: "string" },
695
+ render: (_args, value) => [{
696
+ type: "text",
697
+ text: value
698
+ }]
699
+ },
700
+ async execute(args) {
701
+ const ticker = await marketData.getTicker(args.instId);
702
+ return JSON.stringify(ticker);
703
+ }
704
+ }), log);
705
+ registerTool(ctx, defineTool({
706
+ name: "crypto_get_klines",
707
+ description: "Get recent public klines (candles: open/high/low/close/volume) for an OKX instrument via the OKX public REST API. Intervals use the dsh-trading vocabulary (1m..1M); 1d maps to OKX 1Dutc (UTC day boundary, consistent with Binance daily bars). No credentials required.",
708
+ parameters: {
709
+ instId: {
710
+ type: "string",
711
+ required: true,
712
+ description: "Instrument id — market-canonical (BTCUSDT / BTCUSDT-SWAP) or OKX native (BTC-USDT / BTC-USDT-SWAP)"
713
+ },
714
+ interval: {
715
+ type: "string",
716
+ enum: [...OKX_INTERVAL_VOCABULARY],
717
+ description: "Kline interval (dsh-trading vocabulary; no 8h — OKX has no 8-hour bar)",
718
+ default: "1h"
719
+ },
720
+ limit: {
721
+ type: "integer",
722
+ description: "Number of candles to return (1-300, OKX max 300)",
723
+ default: 100
724
+ }
725
+ },
726
+ output: {
727
+ schema: { type: "string" },
728
+ render: (_args, value) => [{
729
+ type: "text",
730
+ text: value
731
+ }]
732
+ },
733
+ async execute(args) {
734
+ const interval = args.interval ?? "1h";
735
+ const limit = args.limit ?? 100;
736
+ const klines = await marketData.getKlines(args.instId, interval, limit);
737
+ return JSON.stringify(klines);
738
+ }
739
+ }), log);
740
+ registerTool(ctx, defineTool({
741
+ name: "crypto_funding_rate",
742
+ description: "Get the current and next funding rate for an OKX perpetual swap (instId like BTCUSDT-SWAP or BTC-USDT-SWAP) via the OKX public REST API. No credentials required.",
743
+ parameters: { instId: {
744
+ type: "string",
745
+ required: true,
746
+ description: "OKX perpetual swap instrument id, e.g. BTCUSDT-SWAP (canonical) or BTC-USDT-SWAP (native)"
747
+ } },
748
+ output: {
749
+ schema: { type: "string" },
750
+ render: (_args, value) => [{
751
+ type: "text",
752
+ text: value
753
+ }]
754
+ },
755
+ async execute(args) {
756
+ const funding = await marketData.getFundingRate(args.instId);
757
+ return JSON.stringify(funding);
758
+ }
759
+ }), log);
760
+ });
761
+ ctx.inject(["tradingCryptoTrade"], () => {
762
+ registerTool(ctx, createPlaceOrderTool({
763
+ marketData,
764
+ trade,
765
+ config
766
+ }), log);
767
+ registerTool(ctx, defineTool({
768
+ name: "crypto_cancel_order",
769
+ description: "Cancel an OKX order by (instId, ordId). Cancelling an already-terminal order (filled/canceled) is reported as already-terminal, not an error.",
770
+ parameters: {
771
+ instId: {
772
+ type: "string",
773
+ required: true,
774
+ description: "Instrument id the order belongs to — canonical (BTCUSDT) or native (BTC-USDT)"
775
+ },
776
+ ordId: {
777
+ type: "string",
778
+ required: true,
779
+ description: "OKX order id (ordId from place/get order)"
780
+ }
781
+ },
782
+ output: {
783
+ schema: { type: "string" },
784
+ render: (_args, value) => [{
785
+ type: "text",
786
+ text: value
787
+ }]
788
+ },
789
+ async execute(args) {
790
+ await trade.cancelOrder(args.ordId, args.instId);
791
+ return JSON.stringify({
792
+ status: "canceled",
793
+ instId: args.instId,
794
+ ordId: args.ordId,
795
+ timestamp: Date.now()
796
+ });
797
+ }
798
+ }), log);
799
+ registerTool(ctx, defineTool({
800
+ name: "crypto_get_order",
801
+ description: "Query one OKX order by (instId, ordId): state, filled quantity, average price. Read-only.",
802
+ parameters: {
803
+ instId: {
804
+ type: "string",
805
+ required: true,
806
+ description: "Instrument id — canonical (BTCUSDT / BTCUSDT-SWAP) or native (BTC-USDT)"
807
+ },
808
+ ordId: {
809
+ type: "string",
810
+ required: true,
811
+ description: "OKX order id"
812
+ }
813
+ },
814
+ output: {
815
+ schema: { type: "string" },
816
+ render: (_args, value) => [{
817
+ type: "text",
818
+ text: value
819
+ }]
820
+ },
821
+ async execute(args) {
822
+ const order = await trade.getOrder(args.instId, args.ordId);
823
+ return JSON.stringify(order);
824
+ }
825
+ }), log);
826
+ registerTool(ctx, defineTool({
827
+ name: "crypto_get_balance",
828
+ description: "Read the OKX account balances (available/frozen per currency) via the signed REST API. Requires the configured credential refs to resolve.",
829
+ parameters: {},
830
+ output: {
831
+ schema: { type: "string" },
832
+ render: (_args, value) => [{
833
+ type: "text",
834
+ text: value
835
+ }]
836
+ },
837
+ async execute() {
838
+ const balances = await trade.getBalances();
839
+ return JSON.stringify({
840
+ env: config.env,
841
+ simulated: config.env === "demo",
842
+ balances
843
+ });
844
+ }
845
+ }), log);
846
+ registerTool(ctx, defineTool({
847
+ name: "crypto_get_positions",
848
+ description: "Read the OKX account positions (size converted from contracts to coins for swaps, entry/mark price, unrealized PnL, leverage). Read-only.",
849
+ parameters: {},
850
+ output: {
851
+ schema: { type: "string" },
852
+ render: (_args, value) => [{
853
+ type: "text",
854
+ text: value
855
+ }]
856
+ },
857
+ async execute() {
858
+ const positions = await trade.getPositions();
859
+ return JSON.stringify({
860
+ env: config.env,
861
+ simulated: config.env === "demo",
862
+ positions
863
+ });
864
+ }
865
+ }), log);
866
+ });
867
+ }
868
+ //#endregion
869
+ export { BAR_MAP, Config, OKX_INTERVAL_VOCABULARY, OkxMarketDataService, OkxRestClient, OkxTradeService, ROUTER_PROVIDER, TRADING_CRYPTO_MARKET_DATA_KEY, TRADING_CRYPTO_TRADE_KEY, TradingServiceError, apply, barDurationMs, buildAuthHeaders, buildDryRunReceipt, createPlaceOrderTool, credentialRefsFor, evaluateOrderGate, inject, isoTimestamp, mapOrderState, name, normalizeOkxSymbol, normalizeSize, resolveCredentials, signPayload, signaturePrehash, toBar, toCanonicalOkxSymbol, toOkxSwapInstId };