@catalyst-team/poly-sdk 0.4.6 → 0.4.7

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.
Files changed (55) hide show
  1. package/README.md +18 -9
  2. package/README.zh-CN.md +18 -9
  3. package/dist/src/clients/data-api.d.ts +25 -0
  4. package/dist/src/clients/data-api.d.ts.map +1 -1
  5. package/dist/src/clients/data-api.js +57 -0
  6. package/dist/src/clients/data-api.js.map +1 -1
  7. package/dist/src/core/types.d.ts +55 -0
  8. package/dist/src/core/types.d.ts.map +1 -1
  9. package/dist/src/index.d.ts +6 -5
  10. package/dist/src/index.d.ts.map +1 -1
  11. package/dist/src/index.js +4 -2
  12. package/dist/src/index.js.map +1 -1
  13. package/dist/src/realtime/index.d.ts +18 -0
  14. package/dist/src/realtime/index.d.ts.map +1 -0
  15. package/dist/src/realtime/index.js +14 -0
  16. package/dist/src/realtime/index.js.map +1 -0
  17. package/dist/src/realtime/realtime-data-client.d.ts +274 -0
  18. package/dist/src/realtime/realtime-data-client.d.ts.map +1 -0
  19. package/dist/src/realtime/realtime-data-client.js +771 -0
  20. package/dist/src/realtime/realtime-data-client.js.map +1 -0
  21. package/dist/src/realtime/types.d.ts +485 -0
  22. package/dist/src/realtime/types.d.ts.map +1 -0
  23. package/dist/src/realtime/types.js +36 -0
  24. package/dist/src/realtime/types.js.map +1 -0
  25. package/dist/src/services/arbitrage-service.d.ts.map +1 -1
  26. package/dist/src/services/arbitrage-service.js +2 -1
  27. package/dist/src/services/arbitrage-service.js.map +1 -1
  28. package/dist/src/services/dip-arb-service.d.ts.map +1 -1
  29. package/dist/src/services/dip-arb-service.js +3 -19
  30. package/dist/src/services/dip-arb-service.js.map +1 -1
  31. package/dist/src/services/market-service.d.ts +93 -11
  32. package/dist/src/services/market-service.d.ts.map +1 -1
  33. package/dist/src/services/market-service.js +189 -22
  34. package/dist/src/services/market-service.js.map +1 -1
  35. package/dist/src/services/order-handle.test.d.ts +15 -0
  36. package/dist/src/services/order-handle.test.d.ts.map +1 -0
  37. package/dist/src/services/order-handle.test.js +333 -0
  38. package/dist/src/services/order-handle.test.js.map +1 -0
  39. package/dist/src/services/order-manager.d.ts +162 -6
  40. package/dist/src/services/order-manager.d.ts.map +1 -1
  41. package/dist/src/services/order-manager.js +419 -30
  42. package/dist/src/services/order-manager.js.map +1 -1
  43. package/dist/src/services/realtime-service-v2.d.ts +122 -6
  44. package/dist/src/services/realtime-service-v2.d.ts.map +1 -1
  45. package/dist/src/services/realtime-service-v2.js +475 -70
  46. package/dist/src/services/realtime-service-v2.js.map +1 -1
  47. package/dist/src/services/trading-service.d.ts +129 -1
  48. package/dist/src/services/trading-service.d.ts.map +1 -1
  49. package/dist/src/services/trading-service.js +198 -5
  50. package/dist/src/services/trading-service.js.map +1 -1
  51. package/package.json +1 -2
  52. package/dist/src/services/ctf-detector.d.ts +0 -215
  53. package/dist/src/services/ctf-detector.d.ts.map +0 -1
  54. package/dist/src/services/ctf-detector.js +0 -420
  55. package/dist/src/services/ctf-detector.js.map +0 -1
@@ -187,6 +187,219 @@ import { createUnifiedCache } from '../core/unified-cache.js';
187
187
  import { OrderStatus } from '../core/types.js';
188
188
  import { isTerminalStatus, isValidStatusTransition } from '../core/order-status.js';
189
189
  import { PolymarketError, ErrorCode } from '../core/errors.js';
190
+ /**
191
+ * Internal implementation of OrderHandle.
192
+ *
193
+ * Lifecycle:
194
+ * 1. Constructor receives orderManager ref and an executor function
195
+ * 2. Status starts at 'created'
196
+ * 3. Executor is invoked immediately (fire-and-forget)
197
+ * 4. On success: stores orderId, subscribes to OrderManager events
198
+ * 5. On rejection: transitions to 'rejected', resolves promise
199
+ * 6. On terminal events: resolves promise, removes listeners
200
+ */
201
+ export class OrderHandleImpl {
202
+ orderManager;
203
+ executor;
204
+ _orderId;
205
+ _status = 'created';
206
+ _fills = [];
207
+ _order = null;
208
+ _reason;
209
+ _resolve;
210
+ _promise;
211
+ _handlers = new Map();
212
+ _eventCleanup = null;
213
+ constructor(orderManager, executor) {
214
+ this.orderManager = orderManager;
215
+ this.executor = executor;
216
+ this._promise = new Promise((resolve) => {
217
+ this._resolve = resolve;
218
+ });
219
+ // Fire-and-forget execution
220
+ this.execute();
221
+ }
222
+ // ===== PromiseLike implementation =====
223
+ then(onfulfilled, onrejected) {
224
+ return this._promise.then(onfulfilled, onrejected);
225
+ }
226
+ // ===== Chainable lifecycle callbacks =====
227
+ onAccepted(handler) {
228
+ this.addHandler('accepted', handler);
229
+ return this;
230
+ }
231
+ onPartialFill(handler) {
232
+ this.addHandler('partial_fill', handler);
233
+ return this;
234
+ }
235
+ onFilled(handler) {
236
+ this.addHandler('filled', handler);
237
+ return this;
238
+ }
239
+ onRejected(handler) {
240
+ this.addHandler('rejected', handler);
241
+ return this;
242
+ }
243
+ onCancelled(handler) {
244
+ this.addHandler('cancelled', handler);
245
+ return this;
246
+ }
247
+ onExpired(handler) {
248
+ this.addHandler('expired', handler);
249
+ return this;
250
+ }
251
+ // ===== Operations =====
252
+ async cancel() {
253
+ if (!this._orderId)
254
+ return false;
255
+ if (this.isTerminal())
256
+ return false;
257
+ const result = await this.orderManager.cancelOrder(this._orderId);
258
+ return result.success;
259
+ }
260
+ // ===== State query =====
261
+ get orderId() {
262
+ return this._orderId;
263
+ }
264
+ get status() {
265
+ return this._status;
266
+ }
267
+ // ===== Private implementation =====
268
+ async execute() {
269
+ try {
270
+ const result = await this.executor();
271
+ if (!result.success || !result.orderId) {
272
+ // Order rejected by API or validation
273
+ this._status = 'rejected';
274
+ this._reason = result.errorMsg || 'Order rejected';
275
+ this.invokeHandlers('rejected', this._reason);
276
+ this._resolve({
277
+ status: 'rejected',
278
+ order: null,
279
+ fills: [],
280
+ reason: this._reason,
281
+ });
282
+ return;
283
+ }
284
+ // Store orderId and subscribe to events
285
+ this._orderId = result.orderId;
286
+ this.subscribeToEvents();
287
+ }
288
+ catch (error) {
289
+ // Unexpected error during execution
290
+ this._status = 'rejected';
291
+ this._reason = error instanceof Error ? error.message : String(error);
292
+ this.invokeHandlers('rejected', this._reason);
293
+ this._resolve({
294
+ status: 'rejected',
295
+ order: null,
296
+ fills: [],
297
+ reason: this._reason,
298
+ });
299
+ }
300
+ }
301
+ subscribeToEvents() {
302
+ const onOpened = (order) => {
303
+ if (order.id !== this._orderId)
304
+ return;
305
+ this._status = 'open';
306
+ this._order = order;
307
+ this.invokeHandlers('accepted', order);
308
+ };
309
+ const onPartialFill = (fill) => {
310
+ if (fill.orderId !== this._orderId)
311
+ return;
312
+ this._status = 'partially_filled';
313
+ this._order = fill.order;
314
+ this._fills.push(fill);
315
+ this.invokeHandlers('partial_fill', fill);
316
+ };
317
+ const onFilled = (fill) => {
318
+ if (fill.orderId !== this._orderId)
319
+ return;
320
+ this._status = 'filled';
321
+ this._order = fill.order;
322
+ this._fills.push(fill);
323
+ this.invokeHandlers('filled', fill);
324
+ this.resolveTerminal('filled');
325
+ };
326
+ const onCancelled = (event) => {
327
+ if (event.orderId !== this._orderId)
328
+ return;
329
+ this._status = 'cancelled';
330
+ this._order = event.order;
331
+ this.invokeHandlers('cancelled', event.order);
332
+ this.resolveTerminal('cancelled', 'Order cancelled');
333
+ };
334
+ const onExpired = (event) => {
335
+ if (event.orderId !== this._orderId)
336
+ return;
337
+ this._status = 'expired';
338
+ this._order = event.order;
339
+ this.invokeHandlers('expired', event.order);
340
+ this.resolveTerminal('expired', 'Order expired');
341
+ };
342
+ const onRejected = (event) => {
343
+ if (event.orderId !== this._orderId)
344
+ return;
345
+ this._status = 'rejected';
346
+ this._reason = event.reason;
347
+ this.invokeHandlers('rejected', event.reason);
348
+ this.resolveTerminal('rejected', event.reason);
349
+ };
350
+ this.orderManager.on('order_opened', onOpened);
351
+ this.orderManager.on('order_partially_filled', onPartialFill);
352
+ this.orderManager.on('order_filled', onFilled);
353
+ this.orderManager.on('order_cancelled', onCancelled);
354
+ this.orderManager.on('order_expired', onExpired);
355
+ this.orderManager.on('order_rejected', onRejected);
356
+ this._eventCleanup = () => {
357
+ this.orderManager.removeListener('order_opened', onOpened);
358
+ this.orderManager.removeListener('order_partially_filled', onPartialFill);
359
+ this.orderManager.removeListener('order_filled', onFilled);
360
+ this.orderManager.removeListener('order_cancelled', onCancelled);
361
+ this.orderManager.removeListener('order_expired', onExpired);
362
+ this.orderManager.removeListener('order_rejected', onRejected);
363
+ };
364
+ }
365
+ resolveTerminal(status, reason) {
366
+ this.cleanup();
367
+ this._resolve({
368
+ status,
369
+ order: this._order,
370
+ fills: this._fills,
371
+ reason,
372
+ });
373
+ }
374
+ cleanup() {
375
+ if (this._eventCleanup) {
376
+ this._eventCleanup();
377
+ this._eventCleanup = null;
378
+ }
379
+ }
380
+ isTerminal() {
381
+ return this._status === 'filled' || this._status === 'cancelled' || this._status === 'rejected' || this._status === 'expired';
382
+ }
383
+ addHandler(event, handler) {
384
+ if (!this._handlers.has(event)) {
385
+ this._handlers.set(event, []);
386
+ }
387
+ this._handlers.get(event).push(handler);
388
+ }
389
+ invokeHandlers(event, ...args) {
390
+ const handlers = this._handlers.get(event);
391
+ if (!handlers)
392
+ return;
393
+ for (const handler of handlers) {
394
+ try {
395
+ handler(...args);
396
+ }
397
+ catch {
398
+ // Swallow handler errors to avoid breaking the lifecycle
399
+ }
400
+ }
401
+ }
402
+ }
190
403
  // ============================================================================
191
404
  // OrderManager Implementation
192
405
  // ============================================================================
@@ -209,7 +422,10 @@ export class OrderManager extends EventEmitter {
209
422
  this.config = {
210
423
  ...config,
211
424
  chainId: config.chainId ?? 137,
212
- mode: config.mode ?? 'hybrid',
425
+ // Bug 24 fix: Default to 'websocket' mode instead of 'hybrid'
426
+ // Polling mechanism can update watched orders with incorrect tokenId,
427
+ // causing fill events to be processed with wrong token type
428
+ mode: config.mode ?? 'websocket',
213
429
  pollingInterval: config.pollingInterval ?? 5000,
214
430
  polygonRpcUrl: config.polygonRpcUrl ?? 'https://polygon-rpc.com',
215
431
  };
@@ -307,8 +523,13 @@ export class OrderManager extends EventEmitter {
307
523
  // Submit order via TradingService
308
524
  const result = await this.tradingService.createLimitOrder(params);
309
525
  if (result.success && result.orderId) {
310
- // Auto-watch the order
311
- this.watchOrder(result.orderId, metadata);
526
+ // Auto-watch the order with initial order info (Bug 24 fix)
527
+ this.watchOrder(result.orderId, metadata, {
528
+ tokenId: params.tokenId,
529
+ side: params.side,
530
+ price: params.price,
531
+ size: params.size,
532
+ });
312
533
  // Emit order_created event
313
534
  this.emit('order_created', {
314
535
  id: result.orderId,
@@ -365,10 +586,15 @@ export class OrderManager extends EventEmitter {
365
586
  // Submit market order via TradingService
366
587
  const result = await this.tradingService.createMarketOrder(params);
367
588
  if (result.success && result.orderId) {
368
- // Auto-watch the order
589
+ // Auto-watch the order with initial order info (Bug 24 fix)
369
590
  this.watchOrder(result.orderId, {
370
591
  ...metadata,
371
592
  orderType: params.orderType || 'FOK', // Track order type for lifecycle handling
593
+ }, {
594
+ tokenId: params.tokenId,
595
+ side: params.side,
596
+ price: params.price,
597
+ size: params.amount,
372
598
  });
373
599
  // Emit order_created event
374
600
  this.emit('order_created', {
@@ -400,6 +626,21 @@ export class OrderManager extends EventEmitter {
400
626
  }
401
627
  return result;
402
628
  }
629
+ /**
630
+ * Cancel all open orders for this wallet.
631
+ * Useful for cleanup on strategy shutdown.
632
+ */
633
+ async cancelAllOrders() {
634
+ this.ensureInitialized();
635
+ const result = await this.tradingService.cancelAllOrders();
636
+ if (result.success) {
637
+ // Unwatch all orders
638
+ for (const orderId of this.watchedOrders.keys()) {
639
+ this.unwatchOrder(orderId);
640
+ }
641
+ }
642
+ return result;
643
+ }
403
644
  /**
404
645
  * Get order details
405
646
  * Fetches from TradingService (CLOB API)
@@ -424,36 +665,93 @@ export class OrderManager extends EventEmitter {
424
665
  return result;
425
666
  }
426
667
  // ============================================================================
668
+ // Public API - OrderHandle (Fluent Lifecycle)
669
+ // ============================================================================
670
+ /**
671
+ * Place a limit order and return a fluent OrderHandle.
672
+ *
673
+ * The handle is PromiseLike - await it for the terminal result.
674
+ * Chain lifecycle callbacks for real-time updates.
675
+ *
676
+ * @param params Limit order parameters
677
+ * @param metadata Optional metadata for tracking
678
+ * @returns OrderHandle - chainable, awaitable order lifecycle
679
+ *
680
+ * @example
681
+ * ```typescript
682
+ * const handle = orderManager.placeOrder({
683
+ * tokenId: '0x...',
684
+ * side: 'BUY',
685
+ * price: 0.52,
686
+ * size: 100,
687
+ * });
688
+ *
689
+ * handle
690
+ * .onAccepted((order) => console.log('Live:', order.id))
691
+ * .onFilled((fill) => console.log('Done!'));
692
+ *
693
+ * const result = await handle;
694
+ * ```
695
+ */
696
+ placeOrder(params, metadata) {
697
+ return new OrderHandleImpl(this, () => this.createOrder(params, metadata));
698
+ }
699
+ /**
700
+ * Place a market order (FOK/FAK) and return a fluent OrderHandle.
701
+ *
702
+ * @param params Market order parameters
703
+ * @param metadata Optional metadata for tracking
704
+ * @returns OrderHandle - chainable, awaitable order lifecycle
705
+ *
706
+ * @example
707
+ * ```typescript
708
+ * const result = await orderManager.placeMarketOrder({
709
+ * tokenId: '0x...',
710
+ * side: 'BUY',
711
+ * amount: 50,
712
+ * orderType: 'FOK',
713
+ * });
714
+ * console.log(result.status); // 'filled' or 'cancelled'
715
+ * ```
716
+ */
717
+ placeMarketOrder(params, metadata) {
718
+ return new OrderHandleImpl(this, () => this.createMarketOrder(params, metadata));
719
+ }
720
+ // ============================================================================
427
721
  // Public API - Order Monitoring
428
722
  // ============================================================================
429
723
  /**
430
- * Manually watch an order
431
- * Used for monitoring externally-created orders
724
+ * Watch an order by ID
432
725
  *
433
- * @param orderId Order ID to watch
434
- * @param metadata Optional metadata
726
+ * @param orderId - Order ID to watch
727
+ * @param metadata - Optional metadata for strategy context
728
+ * @param initialOrderInfo - Optional initial order info (tokenId, side, price, size)
729
+ * to avoid relying on polling for accurate token type
435
730
  */
436
- watchOrder(orderId, metadata) {
731
+ watchOrder(orderId, metadata, initialOrderInfo) {
437
732
  if (this.watchedOrders.has(orderId)) {
438
733
  return; // Already watching
439
734
  }
440
735
  // Create initial watched state
736
+ // Bug 24 fix: Use initialOrderInfo if provided to ensure correct tokenId
441
737
  const watched = {
442
738
  orderId,
443
739
  order: {
444
740
  id: orderId,
445
741
  status: OrderStatus.PENDING,
446
- tokenId: '',
447
- side: 'BUY',
448
- price: 0,
449
- originalSize: 0,
742
+ tokenId: initialOrderInfo?.tokenId || '',
743
+ side: initialOrderInfo?.side || 'BUY',
744
+ price: initialOrderInfo?.price || 0,
745
+ originalSize: initialOrderInfo?.size || 0,
450
746
  filledSize: 0,
451
- remainingSize: 0,
747
+ remainingSize: initialOrderInfo?.size || 0,
452
748
  associateTrades: [],
453
749
  createdAt: Date.now(),
454
750
  },
455
751
  metadata,
456
752
  lastStatus: OrderStatus.PENDING,
753
+ // Bug 24 fix: Store initialTokenId to protect from polling overwrites
754
+ initialTokenId: initialOrderInfo?.tokenId,
457
755
  };
458
756
  this.watchedOrders.set(orderId, watched);
459
757
  // Start monitoring based on mode
@@ -551,17 +849,29 @@ export class OrderManager extends EventEmitter {
551
849
  // (We use dynamic import to avoid circular dependencies)
552
850
  const { RealtimeServiceV2 } = await import('./realtime-service-v2.js');
553
851
  this.realtimeService = new RealtimeServiceV2({ autoReconnect: true });
554
- // Connect to WebSocket
852
+ // Connect to WebSocket and wait for connection to be established
853
+ // connect() is async and returns a Promise that resolves when connected
555
854
  if (this.realtimeService) {
556
- this.realtimeService.connect();
855
+ console.log(`[OrderManager] Connecting to WebSocket...`);
856
+ await this.realtimeService.connect();
857
+ console.log(`[OrderManager] WebSocket connected successfully`);
557
858
  }
558
859
  // Subscribe to user events (requires credentials)
860
+ // Now safe to subscribe since connection is established
559
861
  const credentials = this.tradingService.getCredentials();
560
862
  if (credentials && this.realtimeService) {
561
- this.realtimeService.subscribeUserEvents(credentials, {
863
+ console.log(`[OrderManager] Subscribing to user events...`);
864
+ // Map ApiCredentials (key) to ClobApiKeyCreds (apiKey) format
865
+ const clobAuth = {
866
+ apiKey: credentials.key,
867
+ secret: credentials.secret,
868
+ passphrase: credentials.passphrase,
869
+ };
870
+ this.realtimeService.subscribeUserEvents(clobAuth, {
562
871
  onOrder: this.handleUserOrder.bind(this),
563
872
  onTrade: this.handleUserTrade.bind(this),
564
873
  });
874
+ console.log(`[OrderManager] User events subscription complete`);
565
875
  }
566
876
  }
567
877
  /**
@@ -580,17 +890,17 @@ export class OrderManager extends EventEmitter {
580
890
  // Update order state
581
891
  watched.order.price = userOrder.price;
582
892
  watched.order.originalSize = userOrder.originalSize;
583
- watched.order.filledSize = userOrder.matchedSize;
584
- watched.order.remainingSize = userOrder.originalSize - userOrder.matchedSize;
893
+ watched.order.filledSize = userOrder.sizeMatched;
894
+ watched.order.remainingSize = userOrder.originalSize - userOrder.sizeMatched;
585
895
  // Infer new status
586
896
  let newStatus = watched.lastStatus;
587
897
  if (userOrder.eventType === 'PLACEMENT') {
588
898
  newStatus = OrderStatus.OPEN;
589
899
  }
590
900
  else if (userOrder.eventType === 'UPDATE') {
591
- if (userOrder.matchedSize > 0) {
901
+ if (userOrder.sizeMatched > 0) {
592
902
  newStatus =
593
- userOrder.matchedSize >= userOrder.originalSize
903
+ userOrder.sizeMatched >= userOrder.originalSize
594
904
  ? OrderStatus.FILLED
595
905
  : OrderStatus.PARTIALLY_FILLED;
596
906
  }
@@ -606,31 +916,105 @@ export class OrderManager extends EventEmitter {
606
916
  /**
607
917
  * Handle USER_TRADE WebSocket event (Polymarket-specific)
608
918
  * Triggered when: MATCHED, MINED, CONFIRMED
919
+ *
920
+ * Order lookup priority:
921
+ * 1. takerOrderId - if we placed a market order (FOK/FAK) or taker limit order
922
+ * 2. makerOrders[].orderId - if our limit order was matched as maker
609
923
  */
610
924
  async handleUserTrade(userTrade) {
611
- const watched = this.watchedOrders.get(userTrade.market);
612
- if (!watched)
925
+ // Debug: Log all incoming USER_TRADE events
926
+ console.log(`[OrderManager] WebSocket USER_TRADE received:`, {
927
+ tradeId: userTrade.tradeId,
928
+ takerOrderId: userTrade.takerOrderId,
929
+ makerOrderIds: userTrade.makerOrders?.map(m => m.orderId),
930
+ size: userTrade.size,
931
+ price: userTrade.price,
932
+ status: userTrade.status,
933
+ watchedOrderCount: this.watchedOrders.size,
934
+ });
935
+ // Find the watched order using takerOrderId or makerOrders
936
+ let watched;
937
+ let makerInfo;
938
+ // Priority 1: Check if we're the taker
939
+ if (userTrade.takerOrderId) {
940
+ watched = this.watchedOrders.get(userTrade.takerOrderId);
941
+ }
942
+ // Priority 2: Check if we're a maker
943
+ if (!watched && userTrade.makerOrders) {
944
+ for (const maker of userTrade.makerOrders) {
945
+ watched = this.watchedOrders.get(maker.orderId);
946
+ if (watched) {
947
+ makerInfo = maker;
948
+ break;
949
+ }
950
+ }
951
+ }
952
+ if (!watched) {
953
+ console.log(`[OrderManager] USER_TRADE not for any watched order, ignoring`);
613
954
  return;
955
+ }
956
+ console.log(`[OrderManager] USER_TRADE matched watched order: ${watched.orderId}`);
957
+ // Bug 16 Fix: When we're the maker, use maker-specific matchedAmount and price
958
+ // userTrade.size is the TOTAL trade size (sum of all makers), not our individual fill
959
+ // Bug 20 Fix: Previously used wrong field name (matched_size instead of matched_amount)
960
+ // Now correctly reading matched_amount from Polymarket API
961
+ const rawMatchedAmount = makerInfo?.matchedAmount;
962
+ let fillSize = rawMatchedAmount ?? userTrade.size;
963
+ // Debug: Log raw values for verification
964
+ if (makerInfo) {
965
+ console.log(`[OrderManager] Maker fill debug:`, {
966
+ rawMatchedAmount,
967
+ makerOrdersLength: userTrade.makerOrders?.length,
968
+ willApplyFallback: fillSize === 0 || fillSize === undefined,
969
+ makerOrdersDetail: userTrade.makerOrders?.map(m => ({
970
+ orderId: m.orderId?.slice(0, 12),
971
+ matchedAmount: m.matchedAmount,
972
+ price: m.price,
973
+ })),
974
+ });
975
+ }
976
+ // Fallback: if matchedAmount is 0/undefined and we're a maker, use trade size
977
+ // This shouldn't happen now that we're reading the correct field, but kept as safety
978
+ if (makerInfo && (fillSize === 0 || fillSize === undefined)) {
979
+ console.log(`[OrderManager] Fallback applied: using userTrade.size ${userTrade.size} instead of ${fillSize}`);
980
+ fillSize = userTrade.size;
981
+ }
982
+ const fillPrice = makerInfo?.price ?? userTrade.price;
983
+ console.log(`[OrderManager] USER_TRADE fill details:`, {
984
+ isMaker: !!makerInfo,
985
+ fillSize,
986
+ fillPrice,
987
+ tradeTotalSize: userTrade.size,
988
+ });
614
989
  // Deduplicate events
615
- const eventKey = `trade_${userTrade.tradeId}_${userTrade.status}_${userTrade.timestamp}`;
990
+ // When we're maker, include our orderId in the key to handle multiple makers in same trade
991
+ const eventKey = makerInfo
992
+ ? `trade_${userTrade.tradeId}_${userTrade.status}_${watched.orderId}`
993
+ : `trade_${userTrade.tradeId}_${userTrade.status}_${userTrade.timestamp}`;
616
994
  if (this.processedEvents.has(eventKey))
617
995
  return;
618
996
  this.processedEvents.add(eventKey);
619
997
  // Emit fill event
620
- const isCompleteFill = watched.order.filledSize + userTrade.size >= watched.order.originalSize;
998
+ // Calculate remaining size after this fill
999
+ const remainingAfterFill = watched.order.originalSize - (watched.order.filledSize + fillSize);
1000
+ // Complete fill if: sum >= originalSize OR remaining is zero/negative
1001
+ // Note: For market orders (FOK/FAK), originalSize is in USDC but filledSize is in shares,
1002
+ // causing remainingAfterFill to be negative. This is still a complete fill.
1003
+ const isCompleteFill = watched.order.filledSize + fillSize >= watched.order.originalSize ||
1004
+ remainingAfterFill <= 0;
621
1005
  const fillEvent = {
622
1006
  orderId: watched.orderId,
623
1007
  order: watched.order,
624
1008
  fill: {
625
1009
  tradeId: userTrade.tradeId,
626
- size: userTrade.size,
627
- price: userTrade.price,
1010
+ size: fillSize,
1011
+ price: fillPrice,
628
1012
  fee: 0, // Fee info not in UserTrade
629
1013
  timestamp: userTrade.timestamp,
630
1014
  transactionHash: userTrade.transactionHash,
631
1015
  },
632
- cumulativeFilled: watched.order.filledSize + userTrade.size,
633
- remainingSize: watched.order.originalSize - (watched.order.filledSize + userTrade.size),
1016
+ cumulativeFilled: watched.order.filledSize + fillSize,
1017
+ remainingSize: watched.order.originalSize - (watched.order.filledSize + fillSize),
634
1018
  isCompleteFill,
635
1019
  };
636
1020
  if (isCompleteFill) {
@@ -697,7 +1081,12 @@ export class OrderManager extends EventEmitter {
697
1081
  // Only emit if this is an actual increase from a previously known state
698
1082
  if (newFilledSize > oldFilledSize && oldFilledSize >= 0) {
699
1083
  const fillDelta = newFilledSize - oldFilledSize;
700
- const isCompleteFill = freshOrder.remainingSize === 0;
1084
+ // Check for complete fill using status (preferred) or remainingSize
1085
+ // Note: For market orders (FOK/FAK), remainingSize may be negative due to unit mismatch
1086
+ // (originalSize is in USDC, filledSize is in shares), so we rely on status
1087
+ const isCompleteFill = freshOrder.status === OrderStatus.FILLED ||
1088
+ freshOrder.remainingSize === 0 ||
1089
+ freshOrder.remainingSize <= 0;
701
1090
  // Deduplicate fill events using fill size as key
702
1091
  const eventKey = `fill_${orderId}_${newFilledSize}`;
703
1092
  if (!this.processedEvents.has(eventKey)) {