@imbingox/acex 0.2.0 → 0.3.0-beta.1

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/README.md CHANGED
@@ -29,7 +29,9 @@ const book = client.market.getL1Book({
29
29
  exchange: "binance",
30
30
  symbol: "BTC/USDT:USDT",
31
31
  });
32
+ const books = client.market.getL1Books("BTC/USDT:USDT");
32
33
  console.log(`bid=${book?.bidPrice.toFixed()} ask=${book?.askPrice.toFixed()}`);
34
+ console.log(`venues=${books.length}`);
33
35
  console.log(`book freshness=${book?.status.freshness}`);
34
36
 
35
37
  await client.market.subscribeFundingRate({
@@ -41,7 +43,9 @@ const funding = client.market.getFundingRate({
41
43
  exchange: "binance",
42
44
  symbol: "BTC/USDT:USDT",
43
45
  });
46
+ const fundingRates = client.market.getFundingRates("BTC/USDT:USDT");
44
47
  console.log(`funding=${funding?.fundingRate.toFixed()}`);
48
+ console.log(`funding venues=${fundingRates.length}`);
45
49
 
46
50
  for await (const event of client.market.events.l1BookUpdates({
47
51
  exchange: "binance",
@@ -121,6 +125,26 @@ bun run type-check
121
125
  bun run test
122
126
  ```
123
127
 
128
+ ### 测试分层
129
+
130
+ 默认 `bun run test` 只运行快速、确定性的本地测试,不访问真实交易所:
131
+
132
+ | 命令 | 覆盖范围 | 是否进入默认 CI |
133
+ |------|----------|----------------|
134
+ | `bun run test:unit` | `tests/unit/`,底层工具和无全局副作用的单元测试 | 是 |
135
+ | `bun run test:integration` | `tests/integration/`,fake REST + fake WebSocket 的 SDK 跨层集成测试 | 是 |
136
+ | `bun run test` | `test:unit` + `test:integration` | 是 |
137
+ | `bun run test:soak` | `tests/soak/`,60 秒级稳定性/连续更新测试 | 否 |
138
+ | `bun run test:all` | 默认快速测试 + soak 测试 | 否 |
139
+
140
+ 测试 support 结构:
141
+
142
+ - `tests/support/test-utils.ts`:通用 fake WebSocket、事件等待、Response helper 和全局清理。
143
+ - `tests/support/exchanges/binance.ts`:Binance 专用 REST/WS fixtures 与 installer。
144
+ - 新增交易所时,优先新增 `tests/support/exchanges/<exchange>.ts`,复用通用 helper,避免把交易所 payload 写进通用测试工具。
145
+
146
+ GitHub Actions 的 `CI` workflow 会在 PR 和 `main` push 时运行 lint、type-check、unit、integration;release workflow 继续复用 `bun run test`,不会执行 soak/live。
147
+
124
148
  ### 真实环境 smoke / soak 脚本
125
149
 
126
150
  不进默认 `bun run test`,单独执行:
@@ -141,7 +165,7 @@ bun run test:live:order:soak
141
165
 
142
166
  覆盖内容:
143
167
 
144
- - `market`:`loadMarkets()`、`subscribeL1Book()`、`subscribeFundingRate()`、`getL1Book()` / `getFundingRate()`、对应事件流和可选断线重连(`--disconnect-target funding` 可单独验证资金费率重连)
168
+ - `market`:`loadMarkets()`、`subscribeL1Book()`、`subscribeFundingRate()`、`getL1Book()` / `getL1Books()`、`getFundingRate()` / `getFundingRates()`、对应事件流和可选断线重连(`--disconnect-target funding` 可单独验证资金费率重连)
145
169
  - `account`:Binance PAPI UM 账户 bootstrap、余额/仓位/风险投影、private stream 更新和可选重连
146
170
  - `order`:open orders bootstrap、`subscribeOrders()`、订单事件投影和可选重连
147
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.2.0",
3
+ "version": "0.3.0-beta.1",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  "lint:fix": "biome check --write .",
27
27
  "release": "changeset publish",
28
28
  "type-check": "tsc --noEmit",
29
- "test": "bun test --max-concurrency=1",
29
+ "test": "bun test --max-concurrency=1 tests/unit tests/integration",
30
30
  "test:live:account": "bun run scripts/live-account-smoke.ts",
31
31
  "test:live:account:smoke": "bun run scripts/live-account-smoke.ts --duration 10",
32
32
  "test:live:account:soak": "bun run scripts/live-account-smoke.ts --duration 60 --disconnect-after 5",
@@ -36,7 +36,11 @@
36
36
  "test:live:order": "bun run scripts/live-order-smoke.ts",
37
37
  "test:live:order:smoke": "bun run scripts/live-order-smoke.ts --duration 10",
38
38
  "test:live:order:soak": "bun run scripts/live-order-smoke.ts --duration 60 --disconnect-after 5",
39
- "version-packages": "changeset version && files=\"package.json\"; if [ -f .changeset/pre.json ]; then files=\"$files .changeset/pre.json\"; fi; if [ -f CHANGELOG.md ]; then files=\"$files CHANGELOG.md\"; fi; biome check --write $files"
39
+ "version-packages": "changeset version && files=\"package.json\"; if [ -f .changeset/pre.json ]; then files=\"$files .changeset/pre.json\"; fi; if [ -f CHANGELOG.md ]; then files=\"$files CHANGELOG.md\"; fi; biome check --write $files",
40
+ "test:unit": "bun test tests/unit",
41
+ "test:integration": "bun test --max-concurrency=1 tests/integration",
42
+ "test:soak": "bun test --max-concurrency=1 tests/soak",
43
+ "test:all": "bun run test && bun run test:soak"
40
44
  },
41
45
  "devDependencies": {
42
46
  "@biomejs/biome": "^2.4.10",
@@ -43,13 +43,13 @@ interface BinanceMarkPriceMessage {
43
43
  T?: number;
44
44
  }
45
45
 
46
- const BINANCE_USDM_WS_BASE_URL = "wss://fstream.binance.com/ws";
46
+ const BINANCE_USDM_MARKET_WS_BASE_URL = "wss://fstream.binance.com/market/ws";
47
47
  const BINANCE_COINM_WS_BASE_URL = "wss://dstream.binance.com/ws";
48
48
 
49
49
  function getWsBaseUrl(market: BinanceMarketDefinition): string {
50
50
  switch (market.family) {
51
51
  case "usdm":
52
- return BINANCE_USDM_WS_BASE_URL;
52
+ return BINANCE_USDM_MARKET_WS_BASE_URL;
53
53
  case "coinm":
54
54
  return BINANCE_COINM_WS_BASE_URL;
55
55
  case "spot":
@@ -60,7 +60,7 @@ function getWsBaseUrl(market: BinanceMarketDefinition): string {
60
60
  }
61
61
 
62
62
  function buildMarkPriceUrl(market: BinanceMarketDefinition): string {
63
- return `${getWsBaseUrl(market)}/${market.id.toLowerCase()}@markPrice@1s`;
63
+ return `${getWsBaseUrl(market)}/${market.id.toLowerCase()}@markPrice`;
64
64
  }
65
65
 
66
66
  function parseMarkPriceMessage(
@@ -551,7 +551,12 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
551
551
  type: encodeOrderType(request.type),
552
552
  quantity: request.amount,
553
553
  price: request.price,
554
- timeInForce: request.type === "limit" ? "GTC" : undefined,
554
+ timeInForce:
555
+ request.type === "limit"
556
+ ? request.postOnly === true
557
+ ? "GTX"
558
+ : "GTC"
559
+ : undefined,
555
560
  newClientOrderId: request.clientOrderId,
556
561
  reduceOnly:
557
562
  request.reduceOnly === undefined
@@ -154,6 +154,7 @@ export interface CreateOrderRequest {
154
154
  type: CreateOrderType;
155
155
  amount: string;
156
156
  price?: string;
157
+ postOnly?: boolean;
157
158
  clientOrderId?: string;
158
159
  reduceOnly?: boolean;
159
160
  positionSide?: PositionSide;
@@ -297,6 +297,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
297
297
  type: input.type,
298
298
  amount: input.amount,
299
299
  price: input.type === "limit" ? input.price : undefined,
300
+ postOnly: input.type === "limit" ? input.postOnly : undefined,
300
301
  clientOrderId: input.clientOrderId,
301
302
  reduceOnly: input.reduceOnly,
302
303
  positionSide: input.positionSide,
@@ -31,6 +31,8 @@ import type {
31
31
  MarketKeyInput,
32
32
  MarketManager,
33
33
  MarketStatusChangedEvent,
34
+ NormalizedOrderInput,
35
+ NormalizeOrderInputInput,
34
36
  SubscribeFundingRateInput,
35
37
  SubscribeL1BookInput,
36
38
  SubscriptionActivity,
@@ -91,6 +93,17 @@ function cloneMarketDefinition(definition: MarketDefinition): MarketDefinition {
91
93
  return { ...definition, raw: { ...definition.raw } };
92
94
  }
93
95
 
96
+ function floorToStep(value: BigNumber, step: BigNumber): BigNumber {
97
+ if (step.isLessThanOrEqualTo(0)) {
98
+ return value;
99
+ }
100
+ return value.dividedToIntegerBy(step).multipliedBy(step);
101
+ }
102
+
103
+ function normalizeDecimalInput(value: BigNumber): string {
104
+ return value.isFinite() ? value.toFixed() : value.toString();
105
+ }
106
+
94
107
  export class MarketManagerImpl
95
108
  implements MarketManager, ManagerLifecycle, HealthReporter<MarketDataStatus>
96
109
  {
@@ -224,9 +237,10 @@ export class MarketManagerImpl
224
237
  return market ? cloneMarketDefinition(market) : undefined;
225
238
  }
226
239
 
227
- findMarkets(symbol: string): MarketDefinition[] {
240
+ getMarkets(symbol: string): MarketDefinition[] {
228
241
  return [...this.definitions.values()]
229
242
  .filter((market) => market.symbol === symbol)
243
+ .sort((left, right) => left.exchange.localeCompare(right.exchange))
230
244
  .map((market) => cloneMarketDefinition(market));
231
245
  }
232
246
 
@@ -240,16 +254,96 @@ export class MarketManagerImpl
240
254
  .map((market) => cloneMarketDefinition(market));
241
255
  }
242
256
 
257
+ normalizeOrderInput(input: NormalizeOrderInputInput): NormalizedOrderInput {
258
+ const market = this.resolveLoadedMarket(input);
259
+ const rawPrice = new BigNumber(input.price);
260
+ const rawAmount = new BigNumber(input.amount);
261
+ const price = floorToStep(rawPrice, market.priceStep);
262
+ const amount = floorToStep(rawAmount, market.amountStep);
263
+
264
+ const normalized: NormalizedOrderInput = {
265
+ price: normalizeDecimalInput(price),
266
+ amount: normalizeDecimalInput(amount),
267
+ rawPrice: normalizeDecimalInput(rawPrice),
268
+ rawAmount: normalizeDecimalInput(rawAmount),
269
+ adjusted: !price.isEqualTo(rawPrice) || !amount.isEqualTo(rawAmount),
270
+ accepted: true,
271
+ priceStep: market.priceStep.toFixed(),
272
+ amountStep: market.amountStep.toFixed(),
273
+ minAmount: market.minAmount?.toFixed(),
274
+ minNotional: market.minNotional?.toFixed(),
275
+ };
276
+
277
+ if (!price.isFinite() || price.isLessThanOrEqualTo(0)) {
278
+ return {
279
+ ...normalized,
280
+ accepted: false,
281
+ rejectReason: "price_not_positive",
282
+ };
283
+ }
284
+
285
+ if (!amount.isFinite() || amount.isLessThanOrEqualTo(0)) {
286
+ return {
287
+ ...normalized,
288
+ accepted: false,
289
+ rejectReason: "amount_not_positive",
290
+ };
291
+ }
292
+
293
+ if (market.minAmount && amount.isLessThan(market.minAmount)) {
294
+ return {
295
+ ...normalized,
296
+ accepted: false,
297
+ rejectReason: "amount_below_min",
298
+ };
299
+ }
300
+
301
+ if (market.minNotional) {
302
+ const notional = amount.multipliedBy(price);
303
+ if (notional.isLessThan(market.minNotional)) {
304
+ return {
305
+ ...normalized,
306
+ accepted: false,
307
+ rejectReason: "notional_below_min",
308
+ };
309
+ }
310
+ }
311
+
312
+ return normalized;
313
+ }
314
+
243
315
  getL1Book(key: MarketKeyInput): L1Book | undefined {
244
316
  const book = this.records.get(marketKey(key))?.l1Book;
245
317
  return book ? cloneL1Book(book) : undefined;
246
318
  }
247
319
 
320
+ getL1Books(symbol: string): L1Book[] {
321
+ return [...this.records.values()]
322
+ .filter(
323
+ (record): record is MarketRecord & { l1Book: L1Book } =>
324
+ record.symbol === symbol && Boolean(record.l1Book),
325
+ )
326
+ .map((record) => cloneL1Book(record.l1Book))
327
+ .sort((left, right) => left.exchange.localeCompare(right.exchange));
328
+ }
329
+
248
330
  getFundingRate(key: MarketKeyInput): FundingRateSnapshot | undefined {
249
331
  const fundingRate = this.records.get(marketKey(key))?.fundingRate;
250
332
  return fundingRate ? cloneFundingRate(fundingRate) : undefined;
251
333
  }
252
334
 
335
+ getFundingRates(symbol: string): FundingRateSnapshot[] {
336
+ return [...this.records.values()]
337
+ .filter(
338
+ (
339
+ record,
340
+ ): record is MarketRecord & { fundingRate: FundingRateSnapshot } =>
341
+ record.symbol === symbol && Boolean(record.fundingRate),
342
+ )
343
+ .map((record) => cloneFundingRate(record.fundingRate))
344
+ .sort((left, right) => left.exchange.localeCompare(right.exchange));
345
+ }
346
+
253
347
  getMarketStatus(key: MarketKeyInput): MarketDataStatus | undefined {
254
348
  const status = this.records.get(marketKey(key))?.status;
255
349
  return status ? cloneMarketStatus(status) : undefined;
@@ -391,6 +485,20 @@ export class MarketManagerImpl
391
485
  return market;
392
486
  }
393
487
 
488
+ private resolveLoadedMarket(input: MarketKeyInput): MarketDefinition {
489
+ const market = this.definitions.get(marketKey(input));
490
+ if (!market) {
491
+ throw this.createError(
492
+ "MARKET_NOT_FOUND",
493
+ `Unknown market symbol: ${input.symbol}`,
494
+ { exchange: input.exchange, symbol: input.symbol },
495
+ "market",
496
+ );
497
+ }
498
+
499
+ return market;
500
+ }
501
+
394
502
  private assertSupportedExchange(exchange: Exchange): void {
395
503
  if (exchange === this.adapter.exchange) {
396
504
  return;
@@ -57,6 +57,33 @@ export interface MarketKeyInput {
57
57
  symbol: string;
58
58
  }
59
59
 
60
+ export type DecimalInput = string | number | BigNumber;
61
+
62
+ export type NormalizeOrderInputRejectReason =
63
+ | "price_not_positive"
64
+ | "amount_not_positive"
65
+ | "amount_below_min"
66
+ | "notional_below_min";
67
+
68
+ export interface NormalizeOrderInputInput extends MarketKeyInput {
69
+ price: DecimalInput;
70
+ amount: DecimalInput;
71
+ }
72
+
73
+ export interface NormalizedOrderInput {
74
+ price: string;
75
+ amount: string;
76
+ rawPrice: string;
77
+ rawAmount: string;
78
+ adjusted: boolean;
79
+ accepted: boolean;
80
+ rejectReason?: NormalizeOrderInputRejectReason;
81
+ priceStep: string;
82
+ amountStep: string;
83
+ minAmount?: string;
84
+ minNotional?: string;
85
+ }
86
+
60
87
  export interface SubscribeL1BookInput extends MarketKeyInput {}
61
88
 
62
89
  export interface SubscribeFundingRateInput extends MarketKeyInput {}
@@ -142,9 +169,12 @@ export interface MarketManager {
142
169
  unsubscribeFundingRate(input: SubscribeFundingRateInput): Promise<void>;
143
170
 
144
171
  getMarket(exchange: Exchange, symbol: string): MarketDefinition | undefined;
145
- findMarkets(symbol: string): MarketDefinition[];
172
+ getMarkets(symbol: string): MarketDefinition[];
146
173
  listMarkets(exchange?: Exchange): MarketDefinition[];
174
+ normalizeOrderInput(input: NormalizeOrderInputInput): NormalizedOrderInput;
147
175
  getL1Book(key: MarketKeyInput): L1Book | undefined;
176
+ getL1Books(symbol: string): L1Book[];
148
177
  getFundingRate(key: MarketKeyInput): FundingRateSnapshot | undefined;
178
+ getFundingRates(symbol: string): FundingRateSnapshot[];
149
179
  getMarketStatus(key: MarketKeyInput): MarketDataStatus | undefined;
150
180
  }
@@ -66,6 +66,7 @@ interface CreateOrderInputBase {
66
66
  export interface CreateLimitOrderInput extends CreateOrderInputBase {
67
67
  type: "limit";
68
68
  price: string;
69
+ postOnly?: boolean;
69
70
  }
70
71
 
71
72
  export interface CreateMarketOrderInput extends CreateOrderInputBase {