@huskly/ibkr-client 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,6 +74,7 @@ const IBKR_WORKING_STATUSES = new Set([
74
74
  "SUBMITTED",
75
75
  "PENDING_CANCEL",
76
76
  ]);
77
+ const RECOVERY_TERMINAL_ORDER_FILTERS = ["filled", "cancelled", "inactive"];
77
78
  const KNOWN_ORDER_WARNING_IDS = new Set(["o163"]);
78
79
  /** Extract the canonical OSI symbol embedded in an IBKR option description. */
79
80
  function extractOsiPositionSymbol(contractDescription) {
@@ -100,6 +101,17 @@ function isHeadersLike(input) {
100
101
  input !== null &&
101
102
  typeof input.get === "function");
102
103
  }
104
+ function isIbkrTrade(input) {
105
+ if (typeof input !== "object" || input === null || Array.isArray(input))
106
+ return false;
107
+ const record = input;
108
+ return ((record["account"] === undefined || typeof record["account"] === "string") &&
109
+ (record["accountCode"] === undefined || typeof record["accountCode"] === "string") &&
110
+ (record["order_ref"] === undefined || typeof record["order_ref"] === "string") &&
111
+ (record["order_id"] === undefined ||
112
+ typeof record["order_id"] === "string" ||
113
+ typeof record["order_id"] === "number"));
114
+ }
103
115
  function headerToString(value) {
104
116
  if (value === undefined || value === null)
105
117
  return undefined;
@@ -336,6 +348,532 @@ export class IbkrClient {
336
348
  });
337
349
  return this.normalizeMultiOrderSubmission(response, parent.clientOrderId);
338
350
  }
351
+ async submitDerivativeOrderGraph(request) {
352
+ this.validateOrderGraph(request);
353
+ const diagnostics = await this.getTradingDiagnostics(request.accountId);
354
+ if (!diagnostics.authenticated || diagnostics.competingSession) {
355
+ throw new Error("IBKR brokerage session is not safely authenticated for submission");
356
+ }
357
+ await this.prepareBrokerageAccount(request.accountId);
358
+ const response = await this.singleAttemptRequest({
359
+ path: `iserver/account/${request.accountId}/orders`,
360
+ method: "POST",
361
+ data: { orders: request.nodes.map((node) => this.graphOrderTicket(request, node)) },
362
+ });
363
+ return this.normalizeOrderGraphSubmission(response, request, []);
364
+ }
365
+ async acknowledgeDerivativeOrderGraphWarning(input) {
366
+ if (input.confirmed !== true)
367
+ throw new Error("Order warning confirmation must be true");
368
+ this.validateOrderGraph(input.continuation.request);
369
+ if (!input.continuation.replyId.trim())
370
+ throw new Error("An exact warning reply ID is required");
371
+ const diagnostics = await this.getTradingDiagnostics(input.continuation.request.accountId);
372
+ if (!diagnostics.authenticated || diagnostics.competingSession) {
373
+ throw new Error("IBKR brokerage session is not safely authenticated for submission");
374
+ }
375
+ await this.prepareBrokerageAccount(input.continuation.request.accountId);
376
+ const response = await this.singleAttemptRequest({
377
+ path: `iserver/reply/${encodeURIComponent(input.continuation.replyId)}`,
378
+ method: "POST",
379
+ data: { confirmed: true },
380
+ });
381
+ return this.normalizeOrderGraphSubmission(response, input.continuation.request, input.continuation.members);
382
+ }
383
+ async recoverDerivativeOrderGraph(input, request) {
384
+ this.validateOrderGraph(request);
385
+ if (input.accountId !== request.accountId)
386
+ throw new Error("Graph recovery account does not match request");
387
+ if (input.rootClientOrderId !== undefined &&
388
+ input.rootClientOrderId !== request.rootClientOrderId) {
389
+ throw new Error("Root client order ID does not match the requested graph");
390
+ }
391
+ if (input.orderId !== undefined && !input.orderId.trim()) {
392
+ throw new Error("An exact broker order ID is required for graph recovery");
393
+ }
394
+ await this.prepareBrokerageAccount(input.accountId);
395
+ const response = await this.req({
396
+ path: "iserver/account/orders",
397
+ params: { force: true, accountId: input.accountId },
398
+ });
399
+ const flattenedActiveSnapshot = this.flattenCompleteOrderSnapshot(response);
400
+ const activeSnapshotIncomplete = flattenedActiveSnapshot === null;
401
+ const invalidActiveAccountEvidence = flattenedActiveSnapshot?.some(({ order }) => !this.orderHasExactAccount(order, input.accountId)) ?? false;
402
+ const invalidNestedActiveEvidence = flattenedActiveSnapshot?.some(({ order, nestedParent }) => nestedParent !== null &&
403
+ !this.recoveryGraphOrderMayBeAttached(request, order) &&
404
+ this.recoveryGraphOrderIsAttached(request, nestedParent)) ?? false;
405
+ const accountOrders = (flattenedActiveSnapshot ?? [])
406
+ .map(({ order }) => order)
407
+ .filter((order) => this.orderHasExactAccount(order, input.accountId));
408
+ const activeMatchesByMember = new Map();
409
+ const observedCandidates = activeSnapshotIncomplete || invalidActiveAccountEvidence || invalidNestedActiveEvidence
410
+ ? [response]
411
+ : [];
412
+ observedCandidates.push(...accountOrders.filter((order) => this.recoveryGraphOrderMayBeAttached(request, order)));
413
+ for (const node of request.nodes) {
414
+ const matches = accountOrders.filter((order) => this.terminalOrderTicketIsValid(order) &&
415
+ this.orderHasValidRecoveryStatus(order) &&
416
+ this.recoveryOrderMatchesGraphNode(request, node, order));
417
+ activeMatchesByMember.set(node.memberId, matches);
418
+ }
419
+ const selected = new Map();
420
+ const usedOrderIds = new Set();
421
+ for (const node of request.nodes) {
422
+ const matches = activeMatchesByMember.get(node.memberId) ?? [];
423
+ const [match] = matches;
424
+ if (matches.length !== 1 || match === undefined)
425
+ continue;
426
+ const orderId = this.recoveryOrderId(match);
427
+ if (orderId === undefined)
428
+ continue;
429
+ if (usedOrderIds.has(orderId))
430
+ continue;
431
+ usedOrderIds.add(orderId);
432
+ selected.set(node.memberId, match);
433
+ }
434
+ const unresolved = request.nodes.filter((node) => !selected.has(node.memberId));
435
+ const terminalEvidence = await this.findRecoveryGraphTerminalCandidates(input.accountId, request, input.orderId);
436
+ this.reconcileSelectedGraphMembers(request, selected, terminalEvidence);
437
+ if (unresolved.length > 0) {
438
+ const assignments = this.assignRecoveryGraphCandidates(unresolved, terminalEvidence.byNode, usedOrderIds);
439
+ for (const node of unresolved) {
440
+ const order = assignments.get(node.memberId);
441
+ if (order === undefined)
442
+ continue;
443
+ selected.set(node.memberId, order);
444
+ const orderId = this.recoveryOrderId(order);
445
+ if (orderId !== undefined)
446
+ usedOrderIds.add(orderId);
447
+ observedCandidates.push(order);
448
+ }
449
+ }
450
+ observedCandidates.push(...terminalEvidence.observedResponses);
451
+ if (terminalEvidence.invalidAttachedEvidence) {
452
+ observedCandidates.push({ reason: "Invalid or conflicting terminal broker evidence" });
453
+ }
454
+ const selectedCandidates = [...selected.values()];
455
+ const requestedOrderIdMissing = input.orderId !== undefined &&
456
+ !selectedCandidates.some((order) => this.recoveryOrderId(order) === input.orderId);
457
+ const members = request.nodes.map((node) => {
458
+ const order = selected.get(node.memberId);
459
+ return this.graphMemberEvidence(request, node, order);
460
+ });
461
+ const ids = members.flatMap(({ orderId }) => (orderId === null ? [] : [orderId]));
462
+ const hasDistinctOrderIds = new Set(ids).size === request.nodes.length;
463
+ const linkedOrderIds = new Set();
464
+ let linkedOrderMissingBrokerId = false;
465
+ for (const order of accountOrders) {
466
+ if (!this.recoveryGraphOrderMayBeAttached(request, order))
467
+ continue;
468
+ if (!this.recoveryGraphOrderIsAttached(request, order)) {
469
+ linkedOrderMissingBrokerId = true;
470
+ continue;
471
+ }
472
+ const orderId = this.recoveryOrderId(order);
473
+ if (orderId === undefined) {
474
+ linkedOrderMissingBrokerId = true;
475
+ continue;
476
+ }
477
+ linkedOrderIds.add(orderId);
478
+ }
479
+ for (const order of terminalEvidence.linkedOrders) {
480
+ const orderId = this.recoveryOrderId(order);
481
+ if (orderId === undefined) {
482
+ linkedOrderMissingBrokerId = true;
483
+ continue;
484
+ }
485
+ linkedOrderIds.add(orderId);
486
+ }
487
+ const selectedOrderIds = new Set(selectedCandidates.flatMap((order) => {
488
+ const orderId = this.recoveryOrderId(order);
489
+ return orderId === undefined ? [] : [orderId];
490
+ }));
491
+ if (selected.size !== request.nodes.length ||
492
+ !hasDistinctOrderIds ||
493
+ linkedOrderMissingBrokerId ||
494
+ [...linkedOrderIds].some((orderId) => !selectedOrderIds.has(orderId)) ||
495
+ requestedOrderIdMissing ||
496
+ activeSnapshotIncomplete ||
497
+ invalidActiveAccountEvidence ||
498
+ invalidNestedActiveEvidence ||
499
+ terminalEvidence.invalidAttachedEvidence ||
500
+ terminalEvidence.terminalSnapshotLookupFailed ||
501
+ members.some(({ status }) => status === "UNKNOWN" || status === "WARNING_PENDING")) {
502
+ return {
503
+ state: "recovery_required",
504
+ rootClientOrderId: request.rootClientOrderId,
505
+ members,
506
+ reasons: [
507
+ "Exact graph recovery found incomplete, duplicated, or ambiguous member evidence",
508
+ ],
509
+ warnings: [],
510
+ errors: [],
511
+ unrecognizedResponses: observedCandidates,
512
+ };
513
+ }
514
+ return {
515
+ state: "accepted",
516
+ rootClientOrderId: request.rootClientOrderId,
517
+ members: this.attachGraphParentOrderIds(members),
518
+ warnings: [],
519
+ };
520
+ }
521
+ reconcileSelectedGraphMembers(request, selected, terminalEvidence) {
522
+ for (const node of request.nodes) {
523
+ const selectedOrder = selected.get(node.memberId);
524
+ const selectedOrderId = selectedOrder === undefined ? undefined : this.recoveryOrderId(selectedOrder);
525
+ if (selectedOrderId === undefined)
526
+ continue;
527
+ const terminalMatches = new Map();
528
+ for (const order of terminalEvidence.byNode.get(node.memberId) ?? []) {
529
+ const orderId = this.recoveryOrderId(order);
530
+ if (orderId === selectedOrderId)
531
+ terminalMatches.set(orderId, order);
532
+ }
533
+ const terminalLinked = terminalMatches.size > 0 ||
534
+ terminalEvidence.linkedOrders.some((order) => this.recoveryOrderId(order) === selectedOrderId && this.isTerminalRecoveryOrder(order));
535
+ if (!terminalLinked)
536
+ continue;
537
+ if (terminalMatches.size !== 1) {
538
+ terminalEvidence.invalidAttachedEvidence = true;
539
+ continue;
540
+ }
541
+ const [terminalOrder] = terminalMatches.values();
542
+ if (terminalOrder !== undefined)
543
+ selected.set(node.memberId, terminalOrder);
544
+ }
545
+ }
546
+ isTerminalRecoveryOrder(order) {
547
+ const status = this.canonicalIbkrOrderStatus(order.order_status ?? order.orderStatus ?? order.status);
548
+ return (status === "FILLED" ||
549
+ status === "CANCELLED" ||
550
+ status === "INACTIVE" ||
551
+ status === "REJECTED");
552
+ }
553
+ async findRecoveryGraphTerminalCandidates(accountId, request, knownOrderId) {
554
+ const byNode = new Map();
555
+ for (const node of request.nodes)
556
+ byNode.set(node.memberId, []);
557
+ const linkedOrders = [];
558
+ const observedResponses = [];
559
+ let invalidAttachedEvidence = false;
560
+ let terminalSnapshotLookupFailed = false;
561
+ const candidateOrderIds = new Set();
562
+ if (knownOrderId !== undefined)
563
+ candidateOrderIds.add(knownOrderId);
564
+ const terminalOrdersById = new Map();
565
+ for (const filter of RECOVERY_TERMINAL_ORDER_FILTERS) {
566
+ try {
567
+ const response = await this.req({
568
+ path: "iserver/account/orders",
569
+ params: { force: true, accountId, filters: filter },
570
+ });
571
+ const flattenedSnapshot = this.flattenCompleteOrderSnapshot(response);
572
+ if (flattenedSnapshot === null) {
573
+ terminalSnapshotLookupFailed = true;
574
+ observedResponses.push({ source: "terminal_order_snapshot", filter, response });
575
+ continue;
576
+ }
577
+ for (const { order, nestedParent } of flattenedSnapshot) {
578
+ if (!this.recoveryGraphOrderMayBeAttached(request, order)) {
579
+ if (nestedParent === null ||
580
+ !this.recoveryGraphOrderIsAttached(request, nestedParent)) {
581
+ continue;
582
+ }
583
+ observedResponses.push(order);
584
+ linkedOrders.push(order);
585
+ invalidAttachedEvidence = true;
586
+ continue;
587
+ }
588
+ observedResponses.push(order);
589
+ if (!this.recoveryGraphOrderIsAttached(request, order)) {
590
+ linkedOrders.push(order);
591
+ invalidAttachedEvidence = true;
592
+ continue;
593
+ }
594
+ if (!this.orderHasExactAccount(order, accountId)) {
595
+ invalidAttachedEvidence = true;
596
+ continue;
597
+ }
598
+ if (!this.terminalOrderTicketIsValid(order) || !this.orderHasValidRecoveryStatus(order)) {
599
+ linkedOrders.push(order);
600
+ invalidAttachedEvidence = true;
601
+ continue;
602
+ }
603
+ const orderId = this.recoveryOrderId(order);
604
+ if (orderId === undefined) {
605
+ linkedOrders.push(order);
606
+ invalidAttachedEvidence = true;
607
+ continue;
608
+ }
609
+ const previousOrder = terminalOrdersById.get(orderId);
610
+ if (previousOrder !== undefined &&
611
+ (this.terminalOrderTicketConflicts(previousOrder, order) ||
612
+ this.recoveryGraphAttachmentKey(request, previousOrder) !==
613
+ this.recoveryGraphAttachmentKey(request, order))) {
614
+ invalidAttachedEvidence = true;
615
+ linkedOrders.push(order);
616
+ continue;
617
+ }
618
+ terminalOrdersById.set(orderId, order);
619
+ linkedOrders.push(order);
620
+ candidateOrderIds.add(orderId);
621
+ }
622
+ }
623
+ catch (error) {
624
+ terminalSnapshotLookupFailed = true;
625
+ observedResponses.push({
626
+ source: "terminal_order_snapshot",
627
+ filter,
628
+ error: error instanceof Error ? error.message : String(error),
629
+ });
630
+ }
631
+ }
632
+ const tradeEvidenceById = new Map();
633
+ try {
634
+ const response = await this.req({
635
+ path: "iserver/account/trades",
636
+ params: { days: 7 },
637
+ });
638
+ if (Array.isArray(response)) {
639
+ for (const rawTrade of response) {
640
+ if (typeof rawTrade !== "object" || rawTrade === null || Array.isArray(rawTrade))
641
+ continue;
642
+ const tradeRecord = rawTrade;
643
+ const orderRef = this.trimmedString(tradeRecord["order_ref"]);
644
+ const parentRef = this.recoveryOrderId({
645
+ order_id: tradeRecord["parent_order_ref"],
646
+ orderId: tradeRecord["parentOrderRef"],
647
+ });
648
+ if (orderRef !== request.rootClientOrderId && parentRef !== request.rootClientOrderId) {
649
+ continue;
650
+ }
651
+ const accounts = [tradeRecord["account"], tradeRecord["accountCode"]].filter((value) => value !== undefined);
652
+ if (accounts.length > 0 &&
653
+ accounts.every((value) => typeof value === "string" && value !== accountId)) {
654
+ continue;
655
+ }
656
+ if (accounts.length === 0 ||
657
+ accounts.some((value) => typeof value !== "string" || value !== accountId) ||
658
+ !isIbkrTrade(rawTrade)) {
659
+ observedResponses.push(rawTrade);
660
+ invalidAttachedEvidence = true;
661
+ continue;
662
+ }
663
+ const orderId = this.recoveryOrderId(rawTrade);
664
+ if (orderId === undefined) {
665
+ observedResponses.push(rawTrade);
666
+ invalidAttachedEvidence = true;
667
+ continue;
668
+ }
669
+ candidateOrderIds.add(orderId);
670
+ tradeEvidenceById.set(orderId, {
671
+ ...rawTrade,
672
+ account: accountId,
673
+ order_id: orderId,
674
+ });
675
+ }
676
+ }
677
+ }
678
+ catch {
679
+ // Exact broker IDs and terminal order snapshots remain usable when trade history is unavailable.
680
+ }
681
+ for (const orderId of candidateOrderIds) {
682
+ const terminalOrder = terminalOrdersById.get(orderId);
683
+ try {
684
+ const order = await this.req({
685
+ path: `iserver/account/order/status/${encodeURIComponent(orderId)}`,
686
+ });
687
+ observedResponses.push(order);
688
+ if (!this.orderMatchesExactRecoveryIdentity(order, accountId, orderId)) {
689
+ invalidAttachedEvidence = true;
690
+ continue;
691
+ }
692
+ if (!this.orderHasValidRecoveryStatus(order)) {
693
+ invalidAttachedEvidence = true;
694
+ terminalOrdersById.delete(orderId);
695
+ continue;
696
+ }
697
+ if (!this.terminalOrderTicketIsValid(order)) {
698
+ invalidAttachedEvidence = true;
699
+ terminalOrdersById.delete(orderId);
700
+ continue;
701
+ }
702
+ if (terminalOrder !== undefined &&
703
+ this.terminalOrderTicketConflicts(terminalOrder, order)) {
704
+ invalidAttachedEvidence = true;
705
+ terminalOrdersById.delete(orderId);
706
+ continue;
707
+ }
708
+ if (terminalOrder !== undefined &&
709
+ this.recoveryGraphAttachmentKey(request, terminalOrder) !==
710
+ this.recoveryGraphAttachmentKey(request, order)) {
711
+ invalidAttachedEvidence = true;
712
+ terminalOrdersById.delete(orderId);
713
+ continue;
714
+ }
715
+ if (!this.recoveryGraphOrderIsAttached(request, order)) {
716
+ invalidAttachedEvidence = true;
717
+ terminalOrdersById.delete(orderId);
718
+ continue;
719
+ }
720
+ const resolvedOrder = terminalOrder === undefined ? order : { ...terminalOrder, ...order };
721
+ terminalOrdersById.set(orderId, resolvedOrder);
722
+ if (terminalOrder === undefined)
723
+ linkedOrders.push(resolvedOrder);
724
+ }
725
+ catch (error) {
726
+ observedResponses.push({
727
+ source: "terminal_order_status",
728
+ orderId,
729
+ error: error instanceof Error ? error.message : String(error),
730
+ });
731
+ if (terminalOrder === undefined) {
732
+ const tradeEvidence = tradeEvidenceById.get(orderId);
733
+ if (tradeEvidence !== undefined) {
734
+ linkedOrders.push(tradeEvidence);
735
+ observedResponses.push(tradeEvidence);
736
+ }
737
+ continue;
738
+ }
739
+ }
740
+ const resolvedOrder = terminalOrdersById.get(orderId) ?? terminalOrder;
741
+ if (resolvedOrder === undefined)
742
+ continue;
743
+ if (!this.orderHasExactAccount(resolvedOrder, accountId)) {
744
+ invalidAttachedEvidence = true;
745
+ continue;
746
+ }
747
+ for (const node of request.nodes) {
748
+ if (this.terminalOrderMatchesGraphNode(request, node, resolvedOrder)) {
749
+ const existing = byNode.get(node.memberId);
750
+ if (existing !== undefined)
751
+ existing.push(resolvedOrder);
752
+ }
753
+ }
754
+ }
755
+ return {
756
+ byNode,
757
+ linkedOrders,
758
+ observedResponses,
759
+ invalidAttachedEvidence,
760
+ terminalSnapshotLookupFailed,
761
+ };
762
+ }
763
+ recoveryGraphOrderIsAttached(request, order) {
764
+ return this.recoveryGraphAttachmentKey(request, order) !== null;
765
+ }
766
+ recoveryGraphOrderMayBeAttached(request, order) {
767
+ return [
768
+ order.cOID,
769
+ order.order_ref,
770
+ order.parentId,
771
+ order.parent_id,
772
+ order.parentClientOrderId,
773
+ order.parent_order_ref,
774
+ ].some((value) => (typeof value === "string" || typeof value === "number") &&
775
+ String(value).trim() === request.rootClientOrderId);
776
+ }
777
+ recoveryGraphAttachmentKey(request, order) {
778
+ const parentIdentity = this.consistentStringAliases(order.parentId, order.parent_id, order.parentClientOrderId, order.parent_order_ref);
779
+ const clientIdentity = this.consistentStringAliases(order.cOID, order.order_ref);
780
+ if (!parentIdentity.valid || !clientIdentity.valid)
781
+ return null;
782
+ const parentId = parentIdentity.value ?? "";
783
+ if (parentId === request.rootClientOrderId)
784
+ return "child";
785
+ const clientOrderId = clientIdentity.value;
786
+ if (clientOrderId === request.rootClientOrderId && parentId === "")
787
+ return "root";
788
+ return null;
789
+ }
790
+ terminalOrderTicketConflicts(first, second) {
791
+ const firstTicket = this.terminalOrderTicketFingerprint(first);
792
+ const secondTicket = this.terminalOrderTicketFingerprint(second);
793
+ return Object.keys(firstTicket).some((field) => field in secondTicket && firstTicket[field] !== secondTicket[field]);
794
+ }
795
+ terminalOrderTicketIsValid(order) {
796
+ return !Object.values(this.terminalOrderTicketFingerprint(order)).includes("__MALFORMED_TERMINAL_TICKET_FIELD__");
797
+ }
798
+ terminalOrderTicketFingerprint(order) {
799
+ const ticket = {};
800
+ const malformed = "__MALFORMED_TERMINAL_TICKET_FIELD__";
801
+ const addAliases = (field, values, normalize) => {
802
+ const provided = values.filter((value) => value !== undefined);
803
+ if (provided.length === 0)
804
+ return;
805
+ const normalized = provided.map(normalize);
806
+ const [first] = normalized;
807
+ ticket[field] =
808
+ first !== undefined && normalized.every((value) => value === first) ? first : malformed;
809
+ };
810
+ const normalizeNumber = (value) => {
811
+ if (typeof value === "number" && Number.isFinite(value)) {
812
+ return String(value);
813
+ }
814
+ if (typeof value === "string" && value.trim() !== "") {
815
+ const numeric = Number(value);
816
+ return Number.isFinite(numeric) ? String(numeric) : undefined;
817
+ }
818
+ return undefined;
819
+ };
820
+ addAliases("conid", [order.conid], normalizeNumber);
821
+ if (order.conidex !== undefined) {
822
+ ticket["conidex"] =
823
+ typeof order.conidex === "string"
824
+ ? JSON.stringify(this.parseComboLegs(order.conidex))
825
+ : malformed;
826
+ }
827
+ addAliases("orderType", [order.order_type, order.orderType], (value) => typeof value === "string" ? this.normalizeOrderType(value) : undefined);
828
+ addAliases("side", [order.side], (value) => typeof value === "string" ? this.normalizeOrderSide(value) : undefined);
829
+ addAliases("quantity", [order.total_size, order.totalSize, order.size], normalizeNumber);
830
+ addAliases("price", [order.limitPrice, order.limit_price, order.stopPrice, order.price], normalizeNumber);
831
+ addAliases("tif", [order.tif, order.timeInForce], (value) => typeof value === "string" ? value.toUpperCase() : undefined);
832
+ addAliases("outsideRTH", [order.outsideRTH, order.outside_rth], (value) => typeof value === "boolean" ? value : undefined);
833
+ return ticket;
834
+ }
835
+ orderHasExactAccount(order, accountId) {
836
+ const accounts = [order.account, order.acct];
837
+ const provided = accounts.filter((account) => account !== undefined);
838
+ return (provided.length > 0 &&
839
+ provided.every((account) => typeof account === "string" && account === accountId));
840
+ }
841
+ orderMatchesExactRecoveryIdentity(order, accountId, orderId) {
842
+ return this.orderHasExactAccount(order, accountId) && this.recoveryOrderId(order) === orderId;
843
+ }
844
+ orderHasValidRecoveryStatus(order) {
845
+ const provided = [order.order_status, order.orderStatus, order.status].filter((status) => status !== undefined);
846
+ if (provided.length === 0)
847
+ return true;
848
+ const normalized = provided.map((status) => this.canonicalIbkrOrderStatus(status));
849
+ const [first] = normalized;
850
+ return first !== undefined && normalized.every((status) => status === first);
851
+ }
852
+ assignRecoveryGraphCandidates(unresolvedNodes, terminalCandidates, usedOrderIds) {
853
+ const assignments = new Map();
854
+ const uniqueCandidates = new Map();
855
+ for (const node of unresolvedNodes) {
856
+ const candidates = terminalCandidates.get(node.memberId) ?? [];
857
+ if (candidates.length !== 1)
858
+ continue;
859
+ const order = candidates[0];
860
+ if (order === undefined)
861
+ continue;
862
+ const orderId = this.recoveryOrderId(order);
863
+ if (orderId === undefined || usedOrderIds.has(orderId))
864
+ continue;
865
+ const owners = uniqueCandidates.get(orderId) ?? [];
866
+ owners.push({ node, order });
867
+ uniqueCandidates.set(orderId, owners);
868
+ }
869
+ for (const owners of uniqueCandidates.values()) {
870
+ const owner = owners[0];
871
+ if (owners.length !== 1 || owner === undefined)
872
+ continue;
873
+ assignments.set(owner.node.memberId, owner.order);
874
+ }
875
+ return assignments;
876
+ }
339
877
  async acknowledgeOrderWarning(input) {
340
878
  if (!input.replyId.trim())
341
879
  throw new Error("An exact warning reply ID is required");
@@ -372,12 +910,12 @@ export class IbkrClient {
372
910
  const order = await this.req({
373
911
  path: `iserver/account/order/status/${encodeURIComponent(orderId)}`,
374
912
  });
375
- if (String(order.order_id ?? order.orderId ?? "") !== orderId) {
376
- throw new Error(`IBKR response does not match the requested order ${orderId}`);
377
- }
378
- if ((order.account ?? order.acct) !== accountId) {
913
+ if (!this.orderHasExactAccount(order, accountId)) {
379
914
  throw new Error(`IBKR order ${orderId} does not belong to the requested account`);
380
915
  }
916
+ if (this.recoveryOrderId(order) !== orderId) {
917
+ throw new Error(`IBKR response does not match the requested order ${orderId}`);
918
+ }
381
919
  const lifecycle = this.normalizeDerivativeOrderLifecycle(accountId, orderId, order);
382
920
  if (lifecycle.status === "UNKNOWN") {
383
921
  throw new Error(`IBKR order ${orderId} returned an unrecognized status`);
@@ -411,6 +949,59 @@ export class IbkrClient {
411
949
  }
412
950
  return this.getDerivativeOrderStatus(input.accountId, String(orderId));
413
951
  }
952
+ async listActiveDerivativeOrders(accountId) {
953
+ if (!accountId.trim())
954
+ throw new Error("An exact account ID is required");
955
+ await this.prepareBrokerageAccount(accountId);
956
+ const response = await this.req({
957
+ path: "iserver/account/orders",
958
+ params: { force: true, accountId },
959
+ });
960
+ const flattened = this.flattenCompleteOrderSnapshot(response);
961
+ if (flattened === null) {
962
+ throw new Error("IBKR active-order snapshot is incomplete");
963
+ }
964
+ const invalidAccountEvidence = flattened.find(({ order }) => {
965
+ const returnedAccounts = [order.account, order.acct];
966
+ const providedAccounts = returnedAccounts.filter((value) => value !== undefined);
967
+ return (providedAccounts.length === 0 ||
968
+ providedAccounts.some((returnedAccount) => typeof returnedAccount !== "string" || returnedAccount !== accountId));
969
+ });
970
+ if (invalidAccountEvidence !== undefined) {
971
+ throw new Error("IBKR active-order response did not provide unambiguous account identity");
972
+ }
973
+ const normalized = flattened.map(({ order, nestedParent }) => this.normalizeActiveDerivativeOrder(accountId, order, nestedParent));
974
+ const byOrderId = new Map();
975
+ const byClientId = new Map();
976
+ for (const order of normalized) {
977
+ if (order.orderId !== null) {
978
+ const members = byOrderId.get(order.orderId) ?? [];
979
+ members.push(order);
980
+ byOrderId.set(order.orderId, members);
981
+ }
982
+ if (order.clientOrderId !== null) {
983
+ const members = byClientId.get(order.clientOrderId) ?? [];
984
+ members.push(order);
985
+ byClientId.set(order.clientOrderId, members);
986
+ }
987
+ }
988
+ for (const order of normalized) {
989
+ if (order.orderId !== null && (byOrderId.get(order.orderId)?.length ?? 0) > 1) {
990
+ this.addOrderUncertainty(order, "DUPLICATE_MEMBER");
991
+ }
992
+ const parentIdentity = order.parentOrderId ?? order.parentClientOrderId;
993
+ if (parentIdentity === null)
994
+ continue;
995
+ const brokerMatches = byOrderId.get(parentIdentity) ?? [];
996
+ const clientMatches = byClientId.get(parentIdentity) ?? [];
997
+ const matches = new Set([...brokerMatches, ...clientMatches]);
998
+ if (matches.size === 0)
999
+ this.addOrderUncertainty(order, "MISSING_PARENT");
1000
+ if (matches.size > 1)
1001
+ this.addOrderUncertainty(order, "AMBIGUOUS_PARENT");
1002
+ }
1003
+ return normalized;
1004
+ }
414
1005
  async getDerivativeExecutions(input) {
415
1006
  if (!input.accountId.trim())
416
1007
  throw new Error("An exact account ID is required");
@@ -702,6 +1293,174 @@ export class IbkrClient {
702
1293
  }
703
1294
  }
704
1295
  }
1296
+ validateOrderGraph(request) {
1297
+ if (!request.accountId.trim())
1298
+ throw new Error("An explicit IBKR account ID is required");
1299
+ if (!request.rootClientOrderId.trim() || request.rootClientOrderId.length > 48) {
1300
+ throw new Error("Root client order ID must contain 1 to 48 characters");
1301
+ }
1302
+ if (request.nodes.length < 1 || request.nodes.length > 8) {
1303
+ throw new Error("Derivative order graphs require 1 to 8 members");
1304
+ }
1305
+ const seen = new Set();
1306
+ let roots = 0;
1307
+ for (const node of request.nodes) {
1308
+ if (!node.memberId.trim() ||
1309
+ node.memberId.length > 15 ||
1310
+ !/^[A-Za-z0-9_-]+$/.test(node.memberId)) {
1311
+ throw new Error("Graph member IDs must contain 1 to 15 safe characters");
1312
+ }
1313
+ if (seen.has(node.memberId))
1314
+ throw new Error("Graph member IDs must be unique");
1315
+ if (node.accountId !== request.accountId)
1316
+ throw new Error("Every graph member must target the graph account");
1317
+ if (node.parentMemberId === undefined)
1318
+ roots += 1;
1319
+ else if (!seen.has(node.parentMemberId))
1320
+ throw new Error("Graph parents must precede their children");
1321
+ else if (node.parentMemberId !== request.nodes[0]?.memberId)
1322
+ throw new Error("Derivative order graphs support only root-to-child attachments");
1323
+ if ("legs" in node) {
1324
+ this.validateComboPreview(node);
1325
+ }
1326
+ else {
1327
+ this.validateGraphSingleNode(node);
1328
+ }
1329
+ seen.add(node.memberId);
1330
+ }
1331
+ if (roots !== 1 || request.nodes[0]?.parentMemberId !== undefined) {
1332
+ throw new Error("Derivative order graphs require exactly one root as the first member");
1333
+ }
1334
+ }
1335
+ validateGraphSingleNode(node) {
1336
+ const orderType = node.orderType;
1337
+ if (orderType !== "LMT" && orderType !== "STP" && orderType !== "MKT") {
1338
+ throw new Error("Graph single order type must be LMT, STP, or MKT");
1339
+ }
1340
+ if (!Number.isSafeInteger(node.quantity) || node.quantity <= 0)
1341
+ throw new Error("Order quantity must be a positive integer");
1342
+ if (!Number.isSafeInteger(node.contract.conid) || node.contract.conid <= 0)
1343
+ throw new Error("Order contract has an invalid IBKR conid");
1344
+ if (node.orderType === "LMT" && (!Number.isFinite(node.limit) || node.limit <= 0))
1345
+ throw new Error("LIMIT order requires a positive limit price");
1346
+ if (node.orderType === "STP" && (!Number.isFinite(node.stopPrice) || node.stopPrice <= 0))
1347
+ throw new Error("STOP order requires a positive stop price");
1348
+ this.cmeOperatorMetadata(node.contract.assetClass, node);
1349
+ }
1350
+ graphClientOrderId(request, node) {
1351
+ return node.parentMemberId === undefined
1352
+ ? request.rootClientOrderId
1353
+ : `${request.rootClientOrderId}:${node.memberId}`;
1354
+ }
1355
+ graphOrderTicket(request, node) {
1356
+ const parent = request.nodes.find(({ memberId }) => memberId === node.parentMemberId);
1357
+ if (node.parentMemberId !== undefined && parent === undefined) {
1358
+ throw new Error("Graph parent evidence was lost after validation");
1359
+ }
1360
+ const identity = parent === undefined
1361
+ ? { cOID: this.graphClientOrderId(request, node) }
1362
+ : { parentId: this.graphClientOrderId(request, parent) };
1363
+ if ("legs" in node)
1364
+ return {
1365
+ ...this.comboOrderTicket(node),
1366
+ ...identity,
1367
+ ...this.cmeOperatorMetadata(node.legs[0].contract.assetClass, node),
1368
+ };
1369
+ return {
1370
+ acctId: node.accountId,
1371
+ conid: node.contract.conid,
1372
+ orderType: node.orderType,
1373
+ side: node.side,
1374
+ ...(node.orderType === "LMT"
1375
+ ? { price: node.limit }
1376
+ : node.orderType === "STP"
1377
+ ? { price: node.stopPrice }
1378
+ : {}),
1379
+ tif: node.tif,
1380
+ quantity: node.quantity,
1381
+ outsideRTH: node.session === "OVERNIGHT",
1382
+ ...identity,
1383
+ ...this.cmeOperatorMetadata(node.contract.assetClass, node),
1384
+ };
1385
+ }
1386
+ liveOrderMatchesGraphNode(request, node, order) {
1387
+ const parentIdentity = this.consistentStringAliases(order.parentId, order.parent_id, order.parentClientOrderId, order.parent_order_ref);
1388
+ if (!parentIdentity.valid)
1389
+ return false;
1390
+ if (node.parentMemberId === undefined) {
1391
+ const clientIdentity = this.consistentStringAliases(order.cOID, order.order_ref);
1392
+ if (!clientIdentity.valid || clientIdentity.value !== request.rootClientOrderId)
1393
+ return false;
1394
+ if (parentIdentity.value !== undefined)
1395
+ return false;
1396
+ }
1397
+ else if (parentIdentity.value !== request.rootClientOrderId)
1398
+ return false;
1399
+ if ("legs" in node) {
1400
+ const liveLegs = this.parseComboLegs(order.conidex);
1401
+ const orderType = this.normalizeOrderType(order.order_type ?? order.orderType);
1402
+ const side = this.normalizeOrderSide(order.side);
1403
+ const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size);
1404
+ const price = this.firstNumber(order.limitPrice, order.limit_price, order.price);
1405
+ const expectedPrice = node.priceEffect === "CREDIT" ? -node.limit : node.limit;
1406
+ const outsideRth = order.outsideRTH ?? order.outside_rth;
1407
+ const tif = order.tif ?? order.timeInForce;
1408
+ return (liveLegs.length === node.legs.length &&
1409
+ node.legs.every((leg, index) => {
1410
+ const liveLeg = liveLegs[index];
1411
+ return liveLeg?.conid === leg.contract.conid && liveLeg.ratio === leg.ratio;
1412
+ }) &&
1413
+ orderType === this.normalizeOrderType("LMT") &&
1414
+ side === "BUY" &&
1415
+ quantity === node.quantity &&
1416
+ price === expectedPrice &&
1417
+ (tif === undefined || tif.toUpperCase() === node.tif) &&
1418
+ (outsideRth === undefined || outsideRth === (node.session === "OVERNIGHT")));
1419
+ }
1420
+ const orderType = this.normalizeOrderType(order.order_type ?? order.orderType);
1421
+ const expectedOrderType = this.normalizeOrderType(node.orderType);
1422
+ const side = this.normalizeOrderSide(order.side);
1423
+ const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size);
1424
+ const price = node.orderType === "LMT"
1425
+ ? this.firstNumber(order.limitPrice, order.limit_price, order.price)
1426
+ : node.orderType === "STP"
1427
+ ? this.firstNumber(order.stopPrice, order.price)
1428
+ : undefined;
1429
+ const expectedPrice = node.orderType === "LMT" ? node.limit : node.orderType === "STP" ? node.stopPrice : undefined;
1430
+ const outsideRth = order.outsideRTH ?? order.outside_rth;
1431
+ const tif = order.tif ?? order.timeInForce;
1432
+ return (order.conid === node.contract.conid &&
1433
+ orderType === expectedOrderType &&
1434
+ side === node.side &&
1435
+ quantity === node.quantity &&
1436
+ price === expectedPrice &&
1437
+ (tif === undefined || tif.toUpperCase() === node.tif) &&
1438
+ (outsideRth === undefined || outsideRth === (node.session === "OVERNIGHT")));
1439
+ }
1440
+ recoveryOrderMatchesGraphNode(request, node, order) {
1441
+ try {
1442
+ return this.liveOrderMatchesGraphNode(request, node, order);
1443
+ }
1444
+ catch {
1445
+ return false;
1446
+ }
1447
+ }
1448
+ terminalOrderMatchesGraphNode(request, node, order) {
1449
+ try {
1450
+ const tif = order.tif ?? order.timeInForce;
1451
+ const outsideRth = order.outsideRTH ?? order.outside_rth;
1452
+ const status = order.order_status ?? order.orderStatus ?? order.status;
1453
+ if (typeof tif !== "string" ||
1454
+ typeof outsideRth !== "boolean" ||
1455
+ typeof status !== "string") {
1456
+ return false;
1457
+ }
1458
+ return this.recoveryOrderMatchesGraphNode(request, node, order);
1459
+ }
1460
+ catch {
1461
+ return false;
1462
+ }
1463
+ }
705
1464
  validateSingleOrder(request) {
706
1465
  this.validateSingleOrderFields(request);
707
1466
  const identityFields = request;
@@ -833,6 +1592,122 @@ export class IbkrClient {
833
1592
  }
834
1593
  return this.singleOrderRecoveryResult(decoded, orders);
835
1594
  }
1595
+ graphMemberEvidence(request, node, order) {
1596
+ const index = request.nodes.findIndex(({ memberId }) => memberId === node.memberId);
1597
+ let depth = 0;
1598
+ let parentId = node.parentMemberId;
1599
+ while (parentId !== undefined) {
1600
+ depth += 1;
1601
+ parentId = request.nodes.find(({ memberId }) => memberId === parentId)?.parentMemberId;
1602
+ }
1603
+ const rawId = order?.order_id ?? order?.orderId;
1604
+ const orderId = typeof rawId === "string" || typeof rawId === "number" ? String(rawId).trim() || null : null;
1605
+ const filledQuantity = this.firstNumber(order?.cum_fill, order?.cumFill, order?.filledQuantity, order?.filled);
1606
+ const quantity = this.firstPositiveNumber(order?.total_size, order?.totalSize, order?.size);
1607
+ const remainingQuantity = this.firstNumber(order?.remainingQuantity, order?.remaining_size, order?.remaining) ??
1608
+ (quantity !== undefined && filledQuantity !== undefined
1609
+ ? Math.max(0, quantity - filledQuantity)
1610
+ : undefined);
1611
+ return {
1612
+ memberId: node.memberId,
1613
+ role: index < 0
1614
+ ? "unknown"
1615
+ : depth === 0
1616
+ ? "root"
1617
+ : depth === 1
1618
+ ? "child"
1619
+ : depth === 2
1620
+ ? "grandchild"
1621
+ : "descendant",
1622
+ parentMemberId: node.parentMemberId ?? null,
1623
+ parentOrderId: null,
1624
+ orderId,
1625
+ status: order === undefined
1626
+ ? "WARNING_PENDING"
1627
+ : this.normalizeDerivativeOrderStatus(order.order_status ?? order.orderStatus ?? order.status, filledQuantity ?? 0, remainingQuantity ?? 0),
1628
+ clientOrderId: this.graphClientOrderId(request, node),
1629
+ request: node,
1630
+ };
1631
+ }
1632
+ attachGraphParentOrderIds(members) {
1633
+ const ids = new Map(members.map(({ memberId, orderId }) => [memberId, orderId]));
1634
+ return members.map((member) => ({
1635
+ ...member,
1636
+ parentOrderId: member.parentMemberId === null ? null : (ids.get(member.parentMemberId) ?? null),
1637
+ }));
1638
+ }
1639
+ normalizeOrderGraphSubmission(response, request, previousMembers) {
1640
+ const decoded = this.decodeOrderSubmission(response);
1641
+ const cleanOrders = decoded.warnings.length === 0 &&
1642
+ decoded.errors.length === 0 &&
1643
+ decoded.unrecognizedResponses.length === 0;
1644
+ const distinct = new Set(decoded.orders.map(({ orderId }) => orderId)).size === decoded.orders.length;
1645
+ const canCorrelatePositionally = cleanOrders && distinct && decoded.orders.length === request.nodes.length;
1646
+ const members = this.attachGraphParentOrderIds(request.nodes.map((node, index) => {
1647
+ const order = canCorrelatePositionally ? decoded.orders[index] : undefined;
1648
+ if (order === undefined)
1649
+ return (previousMembers.find(({ memberId }) => memberId === node.memberId) ??
1650
+ this.graphMemberEvidence(request, node));
1651
+ const evidence = this.graphMemberEvidence(request, node, {
1652
+ order_id: order.orderId,
1653
+ order_status: order.status,
1654
+ });
1655
+ return { ...evidence, status: order.status };
1656
+ }));
1657
+ if (decoded.warnings.length === 1 &&
1658
+ decoded.orders.length === 0 &&
1659
+ decoded.errors.length === 0 &&
1660
+ decoded.unrecognizedResponses.length === 0) {
1661
+ const warning = decoded.warnings[0];
1662
+ if (warning === undefined)
1663
+ throw new Error("Graph warning evidence was lost");
1664
+ return {
1665
+ state: "warning",
1666
+ rootClientOrderId: request.rootClientOrderId,
1667
+ members,
1668
+ warnings: decoded.warnings,
1669
+ continuation: { replyId: warning.replyId, request, members },
1670
+ };
1671
+ }
1672
+ if (decoded.errors.length > 0 &&
1673
+ !decoded.responseIsArray &&
1674
+ decoded.orders.length === 0 &&
1675
+ decoded.warnings.length === 0 &&
1676
+ decoded.unrecognizedResponses.length === 0) {
1677
+ return {
1678
+ state: "rejected",
1679
+ rootClientOrderId: request.rootClientOrderId,
1680
+ members,
1681
+ reasons: decoded.errors.map(({ message }) => message),
1682
+ errors: decoded.errors,
1683
+ };
1684
+ }
1685
+ if (cleanOrders &&
1686
+ distinct &&
1687
+ decoded.orders.length === request.nodes.length &&
1688
+ decoded.pendingCancelOrderIds.length === 0 &&
1689
+ decoded.orders.every(({ status }) => status !== "UNKNOWN" && status !== "REJECTED" && status !== "CANCELED")) {
1690
+ return {
1691
+ state: "accepted",
1692
+ rootClientOrderId: request.rootClientOrderId,
1693
+ members,
1694
+ warnings: [],
1695
+ };
1696
+ }
1697
+ return {
1698
+ state: "recovery_required",
1699
+ rootClientOrderId: request.rootClientOrderId,
1700
+ members,
1701
+ reasons: [
1702
+ this.submissionRecoveryReason(decoded, decoded.orders.length, request.nodes.length),
1703
+ ],
1704
+ warnings: decoded.warnings,
1705
+ errors: decoded.errors,
1706
+ unrecognizedResponses: canCorrelatePositionally
1707
+ ? decoded.unrecognizedResponses
1708
+ : [...decoded.unrecognizedResponses, ...decoded.orders],
1709
+ };
1710
+ }
836
1711
  normalizeMultiOrderSubmission(response, parentClientOrderId) {
837
1712
  const decoded = this.decodeOrderSubmission(response);
838
1713
  const hasDistinctBrokerOrderIds = decoded.orders.length === 2 &&
@@ -1083,6 +1958,198 @@ export class IbkrClient {
1083
1958
  updatedAt: this.parseOrderTime(order)?.toISOString() ?? null,
1084
1959
  };
1085
1960
  }
1961
+ flattenCompleteOrderSnapshot(response) {
1962
+ if (typeof response !== "object" || response === null || Array.isArray(response))
1963
+ return null;
1964
+ const record = response;
1965
+ if (record["snapshot"] !== true || !Array.isArray(record["orders"]))
1966
+ return null;
1967
+ const flattened = [];
1968
+ const visiting = new Set();
1969
+ const visit = (orders, nestedParent) => {
1970
+ for (const rawOrder of orders) {
1971
+ if (typeof rawOrder !== "object" || rawOrder === null || Array.isArray(rawOrder))
1972
+ return false;
1973
+ if (visiting.has(rawOrder))
1974
+ return false;
1975
+ visiting.add(rawOrder);
1976
+ const orderRecord = rawOrder;
1977
+ const childCollections = [orderRecord["childOrders"], orderRecord["children"]];
1978
+ if (childCollections.some((children) => children !== undefined && !Array.isArray(children))) {
1979
+ return false;
1980
+ }
1981
+ const order = rawOrder;
1982
+ flattened.push({ order, nestedParent });
1983
+ const children = new Set();
1984
+ for (const collection of childCollections) {
1985
+ if (Array.isArray(collection)) {
1986
+ for (const child of collection)
1987
+ children.add(child);
1988
+ }
1989
+ }
1990
+ if (!visit([...children], order))
1991
+ return false;
1992
+ visiting.delete(rawOrder);
1993
+ }
1994
+ return true;
1995
+ };
1996
+ return visit(record["orders"], null) ? flattened : null;
1997
+ }
1998
+ normalizeActiveDerivativeOrder(accountId, order, nestedParent) {
1999
+ const uncertainty = [];
2000
+ const total = this.firstNumber(order.total_size, order.totalSize, order.size) ?? null;
2001
+ const filled = this.firstNumber(order.cum_fill, order.cumFill, order.filledQuantity, order.filled) ?? null;
2002
+ const remaining = this.firstNumber(order.remainingQuantity, order.remaining_size, order.remaining) ??
2003
+ (total !== null && filled !== null ? Math.max(0, total - filled) : null);
2004
+ if (total === null || filled === null || remaining === null)
2005
+ uncertainty.push("INCOMPLETE_QUANTITIES");
2006
+ const rawStatus = order.order_status ?? order.orderStatus ?? order.status;
2007
+ const status = this.normalizeDerivativeOrderStatus(rawStatus, filled ?? 0, remaining ?? 0);
2008
+ if (status === "UNKNOWN")
2009
+ uncertainty.push("UNKNOWN_STATUS");
2010
+ const rawOrderId = order.order_id ?? order.orderId;
2011
+ if (rawOrderId === undefined)
2012
+ uncertainty.push("MISSING_BROKER_ORDER_ID");
2013
+ const explicitParentOrderId = order.parent_order_id ?? order.parentOrderId ?? order.parent_id;
2014
+ const explicitParentClientId = order.parentClientOrderId ?? order.parent_order_ref ?? order.parentId;
2015
+ const nestedBrokerId = nestedParent?.order_id ?? nestedParent?.orderId;
2016
+ const nestedClientId = nestedParent?.cOID ?? nestedParent?.order_ref;
2017
+ if (nestedParent !== null &&
2018
+ explicitParentOrderId === undefined &&
2019
+ explicitParentClientId === undefined) {
2020
+ uncertainty.push("PARTIAL_GRAPH");
2021
+ }
2022
+ const legs = this.normalizeActiveDerivativeLegs(order, total, uncertainty);
2023
+ const orderTime = this.parseOrderTime(order)?.toISOString() ?? null;
2024
+ return {
2025
+ accountId,
2026
+ orderId: rawOrderId === undefined ? null : String(rawOrderId),
2027
+ clientOrderId: order.cOID ?? order.order_ref ?? null,
2028
+ parentOrderId: explicitParentOrderId === undefined
2029
+ ? nestedBrokerId === undefined
2030
+ ? null
2031
+ : String(nestedBrokerId)
2032
+ : String(explicitParentOrderId),
2033
+ parentClientOrderId: explicitParentClientId === undefined
2034
+ ? (nestedClientId ?? null)
2035
+ : String(explicitParentClientId),
2036
+ graphRole: nestedParent !== null ||
2037
+ explicitParentOrderId !== undefined ||
2038
+ explicitParentClientId !== undefined
2039
+ ? "CHILD"
2040
+ : rawOrderId === undefined
2041
+ ? "UNKNOWN"
2042
+ : "ROOT",
2043
+ status,
2044
+ totalQuantity: total,
2045
+ filledQuantity: filled,
2046
+ remainingQuantity: remaining,
2047
+ tif: order.tif ?? order.timeInForce ?? null,
2048
+ session: order.outsideRTH === true || order.outside_rth === true
2049
+ ? "OVERNIGHT"
2050
+ : order.outsideRTH === false || order.outside_rth === false
2051
+ ? "REGULAR"
2052
+ : "UNKNOWN",
2053
+ orderType: this.normalizeOrderType(order.order_type ?? order.orderType) ?? null,
2054
+ limitPrice: this.firstNumber(order.limitPrice, order.limit_price, order.price) ?? null,
2055
+ stopPrice: this.firstNumber(order.stopPrice) ?? null,
2056
+ enteredAt: orderTime,
2057
+ updatedAt: order.lastExecutionTime_r !== undefined || order.lastExecutionTime !== undefined
2058
+ ? orderTime
2059
+ : null,
2060
+ legs,
2061
+ uncertainty,
2062
+ };
2063
+ }
2064
+ normalizeActiveDerivativeLegs(order, total, orderUncertainty) {
2065
+ const description = [
2066
+ order.orderDescriptionWithContract,
2067
+ order.order_description_with_contract,
2068
+ order.contractDescription1,
2069
+ order.contract_description_1,
2070
+ order.description1,
2071
+ order.symbol,
2072
+ ]
2073
+ .filter((value) => typeof value === "string")
2074
+ .join(" ");
2075
+ const describedOptions = [...description.matchAll(/([A-Z ]{1,6}\d{6}[CP]\d{8})/gi)].flatMap((match) => {
2076
+ const symbol = match[1]?.toUpperCase() ?? "";
2077
+ const parsed = parseOsiOptionSymbol(symbol);
2078
+ return parsed === null ? [] : [{ symbol, ...parsed }];
2079
+ });
2080
+ const uniqueDescribedOptions = describedOptions.filter((option, index) => describedOptions.findIndex((candidate) => candidate.symbol === option.symbol) === index);
2081
+ const side = this.normalizeOrderSide(order.side);
2082
+ const signedSide = side === "BUY" ? 1 : side === "SELL" ? -1 : null;
2083
+ let rawLegs = [];
2084
+ const conidex = typeof order.conidex === "string" ? order.conidex.trim() : null;
2085
+ if (conidex?.includes(";;;")) {
2086
+ const match = /^(\d+)(?:@[A-Za-z0-9._-]+)?;;;(.+)$/.exec(conidex);
2087
+ if (match?.[1] !== "28812380") {
2088
+ orderUncertainty.push("MALFORMED_CONIDEX");
2089
+ }
2090
+ else {
2091
+ rawLegs = (match[2] ?? "").split(",").map((member) => {
2092
+ const legMatch = /^(\d+)\/([+-]?\d+)$/.exec(member.trim());
2093
+ const conid = Number(legMatch?.[1]);
2094
+ const ratio = Number(legMatch?.[2]);
2095
+ if (!legMatch ||
2096
+ !Number.isSafeInteger(conid) ||
2097
+ conid <= 0 ||
2098
+ !Number.isSafeInteger(ratio) ||
2099
+ ratio === 0) {
2100
+ return { conid: null, ratio: null, quantityRatio: null };
2101
+ }
2102
+ return {
2103
+ conid,
2104
+ ratio: signedSide === null ? null : signedSide * ratio,
2105
+ quantityRatio: Math.abs(ratio),
2106
+ };
2107
+ });
2108
+ if (rawLegs.length === 0 || rawLegs.some((leg) => leg.conid === null)) {
2109
+ orderUncertainty.push("MALFORMED_CONIDEX");
2110
+ }
2111
+ }
2112
+ if (rawLegs.length === 0)
2113
+ orderUncertainty.push("AGGREGATE_ONLY");
2114
+ }
2115
+ else if (Number.isSafeInteger(order.conid) && Number(order.conid) > 0) {
2116
+ rawLegs = [{ conid: Number(order.conid), ratio: signedSide, quantityRatio: 1 }];
2117
+ }
2118
+ else {
2119
+ if (conidex)
2120
+ orderUncertainty.push("MALFORMED_CONIDEX");
2121
+ orderUncertainty.push("MISSING_LEG_IDENTITY");
2122
+ }
2123
+ if (rawLegs.length === 0) {
2124
+ rawLegs = [{ conid: null, ratio: null, quantityRatio: null }];
2125
+ }
2126
+ return rawLegs.map((leg) => {
2127
+ const legUncertainty = [];
2128
+ if (leg.conid === null)
2129
+ legUncertainty.push("MISSING_LEG_IDENTITY");
2130
+ if (leg.ratio === null) {
2131
+ const directionUncertainty = leg.conid !== null && signedSide === null ? "UNKNOWN_SIDE" : "MALFORMED_CONIDEX";
2132
+ legUncertainty.push(directionUncertainty);
2133
+ if (!orderUncertainty.includes(directionUncertainty)) {
2134
+ orderUncertainty.push(directionUncertainty);
2135
+ }
2136
+ }
2137
+ return {
2138
+ conid: leg.conid,
2139
+ ratio: leg.ratio,
2140
+ side: leg.ratio === null ? "UNKNOWN" : leg.ratio > 0 ? "BUY" : "SELL",
2141
+ quantity: total === null || leg.quantityRatio === null ? null : total * leg.quantityRatio,
2142
+ option: rawLegs.length === 1 && uniqueDescribedOptions.length === 1
2143
+ ? (uniqueDescribedOptions[0] ?? null)
2144
+ : null,
2145
+ uncertainty: legUncertainty,
2146
+ };
2147
+ });
2148
+ }
2149
+ addOrderUncertainty(order, uncertainty) {
2150
+ if (!order.uncertainty.includes(uncertainty))
2151
+ order.uncertainty.push(uncertainty);
2152
+ }
1086
2153
  normalizeDerivativeExecution(accountId, trade) {
1087
2154
  if (!trade.execution_id || !Number.isSafeInteger(trade.conid) || Number(trade.conid) <= 0) {
1088
2155
  return undefined;
@@ -1130,8 +2197,39 @@ export class IbkrClient {
1130
2197
  const parsed = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
1131
2198
  return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
1132
2199
  }
2200
+ recoveryOrderId(order) {
2201
+ const identity = this.consistentScalarAliases(order.order_id, order.orderId);
2202
+ return identity.valid ? identity.value : undefined;
2203
+ }
2204
+ consistentScalarAliases(...aliases) {
2205
+ const provided = aliases.filter((alias) => alias !== undefined);
2206
+ if (provided.length === 0)
2207
+ return { valid: true, value: undefined };
2208
+ const normalized = provided.map((alias) => {
2209
+ if (typeof alias !== "string" && typeof alias !== "number")
2210
+ return undefined;
2211
+ const value = String(alias).trim();
2212
+ return value.length === 0 ? undefined : value;
2213
+ });
2214
+ const [first] = normalized;
2215
+ if (first === undefined || normalized.some((value) => value !== first)) {
2216
+ return { valid: false, value: undefined };
2217
+ }
2218
+ return { valid: true, value: first };
2219
+ }
2220
+ consistentStringAliases(...aliases) {
2221
+ const provided = aliases.filter((alias) => alias !== undefined);
2222
+ if (provided.length === 0)
2223
+ return { valid: true, value: undefined };
2224
+ const normalized = provided.map((alias) => typeof alias === "string" && alias.trim() !== "" ? alias.trim() : undefined);
2225
+ const [first] = normalized;
2226
+ if (first === undefined || normalized.some((value) => value !== first)) {
2227
+ return { valid: false, value: undefined };
2228
+ }
2229
+ return { valid: true, value: first };
2230
+ }
1133
2231
  trimmedString(value) {
1134
- if (value === undefined)
2232
+ if (typeof value !== "string")
1135
2233
  return null;
1136
2234
  const trimmed = value.trim();
1137
2235
  return trimmed.length === 0 ? null : trimmed;
@@ -1296,14 +2394,14 @@ export class IbkrClient {
1296
2394
  }
1297
2395
  normalizeDerivativeOrderStatus(value, filledQuantity, remainingQuantity) {
1298
2396
  const status = this.canonicalIbkrOrderStatus(value);
1299
- if (filledQuantity > 0 && remainingQuantity > 0)
1300
- return "PARTIALLY_FILLED";
1301
2397
  if (status === "FILLED")
1302
2398
  return "FILLED";
1303
2399
  if (status === "CANCELLED" || status === "CANCELED")
1304
2400
  return "CANCELED";
1305
2401
  if (status === "INACTIVE" || status === "REJECTED")
1306
2402
  return "REJECTED";
2403
+ if (filledQuantity > 0 && remainingQuantity > 0)
2404
+ return "PARTIALLY_FILLED";
1307
2405
  if (status === "API_PENDING" || status === "PENDING_SUBMIT")
1308
2406
  return "PENDING";
1309
2407
  if (status !== undefined && IBKR_WORKING_STATUSES.has(status))
@@ -1311,8 +2409,10 @@ export class IbkrClient {
1311
2409
  return "UNKNOWN";
1312
2410
  }
1313
2411
  canonicalIbkrOrderStatus(value) {
2412
+ if (typeof value !== "string")
2413
+ return undefined;
1314
2414
  return value
1315
- ?.replace(/([a-z])([A-Z])/g, "$1_$2")
2415
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
1316
2416
  .replace(/\s+/g, "_")
1317
2417
  .toUpperCase();
1318
2418
  }
@@ -1365,11 +2465,14 @@ export class IbkrClient {
1365
2465
  if (brokerageAccounts.accounts && !brokerageAccounts.accounts.includes(accountId)) {
1366
2466
  throw new Error(`IBKR account ${accountId} is not available for trading/order queries.`);
1367
2467
  }
1368
- await this.req({
2468
+ const switchedAccount = await this.req({
1369
2469
  path: "iserver/account",
1370
2470
  method: "POST",
1371
2471
  data: { acctId: accountId },
1372
2472
  });
2473
+ if (switchedAccount.set !== true || switchedAccount.acctId !== accountId) {
2474
+ throw new Error(`IBKR account switch was not confirmed for ${accountId}.`);
2475
+ }
1373
2476
  }
1374
2477
  normalizeStockListing(symbol, listing) {
1375
2478
  const assetType = listing.assetClass === "STK" ? "EQUITY" : listing.assetClass;