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