@dshtrading/connector-ccxt 0.6.1 → 0.6.3

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/rest.js CHANGED
@@ -24,6 +24,13 @@ const INTERVAL_VOCABULARY = [
24
24
  "1w",
25
25
  "1M"
26
26
  ];
27
+ /**
28
+ * 登记的所名 = 本包真有 ticker 直连分支的所名(九条)。
29
+ *
30
+ * 此前这里登记 9 个所名、却只有 bybit/okx 两个具名分支,其余 6 个静默落 Binance 兜底 URL
31
+ * (选 kraken 拿到 Binance 的报价)。上一轮把登记面收窄到 3 所;本轮六条分支补齐,
32
+ * 登记面重新等于实现面。未登记所名仍在**任何 fetch 之前**结构化拒绝(见 notImplementedError)。
33
+ */
27
34
  const SUPPORTED_EXCHANGES = [
28
35
  "binance",
29
36
  "okx",
@@ -36,6 +43,45 @@ const SUPPORTED_EXCHANGES = [
36
43
  "htx"
37
44
  ];
38
45
  /**
46
+ * K 线登记面(2026-10-15 起九条直连分支)。
47
+ *
48
+ * 上一轮曾把 K 线面撤成单分支(binance)并把「按所取 K 线」记为另一张卡;本卡就是那张卡:
49
+ * 九所各有自己的 K 线端点、周期词汇与行结构,周期映射表见 KLINE_INTERVAL_PARAMS。
50
+ * 未登记所名(如 bitstamp)仍在**任何 fetch 之前**结构化拒绝(零请求)。
51
+ */
52
+ const KLINE_EXCHANGES = [
53
+ "binance",
54
+ "okx",
55
+ "bybit",
56
+ "gateio",
57
+ "kucoin",
58
+ "kraken",
59
+ "coinbase",
60
+ "bitfinex",
61
+ "htx"
62
+ ];
63
+ const IMPLEMENTED_EXCHANGES = {
64
+ ticker: SUPPORTED_EXCHANGES,
65
+ klines: KLINE_EXCHANGES
66
+ };
67
+ const UNIMPLEMENTED_HINT = {
68
+ ticker: "ticker has nine implemented direct branches (binance/okx/bybit/gateio/kucoin/kraken/coinbase/bitfinex/htx): use one of them, or the dedicated per-exchange packages @dshtrading/connector-binance / @dshtrading/connector-okx / @dshtrading/connector-bybit.",
69
+ klines: "klines have nine implemented direct branches (binance/okx/bybit/gateio/kucoin/kraken/coinbase/bitfinex/htx): use one of them, or the dedicated per-exchange packages @dshtrading/connector-binance / @dshtrading/connector-okx / @dshtrading/connector-bybit."
70
+ };
71
+ /** 未实现所名的统一拒绝构造:两处调用点共用,禁止各自复制文案。 */
72
+ function notImplementedError(surface, exchange) {
73
+ return new TradingServiceError("TRADING_NOT_IMPLEMENTED", `CCXT connector has no ${surface} branch for exchange "${exchange}" (implemented: ${IMPLEMENTED_EXCHANGES[surface].join(", ")}); zero network requests were made. ${UNIMPLEMENTED_HINT[surface]}`);
74
+ }
75
+ /**
76
+ * 行级判空的统一拒绝:上游答了、但没有这个标的的行(≠ 网络故障、≠ 上游故障)。
77
+ *
78
+ * 上游明确报「查不到」的三种形态都归到这里:ticker 的空行、K 线的错误信封,
79
+ * 以及 requestJson 里的 HTTP 404 / 400 + `label=INVALID_*`。
80
+ */
81
+ function symbolNotFoundError(surface, exchange, symbol) {
82
+ return new TradingServiceError("TRADING_SYMBOL_NOT_FOUND", `CCXT/${exchange} ${surface} not found: ${symbol} (the venue answered, but without a usable row for this pair)`);
83
+ }
84
+ /**
39
85
  * 现货符号归一化(本轮 CCXT 面只做现货)。
40
86
  *
41
87
  * 入参带规范永续后缀 `-SWAP` 时**显式报 TRADING_UNSUPPORTED_SYMBOL**:此前实现会先剥掉
@@ -49,6 +95,413 @@ function normalizeSymbol(raw) {
49
95
  if (clean.includes("/")) return clean.replace("/", "");
50
96
  return clean;
51
97
  }
98
+ /**
99
+ * 各所 quote 资产表(**按所维护**,2026-10-15 起)。
100
+ *
101
+ * 数据来源 = 该所公开挂牌端点的逐所普查(原始响应 `census-*.json` 与派生命题见
102
+ * `spikes/impl-ccxt-exchange-expansion/EVIDENCE-2026-10-15-quote-census-and-klines.md`):
103
+ * - okx `GET /api/v5/public/instruments?instType=SPOT` → `quoteCcy`(剔除空串)
104
+ * - kucoin `GET /api/v2/symbols` → `quoteCurrency`
105
+ * - gateio `GET /api/v4/spot/currency_pairs` → `quote`
106
+ * - kraken `GET /0/public/AssetPairs` → `wsname` 的 quote 段(kraken 的 `quote` 字段是
107
+ * Z/X 前缀的旧代号 ZEUR/XXBT,而请求符号用现代形 EUR/XBT)
108
+ * - coinbase `GET /products` → `quote_currency`
109
+ * - bitfinex `GET /v2/conf/pub:list:pair:exchange` + `.../pub:list:currency` → 最长货币后缀
110
+ * (`UST` 归一成用户写法的 `USDT`;`:` 分隔的挂牌对按分隔符切开)
111
+ * - htx `GET /v2/settings/common/symbols` → `qcdn`(大写展示形)
112
+ *
113
+ * 表只登记上游确实挂牌的 quote:表外计价对在七条变换分支上仍是本地
114
+ * `TRADING_UNSUPPORTED_SYMBOL` 且零请求——「猜一个 quote」等于给上游发错符号,比本地拒绝更糟。
115
+ * 每条表都由 `spikes/impl-ccxt-exchange-expansion/run-probe.ts` 按同一条普查规则现场复核
116
+ * (表 = 普查结果),上游新增 quote 而表未跟进即探针失败。
117
+ */
118
+ const QUOTE_TABLES = {
119
+ okx: [
120
+ "AED",
121
+ "AUD",
122
+ "BRL",
123
+ "BTC",
124
+ "ETH",
125
+ "EUR",
126
+ "PLN",
127
+ "SGD",
128
+ "SOL",
129
+ "TRY",
130
+ "USD",
131
+ "USDC",
132
+ "USDT"
133
+ ],
134
+ kucoin: [
135
+ "BTC",
136
+ "DOGE",
137
+ "ETH",
138
+ "EUR",
139
+ "KCS",
140
+ "TRX",
141
+ "USD1",
142
+ "USDC",
143
+ "USDG",
144
+ "USDT"
145
+ ],
146
+ gateio: [
147
+ "BTC",
148
+ "ETH",
149
+ "GUSD",
150
+ "RLUSD",
151
+ "USD",
152
+ "USD1",
153
+ "USDC",
154
+ "USDT"
155
+ ],
156
+ kraken: [
157
+ "AUD",
158
+ "AUSD",
159
+ "CAD",
160
+ "CHF",
161
+ "DAI",
162
+ "ETH",
163
+ "EUR",
164
+ "EURC",
165
+ "EUROP",
166
+ "FIDD",
167
+ "GBP",
168
+ "JPY",
169
+ "PYUSD",
170
+ "RLUSD",
171
+ "SOFID",
172
+ "SOL",
173
+ "USD",
174
+ "USD1",
175
+ "USDC",
176
+ "USDD",
177
+ "USDQ",
178
+ "USDR",
179
+ "USDT",
180
+ "XBT"
181
+ ],
182
+ coinbase: [
183
+ "AUD",
184
+ "BRL",
185
+ "BTC",
186
+ "CAD",
187
+ "DAI",
188
+ "ETH",
189
+ "EUR",
190
+ "GBP",
191
+ "INR",
192
+ "SGD",
193
+ "USD",
194
+ "USDC",
195
+ "USDT"
196
+ ],
197
+ bitfinex: [
198
+ "BTC",
199
+ "ETH",
200
+ "EUR",
201
+ "GBP",
202
+ "TESTUSD",
203
+ "TESTUSDT",
204
+ "USD",
205
+ "USDT",
206
+ "XAUT"
207
+ ],
208
+ htx: [
209
+ "BRL",
210
+ "BTC",
211
+ "ETH",
212
+ "EUR",
213
+ "EUROC",
214
+ "GBP",
215
+ "HT",
216
+ "HUSD",
217
+ "KGST",
218
+ "PYUSD",
219
+ "RUB",
220
+ "TRX",
221
+ "TRY",
222
+ "TUSD",
223
+ "UAH",
224
+ "USD1",
225
+ "USDC",
226
+ "USDD",
227
+ "USDT",
228
+ "USTC"
229
+ ]
230
+ };
231
+ /**
232
+ * 裸符号 → `{ base, quote }`,按**该所**的 quote 表最长优先匹配。
233
+ *
234
+ * 只服务需要形态变换的七条分支(`requestSymbol`);binance/bybit 把裸符号原样发上游,
235
+ * 不经过这里——上游存在 `ETHBNB`/`BTCFDUSD` 这类不在任何 quote 表里的计价对。
236
+ * base 为空(如 `USDT`)或后缀不在该所表内(如 gateio 的 `BTCJPY`、kraken 的 `BTCBRL`)
237
+ * → TRADING_UNSUPPORTED_SYMBOL,文案点名该所与该所的 quote 表:猜一个 quote 会变成给上游发错符号。
238
+ */
239
+ function splitPair(symbol, exchange) {
240
+ const clean = symbol.trim().toUpperCase();
241
+ const table = QUOTE_TABLES[exchange];
242
+ const quotes = [...table].sort((a, b) => b.length - a.length);
243
+ for (const quote of quotes) if (clean.length > quote.length && clean.endsWith(quote)) return {
244
+ base: clean.slice(0, clean.length - quote.length),
245
+ quote
246
+ };
247
+ throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `CCXT connector cannot split "${symbol}" into base+quote for ${exchange}: ${exchange} quotes are ${[...table].sort().join(", ")} (longest match first, base must be non-empty).`);
248
+ }
249
+ /**
250
+ * 各所现货端点的请求符号形态(入参 = `normalizeSymbol` 的裸符号,如 BTCUSDT)。
251
+ *
252
+ * okx/kucoin/coinbase 用 `-`、gateio 用 `_`、kraken 把 BTC 记作 XBT、bitfinex 加 `t`
253
+ * 前缀且 USDT 记作 UST、htx 全小写。导出让离线用例与探针能直接断言拼出来的符号形态
254
+ * (不必为了看一个 URL 去打网络)。
255
+ */
256
+ function requestSymbol(exchange, sym) {
257
+ const { base, quote } = splitPair(sym, exchange);
258
+ switch (exchange) {
259
+ case "okx":
260
+ case "kucoin":
261
+ case "coinbase": return `${base}-${quote}`;
262
+ case "gateio": return `${base}_${quote}`;
263
+ case "kraken": return `${base === "BTC" ? "XBT" : base}${quote}`;
264
+ case "bitfinex": return `t${base}${quote === "USDT" ? "UST" : quote}`;
265
+ case "htx": return `${base}${quote}`.toLowerCase();
266
+ }
267
+ }
268
+ /**
269
+ * 上游字段读成数值:缺失、空串或非有限数一律 undefined(判空统一走 symbolNotFoundError)。
270
+ * 各所报价字段有的是字符串(gateio/kucoin/coinbase/bithumb 系)有的是数字(bitfinex/htx 系)。
271
+ */
272
+ function toNumber(value) {
273
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
274
+ if (typeof value === "string" && value.trim() !== "") {
275
+ const parsed = Number(value);
276
+ return Number.isFinite(parsed) ? parsed : void 0;
277
+ }
278
+ }
279
+ /**
280
+ * 400 + JSON 正文里 `label` 以 `INVALID_` 开头 = 上游明确说「这个符号/币种不存在」,
281
+ * 不是基础设施故障(gateio 实测 `{"label":"INVALID_CURRENCY_PAIR"}` / `INVALID_CURRENCY`)。
282
+ * 正文不是 JSON 时一律返回 false(bybit 的 403 正文就是非 JSON 的拦截片段)。
283
+ */
284
+ function invalidSymbolLabel(body) {
285
+ try {
286
+ const parsed = JSON.parse(body);
287
+ return typeof parsed?.label === "string" && parsed.label.startsWith("INVALID_");
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+ /** 上游正文的取证片段:压成单行、截断约 200 字符(进 message,不进日志正文)。 */
293
+ function bodySnippet(body) {
294
+ return body.replace(/\s+/g, " ").trim().slice(0, 200);
295
+ }
296
+ /**
297
+ * api `Interval` → 各所 K 线周期参数(缺键 = 该所不支持,抛 TRADING_UNSUPPORTED_INTERVAL 且零请求)。
298
+ *
299
+ * 逐所实测口径(原始响应见 `spikes/impl-ccxt-exchange-expansion/klines-*.json` 与同目录 EVIDENCE):
300
+ * - binance 原文直发;bybit `1/5/15/30/60/240/D/W/M`
301
+ * - okx bar 取 **utc 变体**(`1Dutc`/`1Wutc`/`1Mutc`):与 connector-okx 同一裁决,
302
+ * 日界对齐 UTC(Binance 口径);okx 的 `1D` 是 UTC+8 零点,跨连接器会错位 8 小时
303
+ * - gateio 无 1M(实测 400 `invalid interval 1M`);1w 用上游的 `7d`
304
+ * - kucoin 无 1M(实测 400 `Incorrect candlestick type`)
305
+ * - kraken 周期是**分钟数**(1w=10080、1M=21600)
306
+ * - coinbase granularity 是**秒**,且实测只接受 60/300/900/3600/21600/86400:30m→1800 与
307
+ * 4h→14400 都回 400 `Unsupported granularity`(1w/1M 同),故本词汇表内只支持 1m/5m/15m/1h/1d
308
+ * - bitfinex `1m..1h` 小写、日线及以上大写(`1D`/`1W`/`1M`);4h 实测上游接受
309
+ * - htx `1min/5min/…/60min/4hour/1day/1mon/1week`
310
+ */
311
+ const KLINE_INTERVAL_PARAMS = {
312
+ binance: {
313
+ "1m": "1m",
314
+ "5m": "5m",
315
+ "15m": "15m",
316
+ "30m": "30m",
317
+ "1h": "1h",
318
+ "4h": "4h",
319
+ "1d": "1d",
320
+ "1w": "1w",
321
+ "1M": "1M"
322
+ },
323
+ okx: {
324
+ "1m": "1m",
325
+ "5m": "5m",
326
+ "15m": "15m",
327
+ "30m": "30m",
328
+ "1h": "1H",
329
+ "4h": "4H",
330
+ "1d": "1Dutc",
331
+ "1w": "1Wutc",
332
+ "1M": "1Mutc"
333
+ },
334
+ bybit: {
335
+ "1m": "1",
336
+ "5m": "5",
337
+ "15m": "15",
338
+ "30m": "30",
339
+ "1h": "60",
340
+ "4h": "240",
341
+ "1d": "D",
342
+ "1w": "W",
343
+ "1M": "M"
344
+ },
345
+ gateio: {
346
+ "1m": "1m",
347
+ "5m": "5m",
348
+ "15m": "15m",
349
+ "30m": "30m",
350
+ "1h": "1h",
351
+ "4h": "4h",
352
+ "1d": "1d",
353
+ "1w": "7d"
354
+ },
355
+ kucoin: {
356
+ "1m": "1min",
357
+ "5m": "5min",
358
+ "15m": "15min",
359
+ "30m": "30min",
360
+ "1h": "1hour",
361
+ "4h": "4hour",
362
+ "1d": "1day",
363
+ "1w": "1week"
364
+ },
365
+ kraken: {
366
+ "1m": "1",
367
+ "5m": "5",
368
+ "15m": "15",
369
+ "30m": "30",
370
+ "1h": "60",
371
+ "4h": "240",
372
+ "1d": "1440",
373
+ "1w": "10080",
374
+ "1M": "21600"
375
+ },
376
+ coinbase: {
377
+ "1m": "60",
378
+ "5m": "300",
379
+ "15m": "900",
380
+ "1h": "3600",
381
+ "1d": "86400"
382
+ },
383
+ bitfinex: {
384
+ "1m": "1m",
385
+ "5m": "5m",
386
+ "15m": "15m",
387
+ "30m": "30m",
388
+ "1h": "1h",
389
+ "4h": "4h",
390
+ "1d": "1D",
391
+ "1w": "1W",
392
+ "1M": "1M"
393
+ },
394
+ htx: {
395
+ "1m": "1min",
396
+ "5m": "5min",
397
+ "15m": "15min",
398
+ "30m": "30min",
399
+ "1h": "60min",
400
+ "4h": "4hour",
401
+ "1d": "1day",
402
+ "1w": "1week",
403
+ "1M": "1mon"
404
+ }
405
+ };
406
+ /** 周期毫秒时长(closeTime 补算用;1M 以 30 天近似,与 connector-okx 同口径)。 */
407
+ const INTERVAL_MS = {
408
+ "1m": 6e4,
409
+ "3m": 18e4,
410
+ "5m": 3e5,
411
+ "15m": 9e5,
412
+ "30m": 18e5,
413
+ "1h": 36e5,
414
+ "2h": 72e5,
415
+ "4h": 144e5,
416
+ "6h": 216e5,
417
+ "8h": 288e5,
418
+ "12h": 432e5,
419
+ "1d": 864e5,
420
+ "3d": 2592e5,
421
+ "1w": 6048e5,
422
+ "1M": 2592e6
423
+ };
424
+ /** 某所支持的周期(映射表键集;供离线用例与探针断言,不在运行时按上游探测)。 */
425
+ function klineSupportedIntervals(exchange) {
426
+ return Object.keys(KLINE_INTERVAL_PARAMS[exchange]);
427
+ }
428
+ /**
429
+ * `Interval` → 该所周期参数;该所不支持(coinbase 的 30m/4h/1w/1M、gateio/kucoin 的 1M、
430
+ * 以及任何词汇表外取值)→ TRADING_UNSUPPORTED_INTERVAL,**在 fetch 之前**(零请求)。
431
+ */
432
+ function klineIntervalParam(exchange, interval) {
433
+ const param = KLINE_INTERVAL_PARAMS[exchange][interval];
434
+ if (param === void 0) throw new TradingServiceError("TRADING_UNSUPPORTED_INTERVAL", `CCXT/${exchange} klines: unsupported interval ${String(interval)} — ${exchange} supports ${klineSupportedIntervals(exchange).join("/")}; no network request was made.`);
435
+ return param;
436
+ }
437
+ /** 单根 K 线的形状判空:任一分量非有限数即整根丢弃(丢行,不猜值)。 */
438
+ function klineRow(openTime, open, high, low, close, volume, closeTime) {
439
+ const o = toNumber(open);
440
+ const h = toNumber(high);
441
+ const l = toNumber(low);
442
+ const c = toNumber(close);
443
+ const v = toNumber(volume);
444
+ if (!Number.isFinite(openTime) || !Number.isFinite(closeTime)) return void 0;
445
+ if (o === void 0 || h === void 0 || l === void 0 || c === void 0 || v === void 0) return void 0;
446
+ return {
447
+ openTime,
448
+ open: o,
449
+ high: h,
450
+ low: l,
451
+ close: c,
452
+ volume: v,
453
+ closeTime
454
+ };
455
+ }
456
+ /**
457
+ * 行集归一:按 openTime 去重、**升序(旧→新)**排序、只保留最新 limit 根。
458
+ *
459
+ * 九所里只有 binance 与 kraken 的上游序列本来就是升序;okx/kucoin/gateio/coinbase/bitfinex/htx/bybit
460
+ * 都是新→旧。统一在这里归一,消费方拿到的序列与 connector-binance 同向。
461
+ */
462
+ function finalizeKlines(rows, limit) {
463
+ const byOpenTime = /* @__PURE__ */ new Map();
464
+ for (const row of rows) {
465
+ if (row === void 0) continue;
466
+ byOpenTime.set(row.openTime, row);
467
+ }
468
+ const sorted = [...byOpenTime.values()].sort((a, b) => a.openTime - b.openTime);
469
+ return limit > 0 ? sorted.slice(-limit) : sorted;
470
+ }
471
+ /**
472
+ * 各所单请求的根数参数与上限(超出上限只请求上限内的根数,**不翻页**)。
473
+ * kucoin / kraken / coinbase 的上游没有根数参数(kucoin 固定回 1500、kraken 720、coinbase 300),
474
+ * 一律留空并在 finalizeKlines 里切成 limit 根。
475
+ */
476
+ const KLINE_LIMIT_PARAMS = {
477
+ binance: {
478
+ query: "limit",
479
+ max: 1e3
480
+ },
481
+ okx: {
482
+ query: "limit",
483
+ max: 300
484
+ },
485
+ bybit: {
486
+ query: "limit",
487
+ max: 1e3
488
+ },
489
+ gateio: {
490
+ query: "limit",
491
+ max: 1e3
492
+ },
493
+ kucoin: void 0,
494
+ kraken: void 0,
495
+ coinbase: void 0,
496
+ bitfinex: {
497
+ query: "limit",
498
+ max: 1e4
499
+ },
500
+ htx: {
501
+ query: "size",
502
+ max: 2e3
503
+ }
504
+ };
52
505
  var CcxtRestClient = class {
53
506
  exchange;
54
507
  apiKey;
@@ -63,7 +516,13 @@ var CcxtRestClient = class {
63
516
  async requestJson(url) {
64
517
  try {
65
518
  const res = await this.fetchImpl(url, { headers: { "Accept": "application/json" } });
66
- if (!res.ok) throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `CCXT HTTP ${res.status}: ${res.statusText}`);
519
+ if (!res.ok) {
520
+ const body = await res.text().catch(() => "");
521
+ const snippet = bodySnippet(body);
522
+ if (res.status === 404) throw new TradingServiceError("TRADING_SYMBOL_NOT_FOUND", `CCXT HTTP 404 ${res.statusText}: ${snippet} (${url})`);
523
+ if (res.status === 400 && invalidSymbolLabel(body)) throw new TradingServiceError("TRADING_SYMBOL_NOT_FOUND", `CCXT HTTP 400 ${res.statusText}: ${snippet}`);
524
+ throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `CCXT HTTP ${res.status} ${res.statusText}: ${snippet}`);
525
+ }
67
526
  return await res.json();
68
527
  } catch (err) {
69
528
  if (err instanceof TradingServiceError) throw err;
@@ -72,10 +531,11 @@ var CcxtRestClient = class {
72
531
  }
73
532
  async getTicker(symbol, exchange = this.exchange) {
74
533
  const sym = normalizeSymbol(symbol);
534
+ if (!SUPPORTED_EXCHANGES.includes(exchange)) throw notImplementedError("ticker", exchange);
75
535
  if (exchange === "bybit") {
76
536
  const url = `https://api.bybit.com/v5/market/tickers?category=spot&symbol=${sym}`;
77
537
  const row = (await this.requestJson(url)).result?.list?.[0];
78
- if (!row) throw new TradingServiceError("TRADING_SYMBOL_NOT_FOUND", `CCXT/Bybit ticker not found: ${symbol}`);
538
+ if (!row) throw symbolNotFoundError("ticker", "bybit", symbol);
79
539
  return {
80
540
  symbol: sym,
81
541
  price: parseFloat(row.lastPrice),
@@ -84,9 +544,9 @@ var CcxtRestClient = class {
84
544
  };
85
545
  }
86
546
  if (exchange === "okx") {
87
- const url = `https://www.okx.com/api/v5/market/ticker?instId=${symbol.includes("-") ? symbol : `${sym.replace("USDT", "")}-USDT`}`;
547
+ const url = `https://www.okx.com/api/v5/market/ticker?instId=${requestSymbol("okx", sym)}`;
88
548
  const row = (await this.requestJson(url)).data?.[0];
89
- if (!row) throw new TradingServiceError("TRADING_SYMBOL_NOT_FOUND", `CCXT/OKX ticker not found: ${symbol}`);
549
+ if (!row) throw symbolNotFoundError("ticker", "okx", symbol);
90
550
  return {
91
551
  symbol: sym,
92
552
  price: parseFloat(row.last),
@@ -94,6 +554,88 @@ var CcxtRestClient = class {
94
554
  timestamp: parseInt(row.ts, 10)
95
555
  };
96
556
  }
557
+ if (exchange === "gateio") {
558
+ const url = `https://api.gateio.ws/api/v4/spot/tickers?currency_pair=${requestSymbol("gateio", sym)}`;
559
+ const res = await this.requestJson(url);
560
+ const row = Array.isArray(res) ? res[0] : void 0;
561
+ const price = toNumber(row?.last);
562
+ const volume = toNumber(row?.base_volume);
563
+ if (price === void 0 || volume === void 0) throw symbolNotFoundError("ticker", "gateio", symbol);
564
+ return {
565
+ symbol: sym,
566
+ price,
567
+ volume,
568
+ timestamp: Date.now()
569
+ };
570
+ }
571
+ if (exchange === "kucoin") {
572
+ const url = `https://api.kucoin.com/api/v1/market/stats?symbol=${requestSymbol("kucoin", sym)}`;
573
+ const res = await this.requestJson(url);
574
+ const price = toNumber(res.data?.last);
575
+ const volume = toNumber(res.data?.vol);
576
+ if (res.code !== "200000" || price === void 0 || volume === void 0) throw symbolNotFoundError("ticker", "kucoin", symbol);
577
+ return {
578
+ symbol: sym,
579
+ price,
580
+ volume,
581
+ timestamp: typeof res.data?.time === "number" && Number.isFinite(res.data.time) ? res.data.time : Date.now()
582
+ };
583
+ }
584
+ if (exchange === "kraken") {
585
+ const url = `https://api.kraken.com/0/public/Ticker?pair=${requestSymbol("kraken", sym)}`;
586
+ const res = await this.requestJson(url);
587
+ const key = Object.keys(res.result ?? {})[0];
588
+ const row = key === void 0 ? void 0 : res.result?.[key];
589
+ const price = toNumber(row?.c?.[0]);
590
+ const volume = toNumber(row?.v?.[1]);
591
+ if ((res.error?.length ?? 0) > 0 || price === void 0 || volume === void 0) throw symbolNotFoundError("ticker", "kraken", symbol);
592
+ return {
593
+ symbol: sym,
594
+ price,
595
+ volume,
596
+ timestamp: Date.now()
597
+ };
598
+ }
599
+ if (exchange === "coinbase") {
600
+ const url = `https://api.exchange.coinbase.com/products/${requestSymbol("coinbase", sym)}/ticker`;
601
+ const res = await this.requestJson(url);
602
+ const price = toNumber(res.price);
603
+ const volume = toNumber(res.volume);
604
+ if (price === void 0 || volume === void 0) throw symbolNotFoundError("ticker", "coinbase", symbol);
605
+ return {
606
+ symbol: sym,
607
+ price,
608
+ volume,
609
+ timestamp: Date.now()
610
+ };
611
+ }
612
+ if (exchange === "bitfinex") {
613
+ const url = `https://api-pub.bitfinex.com/v2/ticker/${requestSymbol("bitfinex", sym)}`;
614
+ const res = await this.requestJson(url);
615
+ if (!Array.isArray(res) || res.length === 0) throw symbolNotFoundError("ticker", "bitfinex", symbol);
616
+ const price = toNumber(res[6]);
617
+ const volume = toNumber(res[7]);
618
+ if (price === void 0 || volume === void 0) throw symbolNotFoundError("ticker", "bitfinex", symbol);
619
+ return {
620
+ symbol: sym,
621
+ price,
622
+ volume,
623
+ timestamp: Date.now()
624
+ };
625
+ }
626
+ if (exchange === "htx") {
627
+ const url = `https://api.htx.com/market/detail/merged?symbol=${requestSymbol("htx", sym)}`;
628
+ const res = await this.requestJson(url);
629
+ const price = toNumber(res.tick?.close);
630
+ const volume = toNumber(res.tick?.amount);
631
+ if (res.status !== "ok" || price === void 0 || volume === void 0) throw symbolNotFoundError("ticker", "htx", symbol);
632
+ return {
633
+ symbol: sym,
634
+ price,
635
+ volume,
636
+ timestamp: typeof res.ts === "number" && Number.isFinite(res.ts) ? res.ts : Date.now()
637
+ };
638
+ }
97
639
  const url = `https://api.binance.com/api/v3/ticker/24hr?symbol=${sym}`;
98
640
  const data = await this.requestJson(url);
99
641
  return {
@@ -103,26 +645,127 @@ var CcxtRestClient = class {
103
645
  timestamp: data.closeTime
104
646
  };
105
647
  }
648
+ /**
649
+ * K 线九条直连分支(2026-10-15 起):周期按所映射(KLINE_INTERVAL_PARAMS)、行结构按所解析,
650
+ * 输出一律**升序(旧→新)且只含最新 limit 根**。
651
+ *
652
+ * `exchange` 是**连接器自有形参**(缺省 = 客户端默认所名):`@dshtrading/api` 的
653
+ * `MarketDataService.getKlines(symbol, interval, limit?)` 没有这个形参,服务缝保持与契约同形,
654
+ * 形参只在本客户端方法与 `crypto_get_klines` 工具上暴露(见包 README「周期与契约」)。
655
+ * 未登记所名 → TRADING_NOT_IMPLEMENTED、该所不支持的周期 → TRADING_UNSUPPORTED_INTERVAL,
656
+ * 两者都在 fetch 之前(零请求)。
657
+ */
106
658
  async getKlines(symbol, interval = "1d", limit = 100, exchange = this.exchange) {
107
- const url = `https://api.binance.com/api/v3/klines?symbol=${normalizeSymbol(symbol)}&interval=${interval}&limit=${limit}`;
108
- const data = await this.requestJson(url);
109
- if (!Array.isArray(data)) return [];
110
- return data.map((r) => ({
111
- openTime: r[0],
112
- open: parseFloat(r[1]),
113
- high: parseFloat(r[2]),
114
- low: parseFloat(r[3]),
115
- close: parseFloat(r[4]),
116
- volume: parseFloat(r[5]),
117
- closeTime: r[6]
118
- }));
119
- }
120
- async getBalance() {
121
- return {
122
- currency: "USDT",
123
- available: 1e5,
124
- total: 1e5
125
- };
659
+ const sym = normalizeSymbol(symbol);
660
+ if (!KLINE_EXCHANGES.includes(exchange)) throw notImplementedError("klines", exchange);
661
+ const venue = exchange;
662
+ const period = klineIntervalParam(venue, interval);
663
+ const stepMs = INTERVAL_MS[interval];
664
+ const cap = KLINE_LIMIT_PARAMS[venue];
665
+ const cappedLimit = cap === void 0 ? void 0 : Math.min(Math.max(limit, 1), cap.max);
666
+ const limitQuery = cap === void 0 || cappedLimit === void 0 ? "" : `&${cap.query}=${cappedLimit}`;
667
+ const limitQueryStart = cap === void 0 || cappedLimit === void 0 ? "" : `?${cap.query}=${cappedLimit}`;
668
+ switch (venue) {
669
+ case "binance": {
670
+ const url = `https://api.binance.com/api/v3/klines?symbol=${sym}&interval=${period}${limitQuery}`;
671
+ const data = await this.requestJson(url);
672
+ if (!Array.isArray(data)) return [];
673
+ return finalizeKlines(data.map((raw) => {
674
+ if (!Array.isArray(raw)) return void 0;
675
+ const [openTime, open, high, low, close, volume, closeTime] = raw;
676
+ return klineRow(Number(openTime), open, high, low, close, volume, Number(closeTime));
677
+ }), limit);
678
+ }
679
+ case "bybit": {
680
+ const url = `https://api.bybit.com/v5/market/kline?category=spot&symbol=${sym}&interval=${period}&limit=${cappedLimit ?? 200}`;
681
+ const data = await this.requestJson(url);
682
+ if (data.retCode !== 0) throw symbolNotFoundError("klines", "bybit", symbol);
683
+ return finalizeKlines((Array.isArray(data.result?.list) ? data.result.list : []).map((raw) => {
684
+ if (!Array.isArray(raw)) return void 0;
685
+ const [openTime, open, high, low, close, volume] = raw;
686
+ const t = Number(openTime);
687
+ return klineRow(t, open, high, low, close, volume, t + stepMs - 1);
688
+ }), limit);
689
+ }
690
+ case "okx": {
691
+ const url = `https://www.okx.com/api/v5/market/candles?instId=${requestSymbol("okx", sym)}&bar=${period}${limitQuery}`;
692
+ const data = await this.requestJson(url);
693
+ if (data.code !== "0") throw symbolNotFoundError("klines", "okx", symbol);
694
+ return finalizeKlines((Array.isArray(data.data) ? data.data : []).map((raw) => {
695
+ if (!Array.isArray(raw)) return void 0;
696
+ const [ts, open, high, low, close, volume] = raw;
697
+ const t = Number(ts);
698
+ return klineRow(t, open, high, low, close, volume, t + stepMs - 1);
699
+ }), limit);
700
+ }
701
+ case "gateio": {
702
+ const url = `https://api.gateio.ws/api/v4/spot/candlesticks?currency_pair=${requestSymbol("gateio", sym)}&interval=${period}${limitQuery}`;
703
+ const data = await this.requestJson(url);
704
+ if (!Array.isArray(data)) return [];
705
+ return finalizeKlines(data.map((raw) => {
706
+ if (!Array.isArray(raw)) return void 0;
707
+ const [ts, , close, high, low, open, baseVolume] = raw;
708
+ const t = Number(ts) * 1e3;
709
+ return klineRow(t, open, high, low, close, baseVolume, t + stepMs - 1);
710
+ }), limit);
711
+ }
712
+ case "kucoin": {
713
+ const url = `https://api.kucoin.com/api/v1/market/candles?symbol=${requestSymbol("kucoin", sym)}&type=${period}`;
714
+ const data = await this.requestJson(url);
715
+ if (data.code !== "200000") throw symbolNotFoundError("klines", "kucoin", symbol);
716
+ return finalizeKlines((Array.isArray(data.data) ? data.data : []).map((raw) => {
717
+ if (!Array.isArray(raw)) return void 0;
718
+ const [ts, open, close, high, low, volume] = raw;
719
+ const t = Number(ts) * 1e3;
720
+ return klineRow(t, open, high, low, close, volume, t + stepMs - 1);
721
+ }), limit);
722
+ }
723
+ case "kraken": {
724
+ const url = `https://api.kraken.com/0/public/OHLC?pair=${requestSymbol("kraken", sym)}&interval=${period}`;
725
+ const data = await this.requestJson(url);
726
+ if ((data.error?.length ?? 0) > 0) throw symbolNotFoundError("klines", "kraken", symbol);
727
+ const rows = Object.values(data.result ?? {}).find((value) => Array.isArray(value));
728
+ if (!Array.isArray(rows)) return [];
729
+ return finalizeKlines(rows.map((raw) => {
730
+ if (!Array.isArray(raw)) return void 0;
731
+ const [ts, open, high, low, close, , volume] = raw;
732
+ const t = Number(ts) * 1e3;
733
+ return klineRow(t, open, high, low, close, volume, t + stepMs - 1);
734
+ }), limit);
735
+ }
736
+ case "coinbase": {
737
+ const url = `https://api.exchange.coinbase.com/products/${requestSymbol("coinbase", sym)}/candles?granularity=${period}`;
738
+ const data = await this.requestJson(url);
739
+ if (!Array.isArray(data)) return [];
740
+ return finalizeKlines(data.map((raw) => {
741
+ if (!Array.isArray(raw)) return void 0;
742
+ const [ts, low, high, open, close, volume] = raw;
743
+ const t = Number(ts) * 1e3;
744
+ return klineRow(t, open, high, low, close, volume, t + stepMs - 1);
745
+ }), limit);
746
+ }
747
+ case "bitfinex": {
748
+ const url = `https://api-pub.bitfinex.com/v2/candles/trade:${period}:${requestSymbol("bitfinex", sym)}/hist${limitQueryStart}`;
749
+ const data = await this.requestJson(url);
750
+ if (!Array.isArray(data)) return [];
751
+ return finalizeKlines(data.map((raw) => {
752
+ if (!Array.isArray(raw)) return void 0;
753
+ const [mts, open, close, high, low, volume] = raw;
754
+ const t = Number(mts);
755
+ return klineRow(t, open, high, low, close, volume, t + stepMs - 1);
756
+ }), limit);
757
+ }
758
+ case "htx": {
759
+ const url = `https://api.htx.com/market/history/kline?symbol=${requestSymbol("htx", sym)}&period=${period}${limitQuery}`;
760
+ const data = await this.requestJson(url);
761
+ if (data.status !== "ok") throw symbolNotFoundError("klines", "htx", symbol);
762
+ return finalizeKlines((Array.isArray(data.data) ? data.data : []).map((raw) => {
763
+ const row = raw;
764
+ const t = Number(row.id) * 1e3;
765
+ return klineRow(t, row.open, row.high, row.low, row.close, row.amount, t + stepMs - 1);
766
+ }), limit);
767
+ }
768
+ }
126
769
  }
127
770
  async placeOrder(_creds, req) {
128
771
  const sym = normalizeSymbol(req.symbol);
@@ -146,4 +789,4 @@ var CcxtRestClient = class {
146
789
  }
147
790
  };
148
791
  //#endregion
149
- export { CcxtRestClient, INTERVAL_VOCABULARY, SUPPORTED_EXCHANGES, TradingServiceError, normalizeSymbol };
792
+ export { CcxtRestClient, INTERVAL_VOCABULARY, KLINE_EXCHANGES, QUOTE_TABLES, SUPPORTED_EXCHANGES, TradingServiceError, klineIntervalParam, klineSupportedIntervals, normalizeSymbol, requestSymbol, splitPair };