@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.
@@ -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
- constructor(config) {
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
- if (!request.extOperator.trim())
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.sendRequest({
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
- extOperator: request.extOperator,
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.sendRequest({
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 ${orderId} was not found`);
286
- if (!this.orderBelongsToAccount(order, accountId)) {
287
- throw new Error(`IBKR order ${orderId} does not belong to the requested account`);
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() || !input.extOperator.trim()) {
293
- throw new Error("Exact account, order, and external operator are required");
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.sendRequest({
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 items = Array.isArray(response) ? response : [response];
543
- const warnings = items.flatMap((item) => {
544
- if (!("id" in item) || typeof item.id !== "string")
545
- return [];
546
- const messageIds = item.messageIds?.filter((value) => typeof value === "string") ?? [];
547
- return [
548
- {
549
- replyId: item.id,
550
- messages: item.message?.filter((value) => typeof value === "string") ?? [],
551
- messageIds,
552
- known: messageIds.length > 0 && messageIds.every((id) => KNOWN_ORDER_WARNING_IDS.has(id)),
553
- },
554
- ];
555
- });
556
- if (warnings.length > 0)
557
- return { state: "warning", warnings };
558
- const reasons = items.flatMap((item) => "error" in item && typeof item.error === "string" && item.error.trim()
559
- ? [item.error.trim()]
560
- : []);
561
- if (reasons.length > 0)
562
- return { state: "rejected", reasons };
563
- const accepted = items.find((item) => "order_id" in item || "orderId" in item);
564
- if (accepted !== undefined) {
565
- const orderId = ("order_id" in accepted ? accepted.order_id : undefined) ??
566
- ("orderId" in accepted ? accepted.orderId : undefined);
567
- if (orderId !== undefined) {
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: "accepted",
570
- orderId: String(orderId),
571
- status: this.normalizeDerivativeOrderStatus(("order_status" in accepted ? accepted.order_status : undefined) ??
572
- ("orderStatus" in accepted ? accepted.orderStatus : undefined), 0, 0),
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
- return { state: "rejected", reasons: ["IBKR returned an unknown order response"] };
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) ?? 0;
582
- const filledQuantity = this.firstNumber(order.cum_fill, order.cumFill, order.filledQuantity) ?? 0;
583
- const remainingQuantity = order.remainingQuantity === undefined
584
- ? Math.max(0, quantity - filledQuantity)
585
- : toNumber(order.remainingQuantity);
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 = (await Promise.all(monthCodes(query.from, query.to).map((month) => this.discoverDerivativeMonth(query.underlying, query.assetClass, month, query.exchange, query.right)))).flat();
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
- async req(input) {
1575
- let retries = 0;
1576
- for (;;) {
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 await this.sendRequest(input);
2340
+ return [JSON.stringify(value)];
1579
2341
  }
1580
- catch (error) {
1581
- const status = this.httpStatusFromError(error);
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;