@huskly/ibkr-client 0.10.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 +56 -0
- package/dist/ibkr/ibkrApiTypes.d.ts +52 -0
- package/dist/ibkr/ibkrApiTypes.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.d.ts +53 -16
- package/dist/ibkr/ibkrClient.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.js +609 -38
- 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 +152 -0
- 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",
|
|
@@ -76,6 +74,7 @@ const IBKR_WORKING_STATUSES = new Set([
|
|
|
76
74
|
"SUBMITTED",
|
|
77
75
|
"PENDING_CANCEL",
|
|
78
76
|
]);
|
|
77
|
+
const KNOWN_ORDER_WARNING_IDS = new Set(["o163"]);
|
|
79
78
|
/** Extract the canonical OSI symbol embedded in an IBKR option description. */
|
|
80
79
|
function extractOsiPositionSymbol(contractDescription) {
|
|
81
80
|
return /\[([A-Z]+\s*\d{6}[CP]\d{8})\s+\d+\]\s*$/.exec(contractDescription)?.[1];
|
|
@@ -166,8 +165,19 @@ export class IbkrClient {
|
|
|
166
165
|
accountIdPromise;
|
|
167
166
|
optionDiscovery = new Map();
|
|
168
167
|
derivativeDiscovery = new Map();
|
|
169
|
-
|
|
168
|
+
requestScheduler;
|
|
169
|
+
constructor(config, options = {}) {
|
|
170
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
|
+
});
|
|
171
181
|
}
|
|
172
182
|
/** Obtain the live session token (idempotent — safe to await repeatedly). */
|
|
173
183
|
init() {
|
|
@@ -223,30 +233,168 @@ export class IbkrClient {
|
|
|
223
233
|
path: "iserver/marketdata/snapshot",
|
|
224
234
|
params: { conids, fields: "6509" },
|
|
225
235
|
});
|
|
226
|
-
const exchange = request.legs[0].contract.exchange;
|
|
227
|
-
const spreadConid = exchange === "SMART" ? "28812380" : `28812380@${exchange}`;
|
|
228
|
-
const conidex = `${spreadConid};;;${request.legs
|
|
229
|
-
.map(({ contract, ratio }) => `${String(contract.conid)}/${String(ratio)}`)
|
|
230
|
-
.join(",")}`;
|
|
231
236
|
const response = await this.req({
|
|
232
237
|
path: `iserver/account/${request.accountId}/orders/whatif`,
|
|
233
238
|
method: "POST",
|
|
239
|
+
data: {
|
|
240
|
+
orders: [this.comboOrderTicket(request)],
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
return this.normalizeComboPreview(request.accountId, diagnostics, response);
|
|
244
|
+
}
|
|
245
|
+
async submitDerivativeCombo(request) {
|
|
246
|
+
this.validateComboPreview(request);
|
|
247
|
+
if (!request.clientOrderId.trim() || request.clientOrderId.length > 64) {
|
|
248
|
+
throw new Error("Client order ID must contain 1 to 64 characters");
|
|
249
|
+
}
|
|
250
|
+
const cmeOperatorMetadata = this.cmeOperatorMetadata(request.legs[0].contract.assetClass, request);
|
|
251
|
+
const diagnostics = await this.getTradingDiagnostics(request.accountId);
|
|
252
|
+
if (!diagnostics.authenticated || diagnostics.competingSession) {
|
|
253
|
+
throw new Error("IBKR brokerage session is not safely authenticated for submission");
|
|
254
|
+
}
|
|
255
|
+
await this.prepareBrokerageAccount(request.accountId);
|
|
256
|
+
const response = await this.singleAttemptRequest({
|
|
257
|
+
path: `iserver/account/${request.accountId}/orders`,
|
|
258
|
+
method: "POST",
|
|
234
259
|
data: {
|
|
235
260
|
orders: [
|
|
236
261
|
{
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
price: request.priceEffect === "CREDIT" ? -request.limit : request.limit,
|
|
241
|
-
side: "BUY",
|
|
242
|
-
tif: request.tif,
|
|
243
|
-
quantity: request.quantity,
|
|
244
|
-
outsideRTH: request.session === "OVERNIGHT",
|
|
262
|
+
...this.comboOrderTicket(request),
|
|
263
|
+
cOID: request.clientOrderId,
|
|
264
|
+
...cmeOperatorMetadata,
|
|
245
265
|
},
|
|
246
266
|
],
|
|
247
267
|
},
|
|
248
268
|
});
|
|
249
|
-
return this.
|
|
269
|
+
return this.normalizeOrderSubmission(response, request.clientOrderId);
|
|
270
|
+
}
|
|
271
|
+
async acknowledgeOrderWarning(input) {
|
|
272
|
+
if (!input.replyId.trim())
|
|
273
|
+
throw new Error("An exact warning reply ID is required");
|
|
274
|
+
const response = await this.singleAttemptRequest({
|
|
275
|
+
path: `iserver/reply/${encodeURIComponent(input.replyId)}`,
|
|
276
|
+
method: "POST",
|
|
277
|
+
data: { confirmed: true },
|
|
278
|
+
});
|
|
279
|
+
return this.normalizeOrderSubmission(response, null);
|
|
280
|
+
}
|
|
281
|
+
async getDerivativeOrderStatus(accountId, orderId) {
|
|
282
|
+
if (!accountId.trim() || !orderId.trim()) {
|
|
283
|
+
throw new Error("Exact account and order IDs are required");
|
|
284
|
+
}
|
|
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);
|
|
311
|
+
const response = await this.req({
|
|
312
|
+
path: "iserver/account/orders",
|
|
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;
|
|
319
|
+
});
|
|
320
|
+
if (order === undefined)
|
|
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())));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async cancelDerivativeOrder(input) {
|
|
382
|
+
if (!input.accountId.trim() || !input.orderId.trim()) {
|
|
383
|
+
throw new Error("Exact account and order IDs are required");
|
|
384
|
+
}
|
|
385
|
+
const cmeOperatorMetadata = this.cmeOperatorMetadata(input.assetClass, input);
|
|
386
|
+
await this.prepareBrokerageAccount(input.accountId);
|
|
387
|
+
const response = await this.singleAttemptRequest({
|
|
388
|
+
path: `iserver/account/${input.accountId}/order/${encodeURIComponent(input.orderId)}`,
|
|
389
|
+
method: "DELETE",
|
|
390
|
+
...(Object.keys(cmeOperatorMetadata).length > 0 ? { params: cmeOperatorMetadata } : {}),
|
|
391
|
+
});
|
|
392
|
+
return {
|
|
393
|
+
state: "requested",
|
|
394
|
+
accountId: input.accountId,
|
|
395
|
+
orderId: input.orderId,
|
|
396
|
+
message: this.trimmedString(response.msg),
|
|
397
|
+
};
|
|
250
398
|
}
|
|
251
399
|
async getAccountId() {
|
|
252
400
|
this.accountIdPromise ??= (async () => {
|
|
@@ -468,6 +616,362 @@ export class IbkrClient {
|
|
|
468
616
|
}
|
|
469
617
|
}
|
|
470
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
|
+
}
|
|
630
|
+
comboOrderTicket(request) {
|
|
631
|
+
const exchange = request.legs[0].contract.exchange;
|
|
632
|
+
const spreadConid = exchange === "SMART" ? "28812380" : `28812380@${exchange}`;
|
|
633
|
+
return {
|
|
634
|
+
acctId: request.accountId,
|
|
635
|
+
conidex: `${spreadConid};;;${request.legs
|
|
636
|
+
.map(({ contract, ratio }) => `${String(contract.conid)}/${String(ratio)}`)
|
|
637
|
+
.join(",")}`,
|
|
638
|
+
orderType: "LMT",
|
|
639
|
+
price: request.priceEffect === "CREDIT" ? -request.limit : request.limit,
|
|
640
|
+
side: "BUY",
|
|
641
|
+
tif: request.tif,
|
|
642
|
+
quantity: request.quantity,
|
|
643
|
+
outsideRTH: request.session === "OVERNIGHT",
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
normalizeOrderSubmission(response, clientOrderId) {
|
|
647
|
+
const items = Array.isArray(response) ? response : [response];
|
|
648
|
+
const warnings = items.flatMap((item) => {
|
|
649
|
+
if (!("id" in item) || typeof item.id !== "string")
|
|
650
|
+
return [];
|
|
651
|
+
const messageIds = Array.isArray(item.messageIds)
|
|
652
|
+
? item.messageIds.filter((value) => typeof value === "string")
|
|
653
|
+
: [];
|
|
654
|
+
return [
|
|
655
|
+
{
|
|
656
|
+
replyId: item.id,
|
|
657
|
+
messages: Array.isArray(item.message)
|
|
658
|
+
? item.message.filter((value) => typeof value === "string")
|
|
659
|
+
: [],
|
|
660
|
+
messageIds,
|
|
661
|
+
known: messageIds.length > 0 && messageIds.every((id) => KNOWN_ORDER_WARNING_IDS.has(id)),
|
|
662
|
+
},
|
|
663
|
+
];
|
|
664
|
+
});
|
|
665
|
+
if (warnings.length > 0)
|
|
666
|
+
return { state: "warning", warnings };
|
|
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
|
+
}
|
|
675
|
+
const accepted = items.find((item) => "order_id" in item || "orderId" in item);
|
|
676
|
+
if (accepted !== undefined) {
|
|
677
|
+
const orderId = ("order_id" in accepted ? accepted.order_id : undefined) ??
|
|
678
|
+
("orderId" in accepted ? accepted.orderId : undefined);
|
|
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);
|
|
682
|
+
return {
|
|
683
|
+
state: "accepted",
|
|
684
|
+
orderId: String(orderId),
|
|
685
|
+
status: this.normalizeDerivativeOrderStatus(typeof orderStatus === "string" ? orderStatus : undefined, 0, 0),
|
|
686
|
+
clientOrderId,
|
|
687
|
+
warnings: [],
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
}
|
|
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
|
+
};
|
|
716
|
+
}
|
|
717
|
+
normalizeDerivativeOrderLifecycle(accountId, orderId, order) {
|
|
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
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
accountId,
|
|
730
|
+
orderId,
|
|
731
|
+
clientOrderId: order.cOID ?? order.order_ref ?? null,
|
|
732
|
+
status: this.normalizeDerivativeOrderStatus(order.order_status ?? order.orderStatus ?? order.status, filledQuantity, remainingQuantity),
|
|
733
|
+
quantity,
|
|
734
|
+
filledQuantity,
|
|
735
|
+
remainingQuantity,
|
|
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,
|
|
738
|
+
commissionAndFees: typeof order.commissionAndFees === "number"
|
|
739
|
+
? order.commissionAndFees
|
|
740
|
+
: this.whatIfNumber(order.commissionAndFees),
|
|
741
|
+
legs: this.parseComboLegs(order.conidex),
|
|
742
|
+
updatedAt: this.parseOrderTime(order)?.toISOString() ?? null,
|
|
743
|
+
};
|
|
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
|
+
}
|
|
943
|
+
parseComboLegs(conidex) {
|
|
944
|
+
const encoded = conidex?.split(";;;")[1];
|
|
945
|
+
if (!encoded)
|
|
946
|
+
return [];
|
|
947
|
+
return encoded.split(",").flatMap((leg) => {
|
|
948
|
+
const [conidValue, ratioValue] = leg.split("/");
|
|
949
|
+
const conid = Number(conidValue);
|
|
950
|
+
const ratio = Number(ratioValue);
|
|
951
|
+
return Number.isSafeInteger(conid) && conid > 0 && Number.isSafeInteger(ratio) && ratio !== 0
|
|
952
|
+
? [{ conid, ratio }]
|
|
953
|
+
: [];
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
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";
|
|
963
|
+
if (status === "FILLED")
|
|
964
|
+
return "FILLED";
|
|
965
|
+
if (status === "CANCELLED" || status === "CANCELED")
|
|
966
|
+
return "CANCELED";
|
|
967
|
+
if (status === "INACTIVE" || status === "REJECTED")
|
|
968
|
+
return "REJECTED";
|
|
969
|
+
if (status === "API_PENDING" || status === "PENDING_SUBMIT")
|
|
970
|
+
return "PENDING";
|
|
971
|
+
if (status !== undefined && IBKR_WORKING_STATUSES.has(status))
|
|
972
|
+
return "WORKING";
|
|
973
|
+
return "UNKNOWN";
|
|
974
|
+
}
|
|
471
975
|
normalizeComboPreview(accountId, diagnostics, response) {
|
|
472
976
|
const commission = this.whatIfNumber(response.amount?.commission);
|
|
473
977
|
const initialMargin = this.whatIfMargin(response.initial);
|
|
@@ -608,7 +1112,10 @@ export class IbkrClient {
|
|
|
608
1112
|
}
|
|
609
1113
|
/** Discover listed derivative series over an inclusive calendar range. */
|
|
610
1114
|
async getDerivativeExpiries(query) {
|
|
611
|
-
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
|
+
}
|
|
612
1119
|
const filtered = contracts.filter((contract) => contract.expiration >= query.from &&
|
|
613
1120
|
contract.expiration <= query.to &&
|
|
614
1121
|
(query.right === undefined || contract.right === query.right) &&
|
|
@@ -1286,6 +1793,14 @@ export class IbkrClient {
|
|
|
1286
1793
|
wait(ms) {
|
|
1287
1794
|
return sleep(ms);
|
|
1288
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
|
+
}
|
|
1289
1804
|
normalizeQuote(contract, snapshot, history) {
|
|
1290
1805
|
const symbol = this.snapshotString(snapshot, "55") ?? contract.symbol;
|
|
1291
1806
|
const description = this.snapshotString(snapshot, "58") ?? history?.text ?? contract.description;
|
|
@@ -1407,23 +1922,84 @@ export class IbkrClient {
|
|
|
1407
1922
|
async sendRequest(input) {
|
|
1408
1923
|
return (await this.raw.request(input));
|
|
1409
1924
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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 [];
|
|
1413
1995
|
try {
|
|
1414
|
-
return
|
|
1996
|
+
return [JSON.stringify(value)];
|
|
1415
1997
|
}
|
|
1416
|
-
catch
|
|
1417
|
-
|
|
1418
|
-
if (status !== 429 || retries >= READ_ONLY_REQUEST_MAX_RETRIES) {
|
|
1419
|
-
throw error;
|
|
1420
|
-
}
|
|
1421
|
-
const retryAfter = this.retryAfterFromError(error);
|
|
1422
|
-
const delayMs = this.computeBackoffDelayMs(retries, retryAfter);
|
|
1423
|
-
retries += 1;
|
|
1424
|
-
await this.wait(delayMs);
|
|
1998
|
+
catch {
|
|
1999
|
+
return [];
|
|
1425
2000
|
}
|
|
1426
|
-
}
|
|
2001
|
+
})
|
|
2002
|
+
.join(" ");
|
|
1427
2003
|
}
|
|
1428
2004
|
httpStatusFromError(error) {
|
|
1429
2005
|
if (typeof error !== "object" || error === null)
|
|
@@ -1459,11 +2035,6 @@ export class IbkrClient {
|
|
|
1459
2035
|
this.headerValue(directHeaders, "Retry-After");
|
|
1460
2036
|
return parseRetryAfter(retryAfterRaw);
|
|
1461
2037
|
}
|
|
1462
|
-
computeBackoffDelayMs(retry, retryAfterMs) {
|
|
1463
|
-
if (retryAfterMs !== undefined)
|
|
1464
|
-
return Math.min(retryAfterMs, REQUEST_RETRY_MAX_DELAY_MS);
|
|
1465
|
-
return Math.min(REQUEST_RETRY_BASE_DELAY_MS * 2 ** retry, REQUEST_RETRY_MAX_DELAY_MS);
|
|
1466
|
-
}
|
|
1467
2038
|
numberFromUnknown(value) {
|
|
1468
2039
|
if (typeof value === "number")
|
|
1469
2040
|
return Number.isFinite(value) ? value : undefined;
|