@imbingox/acex 0.3.1-beta.0 → 0.4.0-beta.10

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.
@@ -1,5 +1,8 @@
1
1
  import BigNumber from "bignumber.js";
2
- import type { RawOrderUpdate } from "../adapters/types.ts";
2
+ import type {
3
+ RawOpenOrdersSnapshot,
4
+ RawOrderUpdate,
5
+ } from "../adapters/types.ts";
3
6
  import type {
4
7
  AccountAwareManager,
5
8
  ClientContext,
@@ -8,9 +11,18 @@ import type {
8
11
  PrivateOrderDataConsumer,
9
12
  PrivateSubscriptionState,
10
13
  } from "../client/context.ts";
11
- import { AcexError } from "../errors.ts";
14
+ import {
15
+ AcexError,
16
+ buildAcexErrorDetails,
17
+ formatAcexErrorMessage,
18
+ } from "../errors.ts";
12
19
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
20
+ import { toCanonical } from "../internal/decimal.ts";
13
21
  import { matchesOrderFilter } from "../internal/filters.ts";
22
+ import {
23
+ canDeleteMissingFromSnapshot,
24
+ shouldApplyWatermarkedUpdate,
25
+ } from "../internal/watermark.ts";
14
26
  import type {
15
27
  CancelAllOrdersInput,
16
28
  CancelOrderInput,
@@ -32,15 +44,55 @@ interface OrderRecord {
32
44
  accountId: string;
33
45
  venue: Venue;
34
46
  subscribed: boolean;
35
- snapshots: Map<string, OrderSnapshot>;
47
+ openOrders: Map<string, Map<string, OrderSnapshot>>;
48
+ 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>>;
36
52
  status: OrderDataStatus;
37
53
  }
38
54
 
55
+ type OrderTable = "open" | "closed";
56
+
57
+ interface OrderLocation {
58
+ table: OrderTable;
59
+ symbol: string;
60
+ key: string;
61
+ }
62
+
63
+ interface OrderManagerOptions {
64
+ maxClosedOrdersPerSymbol?: number;
65
+ }
66
+
67
+ const DEFAULT_MAX_CLOSED_ORDERS_PER_SYMBOL = 500;
68
+
39
69
  function cloneOrderStatus(status: OrderDataStatus): OrderDataStatus {
40
70
  return { ...status };
41
71
  }
42
72
 
73
+ function normalizeMaxClosedOrdersPerSymbol(value: number | undefined): number {
74
+ return value !== undefined && Number.isInteger(value) && value > 0
75
+ ? value
76
+ : DEFAULT_MAX_CLOSED_ORDERS_PER_SYMBOL;
77
+ }
78
+
43
79
  function getOrderLookupKey(input: {
80
+ symbol: string;
81
+ orderId?: string;
82
+ clientOrderId?: string;
83
+ }): string | undefined {
84
+ if (input.orderId) {
85
+ return `symbol:${input.symbol}:order:${input.orderId}`;
86
+ }
87
+
88
+ if (input.clientOrderId) {
89
+ return `symbol:${input.symbol}:client:${input.clientOrderId}`;
90
+ }
91
+
92
+ return undefined;
93
+ }
94
+
95
+ function getOrderKey(input: {
44
96
  orderId?: string;
45
97
  clientOrderId?: string;
46
98
  }): string | undefined {
@@ -55,6 +107,94 @@ function getOrderLookupKey(input: {
55
107
  return undefined;
56
108
  }
57
109
 
110
+ function shouldMatchOrderIdentity(
111
+ candidate: OrderSnapshot,
112
+ input: { symbol?: string; orderId?: string; clientOrderId?: string },
113
+ ): boolean {
114
+ if (input.symbol && candidate.symbol !== input.symbol) {
115
+ return false;
116
+ }
117
+
118
+ return Boolean(
119
+ (input.orderId && candidate.orderId === input.orderId) ||
120
+ (input.clientOrderId && candidate.clientOrderId === input.clientOrderId),
121
+ );
122
+ }
123
+
124
+ function shouldMatchStoredOrderIdentity(
125
+ candidate: OrderSnapshot,
126
+ input: { symbol: string; orderId?: string; clientOrderId?: string },
127
+ ): boolean {
128
+ if (candidate.symbol !== input.symbol) {
129
+ return false;
130
+ }
131
+
132
+ if (candidate.orderId && input.orderId) {
133
+ return candidate.orderId === input.orderId;
134
+ }
135
+
136
+ // clientOrderId 只作"尚未拿到 orderId 的订单"的临时身份:已带 orderId 的候选
137
+ // (含 clientOrderId 复用后躺在 closed 的旧订单)不得被 cid-only 更新归并,否则会
138
+ // carry-forward 旧 orderId、污染 closed。orderId 后填充时 candidate 仍无 orderId,照常匹配。
139
+ return Boolean(
140
+ input.clientOrderId &&
141
+ candidate.clientOrderId === input.clientOrderId &&
142
+ !candidate.orderId,
143
+ );
144
+ }
145
+
146
+ function successfulStatus(
147
+ status: OrderDataStatus,
148
+ options: {
149
+ ready?: boolean;
150
+ lastReceivedAt?: number;
151
+ lastReadyAt?: number;
152
+ preserveStatus?: boolean;
153
+ },
154
+ ): OrderDataStatus {
155
+ const preservesStreamState =
156
+ options.preserveStatus &&
157
+ (status.runtimeStatus === "reconnecting" ||
158
+ status.reason === "ws_disconnected" ||
159
+ status.reason === "heartbeat_timeout");
160
+ const ready = options.ready ?? true;
161
+
162
+ return {
163
+ ...status,
164
+ activity: "active",
165
+ ready,
166
+ runtimeStatus: preservesStreamState ? status.runtimeStatus : "healthy",
167
+ reason: preservesStreamState ? status.reason : undefined,
168
+ lastReceivedAt: options.lastReceivedAt ?? status.lastReceivedAt,
169
+ lastReadyAt: ready
170
+ ? (options.lastReadyAt ??
171
+ (options.preserveStatus ? status.lastReadyAt : undefined) ??
172
+ Date.now())
173
+ : status.lastReadyAt,
174
+ inactiveSince: undefined,
175
+ };
176
+ }
177
+
178
+ function isOpenOrder(snapshot: OrderSnapshot): boolean {
179
+ return snapshot.status === "open" || snapshot.status === "partially_filled";
180
+ }
181
+
182
+ function orderPriority(status: OrderSnapshot["status"]): number {
183
+ switch (status) {
184
+ case "filled":
185
+ return 5;
186
+ case "canceled":
187
+ case "expired":
188
+ return 4;
189
+ case "rejected":
190
+ return 3;
191
+ case "partially_filled":
192
+ return 2;
193
+ case "open":
194
+ return 1;
195
+ }
196
+ }
197
+
58
198
  export class OrderManagerImpl
59
199
  implements
60
200
  OrderManager,
@@ -66,13 +206,17 @@ export class OrderManagerImpl
66
206
  readonly events: OrderEventStreams;
67
207
 
68
208
  private readonly context: ClientContext;
209
+ private readonly maxClosedOrdersPerSymbol: number;
69
210
  private readonly orderBus = new AsyncEventBus<OrderEvent>();
70
211
  private readonly orderStatusBus =
71
212
  new AsyncEventBus<OrderStatusChangedEvent>();
72
213
  private readonly records = new Map<string, OrderRecord>();
73
214
 
74
- constructor(context: ClientContext) {
215
+ constructor(context: ClientContext, options: OrderManagerOptions = {}) {
75
216
  this.context = context;
217
+ this.maxClosedOrdersPerSymbol = normalizeMaxClosedOrdersPerSymbol(
218
+ options.maxClosedOrdersPerSymbol,
219
+ );
76
220
 
77
221
  this.events = {
78
222
  status: (filter) =>
@@ -101,7 +245,10 @@ export class OrderManagerImpl
101
245
  async subscribeOrders(input: SubscribeOrdersInput): Promise<void> {
102
246
  this.context.assertStarted();
103
247
  const account = this.context.getRegisteredAccount(input.accountId);
104
- if (account.venue === "juplend") {
248
+ if (
249
+ this.context.getPrivateOrderCapabilities(account.venue)?.updates ===
250
+ "unsupported"
251
+ ) {
105
252
  throw this.createError(
106
253
  "VENUE_NOT_SUPPORTED",
107
254
  `Venue does not support private order subscriptions: ${account.venue}`,
@@ -217,17 +364,56 @@ export class OrderManagerImpl
217
364
  return undefined;
218
365
  }
219
366
 
220
- for (const snapshot of record.snapshots.values()) {
221
- if (input.orderId && snapshot.orderId === input.orderId) {
222
- return snapshot;
367
+ if (input.symbol && input.orderId) {
368
+ const location = this.getOrderIdLocation(
369
+ record,
370
+ input.symbol,
371
+ input.orderId,
372
+ );
373
+ const snapshot = location
374
+ ? this.getSnapshotAtLocation(record, location)
375
+ : undefined;
376
+ if (!snapshot) {
377
+ return undefined;
223
378
  }
224
379
 
225
380
  if (
226
381
  input.clientOrderId &&
227
- snapshot.clientOrderId === input.clientOrderId
382
+ snapshot.clientOrderId !== input.clientOrderId
228
383
  ) {
229
- return snapshot;
384
+ return undefined;
230
385
  }
386
+
387
+ return snapshot;
388
+ }
389
+
390
+ if (input.orderId) {
391
+ 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
+ })),
403
+ ),
404
+ );
405
+ }
406
+
407
+ if (input.clientOrderId) {
408
+ return this.selectLatestSnapshot(
409
+ this.getSnapshotsForClientOrderId(record, input.clientOrderId).filter(
410
+ (snapshot) =>
411
+ shouldMatchOrderIdentity(snapshot, {
412
+ symbol: input.symbol,
413
+ clientOrderId: input.clientOrderId,
414
+ }),
415
+ ),
416
+ );
231
417
  }
232
418
 
233
419
  return undefined;
@@ -239,15 +425,11 @@ export class OrderManagerImpl
239
425
  return [];
240
426
  }
241
427
 
242
- return [...record.snapshots.values()].filter((snapshot) => {
243
- if (symbol && snapshot.symbol !== symbol) {
244
- return false;
245
- }
428
+ if (symbol) {
429
+ return [...(record.openOrders.get(symbol)?.values() ?? [])];
430
+ }
246
431
 
247
- return (
248
- snapshot.status === "open" || snapshot.status === "partially_filled"
249
- );
250
- });
432
+ return this.getOpenOrderSnapshots(record);
251
433
  }
252
434
 
253
435
  getOrderStatus(accountId: string): OrderDataStatus | undefined {
@@ -315,7 +497,7 @@ export class OrderManagerImpl
315
497
 
316
498
  record.status = {
317
499
  ...this.createStatus(accountId, venue, "active"),
318
- ready: record.snapshots.size > 0,
500
+ ready: this.getSnapshotCount(record) > 0,
319
501
  runtimeStatus: "bootstrap_pending",
320
502
  reason: undefined,
321
503
  lastReceivedAt: record.status.lastReceivedAt,
@@ -328,40 +510,82 @@ export class OrderManagerImpl
328
510
  onPrivateOrderBootstrap(
329
511
  accountId: string,
330
512
  venue: Venue,
331
- snapshots: RawOrderUpdate[],
332
- ): void {
513
+ snapshot: RawOpenOrdersSnapshot,
514
+ options: { requestStartedAt: number; preserveStatus?: boolean },
515
+ ): OrderSnapshot[] {
516
+ return this.onPrivateOrderReconcile(accountId, venue, snapshot, options);
517
+ }
518
+
519
+ onPrivateOrderReconcile(
520
+ accountId: string,
521
+ venue: Venue,
522
+ snapshot: RawOpenOrdersSnapshot,
523
+ options: { requestStartedAt: number; preserveStatus?: boolean },
524
+ ): OrderSnapshot[] {
333
525
  const record = this.getOrCreateRecord(accountId, venue);
334
526
  if (!record.subscribed) {
335
- return;
527
+ return [];
336
528
  }
337
529
 
338
- const nextSnapshots = new Map<string, OrderSnapshot>();
339
- for (const update of snapshots) {
340
- const snapshot = this.createSnapshot(
530
+ const openSetKeys = new Set<string>();
531
+ for (const update of snapshot.orders) {
532
+ const lookupKey = getOrderLookupKey(update);
533
+ if (lookupKey) {
534
+ openSetKeys.add(lookupKey);
535
+ }
536
+ const current = this.getExistingSnapshot(record, update);
537
+ const nextSnapshot = this.applyUpdateToRecord(
538
+ record,
341
539
  accountId,
342
540
  venue,
343
541
  update,
344
- this.getExistingSnapshot(record, update),
542
+ {
543
+ requestStartedAt: options.requestStartedAt,
544
+ preserveStatus: true,
545
+ },
345
546
  );
346
- this.setSnapshot(nextSnapshots, snapshot);
547
+ if (nextSnapshot) {
548
+ const nextLookupKey = getOrderLookupKey(nextSnapshot);
549
+ if (nextLookupKey) {
550
+ openSetKeys.add(nextLookupKey);
551
+ }
552
+ } else if (current) {
553
+ const currentLookupKey = getOrderLookupKey(current);
554
+ if (currentLookupKey) {
555
+ openSetKeys.add(currentLookupKey);
556
+ }
557
+ }
347
558
  }
348
559
 
349
- record.snapshots = nextSnapshots;
350
- const orderedSnapshots = [...record.snapshots.values()];
351
- const latestTs = orderedSnapshots.reduce(
352
- (max, snapshot) => Math.max(max, snapshot.updatedAt),
353
- 0,
560
+ const disappeared = this.getOpenOrderSnapshots(record).filter((order) => {
561
+ if (!isOpenOrder(order)) {
562
+ return false;
563
+ }
564
+
565
+ const lookupKey = getOrderLookupKey(order);
566
+ if (!lookupKey || openSetKeys.has(lookupKey)) {
567
+ return false;
568
+ }
569
+
570
+ return canDeleteMissingFromSnapshot(order, {
571
+ requestStartedAt: options.requestStartedAt,
572
+ snapshotExchangeTs: snapshot.snapshotExchangeTs,
573
+ });
574
+ });
575
+
576
+ const orderedSnapshots = this.getAllSnapshots(record);
577
+ const latestTs = Math.max(
578
+ snapshot.snapshotReceivedAt,
579
+ orderedSnapshots.reduce(
580
+ (max, order) => Math.max(max, order.updatedAt),
581
+ 0,
582
+ ),
354
583
  );
355
- record.status = {
356
- ...record.status,
357
- activity: "active",
358
- ready: true,
359
- runtimeStatus: "healthy",
360
- reason: undefined,
584
+ record.status = successfulStatus(record.status, {
585
+ preserveStatus: options.preserveStatus,
361
586
  lastReceivedAt: latestTs || record.status.lastReceivedAt,
362
587
  lastReadyAt: latestTs || this.context.now(),
363
- inactiveSince: undefined,
364
- };
588
+ });
365
589
 
366
590
  const event: OrderSnapshotReplacedEvent = {
367
591
  type: "order.snapshot_replaced",
@@ -373,21 +597,37 @@ export class OrderManagerImpl
373
597
 
374
598
  this.orderBus.publish(event);
375
599
  this.publishStatus(record);
600
+ return disappeared;
601
+ }
602
+
603
+ getPrivateOpenOrders(accountId: string): OrderSnapshot[] {
604
+ return this.getOpenOrders(accountId);
376
605
  }
377
606
 
378
607
  onPrivateOrderUpdate(
379
608
  accountId: string,
380
609
  venue: Venue,
381
610
  update: RawOrderUpdate,
611
+ options: { requestStartedAt?: number; preserveStatus?: boolean } = {},
382
612
  ): void {
383
613
  const record = this.getOrCreateRecord(accountId, venue);
384
614
  if (!record.subscribed) {
385
615
  return;
386
616
  }
387
617
 
388
- const previous = this.getExistingSnapshot(record, update);
389
- const snapshot = this.createSnapshot(accountId, venue, update, previous);
390
- this.setSnapshot(record.snapshots, snapshot);
618
+ const snapshot = this.applyUpdateToRecord(
619
+ record,
620
+ accountId,
621
+ venue,
622
+ update,
623
+ {
624
+ requestStartedAt: options.requestStartedAt,
625
+ preserveStatus: options.preserveStatus,
626
+ },
627
+ );
628
+ if (!snapshot) {
629
+ return;
630
+ }
391
631
 
392
632
  const eventType =
393
633
  snapshot.status === "filled"
@@ -407,16 +647,11 @@ export class OrderManagerImpl
407
647
  ts: this.context.now(),
408
648
  });
409
649
 
410
- record.status = {
411
- ...record.status,
412
- activity: "active",
413
- ready: true,
414
- runtimeStatus: "healthy",
415
- reason: undefined,
650
+ record.status = successfulStatus(record.status, {
651
+ preserveStatus: options.preserveStatus,
416
652
  lastReceivedAt: snapshot.receivedAt,
417
653
  lastReadyAt: snapshot.updatedAt,
418
- inactiveSince: undefined,
419
- };
654
+ });
420
655
  this.publishStatus(record);
421
656
  }
422
657
 
@@ -467,7 +702,11 @@ export class OrderManagerImpl
467
702
  accountId,
468
703
  venue,
469
704
  subscribed: false,
470
- snapshots: new Map(),
705
+ openOrders: new Map(),
706
+ closedOrders: new Map(),
707
+ orderIdIndex: new Map(),
708
+ orderIdOnlyIndex: new Map(),
709
+ clientOrderIdIndex: new Map(),
471
710
  status: this.createStatus(accountId, venue, "inactive"),
472
711
  };
473
712
 
@@ -491,18 +730,40 @@ export class OrderManagerImpl
491
730
 
492
731
  private getExistingSnapshot(
493
732
  record: OrderRecord,
494
- update: { orderId?: string; clientOrderId?: string },
733
+ update: { symbol: string; orderId?: string; clientOrderId?: string },
495
734
  ): OrderSnapshot | undefined {
496
- for (const snapshot of record.snapshots.values()) {
497
- if (update.orderId && snapshot.orderId === update.orderId) {
498
- return snapshot;
735
+ const location = this.getExistingSnapshotLocation(record, update);
736
+ return location ? this.getSnapshotAtLocation(record, location) : undefined;
737
+ }
738
+
739
+ private getExistingSnapshotLocation(
740
+ record: OrderRecord,
741
+ update: { symbol: string; orderId?: string; clientOrderId?: string },
742
+ ): OrderLocation | undefined {
743
+ if (update.orderId) {
744
+ const location = this.getOrderIdLocation(
745
+ record,
746
+ update.symbol,
747
+ update.orderId,
748
+ );
749
+ const snapshot = location
750
+ ? this.getSnapshotAtLocation(record, location)
751
+ : undefined;
752
+ if (snapshot && shouldMatchStoredOrderIdentity(snapshot, update)) {
753
+ return location;
499
754
  }
755
+ }
500
756
 
501
- if (
502
- update.clientOrderId &&
503
- snapshot.clientOrderId === update.clientOrderId
504
- ) {
505
- return snapshot;
757
+ if (!update.clientOrderId) {
758
+ return undefined;
759
+ }
760
+
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;
506
767
  }
507
768
  }
508
769
 
@@ -510,17 +771,441 @@ export class OrderManagerImpl
510
771
  }
511
772
 
512
773
  private setSnapshot(
513
- snapshots: Map<string, OrderSnapshot>,
774
+ record: OrderRecord,
775
+ snapshot: OrderSnapshot,
776
+ previous?: OrderSnapshot,
777
+ ): OrderLocation | undefined {
778
+ const existing = previous ?? this.getExistingSnapshot(record, snapshot);
779
+ const previousLocation = existing
780
+ ? this.getSnapshotLocation(existing)
781
+ : undefined;
782
+
783
+ if (previousLocation) {
784
+ return this.moveSnapshot(record, previousLocation, snapshot);
785
+ }
786
+
787
+ return this.insertSnapshot(record, snapshot);
788
+ }
789
+
790
+ private insertSnapshot(
791
+ record: OrderRecord,
792
+ snapshot: OrderSnapshot,
793
+ ): OrderLocation | undefined {
794
+ const location = this.getSnapshotLocation(snapshot);
795
+ if (!location) {
796
+ this.warnDroppedUnkeyedTerminalOrder(record, snapshot);
797
+ return undefined;
798
+ }
799
+
800
+ this.deleteSnapshot(record, location);
801
+
802
+ const table = this.getOrderTable(record, location.table);
803
+ const symbolOrders = this.getOrCreateSymbolOrders(table, location.symbol);
804
+ symbolOrders.set(location.key, snapshot);
805
+ this.trimClosedOrdersForSymbol(record, location);
806
+
807
+ if (snapshot.orderId) {
808
+ const symbolIndex = this.getOrCreateOrderIdSymbolIndex(
809
+ record,
810
+ snapshot.symbol,
811
+ );
812
+ symbolIndex.set(snapshot.orderId, location);
813
+ this.addLocationToSetIndex(
814
+ record.orderIdOnlyIndex,
815
+ snapshot.orderId,
816
+ location,
817
+ );
818
+ }
819
+
820
+ if (snapshot.clientOrderId) {
821
+ this.addLocationToSetIndex(
822
+ record.clientOrderIdIndex,
823
+ snapshot.clientOrderId,
824
+ location,
825
+ );
826
+ }
827
+
828
+ this.warnProvisionalTerminalOrder(record, snapshot);
829
+ return location;
830
+ }
831
+
832
+ private deleteSnapshot(
833
+ record: OrderRecord,
834
+ location: OrderLocation,
835
+ ): OrderSnapshot | undefined {
836
+ const snapshot = this.getSnapshotAtLocation(record, location);
837
+ if (!snapshot) {
838
+ return undefined;
839
+ }
840
+
841
+ const table = this.getOrderTable(record, location.table);
842
+ const symbolOrders = table.get(location.symbol);
843
+ symbolOrders?.delete(location.key);
844
+ if (symbolOrders?.size === 0) {
845
+ table.delete(location.symbol);
846
+ }
847
+
848
+ if (snapshot.orderId) {
849
+ const symbolIndex = record.orderIdIndex.get(location.symbol);
850
+ if (
851
+ symbolIndex?.get(snapshot.orderId) &&
852
+ this.locationsEqual(symbolIndex.get(snapshot.orderId), location)
853
+ ) {
854
+ symbolIndex.delete(snapshot.orderId);
855
+ }
856
+ if (symbolIndex?.size === 0) {
857
+ record.orderIdIndex.delete(location.symbol);
858
+ }
859
+ this.removeLocationFromSetIndex(
860
+ record.orderIdOnlyIndex,
861
+ snapshot.orderId,
862
+ location,
863
+ );
864
+ }
865
+
866
+ if (snapshot.clientOrderId) {
867
+ this.removeLocationFromSetIndex(
868
+ record.clientOrderIdIndex,
869
+ snapshot.clientOrderId,
870
+ location,
871
+ );
872
+ }
873
+
874
+ return snapshot;
875
+ }
876
+
877
+ private moveSnapshot(
878
+ record: OrderRecord,
879
+ previousLocation: OrderLocation,
880
+ snapshot: OrderSnapshot,
881
+ ): OrderLocation | undefined {
882
+ this.deleteSnapshot(record, previousLocation);
883
+ return this.insertSnapshot(record, snapshot);
884
+ }
885
+
886
+ private trimClosedOrdersForSymbol(
887
+ record: OrderRecord,
888
+ location: OrderLocation,
889
+ ): void {
890
+ if (location.table !== "closed") {
891
+ return;
892
+ }
893
+
894
+ let symbolOrders = record.closedOrders.get(location.symbol);
895
+ if (!symbolOrders || symbolOrders.size <= this.maxClosedOrdersPerSymbol) {
896
+ return;
897
+ }
898
+
899
+ const trimBatchSize = Math.max(
900
+ 1,
901
+ Math.floor(this.maxClosedOrdersPerSymbol / 10),
902
+ );
903
+ while (symbolOrders && symbolOrders.size > this.maxClosedOrdersPerSymbol) {
904
+ const keys = symbolOrders.keys();
905
+ for (let deleted = 0; deleted < trimBatchSize; deleted += 1) {
906
+ const next = keys.next();
907
+ if (next.done) {
908
+ break;
909
+ }
910
+ this.deleteSnapshot(record, {
911
+ table: "closed",
912
+ symbol: location.symbol,
913
+ key: next.value,
914
+ });
915
+ }
916
+ symbolOrders = record.closedOrders.get(location.symbol);
917
+ }
918
+ }
919
+
920
+ private getSnapshotLocation(
921
+ snapshot: OrderSnapshot,
922
+ ): OrderLocation | undefined {
923
+ const key = getOrderKey(snapshot);
924
+ if (!key) {
925
+ return undefined;
926
+ }
927
+
928
+ return {
929
+ table: isOpenOrder(snapshot) ? "open" : "closed",
930
+ symbol: snapshot.symbol,
931
+ key,
932
+ };
933
+ }
934
+
935
+ private warnDroppedUnkeyedTerminalOrder(
936
+ record: OrderRecord,
514
937
  snapshot: OrderSnapshot,
515
938
  ): void {
516
- const lookupKey =
517
- getOrderLookupKey(snapshot) ??
518
- getOrderLookupKey({
519
- clientOrderId: snapshot.clientOrderId,
520
- });
521
- if (lookupKey) {
522
- snapshots.set(lookupKey, snapshot);
939
+ if (isOpenOrder(snapshot)) {
940
+ return;
941
+ }
942
+
943
+ this.context.publishRuntimeError(
944
+ "order",
945
+ new Error(
946
+ "Dropped terminal order update without orderId or clientOrderId",
947
+ ),
948
+ {
949
+ accountId: record.accountId,
950
+ venue: record.venue,
951
+ symbol: snapshot.symbol,
952
+ },
953
+ );
954
+ }
955
+
956
+ private warnProvisionalTerminalOrder(
957
+ record: OrderRecord,
958
+ snapshot: OrderSnapshot,
959
+ ): void {
960
+ // 终态单缺 orderId 但有 clientOrderId: 用 client key provisional 存储并告警。
961
+ // adapter 契约要求终态带 orderId(见 adapter-contract.md);仅 cid 无法保证稳定唯一主键。
962
+ if (snapshot.orderId || isOpenOrder(snapshot) || !snapshot.clientOrderId) {
963
+ return;
964
+ }
965
+
966
+ this.context.publishRuntimeError(
967
+ "order",
968
+ new Error(
969
+ "Stored terminal order without orderId using provisional clientOrderId key",
970
+ ),
971
+ {
972
+ accountId: record.accountId,
973
+ venue: record.venue,
974
+ symbol: snapshot.symbol,
975
+ },
976
+ );
977
+ }
978
+
979
+ private getSnapshotAtLocation(
980
+ record: OrderRecord,
981
+ location: OrderLocation,
982
+ ): OrderSnapshot | undefined {
983
+ return this.getOrderTable(record, location.table)
984
+ .get(location.symbol)
985
+ ?.get(location.key);
986
+ }
987
+
988
+ private getOrderTable(
989
+ record: OrderRecord,
990
+ table: OrderTable,
991
+ ): Map<string, Map<string, OrderSnapshot>> {
992
+ return table === "open" ? record.openOrders : record.closedOrders;
993
+ }
994
+
995
+ private getOrCreateSymbolOrders(
996
+ table: Map<string, Map<string, OrderSnapshot>>,
997
+ symbol: string,
998
+ ): Map<string, OrderSnapshot> {
999
+ const existing = table.get(symbol);
1000
+ if (existing) {
1001
+ return existing;
1002
+ }
1003
+
1004
+ const created = new Map<string, OrderSnapshot>();
1005
+ table.set(symbol, created);
1006
+ return created;
1007
+ }
1008
+
1009
+ private getOrCreateOrderIdSymbolIndex(
1010
+ record: OrderRecord,
1011
+ symbol: string,
1012
+ ): Map<string, OrderLocation> {
1013
+ const existing = record.orderIdIndex.get(symbol);
1014
+ if (existing) {
1015
+ return existing;
1016
+ }
1017
+
1018
+ const created = new Map<string, OrderLocation>();
1019
+ record.orderIdIndex.set(symbol, created);
1020
+ return created;
1021
+ }
1022
+
1023
+ private getOrderIdLocation(
1024
+ record: OrderRecord,
1025
+ symbol: string,
1026
+ orderId: string,
1027
+ ): OrderLocation | undefined {
1028
+ return record.orderIdIndex.get(symbol)?.get(orderId);
1029
+ }
1030
+
1031
+ private getSnapshotsForOrderId(
1032
+ record: OrderRecord,
1033
+ orderId: string,
1034
+ ): OrderSnapshot[] {
1035
+ return this.getSnapshotsForLocations(
1036
+ record,
1037
+ record.orderIdOnlyIndex.get(orderId),
1038
+ );
1039
+ }
1040
+
1041
+ private getSnapshotsForClientOrderId(
1042
+ record: OrderRecord,
1043
+ clientOrderId: string,
1044
+ ): OrderSnapshot[] {
1045
+ return this.getSnapshotsForLocations(
1046
+ record,
1047
+ record.clientOrderIdIndex.get(clientOrderId),
1048
+ );
1049
+ }
1050
+
1051
+ private getSnapshotsForLocations(
1052
+ record: OrderRecord,
1053
+ locations?: Iterable<OrderLocation>,
1054
+ ): OrderSnapshot[] {
1055
+ if (!locations) {
1056
+ return [];
1057
+ }
1058
+
1059
+ const snapshots: OrderSnapshot[] = [];
1060
+ for (const location of locations) {
1061
+ const snapshot = this.getSnapshotAtLocation(record, location);
1062
+ if (snapshot) {
1063
+ snapshots.push(snapshot);
1064
+ }
1065
+ }
1066
+
1067
+ return snapshots;
1068
+ }
1069
+
1070
+ private getOpenOrderSnapshots(record: OrderRecord): OrderSnapshot[] {
1071
+ return this.getSnapshotsInTable(record.openOrders);
1072
+ }
1073
+
1074
+ private getAllSnapshots(record: OrderRecord): OrderSnapshot[] {
1075
+ return [
1076
+ ...this.getSnapshotsInTable(record.openOrders),
1077
+ ...this.getSnapshotsInTable(record.closedOrders),
1078
+ ];
1079
+ }
1080
+
1081
+ private getSnapshotsInTable(
1082
+ table: Map<string, Map<string, OrderSnapshot>>,
1083
+ ): OrderSnapshot[] {
1084
+ const snapshots: OrderSnapshot[] = [];
1085
+ for (const symbolOrders of table.values()) {
1086
+ snapshots.push(...symbolOrders.values());
1087
+ }
1088
+
1089
+ return snapshots;
1090
+ }
1091
+
1092
+ private getSnapshotCount(record: OrderRecord): number {
1093
+ return (
1094
+ this.getSnapshotCountInTable(record.openOrders) +
1095
+ this.getSnapshotCountInTable(record.closedOrders)
1096
+ );
1097
+ }
1098
+
1099
+ private getSnapshotCountInTable(
1100
+ table: Map<string, Map<string, OrderSnapshot>>,
1101
+ ): number {
1102
+ let size = 0;
1103
+ for (const symbolOrders of table.values()) {
1104
+ size += symbolOrders.size;
1105
+ }
1106
+
1107
+ return size;
1108
+ }
1109
+
1110
+ private addLocationToSetIndex(
1111
+ index: Map<string, Set<OrderLocation>>,
1112
+ key: string,
1113
+ location: OrderLocation,
1114
+ ): void {
1115
+ this.removeLocationFromSetIndex(index, key, location);
1116
+
1117
+ const locations = index.get(key);
1118
+ if (locations) {
1119
+ locations.add(location);
1120
+ return;
1121
+ }
1122
+
1123
+ index.set(key, new Set([location]));
1124
+ }
1125
+
1126
+ private removeLocationFromSetIndex(
1127
+ index: Map<string, Set<OrderLocation>>,
1128
+ key: string,
1129
+ location: OrderLocation,
1130
+ ): void {
1131
+ const locations = index.get(key);
1132
+ if (!locations) {
1133
+ return;
1134
+ }
1135
+
1136
+ for (const candidate of locations) {
1137
+ if (this.locationsEqual(candidate, location)) {
1138
+ locations.delete(candidate);
1139
+ break;
1140
+ }
1141
+ }
1142
+
1143
+ if (locations.size === 0) {
1144
+ index.delete(key);
1145
+ }
1146
+ }
1147
+
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
+ private selectLatestSnapshot(
1161
+ snapshots: OrderSnapshot[],
1162
+ ): OrderSnapshot | undefined {
1163
+ let latest: OrderSnapshot | undefined;
1164
+ for (const snapshot of snapshots) {
1165
+ if (!latest) {
1166
+ latest = snapshot;
1167
+ continue;
1168
+ }
1169
+
1170
+ const snapshotOpen = isOpenOrder(snapshot);
1171
+ const latestOpen = isOpenOrder(latest);
1172
+ if (snapshotOpen !== latestOpen) {
1173
+ // open 候选绝对优先:当前活跃订单优于历史终态(clientOrderId 复用时旧单已 closed)
1174
+ if (snapshotOpen) {
1175
+ latest = snapshot;
1176
+ }
1177
+ continue;
1178
+ }
1179
+
1180
+ // 同为 open 或同为 closed: 取 updatedAt 最新。
1181
+ // 不能用 seq —— seq 是单订单版本号,跨订单(如复用 cid 的不同订单)不可比。
1182
+ if (snapshot.updatedAt > latest.updatedAt) {
1183
+ latest = snapshot;
1184
+ }
523
1185
  }
1186
+
1187
+ return latest;
1188
+ }
1189
+
1190
+ private applyUpdateToRecord(
1191
+ record: OrderRecord,
1192
+ accountId: string,
1193
+ venue: Venue,
1194
+ update: RawOrderUpdate,
1195
+ options: { requestStartedAt?: number; preserveStatus?: boolean } = {},
1196
+ ): OrderSnapshot | undefined {
1197
+ const previous = this.getExistingSnapshot(record, update);
1198
+ if (
1199
+ !shouldApplyWatermarkedUpdate(previous, update, {
1200
+ requestStartedAt: options.requestStartedAt,
1201
+ source: options.requestStartedAt === undefined ? "stream" : "rest",
1202
+ })
1203
+ ) {
1204
+ return undefined;
1205
+ }
1206
+
1207
+ const snapshot = this.createSnapshot(accountId, venue, update, previous);
1208
+ return this.setSnapshot(record, snapshot, previous) ? snapshot : undefined;
524
1209
  }
525
1210
 
526
1211
  private createSnapshot(
@@ -530,7 +1215,13 @@ export class OrderManagerImpl
530
1215
  previous?: OrderSnapshot,
531
1216
  ): OrderSnapshot {
532
1217
  const amount = new BigNumber(input.amount);
533
- const filled = new BigNumber(input.filled);
1218
+ const rawFilled = new BigNumber(input.filled);
1219
+ const filled =
1220
+ previous &&
1221
+ input.exchangeTs !== undefined &&
1222
+ previous.exchangeTs === input.exchangeTs
1223
+ ? BigNumber.maximum(rawFilled, previous.filled)
1224
+ : rawFilled;
534
1225
  const remaining =
535
1226
  input.remaining === undefined
536
1227
  ? amount.minus(filled)
@@ -539,29 +1230,27 @@ export class OrderManagerImpl
539
1230
  return {
540
1231
  accountId,
541
1232
  venue,
542
- orderId: input.orderId,
543
- clientOrderId: input.clientOrderId,
1233
+ orderId: input.orderId ?? previous?.orderId,
1234
+ clientOrderId: input.clientOrderId ?? previous?.clientOrderId,
544
1235
  symbol: input.symbol,
545
1236
  side: input.side,
546
1237
  type: input.type,
547
- status: input.status,
1238
+ status: this.mergeOrderStatus(input, previous),
548
1239
  price:
549
- input.price === undefined
550
- ? previous?.price
551
- : new BigNumber(input.price),
1240
+ input.price === undefined ? previous?.price : toCanonical(input.price),
552
1241
  triggerPrice:
553
1242
  input.triggerPrice === undefined
554
1243
  ? previous?.triggerPrice
555
- : new BigNumber(input.triggerPrice),
556
- amount,
557
- filled,
558
- remaining,
1244
+ : toCanonical(input.triggerPrice),
1245
+ amount: toCanonical(amount),
1246
+ filled: toCanonical(filled),
1247
+ remaining: toCanonical(remaining),
559
1248
  reduceOnly: input.reduceOnly ?? previous?.reduceOnly,
560
1249
  positionSide: input.positionSide ?? previous?.positionSide,
561
1250
  avgFillPrice:
562
1251
  input.avgFillPrice === undefined
563
1252
  ? previous?.avgFillPrice
564
- : new BigNumber(input.avgFillPrice),
1253
+ : toCanonical(input.avgFillPrice),
565
1254
  exchangeTs: input.exchangeTs,
566
1255
  receivedAt: input.receivedAt,
567
1256
  updatedAt: input.receivedAt,
@@ -569,6 +1258,26 @@ export class OrderManagerImpl
569
1258
  };
570
1259
  }
571
1260
 
1261
+ private mergeOrderStatus(
1262
+ input: RawOrderUpdate,
1263
+ previous?: OrderSnapshot,
1264
+ ): OrderSnapshot["status"] {
1265
+ if (!previous) {
1266
+ return input.status;
1267
+ }
1268
+
1269
+ if (
1270
+ input.exchangeTs !== undefined &&
1271
+ previous.exchangeTs !== undefined &&
1272
+ input.exchangeTs === previous.exchangeTs &&
1273
+ orderPriority(input.status) < orderPriority(previous.status)
1274
+ ) {
1275
+ return previous.status;
1276
+ }
1277
+
1278
+ return input.status;
1279
+ }
1280
+
572
1281
  private publishStatus(record: OrderRecord): void {
573
1282
  const event: OrderStatusChangedEvent = {
574
1283
  type: "order.status_changed",
@@ -626,7 +1335,7 @@ export class OrderManagerImpl
626
1335
  const record = this.getOrCreateRecord(accountId, venue);
627
1336
  const previous = this.getExistingSnapshot(record, update);
628
1337
  const snapshot = this.createSnapshot(accountId, venue, update, previous);
629
- this.setSnapshot(record.snapshots, snapshot);
1338
+ this.setSnapshot(record, snapshot, previous);
630
1339
  return snapshot;
631
1340
  }
632
1341
 
@@ -654,7 +1363,8 @@ export class OrderManagerImpl
654
1363
  symbol?: string;
655
1364
  },
656
1365
  ): AcexError {
657
- const error = new AcexError(code, message);
1366
+ const details = buildAcexErrorDetails(metadata);
1367
+ const error = new AcexError(code, message, { details });
658
1368
  this.context.publishRuntimeError("order", error, metadata);
659
1369
  return error;
660
1370
  }
@@ -681,6 +1391,10 @@ export class OrderManagerImpl
681
1391
  error instanceof Error ? error : new Error(message),
682
1392
  metadata,
683
1393
  );
684
- return new AcexError(code, message);
1394
+ const details = buildAcexErrorDetails(metadata, error);
1395
+ return new AcexError(code, formatAcexErrorMessage(message, details), {
1396
+ cause: error,
1397
+ details,
1398
+ });
685
1399
  }
686
1400
  }