@huskly/ibkr-client 1.6.0 → 2.1.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`,
@@ -1544,7 +1646,106 @@ export class IbkrClient {
1544
1646
  },
1545
1647
  };
1546
1648
  }
1649
+ /**
1650
+ * Read one settled-cash observation of the account from the same account
1651
+ * summary endpoint {@link getAccountBalances} uses.
1652
+ *
1653
+ * The observation names the account, states the currency IBKR gave for each
1654
+ * figure, and carries one client-minted timestamp. A figure that is absent,
1655
+ * null, non-finite, or not convertible reads `null`, and a missing currency
1656
+ * reads `null`. A currency is never inferred and never defaulted to `"USD"`.
1657
+ * One missing field never makes this method throw.
1658
+ *
1659
+ * Settled cash is reported as evidence only, from the live `settledcashbydate`
1660
+ * string field. This package refuses nothing and infers nothing: the CONSUMER,
1661
+ * not this package, decides which dates count as settled, and a date after the
1662
+ * observation date is not settled cash.
1663
+ */
1664
+ async getAccountSettlementEvidence() {
1665
+ this.assertOpen();
1666
+ const accountId = await this.getAccountId();
1667
+ const summary = await this.req({
1668
+ path: `portfolio/${accountId}/summary`,
1669
+ });
1670
+ return {
1671
+ accountId,
1672
+ observedAtEpochMillis: this.now(),
1673
+ settledCashByDate: this.settledCashByDate(summary),
1674
+ settledCashByDateRaw: this.settledCashByDateRaw(summary),
1675
+ availableFunds: this.settlementFigure(summary, "availablefunds"),
1676
+ totalCashValue: this.settlementFigure(summary, "totalcashvalue"),
1677
+ accruedCash: this.settlementFigure(summary, "accruedcash"),
1678
+ excessLiquidity: this.settlementFigure(summary, "excessliquidity"),
1679
+ buyingPower: this.settlementFigure(summary, "buyingpower"),
1680
+ netLiquidation: this.settlementFigure(summary, "netliquidation"),
1681
+ accountType: this.summaryFieldValue(summary, "accounttype"),
1682
+ tradingType: this.summaryFieldValue(summary, "tradingtype-s"),
1683
+ presentSummaryFieldNames: this.presentSummaryFieldNames(summary),
1684
+ };
1685
+ }
1686
+ /**
1687
+ * The exact `settledcashbydate` `value` string, unparsed. `null` when the key
1688
+ * is absent, is not an object, or carries no usable string.
1689
+ */
1690
+ settledCashByDateRaw(summary) {
1691
+ return this.summaryFieldValue(summary, "settledcashbydate");
1692
+ }
1693
+ /**
1694
+ * Parse the `settledcashbydate` `value` string into one entry for each
1695
+ * `YYYYMMDD:amount` pair, in the order the broker wrote them. Several pairs
1696
+ * may share one string, separated by `;` or `,`. A comma is always a pair
1697
+ * separator here, never a thousands separator. A pair that does not carry
1698
+ * an eight-digit date and a usable amount is skipped, never guessed; the raw
1699
+ * string still reports it. This is evidence only: which dates count as
1700
+ * settled is the consumer's decision, and a date after the observation date
1701
+ * is not settled cash.
1702
+ */
1703
+ settledCashByDate(summary) {
1704
+ const raw = this.settledCashByDateRaw(summary);
1705
+ if (raw === null)
1706
+ return [];
1707
+ const out = [];
1708
+ for (const pair of raw.split(/[;,]/)) {
1709
+ const separator = pair.indexOf(":");
1710
+ if (separator < 0)
1711
+ continue;
1712
+ const settlementDate = pair.slice(0, separator).trim();
1713
+ if (!/^\d{8}$/.test(settlementDate))
1714
+ continue;
1715
+ const amount = toNullableNumber(pair.slice(separator + 1).trim());
1716
+ if (amount === null)
1717
+ continue;
1718
+ out.push({ settlementDate, amount });
1719
+ }
1720
+ return out;
1721
+ }
1722
+ /** The `value` string of one summary key, or `null` when it is unusable. */
1723
+ summaryFieldValue(summary, key) {
1724
+ const field = summary[key];
1725
+ if (field === null || typeof field !== "object")
1726
+ return null;
1727
+ return this.trimmedString(field.value);
1728
+ }
1729
+ /** Narrow one summary key into an amount and the currency IBKR stated for it. */
1730
+ settlementFigure(summary, key) {
1731
+ const field = summary[key];
1732
+ if (field === null || typeof field !== "object")
1733
+ return { amount: null, currency: null };
1734
+ const record = field;
1735
+ return {
1736
+ amount: toNullableNumber(record.amount),
1737
+ currency: this.trimmedString(record.currency),
1738
+ };
1739
+ }
1740
+ /** The sorted key names present in the summary response. Names only, never values. */
1741
+ presentSummaryFieldNames(summary) {
1742
+ const record = summary;
1743
+ if (record === null || typeof record !== "object")
1744
+ return [];
1745
+ return Object.keys(record).sort();
1746
+ }
1547
1747
  async getPositions(symbol) {
1748
+ this.assertOpen();
1548
1749
  const accountId = await this.getAccountId();
1549
1750
  const rows = await this.fetchAllPositions(accountId);
1550
1751
  for (const position of rows) {
@@ -1622,6 +1823,7 @@ export class IbkrClient {
1622
1823
  };
1623
1824
  }
1624
1825
  async getQuotes(requests, options = {}) {
1826
+ this.assertOpen();
1625
1827
  const unique = new Map();
1626
1828
  for (const request of requests) {
1627
1829
  if (!request.symbol.trim())
@@ -1680,6 +1882,7 @@ export class IbkrClient {
1680
1882
  }
1681
1883
  /** Resolve equity/ETF symbols to IBKR contracts via `trsrv/stocks`. */
1682
1884
  async searchInstruments(symbol, projection = "symbol-search") {
1885
+ this.assertOpen();
1683
1886
  if (projection !== "symbol-search" && projection !== "search") {
1684
1887
  throw new Error(`IBKR search currently supports only symbol-search/search projections (got '${projection}').`);
1685
1888
  }
@@ -1693,6 +1896,7 @@ export class IbkrClient {
1693
1896
  return (response[query] ?? []).flatMap((listing) => this.normalizeStockListing(query, listing));
1694
1897
  }
1695
1898
  async fetchTransactionHistory(startDate, endDate) {
1899
+ this.assertOpen();
1696
1900
  const accountId = await this.getAccountId();
1697
1901
  const rows = await this.fetchAllPositions(accountId);
1698
1902
  const positionsByConid = new Map(rows
@@ -1722,25 +1926,28 @@ export class IbkrClient {
1722
1926
  return [{ accountNumber: accountId, transactions: [...transactionsByKey.values()] }];
1723
1927
  }
1724
1928
  async fetchOrders(options) {
1929
+ this.assertOpen();
1725
1930
  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,
1931
+ return this.withAccountCriticalSection(async () => {
1932
+ await this.prepareBrokerageAccount(accountId);
1933
+ const params = {};
1934
+ if (options.status && options.status.toUpperCase() !== "WORKING") {
1935
+ params["filters"] = this.ibkrStatusFilter(options.status);
1936
+ }
1937
+ const response = await this.req({
1938
+ path: "iserver/account/orders",
1939
+ params,
1940
+ });
1941
+ let orders = (response.orders ?? [])
1942
+ .filter((order) => this.orderBelongsToAccount(order, accountId))
1943
+ .map((order) => this.normalizeOrder(order))
1944
+ .filter((order) => this.orderMatchesStatus(order, options.status))
1945
+ .filter((order) => this.orderInDateRange(order, options.fromEnteredTime, options.toEnteredTime))
1946
+ .sort((left, right) => this.orderTimeMs(right) - this.orderTimeMs(left));
1947
+ if (options.maxResults !== undefined)
1948
+ orders = orders.slice(0, options.maxResults);
1949
+ return [{ accountNumber: accountId, orders }];
1734
1950
  });
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
1951
  }
1745
1952
  validateComboPreview(request) {
1746
1953
  if (!request.accountId.trim())
@@ -2287,7 +2494,172 @@ export class IbkrClient {
2287
2494
  ],
2288
2495
  };
2289
2496
  }
2290
- normalizeMultiOrderSubmission(response, parentClientOrderId) {
2497
+ normalizeOrderCancellation(input, response) {
2498
+ const record = isUnknownRecord(response) ? response : null;
2499
+ const message = this.trimmedString(record?.["msg"]);
2500
+ const accountId = this.trimmedString(record?.["account"]);
2501
+ const orderId = this.cancellationOrderId(record?.["order_id"]);
2502
+ const errorParts = record === null ? [] : this.cancellationErrorParts(record);
2503
+ const evidence = {
2504
+ message,
2505
+ accountId,
2506
+ orderId,
2507
+ error: errorParts.length > 0 ? errorParts.join("; ").slice(0, 4_096) : null,
2508
+ response: this.sanitizeJsonEvidence(response),
2509
+ };
2510
+ const accountProvided = record !== null && "account" in record;
2511
+ const orderProvided = record !== null && "order_id" in record;
2512
+ const conidProvided = record !== null && "conid" in record;
2513
+ const conid = record?.["conid"];
2514
+ const unknownFields = record === null
2515
+ ? []
2516
+ : Object.keys(record).filter((key) => key !== "msg" && key !== "account" && key !== "order_id" && key !== "conid");
2517
+ let reason = null;
2518
+ if (record === null)
2519
+ reason = "IBKR returned a malformed cancellation response";
2520
+ else if (errorParts.length > 0)
2521
+ reason = "IBKR returned cancellation error evidence";
2522
+ else if (unknownFields.length > 0)
2523
+ reason = "IBKR returned undocumented cancellation fields";
2524
+ else if (message !== "Request was submitted")
2525
+ reason = "IBKR did not confirm the cancellation request";
2526
+ else if (accountProvided && accountId === null)
2527
+ reason = "IBKR returned malformed cancellation account evidence";
2528
+ else if (orderProvided && orderId === null)
2529
+ reason = "IBKR returned malformed cancellation order evidence";
2530
+ else if (conidProvided &&
2531
+ (typeof conid !== "number" || !Number.isSafeInteger(conid) || conid <= 0))
2532
+ reason = "IBKR returned malformed cancellation conid evidence";
2533
+ else if (accountId !== null && accountId !== input.accountId)
2534
+ reason = "IBKR cancellation account evidence conflicts with the request";
2535
+ else if (orderId !== null && orderId !== input.orderId)
2536
+ reason = "IBKR cancellation order evidence conflicts with the request";
2537
+ if (reason !== null || message === null) {
2538
+ return {
2539
+ state: "recovery_required",
2540
+ accountId: input.accountId,
2541
+ orderId: input.orderId,
2542
+ reason: reason ?? "IBKR did not confirm the cancellation request",
2543
+ evidence,
2544
+ };
2545
+ }
2546
+ return {
2547
+ state: "requested",
2548
+ accountId: input.accountId,
2549
+ orderId: input.orderId,
2550
+ message,
2551
+ };
2552
+ }
2553
+ sanitizeJsonEvidence(value) {
2554
+ const seen = new WeakMap();
2555
+ let remainingEntries = 500;
2556
+ const dictionary = () => Object.create(null);
2557
+ const markerKey = (source, result, label) => {
2558
+ let key = label;
2559
+ while (Object.prototype.hasOwnProperty.call(source, key) ||
2560
+ Object.prototype.hasOwnProperty.call(result, key)) {
2561
+ key += "#";
2562
+ }
2563
+ return key;
2564
+ };
2565
+ const sanitize = (item, depth, path) => {
2566
+ if (item === null || typeof item === "boolean" || typeof item === "string")
2567
+ return item;
2568
+ if (typeof item === "number") {
2569
+ return Number.isFinite(item) ? item : `[non-json number: ${String(item)}]`;
2570
+ }
2571
+ if (typeof item !== "object")
2572
+ return `[non-json ${typeof item}]`;
2573
+ const priorPath = seen.get(item);
2574
+ if (priorPath !== undefined)
2575
+ return `[reference: ${priorPath}]`;
2576
+ seen.set(item, path);
2577
+ if (depth >= 8) {
2578
+ const result = dictionary();
2579
+ result[markerKey(item, result, "[truncated: depth]")] = true;
2580
+ return result;
2581
+ }
2582
+ if (Array.isArray(item)) {
2583
+ const result = [];
2584
+ for (const [index, member] of item.entries()) {
2585
+ if (remainingEntries <= 0) {
2586
+ result.push("[truncated: entry count]");
2587
+ break;
2588
+ }
2589
+ remainingEntries -= 1;
2590
+ result.push(sanitize(member, depth + 1, `${path}[${String(index)}]`));
2591
+ }
2592
+ return result;
2593
+ }
2594
+ const result = dictionary();
2595
+ for (const ownKey of Reflect.ownKeys(item)) {
2596
+ if (remainingEntries <= 0) {
2597
+ result[markerKey(item, result, "[truncated: entry count]")] = true;
2598
+ break;
2599
+ }
2600
+ remainingEntries -= 1;
2601
+ const key = typeof ownKey === "string"
2602
+ ? ownKey
2603
+ : markerKey(item, result, `[non-json symbol key: ${ownKey.description ?? ""}]`);
2604
+ let member;
2605
+ try {
2606
+ member = Reflect.get(item, ownKey);
2607
+ }
2608
+ catch {
2609
+ member = "[unreadable property]";
2610
+ }
2611
+ result[key] = sanitize(member, depth + 1, `${path}.${JSON.stringify(key)}`);
2612
+ }
2613
+ return result;
2614
+ };
2615
+ const sanitized = sanitize(value, 0, "$");
2616
+ const encoded = JSON.stringify(sanitized);
2617
+ if (encoded.length <= 8_192)
2618
+ return sanitized;
2619
+ const fallback = dictionary();
2620
+ fallback["[truncated: evidence size]"] = true;
2621
+ fallback["originalSerializedLength"] = encoded.length;
2622
+ let low = 0;
2623
+ let high = encoded.length;
2624
+ while (low < high) {
2625
+ const middle = Math.ceil((low + high) / 2);
2626
+ fallback["preview"] = encoded.slice(0, middle);
2627
+ if (JSON.stringify(fallback).length <= 8_192)
2628
+ low = middle;
2629
+ else
2630
+ high = middle - 1;
2631
+ }
2632
+ fallback["preview"] = encoded.slice(0, low);
2633
+ return fallback;
2634
+ }
2635
+ cancellationOrderId(value) {
2636
+ if (typeof value === "string")
2637
+ return this.trimmedString(value);
2638
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
2639
+ ? String(value)
2640
+ : null;
2641
+ }
2642
+ cancellationErrorParts(record) {
2643
+ const parts = [];
2644
+ for (const key of ["error", "message", "text"]) {
2645
+ if (!(key in record))
2646
+ continue;
2647
+ const text = this.trimmedString(record[key]);
2648
+ parts.push(text === null ? `${key}: present` : `${key}: ${text}`);
2649
+ }
2650
+ for (const key of ["statusCode", "code"]) {
2651
+ if (!(key in record))
2652
+ continue;
2653
+ const value = record[key];
2654
+ parts.push(typeof value === "string" || typeof value === "number"
2655
+ ? `${key}: ${String(value)}`
2656
+ : `${key}: present`);
2657
+ }
2658
+ if (record["success"] === false)
2659
+ parts.push("success: false");
2660
+ return parts;
2661
+ }
2662
+ normalizeMultiOrderSubmission(response, parentClientOrderId, accountId) {
2291
2663
  const decoded = this.decodeOrderSubmission(response);
2292
2664
  const hasDistinctBrokerOrderIds = decoded.orders.length === 2 &&
2293
2665
  new Set(decoded.orders.map(({ orderId }) => orderId)).size === 2;
@@ -2314,7 +2686,7 @@ export class IbkrClient {
2314
2686
  return {
2315
2687
  state: "warning",
2316
2688
  warnings: decoded.warnings,
2317
- continuation: { replyId: warning.replyId, parentClientOrderId },
2689
+ continuation: { accountId, replyId: warning.replyId, parentClientOrderId },
2318
2690
  };
2319
2691
  }
2320
2692
  if (hasErrors &&
@@ -3147,15 +3519,57 @@ export class IbkrClient {
3147
3519
  const parsed = Number(match[0].replace(/,/g, ""));
3148
3520
  return Number.isFinite(parsed) ? parsed : null;
3149
3521
  }
3522
+ withAccountCriticalSection(operation) {
3523
+ this.assertOpen();
3524
+ const result = this.accountCriticalSectionTail.then(async () => {
3525
+ this.assertOpen();
3526
+ return operation();
3527
+ });
3528
+ this.accountCriticalSectionTail = result.then(() => undefined, () => undefined);
3529
+ return result;
3530
+ }
3531
+ withTradingMutation(accountId, unsafeMessage, operation) {
3532
+ return this.withAccountCriticalSection(async () => {
3533
+ const diagnostics = await this.getTradingDiagnostics(accountId);
3534
+ if (!this.isSafeForTradingMutation(diagnostics))
3535
+ throw new Error(unsafeMessage);
3536
+ await this.prepareBrokerageAccount(accountId);
3537
+ return operation(diagnostics);
3538
+ });
3539
+ }
3540
+ isSafeForTradingMutation(diagnostics) {
3541
+ return (diagnostics.authenticated === true &&
3542
+ diagnostics.connected === true &&
3543
+ diagnostics.competingSession === false &&
3544
+ diagnostics.environment !== null);
3545
+ }
3546
+ assertOpen() {
3547
+ if (this.closed)
3548
+ throw this.closedError();
3549
+ }
3550
+ closedError() {
3551
+ return new Error("This IBKR client is closed");
3552
+ }
3553
+ booleanOrNull(value) {
3554
+ return typeof value === "boolean" ? value : null;
3555
+ }
3556
+ accountIdsOrNull(value) {
3557
+ return Array.isArray(value) &&
3558
+ Array.from(value).every((accountId) => typeof accountId === "string")
3559
+ ? value
3560
+ : null;
3561
+ }
3150
3562
  async prepareBrokerageAccount(accountId) {
3151
- const brokerageAccounts = await this.req({
3563
+ const rawBrokerageAccounts = await this.req({
3152
3564
  path: "iserver/accounts",
3153
3565
  });
3154
- if (brokerageAccounts.selectedAccount === accountId)
3155
- return;
3156
- if (brokerageAccounts.accounts && !brokerageAccounts.accounts.includes(accountId)) {
3566
+ const brokerageAccounts = isUnknownRecord(rawBrokerageAccounts) ? rawBrokerageAccounts : {};
3567
+ const accountIds = this.accountIdsOrNull(brokerageAccounts["accounts"]);
3568
+ if (!accountIds?.includes(accountId)) {
3157
3569
  throw new Error(`IBKR account ${accountId} is not available for trading/order queries.`);
3158
3570
  }
3571
+ if (brokerageAccounts["selectedAccount"] === accountId)
3572
+ return;
3159
3573
  const switchedAccount = await this.singleAttemptRequest({
3160
3574
  path: "iserver/account",
3161
3575
  method: "POST",
@@ -3294,6 +3708,7 @@ export class IbkrClient {
3294
3708
  }
3295
3709
  /** Return complete daily history with the exact validated IBKR request context. */
3296
3710
  async getPriceHistory(input) {
3711
+ this.assertOpen();
3297
3712
  const requestedSymbol = input.symbol.trim().toUpperCase();
3298
3713
  if (!requestedSymbol) {
3299
3714
  throw new IbkrPriceHistoryContractError("Price history requires a symbol", "CONTRACT_INVALID");
@@ -3444,6 +3859,7 @@ export class IbkrClient {
3444
3859
  }
3445
3860
  /** Discover listed derivative series over an inclusive calendar range. */
3446
3861
  async getDerivativeExpiries(query) {
3862
+ this.assertOpen();
3447
3863
  const contracts = [];
3448
3864
  for (const month of monthCodes(query.from, query.to)) {
3449
3865
  contracts.push(...(await this.discoverDerivativeMonth(query.underlying, query.assetClass, month, query.exchange, query.right)));
@@ -3477,6 +3893,7 @@ export class IbkrClient {
3477
3893
  }
3478
3894
  /** Discover contracts for one exact expiration, preserving class and venue identity. */
3479
3895
  async getDerivativeContracts(query) {
3896
+ this.assertOpen();
3480
3897
  const tradingClass = query.tradingClass?.trim().toUpperCase();
3481
3898
  return (await this.discoverDerivativeMonth(query.underlying, query.assetClass, monthCode(query.expiration), query.exchange, query.right, query.strike)).filter((contract) => contract.expiration === query.expiration &&
3482
3899
  (query.right === undefined || contract.right === query.right) &&
@@ -3485,6 +3902,7 @@ export class IbkrClient {
3485
3902
  }
3486
3903
  /** Resolve exactly one contract and reject missing or ambiguous semantic identity. */
3487
3904
  async resolveDerivativeContract(query) {
3905
+ this.assertOpen();
3488
3906
  const contracts = await this.getDerivativeContracts(query);
3489
3907
  if (!contracts.length) {
3490
3908
  throw new Error(`IBKR returned no exact ${query.assetClass} contract for ${query.underlying} ${query.expiration} ${query.right}${String(query.strike)}`);
@@ -3500,6 +3918,7 @@ export class IbkrClient {
3500
3918
  }
3501
3919
  /** Return an exact-expiration derivative chain with explicit data availability. */
3502
3920
  async getDerivativeChain(query) {
3921
+ this.assertOpen();
3503
3922
  const contracts = await this.getDerivativeContracts(query);
3504
3923
  if (!contracts.length) {
3505
3924
  throw new Error(`IBKR returned no ${query.assetClass} contracts for ${query.underlying} ${query.expiration}`);
@@ -3512,6 +3931,7 @@ export class IbkrClient {
3512
3931
  }
3513
3932
  /** Quote the broker-linked underlying (for example, the Sep NQ future behind QN3). */
3514
3933
  async getDerivativeReferenceQuote(contract) {
3934
+ this.assertOpen();
3515
3935
  const detailResponse = await this.req({
3516
3936
  path: "trsrv/secdef",
3517
3937
  params: { conids: String(contract.conid) },
@@ -3558,6 +3978,7 @@ export class IbkrClient {
3558
3978
  }
3559
3979
  /** Discover every listed weekly/monthly expiry in the requested calendar range. */
3560
3980
  async getOptionExpiries(symbol, right, fromDate, toDate, options = {}) {
3981
+ this.assertOpen();
3561
3982
  const normalized = symbol.trim().toUpperCase();
3562
3983
  const months = monthCodes(fromDate, toDate);
3563
3984
  const contracts = [];
@@ -3573,6 +3994,7 @@ export class IbkrClient {
3573
3994
  }
3574
3995
  /** Build one exact-expiry chain with canonical OSI symbols and required pricing/greeks. */
3575
3996
  async getOptionChain(symbol, expiry, right, options = {}) {
3997
+ this.assertOpen();
3576
3998
  const month = monthCode(expiry);
3577
3999
  const normalized = symbol.trim().toUpperCase();
3578
4000
  const discovery = await this.discoverOptions(normalized, month, right, options);
@@ -3592,6 +4014,7 @@ export class IbkrClient {
3592
4014
  }
3593
4015
  /** Return every qualified contract for one exact expiry and side without hiding sparse data. */
3594
4016
  async getOptionChainSnapshot(symbol, expiry, right, options = {}) {
4017
+ this.assertOpen();
3595
4018
  const month = monthCode(expiry);
3596
4019
  const normalized = symbol.trim().toUpperCase();
3597
4020
  const discovery = await this.discoverOptions(normalized, month, right, options);
@@ -3603,6 +4026,7 @@ export class IbkrClient {
3603
4026
  }
3604
4027
  /** Fetch one exact option quote; null means the contract is not listed. */
3605
4028
  async getOptionQuote(input) {
4029
+ this.assertOpen();
3606
4030
  const contract = await this.resolveOptionContract(input);
3607
4031
  if (!contract)
3608
4032
  return null;
@@ -3610,6 +4034,7 @@ export class IbkrClient {
3610
4034
  }
3611
4035
  /** Resolve a conid back into the canonical OSI-bearing option contract. */
3612
4036
  async getOptionContract(conid) {
4037
+ this.assertOpen();
3613
4038
  const response = await this.req({
3614
4039
  path: "trsrv/secdef",
3615
4040
  params: { conids: String(conid) },
@@ -4984,6 +5409,8 @@ export class IbkrClient {
4984
5409
  return this.scheduledRequest(input, "SINGLE_ATTEMPT");
4985
5410
  }
4986
5411
  scheduledRequest(input, retryPolicy, signal, onTerminalFailure) {
5412
+ if (this.closed)
5413
+ return Promise.reject(this.closedError());
4987
5414
  return this.requestScheduler.schedule({
4988
5415
  endpoint: this.requestEndpoint(input.path),
4989
5416
  priority: this.requestPriority(input.path),