@huskly/ibkr-client 0.11.0 → 0.12.0

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