@huskly/ibkr-client 0.11.0 → 0.12.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 +41 -8
- package/dist/ibkr/ibkrApiTypes.d.ts +35 -1
- package/dist/ibkr/ibkrApiTypes.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.d.ts +41 -21
- package/dist/ibkr/ibkrClient.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.js +469 -62
- package/dist/ibkr/ibkrClient.js.map +1 -1
- package/dist/ibkr/requestScheduler.d.ts +78 -0
- package/dist/ibkr/requestScheduler.d.ts.map +1 -0
- package/dist/ibkr/requestScheduler.js +205 -0
- package/dist/ibkr/requestScheduler.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +104 -9
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/ibkr/ibkrClient.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { ASSET_CLASS_LABELS, toNumber } from "../helpers.js";
|
|
3
3
|
import { normalizeOptionContract, parseOsiOptionSymbol } from "./optionContract.js";
|
|
4
4
|
import { normalizeDerivativeContract, normalizeDerivativeDataAvailability, } from "./derivativeContract.js";
|
|
5
|
+
import { IbkrRequestScheduler, } from "./requestScheduler.js";
|
|
5
6
|
// `ibkr-client`'s published ESM build is broken: its `import` condition points
|
|
6
7
|
// at files that use extensionless relative imports, which Node's strict ESM
|
|
7
8
|
// resolver rejects. Its CJS build is fine, so we deliberately load that via
|
|
@@ -56,9 +57,6 @@ const QUOTE_FIELDS = [
|
|
|
56
57
|
const OPTION_DISCOVERY_MONTH_CONCURRENCY = 1;
|
|
57
58
|
const OPTION_SECDEF_INFO_BATCH_SIZE = 8;
|
|
58
59
|
const OPTION_MARKETDATA_BATCH_SIZE = 100;
|
|
59
|
-
const READ_ONLY_REQUEST_MAX_RETRIES = 3;
|
|
60
|
-
const REQUEST_RETRY_BASE_DELAY_MS = 250;
|
|
61
|
-
const REQUEST_RETRY_MAX_DELAY_MS = 5_000;
|
|
62
60
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
63
61
|
const IBKR_STATUS_FILTERS = {
|
|
64
62
|
CANCELED: "cancelled",
|
|
@@ -167,8 +165,19 @@ export class IbkrClient {
|
|
|
167
165
|
accountIdPromise;
|
|
168
166
|
optionDiscovery = new Map();
|
|
169
167
|
derivativeDiscovery = new Map();
|
|
170
|
-
|
|
168
|
+
requestScheduler;
|
|
169
|
+
constructor(config, options = {}) {
|
|
171
170
|
this.raw = new RawIbkrClientCtor(config);
|
|
171
|
+
this.requestScheduler = new IbkrRequestScheduler({
|
|
172
|
+
...options.requestScheduler,
|
|
173
|
+
now: () => this.now(),
|
|
174
|
+
sleep: (ms) => this.wait(ms),
|
|
175
|
+
random: () => this.random(),
|
|
176
|
+
classifyError: (error) => this.classifyRequestError(error),
|
|
177
|
+
...(options.onRequestTelemetry === undefined
|
|
178
|
+
? {}
|
|
179
|
+
: { onTelemetry: options.onRequestTelemetry }),
|
|
180
|
+
});
|
|
172
181
|
}
|
|
173
182
|
/** Obtain the live session token (idempotent — safe to await repeatedly). */
|
|
174
183
|
init() {
|
|
@@ -238,14 +247,13 @@ export class IbkrClient {
|
|
|
238
247
|
if (!request.clientOrderId.trim() || request.clientOrderId.length > 64) {
|
|
239
248
|
throw new Error("Client order ID must contain 1 to 64 characters");
|
|
240
249
|
}
|
|
241
|
-
|
|
242
|
-
throw new Error("External operator is required");
|
|
250
|
+
const cmeOperatorMetadata = this.cmeOperatorMetadata(request.legs[0].contract.assetClass, request);
|
|
243
251
|
const diagnostics = await this.getTradingDiagnostics(request.accountId);
|
|
244
252
|
if (!diagnostics.authenticated || diagnostics.competingSession) {
|
|
245
253
|
throw new Error("IBKR brokerage session is not safely authenticated for submission");
|
|
246
254
|
}
|
|
247
255
|
await this.prepareBrokerageAccount(request.accountId);
|
|
248
|
-
const response = await this.
|
|
256
|
+
const response = await this.singleAttemptRequest({
|
|
249
257
|
path: `iserver/account/${request.accountId}/orders`,
|
|
250
258
|
method: "POST",
|
|
251
259
|
data: {
|
|
@@ -253,8 +261,7 @@ export class IbkrClient {
|
|
|
253
261
|
{
|
|
254
262
|
...this.comboOrderTicket(request),
|
|
255
263
|
cOID: request.clientOrderId,
|
|
256
|
-
|
|
257
|
-
manualIndicator: request.manualIndicator,
|
|
264
|
+
...cmeOperatorMetadata,
|
|
258
265
|
},
|
|
259
266
|
],
|
|
260
267
|
},
|
|
@@ -264,7 +271,7 @@ export class IbkrClient {
|
|
|
264
271
|
async acknowledgeOrderWarning(input) {
|
|
265
272
|
if (!input.replyId.trim())
|
|
266
273
|
throw new Error("An exact warning reply ID is required");
|
|
267
|
-
const response = await this.
|
|
274
|
+
const response = await this.singleAttemptRequest({
|
|
268
275
|
path: `iserver/reply/${encodeURIComponent(input.replyId)}`,
|
|
269
276
|
method: "POST",
|
|
270
277
|
data: { confirmed: true },
|
|
@@ -276,31 +283,118 @@ export class IbkrClient {
|
|
|
276
283
|
throw new Error("Exact account and order IDs are required");
|
|
277
284
|
}
|
|
278
285
|
await this.prepareBrokerageAccount(accountId);
|
|
286
|
+
const order = await this.req({
|
|
287
|
+
path: `iserver/account/order/status/${encodeURIComponent(orderId)}`,
|
|
288
|
+
});
|
|
289
|
+
if (String(order.order_id ?? order.orderId ?? "") !== orderId) {
|
|
290
|
+
throw new Error(`IBKR response does not match the requested order ${orderId}`);
|
|
291
|
+
}
|
|
292
|
+
if ((order.account ?? order.acct) !== accountId) {
|
|
293
|
+
throw new Error(`IBKR order ${orderId} does not belong to the requested account`);
|
|
294
|
+
}
|
|
295
|
+
const lifecycle = this.normalizeDerivativeOrderLifecycle(accountId, orderId, order);
|
|
296
|
+
if (lifecycle.status === "UNKNOWN") {
|
|
297
|
+
throw new Error(`IBKR order ${orderId} returned an unrecognized status`);
|
|
298
|
+
}
|
|
299
|
+
return lifecycle;
|
|
300
|
+
}
|
|
301
|
+
async findDerivativeOrder(input) {
|
|
302
|
+
if (!input.accountId.trim())
|
|
303
|
+
throw new Error("An exact account ID is required");
|
|
304
|
+
const identity = input.orderId ?? input.clientOrderId;
|
|
305
|
+
if (!identity.trim())
|
|
306
|
+
throw new Error("An exact broker or client order ID is required");
|
|
307
|
+
if (input.orderId !== undefined) {
|
|
308
|
+
return this.getDerivativeOrderStatus(input.accountId, input.orderId);
|
|
309
|
+
}
|
|
310
|
+
await this.prepareBrokerageAccount(input.accountId);
|
|
279
311
|
const response = await this.req({
|
|
280
312
|
path: "iserver/account/orders",
|
|
281
|
-
params: { force: true, accountId },
|
|
313
|
+
params: { force: true, accountId: input.accountId },
|
|
314
|
+
});
|
|
315
|
+
const order = response.orders?.find((candidate) => {
|
|
316
|
+
if (!this.orderBelongsToAccount(candidate, input.accountId))
|
|
317
|
+
return false;
|
|
318
|
+
return (candidate.cOID ?? candidate.order_ref) === input.clientOrderId;
|
|
282
319
|
});
|
|
283
|
-
const order = response.orders?.find((candidate) => String(candidate.order_id ?? candidate.orderId) === orderId);
|
|
284
320
|
if (order === undefined)
|
|
285
|
-
throw new Error(`IBKR order ${
|
|
286
|
-
|
|
287
|
-
|
|
321
|
+
throw new Error(`IBKR order ${identity} was not found`);
|
|
322
|
+
const orderId = order.order_id ?? order.orderId;
|
|
323
|
+
if (orderId === undefined) {
|
|
324
|
+
throw new Error(`IBKR order ${identity} did not include a broker order ID`);
|
|
325
|
+
}
|
|
326
|
+
return this.getDerivativeOrderStatus(input.accountId, String(orderId));
|
|
327
|
+
}
|
|
328
|
+
async getDerivativeExecutions(input) {
|
|
329
|
+
if (!input.accountId.trim())
|
|
330
|
+
throw new Error("An exact account ID is required");
|
|
331
|
+
if (input.days !== undefined &&
|
|
332
|
+
(!Number.isSafeInteger(input.days) || input.days < 1 || input.days > 7)) {
|
|
333
|
+
throw new Error("Execution history days must be an integer from 1 through 7");
|
|
334
|
+
}
|
|
335
|
+
if (input.orderId !== undefined && !input.orderId.trim()) {
|
|
336
|
+
throw new Error("Broker order ID cannot be empty");
|
|
337
|
+
}
|
|
338
|
+
if (input.clientOrderId !== undefined && !input.clientOrderId.trim()) {
|
|
339
|
+
throw new Error("Client order ID cannot be empty");
|
|
340
|
+
}
|
|
341
|
+
await this.prepareBrokerageAccount(input.accountId);
|
|
342
|
+
const response = await this.req({
|
|
343
|
+
path: "iserver/account/trades",
|
|
344
|
+
...(input.days === undefined ? {} : { params: { days: input.days } }),
|
|
345
|
+
});
|
|
346
|
+
return response
|
|
347
|
+
.filter((trade) => (trade.account ?? trade.accountCode) === input.accountId)
|
|
348
|
+
.filter((trade) => input.orderId === undefined || String(trade.order_id) === input.orderId)
|
|
349
|
+
.filter((trade) => input.clientOrderId === undefined || trade.order_ref === input.clientOrderId)
|
|
350
|
+
.flatMap((trade) => {
|
|
351
|
+
const execution = this.normalizeDerivativeExecution(input.accountId, trade);
|
|
352
|
+
return execution === undefined ? [] : [execution];
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
async reconcileDerivativeComboExecution(request) {
|
|
356
|
+
this.validateReconciliationRequest(request);
|
|
357
|
+
const lifecycle = await this.getDerivativeOrderStatus(request.accountId, request.orderId);
|
|
358
|
+
const deadline = this.now() + (request.timeoutMs ?? 30_000);
|
|
359
|
+
const pollMs = request.pollMs ?? 1_000;
|
|
360
|
+
for (;;) {
|
|
361
|
+
const executions = await this.getDerivativeExecutions({
|
|
362
|
+
accountId: request.accountId,
|
|
363
|
+
clientOrderId: request.clientOrderId,
|
|
364
|
+
days: 1,
|
|
365
|
+
});
|
|
366
|
+
const result = this.evaluateDerivativeReconciliation(request, lifecycle, executions);
|
|
367
|
+
if (result.state !== "PENDING")
|
|
368
|
+
return result;
|
|
369
|
+
if (!this.isTerminalDerivativeStatus(lifecycle.status))
|
|
370
|
+
return result;
|
|
371
|
+
if (this.now() >= deadline) {
|
|
372
|
+
return {
|
|
373
|
+
...result,
|
|
374
|
+
state: "RECOVERY_REQUIRED",
|
|
375
|
+
reason: result.reason ?? "Terminal order is missing expected execution evidence",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
await this.wait(Math.min(pollMs, Math.max(0, deadline - this.now())));
|
|
288
379
|
}
|
|
289
|
-
return this.normalizeDerivativeOrderLifecycle(accountId, orderId, order);
|
|
290
380
|
}
|
|
291
381
|
async cancelDerivativeOrder(input) {
|
|
292
|
-
if (!input.accountId.trim() || !input.orderId.trim()
|
|
293
|
-
throw new Error("Exact account
|
|
382
|
+
if (!input.accountId.trim() || !input.orderId.trim()) {
|
|
383
|
+
throw new Error("Exact account and order IDs are required");
|
|
294
384
|
}
|
|
385
|
+
const cmeOperatorMetadata = this.cmeOperatorMetadata(input.assetClass, input);
|
|
295
386
|
await this.prepareBrokerageAccount(input.accountId);
|
|
296
|
-
await this.
|
|
387
|
+
const response = await this.singleAttemptRequest({
|
|
297
388
|
path: `iserver/account/${input.accountId}/order/${encodeURIComponent(input.orderId)}`,
|
|
298
389
|
method: "DELETE",
|
|
299
|
-
params: {
|
|
300
|
-
extOperator: input.extOperator,
|
|
301
|
-
manualIndicator: input.manualIndicator,
|
|
302
|
-
},
|
|
390
|
+
...(Object.keys(cmeOperatorMetadata).length > 0 ? { params: cmeOperatorMetadata } : {}),
|
|
303
391
|
});
|
|
392
|
+
return {
|
|
393
|
+
state: "requested",
|
|
394
|
+
accountId: input.accountId,
|
|
395
|
+
orderId: input.orderId,
|
|
396
|
+
message: this.trimmedString(response.msg),
|
|
397
|
+
};
|
|
304
398
|
}
|
|
305
399
|
async getAccountId() {
|
|
306
400
|
this.accountIdPromise ??= (async () => {
|
|
@@ -522,6 +616,17 @@ export class IbkrClient {
|
|
|
522
616
|
}
|
|
523
617
|
}
|
|
524
618
|
}
|
|
619
|
+
cmeOperatorMetadata(assetClass, input) {
|
|
620
|
+
if (assetClass === "OPT")
|
|
621
|
+
return {};
|
|
622
|
+
if (!input.extOperator?.trim() || input.manualIndicator === undefined) {
|
|
623
|
+
throw new Error(`${assetClass} orders require exact CME operator metadata`);
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
extOperator: input.extOperator,
|
|
627
|
+
manualIndicator: input.manualIndicator,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
525
630
|
comboOrderTicket(request) {
|
|
526
631
|
const exchange = request.legs[0].contract.exchange;
|
|
527
632
|
const spreadConid = exchange === "SMART" ? "28812380" : `28812380@${exchange}`;
|
|
@@ -543,11 +648,15 @@ export class IbkrClient {
|
|
|
543
648
|
const warnings = items.flatMap((item) => {
|
|
544
649
|
if (!("id" in item) || typeof item.id !== "string")
|
|
545
650
|
return [];
|
|
546
|
-
const messageIds = item.messageIds
|
|
651
|
+
const messageIds = Array.isArray(item.messageIds)
|
|
652
|
+
? item.messageIds.filter((value) => typeof value === "string")
|
|
653
|
+
: [];
|
|
547
654
|
return [
|
|
548
655
|
{
|
|
549
656
|
replyId: item.id,
|
|
550
|
-
messages: item.message
|
|
657
|
+
messages: Array.isArray(item.message)
|
|
658
|
+
? item.message.filter((value) => typeof value === "string")
|
|
659
|
+
: [],
|
|
551
660
|
messageIds,
|
|
552
661
|
known: messageIds.length > 0 && messageIds.every((id) => KNOWN_ORDER_WARNING_IDS.has(id)),
|
|
553
662
|
},
|
|
@@ -555,44 +664,77 @@ export class IbkrClient {
|
|
|
555
664
|
});
|
|
556
665
|
if (warnings.length > 0)
|
|
557
666
|
return { state: "warning", warnings };
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
667
|
+
const errors = items.flatMap((item) => {
|
|
668
|
+
if (!("error" in item) || item.error === undefined || item.error === null)
|
|
669
|
+
return [];
|
|
670
|
+
return [this.normalizeBrokerError(item.error, item)];
|
|
671
|
+
});
|
|
672
|
+
if (errors.length > 0) {
|
|
673
|
+
return { state: "rejected", reasons: errors.map(({ message }) => message), errors };
|
|
674
|
+
}
|
|
563
675
|
const accepted = items.find((item) => "order_id" in item || "orderId" in item);
|
|
564
676
|
if (accepted !== undefined) {
|
|
565
677
|
const orderId = ("order_id" in accepted ? accepted.order_id : undefined) ??
|
|
566
678
|
("orderId" in accepted ? accepted.orderId : undefined);
|
|
567
|
-
if (orderId
|
|
679
|
+
if (typeof orderId === "string" || typeof orderId === "number") {
|
|
680
|
+
const orderStatus = ("order_status" in accepted ? accepted.order_status : undefined) ??
|
|
681
|
+
("orderStatus" in accepted ? accepted.orderStatus : undefined);
|
|
568
682
|
return {
|
|
569
683
|
state: "accepted",
|
|
570
684
|
orderId: String(orderId),
|
|
571
|
-
status: this.normalizeDerivativeOrderStatus(
|
|
572
|
-
("orderStatus" in accepted ? accepted.orderStatus : undefined), 0, 0),
|
|
685
|
+
status: this.normalizeDerivativeOrderStatus(typeof orderStatus === "string" ? orderStatus : undefined, 0, 0),
|
|
573
686
|
clientOrderId,
|
|
574
687
|
warnings: [],
|
|
575
688
|
};
|
|
576
689
|
}
|
|
577
690
|
}
|
|
578
|
-
|
|
691
|
+
const unknown = "IBKR returned an unknown order response";
|
|
692
|
+
return {
|
|
693
|
+
state: "rejected",
|
|
694
|
+
reasons: [unknown],
|
|
695
|
+
errors: [{ message: unknown, code: null, statusCode: null, details: {} }],
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
normalizeBrokerError(error, response) {
|
|
699
|
+
const nested = typeof error === "object" && error !== null ? error : undefined;
|
|
700
|
+
const nestedMessage = nested ? nested.message : undefined;
|
|
701
|
+
const responseMessage = response["message"];
|
|
702
|
+
const message = (typeof nestedMessage === "string" && nestedMessage.trim()) ||
|
|
703
|
+
(typeof error === "string" && error.trim()) ||
|
|
704
|
+
(typeof responseMessage === "string" && responseMessage.trim()) ||
|
|
705
|
+
"IBKR rejected the order";
|
|
706
|
+
const nestedCode = nested ? nested.code : undefined;
|
|
707
|
+
const responseCode = response["code"];
|
|
708
|
+
const codeValue = nestedCode ?? responseCode;
|
|
709
|
+
const statusValue = response["statusCode"];
|
|
710
|
+
return {
|
|
711
|
+
message,
|
|
712
|
+
code: typeof codeValue === "string" || typeof codeValue === "number" ? String(codeValue) : null,
|
|
713
|
+
statusCode: typeof statusValue === "number" && Number.isFinite(statusValue) ? statusValue : null,
|
|
714
|
+
details: response,
|
|
715
|
+
};
|
|
579
716
|
}
|
|
580
717
|
normalizeDerivativeOrderLifecycle(accountId, orderId, order) {
|
|
581
|
-
const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size)
|
|
582
|
-
const filledQuantity = this.firstNumber(order.cum_fill, order.cumFill, order.filledQuantity)
|
|
583
|
-
const remainingQuantity = order.remainingQuantity
|
|
584
|
-
|
|
585
|
-
|
|
718
|
+
const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size);
|
|
719
|
+
const filledQuantity = this.firstNumber(order.cum_fill, order.cumFill, order.filledQuantity, order.filled);
|
|
720
|
+
const remainingQuantity = this.firstNumber(order.remainingQuantity, order.remaining_size, order.remaining);
|
|
721
|
+
if (quantity === undefined ||
|
|
722
|
+
filledQuantity === undefined ||
|
|
723
|
+
filledQuantity < 0 ||
|
|
724
|
+
remainingQuantity === undefined ||
|
|
725
|
+
remainingQuantity < 0) {
|
|
726
|
+
throw new Error(`IBKR order ${orderId} returned incomplete fill quantities`);
|
|
727
|
+
}
|
|
586
728
|
return {
|
|
587
729
|
accountId,
|
|
588
730
|
orderId,
|
|
589
|
-
clientOrderId: order.cOID ?? null,
|
|
731
|
+
clientOrderId: order.cOID ?? order.order_ref ?? null,
|
|
590
732
|
status: this.normalizeDerivativeOrderStatus(order.order_status ?? order.orderStatus ?? order.status, filledQuantity, remainingQuantity),
|
|
591
733
|
quantity,
|
|
592
734
|
filledQuantity,
|
|
593
735
|
remainingQuantity,
|
|
594
|
-
averagePrice: this.firstNumber(order.avgPrice, order.average_price, order.averagePrice) ?? null,
|
|
595
|
-
limitPrice: this.firstNumber(order.limitPrice, order.price) ?? null,
|
|
736
|
+
averagePrice: this.firstNumber(order.avgPrice, order.avg_price, order.average_price, order.averagePrice) ?? null,
|
|
737
|
+
limitPrice: this.firstNumber(order.limitPrice, order.limit_price, order.price) ?? null,
|
|
596
738
|
commissionAndFees: typeof order.commissionAndFees === "number"
|
|
597
739
|
? order.commissionAndFees
|
|
598
740
|
: this.whatIfNumber(order.commissionAndFees),
|
|
@@ -600,6 +742,204 @@ export class IbkrClient {
|
|
|
600
742
|
updatedAt: this.parseOrderTime(order)?.toISOString() ?? null,
|
|
601
743
|
};
|
|
602
744
|
}
|
|
745
|
+
normalizeDerivativeExecution(accountId, trade) {
|
|
746
|
+
if (!trade.execution_id || !Number.isSafeInteger(trade.conid) || Number(trade.conid) <= 0) {
|
|
747
|
+
return undefined;
|
|
748
|
+
}
|
|
749
|
+
const commission = typeof trade.commission === "number" ? trade.commission : this.whatIfNumber(trade.commission);
|
|
750
|
+
const commissionCurrency = typeof trade.commission === "string"
|
|
751
|
+
? (/\b(?<currency>[A-Z]{3})\s*$/.exec(trade.commission.trim())?.groups?.["currency"] ??
|
|
752
|
+
null)
|
|
753
|
+
: null;
|
|
754
|
+
const side = trade.side?.trim().toUpperCase();
|
|
755
|
+
return {
|
|
756
|
+
accountId,
|
|
757
|
+
executionId: trade.execution_id,
|
|
758
|
+
orderId: trade.order_id === undefined ? null : String(trade.order_id),
|
|
759
|
+
clientOrderId: this.trimmedString(trade.order_ref),
|
|
760
|
+
conid: Number(trade.conid),
|
|
761
|
+
symbol: this.trimmedString(trade.contract_description_1) ?? this.trimmedString(trade.symbol),
|
|
762
|
+
side: side === "B" || side === "BUY" || side === "BOT"
|
|
763
|
+
? "BUY"
|
|
764
|
+
: side === "S" || side === "SELL" || side === "SLD"
|
|
765
|
+
? "SELL"
|
|
766
|
+
: "UNKNOWN",
|
|
767
|
+
quantity: this.firstNumber(trade.size) ?? 0,
|
|
768
|
+
price: this.firstNumber(trade.price) ?? null,
|
|
769
|
+
commission,
|
|
770
|
+
commissionCurrency,
|
|
771
|
+
netAmount: this.firstNumber(trade.net_amount) ?? null,
|
|
772
|
+
exchange: this.trimmedString(trade.exchange),
|
|
773
|
+
executedAt: this.parseTradeTime(trade),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
parseTradeTime(trade) {
|
|
777
|
+
if (trade.trade_time_r !== undefined) {
|
|
778
|
+
const epoch = new Date(trade.trade_time_r);
|
|
779
|
+
if (!Number.isNaN(epoch.getTime()))
|
|
780
|
+
return epoch.toISOString();
|
|
781
|
+
}
|
|
782
|
+
const value = trade.trade_time;
|
|
783
|
+
const match = value ? /^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2}):(\d{2})$/.exec(value) : null;
|
|
784
|
+
if (!match)
|
|
785
|
+
return null;
|
|
786
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
787
|
+
if (!year || !month || !day || !hour || !minute || !second)
|
|
788
|
+
return null;
|
|
789
|
+
const parsed = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
|
|
790
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
791
|
+
}
|
|
792
|
+
trimmedString(value) {
|
|
793
|
+
if (value === undefined)
|
|
794
|
+
return null;
|
|
795
|
+
const trimmed = value.trim();
|
|
796
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
797
|
+
}
|
|
798
|
+
validateReconciliationRequest(request) {
|
|
799
|
+
if (!request.accountId.trim() || !request.orderId.trim() || !request.clientOrderId.trim()) {
|
|
800
|
+
throw new Error("Exact account, order, and client order IDs are required for reconciliation");
|
|
801
|
+
}
|
|
802
|
+
if (!Number.isSafeInteger(request.quantity) || request.quantity <= 0) {
|
|
803
|
+
throw new Error("Reconciliation quantity must be a positive integer");
|
|
804
|
+
}
|
|
805
|
+
if (!Number.isFinite(request.multiplier) || request.multiplier <= 0) {
|
|
806
|
+
throw new Error("Reconciliation multiplier must be positive");
|
|
807
|
+
}
|
|
808
|
+
if (request.legs.some(({ conid, ratio }) => !Number.isSafeInteger(conid) || conid <= 0 || !Number.isSafeInteger(ratio) || ratio === 0) ||
|
|
809
|
+
request.legs[0].conid === request.legs[1].conid) {
|
|
810
|
+
throw new Error("Reconciliation legs require distinct conids and non-zero integer ratios");
|
|
811
|
+
}
|
|
812
|
+
if ((request.timeoutMs ?? 30_000) < 0 || (request.pollMs ?? 1_000) <= 0) {
|
|
813
|
+
throw new Error("Reconciliation timing must use a non-negative timeout and positive poll");
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
evaluateDerivativeReconciliation(request, lifecycle, trades) {
|
|
817
|
+
const base = {
|
|
818
|
+
aggregateStatus: lifecycle.status,
|
|
819
|
+
filledQuantity: lifecycle.filledQuantity,
|
|
820
|
+
remainingQuantity: lifecycle.remainingQuantity,
|
|
821
|
+
multiplier: request.multiplier,
|
|
822
|
+
};
|
|
823
|
+
const recovery = (reason) => ({
|
|
824
|
+
...base,
|
|
825
|
+
state: "RECOVERY_REQUIRED",
|
|
826
|
+
reason,
|
|
827
|
+
legs: [],
|
|
828
|
+
grossPoints: null,
|
|
829
|
+
grossAmount: null,
|
|
830
|
+
commission: null,
|
|
831
|
+
netAmount: null,
|
|
832
|
+
});
|
|
833
|
+
if (lifecycle.quantity !== request.quantity) {
|
|
834
|
+
return recovery("Aggregate order quantity does not match the reviewed combo");
|
|
835
|
+
}
|
|
836
|
+
if (lifecycle.clientOrderId !== null && lifecycle.clientOrderId !== request.clientOrderId) {
|
|
837
|
+
return recovery("Aggregate client order reference does not match the reviewed combo");
|
|
838
|
+
}
|
|
839
|
+
const expectedLegs = request.legs.map(({ conid, ratio }) => ({ conid, ratio }));
|
|
840
|
+
if (JSON.stringify(lifecycle.legs) !== JSON.stringify(expectedLegs)) {
|
|
841
|
+
return recovery("Aggregate combo legs do not match the reviewed combo");
|
|
842
|
+
}
|
|
843
|
+
const matching = trades.filter(({ clientOrderId }) => clientOrderId === request.clientOrderId);
|
|
844
|
+
const executionIds = new Set();
|
|
845
|
+
for (const trade of matching) {
|
|
846
|
+
if (executionIds.has(trade.executionId)) {
|
|
847
|
+
return recovery("Duplicate execution ID requires manual recovery");
|
|
848
|
+
}
|
|
849
|
+
executionIds.add(trade.executionId);
|
|
850
|
+
if (trade.orderId !== null && trade.orderId !== request.orderId) {
|
|
851
|
+
return recovery("Execution order ID does not match the reviewed combo");
|
|
852
|
+
}
|
|
853
|
+
const expected = request.legs.find(({ conid }) => conid === trade.conid);
|
|
854
|
+
if (expected === undefined) {
|
|
855
|
+
return recovery("Execution contains an unexpected combo leg");
|
|
856
|
+
}
|
|
857
|
+
if (trade.side !== this.sideForRatio(expected.ratio)) {
|
|
858
|
+
return recovery("Execution side does not match the reviewed combo ratio");
|
|
859
|
+
}
|
|
860
|
+
if (trade.quantity <= 0 ||
|
|
861
|
+
trade.price === null ||
|
|
862
|
+
trade.price < 0 ||
|
|
863
|
+
trade.commission === null ||
|
|
864
|
+
trade.commission < 0 ||
|
|
865
|
+
trade.executedAt === null) {
|
|
866
|
+
return recovery("Execution contains incomplete economics or timing evidence");
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const completeExecutions = matching.flatMap((trade) => trade.side !== "UNKNOWN" &&
|
|
870
|
+
trade.price !== null &&
|
|
871
|
+
trade.commission !== null &&
|
|
872
|
+
trade.executedAt !== null
|
|
873
|
+
? [
|
|
874
|
+
{
|
|
875
|
+
...trade,
|
|
876
|
+
side: trade.side,
|
|
877
|
+
price: trade.price,
|
|
878
|
+
commission: trade.commission,
|
|
879
|
+
executedAt: trade.executedAt,
|
|
880
|
+
},
|
|
881
|
+
]
|
|
882
|
+
: []);
|
|
883
|
+
const summaries = [];
|
|
884
|
+
for (const expected of request.legs) {
|
|
885
|
+
const executions = completeExecutions.filter(({ conid }) => conid === expected.conid);
|
|
886
|
+
const quantity = executions.reduce((sum, trade) => sum + trade.quantity, 0);
|
|
887
|
+
const expectedQuantity = lifecycle.filledQuantity * Math.abs(expected.ratio);
|
|
888
|
+
if (quantity > expectedQuantity) {
|
|
889
|
+
return recovery("Execution quantity exceeds the aggregate fill");
|
|
890
|
+
}
|
|
891
|
+
if (quantity < expectedQuantity) {
|
|
892
|
+
return {
|
|
893
|
+
...base,
|
|
894
|
+
state: "PENDING",
|
|
895
|
+
reason: "Terminal order is missing expected execution evidence",
|
|
896
|
+
legs: summaries,
|
|
897
|
+
grossPoints: null,
|
|
898
|
+
grossAmount: null,
|
|
899
|
+
commission: null,
|
|
900
|
+
netAmount: null,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
if (quantity > 0) {
|
|
904
|
+
summaries.push({
|
|
905
|
+
conid: expected.conid,
|
|
906
|
+
side: this.sideForRatio(expected.ratio),
|
|
907
|
+
quantity,
|
|
908
|
+
averagePrice: this.round(executions.reduce((sum, trade) => sum + trade.price * trade.quantity, 0) / quantity, 8),
|
|
909
|
+
commission: this.round(executions.reduce((sum, trade) => sum + trade.commission, 0), 2),
|
|
910
|
+
executionCount: executions.length,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const cashFlowPoints = completeExecutions.reduce((sum, trade) => sum + (trade.side === "SELL" ? 1 : -1) * trade.price * trade.quantity, 0);
|
|
915
|
+
const grossPoints = lifecycle.filledQuantity > 0 ? this.round(cashFlowPoints / lifecycle.filledQuantity, 8) : 0;
|
|
916
|
+
const grossAmount = this.round(cashFlowPoints * request.multiplier, 2);
|
|
917
|
+
const commission = this.round(completeExecutions.reduce((sum, trade) => sum + trade.commission, 0), 2);
|
|
918
|
+
const result = {
|
|
919
|
+
...base,
|
|
920
|
+
state: this.isTerminalDerivativeStatus(lifecycle.status) ? "VERIFIED" : "PENDING",
|
|
921
|
+
reason: null,
|
|
922
|
+
legs: summaries,
|
|
923
|
+
grossPoints,
|
|
924
|
+
grossAmount,
|
|
925
|
+
commission,
|
|
926
|
+
netAmount: this.round(grossAmount - commission, 2),
|
|
927
|
+
};
|
|
928
|
+
if (lifecycle.status === "FILLED" && lifecycle.filledQuantity !== request.quantity) {
|
|
929
|
+
return recovery("Filled aggregate quantity does not match the reviewed combo");
|
|
930
|
+
}
|
|
931
|
+
return result;
|
|
932
|
+
}
|
|
933
|
+
sideForRatio(ratio) {
|
|
934
|
+
return ratio > 0 ? "BUY" : "SELL";
|
|
935
|
+
}
|
|
936
|
+
isTerminalDerivativeStatus(status) {
|
|
937
|
+
return status === "FILLED" || status === "CANCELED" || status === "REJECTED";
|
|
938
|
+
}
|
|
939
|
+
round(value, decimalPlaces) {
|
|
940
|
+
const factor = 10 ** decimalPlaces;
|
|
941
|
+
return Math.round((value + Number.EPSILON) * factor) / factor;
|
|
942
|
+
}
|
|
603
943
|
parseComboLegs(conidex) {
|
|
604
944
|
const encoded = conidex?.split(";;;")[1];
|
|
605
945
|
if (!encoded)
|
|
@@ -772,7 +1112,10 @@ export class IbkrClient {
|
|
|
772
1112
|
}
|
|
773
1113
|
/** Discover listed derivative series over an inclusive calendar range. */
|
|
774
1114
|
async getDerivativeExpiries(query) {
|
|
775
|
-
const contracts =
|
|
1115
|
+
const contracts = [];
|
|
1116
|
+
for (const month of monthCodes(query.from, query.to)) {
|
|
1117
|
+
contracts.push(...(await this.discoverDerivativeMonth(query.underlying, query.assetClass, month, query.exchange, query.right)));
|
|
1118
|
+
}
|
|
776
1119
|
const filtered = contracts.filter((contract) => contract.expiration >= query.from &&
|
|
777
1120
|
contract.expiration <= query.to &&
|
|
778
1121
|
(query.right === undefined || contract.right === query.right) &&
|
|
@@ -1450,6 +1793,14 @@ export class IbkrClient {
|
|
|
1450
1793
|
wait(ms) {
|
|
1451
1794
|
return sleep(ms);
|
|
1452
1795
|
}
|
|
1796
|
+
/** Overridable monotonic-enough wall clock for bounded polling tests. */
|
|
1797
|
+
now() {
|
|
1798
|
+
return Date.now();
|
|
1799
|
+
}
|
|
1800
|
+
/** Overridable entropy source for deterministic scheduler jitter tests. */
|
|
1801
|
+
random() {
|
|
1802
|
+
return Math.random();
|
|
1803
|
+
}
|
|
1453
1804
|
normalizeQuote(contract, snapshot, history) {
|
|
1454
1805
|
const symbol = this.snapshotString(snapshot, "55") ?? contract.symbol;
|
|
1455
1806
|
const description = this.snapshotString(snapshot, "58") ?? history?.text ?? contract.description;
|
|
@@ -1571,23 +1922,84 @@ export class IbkrClient {
|
|
|
1571
1922
|
async sendRequest(input) {
|
|
1572
1923
|
return (await this.raw.request(input));
|
|
1573
1924
|
}
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1925
|
+
req(input) {
|
|
1926
|
+
return this.scheduledRequest(input, true);
|
|
1927
|
+
}
|
|
1928
|
+
singleAttemptRequest(input) {
|
|
1929
|
+
return this.scheduledRequest(input, false);
|
|
1930
|
+
}
|
|
1931
|
+
scheduledRequest(input, retryable) {
|
|
1932
|
+
return this.requestScheduler.schedule({
|
|
1933
|
+
endpoint: this.requestEndpoint(input.path),
|
|
1934
|
+
priority: this.requestPriority(input.path),
|
|
1935
|
+
retryable,
|
|
1936
|
+
}, () => this.sendRequest(input));
|
|
1937
|
+
}
|
|
1938
|
+
requestPriority(path) {
|
|
1939
|
+
if (path === "iserver/accounts" ||
|
|
1940
|
+
path === "iserver/auth/status" ||
|
|
1941
|
+
path.includes("/orders") ||
|
|
1942
|
+
path.includes("/order/status/") ||
|
|
1943
|
+
path === "iserver/account/trades" ||
|
|
1944
|
+
path.startsWith("iserver/reply/")) {
|
|
1945
|
+
return "EXECUTION";
|
|
1946
|
+
}
|
|
1947
|
+
if (path.includes("secdef"))
|
|
1948
|
+
return "DISCOVERY";
|
|
1949
|
+
return "STANDARD";
|
|
1950
|
+
}
|
|
1951
|
+
requestEndpoint(path) {
|
|
1952
|
+
if (path.includes("secdef/"))
|
|
1953
|
+
return path.slice(path.indexOf("secdef/"));
|
|
1954
|
+
if (path.includes("/order/status/"))
|
|
1955
|
+
return "account/order/status";
|
|
1956
|
+
if (path.includes("/orders/whatif"))
|
|
1957
|
+
return "account/orders/whatif";
|
|
1958
|
+
if (path.endsWith("/orders"))
|
|
1959
|
+
return "account/orders";
|
|
1960
|
+
if (path.includes("/order/"))
|
|
1961
|
+
return "account/order";
|
|
1962
|
+
if (path.startsWith("iserver/reply/"))
|
|
1963
|
+
return "reply";
|
|
1964
|
+
if (path === "iserver/account/trades")
|
|
1965
|
+
return "account/trades";
|
|
1966
|
+
return path.split("/").slice(0, 2).join("/");
|
|
1967
|
+
}
|
|
1968
|
+
classifyRequestError(error) {
|
|
1969
|
+
if (/temporar(?:ily|y).*(?:block|ban)|(?:ip|access).*(?:temporar(?:ily|y) )?blocked/i.test(this.requestErrorText(error))) {
|
|
1970
|
+
return { kind: "TEMPORARILY_BLOCKED" };
|
|
1971
|
+
}
|
|
1972
|
+
if (this.httpStatusFromError(error) === 429) {
|
|
1973
|
+
const retryAfterMs = this.retryAfterFromError(error);
|
|
1974
|
+
return retryAfterMs === undefined
|
|
1975
|
+
? { kind: "THROTTLED" }
|
|
1976
|
+
: { kind: "THROTTLED", retryAfterMs };
|
|
1977
|
+
}
|
|
1978
|
+
return { kind: "OTHER" };
|
|
1979
|
+
}
|
|
1980
|
+
requestErrorText(error) {
|
|
1981
|
+
if (typeof error !== "object" || error === null)
|
|
1982
|
+
return String(error);
|
|
1983
|
+
const message = error.message;
|
|
1984
|
+
const response = error.response;
|
|
1985
|
+
const responseData = typeof response === "object" && response !== null && "data" in response
|
|
1986
|
+
? response.data
|
|
1987
|
+
: undefined;
|
|
1988
|
+
const body = error.body;
|
|
1989
|
+
return [message, responseData, body]
|
|
1990
|
+
.flatMap((value) => {
|
|
1991
|
+
if (typeof value === "string")
|
|
1992
|
+
return [value];
|
|
1993
|
+
if (value === undefined)
|
|
1994
|
+
return [];
|
|
1577
1995
|
try {
|
|
1578
|
-
return
|
|
1996
|
+
return [JSON.stringify(value)];
|
|
1579
1997
|
}
|
|
1580
|
-
catch
|
|
1581
|
-
|
|
1582
|
-
if (status !== 429 || retries >= READ_ONLY_REQUEST_MAX_RETRIES) {
|
|
1583
|
-
throw error;
|
|
1584
|
-
}
|
|
1585
|
-
const retryAfter = this.retryAfterFromError(error);
|
|
1586
|
-
const delayMs = this.computeBackoffDelayMs(retries, retryAfter);
|
|
1587
|
-
retries += 1;
|
|
1588
|
-
await this.wait(delayMs);
|
|
1998
|
+
catch {
|
|
1999
|
+
return [];
|
|
1589
2000
|
}
|
|
1590
|
-
}
|
|
2001
|
+
})
|
|
2002
|
+
.join(" ");
|
|
1591
2003
|
}
|
|
1592
2004
|
httpStatusFromError(error) {
|
|
1593
2005
|
if (typeof error !== "object" || error === null)
|
|
@@ -1623,11 +2035,6 @@ export class IbkrClient {
|
|
|
1623
2035
|
this.headerValue(directHeaders, "Retry-After");
|
|
1624
2036
|
return parseRetryAfter(retryAfterRaw);
|
|
1625
2037
|
}
|
|
1626
|
-
computeBackoffDelayMs(retry, retryAfterMs) {
|
|
1627
|
-
if (retryAfterMs !== undefined)
|
|
1628
|
-
return Math.min(retryAfterMs, REQUEST_RETRY_MAX_DELAY_MS);
|
|
1629
|
-
return Math.min(REQUEST_RETRY_BASE_DELAY_MS * 2 ** retry, REQUEST_RETRY_MAX_DELAY_MS);
|
|
1630
|
-
}
|
|
1631
2038
|
numberFromUnknown(value) {
|
|
1632
2039
|
if (typeof value === "number")
|
|
1633
2040
|
return Number.isFinite(value) ? value : undefined;
|