@huskly/ibkr-client 0.11.0 → 0.13.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 +70 -9
- package/dist/ibkr/ibkrApiTypes.d.ts +35 -1
- package/dist/ibkr/ibkrApiTypes.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.d.ts +64 -21
- package/dist/ibkr/ibkrClient.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.js +839 -88
- 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 +191 -8
- 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,54 +261,226 @@ 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
|
},
|
|
261
268
|
});
|
|
262
269
|
return this.normalizeOrderSubmission(response, request.clientOrderId);
|
|
263
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
|
+
}
|
|
264
339
|
async acknowledgeOrderWarning(input) {
|
|
265
340
|
if (!input.replyId.trim())
|
|
266
341
|
throw new Error("An exact warning reply ID is required");
|
|
267
|
-
const response = await this.
|
|
342
|
+
const response = await this.singleAttemptRequest({
|
|
268
343
|
path: `iserver/reply/${encodeURIComponent(input.replyId)}`,
|
|
269
344
|
method: "POST",
|
|
270
345
|
data: { confirmed: true },
|
|
271
346
|
});
|
|
272
347
|
return this.normalizeOrderSubmission(response, null);
|
|
273
348
|
}
|
|
349
|
+
async acknowledgeContingentOrderWarning(input) {
|
|
350
|
+
const confirmed = input.confirmed;
|
|
351
|
+
if (confirmed !== true) {
|
|
352
|
+
throw new Error("Order warning confirmation must be true");
|
|
353
|
+
}
|
|
354
|
+
if (!input.continuation.replyId.trim()) {
|
|
355
|
+
throw new Error("An exact warning reply ID is required");
|
|
356
|
+
}
|
|
357
|
+
if (!input.continuation.parentClientOrderId.trim()) {
|
|
358
|
+
throw new Error("An exact parent client order ID is required");
|
|
359
|
+
}
|
|
360
|
+
const response = await this.singleAttemptRequest({
|
|
361
|
+
path: `iserver/reply/${encodeURIComponent(input.continuation.replyId)}`,
|
|
362
|
+
method: "POST",
|
|
363
|
+
data: { confirmed: true },
|
|
364
|
+
});
|
|
365
|
+
return this.normalizeMultiOrderSubmission(response, input.continuation.parentClientOrderId);
|
|
366
|
+
}
|
|
274
367
|
async getDerivativeOrderStatus(accountId, orderId) {
|
|
275
368
|
if (!accountId.trim() || !orderId.trim()) {
|
|
276
369
|
throw new Error("Exact account and order IDs are required");
|
|
277
370
|
}
|
|
278
371
|
await this.prepareBrokerageAccount(accountId);
|
|
372
|
+
const order = await this.req({
|
|
373
|
+
path: `iserver/account/order/status/${encodeURIComponent(orderId)}`,
|
|
374
|
+
});
|
|
375
|
+
if (String(order.order_id ?? order.orderId ?? "") !== orderId) {
|
|
376
|
+
throw new Error(`IBKR response does not match the requested order ${orderId}`);
|
|
377
|
+
}
|
|
378
|
+
if ((order.account ?? order.acct) !== accountId) {
|
|
379
|
+
throw new Error(`IBKR order ${orderId} does not belong to the requested account`);
|
|
380
|
+
}
|
|
381
|
+
const lifecycle = this.normalizeDerivativeOrderLifecycle(accountId, orderId, order);
|
|
382
|
+
if (lifecycle.status === "UNKNOWN") {
|
|
383
|
+
throw new Error(`IBKR order ${orderId} returned an unrecognized status`);
|
|
384
|
+
}
|
|
385
|
+
return lifecycle;
|
|
386
|
+
}
|
|
387
|
+
async findDerivativeOrder(input) {
|
|
388
|
+
if (!input.accountId.trim())
|
|
389
|
+
throw new Error("An exact account ID is required");
|
|
390
|
+
const identity = input.orderId ?? input.clientOrderId;
|
|
391
|
+
if (!identity.trim())
|
|
392
|
+
throw new Error("An exact broker or client order ID is required");
|
|
393
|
+
if (input.orderId !== undefined) {
|
|
394
|
+
return this.getDerivativeOrderStatus(input.accountId, input.orderId);
|
|
395
|
+
}
|
|
396
|
+
await this.prepareBrokerageAccount(input.accountId);
|
|
279
397
|
const response = await this.req({
|
|
280
398
|
path: "iserver/account/orders",
|
|
281
|
-
params: { force: true, accountId },
|
|
399
|
+
params: { force: true, accountId: input.accountId },
|
|
400
|
+
});
|
|
401
|
+
const order = response.orders?.find((candidate) => {
|
|
402
|
+
if (!this.orderBelongsToAccount(candidate, input.accountId))
|
|
403
|
+
return false;
|
|
404
|
+
return (candidate.cOID ?? candidate.order_ref) === input.clientOrderId;
|
|
282
405
|
});
|
|
283
|
-
const order = response.orders?.find((candidate) => String(candidate.order_id ?? candidate.orderId) === orderId);
|
|
284
406
|
if (order === undefined)
|
|
285
|
-
throw new Error(`IBKR order ${
|
|
286
|
-
|
|
287
|
-
|
|
407
|
+
throw new Error(`IBKR order ${identity} was not found`);
|
|
408
|
+
const orderId = order.order_id ?? order.orderId;
|
|
409
|
+
if (orderId === undefined) {
|
|
410
|
+
throw new Error(`IBKR order ${identity} did not include a broker order ID`);
|
|
411
|
+
}
|
|
412
|
+
return this.getDerivativeOrderStatus(input.accountId, String(orderId));
|
|
413
|
+
}
|
|
414
|
+
async getDerivativeExecutions(input) {
|
|
415
|
+
if (!input.accountId.trim())
|
|
416
|
+
throw new Error("An exact account ID is required");
|
|
417
|
+
if (input.days !== undefined &&
|
|
418
|
+
(!Number.isSafeInteger(input.days) || input.days < 1 || input.days > 7)) {
|
|
419
|
+
throw new Error("Execution history days must be an integer from 1 through 7");
|
|
420
|
+
}
|
|
421
|
+
if (input.orderId !== undefined && !input.orderId.trim()) {
|
|
422
|
+
throw new Error("Broker order ID cannot be empty");
|
|
423
|
+
}
|
|
424
|
+
if (input.clientOrderId !== undefined && !input.clientOrderId.trim()) {
|
|
425
|
+
throw new Error("Client order ID cannot be empty");
|
|
426
|
+
}
|
|
427
|
+
await this.prepareBrokerageAccount(input.accountId);
|
|
428
|
+
const response = await this.req({
|
|
429
|
+
path: "iserver/account/trades",
|
|
430
|
+
...(input.days === undefined ? {} : { params: { days: input.days } }),
|
|
431
|
+
});
|
|
432
|
+
return response
|
|
433
|
+
.filter((trade) => (trade.account ?? trade.accountCode) === input.accountId)
|
|
434
|
+
.filter((trade) => input.orderId === undefined || String(trade.order_id) === input.orderId)
|
|
435
|
+
.filter((trade) => input.clientOrderId === undefined || trade.order_ref === input.clientOrderId)
|
|
436
|
+
.flatMap((trade) => {
|
|
437
|
+
const execution = this.normalizeDerivativeExecution(input.accountId, trade);
|
|
438
|
+
return execution === undefined ? [] : [execution];
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
async reconcileDerivativeComboExecution(request) {
|
|
442
|
+
this.validateReconciliationRequest(request);
|
|
443
|
+
const lifecycle = await this.getDerivativeOrderStatus(request.accountId, request.orderId);
|
|
444
|
+
const deadline = this.now() + (request.timeoutMs ?? 30_000);
|
|
445
|
+
const pollMs = request.pollMs ?? 1_000;
|
|
446
|
+
for (;;) {
|
|
447
|
+
const executions = await this.getDerivativeExecutions({
|
|
448
|
+
accountId: request.accountId,
|
|
449
|
+
clientOrderId: request.clientOrderId,
|
|
450
|
+
days: 1,
|
|
451
|
+
});
|
|
452
|
+
const result = this.evaluateDerivativeReconciliation(request, lifecycle, executions);
|
|
453
|
+
if (result.state !== "PENDING")
|
|
454
|
+
return result;
|
|
455
|
+
if (!this.isTerminalDerivativeStatus(lifecycle.status))
|
|
456
|
+
return result;
|
|
457
|
+
if (this.now() >= deadline) {
|
|
458
|
+
return {
|
|
459
|
+
...result,
|
|
460
|
+
state: "RECOVERY_REQUIRED",
|
|
461
|
+
reason: result.reason ?? "Terminal order is missing expected execution evidence",
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
await this.wait(Math.min(pollMs, Math.max(0, deadline - this.now())));
|
|
288
465
|
}
|
|
289
|
-
return this.normalizeDerivativeOrderLifecycle(accountId, orderId, order);
|
|
290
466
|
}
|
|
291
467
|
async cancelDerivativeOrder(input) {
|
|
292
|
-
if (!input.accountId.trim() || !input.orderId.trim()
|
|
293
|
-
throw new Error("Exact account
|
|
468
|
+
if (!input.accountId.trim() || !input.orderId.trim()) {
|
|
469
|
+
throw new Error("Exact account and order IDs are required");
|
|
294
470
|
}
|
|
471
|
+
const cmeOperatorMetadata = this.cmeOperatorMetadata(input.assetClass, input);
|
|
295
472
|
await this.prepareBrokerageAccount(input.accountId);
|
|
296
|
-
await this.
|
|
473
|
+
const response = await this.singleAttemptRequest({
|
|
297
474
|
path: `iserver/account/${input.accountId}/order/${encodeURIComponent(input.orderId)}`,
|
|
298
475
|
method: "DELETE",
|
|
299
|
-
params: {
|
|
300
|
-
extOperator: input.extOperator,
|
|
301
|
-
manualIndicator: input.manualIndicator,
|
|
302
|
-
},
|
|
476
|
+
...(Object.keys(cmeOperatorMetadata).length > 0 ? { params: cmeOperatorMetadata } : {}),
|
|
303
477
|
});
|
|
478
|
+
return {
|
|
479
|
+
state: "requested",
|
|
480
|
+
accountId: input.accountId,
|
|
481
|
+
orderId: input.orderId,
|
|
482
|
+
message: this.trimmedString(response.msg),
|
|
483
|
+
};
|
|
304
484
|
}
|
|
305
485
|
async getAccountId() {
|
|
306
486
|
this.accountIdPromise ??= (async () => {
|
|
@@ -522,6 +702,66 @@ export class IbkrClient {
|
|
|
522
702
|
}
|
|
523
703
|
}
|
|
524
704
|
}
|
|
705
|
+
validateSingleOrder(request) {
|
|
706
|
+
this.validateSingleOrderFields(request);
|
|
707
|
+
const identityFields = request;
|
|
708
|
+
const hasClientOrderId = "clientOrderId" in identityFields;
|
|
709
|
+
const hasParentId = "parentId" in identityFields;
|
|
710
|
+
if (hasClientOrderId && typeof identityFields.clientOrderId !== "string") {
|
|
711
|
+
throw new Error("Client order ID must be a string");
|
|
712
|
+
}
|
|
713
|
+
if (hasParentId && typeof identityFields.parentId !== "string") {
|
|
714
|
+
throw new Error("Parent order ID must be a string");
|
|
715
|
+
}
|
|
716
|
+
const clientOrderId = hasClientOrderId ? identityFields.clientOrderId : undefined;
|
|
717
|
+
const parentId = hasParentId ? identityFields.parentId : undefined;
|
|
718
|
+
if (clientOrderId !== undefined && parentId !== undefined) {
|
|
719
|
+
throw new Error("Attached child orders must not include a client order ID");
|
|
720
|
+
}
|
|
721
|
+
const identity = clientOrderId ?? parentId;
|
|
722
|
+
if (!identity?.trim() || identity.length > 64) {
|
|
723
|
+
throw new Error(parentId === undefined
|
|
724
|
+
? "Client order ID must contain 1 to 64 characters"
|
|
725
|
+
: "Parent order ID must contain 1 to 64 characters");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
validateSingleOrderFields(request) {
|
|
729
|
+
if (!request.accountId.trim())
|
|
730
|
+
throw new Error("An explicit IBKR account ID is required");
|
|
731
|
+
const orderType = request.orderType;
|
|
732
|
+
if (orderType !== "LMT" && orderType !== "STP") {
|
|
733
|
+
throw new Error("Order type must be LMT or STP");
|
|
734
|
+
}
|
|
735
|
+
if (!Number.isSafeInteger(request.quantity) || request.quantity <= 0) {
|
|
736
|
+
throw new Error("Order quantity must be a positive integer");
|
|
737
|
+
}
|
|
738
|
+
if (!Number.isSafeInteger(request.contract.conid) || request.contract.conid <= 0) {
|
|
739
|
+
throw new Error("Order contract has an invalid IBKR conid");
|
|
740
|
+
}
|
|
741
|
+
if (request.orderType === "LMT") {
|
|
742
|
+
const limit = request.limit;
|
|
743
|
+
if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) {
|
|
744
|
+
throw new Error("LIMIT order requires a positive limit price");
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (request.orderType === "STP") {
|
|
748
|
+
const stopPrice = request.stopPrice;
|
|
749
|
+
if (typeof stopPrice !== "number" || !Number.isFinite(stopPrice) || stopPrice <= 0) {
|
|
750
|
+
throw new Error("STOP order requires a positive stop price");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
cmeOperatorMetadata(assetClass, input) {
|
|
755
|
+
if (assetClass === "OPT")
|
|
756
|
+
return {};
|
|
757
|
+
if (!input.extOperator?.trim() || input.manualIndicator === undefined) {
|
|
758
|
+
throw new Error(`${assetClass} orders require exact CME operator metadata`);
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
extOperator: input.extOperator,
|
|
762
|
+
manualIndicator: input.manualIndicator,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
525
765
|
comboOrderTicket(request) {
|
|
526
766
|
const exchange = request.legs[0].contract.exchange;
|
|
527
767
|
const spreadConid = exchange === "SMART" ? "28812380" : `28812380@${exchange}`;
|
|
@@ -538,61 +778,304 @@ export class IbkrClient {
|
|
|
538
778
|
outsideRTH: request.session === "OVERNIGHT",
|
|
539
779
|
};
|
|
540
780
|
}
|
|
781
|
+
singleOrderTicket(request) {
|
|
782
|
+
return {
|
|
783
|
+
acctId: request.accountId,
|
|
784
|
+
conid: request.contract.conid,
|
|
785
|
+
orderType: request.orderType,
|
|
786
|
+
side: request.side,
|
|
787
|
+
price: request.orderType === "LMT" ? request.limit : request.stopPrice,
|
|
788
|
+
tif: request.tif,
|
|
789
|
+
quantity: request.quantity,
|
|
790
|
+
outsideRTH: request.session === "OVERNIGHT",
|
|
791
|
+
};
|
|
792
|
+
}
|
|
541
793
|
normalizeOrderSubmission(response, clientOrderId) {
|
|
542
|
-
const
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
794
|
+
const decoded = this.decodeOrderSubmission(response);
|
|
795
|
+
const correlatedClientOrderId = decoded.orders.length === 1 ? clientOrderId : null;
|
|
796
|
+
const orders = decoded.orders.map((order) => ({
|
|
797
|
+
...order,
|
|
798
|
+
clientOrderId: correlatedClientOrderId,
|
|
799
|
+
}));
|
|
800
|
+
const hasWarnings = decoded.warnings.length > 0;
|
|
801
|
+
const hasErrors = decoded.errors.length > 0;
|
|
802
|
+
const hasUnknown = decoded.unrecognizedResponses.length > 0;
|
|
803
|
+
const hasPendingCancel = decoded.pendingCancelOrderIds.length > 0;
|
|
804
|
+
if (decoded.warnings.length === 1 && !hasErrors && orders.length === 0 && !hasUnknown) {
|
|
805
|
+
return { state: "warning", warnings: decoded.warnings };
|
|
806
|
+
}
|
|
807
|
+
if (hasErrors &&
|
|
808
|
+
!decoded.responseIsArray &&
|
|
809
|
+
!hasWarnings &&
|
|
810
|
+
orders.length === 0 &&
|
|
811
|
+
!hasUnknown) {
|
|
812
|
+
return {
|
|
813
|
+
state: "rejected",
|
|
814
|
+
reasons: decoded.errors.map(({ message }) => message),
|
|
815
|
+
errors: decoded.errors,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
if (!hasWarnings && !hasErrors && !hasUnknown && !hasPendingCancel && orders.length === 1) {
|
|
819
|
+
const order = orders[0];
|
|
820
|
+
if (order === undefined)
|
|
821
|
+
throw new Error("Single-order normalization lost order evidence");
|
|
822
|
+
if (order.status === "REJECTED" || order.status === "CANCELED") {
|
|
568
823
|
return {
|
|
569
|
-
state: "
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
clientOrderId,
|
|
574
|
-
warnings: [],
|
|
824
|
+
state: "rejected",
|
|
825
|
+
reasons: [`Order ${order.orderId} returned terminal status ${order.status}`],
|
|
826
|
+
errors: [],
|
|
827
|
+
orders,
|
|
575
828
|
};
|
|
576
829
|
}
|
|
830
|
+
if (order.status !== "UNKNOWN") {
|
|
831
|
+
return { state: "accepted", ...order, warnings: [] };
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return this.singleOrderRecoveryResult(decoded, orders);
|
|
835
|
+
}
|
|
836
|
+
normalizeMultiOrderSubmission(response, parentClientOrderId) {
|
|
837
|
+
const decoded = this.decodeOrderSubmission(response);
|
|
838
|
+
const hasDistinctBrokerOrderIds = decoded.orders.length === 2 &&
|
|
839
|
+
new Set(decoded.orders.map(({ orderId }) => orderId)).size === 2;
|
|
840
|
+
const rolesArePositionallyComplete = decoded.orders.length === 2 &&
|
|
841
|
+
hasDistinctBrokerOrderIds &&
|
|
842
|
+
decoded.warnings.length === 0 &&
|
|
843
|
+
decoded.errors.length === 0 &&
|
|
844
|
+
decoded.unrecognizedResponses.length === 0;
|
|
845
|
+
const orders = decoded.orders.map((order, index) => ({
|
|
846
|
+
...order,
|
|
847
|
+
clientOrderId: rolesArePositionallyComplete && index === 0 ? parentClientOrderId : null,
|
|
848
|
+
role: rolesArePositionallyComplete && index === 0
|
|
849
|
+
? "parent"
|
|
850
|
+
: rolesArePositionallyComplete && index === 1
|
|
851
|
+
? "child"
|
|
852
|
+
: "unknown",
|
|
853
|
+
}));
|
|
854
|
+
const hasWarnings = decoded.warnings.length > 0;
|
|
855
|
+
const hasErrors = decoded.errors.length > 0;
|
|
856
|
+
const hasUnknown = decoded.unrecognizedResponses.length > 0;
|
|
857
|
+
if (decoded.warnings.length === 1 && !hasErrors && orders.length === 0 && !hasUnknown) {
|
|
858
|
+
const warning = decoded.warnings[0];
|
|
859
|
+
if (warning === undefined)
|
|
860
|
+
throw new Error("Contingent warning evidence was lost");
|
|
861
|
+
return {
|
|
862
|
+
state: "warning",
|
|
863
|
+
warnings: decoded.warnings,
|
|
864
|
+
continuation: { replyId: warning.replyId, parentClientOrderId },
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
if (hasErrors &&
|
|
868
|
+
!decoded.responseIsArray &&
|
|
869
|
+
!hasWarnings &&
|
|
870
|
+
orders.length === 0 &&
|
|
871
|
+
!hasUnknown) {
|
|
872
|
+
return {
|
|
873
|
+
state: "rejected",
|
|
874
|
+
parentClientOrderId,
|
|
875
|
+
reasons: decoded.errors.map(({ message }) => message),
|
|
876
|
+
errors: decoded.errors,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
if (!hasWarnings &&
|
|
880
|
+
!hasErrors &&
|
|
881
|
+
!hasUnknown &&
|
|
882
|
+
decoded.pendingCancelOrderIds.length === 0 &&
|
|
883
|
+
orders.length === 2 &&
|
|
884
|
+
hasDistinctBrokerOrderIds) {
|
|
885
|
+
const [parent, child] = orders;
|
|
886
|
+
if (parent !== undefined && child !== undefined) {
|
|
887
|
+
const terminalFailure = orders.find(({ status }) => status === "REJECTED" || status === "CANCELED");
|
|
888
|
+
const unknownStatus = orders.find(({ status }) => status === "UNKNOWN");
|
|
889
|
+
if (terminalFailure === undefined && unknownStatus === undefined) {
|
|
890
|
+
return { state: "accepted", orders: [parent, child], warnings: [] };
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return this.contingentRecoveryResult(decoded, orders, parentClientOrderId);
|
|
895
|
+
}
|
|
896
|
+
decodeOrderSubmission(response) {
|
|
897
|
+
const items = Array.isArray(response) ? response : [response];
|
|
898
|
+
const decoded = {
|
|
899
|
+
responseIsArray: Array.isArray(response),
|
|
900
|
+
orders: [],
|
|
901
|
+
pendingCancelOrderIds: [],
|
|
902
|
+
warnings: [],
|
|
903
|
+
errors: [],
|
|
904
|
+
unrecognizedResponses: [],
|
|
905
|
+
};
|
|
906
|
+
for (const item of items) {
|
|
907
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
908
|
+
decoded.unrecognizedResponses.push(item);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const record = item;
|
|
912
|
+
let recognized = false;
|
|
913
|
+
const rawWarningId = record["id"];
|
|
914
|
+
if (typeof rawWarningId === "string" && rawWarningId.trim()) {
|
|
915
|
+
const rawMessageIds = record["messageIds"];
|
|
916
|
+
const messageIds = Array.isArray(rawMessageIds)
|
|
917
|
+
? rawMessageIds.filter((value) => typeof value === "string")
|
|
918
|
+
: [];
|
|
919
|
+
decoded.warnings.push({
|
|
920
|
+
replyId: rawWarningId.trim(),
|
|
921
|
+
messages: Array.isArray(record["message"])
|
|
922
|
+
? record["message"].filter((value) => typeof value === "string")
|
|
923
|
+
: [],
|
|
924
|
+
messageIds,
|
|
925
|
+
known: Array.isArray(rawMessageIds) &&
|
|
926
|
+
rawMessageIds.length > 0 &&
|
|
927
|
+
rawMessageIds.every((id) => typeof id === "string" && KNOWN_ORDER_WARNING_IDS.has(id)),
|
|
928
|
+
});
|
|
929
|
+
recognized = true;
|
|
930
|
+
}
|
|
931
|
+
else if ("id" in record) {
|
|
932
|
+
decoded.unrecognizedResponses.push({ ...record });
|
|
933
|
+
recognized = true;
|
|
934
|
+
}
|
|
935
|
+
if (record["error"] !== undefined && record["error"] !== null) {
|
|
936
|
+
if (this.isMeaningfulBrokerError(record["error"], record)) {
|
|
937
|
+
decoded.errors.push(this.normalizeBrokerError(record["error"], record));
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
decoded.unrecognizedResponses.push({ ...record });
|
|
941
|
+
}
|
|
942
|
+
recognized = true;
|
|
943
|
+
}
|
|
944
|
+
const hasOrderId = "order_id" in record || "orderId" in record;
|
|
945
|
+
const rawOrderId = record["order_id"] ?? record["orderId"];
|
|
946
|
+
const orderId = typeof rawOrderId === "string" && rawOrderId.trim()
|
|
947
|
+
? rawOrderId.trim()
|
|
948
|
+
: typeof rawOrderId === "number" && Number.isSafeInteger(rawOrderId) && rawOrderId > 0
|
|
949
|
+
? String(rawOrderId)
|
|
950
|
+
: null;
|
|
951
|
+
if (orderId !== null) {
|
|
952
|
+
const orderStatus = record["order_status"] ?? record["orderStatus"];
|
|
953
|
+
if (typeof orderStatus === "string" &&
|
|
954
|
+
this.canonicalIbkrOrderStatus(orderStatus) === "PENDING_CANCEL") {
|
|
955
|
+
decoded.pendingCancelOrderIds.push(orderId);
|
|
956
|
+
}
|
|
957
|
+
decoded.orders.push({
|
|
958
|
+
orderId,
|
|
959
|
+
status: this.normalizeDerivativeOrderStatus(typeof orderStatus === "string" ? orderStatus : undefined, 0, 0),
|
|
960
|
+
clientOrderId: null,
|
|
961
|
+
});
|
|
962
|
+
recognized = true;
|
|
963
|
+
}
|
|
964
|
+
else if (hasOrderId) {
|
|
965
|
+
decoded.unrecognizedResponses.push({ ...record });
|
|
966
|
+
recognized = true;
|
|
967
|
+
}
|
|
968
|
+
if (!recognized)
|
|
969
|
+
decoded.unrecognizedResponses.push({ ...record });
|
|
577
970
|
}
|
|
578
|
-
|
|
971
|
+
if (items.length === 0)
|
|
972
|
+
decoded.unrecognizedResponses.push({});
|
|
973
|
+
return decoded;
|
|
974
|
+
}
|
|
975
|
+
singleOrderRecoveryResult(decoded, orders) {
|
|
976
|
+
return {
|
|
977
|
+
state: "recovery_required",
|
|
978
|
+
reasons: [this.submissionRecoveryReason(decoded, orders.length, 1)],
|
|
979
|
+
orders,
|
|
980
|
+
warnings: decoded.warnings,
|
|
981
|
+
errors: decoded.errors,
|
|
982
|
+
unrecognizedResponses: decoded.unrecognizedResponses,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
contingentRecoveryResult(decoded, orders, parentClientOrderId) {
|
|
986
|
+
return {
|
|
987
|
+
state: "recovery_required",
|
|
988
|
+
parentClientOrderId,
|
|
989
|
+
reasons: [this.submissionRecoveryReason(decoded, orders.length, 2)],
|
|
990
|
+
orders,
|
|
991
|
+
warnings: decoded.warnings,
|
|
992
|
+
errors: decoded.errors,
|
|
993
|
+
unrecognizedResponses: decoded.unrecognizedResponses,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
submissionRecoveryReason(decoded, orderCount, expectedOrderCount) {
|
|
997
|
+
const pendingCancelOrderId = decoded.pendingCancelOrderIds[0];
|
|
998
|
+
if (pendingCancelOrderId !== undefined) {
|
|
999
|
+
return `Order ${pendingCancelOrderId} has a pending cancellation`;
|
|
1000
|
+
}
|
|
1001
|
+
const terminal = decoded.orders.find(({ status }) => status === "REJECTED" || status === "CANCELED");
|
|
1002
|
+
if (terminal !== undefined) {
|
|
1003
|
+
return `Order ${terminal.orderId} returned terminal status ${terminal.status}`;
|
|
1004
|
+
}
|
|
1005
|
+
if (decoded.orders.some(({ status }) => status === "UNKNOWN")) {
|
|
1006
|
+
return "IBKR returned an order ID with an unknown status";
|
|
1007
|
+
}
|
|
1008
|
+
if (new Set(decoded.orders.map(({ orderId }) => orderId)).size < decoded.orders.length) {
|
|
1009
|
+
return "IBKR returned duplicate broker order IDs";
|
|
1010
|
+
}
|
|
1011
|
+
if (decoded.errors.length > 0 && decoded.warnings.length > 0) {
|
|
1012
|
+
return "IBKR returned both warnings and rejections for one submission";
|
|
1013
|
+
}
|
|
1014
|
+
if (decoded.warnings.length > 1) {
|
|
1015
|
+
return "IBKR returned multiple warning continuations for one submission";
|
|
1016
|
+
}
|
|
1017
|
+
if (orderCount !== expectedOrderCount) {
|
|
1018
|
+
return `IBKR returned ${String(orderCount)} of ${String(expectedOrderCount)} expected order acknowledgements`;
|
|
1019
|
+
}
|
|
1020
|
+
if (decoded.unrecognizedResponses.length > 0) {
|
|
1021
|
+
return "IBKR returned one or more unrecognized order responses";
|
|
1022
|
+
}
|
|
1023
|
+
return "IBKR returned mixed or incomplete order evidence";
|
|
1024
|
+
}
|
|
1025
|
+
normalizeBrokerError(error, response) {
|
|
1026
|
+
const nested = typeof error === "object" && error !== null ? error : undefined;
|
|
1027
|
+
const nestedMessage = nested ? nested.message : undefined;
|
|
1028
|
+
const responseMessage = response["message"];
|
|
1029
|
+
const message = (typeof nestedMessage === "string" && nestedMessage.trim()) ||
|
|
1030
|
+
(typeof error === "string" && error.trim()) ||
|
|
1031
|
+
(typeof responseMessage === "string" && responseMessage.trim()) ||
|
|
1032
|
+
"IBKR rejected the order";
|
|
1033
|
+
const nestedCode = nested ? nested.code : undefined;
|
|
1034
|
+
const responseCode = response["code"];
|
|
1035
|
+
const codeValue = nestedCode ?? responseCode;
|
|
1036
|
+
const statusValue = response["statusCode"];
|
|
1037
|
+
return {
|
|
1038
|
+
message,
|
|
1039
|
+
code: typeof codeValue === "string" || typeof codeValue === "number" ? String(codeValue) : null,
|
|
1040
|
+
statusCode: typeof statusValue === "number" && Number.isFinite(statusValue) ? statusValue : null,
|
|
1041
|
+
details: response,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
isMeaningfulBrokerError(error, response) {
|
|
1045
|
+
const nested = typeof error === "object" && error !== null ? error : undefined;
|
|
1046
|
+
const nestedRecord = nested;
|
|
1047
|
+
const messages = [error, nestedRecord?.["message"], response["message"]];
|
|
1048
|
+
if (messages.some((value) => typeof value === "string" && value.trim()))
|
|
1049
|
+
return true;
|
|
1050
|
+
const code = nestedRecord?.["code"] ?? response["code"];
|
|
1051
|
+
if ((typeof code === "string" && code.trim()) ||
|
|
1052
|
+
(typeof code === "number" && Number.isFinite(code))) {
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
1055
|
+
const status = nestedRecord?.["statusCode"] ?? nestedRecord?.["status"] ?? response["statusCode"];
|
|
1056
|
+
return typeof status === "number" && Number.isFinite(status) && status >= 400;
|
|
579
1057
|
}
|
|
580
1058
|
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
|
-
|
|
1059
|
+
const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size);
|
|
1060
|
+
const filledQuantity = this.firstNumber(order.cum_fill, order.cumFill, order.filledQuantity, order.filled);
|
|
1061
|
+
const remainingQuantity = this.firstNumber(order.remainingQuantity, order.remaining_size, order.remaining);
|
|
1062
|
+
if (quantity === undefined ||
|
|
1063
|
+
filledQuantity === undefined ||
|
|
1064
|
+
filledQuantity < 0 ||
|
|
1065
|
+
remainingQuantity === undefined ||
|
|
1066
|
+
remainingQuantity < 0) {
|
|
1067
|
+
throw new Error(`IBKR order ${orderId} returned incomplete fill quantities`);
|
|
1068
|
+
}
|
|
586
1069
|
return {
|
|
587
1070
|
accountId,
|
|
588
1071
|
orderId,
|
|
589
|
-
clientOrderId: order.cOID ?? null,
|
|
1072
|
+
clientOrderId: order.cOID ?? order.order_ref ?? null,
|
|
590
1073
|
status: this.normalizeDerivativeOrderStatus(order.order_status ?? order.orderStatus ?? order.status, filledQuantity, remainingQuantity),
|
|
591
1074
|
quantity,
|
|
592
1075
|
filledQuantity,
|
|
593
1076
|
remainingQuantity,
|
|
594
|
-
averagePrice: this.firstNumber(order.avgPrice, order.average_price, order.averagePrice) ?? null,
|
|
595
|
-
limitPrice: this.firstNumber(order.limitPrice, order.price) ?? null,
|
|
1077
|
+
averagePrice: this.firstNumber(order.avgPrice, order.avg_price, order.average_price, order.averagePrice) ?? null,
|
|
1078
|
+
limitPrice: this.firstNumber(order.limitPrice, order.limit_price, order.price) ?? null,
|
|
596
1079
|
commissionAndFees: typeof order.commissionAndFees === "number"
|
|
597
1080
|
? order.commissionAndFees
|
|
598
1081
|
: this.whatIfNumber(order.commissionAndFees),
|
|
@@ -600,6 +1083,204 @@ export class IbkrClient {
|
|
|
600
1083
|
updatedAt: this.parseOrderTime(order)?.toISOString() ?? null,
|
|
601
1084
|
};
|
|
602
1085
|
}
|
|
1086
|
+
normalizeDerivativeExecution(accountId, trade) {
|
|
1087
|
+
if (!trade.execution_id || !Number.isSafeInteger(trade.conid) || Number(trade.conid) <= 0) {
|
|
1088
|
+
return undefined;
|
|
1089
|
+
}
|
|
1090
|
+
const commission = typeof trade.commission === "number" ? trade.commission : this.whatIfNumber(trade.commission);
|
|
1091
|
+
const commissionCurrency = typeof trade.commission === "string"
|
|
1092
|
+
? (/\b(?<currency>[A-Z]{3})\s*$/.exec(trade.commission.trim())?.groups?.["currency"] ??
|
|
1093
|
+
null)
|
|
1094
|
+
: null;
|
|
1095
|
+
const side = trade.side?.trim().toUpperCase();
|
|
1096
|
+
return {
|
|
1097
|
+
accountId,
|
|
1098
|
+
executionId: trade.execution_id,
|
|
1099
|
+
orderId: trade.order_id === undefined ? null : String(trade.order_id),
|
|
1100
|
+
clientOrderId: this.trimmedString(trade.order_ref),
|
|
1101
|
+
conid: Number(trade.conid),
|
|
1102
|
+
symbol: this.trimmedString(trade.contract_description_1) ?? this.trimmedString(trade.symbol),
|
|
1103
|
+
side: side === "B" || side === "BUY" || side === "BOT"
|
|
1104
|
+
? "BUY"
|
|
1105
|
+
: side === "S" || side === "SELL" || side === "SLD"
|
|
1106
|
+
? "SELL"
|
|
1107
|
+
: "UNKNOWN",
|
|
1108
|
+
quantity: this.firstNumber(trade.size) ?? 0,
|
|
1109
|
+
price: this.firstNumber(trade.price) ?? null,
|
|
1110
|
+
commission,
|
|
1111
|
+
commissionCurrency,
|
|
1112
|
+
netAmount: this.firstNumber(trade.net_amount) ?? null,
|
|
1113
|
+
exchange: this.trimmedString(trade.exchange),
|
|
1114
|
+
executedAt: this.parseTradeTime(trade),
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
parseTradeTime(trade) {
|
|
1118
|
+
if (trade.trade_time_r !== undefined) {
|
|
1119
|
+
const epoch = new Date(trade.trade_time_r);
|
|
1120
|
+
if (!Number.isNaN(epoch.getTime()))
|
|
1121
|
+
return epoch.toISOString();
|
|
1122
|
+
}
|
|
1123
|
+
const value = trade.trade_time;
|
|
1124
|
+
const match = value ? /^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2}):(\d{2})$/.exec(value) : null;
|
|
1125
|
+
if (!match)
|
|
1126
|
+
return null;
|
|
1127
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
1128
|
+
if (!year || !month || !day || !hour || !minute || !second)
|
|
1129
|
+
return null;
|
|
1130
|
+
const parsed = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
|
|
1131
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
1132
|
+
}
|
|
1133
|
+
trimmedString(value) {
|
|
1134
|
+
if (value === undefined)
|
|
1135
|
+
return null;
|
|
1136
|
+
const trimmed = value.trim();
|
|
1137
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
1138
|
+
}
|
|
1139
|
+
validateReconciliationRequest(request) {
|
|
1140
|
+
if (!request.accountId.trim() || !request.orderId.trim() || !request.clientOrderId.trim()) {
|
|
1141
|
+
throw new Error("Exact account, order, and client order IDs are required for reconciliation");
|
|
1142
|
+
}
|
|
1143
|
+
if (!Number.isSafeInteger(request.quantity) || request.quantity <= 0) {
|
|
1144
|
+
throw new Error("Reconciliation quantity must be a positive integer");
|
|
1145
|
+
}
|
|
1146
|
+
if (!Number.isFinite(request.multiplier) || request.multiplier <= 0) {
|
|
1147
|
+
throw new Error("Reconciliation multiplier must be positive");
|
|
1148
|
+
}
|
|
1149
|
+
if (request.legs.some(({ conid, ratio }) => !Number.isSafeInteger(conid) || conid <= 0 || !Number.isSafeInteger(ratio) || ratio === 0) ||
|
|
1150
|
+
request.legs[0].conid === request.legs[1].conid) {
|
|
1151
|
+
throw new Error("Reconciliation legs require distinct conids and non-zero integer ratios");
|
|
1152
|
+
}
|
|
1153
|
+
if ((request.timeoutMs ?? 30_000) < 0 || (request.pollMs ?? 1_000) <= 0) {
|
|
1154
|
+
throw new Error("Reconciliation timing must use a non-negative timeout and positive poll");
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
evaluateDerivativeReconciliation(request, lifecycle, trades) {
|
|
1158
|
+
const base = {
|
|
1159
|
+
aggregateStatus: lifecycle.status,
|
|
1160
|
+
filledQuantity: lifecycle.filledQuantity,
|
|
1161
|
+
remainingQuantity: lifecycle.remainingQuantity,
|
|
1162
|
+
multiplier: request.multiplier,
|
|
1163
|
+
};
|
|
1164
|
+
const recovery = (reason) => ({
|
|
1165
|
+
...base,
|
|
1166
|
+
state: "RECOVERY_REQUIRED",
|
|
1167
|
+
reason,
|
|
1168
|
+
legs: [],
|
|
1169
|
+
grossPoints: null,
|
|
1170
|
+
grossAmount: null,
|
|
1171
|
+
commission: null,
|
|
1172
|
+
netAmount: null,
|
|
1173
|
+
});
|
|
1174
|
+
if (lifecycle.quantity !== request.quantity) {
|
|
1175
|
+
return recovery("Aggregate order quantity does not match the reviewed combo");
|
|
1176
|
+
}
|
|
1177
|
+
if (lifecycle.clientOrderId !== null && lifecycle.clientOrderId !== request.clientOrderId) {
|
|
1178
|
+
return recovery("Aggregate client order reference does not match the reviewed combo");
|
|
1179
|
+
}
|
|
1180
|
+
const expectedLegs = request.legs.map(({ conid, ratio }) => ({ conid, ratio }));
|
|
1181
|
+
if (JSON.stringify(lifecycle.legs) !== JSON.stringify(expectedLegs)) {
|
|
1182
|
+
return recovery("Aggregate combo legs do not match the reviewed combo");
|
|
1183
|
+
}
|
|
1184
|
+
const matching = trades.filter(({ clientOrderId }) => clientOrderId === request.clientOrderId);
|
|
1185
|
+
const executionIds = new Set();
|
|
1186
|
+
for (const trade of matching) {
|
|
1187
|
+
if (executionIds.has(trade.executionId)) {
|
|
1188
|
+
return recovery("Duplicate execution ID requires manual recovery");
|
|
1189
|
+
}
|
|
1190
|
+
executionIds.add(trade.executionId);
|
|
1191
|
+
if (trade.orderId !== null && trade.orderId !== request.orderId) {
|
|
1192
|
+
return recovery("Execution order ID does not match the reviewed combo");
|
|
1193
|
+
}
|
|
1194
|
+
const expected = request.legs.find(({ conid }) => conid === trade.conid);
|
|
1195
|
+
if (expected === undefined) {
|
|
1196
|
+
return recovery("Execution contains an unexpected combo leg");
|
|
1197
|
+
}
|
|
1198
|
+
if (trade.side !== this.sideForRatio(expected.ratio)) {
|
|
1199
|
+
return recovery("Execution side does not match the reviewed combo ratio");
|
|
1200
|
+
}
|
|
1201
|
+
if (trade.quantity <= 0 ||
|
|
1202
|
+
trade.price === null ||
|
|
1203
|
+
trade.price < 0 ||
|
|
1204
|
+
trade.commission === null ||
|
|
1205
|
+
trade.commission < 0 ||
|
|
1206
|
+
trade.executedAt === null) {
|
|
1207
|
+
return recovery("Execution contains incomplete economics or timing evidence");
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const completeExecutions = matching.flatMap((trade) => trade.side !== "UNKNOWN" &&
|
|
1211
|
+
trade.price !== null &&
|
|
1212
|
+
trade.commission !== null &&
|
|
1213
|
+
trade.executedAt !== null
|
|
1214
|
+
? [
|
|
1215
|
+
{
|
|
1216
|
+
...trade,
|
|
1217
|
+
side: trade.side,
|
|
1218
|
+
price: trade.price,
|
|
1219
|
+
commission: trade.commission,
|
|
1220
|
+
executedAt: trade.executedAt,
|
|
1221
|
+
},
|
|
1222
|
+
]
|
|
1223
|
+
: []);
|
|
1224
|
+
const summaries = [];
|
|
1225
|
+
for (const expected of request.legs) {
|
|
1226
|
+
const executions = completeExecutions.filter(({ conid }) => conid === expected.conid);
|
|
1227
|
+
const quantity = executions.reduce((sum, trade) => sum + trade.quantity, 0);
|
|
1228
|
+
const expectedQuantity = lifecycle.filledQuantity * Math.abs(expected.ratio);
|
|
1229
|
+
if (quantity > expectedQuantity) {
|
|
1230
|
+
return recovery("Execution quantity exceeds the aggregate fill");
|
|
1231
|
+
}
|
|
1232
|
+
if (quantity < expectedQuantity) {
|
|
1233
|
+
return {
|
|
1234
|
+
...base,
|
|
1235
|
+
state: "PENDING",
|
|
1236
|
+
reason: "Terminal order is missing expected execution evidence",
|
|
1237
|
+
legs: summaries,
|
|
1238
|
+
grossPoints: null,
|
|
1239
|
+
grossAmount: null,
|
|
1240
|
+
commission: null,
|
|
1241
|
+
netAmount: null,
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
if (quantity > 0) {
|
|
1245
|
+
summaries.push({
|
|
1246
|
+
conid: expected.conid,
|
|
1247
|
+
side: this.sideForRatio(expected.ratio),
|
|
1248
|
+
quantity,
|
|
1249
|
+
averagePrice: this.round(executions.reduce((sum, trade) => sum + trade.price * trade.quantity, 0) / quantity, 8),
|
|
1250
|
+
commission: this.round(executions.reduce((sum, trade) => sum + trade.commission, 0), 2),
|
|
1251
|
+
executionCount: executions.length,
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
const cashFlowPoints = completeExecutions.reduce((sum, trade) => sum + (trade.side === "SELL" ? 1 : -1) * trade.price * trade.quantity, 0);
|
|
1256
|
+
const grossPoints = lifecycle.filledQuantity > 0 ? this.round(cashFlowPoints / lifecycle.filledQuantity, 8) : 0;
|
|
1257
|
+
const grossAmount = this.round(cashFlowPoints * request.multiplier, 2);
|
|
1258
|
+
const commission = this.round(completeExecutions.reduce((sum, trade) => sum + trade.commission, 0), 2);
|
|
1259
|
+
const result = {
|
|
1260
|
+
...base,
|
|
1261
|
+
state: this.isTerminalDerivativeStatus(lifecycle.status) ? "VERIFIED" : "PENDING",
|
|
1262
|
+
reason: null,
|
|
1263
|
+
legs: summaries,
|
|
1264
|
+
grossPoints,
|
|
1265
|
+
grossAmount,
|
|
1266
|
+
commission,
|
|
1267
|
+
netAmount: this.round(grossAmount - commission, 2),
|
|
1268
|
+
};
|
|
1269
|
+
if (lifecycle.status === "FILLED" && lifecycle.filledQuantity !== request.quantity) {
|
|
1270
|
+
return recovery("Filled aggregate quantity does not match the reviewed combo");
|
|
1271
|
+
}
|
|
1272
|
+
return result;
|
|
1273
|
+
}
|
|
1274
|
+
sideForRatio(ratio) {
|
|
1275
|
+
return ratio > 0 ? "BUY" : "SELL";
|
|
1276
|
+
}
|
|
1277
|
+
isTerminalDerivativeStatus(status) {
|
|
1278
|
+
return status === "FILLED" || status === "CANCELED" || status === "REJECTED";
|
|
1279
|
+
}
|
|
1280
|
+
round(value, decimalPlaces) {
|
|
1281
|
+
const factor = 10 ** decimalPlaces;
|
|
1282
|
+
return Math.round((value + Number.EPSILON) * factor) / factor;
|
|
1283
|
+
}
|
|
603
1284
|
parseComboLegs(conidex) {
|
|
604
1285
|
const encoded = conidex?.split(";;;")[1];
|
|
605
1286
|
if (!encoded)
|
|
@@ -614,10 +1295,7 @@ export class IbkrClient {
|
|
|
614
1295
|
});
|
|
615
1296
|
}
|
|
616
1297
|
normalizeDerivativeOrderStatus(value, filledQuantity, remainingQuantity) {
|
|
617
|
-
const status = value
|
|
618
|
-
?.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
619
|
-
.replace(/\s+/g, "_")
|
|
620
|
-
.toUpperCase();
|
|
1298
|
+
const status = this.canonicalIbkrOrderStatus(value);
|
|
621
1299
|
if (filledQuantity > 0 && remainingQuantity > 0)
|
|
622
1300
|
return "PARTIALLY_FILLED";
|
|
623
1301
|
if (status === "FILLED")
|
|
@@ -632,6 +1310,12 @@ export class IbkrClient {
|
|
|
632
1310
|
return "WORKING";
|
|
633
1311
|
return "UNKNOWN";
|
|
634
1312
|
}
|
|
1313
|
+
canonicalIbkrOrderStatus(value) {
|
|
1314
|
+
return value
|
|
1315
|
+
?.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
1316
|
+
.replace(/\s+/g, "_")
|
|
1317
|
+
.toUpperCase();
|
|
1318
|
+
}
|
|
635
1319
|
normalizeComboPreview(accountId, diagnostics, response) {
|
|
636
1320
|
const commission = this.whatIfNumber(response.amount?.commission);
|
|
637
1321
|
const initialMargin = this.whatIfMargin(response.initial);
|
|
@@ -772,7 +1456,10 @@ export class IbkrClient {
|
|
|
772
1456
|
}
|
|
773
1457
|
/** Discover listed derivative series over an inclusive calendar range. */
|
|
774
1458
|
async getDerivativeExpiries(query) {
|
|
775
|
-
const contracts =
|
|
1459
|
+
const contracts = [];
|
|
1460
|
+
for (const month of monthCodes(query.from, query.to)) {
|
|
1461
|
+
contracts.push(...(await this.discoverDerivativeMonth(query.underlying, query.assetClass, month, query.exchange, query.right)));
|
|
1462
|
+
}
|
|
776
1463
|
const filtered = contracts.filter((contract) => contract.expiration >= query.from &&
|
|
777
1464
|
contract.expiration <= query.to &&
|
|
778
1465
|
(query.right === undefined || contract.right === query.right) &&
|
|
@@ -1450,6 +2137,14 @@ export class IbkrClient {
|
|
|
1450
2137
|
wait(ms) {
|
|
1451
2138
|
return sleep(ms);
|
|
1452
2139
|
}
|
|
2140
|
+
/** Overridable monotonic-enough wall clock for bounded polling tests. */
|
|
2141
|
+
now() {
|
|
2142
|
+
return Date.now();
|
|
2143
|
+
}
|
|
2144
|
+
/** Overridable entropy source for deterministic scheduler jitter tests. */
|
|
2145
|
+
random() {
|
|
2146
|
+
return Math.random();
|
|
2147
|
+
}
|
|
1453
2148
|
normalizeQuote(contract, snapshot, history) {
|
|
1454
2149
|
const symbol = this.snapshotString(snapshot, "55") ?? contract.symbol;
|
|
1455
2150
|
const description = this.snapshotString(snapshot, "58") ?? history?.text ?? contract.description;
|
|
@@ -1571,23 +2266,84 @@ export class IbkrClient {
|
|
|
1571
2266
|
async sendRequest(input) {
|
|
1572
2267
|
return (await this.raw.request(input));
|
|
1573
2268
|
}
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2269
|
+
req(input) {
|
|
2270
|
+
return this.scheduledRequest(input, true);
|
|
2271
|
+
}
|
|
2272
|
+
singleAttemptRequest(input) {
|
|
2273
|
+
return this.scheduledRequest(input, false);
|
|
2274
|
+
}
|
|
2275
|
+
scheduledRequest(input, retryable) {
|
|
2276
|
+
return this.requestScheduler.schedule({
|
|
2277
|
+
endpoint: this.requestEndpoint(input.path),
|
|
2278
|
+
priority: this.requestPriority(input.path),
|
|
2279
|
+
retryable,
|
|
2280
|
+
}, () => this.sendRequest(input));
|
|
2281
|
+
}
|
|
2282
|
+
requestPriority(path) {
|
|
2283
|
+
if (path === "iserver/accounts" ||
|
|
2284
|
+
path === "iserver/auth/status" ||
|
|
2285
|
+
path.includes("/orders") ||
|
|
2286
|
+
path.includes("/order/status/") ||
|
|
2287
|
+
path === "iserver/account/trades" ||
|
|
2288
|
+
path.startsWith("iserver/reply/")) {
|
|
2289
|
+
return "EXECUTION";
|
|
2290
|
+
}
|
|
2291
|
+
if (path.includes("secdef"))
|
|
2292
|
+
return "DISCOVERY";
|
|
2293
|
+
return "STANDARD";
|
|
2294
|
+
}
|
|
2295
|
+
requestEndpoint(path) {
|
|
2296
|
+
if (path.includes("secdef/"))
|
|
2297
|
+
return path.slice(path.indexOf("secdef/"));
|
|
2298
|
+
if (path.includes("/order/status/"))
|
|
2299
|
+
return "account/order/status";
|
|
2300
|
+
if (path.includes("/orders/whatif"))
|
|
2301
|
+
return "account/orders/whatif";
|
|
2302
|
+
if (path.endsWith("/orders"))
|
|
2303
|
+
return "account/orders";
|
|
2304
|
+
if (path.includes("/order/"))
|
|
2305
|
+
return "account/order";
|
|
2306
|
+
if (path.startsWith("iserver/reply/"))
|
|
2307
|
+
return "reply";
|
|
2308
|
+
if (path === "iserver/account/trades")
|
|
2309
|
+
return "account/trades";
|
|
2310
|
+
return path.split("/").slice(0, 2).join("/");
|
|
2311
|
+
}
|
|
2312
|
+
classifyRequestError(error) {
|
|
2313
|
+
if (/temporar(?:ily|y).*(?:block|ban)|(?:ip|access).*(?:temporar(?:ily|y) )?blocked/i.test(this.requestErrorText(error))) {
|
|
2314
|
+
return { kind: "TEMPORARILY_BLOCKED" };
|
|
2315
|
+
}
|
|
2316
|
+
if (this.httpStatusFromError(error) === 429) {
|
|
2317
|
+
const retryAfterMs = this.retryAfterFromError(error);
|
|
2318
|
+
return retryAfterMs === undefined
|
|
2319
|
+
? { kind: "THROTTLED" }
|
|
2320
|
+
: { kind: "THROTTLED", retryAfterMs };
|
|
2321
|
+
}
|
|
2322
|
+
return { kind: "OTHER" };
|
|
2323
|
+
}
|
|
2324
|
+
requestErrorText(error) {
|
|
2325
|
+
if (typeof error !== "object" || error === null)
|
|
2326
|
+
return String(error);
|
|
2327
|
+
const message = error.message;
|
|
2328
|
+
const response = error.response;
|
|
2329
|
+
const responseData = typeof response === "object" && response !== null && "data" in response
|
|
2330
|
+
? response.data
|
|
2331
|
+
: undefined;
|
|
2332
|
+
const body = error.body;
|
|
2333
|
+
return [message, responseData, body]
|
|
2334
|
+
.flatMap((value) => {
|
|
2335
|
+
if (typeof value === "string")
|
|
2336
|
+
return [value];
|
|
2337
|
+
if (value === undefined)
|
|
2338
|
+
return [];
|
|
1577
2339
|
try {
|
|
1578
|
-
return
|
|
2340
|
+
return [JSON.stringify(value)];
|
|
1579
2341
|
}
|
|
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);
|
|
2342
|
+
catch {
|
|
2343
|
+
return [];
|
|
1589
2344
|
}
|
|
1590
|
-
}
|
|
2345
|
+
})
|
|
2346
|
+
.join(" ");
|
|
1591
2347
|
}
|
|
1592
2348
|
httpStatusFromError(error) {
|
|
1593
2349
|
if (typeof error !== "object" || error === null)
|
|
@@ -1623,11 +2379,6 @@ export class IbkrClient {
|
|
|
1623
2379
|
this.headerValue(directHeaders, "Retry-After");
|
|
1624
2380
|
return parseRetryAfter(retryAfterRaw);
|
|
1625
2381
|
}
|
|
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
2382
|
numberFromUnknown(value) {
|
|
1632
2383
|
if (typeof value === "number")
|
|
1633
2384
|
return Number.isFinite(value) ? value : undefined;
|