@huskly/ibkr-client 1.6.0 → 2.0.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.
@@ -99,6 +99,11 @@ function parseRetryAfter(raw, now) {
99
99
  function isUnknownRecord(input) {
100
100
  return typeof input === "object" && input !== null && !Array.isArray(input);
101
101
  }
102
+ function isBrokerageSessionInput(input) {
103
+ return (isUnknownRecord(input) &&
104
+ typeof input["compete"] === "boolean" &&
105
+ typeof input["publish"] === "boolean");
106
+ }
102
107
  /**
103
108
  * Every distinct listing of an exact symbol that carries a section of one asset class (#671).
104
109
  *
@@ -410,6 +415,9 @@ export class IbkrInsufficientHistoryError extends Error {
410
415
  export class IbkrClient {
411
416
  raw;
412
417
  initPromise;
418
+ logoutPromise;
419
+ closed = false;
420
+ accountCriticalSectionTail = Promise.resolve();
413
421
  accountIdPromise;
414
422
  optionDiscovery = new Map();
415
423
  optionDefinitionCache;
@@ -441,125 +449,194 @@ export class IbkrClient {
441
449
  : { onTelemetry: options.onRequestTelemetry }),
442
450
  });
443
451
  }
444
- /** Obtain the live session token (idempotent — safe to await repeatedly). */
445
- init() {
446
- this.initPromise ??= (async () => {
447
- try {
448
- await this.raw.init();
449
- }
450
- catch (error) {
451
- throw this.normalizeHttpError(error);
452
- }
453
- // IBKR is slow right after init; give the session a moment to settle.
454
- await this.wait(1000);
455
- })();
452
+ /**
453
+ * Obtain the live session token with the legacy competing-session behavior.
454
+ *
455
+ * @deprecated Use {@link initializeBrokerageSession} with explicit flags.
456
+ */
457
+ async init() {
458
+ this.assertOpen();
459
+ this.initPromise ??= this.initializeBrokerageSession({ compete: true, publish: true });
456
460
  return this.initPromise;
457
461
  }
462
+ async initializeBrokerageSession(input) {
463
+ this.assertOpen();
464
+ const rawInput = input;
465
+ if (!isBrokerageSessionInput(rawInput)) {
466
+ throw new TypeError("IBKR brokerage session initialization flags must be exact booleans");
467
+ }
468
+ try {
469
+ await this.raw.init(rawInput.compete, rawInput.publish);
470
+ }
471
+ catch (error) {
472
+ throw this.normalizeHttpError(error);
473
+ }
474
+ // IBKR is slow right after initialization; give the session a moment to settle.
475
+ await this.wait(1000);
476
+ }
477
+ async renewBrokerageSession(input) {
478
+ this.assertOpen();
479
+ const rawInput = input;
480
+ if (!isBrokerageSessionInput(rawInput) || rawInput.compete) {
481
+ throw new TypeError("IBKR brokerage session renewal requires compete false and an exact publish boolean");
482
+ }
483
+ try {
484
+ await this.raw.init(false, rawInput.publish);
485
+ }
486
+ catch (error) {
487
+ throw this.normalizeHttpError(error);
488
+ }
489
+ await this.wait(1000);
490
+ }
491
+ async getSessionEvidence() {
492
+ this.assertOpen();
493
+ const [status, rawAccounts] = await Promise.all([
494
+ this.getAuthStatus(),
495
+ this.req({ path: "iserver/accounts" }),
496
+ ]);
497
+ const accounts = isUnknownRecord(rawAccounts) ? rawAccounts : {};
498
+ return {
499
+ authenticated: status.authenticated,
500
+ competing: status.competing,
501
+ connected: status.connected,
502
+ accountIds: this.accountIdsOrNull(accounts["accounts"]),
503
+ selectedAccountId: typeof accounts["selectedAccount"] === "string" ? accounts["selectedAccount"] : null,
504
+ isPaper: this.booleanOrNull(accounts["isPaper"]),
505
+ };
506
+ }
507
+ async tickle() {
508
+ this.assertOpen();
509
+ await this.req({ path: "tickle", method: "POST" });
510
+ }
511
+ async logout() {
512
+ this.assertOpen();
513
+ this.logoutPromise ??= this.singleAttemptRequest({
514
+ path: "logout",
515
+ method: "POST",
516
+ }).then(() => undefined);
517
+ return this.logoutPromise;
518
+ }
519
+ close() {
520
+ if (this.closed)
521
+ return Promise.resolve();
522
+ this.closed = true;
523
+ delete this.accountIdPromise;
524
+ this.optionDiscovery.clear();
525
+ this.optionContractResolution.clear();
526
+ this.priceHistoryContractResolution.clear();
527
+ this.derivativeDiscovery.clear();
528
+ return Promise.resolve();
529
+ }
458
530
  async getAuthStatus() {
459
- const status = await this.req({
531
+ this.assertOpen();
532
+ const rawStatus = await this.req({
460
533
  path: "iserver/auth/status",
461
534
  method: "POST",
462
535
  });
536
+ const status = isUnknownRecord(rawStatus) ? rawStatus : {};
463
537
  return {
464
- authenticated: status.authenticated ?? false,
465
- competing: status.competing ?? false,
538
+ authenticated: this.booleanOrNull(status["authenticated"]),
539
+ competing: this.booleanOrNull(status["competing"]),
540
+ connected: this.booleanOrNull(status["connected"]),
466
541
  };
467
542
  }
468
543
  async getTradingDiagnostics(accountId) {
544
+ this.assertOpen();
469
545
  if (!accountId.trim())
470
546
  throw new Error("An explicit IBKR account ID is required");
471
- const [status, accounts] = await Promise.all([
547
+ const [status, rawAccounts] = await Promise.all([
472
548
  this.getAuthStatus(),
473
549
  this.req({ path: "iserver/accounts" }),
474
550
  ]);
475
- if (!accounts.accounts?.includes(accountId)) {
551
+ const accounts = isUnknownRecord(rawAccounts) ? rawAccounts : {};
552
+ const accountIds = this.accountIdsOrNull(accounts["accounts"]);
553
+ if (!accountIds?.includes(accountId)) {
476
554
  throw new Error(`IBKR account ${accountId} is not available to this session`);
477
555
  }
556
+ const rawFeatures = accounts["allowFeatures"];
557
+ const features = isUnknownRecord(rawFeatures) ? rawFeatures : {};
558
+ const rawAssetTypes = features["allowedAssetTypes"];
478
559
  return {
479
560
  accountId,
480
- selectedAccountId: accounts.selectedAccount ?? null,
481
- environment: accounts.isPaper === true ? "paper" : "live",
561
+ selectedAccountId: typeof accounts["selectedAccount"] === "string" ? accounts["selectedAccount"] : null,
562
+ environment: accounts["isPaper"] === true ? "paper" : accounts["isPaper"] === false ? "live" : null,
482
563
  authenticated: status.authenticated,
564
+ connected: status.connected,
483
565
  competingSession: status.competing,
484
- marketDataAvailable: accounts.allowFeatures?.showGFIS ?? null,
485
- advisoryAssetPermissions: accounts.allowFeatures?.allowedAssetTypes
486
- ?.split(",")
487
- .map((value) => value.trim())
488
- .filter(Boolean) ?? [],
566
+ marketDataAvailable: this.booleanOrNull(features["showGFIS"]),
567
+ advisoryAssetPermissions: typeof rawAssetTypes === "string"
568
+ ? rawAssetTypes
569
+ .split(",")
570
+ .map((value) => value.trim())
571
+ .filter(Boolean)
572
+ : [],
489
573
  };
490
574
  }
491
575
  async previewDerivativeCombo(request) {
576
+ this.assertOpen();
492
577
  this.validateComboPreview(request);
493
- const diagnostics = await this.getTradingDiagnostics(request.accountId);
494
- if (!diagnostics.authenticated || diagnostics.competingSession) {
495
- throw new Error("IBKR brokerage session is not safely authenticated for What-If");
496
- }
497
- await this.prepareBrokerageAccount(request.accountId);
498
- const conids = request.legs.map(({ contract }) => contract.conid).join(",");
499
- await this.req({
500
- path: "iserver/marketdata/snapshot",
501
- params: { conids, fields: "6509" },
502
- });
503
- const response = await this.singleAttemptRequest({
504
- path: `iserver/account/${request.accountId}/orders/whatif`,
505
- method: "POST",
506
- data: {
507
- orders: [this.comboOrderTicket(request)],
508
- },
578
+ return this.withTradingMutation(request.accountId, "IBKR brokerage session is not safely authenticated for What-If", async (diagnostics) => {
579
+ const conids = request.legs.map(({ contract }) => contract.conid).join(",");
580
+ await this.req({
581
+ path: "iserver/marketdata/snapshot",
582
+ params: { conids, fields: "6509" },
583
+ });
584
+ const response = await this.singleAttemptRequest({
585
+ path: `iserver/account/${request.accountId}/orders/whatif`,
586
+ method: "POST",
587
+ data: { orders: [this.comboOrderTicket(request)] },
588
+ });
589
+ return this.normalizeComboPreview(request.accountId, diagnostics, response);
509
590
  });
510
- return this.normalizeComboPreview(request.accountId, diagnostics, response);
511
591
  }
512
592
  async submitDerivativeCombo(request) {
593
+ this.assertOpen();
513
594
  this.validateComboPreview(request);
514
595
  if (!request.clientOrderId.trim() || request.clientOrderId.length > 64) {
515
596
  throw new Error("Client order ID must contain 1 to 64 characters");
516
597
  }
517
598
  const cmeOperatorMetadata = this.cmeOperatorMetadata(request.legs[0].contract.assetClass, request);
518
- const diagnostics = await this.getTradingDiagnostics(request.accountId);
519
- if (!diagnostics.authenticated || diagnostics.competingSession) {
520
- throw new Error("IBKR brokerage session is not safely authenticated for submission");
521
- }
522
- await this.prepareBrokerageAccount(request.accountId);
523
- const response = await this.singleAttemptRequest({
524
- path: `iserver/account/${request.accountId}/orders`,
525
- method: "POST",
526
- data: {
527
- orders: [
528
- {
529
- ...this.comboOrderTicket(request),
530
- cOID: request.clientOrderId,
531
- ...cmeOperatorMetadata,
532
- },
533
- ],
534
- },
599
+ return this.withTradingMutation(request.accountId, "IBKR brokerage session is not safely authenticated for submission", async () => {
600
+ const response = await this.singleAttemptRequest({
601
+ path: `iserver/account/${request.accountId}/orders`,
602
+ method: "POST",
603
+ data: {
604
+ orders: [
605
+ {
606
+ ...this.comboOrderTicket(request),
607
+ cOID: request.clientOrderId,
608
+ ...cmeOperatorMetadata,
609
+ },
610
+ ],
611
+ },
612
+ });
613
+ return this.normalizeOrderSubmission(response, request.clientOrderId);
535
614
  });
536
- return this.normalizeOrderSubmission(response, request.clientOrderId);
537
615
  }
538
616
  async submitDerivativeSingleOrder(request) {
617
+ this.assertOpen();
539
618
  this.validateSingleOrder(request);
540
619
  const cmeOperatorMetadata = this.cmeOperatorMetadata(request.contract.assetClass, request);
541
- const diagnostics = await this.getTradingDiagnostics(request.accountId);
542
- if (!diagnostics.authenticated || diagnostics.competingSession) {
543
- throw new Error("IBKR brokerage session is not safely authenticated for submission");
544
- }
545
- await this.prepareBrokerageAccount(request.accountId);
546
- const response = await this.singleAttemptRequest({
547
- path: `iserver/account/${request.accountId}/orders`,
548
- method: "POST",
549
- data: {
550
- orders: [
551
- {
552
- ...this.singleOrderTicket(request),
553
- ...(request.clientOrderId !== undefined ? { cOID: request.clientOrderId } : {}),
554
- ...(request.parentId !== undefined ? { parentId: request.parentId } : {}),
555
- ...cmeOperatorMetadata,
556
- },
557
- ],
558
- },
620
+ return this.withTradingMutation(request.accountId, "IBKR brokerage session is not safely authenticated for submission", async () => {
621
+ const response = await this.singleAttemptRequest({
622
+ path: `iserver/account/${request.accountId}/orders`,
623
+ method: "POST",
624
+ data: {
625
+ orders: [
626
+ {
627
+ ...this.singleOrderTicket(request),
628
+ ...(request.clientOrderId !== undefined ? { cOID: request.clientOrderId } : {}),
629
+ ...(request.parentId !== undefined ? { parentId: request.parentId } : {}),
630
+ ...cmeOperatorMetadata,
631
+ },
632
+ ],
633
+ },
634
+ });
635
+ return this.normalizeOrderSubmission(response, request.clientOrderId ?? null);
559
636
  });
560
- return this.normalizeOrderSubmission(response, request.clientOrderId ?? null);
561
637
  }
562
638
  async submitDerivativeContingentOrders(request) {
639
+ this.assertOpen();
563
640
  const { accountId, parent, child } = request;
564
641
  if (!accountId.trim())
565
642
  throw new Error("An explicit IBKR account ID is required");
@@ -578,64 +655,58 @@ export class IbkrClient {
578
655
  }
579
656
  const parentMetadata = this.cmeOperatorMetadata(parent.contract.assetClass, parent);
580
657
  const childMetadata = this.cmeOperatorMetadata(child.contract.assetClass, child);
581
- const diagnostics = await this.getTradingDiagnostics(accountId);
582
- if (!diagnostics.authenticated || diagnostics.competingSession) {
583
- throw new Error("IBKR brokerage session is not safely authenticated for submission");
584
- }
585
- await this.prepareBrokerageAccount(accountId);
586
- const response = await this.singleAttemptRequest({
587
- path: `iserver/account/${accountId}/orders`,
588
- method: "POST",
589
- data: {
590
- orders: [
591
- {
592
- ...this.singleOrderTicket(parent),
593
- cOID: parent.clientOrderId,
594
- ...parentMetadata,
595
- },
596
- {
597
- ...this.singleOrderTicket(child),
598
- parentId: parent.clientOrderId,
599
- ...childMetadata,
600
- },
601
- ],
602
- },
658
+ return this.withTradingMutation(accountId, "IBKR brokerage session is not safely authenticated for submission", async () => {
659
+ const response = await this.singleAttemptRequest({
660
+ path: `iserver/account/${accountId}/orders`,
661
+ method: "POST",
662
+ data: {
663
+ orders: [
664
+ {
665
+ ...this.singleOrderTicket(parent),
666
+ cOID: parent.clientOrderId,
667
+ ...parentMetadata,
668
+ },
669
+ {
670
+ ...this.singleOrderTicket(child),
671
+ parentId: parent.clientOrderId,
672
+ ...childMetadata,
673
+ },
674
+ ],
675
+ },
676
+ });
677
+ return this.normalizeMultiOrderSubmission(response, parent.clientOrderId, accountId);
603
678
  });
604
- return this.normalizeMultiOrderSubmission(response, parent.clientOrderId);
605
679
  }
606
680
  async submitDerivativeOrderGraph(request) {
681
+ this.assertOpen();
607
682
  this.validateOrderGraph(request);
608
- const diagnostics = await this.getTradingDiagnostics(request.accountId);
609
- if (!diagnostics.authenticated || diagnostics.competingSession) {
610
- throw new Error("IBKR brokerage session is not safely authenticated for submission");
611
- }
612
- await this.prepareBrokerageAccount(request.accountId);
613
- const response = await this.singleAttemptRequest({
614
- path: `iserver/account/${request.accountId}/orders`,
615
- method: "POST",
616
- data: { orders: request.nodes.map((node) => this.graphOrderTicket(request, node)) },
683
+ return this.withTradingMutation(request.accountId, "IBKR brokerage session is not safely authenticated for submission", async () => {
684
+ const response = await this.singleAttemptRequest({
685
+ path: `iserver/account/${request.accountId}/orders`,
686
+ method: "POST",
687
+ data: { orders: request.nodes.map((node) => this.graphOrderTicket(request, node)) },
688
+ });
689
+ return this.normalizeOrderGraphSubmission(response, request, []);
617
690
  });
618
- return this.normalizeOrderGraphSubmission(response, request, []);
619
691
  }
620
692
  async acknowledgeDerivativeOrderGraphWarning(input) {
693
+ this.assertOpen();
621
694
  if (input.confirmed !== true)
622
695
  throw new Error("Order warning confirmation must be true");
623
696
  this.validateOrderGraph(input.continuation.request);
624
697
  if (!input.continuation.replyId.trim())
625
698
  throw new Error("An exact warning reply ID is required");
626
- const diagnostics = await this.getTradingDiagnostics(input.continuation.request.accountId);
627
- if (!diagnostics.authenticated || diagnostics.competingSession) {
628
- throw new Error("IBKR brokerage session is not safely authenticated for submission");
629
- }
630
- await this.prepareBrokerageAccount(input.continuation.request.accountId);
631
- const response = await this.singleAttemptRequest({
632
- path: `iserver/reply/${encodeURIComponent(input.continuation.replyId)}`,
633
- method: "POST",
634
- data: { confirmed: true },
699
+ return this.withTradingMutation(input.continuation.request.accountId, "IBKR brokerage session is not safely authenticated for submission", async () => {
700
+ const response = await this.singleAttemptRequest({
701
+ path: `iserver/reply/${encodeURIComponent(input.continuation.replyId)}`,
702
+ method: "POST",
703
+ data: { confirmed: true },
704
+ });
705
+ return this.normalizeOrderGraphSubmission(response, input.continuation.request, input.continuation.members);
635
706
  });
636
- return this.normalizeOrderGraphSubmission(response, input.continuation.request, input.continuation.members);
637
707
  }
638
708
  async recoverDerivativeOrderGraph(input, request) {
709
+ this.assertOpen();
639
710
  this.validateOrderGraph(request);
640
711
  if (input.accountId !== request.accountId)
641
712
  throw new Error("Graph recovery account does not match request");
@@ -646,138 +717,140 @@ export class IbkrClient {
646
717
  if (input.orderId !== undefined && !input.orderId.trim()) {
647
718
  throw new Error("An exact broker order ID is required for graph recovery");
648
719
  }
649
- await this.prepareBrokerageAccount(input.accountId);
650
- const response = await this.req({
651
- path: "iserver/account/orders",
652
- params: { accountId: input.accountId },
653
- });
654
- const flattenedActiveSnapshot = this.resolveFlattenedGraphParentAliases(request, this.flattenCompleteOrderSnapshot(response));
655
- const activeSnapshotIncomplete = flattenedActiveSnapshot === null;
656
- const invalidActiveAccountEvidence = flattenedActiveSnapshot?.some(({ order }) => !this.orderHasExactAccount(order, input.accountId)) ?? false;
657
- const invalidNestedActiveEvidence = flattenedActiveSnapshot?.some(({ order, nestedParent }) => nestedParent !== null &&
658
- !this.recoveryGraphOrderMayBeAttached(request, order) &&
659
- this.recoveryGraphOrderIsAttached(request, nestedParent)) ?? false;
660
- const accountOrders = (flattenedActiveSnapshot ?? [])
661
- .map(({ order }) => order)
662
- .filter((order) => this.orderHasExactAccount(order, input.accountId));
663
- const activeMatchesByMember = new Map();
664
- const knownActiveOrders = accountOrders.filter((order) => input.orderId !== undefined && this.recoveryOrderId(order) === input.orderId);
665
- const conflictingKnownActiveOrders = knownActiveOrders.filter((order) => !this.recoveryGraphOrderMayBeAttached(request, order));
666
- const observedCandidates = activeSnapshotIncomplete || invalidActiveAccountEvidence || invalidNestedActiveEvidence
667
- ? [response]
668
- : [...conflictingKnownActiveOrders];
669
- observedCandidates.push(...accountOrders.filter((order) => this.recoveryGraphOrderMayBeAttached(request, order)));
670
- for (const node of request.nodes) {
671
- const matches = accountOrders.filter((order) => this.terminalOrderTicketIsValid(order) &&
672
- this.orderHasValidRecoveryStatus(order) &&
673
- this.recoveryOrderMatchesGraphNode(request, node, order));
674
- activeMatchesByMember.set(node.memberId, matches);
675
- }
676
- const selected = new Map();
677
- const usedOrderIds = new Set();
678
- for (const node of request.nodes) {
679
- const matches = activeMatchesByMember.get(node.memberId) ?? [];
680
- const [match] = matches;
681
- if (matches.length !== 1 || match === undefined)
682
- continue;
683
- const orderId = this.recoveryOrderId(match);
684
- if (orderId === undefined)
685
- continue;
686
- if (usedOrderIds.has(orderId))
687
- continue;
688
- usedOrderIds.add(orderId);
689
- selected.set(node.memberId, match);
690
- }
691
- const unresolved = request.nodes.filter((node) => !selected.has(node.memberId));
692
- const terminalEvidence = await this.findRecoveryGraphTerminalCandidates(input.accountId, request, input.orderId);
693
- const conflictingKnownActiveTickets = knownActiveOrders.filter((activeOrder) => terminalEvidence.linkedOrders.some((terminalOrder) => this.recoveryOrderId(terminalOrder) === input.orderId &&
694
- this.terminalOrderTicketConflicts(activeOrder, terminalOrder)));
695
- this.reconcileSelectedGraphMembers(request, selected, terminalEvidence);
696
- if (unresolved.length > 0) {
697
- const assignments = this.assignRecoveryGraphCandidates(unresolved, terminalEvidence.byNode, usedOrderIds);
698
- for (const node of unresolved) {
699
- const order = assignments.get(node.memberId);
700
- if (order === undefined)
720
+ return this.withAccountCriticalSection(async () => {
721
+ await this.prepareBrokerageAccount(input.accountId);
722
+ const response = await this.req({
723
+ path: "iserver/account/orders",
724
+ params: { accountId: input.accountId },
725
+ });
726
+ const flattenedActiveSnapshot = this.resolveFlattenedGraphParentAliases(request, this.flattenCompleteOrderSnapshot(response));
727
+ const activeSnapshotIncomplete = flattenedActiveSnapshot === null;
728
+ const invalidActiveAccountEvidence = flattenedActiveSnapshot?.some(({ order }) => !this.orderHasExactAccount(order, input.accountId)) ?? false;
729
+ const invalidNestedActiveEvidence = flattenedActiveSnapshot?.some(({ order, nestedParent }) => nestedParent !== null &&
730
+ !this.recoveryGraphOrderMayBeAttached(request, order) &&
731
+ this.recoveryGraphOrderIsAttached(request, nestedParent)) ?? false;
732
+ const accountOrders = (flattenedActiveSnapshot ?? [])
733
+ .map(({ order }) => order)
734
+ .filter((order) => this.orderHasExactAccount(order, input.accountId));
735
+ const activeMatchesByMember = new Map();
736
+ const knownActiveOrders = accountOrders.filter((order) => input.orderId !== undefined && this.recoveryOrderId(order) === input.orderId);
737
+ const conflictingKnownActiveOrders = knownActiveOrders.filter((order) => !this.recoveryGraphOrderMayBeAttached(request, order));
738
+ const observedCandidates = activeSnapshotIncomplete || invalidActiveAccountEvidence || invalidNestedActiveEvidence
739
+ ? [response]
740
+ : [...conflictingKnownActiveOrders];
741
+ observedCandidates.push(...accountOrders.filter((order) => this.recoveryGraphOrderMayBeAttached(request, order)));
742
+ for (const node of request.nodes) {
743
+ const matches = accountOrders.filter((order) => this.terminalOrderTicketIsValid(order) &&
744
+ this.orderHasValidRecoveryStatus(order) &&
745
+ this.recoveryOrderMatchesGraphNode(request, node, order));
746
+ activeMatchesByMember.set(node.memberId, matches);
747
+ }
748
+ const selected = new Map();
749
+ const usedOrderIds = new Set();
750
+ for (const node of request.nodes) {
751
+ const matches = activeMatchesByMember.get(node.memberId) ?? [];
752
+ const [match] = matches;
753
+ if (matches.length !== 1 || match === undefined)
701
754
  continue;
702
- selected.set(node.memberId, order);
703
- const orderId = this.recoveryOrderId(order);
704
- if (orderId !== undefined)
705
- usedOrderIds.add(orderId);
706
- observedCandidates.push(order);
755
+ const orderId = this.recoveryOrderId(match);
756
+ if (orderId === undefined)
757
+ continue;
758
+ if (usedOrderIds.has(orderId))
759
+ continue;
760
+ usedOrderIds.add(orderId);
761
+ selected.set(node.memberId, match);
707
762
  }
708
- }
709
- observedCandidates.push(...terminalEvidence.observedResponses);
710
- if (terminalEvidence.invalidAttachedEvidence) {
711
- observedCandidates.push({ reason: "Invalid or conflicting terminal broker evidence" });
712
- }
713
- const selectedCandidates = [...selected.values()];
714
- const requestedOrderIdMissing = input.orderId !== undefined &&
715
- !selectedCandidates.some((order) => this.recoveryOrderId(order) === input.orderId);
716
- const members = request.nodes.map((node) => {
717
- const order = selected.get(node.memberId);
718
- return this.graphMemberEvidence(request, node, order);
719
- });
720
- const ids = members.flatMap(({ orderId }) => (orderId === null ? [] : [orderId]));
721
- const hasDistinctOrderIds = new Set(ids).size === request.nodes.length;
722
- const linkedOrderIds = new Set();
723
- let linkedOrderMissingBrokerId = false;
724
- for (const order of accountOrders) {
725
- if (!this.recoveryGraphOrderMayBeAttached(request, order))
726
- continue;
727
- if (!this.recoveryGraphOrderIsAttached(request, order)) {
728
- linkedOrderMissingBrokerId = true;
729
- continue;
763
+ const unresolved = request.nodes.filter((node) => !selected.has(node.memberId));
764
+ const terminalEvidence = await this.findRecoveryGraphTerminalCandidates(input.accountId, request, input.orderId);
765
+ const conflictingKnownActiveTickets = knownActiveOrders.filter((activeOrder) => terminalEvidence.linkedOrders.some((terminalOrder) => this.recoveryOrderId(terminalOrder) === input.orderId &&
766
+ this.terminalOrderTicketConflicts(activeOrder, terminalOrder)));
767
+ this.reconcileSelectedGraphMembers(request, selected, terminalEvidence);
768
+ if (unresolved.length > 0) {
769
+ const assignments = this.assignRecoveryGraphCandidates(unresolved, terminalEvidence.byNode, usedOrderIds);
770
+ for (const node of unresolved) {
771
+ const order = assignments.get(node.memberId);
772
+ if (order === undefined)
773
+ continue;
774
+ selected.set(node.memberId, order);
775
+ const orderId = this.recoveryOrderId(order);
776
+ if (orderId !== undefined)
777
+ usedOrderIds.add(orderId);
778
+ observedCandidates.push(order);
779
+ }
730
780
  }
731
- const orderId = this.recoveryOrderId(order);
732
- if (orderId === undefined) {
733
- linkedOrderMissingBrokerId = true;
734
- continue;
781
+ observedCandidates.push(...terminalEvidence.observedResponses);
782
+ if (terminalEvidence.invalidAttachedEvidence) {
783
+ observedCandidates.push({ reason: "Invalid or conflicting terminal broker evidence" });
735
784
  }
736
- linkedOrderIds.add(orderId);
737
- }
738
- for (const order of terminalEvidence.linkedOrders) {
739
- const orderId = this.recoveryOrderId(order);
740
- if (orderId === undefined) {
741
- linkedOrderMissingBrokerId = true;
742
- continue;
785
+ const selectedCandidates = [...selected.values()];
786
+ const requestedOrderIdMissing = input.orderId !== undefined &&
787
+ !selectedCandidates.some((order) => this.recoveryOrderId(order) === input.orderId);
788
+ const members = request.nodes.map((node) => {
789
+ const order = selected.get(node.memberId);
790
+ return this.graphMemberEvidence(request, node, order);
791
+ });
792
+ const ids = members.flatMap(({ orderId }) => (orderId === null ? [] : [orderId]));
793
+ const hasDistinctOrderIds = new Set(ids).size === request.nodes.length;
794
+ const linkedOrderIds = new Set();
795
+ let linkedOrderMissingBrokerId = false;
796
+ for (const order of accountOrders) {
797
+ if (!this.recoveryGraphOrderMayBeAttached(request, order))
798
+ continue;
799
+ if (!this.recoveryGraphOrderIsAttached(request, order)) {
800
+ linkedOrderMissingBrokerId = true;
801
+ continue;
802
+ }
803
+ const orderId = this.recoveryOrderId(order);
804
+ if (orderId === undefined) {
805
+ linkedOrderMissingBrokerId = true;
806
+ continue;
807
+ }
808
+ linkedOrderIds.add(orderId);
809
+ }
810
+ for (const order of terminalEvidence.linkedOrders) {
811
+ const orderId = this.recoveryOrderId(order);
812
+ if (orderId === undefined) {
813
+ linkedOrderMissingBrokerId = true;
814
+ continue;
815
+ }
816
+ linkedOrderIds.add(orderId);
817
+ }
818
+ const selectedOrderIds = new Set(selectedCandidates.flatMap((order) => {
819
+ const orderId = this.recoveryOrderId(order);
820
+ return orderId === undefined ? [] : [orderId];
821
+ }));
822
+ if (selected.size !== request.nodes.length ||
823
+ !hasDistinctOrderIds ||
824
+ linkedOrderMissingBrokerId ||
825
+ [...linkedOrderIds].some((orderId) => !selectedOrderIds.has(orderId)) ||
826
+ requestedOrderIdMissing ||
827
+ activeSnapshotIncomplete ||
828
+ invalidActiveAccountEvidence ||
829
+ invalidNestedActiveEvidence ||
830
+ conflictingKnownActiveOrders.length > 0 ||
831
+ conflictingKnownActiveTickets.length > 0 ||
832
+ terminalEvidence.invalidAttachedEvidence ||
833
+ terminalEvidence.terminalSnapshotLookupFailed ||
834
+ members.some(({ status }) => status === "UNKNOWN" || status === "WARNING_PENDING")) {
835
+ return {
836
+ state: "recovery_required",
837
+ rootClientOrderId: request.rootClientOrderId,
838
+ members,
839
+ reasons: [
840
+ "Exact graph recovery found incomplete, duplicated, or ambiguous member evidence",
841
+ ],
842
+ warnings: [],
843
+ errors: [],
844
+ unrecognizedResponses: observedCandidates,
845
+ };
743
846
  }
744
- linkedOrderIds.add(orderId);
745
- }
746
- const selectedOrderIds = new Set(selectedCandidates.flatMap((order) => {
747
- const orderId = this.recoveryOrderId(order);
748
- return orderId === undefined ? [] : [orderId];
749
- }));
750
- if (selected.size !== request.nodes.length ||
751
- !hasDistinctOrderIds ||
752
- linkedOrderMissingBrokerId ||
753
- [...linkedOrderIds].some((orderId) => !selectedOrderIds.has(orderId)) ||
754
- requestedOrderIdMissing ||
755
- activeSnapshotIncomplete ||
756
- invalidActiveAccountEvidence ||
757
- invalidNestedActiveEvidence ||
758
- conflictingKnownActiveOrders.length > 0 ||
759
- conflictingKnownActiveTickets.length > 0 ||
760
- terminalEvidence.invalidAttachedEvidence ||
761
- terminalEvidence.terminalSnapshotLookupFailed ||
762
- members.some(({ status }) => status === "UNKNOWN" || status === "WARNING_PENDING")) {
763
847
  return {
764
- state: "recovery_required",
848
+ state: "accepted",
765
849
  rootClientOrderId: request.rootClientOrderId,
766
- members,
767
- reasons: [
768
- "Exact graph recovery found incomplete, duplicated, or ambiguous member evidence",
769
- ],
850
+ members: this.attachGraphParentOrderIds(members),
770
851
  warnings: [],
771
- errors: [],
772
- unrecognizedResponses: observedCandidates,
773
852
  };
774
- }
775
- return {
776
- state: "accepted",
777
- rootClientOrderId: request.rootClientOrderId,
778
- members: this.attachGraphParentOrderIds(members),
779
- warnings: [],
780
- };
853
+ });
781
854
  }
782
855
  reconcileSelectedGraphMembers(request, selected, terminalEvidence) {
783
856
  for (const node of request.nodes) {
@@ -1293,38 +1366,58 @@ export class IbkrClient {
1293
1366
  return assignments;
1294
1367
  }
1295
1368
  async acknowledgeOrderWarning(input) {
1369
+ this.assertOpen();
1370
+ const confirmed = input.confirmed;
1371
+ if (confirmed !== true)
1372
+ throw new Error("Order warning confirmation must be true");
1373
+ if (!input.accountId.trim())
1374
+ throw new Error("An exact account ID is required");
1296
1375
  if (!input.replyId.trim())
1297
1376
  throw new Error("An exact warning reply ID is required");
1298
- const response = await this.singleAttemptRequest({
1299
- path: `iserver/reply/${encodeURIComponent(input.replyId)}`,
1300
- method: "POST",
1301
- data: { confirmed: true },
1377
+ return this.withTradingMutation(input.accountId, "IBKR brokerage session is not safely authenticated for warning acknowledgement", async () => {
1378
+ const response = await this.singleAttemptRequest({
1379
+ path: `iserver/reply/${encodeURIComponent(input.replyId)}`,
1380
+ method: "POST",
1381
+ data: { confirmed: true },
1382
+ });
1383
+ return this.normalizeOrderSubmission(response, null);
1302
1384
  });
1303
- return this.normalizeOrderSubmission(response, null);
1304
1385
  }
1305
1386
  async acknowledgeContingentOrderWarning(input) {
1387
+ this.assertOpen();
1306
1388
  const confirmed = input.confirmed;
1307
1389
  if (confirmed !== true) {
1308
1390
  throw new Error("Order warning confirmation must be true");
1309
1391
  }
1392
+ if (!input.continuation.accountId.trim()) {
1393
+ throw new Error("An exact account ID is required");
1394
+ }
1310
1395
  if (!input.continuation.replyId.trim()) {
1311
1396
  throw new Error("An exact warning reply ID is required");
1312
1397
  }
1313
1398
  if (!input.continuation.parentClientOrderId.trim()) {
1314
1399
  throw new Error("An exact parent client order ID is required");
1315
1400
  }
1316
- const response = await this.singleAttemptRequest({
1317
- path: `iserver/reply/${encodeURIComponent(input.continuation.replyId)}`,
1318
- method: "POST",
1319
- data: { confirmed: true },
1401
+ return this.withTradingMutation(input.continuation.accountId, "IBKR brokerage session is not safely authenticated for warning acknowledgement", async () => {
1402
+ const response = await this.singleAttemptRequest({
1403
+ path: `iserver/reply/${encodeURIComponent(input.continuation.replyId)}`,
1404
+ method: "POST",
1405
+ data: { confirmed: true },
1406
+ });
1407
+ return this.normalizeMultiOrderSubmission(response, input.continuation.parentClientOrderId, input.continuation.accountId);
1320
1408
  });
1321
- return this.normalizeMultiOrderSubmission(response, input.continuation.parentClientOrderId);
1322
1409
  }
1323
1410
  async getDerivativeOrderStatus(accountId, orderId) {
1411
+ this.assertOpen();
1324
1412
  if (!accountId.trim() || !orderId.trim()) {
1325
1413
  throw new Error("Exact account and order IDs are required");
1326
1414
  }
1327
- await this.prepareBrokerageAccount(accountId);
1415
+ return this.withAccountCriticalSection(async () => {
1416
+ await this.prepareBrokerageAccount(accountId);
1417
+ return this.getDerivativeOrderStatusPrepared(accountId, orderId);
1418
+ });
1419
+ }
1420
+ async getDerivativeOrderStatusPrepared(accountId, orderId) {
1328
1421
  const order = await this.req({
1329
1422
  path: `iserver/account/order/status/${encodeURIComponent(orderId)}`,
1330
1423
  });
@@ -1341,6 +1434,7 @@ export class IbkrClient {
1341
1434
  return lifecycle;
1342
1435
  }
1343
1436
  async findDerivativeOrder(input) {
1437
+ this.assertOpen();
1344
1438
  if (!input.accountId.trim())
1345
1439
  throw new Error("An exact account ID is required");
1346
1440
  const identity = input.orderId ?? input.clientOrderId;
@@ -1349,78 +1443,84 @@ export class IbkrClient {
1349
1443
  if (input.orderId !== undefined) {
1350
1444
  return this.getDerivativeOrderStatus(input.accountId, input.orderId);
1351
1445
  }
1352
- await this.prepareBrokerageAccount(input.accountId);
1353
- const response = await this.req({
1354
- path: "iserver/account/orders",
1355
- params: { accountId: input.accountId },
1356
- });
1357
- const order = response.orders?.find((candidate) => {
1358
- if (!this.orderBelongsToAccount(candidate, input.accountId))
1359
- return false;
1360
- return (candidate.cOID ?? candidate.order_ref) === input.clientOrderId;
1446
+ return this.withAccountCriticalSection(async () => {
1447
+ await this.prepareBrokerageAccount(input.accountId);
1448
+ const response = await this.req({
1449
+ path: "iserver/account/orders",
1450
+ params: { accountId: input.accountId },
1451
+ });
1452
+ const order = response.orders?.find((candidate) => {
1453
+ if (!this.orderBelongsToAccount(candidate, input.accountId))
1454
+ return false;
1455
+ return (candidate.cOID ?? candidate.order_ref) === input.clientOrderId;
1456
+ });
1457
+ if (order === undefined)
1458
+ throw new Error(`IBKR order ${identity} was not found`);
1459
+ const orderId = order.order_id ?? order.orderId;
1460
+ if (orderId === undefined) {
1461
+ throw new Error(`IBKR order ${identity} did not include a broker order ID`);
1462
+ }
1463
+ return this.getDerivativeOrderStatusPrepared(input.accountId, String(orderId));
1361
1464
  });
1362
- if (order === undefined)
1363
- throw new Error(`IBKR order ${identity} was not found`);
1364
- const orderId = order.order_id ?? order.orderId;
1365
- if (orderId === undefined) {
1366
- throw new Error(`IBKR order ${identity} did not include a broker order ID`);
1367
- }
1368
- return this.getDerivativeOrderStatus(input.accountId, String(orderId));
1369
1465
  }
1370
1466
  async listActiveDerivativeOrders(accountId) {
1467
+ this.assertOpen();
1371
1468
  if (!accountId.trim())
1372
1469
  throw new Error("An exact account ID is required");
1373
- await this.prepareBrokerageAccount(accountId);
1374
- const response = await this.req({
1375
- path: "iserver/account/orders",
1376
- params: { accountId },
1377
- });
1378
- const flattened = this.flattenCompleteOrderSnapshot(response);
1379
- if (flattened === null) {
1380
- throw new Error("IBKR active-order snapshot is incomplete");
1381
- }
1382
- const invalidAccountEvidence = flattened.find(({ order }) => {
1383
- const returnedAccounts = [order.account, order.acct];
1384
- const providedAccounts = returnedAccounts.filter((value) => value !== undefined);
1385
- return (providedAccounts.length === 0 ||
1386
- providedAccounts.some((returnedAccount) => typeof returnedAccount !== "string" || returnedAccount !== accountId));
1387
- });
1388
- if (invalidAccountEvidence !== undefined) {
1389
- throw new Error("IBKR active-order response did not provide unambiguous account identity");
1390
- }
1391
- const normalized = flattened.map(({ order, nestedParent }) => this.normalizeActiveDerivativeOrder(accountId, order, nestedParent));
1392
- const byOrderId = new Map();
1393
- const byClientId = new Map();
1394
- for (const order of normalized) {
1395
- if (order.orderId !== null) {
1396
- const members = byOrderId.get(order.orderId) ?? [];
1397
- members.push(order);
1398
- byOrderId.set(order.orderId, members);
1470
+ return this.withAccountCriticalSection(async () => {
1471
+ await this.prepareBrokerageAccount(accountId);
1472
+ const response = await this.req({
1473
+ path: "iserver/account/orders",
1474
+ params: { accountId },
1475
+ });
1476
+ const flattened = this.flattenCompleteOrderSnapshot(response);
1477
+ if (flattened === null) {
1478
+ throw new Error("IBKR active-order snapshot is incomplete");
1399
1479
  }
1400
- if (order.clientOrderId !== null) {
1401
- const members = byClientId.get(order.clientOrderId) ?? [];
1402
- members.push(order);
1403
- byClientId.set(order.clientOrderId, members);
1480
+ const invalidAccountEvidence = flattened.find(({ order }) => {
1481
+ const returnedAccounts = [order.account, order.acct];
1482
+ const providedAccounts = returnedAccounts.filter((value) => value !== undefined);
1483
+ return (providedAccounts.length === 0 ||
1484
+ providedAccounts.some((returnedAccount) => typeof returnedAccount !== "string" || returnedAccount !== accountId));
1485
+ });
1486
+ if (invalidAccountEvidence !== undefined) {
1487
+ throw new Error("IBKR active-order response did not provide unambiguous account identity");
1404
1488
  }
1405
- }
1406
- for (const order of normalized) {
1407
- if (order.orderId !== null && (byOrderId.get(order.orderId)?.length ?? 0) > 1) {
1408
- this.addOrderUncertainty(order, "DUPLICATE_MEMBER");
1489
+ const normalized = flattened.map(({ order, nestedParent }) => this.normalizeActiveDerivativeOrder(accountId, order, nestedParent));
1490
+ const byOrderId = new Map();
1491
+ const byClientId = new Map();
1492
+ for (const order of normalized) {
1493
+ if (order.orderId !== null) {
1494
+ const members = byOrderId.get(order.orderId) ?? [];
1495
+ members.push(order);
1496
+ byOrderId.set(order.orderId, members);
1497
+ }
1498
+ if (order.clientOrderId !== null) {
1499
+ const members = byClientId.get(order.clientOrderId) ?? [];
1500
+ members.push(order);
1501
+ byClientId.set(order.clientOrderId, members);
1502
+ }
1409
1503
  }
1410
- const parentIdentity = order.parentOrderId ?? order.parentClientOrderId;
1411
- if (parentIdentity === null)
1412
- continue;
1413
- const brokerMatches = byOrderId.get(parentIdentity) ?? [];
1414
- const clientMatches = byClientId.get(parentIdentity) ?? [];
1415
- const matches = new Set([...brokerMatches, ...clientMatches]);
1416
- if (matches.size === 0)
1417
- this.addOrderUncertainty(order, "MISSING_PARENT");
1418
- if (matches.size > 1)
1419
- this.addOrderUncertainty(order, "AMBIGUOUS_PARENT");
1420
- }
1421
- return normalized;
1504
+ for (const order of normalized) {
1505
+ if (order.orderId !== null && (byOrderId.get(order.orderId)?.length ?? 0) > 1) {
1506
+ this.addOrderUncertainty(order, "DUPLICATE_MEMBER");
1507
+ }
1508
+ const parentIdentity = order.parentOrderId ?? order.parentClientOrderId;
1509
+ if (parentIdentity === null)
1510
+ continue;
1511
+ const brokerMatches = byOrderId.get(parentIdentity) ?? [];
1512
+ const clientMatches = byClientId.get(parentIdentity) ?? [];
1513
+ const matches = new Set([...brokerMatches, ...clientMatches]);
1514
+ if (matches.size === 0)
1515
+ this.addOrderUncertainty(order, "MISSING_PARENT");
1516
+ if (matches.size > 1)
1517
+ this.addOrderUncertainty(order, "AMBIGUOUS_PARENT");
1518
+ }
1519
+ return normalized;
1520
+ });
1422
1521
  }
1423
1522
  async getDerivativeExecutions(input) {
1523
+ this.assertOpen();
1424
1524
  if (!input.accountId.trim())
1425
1525
  throw new Error("An exact account ID is required");
1426
1526
  if (input.days !== undefined &&
@@ -1433,21 +1533,24 @@ export class IbkrClient {
1433
1533
  if (input.clientOrderId !== undefined && !input.clientOrderId.trim()) {
1434
1534
  throw new Error("Client order ID cannot be empty");
1435
1535
  }
1436
- await this.prepareBrokerageAccount(input.accountId);
1437
- const response = await this.req({
1438
- path: "iserver/account/trades",
1439
- ...(input.days === undefined ? {} : { params: { days: input.days } }),
1440
- });
1441
- return response
1442
- .filter((trade) => (trade.account ?? trade.accountCode) === input.accountId)
1443
- .filter((trade) => input.orderId === undefined || String(trade.order_id) === input.orderId)
1444
- .filter((trade) => input.clientOrderId === undefined || trade.order_ref === input.clientOrderId)
1445
- .flatMap((trade) => {
1446
- const execution = this.normalizeDerivativeExecution(input.accountId, trade);
1447
- return execution === undefined ? [] : [execution];
1536
+ return this.withAccountCriticalSection(async () => {
1537
+ await this.prepareBrokerageAccount(input.accountId);
1538
+ const response = await this.req({
1539
+ path: "iserver/account/trades",
1540
+ ...(input.days === undefined ? {} : { params: { days: input.days } }),
1541
+ });
1542
+ return response
1543
+ .filter((trade) => (trade.account ?? trade.accountCode) === input.accountId)
1544
+ .filter((trade) => input.orderId === undefined || String(trade.order_id) === input.orderId)
1545
+ .filter((trade) => input.clientOrderId === undefined || trade.order_ref === input.clientOrderId)
1546
+ .flatMap((trade) => {
1547
+ const execution = this.normalizeDerivativeExecution(input.accountId, trade);
1548
+ return execution === undefined ? [] : [execution];
1549
+ });
1448
1550
  });
1449
1551
  }
1450
1552
  async reconcileDerivativeComboExecution(request) {
1553
+ this.assertOpen();
1451
1554
  this.validateReconciliationRequest(request);
1452
1555
  const lifecycle = await this.getDerivativeOrderStatus(request.accountId, request.orderId);
1453
1556
  const deadline = this.now() + (request.timeoutMs ?? 30_000);
@@ -1474,24 +1577,22 @@ export class IbkrClient {
1474
1577
  }
1475
1578
  }
1476
1579
  async cancelDerivativeOrder(input) {
1580
+ this.assertOpen();
1477
1581
  if (!input.accountId.trim() || !input.orderId.trim()) {
1478
1582
  throw new Error("Exact account and order IDs are required");
1479
1583
  }
1480
1584
  const cmeOperatorMetadata = this.cmeOperatorMetadata(input.assetClass, input);
1481
- await this.prepareBrokerageAccount(input.accountId);
1482
- const response = await this.singleAttemptRequest({
1483
- path: `iserver/account/${input.accountId}/order/${encodeURIComponent(input.orderId)}`,
1484
- method: "DELETE",
1485
- ...(Object.keys(cmeOperatorMetadata).length > 0 ? { params: cmeOperatorMetadata } : {}),
1585
+ return this.withTradingMutation(input.accountId, "IBKR brokerage session is not safely authenticated for cancellation", async () => {
1586
+ const response = await this.singleAttemptRequest({
1587
+ path: `iserver/account/${input.accountId}/order/${encodeURIComponent(input.orderId)}`,
1588
+ method: "DELETE",
1589
+ ...(Object.keys(cmeOperatorMetadata).length > 0 ? { params: cmeOperatorMetadata } : {}),
1590
+ });
1591
+ return this.normalizeOrderCancellation(input, response);
1486
1592
  });
1487
- return {
1488
- state: "requested",
1489
- accountId: input.accountId,
1490
- orderId: input.orderId,
1491
- message: this.trimmedString(response.msg),
1492
- };
1493
1593
  }
1494
1594
  async getAccountId() {
1595
+ this.assertOpen();
1495
1596
  this.accountIdPromise ??= (async () => {
1496
1597
  const override = process.env["IBKR_ACCOUNT_ID"];
1497
1598
  if (override)
@@ -1505,6 +1606,7 @@ export class IbkrClient {
1505
1606
  return this.accountIdPromise;
1506
1607
  }
1507
1608
  async getAccountBalances() {
1609
+ this.assertOpen();
1508
1610
  const accountId = await this.getAccountId();
1509
1611
  const summary = await this.req({
1510
1612
  path: `portfolio/${accountId}/summary`,
@@ -1545,6 +1647,7 @@ export class IbkrClient {
1545
1647
  };
1546
1648
  }
1547
1649
  async getPositions(symbol) {
1650
+ this.assertOpen();
1548
1651
  const accountId = await this.getAccountId();
1549
1652
  const rows = await this.fetchAllPositions(accountId);
1550
1653
  for (const position of rows) {
@@ -1622,6 +1725,7 @@ export class IbkrClient {
1622
1725
  };
1623
1726
  }
1624
1727
  async getQuotes(requests, options = {}) {
1728
+ this.assertOpen();
1625
1729
  const unique = new Map();
1626
1730
  for (const request of requests) {
1627
1731
  if (!request.symbol.trim())
@@ -1680,6 +1784,7 @@ export class IbkrClient {
1680
1784
  }
1681
1785
  /** Resolve equity/ETF symbols to IBKR contracts via `trsrv/stocks`. */
1682
1786
  async searchInstruments(symbol, projection = "symbol-search") {
1787
+ this.assertOpen();
1683
1788
  if (projection !== "symbol-search" && projection !== "search") {
1684
1789
  throw new Error(`IBKR search currently supports only symbol-search/search projections (got '${projection}').`);
1685
1790
  }
@@ -1693,6 +1798,7 @@ export class IbkrClient {
1693
1798
  return (response[query] ?? []).flatMap((listing) => this.normalizeStockListing(query, listing));
1694
1799
  }
1695
1800
  async fetchTransactionHistory(startDate, endDate) {
1801
+ this.assertOpen();
1696
1802
  const accountId = await this.getAccountId();
1697
1803
  const rows = await this.fetchAllPositions(accountId);
1698
1804
  const positionsByConid = new Map(rows
@@ -1722,25 +1828,28 @@ export class IbkrClient {
1722
1828
  return [{ accountNumber: accountId, transactions: [...transactionsByKey.values()] }];
1723
1829
  }
1724
1830
  async fetchOrders(options) {
1831
+ this.assertOpen();
1725
1832
  const accountId = await this.getAccountId();
1726
- await this.prepareBrokerageAccount(accountId);
1727
- const params = {};
1728
- if (options.status && options.status.toUpperCase() !== "WORKING") {
1729
- params["filters"] = this.ibkrStatusFilter(options.status);
1730
- }
1731
- const response = await this.req({
1732
- path: "iserver/account/orders",
1733
- params,
1833
+ return this.withAccountCriticalSection(async () => {
1834
+ await this.prepareBrokerageAccount(accountId);
1835
+ const params = {};
1836
+ if (options.status && options.status.toUpperCase() !== "WORKING") {
1837
+ params["filters"] = this.ibkrStatusFilter(options.status);
1838
+ }
1839
+ const response = await this.req({
1840
+ path: "iserver/account/orders",
1841
+ params,
1842
+ });
1843
+ let orders = (response.orders ?? [])
1844
+ .filter((order) => this.orderBelongsToAccount(order, accountId))
1845
+ .map((order) => this.normalizeOrder(order))
1846
+ .filter((order) => this.orderMatchesStatus(order, options.status))
1847
+ .filter((order) => this.orderInDateRange(order, options.fromEnteredTime, options.toEnteredTime))
1848
+ .sort((left, right) => this.orderTimeMs(right) - this.orderTimeMs(left));
1849
+ if (options.maxResults !== undefined)
1850
+ orders = orders.slice(0, options.maxResults);
1851
+ return [{ accountNumber: accountId, orders }];
1734
1852
  });
1735
- let orders = (response.orders ?? [])
1736
- .filter((order) => this.orderBelongsToAccount(order, accountId))
1737
- .map((order) => this.normalizeOrder(order))
1738
- .filter((order) => this.orderMatchesStatus(order, options.status))
1739
- .filter((order) => this.orderInDateRange(order, options.fromEnteredTime, options.toEnteredTime))
1740
- .sort((left, right) => this.orderTimeMs(right) - this.orderTimeMs(left));
1741
- if (options.maxResults !== undefined)
1742
- orders = orders.slice(0, options.maxResults);
1743
- return [{ accountNumber: accountId, orders }];
1744
1853
  }
1745
1854
  validateComboPreview(request) {
1746
1855
  if (!request.accountId.trim())
@@ -2287,7 +2396,172 @@ export class IbkrClient {
2287
2396
  ],
2288
2397
  };
2289
2398
  }
2290
- normalizeMultiOrderSubmission(response, parentClientOrderId) {
2399
+ normalizeOrderCancellation(input, response) {
2400
+ const record = isUnknownRecord(response) ? response : null;
2401
+ const message = this.trimmedString(record?.["msg"]);
2402
+ const accountId = this.trimmedString(record?.["account"]);
2403
+ const orderId = this.cancellationOrderId(record?.["order_id"]);
2404
+ const errorParts = record === null ? [] : this.cancellationErrorParts(record);
2405
+ const evidence = {
2406
+ message,
2407
+ accountId,
2408
+ orderId,
2409
+ error: errorParts.length > 0 ? errorParts.join("; ").slice(0, 4_096) : null,
2410
+ response: this.sanitizeJsonEvidence(response),
2411
+ };
2412
+ const accountProvided = record !== null && "account" in record;
2413
+ const orderProvided = record !== null && "order_id" in record;
2414
+ const conidProvided = record !== null && "conid" in record;
2415
+ const conid = record?.["conid"];
2416
+ const unknownFields = record === null
2417
+ ? []
2418
+ : Object.keys(record).filter((key) => key !== "msg" && key !== "account" && key !== "order_id" && key !== "conid");
2419
+ let reason = null;
2420
+ if (record === null)
2421
+ reason = "IBKR returned a malformed cancellation response";
2422
+ else if (errorParts.length > 0)
2423
+ reason = "IBKR returned cancellation error evidence";
2424
+ else if (unknownFields.length > 0)
2425
+ reason = "IBKR returned undocumented cancellation fields";
2426
+ else if (message !== "Request was submitted")
2427
+ reason = "IBKR did not confirm the cancellation request";
2428
+ else if (accountProvided && accountId === null)
2429
+ reason = "IBKR returned malformed cancellation account evidence";
2430
+ else if (orderProvided && orderId === null)
2431
+ reason = "IBKR returned malformed cancellation order evidence";
2432
+ else if (conidProvided &&
2433
+ (typeof conid !== "number" || !Number.isSafeInteger(conid) || conid <= 0))
2434
+ reason = "IBKR returned malformed cancellation conid evidence";
2435
+ else if (accountId !== null && accountId !== input.accountId)
2436
+ reason = "IBKR cancellation account evidence conflicts with the request";
2437
+ else if (orderId !== null && orderId !== input.orderId)
2438
+ reason = "IBKR cancellation order evidence conflicts with the request";
2439
+ if (reason !== null || message === null) {
2440
+ return {
2441
+ state: "recovery_required",
2442
+ accountId: input.accountId,
2443
+ orderId: input.orderId,
2444
+ reason: reason ?? "IBKR did not confirm the cancellation request",
2445
+ evidence,
2446
+ };
2447
+ }
2448
+ return {
2449
+ state: "requested",
2450
+ accountId: input.accountId,
2451
+ orderId: input.orderId,
2452
+ message,
2453
+ };
2454
+ }
2455
+ sanitizeJsonEvidence(value) {
2456
+ const seen = new WeakMap();
2457
+ let remainingEntries = 500;
2458
+ const dictionary = () => Object.create(null);
2459
+ const markerKey = (source, result, label) => {
2460
+ let key = label;
2461
+ while (Object.prototype.hasOwnProperty.call(source, key) ||
2462
+ Object.prototype.hasOwnProperty.call(result, key)) {
2463
+ key += "#";
2464
+ }
2465
+ return key;
2466
+ };
2467
+ const sanitize = (item, depth, path) => {
2468
+ if (item === null || typeof item === "boolean" || typeof item === "string")
2469
+ return item;
2470
+ if (typeof item === "number") {
2471
+ return Number.isFinite(item) ? item : `[non-json number: ${String(item)}]`;
2472
+ }
2473
+ if (typeof item !== "object")
2474
+ return `[non-json ${typeof item}]`;
2475
+ const priorPath = seen.get(item);
2476
+ if (priorPath !== undefined)
2477
+ return `[reference: ${priorPath}]`;
2478
+ seen.set(item, path);
2479
+ if (depth >= 8) {
2480
+ const result = dictionary();
2481
+ result[markerKey(item, result, "[truncated: depth]")] = true;
2482
+ return result;
2483
+ }
2484
+ if (Array.isArray(item)) {
2485
+ const result = [];
2486
+ for (const [index, member] of item.entries()) {
2487
+ if (remainingEntries <= 0) {
2488
+ result.push("[truncated: entry count]");
2489
+ break;
2490
+ }
2491
+ remainingEntries -= 1;
2492
+ result.push(sanitize(member, depth + 1, `${path}[${String(index)}]`));
2493
+ }
2494
+ return result;
2495
+ }
2496
+ const result = dictionary();
2497
+ for (const ownKey of Reflect.ownKeys(item)) {
2498
+ if (remainingEntries <= 0) {
2499
+ result[markerKey(item, result, "[truncated: entry count]")] = true;
2500
+ break;
2501
+ }
2502
+ remainingEntries -= 1;
2503
+ const key = typeof ownKey === "string"
2504
+ ? ownKey
2505
+ : markerKey(item, result, `[non-json symbol key: ${ownKey.description ?? ""}]`);
2506
+ let member;
2507
+ try {
2508
+ member = Reflect.get(item, ownKey);
2509
+ }
2510
+ catch {
2511
+ member = "[unreadable property]";
2512
+ }
2513
+ result[key] = sanitize(member, depth + 1, `${path}.${JSON.stringify(key)}`);
2514
+ }
2515
+ return result;
2516
+ };
2517
+ const sanitized = sanitize(value, 0, "$");
2518
+ const encoded = JSON.stringify(sanitized);
2519
+ if (encoded.length <= 8_192)
2520
+ return sanitized;
2521
+ const fallback = dictionary();
2522
+ fallback["[truncated: evidence size]"] = true;
2523
+ fallback["originalSerializedLength"] = encoded.length;
2524
+ let low = 0;
2525
+ let high = encoded.length;
2526
+ while (low < high) {
2527
+ const middle = Math.ceil((low + high) / 2);
2528
+ fallback["preview"] = encoded.slice(0, middle);
2529
+ if (JSON.stringify(fallback).length <= 8_192)
2530
+ low = middle;
2531
+ else
2532
+ high = middle - 1;
2533
+ }
2534
+ fallback["preview"] = encoded.slice(0, low);
2535
+ return fallback;
2536
+ }
2537
+ cancellationOrderId(value) {
2538
+ if (typeof value === "string")
2539
+ return this.trimmedString(value);
2540
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
2541
+ ? String(value)
2542
+ : null;
2543
+ }
2544
+ cancellationErrorParts(record) {
2545
+ const parts = [];
2546
+ for (const key of ["error", "message", "text"]) {
2547
+ if (!(key in record))
2548
+ continue;
2549
+ const text = this.trimmedString(record[key]);
2550
+ parts.push(text === null ? `${key}: present` : `${key}: ${text}`);
2551
+ }
2552
+ for (const key of ["statusCode", "code"]) {
2553
+ if (!(key in record))
2554
+ continue;
2555
+ const value = record[key];
2556
+ parts.push(typeof value === "string" || typeof value === "number"
2557
+ ? `${key}: ${String(value)}`
2558
+ : `${key}: present`);
2559
+ }
2560
+ if (record["success"] === false)
2561
+ parts.push("success: false");
2562
+ return parts;
2563
+ }
2564
+ normalizeMultiOrderSubmission(response, parentClientOrderId, accountId) {
2291
2565
  const decoded = this.decodeOrderSubmission(response);
2292
2566
  const hasDistinctBrokerOrderIds = decoded.orders.length === 2 &&
2293
2567
  new Set(decoded.orders.map(({ orderId }) => orderId)).size === 2;
@@ -2314,7 +2588,7 @@ export class IbkrClient {
2314
2588
  return {
2315
2589
  state: "warning",
2316
2590
  warnings: decoded.warnings,
2317
- continuation: { replyId: warning.replyId, parentClientOrderId },
2591
+ continuation: { accountId, replyId: warning.replyId, parentClientOrderId },
2318
2592
  };
2319
2593
  }
2320
2594
  if (hasErrors &&
@@ -3147,15 +3421,57 @@ export class IbkrClient {
3147
3421
  const parsed = Number(match[0].replace(/,/g, ""));
3148
3422
  return Number.isFinite(parsed) ? parsed : null;
3149
3423
  }
3424
+ withAccountCriticalSection(operation) {
3425
+ this.assertOpen();
3426
+ const result = this.accountCriticalSectionTail.then(async () => {
3427
+ this.assertOpen();
3428
+ return operation();
3429
+ });
3430
+ this.accountCriticalSectionTail = result.then(() => undefined, () => undefined);
3431
+ return result;
3432
+ }
3433
+ withTradingMutation(accountId, unsafeMessage, operation) {
3434
+ return this.withAccountCriticalSection(async () => {
3435
+ const diagnostics = await this.getTradingDiagnostics(accountId);
3436
+ if (!this.isSafeForTradingMutation(diagnostics))
3437
+ throw new Error(unsafeMessage);
3438
+ await this.prepareBrokerageAccount(accountId);
3439
+ return operation(diagnostics);
3440
+ });
3441
+ }
3442
+ isSafeForTradingMutation(diagnostics) {
3443
+ return (diagnostics.authenticated === true &&
3444
+ diagnostics.connected === true &&
3445
+ diagnostics.competingSession === false &&
3446
+ diagnostics.environment !== null);
3447
+ }
3448
+ assertOpen() {
3449
+ if (this.closed)
3450
+ throw this.closedError();
3451
+ }
3452
+ closedError() {
3453
+ return new Error("This IBKR client is closed");
3454
+ }
3455
+ booleanOrNull(value) {
3456
+ return typeof value === "boolean" ? value : null;
3457
+ }
3458
+ accountIdsOrNull(value) {
3459
+ return Array.isArray(value) &&
3460
+ Array.from(value).every((accountId) => typeof accountId === "string")
3461
+ ? value
3462
+ : null;
3463
+ }
3150
3464
  async prepareBrokerageAccount(accountId) {
3151
- const brokerageAccounts = await this.req({
3465
+ const rawBrokerageAccounts = await this.req({
3152
3466
  path: "iserver/accounts",
3153
3467
  });
3154
- if (brokerageAccounts.selectedAccount === accountId)
3155
- return;
3156
- if (brokerageAccounts.accounts && !brokerageAccounts.accounts.includes(accountId)) {
3468
+ const brokerageAccounts = isUnknownRecord(rawBrokerageAccounts) ? rawBrokerageAccounts : {};
3469
+ const accountIds = this.accountIdsOrNull(brokerageAccounts["accounts"]);
3470
+ if (!accountIds?.includes(accountId)) {
3157
3471
  throw new Error(`IBKR account ${accountId} is not available for trading/order queries.`);
3158
3472
  }
3473
+ if (brokerageAccounts["selectedAccount"] === accountId)
3474
+ return;
3159
3475
  const switchedAccount = await this.singleAttemptRequest({
3160
3476
  path: "iserver/account",
3161
3477
  method: "POST",
@@ -3294,6 +3610,7 @@ export class IbkrClient {
3294
3610
  }
3295
3611
  /** Return complete daily history with the exact validated IBKR request context. */
3296
3612
  async getPriceHistory(input) {
3613
+ this.assertOpen();
3297
3614
  const requestedSymbol = input.symbol.trim().toUpperCase();
3298
3615
  if (!requestedSymbol) {
3299
3616
  throw new IbkrPriceHistoryContractError("Price history requires a symbol", "CONTRACT_INVALID");
@@ -3444,6 +3761,7 @@ export class IbkrClient {
3444
3761
  }
3445
3762
  /** Discover listed derivative series over an inclusive calendar range. */
3446
3763
  async getDerivativeExpiries(query) {
3764
+ this.assertOpen();
3447
3765
  const contracts = [];
3448
3766
  for (const month of monthCodes(query.from, query.to)) {
3449
3767
  contracts.push(...(await this.discoverDerivativeMonth(query.underlying, query.assetClass, month, query.exchange, query.right)));
@@ -3477,6 +3795,7 @@ export class IbkrClient {
3477
3795
  }
3478
3796
  /** Discover contracts for one exact expiration, preserving class and venue identity. */
3479
3797
  async getDerivativeContracts(query) {
3798
+ this.assertOpen();
3480
3799
  const tradingClass = query.tradingClass?.trim().toUpperCase();
3481
3800
  return (await this.discoverDerivativeMonth(query.underlying, query.assetClass, monthCode(query.expiration), query.exchange, query.right, query.strike)).filter((contract) => contract.expiration === query.expiration &&
3482
3801
  (query.right === undefined || contract.right === query.right) &&
@@ -3485,6 +3804,7 @@ export class IbkrClient {
3485
3804
  }
3486
3805
  /** Resolve exactly one contract and reject missing or ambiguous semantic identity. */
3487
3806
  async resolveDerivativeContract(query) {
3807
+ this.assertOpen();
3488
3808
  const contracts = await this.getDerivativeContracts(query);
3489
3809
  if (!contracts.length) {
3490
3810
  throw new Error(`IBKR returned no exact ${query.assetClass} contract for ${query.underlying} ${query.expiration} ${query.right}${String(query.strike)}`);
@@ -3500,6 +3820,7 @@ export class IbkrClient {
3500
3820
  }
3501
3821
  /** Return an exact-expiration derivative chain with explicit data availability. */
3502
3822
  async getDerivativeChain(query) {
3823
+ this.assertOpen();
3503
3824
  const contracts = await this.getDerivativeContracts(query);
3504
3825
  if (!contracts.length) {
3505
3826
  throw new Error(`IBKR returned no ${query.assetClass} contracts for ${query.underlying} ${query.expiration}`);
@@ -3512,6 +3833,7 @@ export class IbkrClient {
3512
3833
  }
3513
3834
  /** Quote the broker-linked underlying (for example, the Sep NQ future behind QN3). */
3514
3835
  async getDerivativeReferenceQuote(contract) {
3836
+ this.assertOpen();
3515
3837
  const detailResponse = await this.req({
3516
3838
  path: "trsrv/secdef",
3517
3839
  params: { conids: String(contract.conid) },
@@ -3558,6 +3880,7 @@ export class IbkrClient {
3558
3880
  }
3559
3881
  /** Discover every listed weekly/monthly expiry in the requested calendar range. */
3560
3882
  async getOptionExpiries(symbol, right, fromDate, toDate, options = {}) {
3883
+ this.assertOpen();
3561
3884
  const normalized = symbol.trim().toUpperCase();
3562
3885
  const months = monthCodes(fromDate, toDate);
3563
3886
  const contracts = [];
@@ -3573,6 +3896,7 @@ export class IbkrClient {
3573
3896
  }
3574
3897
  /** Build one exact-expiry chain with canonical OSI symbols and required pricing/greeks. */
3575
3898
  async getOptionChain(symbol, expiry, right, options = {}) {
3899
+ this.assertOpen();
3576
3900
  const month = monthCode(expiry);
3577
3901
  const normalized = symbol.trim().toUpperCase();
3578
3902
  const discovery = await this.discoverOptions(normalized, month, right, options);
@@ -3592,6 +3916,7 @@ export class IbkrClient {
3592
3916
  }
3593
3917
  /** Return every qualified contract for one exact expiry and side without hiding sparse data. */
3594
3918
  async getOptionChainSnapshot(symbol, expiry, right, options = {}) {
3919
+ this.assertOpen();
3595
3920
  const month = monthCode(expiry);
3596
3921
  const normalized = symbol.trim().toUpperCase();
3597
3922
  const discovery = await this.discoverOptions(normalized, month, right, options);
@@ -3603,6 +3928,7 @@ export class IbkrClient {
3603
3928
  }
3604
3929
  /** Fetch one exact option quote; null means the contract is not listed. */
3605
3930
  async getOptionQuote(input) {
3931
+ this.assertOpen();
3606
3932
  const contract = await this.resolveOptionContract(input);
3607
3933
  if (!contract)
3608
3934
  return null;
@@ -3610,6 +3936,7 @@ export class IbkrClient {
3610
3936
  }
3611
3937
  /** Resolve a conid back into the canonical OSI-bearing option contract. */
3612
3938
  async getOptionContract(conid) {
3939
+ this.assertOpen();
3613
3940
  const response = await this.req({
3614
3941
  path: "trsrv/secdef",
3615
3942
  params: { conids: String(conid) },
@@ -4984,6 +5311,8 @@ export class IbkrClient {
4984
5311
  return this.scheduledRequest(input, "SINGLE_ATTEMPT");
4985
5312
  }
4986
5313
  scheduledRequest(input, retryPolicy, signal, onTerminalFailure) {
5314
+ if (this.closed)
5315
+ return Promise.reject(this.closedError());
4987
5316
  return this.requestScheduler.schedule({
4988
5317
  endpoint: this.requestEndpoint(input.path),
4989
5318
  priority: this.requestPriority(input.path),