@huskly/ibkr-client 0.12.0 → 0.14.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 +81 -10
- package/dist/ibkr/ibkrApiTypes.d.ts +14 -0
- package/dist/ibkr/ibkrApiTypes.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.d.ts +43 -1
- package/dist/ibkr/ibkrClient.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.js +960 -49
- package/dist/ibkr/ibkrClient.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +221 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/ibkr/ibkrClient.js
CHANGED
|
@@ -268,6 +268,170 @@ export class IbkrClient {
|
|
|
268
268
|
});
|
|
269
269
|
return this.normalizeOrderSubmission(response, request.clientOrderId);
|
|
270
270
|
}
|
|
271
|
+
async submitDerivativeSingleOrder(request) {
|
|
272
|
+
this.validateSingleOrder(request);
|
|
273
|
+
const cmeOperatorMetadata = this.cmeOperatorMetadata(request.contract.assetClass, request);
|
|
274
|
+
const diagnostics = await this.getTradingDiagnostics(request.accountId);
|
|
275
|
+
if (!diagnostics.authenticated || diagnostics.competingSession) {
|
|
276
|
+
throw new Error("IBKR brokerage session is not safely authenticated for submission");
|
|
277
|
+
}
|
|
278
|
+
await this.prepareBrokerageAccount(request.accountId);
|
|
279
|
+
const response = await this.singleAttemptRequest({
|
|
280
|
+
path: `iserver/account/${request.accountId}/orders`,
|
|
281
|
+
method: "POST",
|
|
282
|
+
data: {
|
|
283
|
+
orders: [
|
|
284
|
+
{
|
|
285
|
+
...this.singleOrderTicket(request),
|
|
286
|
+
...(request.clientOrderId !== undefined ? { cOID: request.clientOrderId } : {}),
|
|
287
|
+
...(request.parentId !== undefined ? { parentId: request.parentId } : {}),
|
|
288
|
+
...cmeOperatorMetadata,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
return this.normalizeOrderSubmission(response, request.clientOrderId ?? null);
|
|
294
|
+
}
|
|
295
|
+
async submitDerivativeContingentOrders(request) {
|
|
296
|
+
const { accountId, parent, child } = request;
|
|
297
|
+
if (!accountId.trim())
|
|
298
|
+
throw new Error("An explicit IBKR account ID is required");
|
|
299
|
+
if (parent.accountId !== accountId || child.accountId !== accountId) {
|
|
300
|
+
throw new Error("Contingent parent and child orders must target the exact same account");
|
|
301
|
+
}
|
|
302
|
+
this.validateSingleOrderFields(parent);
|
|
303
|
+
this.validateSingleOrderFields(child);
|
|
304
|
+
if (typeof parent.clientOrderId !== "string" ||
|
|
305
|
+
!parent.clientOrderId.trim() ||
|
|
306
|
+
parent.clientOrderId.length > 64) {
|
|
307
|
+
throw new Error("Parent client order ID must contain 1 to 64 characters");
|
|
308
|
+
}
|
|
309
|
+
if ("clientOrderId" in child || "parentId" in child) {
|
|
310
|
+
throw new Error("Contingent child identity is derived from the parent order");
|
|
311
|
+
}
|
|
312
|
+
const parentMetadata = this.cmeOperatorMetadata(parent.contract.assetClass, parent);
|
|
313
|
+
const childMetadata = this.cmeOperatorMetadata(child.contract.assetClass, child);
|
|
314
|
+
const diagnostics = await this.getTradingDiagnostics(accountId);
|
|
315
|
+
if (!diagnostics.authenticated || diagnostics.competingSession) {
|
|
316
|
+
throw new Error("IBKR brokerage session is not safely authenticated for submission");
|
|
317
|
+
}
|
|
318
|
+
await this.prepareBrokerageAccount(accountId);
|
|
319
|
+
const response = await this.singleAttemptRequest({
|
|
320
|
+
path: `iserver/account/${accountId}/orders`,
|
|
321
|
+
method: "POST",
|
|
322
|
+
data: {
|
|
323
|
+
orders: [
|
|
324
|
+
{
|
|
325
|
+
...this.singleOrderTicket(parent),
|
|
326
|
+
cOID: parent.clientOrderId,
|
|
327
|
+
...parentMetadata,
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
...this.singleOrderTicket(child),
|
|
331
|
+
parentId: parent.clientOrderId,
|
|
332
|
+
...childMetadata,
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
return this.normalizeMultiOrderSubmission(response, parent.clientOrderId);
|
|
338
|
+
}
|
|
339
|
+
async submitDerivativeOrderGraph(request) {
|
|
340
|
+
this.validateOrderGraph(request);
|
|
341
|
+
const diagnostics = await this.getTradingDiagnostics(request.accountId);
|
|
342
|
+
if (!diagnostics.authenticated || diagnostics.competingSession) {
|
|
343
|
+
throw new Error("IBKR brokerage session is not safely authenticated for submission");
|
|
344
|
+
}
|
|
345
|
+
await this.prepareBrokerageAccount(request.accountId);
|
|
346
|
+
const response = await this.singleAttemptRequest({
|
|
347
|
+
path: `iserver/account/${request.accountId}/orders`,
|
|
348
|
+
method: "POST",
|
|
349
|
+
data: { orders: request.nodes.map((node) => this.graphOrderTicket(request, node)) },
|
|
350
|
+
});
|
|
351
|
+
return this.normalizeOrderGraphSubmission(response, request, []);
|
|
352
|
+
}
|
|
353
|
+
async acknowledgeDerivativeOrderGraphWarning(input) {
|
|
354
|
+
if (input.confirmed !== true)
|
|
355
|
+
throw new Error("Order warning confirmation must be true");
|
|
356
|
+
this.validateOrderGraph(input.continuation.request);
|
|
357
|
+
if (!input.continuation.replyId.trim())
|
|
358
|
+
throw new Error("An exact warning reply ID is required");
|
|
359
|
+
const diagnostics = await this.getTradingDiagnostics(input.continuation.request.accountId);
|
|
360
|
+
if (!diagnostics.authenticated || diagnostics.competingSession) {
|
|
361
|
+
throw new Error("IBKR brokerage session is not safely authenticated for submission");
|
|
362
|
+
}
|
|
363
|
+
await this.prepareBrokerageAccount(input.continuation.request.accountId);
|
|
364
|
+
const response = await this.singleAttemptRequest({
|
|
365
|
+
path: `iserver/reply/${encodeURIComponent(input.continuation.replyId)}`,
|
|
366
|
+
method: "POST",
|
|
367
|
+
data: { confirmed: true },
|
|
368
|
+
});
|
|
369
|
+
return this.normalizeOrderGraphSubmission(response, input.continuation.request, input.continuation.members);
|
|
370
|
+
}
|
|
371
|
+
async recoverDerivativeOrderGraph(input, request) {
|
|
372
|
+
this.validateOrderGraph(request);
|
|
373
|
+
if (input.accountId !== request.accountId)
|
|
374
|
+
throw new Error("Graph recovery account does not match request");
|
|
375
|
+
await this.prepareBrokerageAccount(input.accountId);
|
|
376
|
+
const response = await this.req({
|
|
377
|
+
path: "iserver/account/orders",
|
|
378
|
+
params: { force: true, accountId: input.accountId },
|
|
379
|
+
});
|
|
380
|
+
const accountOrders = (response.orders ?? []).filter((order) => this.orderBelongsToAccount(order, input.accountId));
|
|
381
|
+
const byMemberId = new Map();
|
|
382
|
+
for (const node of request.nodes) {
|
|
383
|
+
const matches = accountOrders.filter((order) => this.liveOrderMatchesGraphNode(request, node, order));
|
|
384
|
+
const [match] = matches;
|
|
385
|
+
if (matches.length === 1 && match !== undefined)
|
|
386
|
+
byMemberId.set(node.memberId, match);
|
|
387
|
+
}
|
|
388
|
+
const candidates = [...byMemberId.values()];
|
|
389
|
+
const linkedOrders = accountOrders.filter((order) => (order.cOID ?? order.order_ref) === request.rootClientOrderId ||
|
|
390
|
+
String(order.parentId ?? "") === request.rootClientOrderId);
|
|
391
|
+
const candidateSet = new Set(candidates);
|
|
392
|
+
if (input.orderId !== undefined &&
|
|
393
|
+
!candidates.some((order) => String(order.order_id ?? order.orderId) === input.orderId)) {
|
|
394
|
+
throw new Error(`Broker order ${input.orderId} is not a member of the requested graph`);
|
|
395
|
+
}
|
|
396
|
+
if (input.rootClientOrderId !== undefined &&
|
|
397
|
+
input.rootClientOrderId !== request.rootClientOrderId) {
|
|
398
|
+
throw new Error("Root client order ID does not match the requested graph");
|
|
399
|
+
}
|
|
400
|
+
const members = request.nodes.map((node) => {
|
|
401
|
+
const order = byMemberId.get(node.memberId);
|
|
402
|
+
return this.graphMemberEvidence(request, node, order);
|
|
403
|
+
});
|
|
404
|
+
const ids = members.flatMap(({ orderId }) => (orderId === null ? [] : [orderId]));
|
|
405
|
+
const brokerParentsMatch = request.nodes.every((node) => {
|
|
406
|
+
if (node.parentMemberId === undefined)
|
|
407
|
+
return true;
|
|
408
|
+
return (byMemberId.get(node.memberId) !== undefined &&
|
|
409
|
+
String(byMemberId.get(node.memberId)?.parentId ?? "") === request.rootClientOrderId);
|
|
410
|
+
});
|
|
411
|
+
if (candidates.length !== request.nodes.length ||
|
|
412
|
+
linkedOrders.length !== request.nodes.length ||
|
|
413
|
+
linkedOrders.some((order) => !candidateSet.has(order)) ||
|
|
414
|
+
new Set(ids).size !== request.nodes.length ||
|
|
415
|
+
!brokerParentsMatch) {
|
|
416
|
+
return {
|
|
417
|
+
state: "recovery_required",
|
|
418
|
+
rootClientOrderId: request.rootClientOrderId,
|
|
419
|
+
members,
|
|
420
|
+
reasons: [
|
|
421
|
+
"Exact graph recovery found incomplete, duplicated, or ambiguous member evidence",
|
|
422
|
+
],
|
|
423
|
+
warnings: [],
|
|
424
|
+
errors: [],
|
|
425
|
+
unrecognizedResponses: [],
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
state: "accepted",
|
|
430
|
+
rootClientOrderId: request.rootClientOrderId,
|
|
431
|
+
members: this.attachGraphParentOrderIds(members),
|
|
432
|
+
warnings: [],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
271
435
|
async acknowledgeOrderWarning(input) {
|
|
272
436
|
if (!input.replyId.trim())
|
|
273
437
|
throw new Error("An exact warning reply ID is required");
|
|
@@ -278,6 +442,24 @@ export class IbkrClient {
|
|
|
278
442
|
});
|
|
279
443
|
return this.normalizeOrderSubmission(response, null);
|
|
280
444
|
}
|
|
445
|
+
async acknowledgeContingentOrderWarning(input) {
|
|
446
|
+
const confirmed = input.confirmed;
|
|
447
|
+
if (confirmed !== true) {
|
|
448
|
+
throw new Error("Order warning confirmation must be true");
|
|
449
|
+
}
|
|
450
|
+
if (!input.continuation.replyId.trim()) {
|
|
451
|
+
throw new Error("An exact warning reply ID is required");
|
|
452
|
+
}
|
|
453
|
+
if (!input.continuation.parentClientOrderId.trim()) {
|
|
454
|
+
throw new Error("An exact parent client order ID is required");
|
|
455
|
+
}
|
|
456
|
+
const response = await this.singleAttemptRequest({
|
|
457
|
+
path: `iserver/reply/${encodeURIComponent(input.continuation.replyId)}`,
|
|
458
|
+
method: "POST",
|
|
459
|
+
data: { confirmed: true },
|
|
460
|
+
});
|
|
461
|
+
return this.normalizeMultiOrderSubmission(response, input.continuation.parentClientOrderId);
|
|
462
|
+
}
|
|
281
463
|
async getDerivativeOrderStatus(accountId, orderId) {
|
|
282
464
|
if (!accountId.trim() || !orderId.trim()) {
|
|
283
465
|
throw new Error("Exact account and order IDs are required");
|
|
@@ -325,6 +507,59 @@ export class IbkrClient {
|
|
|
325
507
|
}
|
|
326
508
|
return this.getDerivativeOrderStatus(input.accountId, String(orderId));
|
|
327
509
|
}
|
|
510
|
+
async listActiveDerivativeOrders(accountId) {
|
|
511
|
+
if (!accountId.trim())
|
|
512
|
+
throw new Error("An exact account ID is required");
|
|
513
|
+
await this.prepareBrokerageAccount(accountId);
|
|
514
|
+
const response = await this.req({
|
|
515
|
+
path: "iserver/account/orders",
|
|
516
|
+
params: { force: true, accountId },
|
|
517
|
+
});
|
|
518
|
+
if (response.snapshot !== true || !Array.isArray(response.orders)) {
|
|
519
|
+
throw new Error("IBKR active-order snapshot is incomplete");
|
|
520
|
+
}
|
|
521
|
+
const flattened = this.flattenActiveOrders(response.orders);
|
|
522
|
+
const invalidAccountEvidence = flattened.find(({ order }) => {
|
|
523
|
+
const returnedAccounts = [order.account, order.acct];
|
|
524
|
+
const providedAccounts = returnedAccounts.filter((value) => value !== undefined);
|
|
525
|
+
return (providedAccounts.length === 0 ||
|
|
526
|
+
providedAccounts.some((returnedAccount) => typeof returnedAccount !== "string" || returnedAccount !== accountId));
|
|
527
|
+
});
|
|
528
|
+
if (invalidAccountEvidence !== undefined) {
|
|
529
|
+
throw new Error("IBKR active-order response did not provide unambiguous account identity");
|
|
530
|
+
}
|
|
531
|
+
const normalized = flattened.map(({ order, nestedParent }) => this.normalizeActiveDerivativeOrder(accountId, order, nestedParent));
|
|
532
|
+
const byOrderId = new Map();
|
|
533
|
+
const byClientId = new Map();
|
|
534
|
+
for (const order of normalized) {
|
|
535
|
+
if (order.orderId !== null) {
|
|
536
|
+
const members = byOrderId.get(order.orderId) ?? [];
|
|
537
|
+
members.push(order);
|
|
538
|
+
byOrderId.set(order.orderId, members);
|
|
539
|
+
}
|
|
540
|
+
if (order.clientOrderId !== null) {
|
|
541
|
+
const members = byClientId.get(order.clientOrderId) ?? [];
|
|
542
|
+
members.push(order);
|
|
543
|
+
byClientId.set(order.clientOrderId, members);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
for (const order of normalized) {
|
|
547
|
+
if (order.orderId !== null && (byOrderId.get(order.orderId)?.length ?? 0) > 1) {
|
|
548
|
+
this.addOrderUncertainty(order, "DUPLICATE_MEMBER");
|
|
549
|
+
}
|
|
550
|
+
const parentIdentity = order.parentOrderId ?? order.parentClientOrderId;
|
|
551
|
+
if (parentIdentity === null)
|
|
552
|
+
continue;
|
|
553
|
+
const brokerMatches = byOrderId.get(parentIdentity) ?? [];
|
|
554
|
+
const clientMatches = byClientId.get(parentIdentity) ?? [];
|
|
555
|
+
const matches = new Set([...brokerMatches, ...clientMatches]);
|
|
556
|
+
if (matches.size === 0)
|
|
557
|
+
this.addOrderUncertainty(order, "MISSING_PARENT");
|
|
558
|
+
if (matches.size > 1)
|
|
559
|
+
this.addOrderUncertainty(order, "AMBIGUOUS_PARENT");
|
|
560
|
+
}
|
|
561
|
+
return normalized;
|
|
562
|
+
}
|
|
328
563
|
async getDerivativeExecutions(input) {
|
|
329
564
|
if (!input.accountId.trim())
|
|
330
565
|
throw new Error("An exact account ID is required");
|
|
@@ -616,6 +851,193 @@ export class IbkrClient {
|
|
|
616
851
|
}
|
|
617
852
|
}
|
|
618
853
|
}
|
|
854
|
+
validateOrderGraph(request) {
|
|
855
|
+
if (!request.accountId.trim())
|
|
856
|
+
throw new Error("An explicit IBKR account ID is required");
|
|
857
|
+
if (!request.rootClientOrderId.trim() || request.rootClientOrderId.length > 48) {
|
|
858
|
+
throw new Error("Root client order ID must contain 1 to 48 characters");
|
|
859
|
+
}
|
|
860
|
+
if (request.nodes.length < 1 || request.nodes.length > 8) {
|
|
861
|
+
throw new Error("Derivative order graphs require 1 to 8 members");
|
|
862
|
+
}
|
|
863
|
+
const seen = new Set();
|
|
864
|
+
let roots = 0;
|
|
865
|
+
for (const node of request.nodes) {
|
|
866
|
+
if (!node.memberId.trim() ||
|
|
867
|
+
node.memberId.length > 15 ||
|
|
868
|
+
!/^[A-Za-z0-9_-]+$/.test(node.memberId)) {
|
|
869
|
+
throw new Error("Graph member IDs must contain 1 to 15 safe characters");
|
|
870
|
+
}
|
|
871
|
+
if (seen.has(node.memberId))
|
|
872
|
+
throw new Error("Graph member IDs must be unique");
|
|
873
|
+
if (node.accountId !== request.accountId)
|
|
874
|
+
throw new Error("Every graph member must target the graph account");
|
|
875
|
+
if (node.parentMemberId === undefined)
|
|
876
|
+
roots += 1;
|
|
877
|
+
else if (!seen.has(node.parentMemberId))
|
|
878
|
+
throw new Error("Graph parents must precede their children");
|
|
879
|
+
else if (node.parentMemberId !== request.nodes[0]?.memberId)
|
|
880
|
+
throw new Error("Derivative order graphs support only root-to-child attachments");
|
|
881
|
+
if ("legs" in node) {
|
|
882
|
+
this.validateComboPreview(node);
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
this.validateGraphSingleNode(node);
|
|
886
|
+
}
|
|
887
|
+
seen.add(node.memberId);
|
|
888
|
+
}
|
|
889
|
+
if (roots !== 1 || request.nodes[0]?.parentMemberId !== undefined) {
|
|
890
|
+
throw new Error("Derivative order graphs require exactly one root as the first member");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
validateGraphSingleNode(node) {
|
|
894
|
+
const orderType = node.orderType;
|
|
895
|
+
if (orderType !== "LMT" && orderType !== "STP" && orderType !== "MKT") {
|
|
896
|
+
throw new Error("Graph single order type must be LMT, STP, or MKT");
|
|
897
|
+
}
|
|
898
|
+
if (!Number.isSafeInteger(node.quantity) || node.quantity <= 0)
|
|
899
|
+
throw new Error("Order quantity must be a positive integer");
|
|
900
|
+
if (!Number.isSafeInteger(node.contract.conid) || node.contract.conid <= 0)
|
|
901
|
+
throw new Error("Order contract has an invalid IBKR conid");
|
|
902
|
+
if (node.orderType === "LMT" && (!Number.isFinite(node.limit) || node.limit <= 0))
|
|
903
|
+
throw new Error("LIMIT order requires a positive limit price");
|
|
904
|
+
if (node.orderType === "STP" && (!Number.isFinite(node.stopPrice) || node.stopPrice <= 0))
|
|
905
|
+
throw new Error("STOP order requires a positive stop price");
|
|
906
|
+
this.cmeOperatorMetadata(node.contract.assetClass, node);
|
|
907
|
+
}
|
|
908
|
+
graphClientOrderId(request, node) {
|
|
909
|
+
return node.parentMemberId === undefined
|
|
910
|
+
? request.rootClientOrderId
|
|
911
|
+
: `${request.rootClientOrderId}:${node.memberId}`;
|
|
912
|
+
}
|
|
913
|
+
graphOrderTicket(request, node) {
|
|
914
|
+
const parent = request.nodes.find(({ memberId }) => memberId === node.parentMemberId);
|
|
915
|
+
if (node.parentMemberId !== undefined && parent === undefined) {
|
|
916
|
+
throw new Error("Graph parent evidence was lost after validation");
|
|
917
|
+
}
|
|
918
|
+
const identity = parent === undefined
|
|
919
|
+
? { cOID: this.graphClientOrderId(request, node) }
|
|
920
|
+
: { parentId: this.graphClientOrderId(request, parent) };
|
|
921
|
+
if ("legs" in node)
|
|
922
|
+
return {
|
|
923
|
+
...this.comboOrderTicket(node),
|
|
924
|
+
...identity,
|
|
925
|
+
...this.cmeOperatorMetadata(node.legs[0].contract.assetClass, node),
|
|
926
|
+
};
|
|
927
|
+
return {
|
|
928
|
+
acctId: node.accountId,
|
|
929
|
+
conid: node.contract.conid,
|
|
930
|
+
orderType: node.orderType,
|
|
931
|
+
side: node.side,
|
|
932
|
+
...(node.orderType === "LMT"
|
|
933
|
+
? { price: node.limit }
|
|
934
|
+
: node.orderType === "STP"
|
|
935
|
+
? { price: node.stopPrice }
|
|
936
|
+
: {}),
|
|
937
|
+
tif: node.tif,
|
|
938
|
+
quantity: node.quantity,
|
|
939
|
+
outsideRTH: node.session === "OVERNIGHT",
|
|
940
|
+
...identity,
|
|
941
|
+
...this.cmeOperatorMetadata(node.contract.assetClass, node),
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
liveOrderMatchesGraphNode(request, node, order) {
|
|
945
|
+
if (node.parentMemberId === undefined) {
|
|
946
|
+
if ((order.cOID ?? order.order_ref) !== request.rootClientOrderId)
|
|
947
|
+
return false;
|
|
948
|
+
if (String(order.parentId ?? "").trim() !== "")
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
else if (String(order.parentId ?? "") !== request.rootClientOrderId)
|
|
952
|
+
return false;
|
|
953
|
+
if ("legs" in node) {
|
|
954
|
+
const liveLegs = this.parseComboLegs(order.conidex);
|
|
955
|
+
const orderType = this.normalizeOrderType(order.order_type ?? order.orderType);
|
|
956
|
+
const side = this.normalizeOrderSide(order.side);
|
|
957
|
+
const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size);
|
|
958
|
+
const price = this.firstNumber(order.limitPrice, order.limit_price, order.price);
|
|
959
|
+
const expectedPrice = node.priceEffect === "CREDIT" ? -node.limit : node.limit;
|
|
960
|
+
const outsideRth = order.outsideRTH ?? order.outside_rth;
|
|
961
|
+
return (liveLegs.length === node.legs.length &&
|
|
962
|
+
node.legs.every((leg, index) => {
|
|
963
|
+
const liveLeg = liveLegs[index];
|
|
964
|
+
return liveLeg?.conid === leg.contract.conid && liveLeg.ratio === leg.ratio;
|
|
965
|
+
}) &&
|
|
966
|
+
orderType === this.normalizeOrderType("LMT") &&
|
|
967
|
+
side === "BUY" &&
|
|
968
|
+
quantity === node.quantity &&
|
|
969
|
+
price === expectedPrice &&
|
|
970
|
+
(order.tif === undefined || order.tif.toUpperCase() === node.tif) &&
|
|
971
|
+
(outsideRth === undefined || outsideRth === (node.session === "OVERNIGHT")));
|
|
972
|
+
}
|
|
973
|
+
const orderType = this.normalizeOrderType(order.order_type ?? order.orderType);
|
|
974
|
+
const expectedOrderType = this.normalizeOrderType(node.orderType);
|
|
975
|
+
const side = this.normalizeOrderSide(order.side);
|
|
976
|
+
const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size);
|
|
977
|
+
const price = node.orderType === "LMT"
|
|
978
|
+
? this.firstNumber(order.limitPrice, order.limit_price, order.price)
|
|
979
|
+
: node.orderType === "STP"
|
|
980
|
+
? this.firstNumber(order.stopPrice, order.price)
|
|
981
|
+
: undefined;
|
|
982
|
+
const expectedPrice = node.orderType === "LMT" ? node.limit : node.orderType === "STP" ? node.stopPrice : undefined;
|
|
983
|
+
const outsideRth = order.outsideRTH ?? order.outside_rth;
|
|
984
|
+
return (order.conid === node.contract.conid &&
|
|
985
|
+
orderType === expectedOrderType &&
|
|
986
|
+
side === node.side &&
|
|
987
|
+
quantity === node.quantity &&
|
|
988
|
+
price === expectedPrice &&
|
|
989
|
+
(order.tif === undefined || order.tif.toUpperCase() === node.tif) &&
|
|
990
|
+
(outsideRth === undefined || outsideRth === (node.session === "OVERNIGHT")));
|
|
991
|
+
}
|
|
992
|
+
validateSingleOrder(request) {
|
|
993
|
+
this.validateSingleOrderFields(request);
|
|
994
|
+
const identityFields = request;
|
|
995
|
+
const hasClientOrderId = "clientOrderId" in identityFields;
|
|
996
|
+
const hasParentId = "parentId" in identityFields;
|
|
997
|
+
if (hasClientOrderId && typeof identityFields.clientOrderId !== "string") {
|
|
998
|
+
throw new Error("Client order ID must be a string");
|
|
999
|
+
}
|
|
1000
|
+
if (hasParentId && typeof identityFields.parentId !== "string") {
|
|
1001
|
+
throw new Error("Parent order ID must be a string");
|
|
1002
|
+
}
|
|
1003
|
+
const clientOrderId = hasClientOrderId ? identityFields.clientOrderId : undefined;
|
|
1004
|
+
const parentId = hasParentId ? identityFields.parentId : undefined;
|
|
1005
|
+
if (clientOrderId !== undefined && parentId !== undefined) {
|
|
1006
|
+
throw new Error("Attached child orders must not include a client order ID");
|
|
1007
|
+
}
|
|
1008
|
+
const identity = clientOrderId ?? parentId;
|
|
1009
|
+
if (!identity?.trim() || identity.length > 64) {
|
|
1010
|
+
throw new Error(parentId === undefined
|
|
1011
|
+
? "Client order ID must contain 1 to 64 characters"
|
|
1012
|
+
: "Parent order ID must contain 1 to 64 characters");
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
validateSingleOrderFields(request) {
|
|
1016
|
+
if (!request.accountId.trim())
|
|
1017
|
+
throw new Error("An explicit IBKR account ID is required");
|
|
1018
|
+
const orderType = request.orderType;
|
|
1019
|
+
if (orderType !== "LMT" && orderType !== "STP") {
|
|
1020
|
+
throw new Error("Order type must be LMT or STP");
|
|
1021
|
+
}
|
|
1022
|
+
if (!Number.isSafeInteger(request.quantity) || request.quantity <= 0) {
|
|
1023
|
+
throw new Error("Order quantity must be a positive integer");
|
|
1024
|
+
}
|
|
1025
|
+
if (!Number.isSafeInteger(request.contract.conid) || request.contract.conid <= 0) {
|
|
1026
|
+
throw new Error("Order contract has an invalid IBKR conid");
|
|
1027
|
+
}
|
|
1028
|
+
if (request.orderType === "LMT") {
|
|
1029
|
+
const limit = request.limit;
|
|
1030
|
+
if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) {
|
|
1031
|
+
throw new Error("LIMIT order requires a positive limit price");
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (request.orderType === "STP") {
|
|
1035
|
+
const stopPrice = request.stopPrice;
|
|
1036
|
+
if (typeof stopPrice !== "number" || !Number.isFinite(stopPrice) || stopPrice <= 0) {
|
|
1037
|
+
throw new Error("STOP order requires a positive stop price");
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
619
1041
|
cmeOperatorMetadata(assetClass, input) {
|
|
620
1042
|
if (assetClass === "OPT")
|
|
621
1043
|
return {};
|
|
@@ -643,58 +1065,366 @@ export class IbkrClient {
|
|
|
643
1065
|
outsideRTH: request.session === "OVERNIGHT",
|
|
644
1066
|
};
|
|
645
1067
|
}
|
|
1068
|
+
singleOrderTicket(request) {
|
|
1069
|
+
return {
|
|
1070
|
+
acctId: request.accountId,
|
|
1071
|
+
conid: request.contract.conid,
|
|
1072
|
+
orderType: request.orderType,
|
|
1073
|
+
side: request.side,
|
|
1074
|
+
price: request.orderType === "LMT" ? request.limit : request.stopPrice,
|
|
1075
|
+
tif: request.tif,
|
|
1076
|
+
quantity: request.quantity,
|
|
1077
|
+
outsideRTH: request.session === "OVERNIGHT",
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
646
1080
|
normalizeOrderSubmission(response, clientOrderId) {
|
|
1081
|
+
const decoded = this.decodeOrderSubmission(response);
|
|
1082
|
+
const correlatedClientOrderId = decoded.orders.length === 1 ? clientOrderId : null;
|
|
1083
|
+
const orders = decoded.orders.map((order) => ({
|
|
1084
|
+
...order,
|
|
1085
|
+
clientOrderId: correlatedClientOrderId,
|
|
1086
|
+
}));
|
|
1087
|
+
const hasWarnings = decoded.warnings.length > 0;
|
|
1088
|
+
const hasErrors = decoded.errors.length > 0;
|
|
1089
|
+
const hasUnknown = decoded.unrecognizedResponses.length > 0;
|
|
1090
|
+
const hasPendingCancel = decoded.pendingCancelOrderIds.length > 0;
|
|
1091
|
+
if (decoded.warnings.length === 1 && !hasErrors && orders.length === 0 && !hasUnknown) {
|
|
1092
|
+
return { state: "warning", warnings: decoded.warnings };
|
|
1093
|
+
}
|
|
1094
|
+
if (hasErrors &&
|
|
1095
|
+
!decoded.responseIsArray &&
|
|
1096
|
+
!hasWarnings &&
|
|
1097
|
+
orders.length === 0 &&
|
|
1098
|
+
!hasUnknown) {
|
|
1099
|
+
return {
|
|
1100
|
+
state: "rejected",
|
|
1101
|
+
reasons: decoded.errors.map(({ message }) => message),
|
|
1102
|
+
errors: decoded.errors,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
if (!hasWarnings && !hasErrors && !hasUnknown && !hasPendingCancel && orders.length === 1) {
|
|
1106
|
+
const order = orders[0];
|
|
1107
|
+
if (order === undefined)
|
|
1108
|
+
throw new Error("Single-order normalization lost order evidence");
|
|
1109
|
+
if (order.status === "REJECTED" || order.status === "CANCELED") {
|
|
1110
|
+
return {
|
|
1111
|
+
state: "rejected",
|
|
1112
|
+
reasons: [`Order ${order.orderId} returned terminal status ${order.status}`],
|
|
1113
|
+
errors: [],
|
|
1114
|
+
orders,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
if (order.status !== "UNKNOWN") {
|
|
1118
|
+
return { state: "accepted", ...order, warnings: [] };
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return this.singleOrderRecoveryResult(decoded, orders);
|
|
1122
|
+
}
|
|
1123
|
+
graphMemberEvidence(request, node, order) {
|
|
1124
|
+
const index = request.nodes.findIndex(({ memberId }) => memberId === node.memberId);
|
|
1125
|
+
let depth = 0;
|
|
1126
|
+
let parentId = node.parentMemberId;
|
|
1127
|
+
while (parentId !== undefined) {
|
|
1128
|
+
depth += 1;
|
|
1129
|
+
parentId = request.nodes.find(({ memberId }) => memberId === parentId)?.parentMemberId;
|
|
1130
|
+
}
|
|
1131
|
+
const rawId = order?.order_id ?? order?.orderId;
|
|
1132
|
+
const orderId = typeof rawId === "string" || typeof rawId === "number" ? String(rawId).trim() || null : null;
|
|
1133
|
+
const filledQuantity = this.firstNumber(order?.cum_fill, order?.cumFill, order?.filledQuantity, order?.filled);
|
|
1134
|
+
const quantity = this.firstPositiveNumber(order?.total_size, order?.totalSize, order?.size);
|
|
1135
|
+
const remainingQuantity = this.firstNumber(order?.remainingQuantity, order?.remaining_size, order?.remaining) ??
|
|
1136
|
+
(quantity !== undefined && filledQuantity !== undefined
|
|
1137
|
+
? Math.max(0, quantity - filledQuantity)
|
|
1138
|
+
: undefined);
|
|
1139
|
+
return {
|
|
1140
|
+
memberId: node.memberId,
|
|
1141
|
+
role: index < 0
|
|
1142
|
+
? "unknown"
|
|
1143
|
+
: depth === 0
|
|
1144
|
+
? "root"
|
|
1145
|
+
: depth === 1
|
|
1146
|
+
? "child"
|
|
1147
|
+
: depth === 2
|
|
1148
|
+
? "grandchild"
|
|
1149
|
+
: "descendant",
|
|
1150
|
+
parentMemberId: node.parentMemberId ?? null,
|
|
1151
|
+
parentOrderId: null,
|
|
1152
|
+
orderId,
|
|
1153
|
+
status: order === undefined
|
|
1154
|
+
? "WARNING_PENDING"
|
|
1155
|
+
: this.normalizeDerivativeOrderStatus(order.order_status ?? order.orderStatus ?? order.status, filledQuantity ?? 0, remainingQuantity ?? 0),
|
|
1156
|
+
clientOrderId: this.graphClientOrderId(request, node),
|
|
1157
|
+
request: node,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
attachGraphParentOrderIds(members) {
|
|
1161
|
+
const ids = new Map(members.map(({ memberId, orderId }) => [memberId, orderId]));
|
|
1162
|
+
return members.map((member) => ({
|
|
1163
|
+
...member,
|
|
1164
|
+
parentOrderId: member.parentMemberId === null ? null : (ids.get(member.parentMemberId) ?? null),
|
|
1165
|
+
}));
|
|
1166
|
+
}
|
|
1167
|
+
normalizeOrderGraphSubmission(response, request, previousMembers) {
|
|
1168
|
+
const decoded = this.decodeOrderSubmission(response);
|
|
1169
|
+
const cleanOrders = decoded.warnings.length === 0 &&
|
|
1170
|
+
decoded.errors.length === 0 &&
|
|
1171
|
+
decoded.unrecognizedResponses.length === 0;
|
|
1172
|
+
const distinct = new Set(decoded.orders.map(({ orderId }) => orderId)).size === decoded.orders.length;
|
|
1173
|
+
const canCorrelatePositionally = cleanOrders && distinct && decoded.orders.length === request.nodes.length;
|
|
1174
|
+
const members = this.attachGraphParentOrderIds(request.nodes.map((node, index) => {
|
|
1175
|
+
const order = canCorrelatePositionally ? decoded.orders[index] : undefined;
|
|
1176
|
+
if (order === undefined)
|
|
1177
|
+
return (previousMembers.find(({ memberId }) => memberId === node.memberId) ??
|
|
1178
|
+
this.graphMemberEvidence(request, node));
|
|
1179
|
+
const evidence = this.graphMemberEvidence(request, node, {
|
|
1180
|
+
order_id: order.orderId,
|
|
1181
|
+
order_status: order.status,
|
|
1182
|
+
});
|
|
1183
|
+
return { ...evidence, status: order.status };
|
|
1184
|
+
}));
|
|
1185
|
+
if (decoded.warnings.length === 1 &&
|
|
1186
|
+
decoded.orders.length === 0 &&
|
|
1187
|
+
decoded.errors.length === 0 &&
|
|
1188
|
+
decoded.unrecognizedResponses.length === 0) {
|
|
1189
|
+
const warning = decoded.warnings[0];
|
|
1190
|
+
if (warning === undefined)
|
|
1191
|
+
throw new Error("Graph warning evidence was lost");
|
|
1192
|
+
return {
|
|
1193
|
+
state: "warning",
|
|
1194
|
+
rootClientOrderId: request.rootClientOrderId,
|
|
1195
|
+
members,
|
|
1196
|
+
warnings: decoded.warnings,
|
|
1197
|
+
continuation: { replyId: warning.replyId, request, members },
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
if (decoded.errors.length > 0 &&
|
|
1201
|
+
!decoded.responseIsArray &&
|
|
1202
|
+
decoded.orders.length === 0 &&
|
|
1203
|
+
decoded.warnings.length === 0 &&
|
|
1204
|
+
decoded.unrecognizedResponses.length === 0) {
|
|
1205
|
+
return {
|
|
1206
|
+
state: "rejected",
|
|
1207
|
+
rootClientOrderId: request.rootClientOrderId,
|
|
1208
|
+
members,
|
|
1209
|
+
reasons: decoded.errors.map(({ message }) => message),
|
|
1210
|
+
errors: decoded.errors,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
if (cleanOrders &&
|
|
1214
|
+
distinct &&
|
|
1215
|
+
decoded.orders.length === request.nodes.length &&
|
|
1216
|
+
decoded.pendingCancelOrderIds.length === 0 &&
|
|
1217
|
+
decoded.orders.every(({ status }) => status !== "UNKNOWN" && status !== "REJECTED" && status !== "CANCELED")) {
|
|
1218
|
+
return {
|
|
1219
|
+
state: "accepted",
|
|
1220
|
+
rootClientOrderId: request.rootClientOrderId,
|
|
1221
|
+
members,
|
|
1222
|
+
warnings: [],
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
return {
|
|
1226
|
+
state: "recovery_required",
|
|
1227
|
+
rootClientOrderId: request.rootClientOrderId,
|
|
1228
|
+
members,
|
|
1229
|
+
reasons: [
|
|
1230
|
+
this.submissionRecoveryReason(decoded, decoded.orders.length, request.nodes.length),
|
|
1231
|
+
],
|
|
1232
|
+
warnings: decoded.warnings,
|
|
1233
|
+
errors: decoded.errors,
|
|
1234
|
+
unrecognizedResponses: canCorrelatePositionally
|
|
1235
|
+
? decoded.unrecognizedResponses
|
|
1236
|
+
: [...decoded.unrecognizedResponses, ...decoded.orders],
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
normalizeMultiOrderSubmission(response, parentClientOrderId) {
|
|
1240
|
+
const decoded = this.decodeOrderSubmission(response);
|
|
1241
|
+
const hasDistinctBrokerOrderIds = decoded.orders.length === 2 &&
|
|
1242
|
+
new Set(decoded.orders.map(({ orderId }) => orderId)).size === 2;
|
|
1243
|
+
const rolesArePositionallyComplete = decoded.orders.length === 2 &&
|
|
1244
|
+
hasDistinctBrokerOrderIds &&
|
|
1245
|
+
decoded.warnings.length === 0 &&
|
|
1246
|
+
decoded.errors.length === 0 &&
|
|
1247
|
+
decoded.unrecognizedResponses.length === 0;
|
|
1248
|
+
const orders = decoded.orders.map((order, index) => ({
|
|
1249
|
+
...order,
|
|
1250
|
+
clientOrderId: rolesArePositionallyComplete && index === 0 ? parentClientOrderId : null,
|
|
1251
|
+
role: rolesArePositionallyComplete && index === 0
|
|
1252
|
+
? "parent"
|
|
1253
|
+
: rolesArePositionallyComplete && index === 1
|
|
1254
|
+
? "child"
|
|
1255
|
+
: "unknown",
|
|
1256
|
+
}));
|
|
1257
|
+
const hasWarnings = decoded.warnings.length > 0;
|
|
1258
|
+
const hasErrors = decoded.errors.length > 0;
|
|
1259
|
+
const hasUnknown = decoded.unrecognizedResponses.length > 0;
|
|
1260
|
+
if (decoded.warnings.length === 1 && !hasErrors && orders.length === 0 && !hasUnknown) {
|
|
1261
|
+
const warning = decoded.warnings[0];
|
|
1262
|
+
if (warning === undefined)
|
|
1263
|
+
throw new Error("Contingent warning evidence was lost");
|
|
1264
|
+
return {
|
|
1265
|
+
state: "warning",
|
|
1266
|
+
warnings: decoded.warnings,
|
|
1267
|
+
continuation: { replyId: warning.replyId, parentClientOrderId },
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
if (hasErrors &&
|
|
1271
|
+
!decoded.responseIsArray &&
|
|
1272
|
+
!hasWarnings &&
|
|
1273
|
+
orders.length === 0 &&
|
|
1274
|
+
!hasUnknown) {
|
|
1275
|
+
return {
|
|
1276
|
+
state: "rejected",
|
|
1277
|
+
parentClientOrderId,
|
|
1278
|
+
reasons: decoded.errors.map(({ message }) => message),
|
|
1279
|
+
errors: decoded.errors,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
if (!hasWarnings &&
|
|
1283
|
+
!hasErrors &&
|
|
1284
|
+
!hasUnknown &&
|
|
1285
|
+
decoded.pendingCancelOrderIds.length === 0 &&
|
|
1286
|
+
orders.length === 2 &&
|
|
1287
|
+
hasDistinctBrokerOrderIds) {
|
|
1288
|
+
const [parent, child] = orders;
|
|
1289
|
+
if (parent !== undefined && child !== undefined) {
|
|
1290
|
+
const terminalFailure = orders.find(({ status }) => status === "REJECTED" || status === "CANCELED");
|
|
1291
|
+
const unknownStatus = orders.find(({ status }) => status === "UNKNOWN");
|
|
1292
|
+
if (terminalFailure === undefined && unknownStatus === undefined) {
|
|
1293
|
+
return { state: "accepted", orders: [parent, child], warnings: [] };
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return this.contingentRecoveryResult(decoded, orders, parentClientOrderId);
|
|
1298
|
+
}
|
|
1299
|
+
decodeOrderSubmission(response) {
|
|
647
1300
|
const items = Array.isArray(response) ? response : [response];
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1301
|
+
const decoded = {
|
|
1302
|
+
responseIsArray: Array.isArray(response),
|
|
1303
|
+
orders: [],
|
|
1304
|
+
pendingCancelOrderIds: [],
|
|
1305
|
+
warnings: [],
|
|
1306
|
+
errors: [],
|
|
1307
|
+
unrecognizedResponses: [],
|
|
1308
|
+
};
|
|
1309
|
+
for (const item of items) {
|
|
1310
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
1311
|
+
decoded.unrecognizedResponses.push(item);
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
const record = item;
|
|
1315
|
+
let recognized = false;
|
|
1316
|
+
const rawWarningId = record["id"];
|
|
1317
|
+
if (typeof rawWarningId === "string" && rawWarningId.trim()) {
|
|
1318
|
+
const rawMessageIds = record["messageIds"];
|
|
1319
|
+
const messageIds = Array.isArray(rawMessageIds)
|
|
1320
|
+
? rawMessageIds.filter((value) => typeof value === "string")
|
|
1321
|
+
: [];
|
|
1322
|
+
decoded.warnings.push({
|
|
1323
|
+
replyId: rawWarningId.trim(),
|
|
1324
|
+
messages: Array.isArray(record["message"])
|
|
1325
|
+
? record["message"].filter((value) => typeof value === "string")
|
|
659
1326
|
: [],
|
|
660
1327
|
messageIds,
|
|
661
|
-
known:
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1328
|
+
known: Array.isArray(rawMessageIds) &&
|
|
1329
|
+
rawMessageIds.length > 0 &&
|
|
1330
|
+
rawMessageIds.every((id) => typeof id === "string" && KNOWN_ORDER_WARNING_IDS.has(id)),
|
|
1331
|
+
});
|
|
1332
|
+
recognized = true;
|
|
1333
|
+
}
|
|
1334
|
+
else if ("id" in record) {
|
|
1335
|
+
decoded.unrecognizedResponses.push({ ...record });
|
|
1336
|
+
recognized = true;
|
|
1337
|
+
}
|
|
1338
|
+
if (record["error"] !== undefined && record["error"] !== null) {
|
|
1339
|
+
if (this.isMeaningfulBrokerError(record["error"], record)) {
|
|
1340
|
+
decoded.errors.push(this.normalizeBrokerError(record["error"], record));
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
decoded.unrecognizedResponses.push({ ...record });
|
|
1344
|
+
}
|
|
1345
|
+
recognized = true;
|
|
1346
|
+
}
|
|
1347
|
+
const hasOrderId = "order_id" in record || "orderId" in record;
|
|
1348
|
+
const rawOrderId = record["order_id"] ?? record["orderId"];
|
|
1349
|
+
const orderId = typeof rawOrderId === "string" && rawOrderId.trim()
|
|
1350
|
+
? rawOrderId.trim()
|
|
1351
|
+
: typeof rawOrderId === "number" && Number.isSafeInteger(rawOrderId) && rawOrderId > 0
|
|
1352
|
+
? String(rawOrderId)
|
|
1353
|
+
: null;
|
|
1354
|
+
if (orderId !== null) {
|
|
1355
|
+
const orderStatus = record["order_status"] ?? record["orderStatus"];
|
|
1356
|
+
if (typeof orderStatus === "string" &&
|
|
1357
|
+
this.canonicalIbkrOrderStatus(orderStatus) === "PENDING_CANCEL") {
|
|
1358
|
+
decoded.pendingCancelOrderIds.push(orderId);
|
|
1359
|
+
}
|
|
1360
|
+
decoded.orders.push({
|
|
1361
|
+
orderId,
|
|
685
1362
|
status: this.normalizeDerivativeOrderStatus(typeof orderStatus === "string" ? orderStatus : undefined, 0, 0),
|
|
686
|
-
clientOrderId,
|
|
687
|
-
|
|
688
|
-
|
|
1363
|
+
clientOrderId: null,
|
|
1364
|
+
});
|
|
1365
|
+
recognized = true;
|
|
689
1366
|
}
|
|
1367
|
+
else if (hasOrderId) {
|
|
1368
|
+
decoded.unrecognizedResponses.push({ ...record });
|
|
1369
|
+
recognized = true;
|
|
1370
|
+
}
|
|
1371
|
+
if (!recognized)
|
|
1372
|
+
decoded.unrecognizedResponses.push({ ...record });
|
|
690
1373
|
}
|
|
691
|
-
|
|
1374
|
+
if (items.length === 0)
|
|
1375
|
+
decoded.unrecognizedResponses.push({});
|
|
1376
|
+
return decoded;
|
|
1377
|
+
}
|
|
1378
|
+
singleOrderRecoveryResult(decoded, orders) {
|
|
692
1379
|
return {
|
|
693
|
-
state: "
|
|
694
|
-
reasons: [
|
|
695
|
-
|
|
1380
|
+
state: "recovery_required",
|
|
1381
|
+
reasons: [this.submissionRecoveryReason(decoded, orders.length, 1)],
|
|
1382
|
+
orders,
|
|
1383
|
+
warnings: decoded.warnings,
|
|
1384
|
+
errors: decoded.errors,
|
|
1385
|
+
unrecognizedResponses: decoded.unrecognizedResponses,
|
|
696
1386
|
};
|
|
697
1387
|
}
|
|
1388
|
+
contingentRecoveryResult(decoded, orders, parentClientOrderId) {
|
|
1389
|
+
return {
|
|
1390
|
+
state: "recovery_required",
|
|
1391
|
+
parentClientOrderId,
|
|
1392
|
+
reasons: [this.submissionRecoveryReason(decoded, orders.length, 2)],
|
|
1393
|
+
orders,
|
|
1394
|
+
warnings: decoded.warnings,
|
|
1395
|
+
errors: decoded.errors,
|
|
1396
|
+
unrecognizedResponses: decoded.unrecognizedResponses,
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
submissionRecoveryReason(decoded, orderCount, expectedOrderCount) {
|
|
1400
|
+
const pendingCancelOrderId = decoded.pendingCancelOrderIds[0];
|
|
1401
|
+
if (pendingCancelOrderId !== undefined) {
|
|
1402
|
+
return `Order ${pendingCancelOrderId} has a pending cancellation`;
|
|
1403
|
+
}
|
|
1404
|
+
const terminal = decoded.orders.find(({ status }) => status === "REJECTED" || status === "CANCELED");
|
|
1405
|
+
if (terminal !== undefined) {
|
|
1406
|
+
return `Order ${terminal.orderId} returned terminal status ${terminal.status}`;
|
|
1407
|
+
}
|
|
1408
|
+
if (decoded.orders.some(({ status }) => status === "UNKNOWN")) {
|
|
1409
|
+
return "IBKR returned an order ID with an unknown status";
|
|
1410
|
+
}
|
|
1411
|
+
if (new Set(decoded.orders.map(({ orderId }) => orderId)).size < decoded.orders.length) {
|
|
1412
|
+
return "IBKR returned duplicate broker order IDs";
|
|
1413
|
+
}
|
|
1414
|
+
if (decoded.errors.length > 0 && decoded.warnings.length > 0) {
|
|
1415
|
+
return "IBKR returned both warnings and rejections for one submission";
|
|
1416
|
+
}
|
|
1417
|
+
if (decoded.warnings.length > 1) {
|
|
1418
|
+
return "IBKR returned multiple warning continuations for one submission";
|
|
1419
|
+
}
|
|
1420
|
+
if (orderCount !== expectedOrderCount) {
|
|
1421
|
+
return `IBKR returned ${String(orderCount)} of ${String(expectedOrderCount)} expected order acknowledgements`;
|
|
1422
|
+
}
|
|
1423
|
+
if (decoded.unrecognizedResponses.length > 0) {
|
|
1424
|
+
return "IBKR returned one or more unrecognized order responses";
|
|
1425
|
+
}
|
|
1426
|
+
return "IBKR returned mixed or incomplete order evidence";
|
|
1427
|
+
}
|
|
698
1428
|
normalizeBrokerError(error, response) {
|
|
699
1429
|
const nested = typeof error === "object" && error !== null ? error : undefined;
|
|
700
1430
|
const nestedMessage = nested ? nested.message : undefined;
|
|
@@ -714,6 +1444,20 @@ export class IbkrClient {
|
|
|
714
1444
|
details: response,
|
|
715
1445
|
};
|
|
716
1446
|
}
|
|
1447
|
+
isMeaningfulBrokerError(error, response) {
|
|
1448
|
+
const nested = typeof error === "object" && error !== null ? error : undefined;
|
|
1449
|
+
const nestedRecord = nested;
|
|
1450
|
+
const messages = [error, nestedRecord?.["message"], response["message"]];
|
|
1451
|
+
if (messages.some((value) => typeof value === "string" && value.trim()))
|
|
1452
|
+
return true;
|
|
1453
|
+
const code = nestedRecord?.["code"] ?? response["code"];
|
|
1454
|
+
if ((typeof code === "string" && code.trim()) ||
|
|
1455
|
+
(typeof code === "number" && Number.isFinite(code))) {
|
|
1456
|
+
return true;
|
|
1457
|
+
}
|
|
1458
|
+
const status = nestedRecord?.["statusCode"] ?? nestedRecord?.["status"] ?? response["statusCode"];
|
|
1459
|
+
return typeof status === "number" && Number.isFinite(status) && status >= 400;
|
|
1460
|
+
}
|
|
717
1461
|
normalizeDerivativeOrderLifecycle(accountId, orderId, order) {
|
|
718
1462
|
const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size);
|
|
719
1463
|
const filledQuantity = this.firstNumber(order.cum_fill, order.cumFill, order.filledQuantity, order.filled);
|
|
@@ -742,6 +1486,167 @@ export class IbkrClient {
|
|
|
742
1486
|
updatedAt: this.parseOrderTime(order)?.toISOString() ?? null,
|
|
743
1487
|
};
|
|
744
1488
|
}
|
|
1489
|
+
flattenActiveOrders(orders, nestedParent = null) {
|
|
1490
|
+
return orders.flatMap((order) => {
|
|
1491
|
+
const children = [...new Set([...(order.childOrders ?? []), ...(order.children ?? [])])];
|
|
1492
|
+
return [{ order, nestedParent }, ...this.flattenActiveOrders(children, order)];
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
normalizeActiveDerivativeOrder(accountId, order, nestedParent) {
|
|
1496
|
+
const uncertainty = [];
|
|
1497
|
+
const total = this.firstNumber(order.total_size, order.totalSize, order.size) ?? null;
|
|
1498
|
+
const filled = this.firstNumber(order.cum_fill, order.cumFill, order.filledQuantity, order.filled) ?? null;
|
|
1499
|
+
const remaining = this.firstNumber(order.remainingQuantity, order.remaining_size, order.remaining) ??
|
|
1500
|
+
(total !== null && filled !== null ? Math.max(0, total - filled) : null);
|
|
1501
|
+
if (total === null || filled === null || remaining === null)
|
|
1502
|
+
uncertainty.push("INCOMPLETE_QUANTITIES");
|
|
1503
|
+
const rawStatus = order.order_status ?? order.orderStatus ?? order.status;
|
|
1504
|
+
const status = this.normalizeDerivativeOrderStatus(rawStatus, filled ?? 0, remaining ?? 0);
|
|
1505
|
+
if (status === "UNKNOWN")
|
|
1506
|
+
uncertainty.push("UNKNOWN_STATUS");
|
|
1507
|
+
const rawOrderId = order.order_id ?? order.orderId;
|
|
1508
|
+
if (rawOrderId === undefined)
|
|
1509
|
+
uncertainty.push("MISSING_BROKER_ORDER_ID");
|
|
1510
|
+
const explicitParentOrderId = order.parent_order_id ?? order.parentOrderId ?? order.parent_id;
|
|
1511
|
+
const explicitParentClientId = order.parentClientOrderId ?? order.parent_order_ref ?? order.parentId;
|
|
1512
|
+
const nestedBrokerId = nestedParent?.order_id ?? nestedParent?.orderId;
|
|
1513
|
+
const nestedClientId = nestedParent?.cOID ?? nestedParent?.order_ref;
|
|
1514
|
+
if (nestedParent !== null &&
|
|
1515
|
+
explicitParentOrderId === undefined &&
|
|
1516
|
+
explicitParentClientId === undefined) {
|
|
1517
|
+
uncertainty.push("PARTIAL_GRAPH");
|
|
1518
|
+
}
|
|
1519
|
+
const legs = this.normalizeActiveDerivativeLegs(order, total, uncertainty);
|
|
1520
|
+
const orderTime = this.parseOrderTime(order)?.toISOString() ?? null;
|
|
1521
|
+
return {
|
|
1522
|
+
accountId,
|
|
1523
|
+
orderId: rawOrderId === undefined ? null : String(rawOrderId),
|
|
1524
|
+
clientOrderId: order.cOID ?? order.order_ref ?? null,
|
|
1525
|
+
parentOrderId: explicitParentOrderId === undefined
|
|
1526
|
+
? nestedBrokerId === undefined
|
|
1527
|
+
? null
|
|
1528
|
+
: String(nestedBrokerId)
|
|
1529
|
+
: String(explicitParentOrderId),
|
|
1530
|
+
parentClientOrderId: explicitParentClientId === undefined
|
|
1531
|
+
? (nestedClientId ?? null)
|
|
1532
|
+
: String(explicitParentClientId),
|
|
1533
|
+
graphRole: nestedParent !== null ||
|
|
1534
|
+
explicitParentOrderId !== undefined ||
|
|
1535
|
+
explicitParentClientId !== undefined
|
|
1536
|
+
? "CHILD"
|
|
1537
|
+
: rawOrderId === undefined
|
|
1538
|
+
? "UNKNOWN"
|
|
1539
|
+
: "ROOT",
|
|
1540
|
+
status,
|
|
1541
|
+
totalQuantity: total,
|
|
1542
|
+
filledQuantity: filled,
|
|
1543
|
+
remainingQuantity: remaining,
|
|
1544
|
+
tif: order.tif ?? order.timeInForce ?? null,
|
|
1545
|
+
session: order.outsideRTH === true || order.outside_rth === true
|
|
1546
|
+
? "OVERNIGHT"
|
|
1547
|
+
: order.outsideRTH === false || order.outside_rth === false
|
|
1548
|
+
? "REGULAR"
|
|
1549
|
+
: "UNKNOWN",
|
|
1550
|
+
orderType: this.normalizeOrderType(order.order_type ?? order.orderType) ?? null,
|
|
1551
|
+
limitPrice: this.firstNumber(order.limitPrice, order.limit_price, order.price) ?? null,
|
|
1552
|
+
stopPrice: this.firstNumber(order.stopPrice) ?? null,
|
|
1553
|
+
enteredAt: orderTime,
|
|
1554
|
+
updatedAt: order.lastExecutionTime_r !== undefined || order.lastExecutionTime !== undefined
|
|
1555
|
+
? orderTime
|
|
1556
|
+
: null,
|
|
1557
|
+
legs,
|
|
1558
|
+
uncertainty,
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
normalizeActiveDerivativeLegs(order, total, orderUncertainty) {
|
|
1562
|
+
const description = [
|
|
1563
|
+
order.orderDescriptionWithContract,
|
|
1564
|
+
order.order_description_with_contract,
|
|
1565
|
+
order.contractDescription1,
|
|
1566
|
+
order.contract_description_1,
|
|
1567
|
+
order.description1,
|
|
1568
|
+
order.symbol,
|
|
1569
|
+
]
|
|
1570
|
+
.filter((value) => typeof value === "string")
|
|
1571
|
+
.join(" ");
|
|
1572
|
+
const describedOptions = [...description.matchAll(/([A-Z ]{1,6}\d{6}[CP]\d{8})/gi)].flatMap((match) => {
|
|
1573
|
+
const symbol = match[1]?.toUpperCase() ?? "";
|
|
1574
|
+
const parsed = parseOsiOptionSymbol(symbol);
|
|
1575
|
+
return parsed === null ? [] : [{ symbol, ...parsed }];
|
|
1576
|
+
});
|
|
1577
|
+
const uniqueDescribedOptions = describedOptions.filter((option, index) => describedOptions.findIndex((candidate) => candidate.symbol === option.symbol) === index);
|
|
1578
|
+
const side = this.normalizeOrderSide(order.side);
|
|
1579
|
+
const signedSide = side === "BUY" ? 1 : side === "SELL" ? -1 : null;
|
|
1580
|
+
let rawLegs = [];
|
|
1581
|
+
const conidex = typeof order.conidex === "string" ? order.conidex.trim() : null;
|
|
1582
|
+
if (conidex?.includes(";;;")) {
|
|
1583
|
+
const match = /^(\d+)(?:@[A-Za-z0-9._-]+)?;;;(.+)$/.exec(conidex);
|
|
1584
|
+
if (match?.[1] !== "28812380") {
|
|
1585
|
+
orderUncertainty.push("MALFORMED_CONIDEX");
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
rawLegs = (match[2] ?? "").split(",").map((member) => {
|
|
1589
|
+
const legMatch = /^(\d+)\/([+-]?\d+)$/.exec(member.trim());
|
|
1590
|
+
const conid = Number(legMatch?.[1]);
|
|
1591
|
+
const ratio = Number(legMatch?.[2]);
|
|
1592
|
+
if (!legMatch ||
|
|
1593
|
+
!Number.isSafeInteger(conid) ||
|
|
1594
|
+
conid <= 0 ||
|
|
1595
|
+
!Number.isSafeInteger(ratio) ||
|
|
1596
|
+
ratio === 0) {
|
|
1597
|
+
return { conid: null, ratio: null, quantityRatio: null };
|
|
1598
|
+
}
|
|
1599
|
+
return {
|
|
1600
|
+
conid,
|
|
1601
|
+
ratio: signedSide === null ? null : signedSide * ratio,
|
|
1602
|
+
quantityRatio: Math.abs(ratio),
|
|
1603
|
+
};
|
|
1604
|
+
});
|
|
1605
|
+
if (rawLegs.length === 0 || rawLegs.some((leg) => leg.conid === null)) {
|
|
1606
|
+
orderUncertainty.push("MALFORMED_CONIDEX");
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
if (rawLegs.length === 0)
|
|
1610
|
+
orderUncertainty.push("AGGREGATE_ONLY");
|
|
1611
|
+
}
|
|
1612
|
+
else if (Number.isSafeInteger(order.conid) && Number(order.conid) > 0) {
|
|
1613
|
+
rawLegs = [{ conid: Number(order.conid), ratio: signedSide, quantityRatio: 1 }];
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
if (conidex)
|
|
1617
|
+
orderUncertainty.push("MALFORMED_CONIDEX");
|
|
1618
|
+
orderUncertainty.push("MISSING_LEG_IDENTITY");
|
|
1619
|
+
}
|
|
1620
|
+
if (rawLegs.length === 0) {
|
|
1621
|
+
rawLegs = [{ conid: null, ratio: null, quantityRatio: null }];
|
|
1622
|
+
}
|
|
1623
|
+
return rawLegs.map((leg) => {
|
|
1624
|
+
const legUncertainty = [];
|
|
1625
|
+
if (leg.conid === null)
|
|
1626
|
+
legUncertainty.push("MISSING_LEG_IDENTITY");
|
|
1627
|
+
if (leg.ratio === null) {
|
|
1628
|
+
const directionUncertainty = leg.conid !== null && signedSide === null ? "UNKNOWN_SIDE" : "MALFORMED_CONIDEX";
|
|
1629
|
+
legUncertainty.push(directionUncertainty);
|
|
1630
|
+
if (!orderUncertainty.includes(directionUncertainty)) {
|
|
1631
|
+
orderUncertainty.push(directionUncertainty);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
return {
|
|
1635
|
+
conid: leg.conid,
|
|
1636
|
+
ratio: leg.ratio,
|
|
1637
|
+
side: leg.ratio === null ? "UNKNOWN" : leg.ratio > 0 ? "BUY" : "SELL",
|
|
1638
|
+
quantity: total === null || leg.quantityRatio === null ? null : total * leg.quantityRatio,
|
|
1639
|
+
option: rawLegs.length === 1 && uniqueDescribedOptions.length === 1
|
|
1640
|
+
? (uniqueDescribedOptions[0] ?? null)
|
|
1641
|
+
: null,
|
|
1642
|
+
uncertainty: legUncertainty,
|
|
1643
|
+
};
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
addOrderUncertainty(order, uncertainty) {
|
|
1647
|
+
if (!order.uncertainty.includes(uncertainty))
|
|
1648
|
+
order.uncertainty.push(uncertainty);
|
|
1649
|
+
}
|
|
745
1650
|
normalizeDerivativeExecution(accountId, trade) {
|
|
746
1651
|
if (!trade.execution_id || !Number.isSafeInteger(trade.conid) || Number(trade.conid) <= 0) {
|
|
747
1652
|
return undefined;
|
|
@@ -954,24 +1859,27 @@ export class IbkrClient {
|
|
|
954
1859
|
});
|
|
955
1860
|
}
|
|
956
1861
|
normalizeDerivativeOrderStatus(value, filledQuantity, remainingQuantity) {
|
|
957
|
-
const status = value
|
|
958
|
-
?.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
959
|
-
.replace(/\s+/g, "_")
|
|
960
|
-
.toUpperCase();
|
|
961
|
-
if (filledQuantity > 0 && remainingQuantity > 0)
|
|
962
|
-
return "PARTIALLY_FILLED";
|
|
1862
|
+
const status = this.canonicalIbkrOrderStatus(value);
|
|
963
1863
|
if (status === "FILLED")
|
|
964
1864
|
return "FILLED";
|
|
965
1865
|
if (status === "CANCELLED" || status === "CANCELED")
|
|
966
1866
|
return "CANCELED";
|
|
967
1867
|
if (status === "INACTIVE" || status === "REJECTED")
|
|
968
1868
|
return "REJECTED";
|
|
1869
|
+
if (filledQuantity > 0 && remainingQuantity > 0)
|
|
1870
|
+
return "PARTIALLY_FILLED";
|
|
969
1871
|
if (status === "API_PENDING" || status === "PENDING_SUBMIT")
|
|
970
1872
|
return "PENDING";
|
|
971
1873
|
if (status !== undefined && IBKR_WORKING_STATUSES.has(status))
|
|
972
1874
|
return "WORKING";
|
|
973
1875
|
return "UNKNOWN";
|
|
974
1876
|
}
|
|
1877
|
+
canonicalIbkrOrderStatus(value) {
|
|
1878
|
+
return value
|
|
1879
|
+
?.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
1880
|
+
.replace(/\s+/g, "_")
|
|
1881
|
+
.toUpperCase();
|
|
1882
|
+
}
|
|
975
1883
|
normalizeComboPreview(accountId, diagnostics, response) {
|
|
976
1884
|
const commission = this.whatIfNumber(response.amount?.commission);
|
|
977
1885
|
const initialMargin = this.whatIfMargin(response.initial);
|
|
@@ -1021,11 +1929,14 @@ export class IbkrClient {
|
|
|
1021
1929
|
if (brokerageAccounts.accounts && !brokerageAccounts.accounts.includes(accountId)) {
|
|
1022
1930
|
throw new Error(`IBKR account ${accountId} is not available for trading/order queries.`);
|
|
1023
1931
|
}
|
|
1024
|
-
await this.req({
|
|
1932
|
+
const switchedAccount = await this.req({
|
|
1025
1933
|
path: "iserver/account",
|
|
1026
1934
|
method: "POST",
|
|
1027
1935
|
data: { acctId: accountId },
|
|
1028
1936
|
});
|
|
1937
|
+
if (switchedAccount.set !== true || switchedAccount.acctId !== accountId) {
|
|
1938
|
+
throw new Error(`IBKR account switch was not confirmed for ${accountId}.`);
|
|
1939
|
+
}
|
|
1029
1940
|
}
|
|
1030
1941
|
normalizeStockListing(symbol, listing) {
|
|
1031
1942
|
const assetType = listing.assetClass === "STK" ? "EQUITY" : listing.assetClass;
|