@imbingox/acex 0.3.0-beta.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
@@ -125,6 +125,26 @@ bun run type-check
125
125
  bun run test
126
126
  ```
127
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
+
128
148
  ### 真实环境 smoke / soak 脚本
129
149
 
130
150
  不进默认 `bun run test`,单独执行:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.3.0-beta.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",
@@ -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
  {
@@ -241,6 +254,64 @@ export class MarketManagerImpl
241
254
  .map((market) => cloneMarketDefinition(market));
242
255
  }
243
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
+
244
315
  getL1Book(key: MarketKeyInput): L1Book | undefined {
245
316
  const book = this.records.get(marketKey(key))?.l1Book;
246
317
  return book ? cloneL1Book(book) : undefined;
@@ -414,6 +485,20 @@ export class MarketManagerImpl
414
485
  return market;
415
486
  }
416
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
+
417
502
  private assertSupportedExchange(exchange: Exchange): void {
418
503
  if (exchange === this.adapter.exchange) {
419
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 {}
@@ -144,6 +171,7 @@ export interface MarketManager {
144
171
  getMarket(exchange: Exchange, symbol: string): MarketDefinition | undefined;
145
172
  getMarkets(symbol: string): MarketDefinition[];
146
173
  listMarkets(exchange?: Exchange): MarketDefinition[];
174
+ normalizeOrderInput(input: NormalizeOrderInputInput): NormalizedOrderInput;
147
175
  getL1Book(key: MarketKeyInput): L1Book | undefined;
148
176
  getL1Books(symbol: string): L1Book[];
149
177
  getFundingRate(key: MarketKeyInput): FundingRateSnapshot | undefined;
@@ -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 {