@huskly/ibkr-client 0.14.0 → 0.16.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.
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { ASSET_CLASS_LABELS, toNumber } from "../helpers.js";
2
+ import { ASSET_CLASS_LABELS, toNullableNumber, toNumber } from "../helpers.js";
3
3
  import { normalizeOptionContract, parseOsiOptionSymbol } from "./optionContract.js";
4
4
  import { normalizeDerivativeContract, normalizeDerivativeDataAvailability, } from "./derivativeContract.js";
5
5
  import { IbkrRequestScheduler, } from "./requestScheduler.js";
@@ -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);
@@ -650,11 +1092,38 @@ export class IbkrClient {
650
1092
  path: `portfolio/${accountId}/summary`,
651
1093
  });
652
1094
  const amount = (key) => toNumber(summary[key]?.amount);
1095
+ const marginSnapshot = (suffix) => ({
1096
+ equityWithLoanValue: toNullableNumber(summary[`equitywithloanvalue${suffix}`]?.amount),
1097
+ regTEquity: toNullableNumber(summary[`regtequity${suffix}`]?.amount),
1098
+ regTMargin: toNullableNumber(summary[`regtmargin${suffix}`]?.amount),
1099
+ initialMarginRequirement: toNullableNumber(summary[`initmarginreq${suffix}`]?.amount),
1100
+ maintenanceMarginRequirement: toNullableNumber(summary[`maintmarginreq${suffix}`]?.amount),
1101
+ availableFunds: toNullableNumber(summary[`availablefunds${suffix}`]?.amount),
1102
+ excessLiquidity: toNullableNumber(summary[`excessliquidity${suffix}`]?.amount),
1103
+ cushion: toNullableNumber(summary[`cushion${suffix}`]?.amount),
1104
+ sma: toNullableNumber(summary[`sma${suffix}`]?.amount),
1105
+ buyingPower: toNullableNumber(summary[`buyingpower${suffix}`]?.amount),
1106
+ fullInitialMarginRequirement: toNullableNumber(summary[`fullinitmarginreq${suffix}`]?.amount),
1107
+ fullMaintenanceMarginRequirement: toNullableNumber(summary[`fullmaintmarginreq${suffix}`]?.amount),
1108
+ fullAvailableFunds: toNullableNumber(summary[`fullavailablefunds${suffix}`]?.amount),
1109
+ fullExcessLiquidity: toNullableNumber(summary[`fullexcessliquidity${suffix}`]?.amount),
1110
+ lookAheadInitialMarginRequirement: toNullableNumber(summary[`lookaheadinitmarginreq${suffix}`]?.amount),
1111
+ lookAheadMaintenanceMarginRequirement: toNullableNumber(summary[`lookaheadmaintmarginreq${suffix}`]?.amount),
1112
+ lookAheadAvailableFunds: toNullableNumber(summary[`lookaheadavailablefunds${suffix}`]?.amount),
1113
+ lookAheadExcessLiquidity: toNullableNumber(summary[`lookaheadexcessliquidity${suffix}`]?.amount),
1114
+ lookAheadNextChange: toNullableNumber(summary[`lookaheadnextchange${suffix}`]?.amount),
1115
+ leverage: toNullableNumber(summary[`leverage${suffix}`]?.amount),
1116
+ });
653
1117
  return {
654
1118
  netLiquidation: amount("netliquidation"),
655
1119
  availableFunds: amount("availablefunds"),
656
1120
  buyingPower: amount("buyingpower"),
657
1121
  cashBalance: amount("totalcashvalue"),
1122
+ margin: {
1123
+ total: marginSnapshot(""),
1124
+ securities: marginSnapshot("-s"),
1125
+ commodities: marginSnapshot("-c"),
1126
+ },
658
1127
  };
659
1128
  }
660
1129
  async getPositions(symbol) {
@@ -942,13 +1411,17 @@ export class IbkrClient {
942
1411
  };
943
1412
  }
944
1413
  liveOrderMatchesGraphNode(request, node, order) {
1414
+ const parentIdentity = this.consistentStringAliases(order.parentId, order.parent_id, order.parentClientOrderId, order.parent_order_ref);
1415
+ if (!parentIdentity.valid)
1416
+ return false;
945
1417
  if (node.parentMemberId === undefined) {
946
- if ((order.cOID ?? order.order_ref) !== request.rootClientOrderId)
1418
+ const clientIdentity = this.consistentStringAliases(order.cOID, order.order_ref);
1419
+ if (!clientIdentity.valid || clientIdentity.value !== request.rootClientOrderId)
947
1420
  return false;
948
- if (String(order.parentId ?? "").trim() !== "")
1421
+ if (parentIdentity.value !== undefined)
949
1422
  return false;
950
1423
  }
951
- else if (String(order.parentId ?? "") !== request.rootClientOrderId)
1424
+ else if (parentIdentity.value !== request.rootClientOrderId)
952
1425
  return false;
953
1426
  if ("legs" in node) {
954
1427
  const liveLegs = this.parseComboLegs(order.conidex);
@@ -958,6 +1431,7 @@ export class IbkrClient {
958
1431
  const price = this.firstNumber(order.limitPrice, order.limit_price, order.price);
959
1432
  const expectedPrice = node.priceEffect === "CREDIT" ? -node.limit : node.limit;
960
1433
  const outsideRth = order.outsideRTH ?? order.outside_rth;
1434
+ const tif = order.tif ?? order.timeInForce;
961
1435
  return (liveLegs.length === node.legs.length &&
962
1436
  node.legs.every((leg, index) => {
963
1437
  const liveLeg = liveLegs[index];
@@ -967,7 +1441,7 @@ export class IbkrClient {
967
1441
  side === "BUY" &&
968
1442
  quantity === node.quantity &&
969
1443
  price === expectedPrice &&
970
- (order.tif === undefined || order.tif.toUpperCase() === node.tif) &&
1444
+ (tif === undefined || tif.toUpperCase() === node.tif) &&
971
1445
  (outsideRth === undefined || outsideRth === (node.session === "OVERNIGHT")));
972
1446
  }
973
1447
  const orderType = this.normalizeOrderType(order.order_type ?? order.orderType);
@@ -981,14 +1455,39 @@ export class IbkrClient {
981
1455
  : undefined;
982
1456
  const expectedPrice = node.orderType === "LMT" ? node.limit : node.orderType === "STP" ? node.stopPrice : undefined;
983
1457
  const outsideRth = order.outsideRTH ?? order.outside_rth;
1458
+ const tif = order.tif ?? order.timeInForce;
984
1459
  return (order.conid === node.contract.conid &&
985
1460
  orderType === expectedOrderType &&
986
1461
  side === node.side &&
987
1462
  quantity === node.quantity &&
988
1463
  price === expectedPrice &&
989
- (order.tif === undefined || order.tif.toUpperCase() === node.tif) &&
1464
+ (tif === undefined || tif.toUpperCase() === node.tif) &&
990
1465
  (outsideRth === undefined || outsideRth === (node.session === "OVERNIGHT")));
991
1466
  }
1467
+ recoveryOrderMatchesGraphNode(request, node, order) {
1468
+ try {
1469
+ return this.liveOrderMatchesGraphNode(request, node, order);
1470
+ }
1471
+ catch {
1472
+ return false;
1473
+ }
1474
+ }
1475
+ terminalOrderMatchesGraphNode(request, node, order) {
1476
+ try {
1477
+ const tif = order.tif ?? order.timeInForce;
1478
+ const outsideRth = order.outsideRTH ?? order.outside_rth;
1479
+ const status = order.order_status ?? order.orderStatus ?? order.status;
1480
+ if (typeof tif !== "string" ||
1481
+ typeof outsideRth !== "boolean" ||
1482
+ typeof status !== "string") {
1483
+ return false;
1484
+ }
1485
+ return this.recoveryOrderMatchesGraphNode(request, node, order);
1486
+ }
1487
+ catch {
1488
+ return false;
1489
+ }
1490
+ }
992
1491
  validateSingleOrder(request) {
993
1492
  this.validateSingleOrderFields(request);
994
1493
  const identityFields = request;
@@ -1486,11 +1985,42 @@ export class IbkrClient {
1486
1985
  updatedAt: this.parseOrderTime(order)?.toISOString() ?? null,
1487
1986
  };
1488
1987
  }
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
- });
1988
+ flattenCompleteOrderSnapshot(response) {
1989
+ if (typeof response !== "object" || response === null || Array.isArray(response))
1990
+ return null;
1991
+ const record = response;
1992
+ if (record["snapshot"] !== true || !Array.isArray(record["orders"]))
1993
+ return null;
1994
+ const flattened = [];
1995
+ const visiting = new Set();
1996
+ const visit = (orders, nestedParent) => {
1997
+ for (const rawOrder of orders) {
1998
+ if (typeof rawOrder !== "object" || rawOrder === null || Array.isArray(rawOrder))
1999
+ return false;
2000
+ if (visiting.has(rawOrder))
2001
+ return false;
2002
+ visiting.add(rawOrder);
2003
+ const orderRecord = rawOrder;
2004
+ const childCollections = [orderRecord["childOrders"], orderRecord["children"]];
2005
+ if (childCollections.some((children) => children !== undefined && !Array.isArray(children))) {
2006
+ return false;
2007
+ }
2008
+ const order = rawOrder;
2009
+ flattened.push({ order, nestedParent });
2010
+ const children = new Set();
2011
+ for (const collection of childCollections) {
2012
+ if (Array.isArray(collection)) {
2013
+ for (const child of collection)
2014
+ children.add(child);
2015
+ }
2016
+ }
2017
+ if (!visit([...children], order))
2018
+ return false;
2019
+ visiting.delete(rawOrder);
2020
+ }
2021
+ return true;
2022
+ };
2023
+ return visit(record["orders"], null) ? flattened : null;
1494
2024
  }
1495
2025
  normalizeActiveDerivativeOrder(accountId, order, nestedParent) {
1496
2026
  const uncertainty = [];
@@ -1694,8 +2224,39 @@ export class IbkrClient {
1694
2224
  const parsed = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
1695
2225
  return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
1696
2226
  }
2227
+ recoveryOrderId(order) {
2228
+ const identity = this.consistentScalarAliases(order.order_id, order.orderId);
2229
+ return identity.valid ? identity.value : undefined;
2230
+ }
2231
+ consistentScalarAliases(...aliases) {
2232
+ const provided = aliases.filter((alias) => alias !== undefined);
2233
+ if (provided.length === 0)
2234
+ return { valid: true, value: undefined };
2235
+ const normalized = provided.map((alias) => {
2236
+ if (typeof alias !== "string" && typeof alias !== "number")
2237
+ return undefined;
2238
+ const value = String(alias).trim();
2239
+ return value.length === 0 ? undefined : value;
2240
+ });
2241
+ const [first] = normalized;
2242
+ if (first === undefined || normalized.some((value) => value !== first)) {
2243
+ return { valid: false, value: undefined };
2244
+ }
2245
+ return { valid: true, value: first };
2246
+ }
2247
+ consistentStringAliases(...aliases) {
2248
+ const provided = aliases.filter((alias) => alias !== undefined);
2249
+ if (provided.length === 0)
2250
+ return { valid: true, value: undefined };
2251
+ const normalized = provided.map((alias) => typeof alias === "string" && alias.trim() !== "" ? alias.trim() : undefined);
2252
+ const [first] = normalized;
2253
+ if (first === undefined || normalized.some((value) => value !== first)) {
2254
+ return { valid: false, value: undefined };
2255
+ }
2256
+ return { valid: true, value: first };
2257
+ }
1697
2258
  trimmedString(value) {
1698
- if (value === undefined)
2259
+ if (typeof value !== "string")
1699
2260
  return null;
1700
2261
  const trimmed = value.trim();
1701
2262
  return trimmed.length === 0 ? null : trimmed;
@@ -1875,8 +2436,10 @@ export class IbkrClient {
1875
2436
  return "UNKNOWN";
1876
2437
  }
1877
2438
  canonicalIbkrOrderStatus(value) {
2439
+ if (typeof value !== "string")
2440
+ return undefined;
1878
2441
  return value
1879
- ?.replace(/([a-z])([A-Z])/g, "$1_$2")
2442
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
1880
2443
  .replace(/\s+/g, "_")
1881
2444
  .toUpperCase();
1882
2445
  }