@huskly/ibkr-client 0.14.0 → 0.15.0

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.
@@ -74,6 +74,7 @@ const IBKR_WORKING_STATUSES = new Set([
74
74
  "SUBMITTED",
75
75
  "PENDING_CANCEL",
76
76
  ]);
77
+ const RECOVERY_TERMINAL_ORDER_FILTERS = ["filled", "cancelled", "inactive"];
77
78
  const KNOWN_ORDER_WARNING_IDS = new Set(["o163"]);
78
79
  /** Extract the canonical OSI symbol embedded in an IBKR option description. */
79
80
  function extractOsiPositionSymbol(contractDescription) {
@@ -100,6 +101,17 @@ function isHeadersLike(input) {
100
101
  input !== null &&
101
102
  typeof input.get === "function");
102
103
  }
104
+ function isIbkrTrade(input) {
105
+ if (typeof input !== "object" || input === null || Array.isArray(input))
106
+ return false;
107
+ const record = input;
108
+ return ((record["account"] === undefined || typeof record["account"] === "string") &&
109
+ (record["accountCode"] === undefined || typeof record["accountCode"] === "string") &&
110
+ (record["order_ref"] === undefined || typeof record["order_ref"] === "string") &&
111
+ (record["order_id"] === undefined ||
112
+ typeof record["order_id"] === "string" ||
113
+ typeof record["order_id"] === "number"));
114
+ }
103
115
  function headerToString(value) {
104
116
  if (value === undefined || value === null)
105
117
  return undefined;
@@ -372,47 +384,121 @@ export class IbkrClient {
372
384
  this.validateOrderGraph(request);
373
385
  if (input.accountId !== request.accountId)
374
386
  throw new Error("Graph recovery account does not match request");
387
+ if (input.rootClientOrderId !== undefined &&
388
+ input.rootClientOrderId !== request.rootClientOrderId) {
389
+ throw new Error("Root client order ID does not match the requested graph");
390
+ }
391
+ if (input.orderId !== undefined && !input.orderId.trim()) {
392
+ throw new Error("An exact broker order ID is required for graph recovery");
393
+ }
375
394
  await this.prepareBrokerageAccount(input.accountId);
376
395
  const response = await this.req({
377
396
  path: "iserver/account/orders",
378
397
  params: { force: true, accountId: input.accountId },
379
398
  });
380
- const accountOrders = (response.orders ?? []).filter((order) => this.orderBelongsToAccount(order, input.accountId));
381
- const byMemberId = new Map();
399
+ const flattenedActiveSnapshot = this.flattenCompleteOrderSnapshot(response);
400
+ const activeSnapshotIncomplete = flattenedActiveSnapshot === null;
401
+ const invalidActiveAccountEvidence = flattenedActiveSnapshot?.some(({ order }) => !this.orderHasExactAccount(order, input.accountId)) ?? false;
402
+ const invalidNestedActiveEvidence = flattenedActiveSnapshot?.some(({ order, nestedParent }) => nestedParent !== null &&
403
+ !this.recoveryGraphOrderMayBeAttached(request, order) &&
404
+ this.recoveryGraphOrderIsAttached(request, nestedParent)) ?? false;
405
+ const accountOrders = (flattenedActiveSnapshot ?? [])
406
+ .map(({ order }) => order)
407
+ .filter((order) => this.orderHasExactAccount(order, input.accountId));
408
+ const activeMatchesByMember = new Map();
409
+ const observedCandidates = activeSnapshotIncomplete || invalidActiveAccountEvidence || invalidNestedActiveEvidence
410
+ ? [response]
411
+ : [];
412
+ observedCandidates.push(...accountOrders.filter((order) => this.recoveryGraphOrderMayBeAttached(request, order)));
382
413
  for (const node of request.nodes) {
383
- const matches = accountOrders.filter((order) => this.liveOrderMatchesGraphNode(request, node, order));
384
- const [match] = matches;
385
- if (matches.length === 1 && match !== undefined)
386
- byMemberId.set(node.memberId, match);
414
+ const matches = accountOrders.filter((order) => this.terminalOrderTicketIsValid(order) &&
415
+ this.orderHasValidRecoveryStatus(order) &&
416
+ this.recoveryOrderMatchesGraphNode(request, node, order));
417
+ activeMatchesByMember.set(node.memberId, matches);
387
418
  }
388
- const candidates = [...byMemberId.values()];
389
- const linkedOrders = accountOrders.filter((order) => (order.cOID ?? order.order_ref) === request.rootClientOrderId ||
390
- String(order.parentId ?? "") === request.rootClientOrderId);
391
- const candidateSet = new Set(candidates);
392
- if (input.orderId !== undefined &&
393
- !candidates.some((order) => String(order.order_id ?? order.orderId) === input.orderId)) {
394
- throw new Error(`Broker order ${input.orderId} is not a member of the requested graph`);
419
+ const selected = new Map();
420
+ const usedOrderIds = new Set();
421
+ for (const node of request.nodes) {
422
+ const matches = activeMatchesByMember.get(node.memberId) ?? [];
423
+ const [match] = matches;
424
+ if (matches.length !== 1 || match === undefined)
425
+ continue;
426
+ const orderId = this.recoveryOrderId(match);
427
+ if (orderId === undefined)
428
+ continue;
429
+ if (usedOrderIds.has(orderId))
430
+ continue;
431
+ usedOrderIds.add(orderId);
432
+ selected.set(node.memberId, match);
433
+ }
434
+ const unresolved = request.nodes.filter((node) => !selected.has(node.memberId));
435
+ const terminalEvidence = await this.findRecoveryGraphTerminalCandidates(input.accountId, request, input.orderId);
436
+ this.reconcileSelectedGraphMembers(request, selected, terminalEvidence);
437
+ if (unresolved.length > 0) {
438
+ const assignments = this.assignRecoveryGraphCandidates(unresolved, terminalEvidence.byNode, usedOrderIds);
439
+ for (const node of unresolved) {
440
+ const order = assignments.get(node.memberId);
441
+ if (order === undefined)
442
+ continue;
443
+ selected.set(node.memberId, order);
444
+ const orderId = this.recoveryOrderId(order);
445
+ if (orderId !== undefined)
446
+ usedOrderIds.add(orderId);
447
+ observedCandidates.push(order);
448
+ }
395
449
  }
396
- if (input.rootClientOrderId !== undefined &&
397
- input.rootClientOrderId !== request.rootClientOrderId) {
398
- throw new Error("Root client order ID does not match the requested graph");
450
+ observedCandidates.push(...terminalEvidence.observedResponses);
451
+ if (terminalEvidence.invalidAttachedEvidence) {
452
+ observedCandidates.push({ reason: "Invalid or conflicting terminal broker evidence" });
399
453
  }
454
+ const selectedCandidates = [...selected.values()];
455
+ const requestedOrderIdMissing = input.orderId !== undefined &&
456
+ !selectedCandidates.some((order) => this.recoveryOrderId(order) === input.orderId);
400
457
  const members = request.nodes.map((node) => {
401
- const order = byMemberId.get(node.memberId);
458
+ const order = selected.get(node.memberId);
402
459
  return this.graphMemberEvidence(request, node, order);
403
460
  });
404
461
  const ids = members.flatMap(({ orderId }) => (orderId === null ? [] : [orderId]));
405
- const brokerParentsMatch = request.nodes.every((node) => {
406
- if (node.parentMemberId === undefined)
407
- return true;
408
- return (byMemberId.get(node.memberId) !== undefined &&
409
- String(byMemberId.get(node.memberId)?.parentId ?? "") === request.rootClientOrderId);
410
- });
411
- if (candidates.length !== request.nodes.length ||
412
- linkedOrders.length !== request.nodes.length ||
413
- linkedOrders.some((order) => !candidateSet.has(order)) ||
414
- new Set(ids).size !== request.nodes.length ||
415
- !brokerParentsMatch) {
462
+ const hasDistinctOrderIds = new Set(ids).size === request.nodes.length;
463
+ const linkedOrderIds = new Set();
464
+ let linkedOrderMissingBrokerId = false;
465
+ for (const order of accountOrders) {
466
+ if (!this.recoveryGraphOrderMayBeAttached(request, order))
467
+ continue;
468
+ if (!this.recoveryGraphOrderIsAttached(request, order)) {
469
+ linkedOrderMissingBrokerId = true;
470
+ continue;
471
+ }
472
+ const orderId = this.recoveryOrderId(order);
473
+ if (orderId === undefined) {
474
+ linkedOrderMissingBrokerId = true;
475
+ continue;
476
+ }
477
+ linkedOrderIds.add(orderId);
478
+ }
479
+ for (const order of terminalEvidence.linkedOrders) {
480
+ const orderId = this.recoveryOrderId(order);
481
+ if (orderId === undefined) {
482
+ linkedOrderMissingBrokerId = true;
483
+ continue;
484
+ }
485
+ linkedOrderIds.add(orderId);
486
+ }
487
+ const selectedOrderIds = new Set(selectedCandidates.flatMap((order) => {
488
+ const orderId = this.recoveryOrderId(order);
489
+ return orderId === undefined ? [] : [orderId];
490
+ }));
491
+ if (selected.size !== request.nodes.length ||
492
+ !hasDistinctOrderIds ||
493
+ linkedOrderMissingBrokerId ||
494
+ [...linkedOrderIds].some((orderId) => !selectedOrderIds.has(orderId)) ||
495
+ requestedOrderIdMissing ||
496
+ activeSnapshotIncomplete ||
497
+ invalidActiveAccountEvidence ||
498
+ invalidNestedActiveEvidence ||
499
+ terminalEvidence.invalidAttachedEvidence ||
500
+ terminalEvidence.terminalSnapshotLookupFailed ||
501
+ members.some(({ status }) => status === "UNKNOWN" || status === "WARNING_PENDING")) {
416
502
  return {
417
503
  state: "recovery_required",
418
504
  rootClientOrderId: request.rootClientOrderId,
@@ -422,7 +508,7 @@ export class IbkrClient {
422
508
  ],
423
509
  warnings: [],
424
510
  errors: [],
425
- unrecognizedResponses: [],
511
+ unrecognizedResponses: observedCandidates,
426
512
  };
427
513
  }
428
514
  return {
@@ -432,6 +518,362 @@ export class IbkrClient {
432
518
  warnings: [],
433
519
  };
434
520
  }
521
+ reconcileSelectedGraphMembers(request, selected, terminalEvidence) {
522
+ for (const node of request.nodes) {
523
+ const selectedOrder = selected.get(node.memberId);
524
+ const selectedOrderId = selectedOrder === undefined ? undefined : this.recoveryOrderId(selectedOrder);
525
+ if (selectedOrderId === undefined)
526
+ continue;
527
+ const terminalMatches = new Map();
528
+ for (const order of terminalEvidence.byNode.get(node.memberId) ?? []) {
529
+ const orderId = this.recoveryOrderId(order);
530
+ if (orderId === selectedOrderId)
531
+ terminalMatches.set(orderId, order);
532
+ }
533
+ const terminalLinked = terminalMatches.size > 0 ||
534
+ terminalEvidence.linkedOrders.some((order) => this.recoveryOrderId(order) === selectedOrderId && this.isTerminalRecoveryOrder(order));
535
+ if (!terminalLinked)
536
+ continue;
537
+ if (terminalMatches.size !== 1) {
538
+ terminalEvidence.invalidAttachedEvidence = true;
539
+ continue;
540
+ }
541
+ const [terminalOrder] = terminalMatches.values();
542
+ if (terminalOrder !== undefined)
543
+ selected.set(node.memberId, terminalOrder);
544
+ }
545
+ }
546
+ isTerminalRecoveryOrder(order) {
547
+ const status = this.canonicalIbkrOrderStatus(order.order_status ?? order.orderStatus ?? order.status);
548
+ return (status === "FILLED" ||
549
+ status === "CANCELLED" ||
550
+ status === "INACTIVE" ||
551
+ status === "REJECTED");
552
+ }
553
+ async findRecoveryGraphTerminalCandidates(accountId, request, knownOrderId) {
554
+ const byNode = new Map();
555
+ for (const node of request.nodes)
556
+ byNode.set(node.memberId, []);
557
+ const linkedOrders = [];
558
+ const observedResponses = [];
559
+ let invalidAttachedEvidence = false;
560
+ let terminalSnapshotLookupFailed = false;
561
+ const candidateOrderIds = new Set();
562
+ if (knownOrderId !== undefined)
563
+ candidateOrderIds.add(knownOrderId);
564
+ const terminalOrdersById = new Map();
565
+ for (const filter of RECOVERY_TERMINAL_ORDER_FILTERS) {
566
+ try {
567
+ const response = await this.req({
568
+ path: "iserver/account/orders",
569
+ params: { force: true, accountId, filters: filter },
570
+ });
571
+ const flattenedSnapshot = this.flattenCompleteOrderSnapshot(response);
572
+ if (flattenedSnapshot === null) {
573
+ terminalSnapshotLookupFailed = true;
574
+ observedResponses.push({ source: "terminal_order_snapshot", filter, response });
575
+ continue;
576
+ }
577
+ for (const { order, nestedParent } of flattenedSnapshot) {
578
+ if (!this.recoveryGraphOrderMayBeAttached(request, order)) {
579
+ if (nestedParent === null ||
580
+ !this.recoveryGraphOrderIsAttached(request, nestedParent)) {
581
+ continue;
582
+ }
583
+ observedResponses.push(order);
584
+ linkedOrders.push(order);
585
+ invalidAttachedEvidence = true;
586
+ continue;
587
+ }
588
+ observedResponses.push(order);
589
+ if (!this.recoveryGraphOrderIsAttached(request, order)) {
590
+ linkedOrders.push(order);
591
+ invalidAttachedEvidence = true;
592
+ continue;
593
+ }
594
+ if (!this.orderHasExactAccount(order, accountId)) {
595
+ invalidAttachedEvidence = true;
596
+ continue;
597
+ }
598
+ if (!this.terminalOrderTicketIsValid(order) || !this.orderHasValidRecoveryStatus(order)) {
599
+ linkedOrders.push(order);
600
+ invalidAttachedEvidence = true;
601
+ continue;
602
+ }
603
+ const orderId = this.recoveryOrderId(order);
604
+ if (orderId === undefined) {
605
+ linkedOrders.push(order);
606
+ invalidAttachedEvidence = true;
607
+ continue;
608
+ }
609
+ const previousOrder = terminalOrdersById.get(orderId);
610
+ if (previousOrder !== undefined &&
611
+ (this.terminalOrderTicketConflicts(previousOrder, order) ||
612
+ this.recoveryGraphAttachmentKey(request, previousOrder) !==
613
+ this.recoveryGraphAttachmentKey(request, order))) {
614
+ invalidAttachedEvidence = true;
615
+ linkedOrders.push(order);
616
+ continue;
617
+ }
618
+ terminalOrdersById.set(orderId, order);
619
+ linkedOrders.push(order);
620
+ candidateOrderIds.add(orderId);
621
+ }
622
+ }
623
+ catch (error) {
624
+ terminalSnapshotLookupFailed = true;
625
+ observedResponses.push({
626
+ source: "terminal_order_snapshot",
627
+ filter,
628
+ error: error instanceof Error ? error.message : String(error),
629
+ });
630
+ }
631
+ }
632
+ const tradeEvidenceById = new Map();
633
+ try {
634
+ const response = await this.req({
635
+ path: "iserver/account/trades",
636
+ params: { days: 7 },
637
+ });
638
+ if (Array.isArray(response)) {
639
+ for (const rawTrade of response) {
640
+ if (typeof rawTrade !== "object" || rawTrade === null || Array.isArray(rawTrade))
641
+ continue;
642
+ const tradeRecord = rawTrade;
643
+ const orderRef = this.trimmedString(tradeRecord["order_ref"]);
644
+ const parentRef = this.recoveryOrderId({
645
+ order_id: tradeRecord["parent_order_ref"],
646
+ orderId: tradeRecord["parentOrderRef"],
647
+ });
648
+ if (orderRef !== request.rootClientOrderId && parentRef !== request.rootClientOrderId) {
649
+ continue;
650
+ }
651
+ const accounts = [tradeRecord["account"], tradeRecord["accountCode"]].filter((value) => value !== undefined);
652
+ if (accounts.length > 0 &&
653
+ accounts.every((value) => typeof value === "string" && value !== accountId)) {
654
+ continue;
655
+ }
656
+ if (accounts.length === 0 ||
657
+ accounts.some((value) => typeof value !== "string" || value !== accountId) ||
658
+ !isIbkrTrade(rawTrade)) {
659
+ observedResponses.push(rawTrade);
660
+ invalidAttachedEvidence = true;
661
+ continue;
662
+ }
663
+ const orderId = this.recoveryOrderId(rawTrade);
664
+ if (orderId === undefined) {
665
+ observedResponses.push(rawTrade);
666
+ invalidAttachedEvidence = true;
667
+ continue;
668
+ }
669
+ candidateOrderIds.add(orderId);
670
+ tradeEvidenceById.set(orderId, {
671
+ ...rawTrade,
672
+ account: accountId,
673
+ order_id: orderId,
674
+ });
675
+ }
676
+ }
677
+ }
678
+ catch {
679
+ // Exact broker IDs and terminal order snapshots remain usable when trade history is unavailable.
680
+ }
681
+ for (const orderId of candidateOrderIds) {
682
+ const terminalOrder = terminalOrdersById.get(orderId);
683
+ try {
684
+ const order = await this.req({
685
+ path: `iserver/account/order/status/${encodeURIComponent(orderId)}`,
686
+ });
687
+ observedResponses.push(order);
688
+ if (!this.orderMatchesExactRecoveryIdentity(order, accountId, orderId)) {
689
+ invalidAttachedEvidence = true;
690
+ continue;
691
+ }
692
+ if (!this.orderHasValidRecoveryStatus(order)) {
693
+ invalidAttachedEvidence = true;
694
+ terminalOrdersById.delete(orderId);
695
+ continue;
696
+ }
697
+ if (!this.terminalOrderTicketIsValid(order)) {
698
+ invalidAttachedEvidence = true;
699
+ terminalOrdersById.delete(orderId);
700
+ continue;
701
+ }
702
+ if (terminalOrder !== undefined &&
703
+ this.terminalOrderTicketConflicts(terminalOrder, order)) {
704
+ invalidAttachedEvidence = true;
705
+ terminalOrdersById.delete(orderId);
706
+ continue;
707
+ }
708
+ if (terminalOrder !== undefined &&
709
+ this.recoveryGraphAttachmentKey(request, terminalOrder) !==
710
+ this.recoveryGraphAttachmentKey(request, order)) {
711
+ invalidAttachedEvidence = true;
712
+ terminalOrdersById.delete(orderId);
713
+ continue;
714
+ }
715
+ if (!this.recoveryGraphOrderIsAttached(request, order)) {
716
+ invalidAttachedEvidence = true;
717
+ terminalOrdersById.delete(orderId);
718
+ continue;
719
+ }
720
+ const resolvedOrder = terminalOrder === undefined ? order : { ...terminalOrder, ...order };
721
+ terminalOrdersById.set(orderId, resolvedOrder);
722
+ if (terminalOrder === undefined)
723
+ linkedOrders.push(resolvedOrder);
724
+ }
725
+ catch (error) {
726
+ observedResponses.push({
727
+ source: "terminal_order_status",
728
+ orderId,
729
+ error: error instanceof Error ? error.message : String(error),
730
+ });
731
+ if (terminalOrder === undefined) {
732
+ const tradeEvidence = tradeEvidenceById.get(orderId);
733
+ if (tradeEvidence !== undefined) {
734
+ linkedOrders.push(tradeEvidence);
735
+ observedResponses.push(tradeEvidence);
736
+ }
737
+ continue;
738
+ }
739
+ }
740
+ const resolvedOrder = terminalOrdersById.get(orderId) ?? terminalOrder;
741
+ if (resolvedOrder === undefined)
742
+ continue;
743
+ if (!this.orderHasExactAccount(resolvedOrder, accountId)) {
744
+ invalidAttachedEvidence = true;
745
+ continue;
746
+ }
747
+ for (const node of request.nodes) {
748
+ if (this.terminalOrderMatchesGraphNode(request, node, resolvedOrder)) {
749
+ const existing = byNode.get(node.memberId);
750
+ if (existing !== undefined)
751
+ existing.push(resolvedOrder);
752
+ }
753
+ }
754
+ }
755
+ return {
756
+ byNode,
757
+ linkedOrders,
758
+ observedResponses,
759
+ invalidAttachedEvidence,
760
+ terminalSnapshotLookupFailed,
761
+ };
762
+ }
763
+ recoveryGraphOrderIsAttached(request, order) {
764
+ return this.recoveryGraphAttachmentKey(request, order) !== null;
765
+ }
766
+ recoveryGraphOrderMayBeAttached(request, order) {
767
+ return [
768
+ order.cOID,
769
+ order.order_ref,
770
+ order.parentId,
771
+ order.parent_id,
772
+ order.parentClientOrderId,
773
+ order.parent_order_ref,
774
+ ].some((value) => (typeof value === "string" || typeof value === "number") &&
775
+ String(value).trim() === request.rootClientOrderId);
776
+ }
777
+ recoveryGraphAttachmentKey(request, order) {
778
+ const parentIdentity = this.consistentStringAliases(order.parentId, order.parent_id, order.parentClientOrderId, order.parent_order_ref);
779
+ const clientIdentity = this.consistentStringAliases(order.cOID, order.order_ref);
780
+ if (!parentIdentity.valid || !clientIdentity.valid)
781
+ return null;
782
+ const parentId = parentIdentity.value ?? "";
783
+ if (parentId === request.rootClientOrderId)
784
+ return "child";
785
+ const clientOrderId = clientIdentity.value;
786
+ if (clientOrderId === request.rootClientOrderId && parentId === "")
787
+ return "root";
788
+ return null;
789
+ }
790
+ terminalOrderTicketConflicts(first, second) {
791
+ const firstTicket = this.terminalOrderTicketFingerprint(first);
792
+ const secondTicket = this.terminalOrderTicketFingerprint(second);
793
+ return Object.keys(firstTicket).some((field) => field in secondTicket && firstTicket[field] !== secondTicket[field]);
794
+ }
795
+ terminalOrderTicketIsValid(order) {
796
+ return !Object.values(this.terminalOrderTicketFingerprint(order)).includes("__MALFORMED_TERMINAL_TICKET_FIELD__");
797
+ }
798
+ terminalOrderTicketFingerprint(order) {
799
+ const ticket = {};
800
+ const malformed = "__MALFORMED_TERMINAL_TICKET_FIELD__";
801
+ const addAliases = (field, values, normalize) => {
802
+ const provided = values.filter((value) => value !== undefined);
803
+ if (provided.length === 0)
804
+ return;
805
+ const normalized = provided.map(normalize);
806
+ const [first] = normalized;
807
+ ticket[field] =
808
+ first !== undefined && normalized.every((value) => value === first) ? first : malformed;
809
+ };
810
+ const normalizeNumber = (value) => {
811
+ if (typeof value === "number" && Number.isFinite(value)) {
812
+ return String(value);
813
+ }
814
+ if (typeof value === "string" && value.trim() !== "") {
815
+ const numeric = Number(value);
816
+ return Number.isFinite(numeric) ? String(numeric) : undefined;
817
+ }
818
+ return undefined;
819
+ };
820
+ addAliases("conid", [order.conid], normalizeNumber);
821
+ if (order.conidex !== undefined) {
822
+ ticket["conidex"] =
823
+ typeof order.conidex === "string"
824
+ ? JSON.stringify(this.parseComboLegs(order.conidex))
825
+ : malformed;
826
+ }
827
+ addAliases("orderType", [order.order_type, order.orderType], (value) => typeof value === "string" ? this.normalizeOrderType(value) : undefined);
828
+ addAliases("side", [order.side], (value) => typeof value === "string" ? this.normalizeOrderSide(value) : undefined);
829
+ addAliases("quantity", [order.total_size, order.totalSize, order.size], normalizeNumber);
830
+ addAliases("price", [order.limitPrice, order.limit_price, order.stopPrice, order.price], normalizeNumber);
831
+ addAliases("tif", [order.tif, order.timeInForce], (value) => typeof value === "string" ? value.toUpperCase() : undefined);
832
+ addAliases("outsideRTH", [order.outsideRTH, order.outside_rth], (value) => typeof value === "boolean" ? value : undefined);
833
+ return ticket;
834
+ }
835
+ orderHasExactAccount(order, accountId) {
836
+ const accounts = [order.account, order.acct];
837
+ const provided = accounts.filter((account) => account !== undefined);
838
+ return (provided.length > 0 &&
839
+ provided.every((account) => typeof account === "string" && account === accountId));
840
+ }
841
+ orderMatchesExactRecoveryIdentity(order, accountId, orderId) {
842
+ return this.orderHasExactAccount(order, accountId) && this.recoveryOrderId(order) === orderId;
843
+ }
844
+ orderHasValidRecoveryStatus(order) {
845
+ const provided = [order.order_status, order.orderStatus, order.status].filter((status) => status !== undefined);
846
+ if (provided.length === 0)
847
+ return true;
848
+ const normalized = provided.map((status) => this.canonicalIbkrOrderStatus(status));
849
+ const [first] = normalized;
850
+ return first !== undefined && normalized.every((status) => status === first);
851
+ }
852
+ assignRecoveryGraphCandidates(unresolvedNodes, terminalCandidates, usedOrderIds) {
853
+ const assignments = new Map();
854
+ const uniqueCandidates = new Map();
855
+ for (const node of unresolvedNodes) {
856
+ const candidates = terminalCandidates.get(node.memberId) ?? [];
857
+ if (candidates.length !== 1)
858
+ continue;
859
+ const order = candidates[0];
860
+ if (order === undefined)
861
+ continue;
862
+ const orderId = this.recoveryOrderId(order);
863
+ if (orderId === undefined || usedOrderIds.has(orderId))
864
+ continue;
865
+ const owners = uniqueCandidates.get(orderId) ?? [];
866
+ owners.push({ node, order });
867
+ uniqueCandidates.set(orderId, owners);
868
+ }
869
+ for (const owners of uniqueCandidates.values()) {
870
+ const owner = owners[0];
871
+ if (owners.length !== 1 || owner === undefined)
872
+ continue;
873
+ assignments.set(owner.node.memberId, owner.order);
874
+ }
875
+ return assignments;
876
+ }
435
877
  async acknowledgeOrderWarning(input) {
436
878
  if (!input.replyId.trim())
437
879
  throw new Error("An exact warning reply ID is required");
@@ -468,12 +910,12 @@ export class IbkrClient {
468
910
  const order = await this.req({
469
911
  path: `iserver/account/order/status/${encodeURIComponent(orderId)}`,
470
912
  });
471
- if (String(order.order_id ?? order.orderId ?? "") !== orderId) {
472
- throw new Error(`IBKR response does not match the requested order ${orderId}`);
473
- }
474
- if ((order.account ?? order.acct) !== accountId) {
913
+ if (!this.orderHasExactAccount(order, accountId)) {
475
914
  throw new Error(`IBKR order ${orderId} does not belong to the requested account`);
476
915
  }
916
+ if (this.recoveryOrderId(order) !== orderId) {
917
+ throw new Error(`IBKR response does not match the requested order ${orderId}`);
918
+ }
477
919
  const lifecycle = this.normalizeDerivativeOrderLifecycle(accountId, orderId, order);
478
920
  if (lifecycle.status === "UNKNOWN") {
479
921
  throw new Error(`IBKR order ${orderId} returned an unrecognized status`);
@@ -515,10 +957,10 @@ export class IbkrClient {
515
957
  path: "iserver/account/orders",
516
958
  params: { force: true, accountId },
517
959
  });
518
- if (response.snapshot !== true || !Array.isArray(response.orders)) {
960
+ const flattened = this.flattenCompleteOrderSnapshot(response);
961
+ if (flattened === null) {
519
962
  throw new Error("IBKR active-order snapshot is incomplete");
520
963
  }
521
- const flattened = this.flattenActiveOrders(response.orders);
522
964
  const invalidAccountEvidence = flattened.find(({ order }) => {
523
965
  const returnedAccounts = [order.account, order.acct];
524
966
  const providedAccounts = returnedAccounts.filter((value) => value !== undefined);
@@ -942,13 +1384,17 @@ export class IbkrClient {
942
1384
  };
943
1385
  }
944
1386
  liveOrderMatchesGraphNode(request, node, order) {
1387
+ const parentIdentity = this.consistentStringAliases(order.parentId, order.parent_id, order.parentClientOrderId, order.parent_order_ref);
1388
+ if (!parentIdentity.valid)
1389
+ return false;
945
1390
  if (node.parentMemberId === undefined) {
946
- if ((order.cOID ?? order.order_ref) !== request.rootClientOrderId)
1391
+ const clientIdentity = this.consistentStringAliases(order.cOID, order.order_ref);
1392
+ if (!clientIdentity.valid || clientIdentity.value !== request.rootClientOrderId)
947
1393
  return false;
948
- if (String(order.parentId ?? "").trim() !== "")
1394
+ if (parentIdentity.value !== undefined)
949
1395
  return false;
950
1396
  }
951
- else if (String(order.parentId ?? "") !== request.rootClientOrderId)
1397
+ else if (parentIdentity.value !== request.rootClientOrderId)
952
1398
  return false;
953
1399
  if ("legs" in node) {
954
1400
  const liveLegs = this.parseComboLegs(order.conidex);
@@ -958,6 +1404,7 @@ export class IbkrClient {
958
1404
  const price = this.firstNumber(order.limitPrice, order.limit_price, order.price);
959
1405
  const expectedPrice = node.priceEffect === "CREDIT" ? -node.limit : node.limit;
960
1406
  const outsideRth = order.outsideRTH ?? order.outside_rth;
1407
+ const tif = order.tif ?? order.timeInForce;
961
1408
  return (liveLegs.length === node.legs.length &&
962
1409
  node.legs.every((leg, index) => {
963
1410
  const liveLeg = liveLegs[index];
@@ -967,7 +1414,7 @@ export class IbkrClient {
967
1414
  side === "BUY" &&
968
1415
  quantity === node.quantity &&
969
1416
  price === expectedPrice &&
970
- (order.tif === undefined || order.tif.toUpperCase() === node.tif) &&
1417
+ (tif === undefined || tif.toUpperCase() === node.tif) &&
971
1418
  (outsideRth === undefined || outsideRth === (node.session === "OVERNIGHT")));
972
1419
  }
973
1420
  const orderType = this.normalizeOrderType(order.order_type ?? order.orderType);
@@ -981,14 +1428,39 @@ export class IbkrClient {
981
1428
  : undefined;
982
1429
  const expectedPrice = node.orderType === "LMT" ? node.limit : node.orderType === "STP" ? node.stopPrice : undefined;
983
1430
  const outsideRth = order.outsideRTH ?? order.outside_rth;
1431
+ const tif = order.tif ?? order.timeInForce;
984
1432
  return (order.conid === node.contract.conid &&
985
1433
  orderType === expectedOrderType &&
986
1434
  side === node.side &&
987
1435
  quantity === node.quantity &&
988
1436
  price === expectedPrice &&
989
- (order.tif === undefined || order.tif.toUpperCase() === node.tif) &&
1437
+ (tif === undefined || tif.toUpperCase() === node.tif) &&
990
1438
  (outsideRth === undefined || outsideRth === (node.session === "OVERNIGHT")));
991
1439
  }
1440
+ recoveryOrderMatchesGraphNode(request, node, order) {
1441
+ try {
1442
+ return this.liveOrderMatchesGraphNode(request, node, order);
1443
+ }
1444
+ catch {
1445
+ return false;
1446
+ }
1447
+ }
1448
+ terminalOrderMatchesGraphNode(request, node, order) {
1449
+ try {
1450
+ const tif = order.tif ?? order.timeInForce;
1451
+ const outsideRth = order.outsideRTH ?? order.outside_rth;
1452
+ const status = order.order_status ?? order.orderStatus ?? order.status;
1453
+ if (typeof tif !== "string" ||
1454
+ typeof outsideRth !== "boolean" ||
1455
+ typeof status !== "string") {
1456
+ return false;
1457
+ }
1458
+ return this.recoveryOrderMatchesGraphNode(request, node, order);
1459
+ }
1460
+ catch {
1461
+ return false;
1462
+ }
1463
+ }
992
1464
  validateSingleOrder(request) {
993
1465
  this.validateSingleOrderFields(request);
994
1466
  const identityFields = request;
@@ -1486,11 +1958,42 @@ export class IbkrClient {
1486
1958
  updatedAt: this.parseOrderTime(order)?.toISOString() ?? null,
1487
1959
  };
1488
1960
  }
1489
- flattenActiveOrders(orders, nestedParent = null) {
1490
- return orders.flatMap((order) => {
1491
- const children = [...new Set([...(order.childOrders ?? []), ...(order.children ?? [])])];
1492
- return [{ order, nestedParent }, ...this.flattenActiveOrders(children, order)];
1493
- });
1961
+ flattenCompleteOrderSnapshot(response) {
1962
+ if (typeof response !== "object" || response === null || Array.isArray(response))
1963
+ return null;
1964
+ const record = response;
1965
+ if (record["snapshot"] !== true || !Array.isArray(record["orders"]))
1966
+ return null;
1967
+ const flattened = [];
1968
+ const visiting = new Set();
1969
+ const visit = (orders, nestedParent) => {
1970
+ for (const rawOrder of orders) {
1971
+ if (typeof rawOrder !== "object" || rawOrder === null || Array.isArray(rawOrder))
1972
+ return false;
1973
+ if (visiting.has(rawOrder))
1974
+ return false;
1975
+ visiting.add(rawOrder);
1976
+ const orderRecord = rawOrder;
1977
+ const childCollections = [orderRecord["childOrders"], orderRecord["children"]];
1978
+ if (childCollections.some((children) => children !== undefined && !Array.isArray(children))) {
1979
+ return false;
1980
+ }
1981
+ const order = rawOrder;
1982
+ flattened.push({ order, nestedParent });
1983
+ const children = new Set();
1984
+ for (const collection of childCollections) {
1985
+ if (Array.isArray(collection)) {
1986
+ for (const child of collection)
1987
+ children.add(child);
1988
+ }
1989
+ }
1990
+ if (!visit([...children], order))
1991
+ return false;
1992
+ visiting.delete(rawOrder);
1993
+ }
1994
+ return true;
1995
+ };
1996
+ return visit(record["orders"], null) ? flattened : null;
1494
1997
  }
1495
1998
  normalizeActiveDerivativeOrder(accountId, order, nestedParent) {
1496
1999
  const uncertainty = [];
@@ -1694,8 +2197,39 @@ export class IbkrClient {
1694
2197
  const parsed = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
1695
2198
  return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
1696
2199
  }
2200
+ recoveryOrderId(order) {
2201
+ const identity = this.consistentScalarAliases(order.order_id, order.orderId);
2202
+ return identity.valid ? identity.value : undefined;
2203
+ }
2204
+ consistentScalarAliases(...aliases) {
2205
+ const provided = aliases.filter((alias) => alias !== undefined);
2206
+ if (provided.length === 0)
2207
+ return { valid: true, value: undefined };
2208
+ const normalized = provided.map((alias) => {
2209
+ if (typeof alias !== "string" && typeof alias !== "number")
2210
+ return undefined;
2211
+ const value = String(alias).trim();
2212
+ return value.length === 0 ? undefined : value;
2213
+ });
2214
+ const [first] = normalized;
2215
+ if (first === undefined || normalized.some((value) => value !== first)) {
2216
+ return { valid: false, value: undefined };
2217
+ }
2218
+ return { valid: true, value: first };
2219
+ }
2220
+ consistentStringAliases(...aliases) {
2221
+ const provided = aliases.filter((alias) => alias !== undefined);
2222
+ if (provided.length === 0)
2223
+ return { valid: true, value: undefined };
2224
+ const normalized = provided.map((alias) => typeof alias === "string" && alias.trim() !== "" ? alias.trim() : undefined);
2225
+ const [first] = normalized;
2226
+ if (first === undefined || normalized.some((value) => value !== first)) {
2227
+ return { valid: false, value: undefined };
2228
+ }
2229
+ return { valid: true, value: first };
2230
+ }
1697
2231
  trimmedString(value) {
1698
- if (value === undefined)
2232
+ if (typeof value !== "string")
1699
2233
  return null;
1700
2234
  const trimmed = value.trim();
1701
2235
  return trimmed.length === 0 ? null : trimmed;
@@ -1875,8 +2409,10 @@ export class IbkrClient {
1875
2409
  return "UNKNOWN";
1876
2410
  }
1877
2411
  canonicalIbkrOrderStatus(value) {
2412
+ if (typeof value !== "string")
2413
+ return undefined;
1878
2414
  return value
1879
- ?.replace(/([a-z])([A-Z])/g, "$1_$2")
2415
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
1880
2416
  .replace(/\s+/g, "_")
1881
2417
  .toUpperCase();
1882
2418
  }