@imbingox/acex 0.4.0-beta.1 → 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,10 +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";
13
20
  import { toCanonical } from "../internal/decimal.ts";
14
21
  import { matchesOrderFilter } from "../internal/filters.ts";
22
+ import {
23
+ canDeleteMissingFromSnapshot,
24
+ shouldApplyWatermarkedUpdate,
25
+ } from "../internal/watermark.ts";
15
26
  import type {
16
27
  CancelAllOrdersInput,
17
28
  CancelOrderInput,
@@ -33,15 +44,55 @@ interface OrderRecord {
33
44
  accountId: string;
34
45
  venue: Venue;
35
46
  subscribed: boolean;
36
- 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>>;
37
52
  status: OrderDataStatus;
38
53
  }
39
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
+
40
69
  function cloneOrderStatus(status: OrderDataStatus): OrderDataStatus {
41
70
  return { ...status };
42
71
  }
43
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
+
44
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: {
45
96
  orderId?: string;
46
97
  clientOrderId?: string;
47
98
  }): string | undefined {
@@ -56,6 +107,94 @@ function getOrderLookupKey(input: {
56
107
  return undefined;
57
108
  }
58
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
+
59
198
  export class OrderManagerImpl
60
199
  implements
61
200
  OrderManager,
@@ -67,13 +206,17 @@ export class OrderManagerImpl
67
206
  readonly events: OrderEventStreams;
68
207
 
69
208
  private readonly context: ClientContext;
209
+ private readonly maxClosedOrdersPerSymbol: number;
70
210
  private readonly orderBus = new AsyncEventBus<OrderEvent>();
71
211
  private readonly orderStatusBus =
72
212
  new AsyncEventBus<OrderStatusChangedEvent>();
73
213
  private readonly records = new Map<string, OrderRecord>();
74
214
 
75
- constructor(context: ClientContext) {
215
+ constructor(context: ClientContext, options: OrderManagerOptions = {}) {
76
216
  this.context = context;
217
+ this.maxClosedOrdersPerSymbol = normalizeMaxClosedOrdersPerSymbol(
218
+ options.maxClosedOrdersPerSymbol,
219
+ );
77
220
 
78
221
  this.events = {
79
222
  status: (filter) =>
@@ -102,7 +245,10 @@ export class OrderManagerImpl
102
245
  async subscribeOrders(input: SubscribeOrdersInput): Promise<void> {
103
246
  this.context.assertStarted();
104
247
  const account = this.context.getRegisteredAccount(input.accountId);
105
- if (account.venue === "juplend") {
248
+ if (
249
+ this.context.getPrivateOrderCapabilities(account.venue)?.updates ===
250
+ "unsupported"
251
+ ) {
106
252
  throw this.createError(
107
253
  "VENUE_NOT_SUPPORTED",
108
254
  `Venue does not support private order subscriptions: ${account.venue}`,
@@ -218,17 +364,56 @@ export class OrderManagerImpl
218
364
  return undefined;
219
365
  }
220
366
 
221
- for (const snapshot of record.snapshots.values()) {
222
- if (input.orderId && snapshot.orderId === input.orderId) {
223
- 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;
224
378
  }
225
379
 
226
380
  if (
227
381
  input.clientOrderId &&
228
- snapshot.clientOrderId === input.clientOrderId
382
+ snapshot.clientOrderId !== input.clientOrderId
229
383
  ) {
230
- return snapshot;
384
+ return undefined;
231
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
+ );
232
417
  }
233
418
 
234
419
  return undefined;
@@ -240,15 +425,11 @@ export class OrderManagerImpl
240
425
  return [];
241
426
  }
242
427
 
243
- return [...record.snapshots.values()].filter((snapshot) => {
244
- if (symbol && snapshot.symbol !== symbol) {
245
- return false;
246
- }
428
+ if (symbol) {
429
+ return [...(record.openOrders.get(symbol)?.values() ?? [])];
430
+ }
247
431
 
248
- return (
249
- snapshot.status === "open" || snapshot.status === "partially_filled"
250
- );
251
- });
432
+ return this.getOpenOrderSnapshots(record);
252
433
  }
253
434
 
254
435
  getOrderStatus(accountId: string): OrderDataStatus | undefined {
@@ -316,7 +497,7 @@ export class OrderManagerImpl
316
497
 
317
498
  record.status = {
318
499
  ...this.createStatus(accountId, venue, "active"),
319
- ready: record.snapshots.size > 0,
500
+ ready: this.getSnapshotCount(record) > 0,
320
501
  runtimeStatus: "bootstrap_pending",
321
502
  reason: undefined,
322
503
  lastReceivedAt: record.status.lastReceivedAt,
@@ -329,40 +510,82 @@ export class OrderManagerImpl
329
510
  onPrivateOrderBootstrap(
330
511
  accountId: string,
331
512
  venue: Venue,
332
- snapshots: RawOrderUpdate[],
333
- ): 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[] {
334
525
  const record = this.getOrCreateRecord(accountId, venue);
335
526
  if (!record.subscribed) {
336
- return;
527
+ return [];
337
528
  }
338
529
 
339
- const nextSnapshots = new Map<string, OrderSnapshot>();
340
- for (const update of snapshots) {
341
- 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,
342
539
  accountId,
343
540
  venue,
344
541
  update,
345
- this.getExistingSnapshot(record, update),
542
+ {
543
+ requestStartedAt: options.requestStartedAt,
544
+ preserveStatus: true,
545
+ },
346
546
  );
347
- 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
+ }
348
558
  }
349
559
 
350
- record.snapshots = nextSnapshots;
351
- const orderedSnapshots = [...record.snapshots.values()];
352
- const latestTs = orderedSnapshots.reduce(
353
- (max, snapshot) => Math.max(max, snapshot.updatedAt),
354
- 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
+ ),
355
583
  );
356
- record.status = {
357
- ...record.status,
358
- activity: "active",
359
- ready: true,
360
- runtimeStatus: "healthy",
361
- reason: undefined,
584
+ record.status = successfulStatus(record.status, {
585
+ preserveStatus: options.preserveStatus,
362
586
  lastReceivedAt: latestTs || record.status.lastReceivedAt,
363
587
  lastReadyAt: latestTs || this.context.now(),
364
- inactiveSince: undefined,
365
- };
588
+ });
366
589
 
367
590
  const event: OrderSnapshotReplacedEvent = {
368
591
  type: "order.snapshot_replaced",
@@ -374,21 +597,37 @@ export class OrderManagerImpl
374
597
 
375
598
  this.orderBus.publish(event);
376
599
  this.publishStatus(record);
600
+ return disappeared;
601
+ }
602
+
603
+ getPrivateOpenOrders(accountId: string): OrderSnapshot[] {
604
+ return this.getOpenOrders(accountId);
377
605
  }
378
606
 
379
607
  onPrivateOrderUpdate(
380
608
  accountId: string,
381
609
  venue: Venue,
382
610
  update: RawOrderUpdate,
611
+ options: { requestStartedAt?: number; preserveStatus?: boolean } = {},
383
612
  ): void {
384
613
  const record = this.getOrCreateRecord(accountId, venue);
385
614
  if (!record.subscribed) {
386
615
  return;
387
616
  }
388
617
 
389
- const previous = this.getExistingSnapshot(record, update);
390
- const snapshot = this.createSnapshot(accountId, venue, update, previous);
391
- 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
+ }
392
631
 
393
632
  const eventType =
394
633
  snapshot.status === "filled"
@@ -408,16 +647,11 @@ export class OrderManagerImpl
408
647
  ts: this.context.now(),
409
648
  });
410
649
 
411
- record.status = {
412
- ...record.status,
413
- activity: "active",
414
- ready: true,
415
- runtimeStatus: "healthy",
416
- reason: undefined,
650
+ record.status = successfulStatus(record.status, {
651
+ preserveStatus: options.preserveStatus,
417
652
  lastReceivedAt: snapshot.receivedAt,
418
653
  lastReadyAt: snapshot.updatedAt,
419
- inactiveSince: undefined,
420
- };
654
+ });
421
655
  this.publishStatus(record);
422
656
  }
423
657
 
@@ -468,7 +702,11 @@ export class OrderManagerImpl
468
702
  accountId,
469
703
  venue,
470
704
  subscribed: false,
471
- snapshots: new Map(),
705
+ openOrders: new Map(),
706
+ closedOrders: new Map(),
707
+ orderIdIndex: new Map(),
708
+ orderIdOnlyIndex: new Map(),
709
+ clientOrderIdIndex: new Map(),
472
710
  status: this.createStatus(accountId, venue, "inactive"),
473
711
  };
474
712
 
@@ -492,18 +730,40 @@ export class OrderManagerImpl
492
730
 
493
731
  private getExistingSnapshot(
494
732
  record: OrderRecord,
495
- update: { orderId?: string; clientOrderId?: string },
733
+ update: { symbol: string; orderId?: string; clientOrderId?: string },
496
734
  ): OrderSnapshot | undefined {
497
- for (const snapshot of record.snapshots.values()) {
498
- if (update.orderId && snapshot.orderId === update.orderId) {
499
- 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;
500
754
  }
755
+ }
501
756
 
502
- if (
503
- update.clientOrderId &&
504
- snapshot.clientOrderId === update.clientOrderId
505
- ) {
506
- 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;
507
767
  }
508
768
  }
509
769
 
@@ -511,17 +771,441 @@ export class OrderManagerImpl
511
771
  }
512
772
 
513
773
  private setSnapshot(
514
- 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,
515
937
  snapshot: OrderSnapshot,
516
938
  ): void {
517
- const lookupKey =
518
- getOrderLookupKey(snapshot) ??
519
- getOrderLookupKey({
520
- clientOrderId: snapshot.clientOrderId,
521
- });
522
- if (lookupKey) {
523
- 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
+ }
524
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;
525
1209
  }
526
1210
 
527
1211
  private createSnapshot(
@@ -531,7 +1215,13 @@ export class OrderManagerImpl
531
1215
  previous?: OrderSnapshot,
532
1216
  ): OrderSnapshot {
533
1217
  const amount = new BigNumber(input.amount);
534
- 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;
535
1225
  const remaining =
536
1226
  input.remaining === undefined
537
1227
  ? amount.minus(filled)
@@ -540,12 +1230,12 @@ export class OrderManagerImpl
540
1230
  return {
541
1231
  accountId,
542
1232
  venue,
543
- orderId: input.orderId,
544
- clientOrderId: input.clientOrderId,
1233
+ orderId: input.orderId ?? previous?.orderId,
1234
+ clientOrderId: input.clientOrderId ?? previous?.clientOrderId,
545
1235
  symbol: input.symbol,
546
1236
  side: input.side,
547
1237
  type: input.type,
548
- status: input.status,
1238
+ status: this.mergeOrderStatus(input, previous),
549
1239
  price:
550
1240
  input.price === undefined ? previous?.price : toCanonical(input.price),
551
1241
  triggerPrice:
@@ -568,6 +1258,26 @@ export class OrderManagerImpl
568
1258
  };
569
1259
  }
570
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
+
571
1281
  private publishStatus(record: OrderRecord): void {
572
1282
  const event: OrderStatusChangedEvent = {
573
1283
  type: "order.status_changed",
@@ -625,7 +1335,7 @@ export class OrderManagerImpl
625
1335
  const record = this.getOrCreateRecord(accountId, venue);
626
1336
  const previous = this.getExistingSnapshot(record, update);
627
1337
  const snapshot = this.createSnapshot(accountId, venue, update, previous);
628
- this.setSnapshot(record.snapshots, snapshot);
1338
+ this.setSnapshot(record, snapshot, previous);
629
1339
  return snapshot;
630
1340
  }
631
1341
 
@@ -653,7 +1363,8 @@ export class OrderManagerImpl
653
1363
  symbol?: string;
654
1364
  },
655
1365
  ): AcexError {
656
- const error = new AcexError(code, message);
1366
+ const details = buildAcexErrorDetails(metadata);
1367
+ const error = new AcexError(code, message, { details });
657
1368
  this.context.publishRuntimeError("order", error, metadata);
658
1369
  return error;
659
1370
  }
@@ -680,6 +1391,10 @@ export class OrderManagerImpl
680
1391
  error instanceof Error ? error : new Error(message),
681
1392
  metadata,
682
1393
  );
683
- 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
+ });
684
1399
  }
685
1400
  }