@imbingox/acex 0.4.0-beta.10 → 0.4.0-beta.12

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/docs/api.md CHANGED
@@ -430,6 +430,7 @@ interface OrderManager {
430
430
 
431
431
  - `createOrder()` 支持 `limit` / `market`
432
432
  - `limit` 可传 `postOnly: true`,Binance PAPI UM 映射为 `timeInForce=GTX`
433
+ - 未传 `clientOrderId` 时,`createOrder()` 由 SDK 生成合规 client id(`acex-` 前缀,≤32)并作为 Binance `newClientOrderId` 发送,返回 snapshot 的 `clientOrderId` 即该值;自带 `clientOrderId` 超长或含非法字符会抛 `ORDER_INPUT_INVALID`
433
434
  - `cancelOrder()` 必须传 `orderId` 或 `clientOrderId`
434
435
  - `cancelAllOrders()` 必须传 `symbol`,不支持账户级全撤
435
436
  - hedge mode 下必须显式传 `positionSide: "long" | "short"`
@@ -442,7 +443,9 @@ interface OrderManager {
442
443
 
443
444
  - OrderManager 内部按 open / closed 分层缓存订单。**closed(filled / canceled / rejected / expired)订单按 symbol 各保留最近 N 个**,`N = CreateClientOptions.order.maxClosedOrdersPerSymbol`(默认 500,非正或非整数回退默认),超限按 FIFO 裁剪最旧;**open 订单不受此上限限制**。`getOpenOrders()` 查询复杂度与历史终态订单数量无关。
444
445
  - `getOrder(input)` 需带 `orderId` 或 `clientOrderId`(否则返回 `undefined`),`symbol` 可选:
445
- - `clientOrderId` 查询可命中 open 与未被裁剪的 closed;同一 `clientOrderId` 命中多笔时返回**最新一笔**(精确定位历史某一笔请用 `orderId`)。
446
+ - **精确查单推荐传 `symbol + orderId`**(O(1) 精确索引、唯一命中)。
447
+ - 仅 `clientOrderId` 查询可命中 open 与未被裁剪的 closed;当 `clientOrderId` 唯一(你自定义的或 SDK 生成的 `acex-*`)时可精确命中,但同一 `clientOrderId` 命中多笔时返回**最新一笔**(精确定位历史某一笔请用 `symbol + orderId`)。
448
+ - 仅传 `orderId`(不带 `symbol`)时,跨 symbol 同 `orderId` 可能多命中,返回最新一笔;ADL / 系统单会共享 `clientOrderId`(如 `adl_autoclose`),必须用 `symbol + orderId` 精确定位。
446
449
  - 同时给 `orderId` 与 `clientOrderId` 时,两者都匹配才命中。
447
450
  - 已超出保留上限被裁剪的 closed 订单将查不到(返回 `undefined`)。
448
451
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.4.0-beta.10",
3
+ "version": "0.4.0-beta.12",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -93,6 +93,11 @@ interface BinancePapiOpenOrder {
93
93
  time?: number;
94
94
  }
95
95
 
96
+ interface BinancePapiCancelAllResponse {
97
+ code?: number | string;
98
+ msg?: string;
99
+ }
100
+
96
101
  interface BinanceListenKeyResponse {
97
102
  listenKey?: string;
98
103
  }
@@ -859,21 +864,53 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
859
864
  request: CancelAllOrdersRequest,
860
865
  accountOptions?: Record<string, unknown>,
861
866
  ): Promise<RawOrderUpdate[]> {
862
- const receivedAt = Date.now();
863
- const responses = await this.signedRequest<BinancePapiOpenOrder[]>(
867
+ const symbol = encodeUmSymbol(request.symbol);
868
+ const openOrders = await this.signedRequest<BinancePapiOpenOrder[]>(
869
+ "GET",
870
+ "/papi/v1/um/openOrders",
871
+ credentials,
872
+ accountOptions,
873
+ {
874
+ symbol,
875
+ },
876
+ SAFE_READ_RETRY_POLICY,
877
+ );
878
+
879
+ // Venue responds {code,msg}; returned updates are synthesized from the
880
+ // pre-fetch. Orders that fill between fetch and cancel are corrected by
881
+ // the WS terminal event / reconcile.
882
+ const response = await this.signedRequest<BinancePapiCancelAllResponse>(
864
883
  "DELETE",
865
884
  "/papi/v1/um/allOpenOrders",
866
885
  credentials,
867
886
  accountOptions,
868
887
  {
869
- symbol: encodeUmSymbol(request.symbol),
888
+ symbol,
870
889
  },
871
890
  NO_RETRY_POLICY,
872
891
  );
873
892
 
874
- return responses.flatMap((response) => {
875
- const mapped = mapOpenOrder(response, receivedAt);
876
- return mapped ? [mapped] : [];
893
+ if (response.code !== undefined && `${response.code}` !== "200") {
894
+ throw new Error(
895
+ `Binance PAPI cancelAllOrders failed: code=${response.code}, msg=${
896
+ response.msg ?? ""
897
+ }`,
898
+ );
899
+ }
900
+
901
+ const receivedAt = Date.now();
902
+ return openOrders.flatMap((order) => {
903
+ const mapped = mapOpenOrder(order, receivedAt);
904
+ return mapped
905
+ ? [
906
+ {
907
+ ...mapped,
908
+ status: "canceled",
909
+ exchangeTs: undefined,
910
+ receivedAt,
911
+ },
912
+ ]
913
+ : [];
877
914
  });
878
915
  }
879
916
 
@@ -19,6 +19,7 @@ import {
19
19
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
20
20
  import { toCanonical } from "../internal/decimal.ts";
21
21
  import { matchesOrderFilter } from "../internal/filters.ts";
22
+ import { isTransportError } from "../internal/http-client.ts";
22
23
  import {
23
24
  canDeleteMissingFromSnapshot,
24
25
  shouldApplyWatermarkedUpdate,
@@ -46,9 +47,11 @@ interface OrderRecord {
46
47
  subscribed: boolean;
47
48
  openOrders: Map<string, Map<string, OrderSnapshot>>;
48
49
  closedOrders: Map<string, Map<string, OrderSnapshot>>;
49
- orderIdIndex: Map<string, Map<string, OrderLocation>>;
50
- orderIdOnlyIndex: Map<string, Set<OrderLocation>>;
51
- clientOrderIdIndex: Map<string, Set<OrderLocation>>;
50
+ localOrderLocations: Map<string, OrderLocation>;
51
+ orderIdIndex: Map<string, Map<string, string>>;
52
+ orderIdOnlyIndex: Map<string, Set<string>>;
53
+ clientOrderIdIndex: Map<string, Set<string>>;
54
+ pendingClientOrderIdIndex: Map<string, PendingOrderClaim>;
52
55
  status: OrderDataStatus;
53
56
  }
54
57
 
@@ -57,7 +60,12 @@ type OrderTable = "open" | "closed";
57
60
  interface OrderLocation {
58
61
  table: OrderTable;
59
62
  symbol: string;
60
- key: string;
63
+ localOrderId: string;
64
+ }
65
+
66
+ interface PendingOrderClaim {
67
+ localOrderId: string;
68
+ symbol: string;
61
69
  }
62
70
 
63
71
  interface OrderManagerOptions {
@@ -65,6 +73,14 @@ interface OrderManagerOptions {
65
73
  }
66
74
 
67
75
  const DEFAULT_MAX_CLOSED_ORDERS_PER_SYMBOL = 500;
76
+ const SDK_CLIENT_ORDER_ID_PREFIX = "acex-";
77
+ const VENUE_CLIENT_ORDER_ID_PATTERN = /^[.A-Z:/a-z0-9_-]{1,32}$/;
78
+
79
+ const SYSTEM_CLIENT_ORDER_ID_PATTERNS = [
80
+ /^adl_autoclose$/,
81
+ /^autoclose-/,
82
+ /^settlement_autoclose-/,
83
+ ];
68
84
 
69
85
  function cloneOrderStatus(status: OrderDataStatus): OrderDataStatus {
70
86
  return { ...status };
@@ -76,38 +92,24 @@ function normalizeMaxClosedOrdersPerSymbol(value: number | undefined): number {
76
92
  : DEFAULT_MAX_CLOSED_ORDERS_PER_SYMBOL;
77
93
  }
78
94
 
79
- function getOrderLookupKey(input: {
95
+ function getOrderLookupKeys(input: {
80
96
  symbol: string;
81
97
  orderId?: string;
82
98
  clientOrderId?: string;
83
- }): string | undefined {
99
+ }): string[] {
100
+ const keys: string[] = [];
84
101
  if (input.orderId) {
85
- return `symbol:${input.symbol}:order:${input.orderId}`;
102
+ keys.push(`symbol:${input.symbol}:order:${input.orderId}`);
86
103
  }
87
104
 
88
105
  if (input.clientOrderId) {
89
- return `symbol:${input.symbol}:client:${input.clientOrderId}`;
106
+ keys.push(`symbol:${input.symbol}:client:${input.clientOrderId}`);
90
107
  }
91
108
 
92
- return undefined;
109
+ return keys;
93
110
  }
94
111
 
95
- function getOrderKey(input: {
96
- orderId?: string;
97
- clientOrderId?: string;
98
- }): string | undefined {
99
- if (input.orderId) {
100
- return `order:${input.orderId}`;
101
- }
102
-
103
- if (input.clientOrderId) {
104
- return `client:${input.clientOrderId}`;
105
- }
106
-
107
- return undefined;
108
- }
109
-
110
- function shouldMatchOrderIdentity(
112
+ function shouldMatchOrderQuery(
111
113
  candidate: OrderSnapshot,
112
114
  input: { symbol?: string; orderId?: string; clientOrderId?: string },
113
115
  ): boolean {
@@ -115,10 +117,15 @@ function shouldMatchOrderIdentity(
115
117
  return false;
116
118
  }
117
119
 
118
- return Boolean(
119
- (input.orderId && candidate.orderId === input.orderId) ||
120
- (input.clientOrderId && candidate.clientOrderId === input.clientOrderId),
121
- );
120
+ if (input.orderId && candidate.orderId !== input.orderId) {
121
+ return false;
122
+ }
123
+
124
+ if (input.clientOrderId && candidate.clientOrderId !== input.clientOrderId) {
125
+ return false;
126
+ }
127
+
128
+ return Boolean(input.orderId || input.clientOrderId);
122
129
  }
123
130
 
124
131
  function shouldMatchStoredOrderIdentity(
@@ -133,9 +140,12 @@ function shouldMatchStoredOrderIdentity(
133
140
  return candidate.orderId === input.orderId;
134
141
  }
135
142
 
136
- // clientOrderId 只作"尚未拿到 orderId 的订单"的临时身份:已带 orderId 的候选
137
- // (含 clientOrderId 复用后躺在 closed 的旧订单)不得被 cid-only 更新归并,否则会
138
- // carry-forward orderId、污染 closed。orderId 后填充时 candidate 仍无 orderId,照常匹配。
143
+ // clientOrderId is only a temporary identity for an order that does not yet
144
+ // have an orderId. A candidate that already carries an orderId (including an
145
+ // old order sitting in closed that reused this clientOrderId) must not be
146
+ // merged by a cid-only update; otherwise the stale orderId would be
147
+ // carried forward and pollute closed. When the orderId is later filled in,
148
+ // the candidate still lacks an orderId and matches normally.
139
149
  return Boolean(
140
150
  input.clientOrderId &&
141
151
  candidate.clientOrderId === input.clientOrderId &&
@@ -211,6 +221,7 @@ export class OrderManagerImpl
211
221
  private readonly orderStatusBus =
212
222
  new AsyncEventBus<OrderStatusChangedEvent>();
213
223
  private readonly records = new Map<string, OrderRecord>();
224
+ private localOrderSequence = 0;
214
225
 
215
226
  constructor(context: ClientContext, options: OrderManagerOptions = {}) {
216
227
  this.context = context;
@@ -291,11 +302,45 @@ export class OrderManagerImpl
291
302
  const account = this.context.getRegisteredAccount(input.accountId);
292
303
  this.context.ensurePrivateCredentials(input.accountId);
293
304
  this.validateCreateOrderInput(input, account.venue);
305
+ const record = this.getOrCreateRecord(input.accountId, account.venue);
306
+ const localOrderId = this.generateLocalOrderId({
307
+ record,
308
+ avoidOpenClientOrderId: input.clientOrderId === undefined,
309
+ });
310
+ const venueClientOrderId = input.clientOrderId ?? localOrderId;
311
+ this.addPendingClientOrderClaim(
312
+ record,
313
+ input.symbol,
314
+ venueClientOrderId,
315
+ localOrderId,
316
+ );
294
317
 
295
318
  try {
296
- const update = await this.context.createOrder(input);
297
- return this.applyCommandUpdate(input.accountId, account.venue, update);
319
+ const commandInput: CreateOrderInput = {
320
+ ...input,
321
+ clientOrderId: venueClientOrderId,
322
+ };
323
+ const update = await this.context.createOrder(commandInput);
324
+ const snapshot = this.applyCommandUpdate(
325
+ input.accountId,
326
+ account.venue,
327
+ update,
328
+ { localOrderId },
329
+ );
330
+ this.clearPendingClientOrderClaim(
331
+ record,
332
+ venueClientOrderId,
333
+ localOrderId,
334
+ );
335
+ return snapshot;
298
336
  } catch (error) {
337
+ if (!this.shouldRetainPendingClaimAfterCreateError(error)) {
338
+ this.clearPendingClientOrderClaim(
339
+ record,
340
+ venueClientOrderId,
341
+ localOrderId,
342
+ );
343
+ }
299
344
  throw this.wrapCommandError(
300
345
  "ORDER_CREATE_FAILED",
301
346
  `Failed to create order for ${input.accountId}: ${input.symbol}`,
@@ -365,13 +410,13 @@ export class OrderManagerImpl
365
410
  }
366
411
 
367
412
  if (input.symbol && input.orderId) {
368
- const location = this.getOrderIdLocation(
413
+ const localOrderId = this.getLocalOrderIdForVenueOrderId(
369
414
  record,
370
415
  input.symbol,
371
416
  input.orderId,
372
417
  );
373
- const snapshot = location
374
- ? this.getSnapshotAtLocation(record, location)
418
+ const snapshot = localOrderId
419
+ ? this.getSnapshotByLocalOrderId(record, localOrderId)
375
420
  : undefined;
376
421
  if (!snapshot) {
377
422
  return undefined;
@@ -389,17 +434,8 @@ export class OrderManagerImpl
389
434
 
390
435
  if (input.orderId) {
391
436
  return this.selectLatestSnapshot(
392
- this.getSnapshotsForOrderId(record, input.orderId).filter(
393
- (snapshot) =>
394
- shouldMatchOrderIdentity(snapshot, {
395
- symbol: input.symbol,
396
- orderId: input.orderId,
397
- }) &&
398
- (!input.clientOrderId ||
399
- shouldMatchOrderIdentity(snapshot, {
400
- symbol: input.symbol,
401
- clientOrderId: input.clientOrderId,
402
- })),
437
+ this.getSnapshotsForOrderId(record, input.orderId).filter((snapshot) =>
438
+ shouldMatchOrderQuery(snapshot, input),
403
439
  ),
404
440
  );
405
441
  }
@@ -407,11 +443,7 @@ export class OrderManagerImpl
407
443
  if (input.clientOrderId) {
408
444
  return this.selectLatestSnapshot(
409
445
  this.getSnapshotsForClientOrderId(record, input.clientOrderId).filter(
410
- (snapshot) =>
411
- shouldMatchOrderIdentity(snapshot, {
412
- symbol: input.symbol,
413
- clientOrderId: input.clientOrderId,
414
- }),
446
+ (snapshot) => shouldMatchOrderQuery(snapshot, input),
415
447
  ),
416
448
  );
417
449
  }
@@ -529,8 +561,7 @@ export class OrderManagerImpl
529
561
 
530
562
  const openSetKeys = new Set<string>();
531
563
  for (const update of snapshot.orders) {
532
- const lookupKey = getOrderLookupKey(update);
533
- if (lookupKey) {
564
+ for (const lookupKey of getOrderLookupKeys(update)) {
534
565
  openSetKeys.add(lookupKey);
535
566
  }
536
567
  const current = this.getExistingSnapshot(record, update);
@@ -545,13 +576,11 @@ export class OrderManagerImpl
545
576
  },
546
577
  );
547
578
  if (nextSnapshot) {
548
- const nextLookupKey = getOrderLookupKey(nextSnapshot);
549
- if (nextLookupKey) {
579
+ for (const nextLookupKey of getOrderLookupKeys(nextSnapshot)) {
550
580
  openSetKeys.add(nextLookupKey);
551
581
  }
552
582
  } else if (current) {
553
- const currentLookupKey = getOrderLookupKey(current);
554
- if (currentLookupKey) {
583
+ for (const currentLookupKey of getOrderLookupKeys(current)) {
555
584
  openSetKeys.add(currentLookupKey);
556
585
  }
557
586
  }
@@ -562,8 +591,11 @@ export class OrderManagerImpl
562
591
  return false;
563
592
  }
564
593
 
565
- const lookupKey = getOrderLookupKey(order);
566
- if (!lookupKey || openSetKeys.has(lookupKey)) {
594
+ const lookupKeys = getOrderLookupKeys(order);
595
+ if (
596
+ lookupKeys.length === 0 ||
597
+ lookupKeys.some((lookupKey) => openSetKeys.has(lookupKey))
598
+ ) {
567
599
  return false;
568
600
  }
569
601
 
@@ -704,9 +736,11 @@ export class OrderManagerImpl
704
736
  subscribed: false,
705
737
  openOrders: new Map(),
706
738
  closedOrders: new Map(),
739
+ localOrderLocations: new Map(),
707
740
  orderIdIndex: new Map(),
708
741
  orderIdOnlyIndex: new Map(),
709
742
  clientOrderIdIndex: new Map(),
743
+ pendingClientOrderIdIndex: new Map(),
710
744
  status: this.createStatus(accountId, venue, "inactive"),
711
745
  };
712
746
 
@@ -740,91 +774,125 @@ export class OrderManagerImpl
740
774
  record: OrderRecord,
741
775
  update: { symbol: string; orderId?: string; clientOrderId?: string },
742
776
  ): OrderLocation | undefined {
777
+ const resolution = this.resolveLocalOrderIdForUpdate(record, update);
778
+ return resolution.localOrderId
779
+ ? record.localOrderLocations.get(resolution.localOrderId)
780
+ : undefined;
781
+ }
782
+
783
+ private resolveLocalOrderIdForUpdate(
784
+ record: OrderRecord,
785
+ update: { symbol: string; orderId?: string; clientOrderId?: string },
786
+ preferredLocalOrderId?: string,
787
+ ): {
788
+ localOrderId?: string;
789
+ source?: "exact" | "pending" | "provisional" | "preferred";
790
+ } {
743
791
  if (update.orderId) {
744
- const location = this.getOrderIdLocation(
792
+ const exact = this.getLocalOrderIdForVenueOrderId(
745
793
  record,
746
794
  update.symbol,
747
795
  update.orderId,
748
796
  );
749
- const snapshot = location
750
- ? this.getSnapshotAtLocation(record, location)
751
- : undefined;
752
- if (snapshot && shouldMatchStoredOrderIdentity(snapshot, update)) {
753
- return location;
797
+ if (exact) {
798
+ return { localOrderId: exact, source: "exact" };
754
799
  }
755
800
  }
756
801
 
757
- if (!update.clientOrderId) {
758
- return undefined;
802
+ if (preferredLocalOrderId) {
803
+ return { localOrderId: preferredLocalOrderId, source: "preferred" };
759
804
  }
760
805
 
761
- for (const location of record.clientOrderIdIndex.get(
762
- update.clientOrderId,
763
- ) ?? []) {
764
- const snapshot = this.getSnapshotAtLocation(record, location);
765
- if (snapshot && shouldMatchStoredOrderIdentity(snapshot, update)) {
766
- return location;
806
+ if (update.clientOrderId) {
807
+ const pending = record.pendingClientOrderIdIndex.get(
808
+ update.clientOrderId,
809
+ );
810
+ if (pending?.symbol === update.symbol) {
811
+ return { localOrderId: pending.localOrderId, source: "pending" };
767
812
  }
768
813
  }
769
814
 
770
- return undefined;
815
+ if (
816
+ update.clientOrderId &&
817
+ !this.isSystemClientOrderId(update.clientOrderId)
818
+ ) {
819
+ for (const localOrderId of record.clientOrderIdIndex.get(
820
+ update.clientOrderId,
821
+ ) ?? []) {
822
+ const snapshot = this.getSnapshotByLocalOrderId(record, localOrderId);
823
+ if (snapshot && shouldMatchStoredOrderIdentity(snapshot, update)) {
824
+ return { localOrderId, source: "provisional" };
825
+ }
826
+ }
827
+ }
828
+
829
+ return {};
771
830
  }
772
831
 
773
832
  private setSnapshot(
774
833
  record: OrderRecord,
834
+ localOrderId: string,
775
835
  snapshot: OrderSnapshot,
776
- previous?: OrderSnapshot,
836
+ previousLocation?: OrderLocation,
777
837
  ): OrderLocation | undefined {
778
- const existing = previous ?? this.getExistingSnapshot(record, snapshot);
779
- const previousLocation = existing
780
- ? this.getSnapshotLocation(existing)
781
- : undefined;
838
+ if (!snapshot.orderId && !snapshot.clientOrderId) {
839
+ this.warnDroppedUnkeyedTerminalOrder(record, snapshot);
840
+ return undefined;
841
+ }
782
842
 
783
- if (previousLocation) {
784
- return this.moveSnapshot(record, previousLocation, snapshot);
843
+ const currentLocation =
844
+ previousLocation ?? record.localOrderLocations.get(localOrderId);
845
+ if (currentLocation) {
846
+ return this.moveSnapshot(record, currentLocation, localOrderId, snapshot);
785
847
  }
786
848
 
787
- return this.insertSnapshot(record, snapshot);
849
+ return this.insertSnapshot(record, localOrderId, snapshot);
788
850
  }
789
851
 
790
852
  private insertSnapshot(
791
853
  record: OrderRecord,
854
+ localOrderId: string,
792
855
  snapshot: OrderSnapshot,
793
856
  ): OrderLocation | undefined {
794
- const location = this.getSnapshotLocation(snapshot);
795
- if (!location) {
796
- this.warnDroppedUnkeyedTerminalOrder(record, snapshot);
797
- return undefined;
857
+ const existingLocation = record.localOrderLocations.get(localOrderId);
858
+ if (existingLocation) {
859
+ this.deleteSnapshot(record, existingLocation);
798
860
  }
799
861
 
800
- this.deleteSnapshot(record, location);
862
+ const location: OrderLocation = {
863
+ table: isOpenOrder(snapshot) ? "open" : "closed",
864
+ symbol: snapshot.symbol,
865
+ localOrderId,
866
+ };
801
867
 
802
868
  const table = this.getOrderTable(record, location.table);
803
869
  const symbolOrders = this.getOrCreateSymbolOrders(table, location.symbol);
804
- symbolOrders.set(location.key, snapshot);
805
- this.trimClosedOrdersForSymbol(record, location);
870
+ symbolOrders.set(localOrderId, snapshot);
871
+ record.localOrderLocations.set(localOrderId, location);
806
872
 
807
873
  if (snapshot.orderId) {
808
874
  const symbolIndex = this.getOrCreateOrderIdSymbolIndex(
809
875
  record,
810
876
  snapshot.symbol,
811
877
  );
812
- symbolIndex.set(snapshot.orderId, location);
813
- this.addLocationToSetIndex(
878
+ symbolIndex.set(snapshot.orderId, localOrderId);
879
+ this.addLocalOrderIdToSetIndex(
814
880
  record.orderIdOnlyIndex,
815
881
  snapshot.orderId,
816
- location,
882
+ localOrderId,
817
883
  );
818
884
  }
819
885
 
820
886
  if (snapshot.clientOrderId) {
821
- this.addLocationToSetIndex(
887
+ this.addLocalOrderIdToSetIndex(
822
888
  record.clientOrderIdIndex,
823
889
  snapshot.clientOrderId,
824
- location,
890
+ localOrderId,
825
891
  );
826
892
  }
827
893
 
894
+ this.trimClosedOrdersForSymbol(record, location);
895
+ this.warnSystemClientOrderIdOnlyClaim(record, snapshot);
828
896
  this.warnProvisionalTerminalOrder(record, snapshot);
829
897
  return location;
830
898
  }
@@ -840,34 +908,35 @@ export class OrderManagerImpl
840
908
 
841
909
  const table = this.getOrderTable(record, location.table);
842
910
  const symbolOrders = table.get(location.symbol);
843
- symbolOrders?.delete(location.key);
911
+ symbolOrders?.delete(location.localOrderId);
844
912
  if (symbolOrders?.size === 0) {
845
913
  table.delete(location.symbol);
846
914
  }
915
+ record.localOrderLocations.delete(location.localOrderId);
847
916
 
848
917
  if (snapshot.orderId) {
849
918
  const symbolIndex = record.orderIdIndex.get(location.symbol);
850
919
  if (
851
920
  symbolIndex?.get(snapshot.orderId) &&
852
- this.locationsEqual(symbolIndex.get(snapshot.orderId), location)
921
+ symbolIndex.get(snapshot.orderId) === location.localOrderId
853
922
  ) {
854
923
  symbolIndex.delete(snapshot.orderId);
855
924
  }
856
925
  if (symbolIndex?.size === 0) {
857
926
  record.orderIdIndex.delete(location.symbol);
858
927
  }
859
- this.removeLocationFromSetIndex(
928
+ this.removeLocalOrderIdFromSetIndex(
860
929
  record.orderIdOnlyIndex,
861
930
  snapshot.orderId,
862
- location,
931
+ location.localOrderId,
863
932
  );
864
933
  }
865
934
 
866
935
  if (snapshot.clientOrderId) {
867
- this.removeLocationFromSetIndex(
936
+ this.removeLocalOrderIdFromSetIndex(
868
937
  record.clientOrderIdIndex,
869
938
  snapshot.clientOrderId,
870
- location,
939
+ location.localOrderId,
871
940
  );
872
941
  }
873
942
 
@@ -877,10 +946,11 @@ export class OrderManagerImpl
877
946
  private moveSnapshot(
878
947
  record: OrderRecord,
879
948
  previousLocation: OrderLocation,
949
+ localOrderId: string,
880
950
  snapshot: OrderSnapshot,
881
951
  ): OrderLocation | undefined {
882
952
  this.deleteSnapshot(record, previousLocation);
883
- return this.insertSnapshot(record, snapshot);
953
+ return this.insertSnapshot(record, localOrderId, snapshot);
884
954
  }
885
955
 
886
956
  private trimClosedOrdersForSymbol(
@@ -910,40 +980,50 @@ export class OrderManagerImpl
910
980
  this.deleteSnapshot(record, {
911
981
  table: "closed",
912
982
  symbol: location.symbol,
913
- key: next.value,
983
+ localOrderId: next.value,
914
984
  });
915
985
  }
916
986
  symbolOrders = record.closedOrders.get(location.symbol);
917
987
  }
918
988
  }
919
989
 
920
- private getSnapshotLocation(
990
+ private warnDroppedUnkeyedTerminalOrder(
991
+ record: OrderRecord,
921
992
  snapshot: OrderSnapshot,
922
- ): OrderLocation | undefined {
923
- const key = getOrderKey(snapshot);
924
- if (!key) {
925
- return undefined;
993
+ ): void {
994
+ if (isOpenOrder(snapshot)) {
995
+ return;
926
996
  }
927
997
 
928
- return {
929
- table: isOpenOrder(snapshot) ? "open" : "closed",
930
- symbol: snapshot.symbol,
931
- key,
932
- };
998
+ this.context.publishRuntimeError(
999
+ "order",
1000
+ new Error(
1001
+ "Dropped terminal order update without orderId or clientOrderId",
1002
+ ),
1003
+ {
1004
+ accountId: record.accountId,
1005
+ venue: record.venue,
1006
+ symbol: snapshot.symbol,
1007
+ },
1008
+ );
933
1009
  }
934
1010
 
935
- private warnDroppedUnkeyedTerminalOrder(
1011
+ private warnSystemClientOrderIdOnlyClaim(
936
1012
  record: OrderRecord,
937
1013
  snapshot: OrderSnapshot,
938
1014
  ): void {
939
- if (isOpenOrder(snapshot)) {
1015
+ if (
1016
+ snapshot.orderId ||
1017
+ !snapshot.clientOrderId ||
1018
+ !this.isSystemClientOrderId(snapshot.clientOrderId)
1019
+ ) {
940
1020
  return;
941
1021
  }
942
1022
 
943
1023
  this.context.publishRuntimeError(
944
1024
  "order",
945
1025
  new Error(
946
- "Dropped terminal order update without orderId or clientOrderId",
1026
+ "Received system clientOrderId without orderId; cid-only claim is unstable",
947
1027
  ),
948
1028
  {
949
1029
  accountId: record.accountId,
@@ -957,8 +1037,10 @@ export class OrderManagerImpl
957
1037
  record: OrderRecord,
958
1038
  snapshot: OrderSnapshot,
959
1039
  ): void {
960
- // 终态单缺 orderId 但有 clientOrderId: client key provisional 存储并告警。
961
- // adapter 契约要求终态带 orderId(见 adapter-contract.md);仅 cid 无法保证稳定唯一主键。
1040
+ // Terminal order missing orderId but carrying clientOrderId: stored under a
1041
+ // provisional client key and warned. The adapter contract requires terminal
1042
+ // updates to carry orderId (see adapter-contract.md); clientOrderId alone
1043
+ // cannot guarantee a stable unique primary key.
962
1044
  if (snapshot.orderId || isOpenOrder(snapshot) || !snapshot.clientOrderId) {
963
1045
  return;
964
1046
  }
@@ -982,7 +1064,15 @@ export class OrderManagerImpl
982
1064
  ): OrderSnapshot | undefined {
983
1065
  return this.getOrderTable(record, location.table)
984
1066
  .get(location.symbol)
985
- ?.get(location.key);
1067
+ ?.get(location.localOrderId);
1068
+ }
1069
+
1070
+ private getSnapshotByLocalOrderId(
1071
+ record: OrderRecord,
1072
+ localOrderId: string,
1073
+ ): OrderSnapshot | undefined {
1074
+ const location = record.localOrderLocations.get(localOrderId);
1075
+ return location ? this.getSnapshotAtLocation(record, location) : undefined;
986
1076
  }
987
1077
 
988
1078
  private getOrderTable(
@@ -1009,22 +1099,22 @@ export class OrderManagerImpl
1009
1099
  private getOrCreateOrderIdSymbolIndex(
1010
1100
  record: OrderRecord,
1011
1101
  symbol: string,
1012
- ): Map<string, OrderLocation> {
1102
+ ): Map<string, string> {
1013
1103
  const existing = record.orderIdIndex.get(symbol);
1014
1104
  if (existing) {
1015
1105
  return existing;
1016
1106
  }
1017
1107
 
1018
- const created = new Map<string, OrderLocation>();
1108
+ const created = new Map<string, string>();
1019
1109
  record.orderIdIndex.set(symbol, created);
1020
1110
  return created;
1021
1111
  }
1022
1112
 
1023
- private getOrderIdLocation(
1113
+ private getLocalOrderIdForVenueOrderId(
1024
1114
  record: OrderRecord,
1025
1115
  symbol: string,
1026
1116
  orderId: string,
1027
- ): OrderLocation | undefined {
1117
+ ): string | undefined {
1028
1118
  return record.orderIdIndex.get(symbol)?.get(orderId);
1029
1119
  }
1030
1120
 
@@ -1032,7 +1122,7 @@ export class OrderManagerImpl
1032
1122
  record: OrderRecord,
1033
1123
  orderId: string,
1034
1124
  ): OrderSnapshot[] {
1035
- return this.getSnapshotsForLocations(
1125
+ return this.getSnapshotsForLocalOrderIds(
1036
1126
  record,
1037
1127
  record.orderIdOnlyIndex.get(orderId),
1038
1128
  );
@@ -1042,23 +1132,23 @@ export class OrderManagerImpl
1042
1132
  record: OrderRecord,
1043
1133
  clientOrderId: string,
1044
1134
  ): OrderSnapshot[] {
1045
- return this.getSnapshotsForLocations(
1135
+ return this.getSnapshotsForLocalOrderIds(
1046
1136
  record,
1047
1137
  record.clientOrderIdIndex.get(clientOrderId),
1048
1138
  );
1049
1139
  }
1050
1140
 
1051
- private getSnapshotsForLocations(
1141
+ private getSnapshotsForLocalOrderIds(
1052
1142
  record: OrderRecord,
1053
- locations?: Iterable<OrderLocation>,
1143
+ localOrderIds?: Iterable<string>,
1054
1144
  ): OrderSnapshot[] {
1055
- if (!locations) {
1145
+ if (!localOrderIds) {
1056
1146
  return [];
1057
1147
  }
1058
1148
 
1059
1149
  const snapshots: OrderSnapshot[] = [];
1060
- for (const location of locations) {
1061
- const snapshot = this.getSnapshotAtLocation(record, location);
1150
+ for (const localOrderId of localOrderIds) {
1151
+ const snapshot = this.getSnapshotByLocalOrderId(record, localOrderId);
1062
1152
  if (snapshot) {
1063
1153
  snapshots.push(snapshot);
1064
1154
  }
@@ -1107,56 +1197,39 @@ export class OrderManagerImpl
1107
1197
  return size;
1108
1198
  }
1109
1199
 
1110
- private addLocationToSetIndex(
1111
- index: Map<string, Set<OrderLocation>>,
1200
+ private addLocalOrderIdToSetIndex(
1201
+ index: Map<string, Set<string>>,
1112
1202
  key: string,
1113
- location: OrderLocation,
1203
+ localOrderId: string,
1114
1204
  ): void {
1115
- this.removeLocationFromSetIndex(index, key, location);
1205
+ this.removeLocalOrderIdFromSetIndex(index, key, localOrderId);
1116
1206
 
1117
- const locations = index.get(key);
1118
- if (locations) {
1119
- locations.add(location);
1207
+ const localOrderIds = index.get(key);
1208
+ if (localOrderIds) {
1209
+ localOrderIds.add(localOrderId);
1120
1210
  return;
1121
1211
  }
1122
1212
 
1123
- index.set(key, new Set([location]));
1213
+ index.set(key, new Set([localOrderId]));
1124
1214
  }
1125
1215
 
1126
- private removeLocationFromSetIndex(
1127
- index: Map<string, Set<OrderLocation>>,
1216
+ private removeLocalOrderIdFromSetIndex(
1217
+ index: Map<string, Set<string>>,
1128
1218
  key: string,
1129
- location: OrderLocation,
1219
+ localOrderId: string,
1130
1220
  ): void {
1131
- const locations = index.get(key);
1132
- if (!locations) {
1221
+ const localOrderIds = index.get(key);
1222
+ if (!localOrderIds) {
1133
1223
  return;
1134
1224
  }
1135
1225
 
1136
- for (const candidate of locations) {
1137
- if (this.locationsEqual(candidate, location)) {
1138
- locations.delete(candidate);
1139
- break;
1140
- }
1141
- }
1226
+ localOrderIds.delete(localOrderId);
1142
1227
 
1143
- if (locations.size === 0) {
1228
+ if (localOrderIds.size === 0) {
1144
1229
  index.delete(key);
1145
1230
  }
1146
1231
  }
1147
1232
 
1148
- private locationsEqual(
1149
- left: OrderLocation | undefined,
1150
- right: OrderLocation,
1151
- ): boolean {
1152
- return Boolean(
1153
- left &&
1154
- left.table === right.table &&
1155
- left.symbol === right.symbol &&
1156
- left.key === right.key,
1157
- );
1158
- }
1159
-
1160
1233
  private selectLatestSnapshot(
1161
1234
  snapshots: OrderSnapshot[],
1162
1235
  ): OrderSnapshot | undefined {
@@ -1170,15 +1243,18 @@ export class OrderManagerImpl
1170
1243
  const snapshotOpen = isOpenOrder(snapshot);
1171
1244
  const latestOpen = isOpenOrder(latest);
1172
1245
  if (snapshotOpen !== latestOpen) {
1173
- // open 候选绝对优先:当前活跃订单优于历史终态(clientOrderId 复用时旧单已 closed)
1246
+ // Open candidate has absolute priority: current active order takes
1247
+ // precedence over historical terminal state (when clientOrderId is
1248
+ // reused, the old order is already closed).
1174
1249
  if (snapshotOpen) {
1175
1250
  latest = snapshot;
1176
1251
  }
1177
1252
  continue;
1178
1253
  }
1179
1254
 
1180
- // 同为 open 或同为 closed: updatedAt 最新。
1181
- // 不能用 seq —— seq 是单订单版本号,跨订单(如复用 cid 的不同订单)不可比。
1255
+ // Both open or both closed: take the latest by updatedAt.
1256
+ // seq must not be used -- seq is a per-order version number and is not
1257
+ // comparable across orders (e.g. different orders that reuse a cid).
1182
1258
  if (snapshot.updatedAt > latest.updatedAt) {
1183
1259
  latest = snapshot;
1184
1260
  }
@@ -1194,7 +1270,12 @@ export class OrderManagerImpl
1194
1270
  update: RawOrderUpdate,
1195
1271
  options: { requestStartedAt?: number; preserveStatus?: boolean } = {},
1196
1272
  ): OrderSnapshot | undefined {
1197
- const previous = this.getExistingSnapshot(record, update);
1273
+ const resolution = this.resolveLocalOrderIdForUpdate(record, update);
1274
+ const localOrderId = resolution.localOrderId ?? this.generateLocalOrderId();
1275
+ const previousLocation = record.localOrderLocations.get(localOrderId);
1276
+ const previous = previousLocation
1277
+ ? this.getSnapshotAtLocation(record, previousLocation)
1278
+ : undefined;
1198
1279
  if (
1199
1280
  !shouldApplyWatermarkedUpdate(previous, update, {
1200
1281
  requestStartedAt: options.requestStartedAt,
@@ -1205,7 +1286,21 @@ export class OrderManagerImpl
1205
1286
  }
1206
1287
 
1207
1288
  const snapshot = this.createSnapshot(accountId, venue, update, previous);
1208
- return this.setSnapshot(record, snapshot, previous) ? snapshot : undefined;
1289
+ const location = this.setSnapshot(
1290
+ record,
1291
+ localOrderId,
1292
+ snapshot,
1293
+ previousLocation,
1294
+ );
1295
+ if (location && resolution.source === "pending" && update.clientOrderId) {
1296
+ this.clearPendingClientOrderClaim(
1297
+ record,
1298
+ update.clientOrderId,
1299
+ localOrderId,
1300
+ );
1301
+ }
1302
+
1303
+ return location ? snapshot : undefined;
1209
1304
  }
1210
1305
 
1211
1306
  private createSnapshot(
@@ -1291,6 +1386,78 @@ export class OrderManagerImpl
1291
1386
  this.context.publishHealthEvent(event);
1292
1387
  }
1293
1388
 
1389
+ private generateLocalOrderId(options?: {
1390
+ record?: OrderRecord;
1391
+ avoidOpenClientOrderId?: boolean;
1392
+ }): string {
1393
+ while (true) {
1394
+ const candidate = `${SDK_CLIENT_ORDER_ID_PREFIX}${this.context.now().toString(36)}-${(this.localOrderSequence++).toString(36)}`;
1395
+ if (
1396
+ (options?.record &&
1397
+ options.avoidOpenClientOrderId &&
1398
+ this.isVenueClientOrderIdInUseForOpenOrder(
1399
+ options.record,
1400
+ candidate,
1401
+ )) ||
1402
+ options?.record?.pendingClientOrderIdIndex.has(candidate) ||
1403
+ !VENUE_CLIENT_ORDER_ID_PATTERN.test(candidate)
1404
+ ) {
1405
+ continue;
1406
+ }
1407
+
1408
+ return candidate;
1409
+ }
1410
+ }
1411
+
1412
+ private isVenueClientOrderIdInUseForOpenOrder(
1413
+ record: OrderRecord,
1414
+ venueClientOrderId: string,
1415
+ ): boolean {
1416
+ for (const localOrderId of record.clientOrderIdIndex.get(
1417
+ venueClientOrderId,
1418
+ ) ?? []) {
1419
+ const location = record.localOrderLocations.get(localOrderId);
1420
+ if (location?.table === "open") {
1421
+ return true;
1422
+ }
1423
+ }
1424
+
1425
+ return false;
1426
+ }
1427
+
1428
+ private addPendingClientOrderClaim(
1429
+ record: OrderRecord,
1430
+ symbol: string,
1431
+ venueClientOrderId: string,
1432
+ localOrderId: string,
1433
+ ): void {
1434
+ record.pendingClientOrderIdIndex.set(venueClientOrderId, {
1435
+ localOrderId,
1436
+ symbol,
1437
+ });
1438
+ }
1439
+
1440
+ private clearPendingClientOrderClaim(
1441
+ record: OrderRecord,
1442
+ venueClientOrderId: string,
1443
+ localOrderId: string,
1444
+ ): void {
1445
+ const pending = record.pendingClientOrderIdIndex.get(venueClientOrderId);
1446
+ if (pending?.localOrderId === localOrderId) {
1447
+ record.pendingClientOrderIdIndex.delete(venueClientOrderId);
1448
+ }
1449
+ }
1450
+
1451
+ private shouldRetainPendingClaimAfterCreateError(error: unknown): boolean {
1452
+ return isTransportError(error) && error.kind === "timeout";
1453
+ }
1454
+
1455
+ private isSystemClientOrderId(clientOrderId: string): boolean {
1456
+ return SYSTEM_CLIENT_ORDER_ID_PATTERNS.some((pattern) =>
1457
+ pattern.test(clientOrderId),
1458
+ );
1459
+ }
1460
+
1294
1461
  private validateCreateOrderInput(
1295
1462
  input: CreateOrderInput,
1296
1463
  venue: Venue,
@@ -1306,6 +1473,21 @@ export class OrderManagerImpl
1306
1473
  },
1307
1474
  );
1308
1475
  }
1476
+
1477
+ if (
1478
+ input.clientOrderId !== undefined &&
1479
+ !VENUE_CLIENT_ORDER_ID_PATTERN.test(input.clientOrderId)
1480
+ ) {
1481
+ throw this.createError(
1482
+ "ORDER_INPUT_INVALID",
1483
+ `clientOrderId must be 1-32 Binance-safe characters: ${input.accountId}`,
1484
+ {
1485
+ accountId: input.accountId,
1486
+ venue,
1487
+ symbol: input.symbol,
1488
+ },
1489
+ );
1490
+ }
1309
1491
  }
1310
1492
 
1311
1493
  private validateCancelOrderInput(
@@ -1331,11 +1513,21 @@ export class OrderManagerImpl
1331
1513
  accountId: string,
1332
1514
  venue: Venue,
1333
1515
  update: RawOrderUpdate,
1516
+ options: { localOrderId?: string } = {},
1334
1517
  ): OrderSnapshot {
1335
1518
  const record = this.getOrCreateRecord(accountId, venue);
1336
- const previous = this.getExistingSnapshot(record, update);
1519
+ const resolution = this.resolveLocalOrderIdForUpdate(
1520
+ record,
1521
+ update,
1522
+ options.localOrderId,
1523
+ );
1524
+ const localOrderId = resolution.localOrderId ?? this.generateLocalOrderId();
1525
+ const previousLocation = record.localOrderLocations.get(localOrderId);
1526
+ const previous = previousLocation
1527
+ ? this.getSnapshotAtLocation(record, previousLocation)
1528
+ : undefined;
1337
1529
  const snapshot = this.createSnapshot(accountId, venue, update, previous);
1338
- this.setSnapshot(record, snapshot, previous);
1530
+ this.setSnapshot(record, localOrderId, snapshot, previousLocation);
1339
1531
  return snapshot;
1340
1532
  }
1341
1533