@huskly/ibkr-client 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { ASSET_CLASS_LABELS, toNumber } from "../helpers.js";
3
3
  import { normalizeOptionContract, parseOsiOptionSymbol } from "./optionContract.js";
4
4
  import { normalizeDerivativeContract, normalizeDerivativeDataAvailability, } from "./derivativeContract.js";
5
+ import { IbkrRequestScheduler, } from "./requestScheduler.js";
5
6
  // `ibkr-client`'s published ESM build is broken: its `import` condition points
6
7
  // at files that use extensionless relative imports, which Node's strict ESM
7
8
  // resolver rejects. Its CJS build is fine, so we deliberately load that via
@@ -56,9 +57,6 @@ const QUOTE_FIELDS = [
56
57
  const OPTION_DISCOVERY_MONTH_CONCURRENCY = 1;
57
58
  const OPTION_SECDEF_INFO_BATCH_SIZE = 8;
58
59
  const OPTION_MARKETDATA_BATCH_SIZE = 100;
59
- const READ_ONLY_REQUEST_MAX_RETRIES = 3;
60
- const REQUEST_RETRY_BASE_DELAY_MS = 250;
61
- const REQUEST_RETRY_MAX_DELAY_MS = 5_000;
62
60
  const DAY_MS = 24 * 60 * 60 * 1000;
63
61
  const IBKR_STATUS_FILTERS = {
64
62
  CANCELED: "cancelled",
@@ -76,6 +74,7 @@ const IBKR_WORKING_STATUSES = new Set([
76
74
  "SUBMITTED",
77
75
  "PENDING_CANCEL",
78
76
  ]);
77
+ const KNOWN_ORDER_WARNING_IDS = new Set(["o163"]);
79
78
  /** Extract the canonical OSI symbol embedded in an IBKR option description. */
80
79
  function extractOsiPositionSymbol(contractDescription) {
81
80
  return /\[([A-Z]+\s*\d{6}[CP]\d{8})\s+\d+\]\s*$/.exec(contractDescription)?.[1];
@@ -166,8 +165,19 @@ export class IbkrClient {
166
165
  accountIdPromise;
167
166
  optionDiscovery = new Map();
168
167
  derivativeDiscovery = new Map();
169
- constructor(config) {
168
+ requestScheduler;
169
+ constructor(config, options = {}) {
170
170
  this.raw = new RawIbkrClientCtor(config);
171
+ this.requestScheduler = new IbkrRequestScheduler({
172
+ ...options.requestScheduler,
173
+ now: () => this.now(),
174
+ sleep: (ms) => this.wait(ms),
175
+ random: () => this.random(),
176
+ classifyError: (error) => this.classifyRequestError(error),
177
+ ...(options.onRequestTelemetry === undefined
178
+ ? {}
179
+ : { onTelemetry: options.onRequestTelemetry }),
180
+ });
171
181
  }
172
182
  /** Obtain the live session token (idempotent — safe to await repeatedly). */
173
183
  init() {
@@ -223,30 +233,168 @@ export class IbkrClient {
223
233
  path: "iserver/marketdata/snapshot",
224
234
  params: { conids, fields: "6509" },
225
235
  });
226
- const exchange = request.legs[0].contract.exchange;
227
- const spreadConid = exchange === "SMART" ? "28812380" : `28812380@${exchange}`;
228
- const conidex = `${spreadConid};;;${request.legs
229
- .map(({ contract, ratio }) => `${String(contract.conid)}/${String(ratio)}`)
230
- .join(",")}`;
231
236
  const response = await this.req({
232
237
  path: `iserver/account/${request.accountId}/orders/whatif`,
233
238
  method: "POST",
239
+ data: {
240
+ orders: [this.comboOrderTicket(request)],
241
+ },
242
+ });
243
+ return this.normalizeComboPreview(request.accountId, diagnostics, response);
244
+ }
245
+ async submitDerivativeCombo(request) {
246
+ this.validateComboPreview(request);
247
+ if (!request.clientOrderId.trim() || request.clientOrderId.length > 64) {
248
+ throw new Error("Client order ID must contain 1 to 64 characters");
249
+ }
250
+ const cmeOperatorMetadata = this.cmeOperatorMetadata(request.legs[0].contract.assetClass, request);
251
+ const diagnostics = await this.getTradingDiagnostics(request.accountId);
252
+ if (!diagnostics.authenticated || diagnostics.competingSession) {
253
+ throw new Error("IBKR brokerage session is not safely authenticated for submission");
254
+ }
255
+ await this.prepareBrokerageAccount(request.accountId);
256
+ const response = await this.singleAttemptRequest({
257
+ path: `iserver/account/${request.accountId}/orders`,
258
+ method: "POST",
234
259
  data: {
235
260
  orders: [
236
261
  {
237
- acctId: request.accountId,
238
- conidex,
239
- orderType: "LMT",
240
- price: request.priceEffect === "CREDIT" ? -request.limit : request.limit,
241
- side: "BUY",
242
- tif: request.tif,
243
- quantity: request.quantity,
244
- outsideRTH: request.session === "OVERNIGHT",
262
+ ...this.comboOrderTicket(request),
263
+ cOID: request.clientOrderId,
264
+ ...cmeOperatorMetadata,
245
265
  },
246
266
  ],
247
267
  },
248
268
  });
249
- return this.normalizeComboPreview(request.accountId, diagnostics, response);
269
+ return this.normalizeOrderSubmission(response, request.clientOrderId);
270
+ }
271
+ async acknowledgeOrderWarning(input) {
272
+ if (!input.replyId.trim())
273
+ throw new Error("An exact warning reply ID is required");
274
+ const response = await this.singleAttemptRequest({
275
+ path: `iserver/reply/${encodeURIComponent(input.replyId)}`,
276
+ method: "POST",
277
+ data: { confirmed: true },
278
+ });
279
+ return this.normalizeOrderSubmission(response, null);
280
+ }
281
+ async getDerivativeOrderStatus(accountId, orderId) {
282
+ if (!accountId.trim() || !orderId.trim()) {
283
+ throw new Error("Exact account and order IDs are required");
284
+ }
285
+ await this.prepareBrokerageAccount(accountId);
286
+ const order = await this.req({
287
+ path: `iserver/account/order/status/${encodeURIComponent(orderId)}`,
288
+ });
289
+ if (String(order.order_id ?? order.orderId ?? "") !== orderId) {
290
+ throw new Error(`IBKR response does not match the requested order ${orderId}`);
291
+ }
292
+ if ((order.account ?? order.acct) !== accountId) {
293
+ throw new Error(`IBKR order ${orderId} does not belong to the requested account`);
294
+ }
295
+ const lifecycle = this.normalizeDerivativeOrderLifecycle(accountId, orderId, order);
296
+ if (lifecycle.status === "UNKNOWN") {
297
+ throw new Error(`IBKR order ${orderId} returned an unrecognized status`);
298
+ }
299
+ return lifecycle;
300
+ }
301
+ async findDerivativeOrder(input) {
302
+ if (!input.accountId.trim())
303
+ throw new Error("An exact account ID is required");
304
+ const identity = input.orderId ?? input.clientOrderId;
305
+ if (!identity.trim())
306
+ throw new Error("An exact broker or client order ID is required");
307
+ if (input.orderId !== undefined) {
308
+ return this.getDerivativeOrderStatus(input.accountId, input.orderId);
309
+ }
310
+ await this.prepareBrokerageAccount(input.accountId);
311
+ const response = await this.req({
312
+ path: "iserver/account/orders",
313
+ params: { force: true, accountId: input.accountId },
314
+ });
315
+ const order = response.orders?.find((candidate) => {
316
+ if (!this.orderBelongsToAccount(candidate, input.accountId))
317
+ return false;
318
+ return (candidate.cOID ?? candidate.order_ref) === input.clientOrderId;
319
+ });
320
+ if (order === undefined)
321
+ throw new Error(`IBKR order ${identity} was not found`);
322
+ const orderId = order.order_id ?? order.orderId;
323
+ if (orderId === undefined) {
324
+ throw new Error(`IBKR order ${identity} did not include a broker order ID`);
325
+ }
326
+ return this.getDerivativeOrderStatus(input.accountId, String(orderId));
327
+ }
328
+ async getDerivativeExecutions(input) {
329
+ if (!input.accountId.trim())
330
+ throw new Error("An exact account ID is required");
331
+ if (input.days !== undefined &&
332
+ (!Number.isSafeInteger(input.days) || input.days < 1 || input.days > 7)) {
333
+ throw new Error("Execution history days must be an integer from 1 through 7");
334
+ }
335
+ if (input.orderId !== undefined && !input.orderId.trim()) {
336
+ throw new Error("Broker order ID cannot be empty");
337
+ }
338
+ if (input.clientOrderId !== undefined && !input.clientOrderId.trim()) {
339
+ throw new Error("Client order ID cannot be empty");
340
+ }
341
+ await this.prepareBrokerageAccount(input.accountId);
342
+ const response = await this.req({
343
+ path: "iserver/account/trades",
344
+ ...(input.days === undefined ? {} : { params: { days: input.days } }),
345
+ });
346
+ return response
347
+ .filter((trade) => (trade.account ?? trade.accountCode) === input.accountId)
348
+ .filter((trade) => input.orderId === undefined || String(trade.order_id) === input.orderId)
349
+ .filter((trade) => input.clientOrderId === undefined || trade.order_ref === input.clientOrderId)
350
+ .flatMap((trade) => {
351
+ const execution = this.normalizeDerivativeExecution(input.accountId, trade);
352
+ return execution === undefined ? [] : [execution];
353
+ });
354
+ }
355
+ async reconcileDerivativeComboExecution(request) {
356
+ this.validateReconciliationRequest(request);
357
+ const lifecycle = await this.getDerivativeOrderStatus(request.accountId, request.orderId);
358
+ const deadline = this.now() + (request.timeoutMs ?? 30_000);
359
+ const pollMs = request.pollMs ?? 1_000;
360
+ for (;;) {
361
+ const executions = await this.getDerivativeExecutions({
362
+ accountId: request.accountId,
363
+ clientOrderId: request.clientOrderId,
364
+ days: 1,
365
+ });
366
+ const result = this.evaluateDerivativeReconciliation(request, lifecycle, executions);
367
+ if (result.state !== "PENDING")
368
+ return result;
369
+ if (!this.isTerminalDerivativeStatus(lifecycle.status))
370
+ return result;
371
+ if (this.now() >= deadline) {
372
+ return {
373
+ ...result,
374
+ state: "RECOVERY_REQUIRED",
375
+ reason: result.reason ?? "Terminal order is missing expected execution evidence",
376
+ };
377
+ }
378
+ await this.wait(Math.min(pollMs, Math.max(0, deadline - this.now())));
379
+ }
380
+ }
381
+ async cancelDerivativeOrder(input) {
382
+ if (!input.accountId.trim() || !input.orderId.trim()) {
383
+ throw new Error("Exact account and order IDs are required");
384
+ }
385
+ const cmeOperatorMetadata = this.cmeOperatorMetadata(input.assetClass, input);
386
+ await this.prepareBrokerageAccount(input.accountId);
387
+ const response = await this.singleAttemptRequest({
388
+ path: `iserver/account/${input.accountId}/order/${encodeURIComponent(input.orderId)}`,
389
+ method: "DELETE",
390
+ ...(Object.keys(cmeOperatorMetadata).length > 0 ? { params: cmeOperatorMetadata } : {}),
391
+ });
392
+ return {
393
+ state: "requested",
394
+ accountId: input.accountId,
395
+ orderId: input.orderId,
396
+ message: this.trimmedString(response.msg),
397
+ };
250
398
  }
251
399
  async getAccountId() {
252
400
  this.accountIdPromise ??= (async () => {
@@ -468,6 +616,362 @@ export class IbkrClient {
468
616
  }
469
617
  }
470
618
  }
619
+ cmeOperatorMetadata(assetClass, input) {
620
+ if (assetClass === "OPT")
621
+ return {};
622
+ if (!input.extOperator?.trim() || input.manualIndicator === undefined) {
623
+ throw new Error(`${assetClass} orders require exact CME operator metadata`);
624
+ }
625
+ return {
626
+ extOperator: input.extOperator,
627
+ manualIndicator: input.manualIndicator,
628
+ };
629
+ }
630
+ comboOrderTicket(request) {
631
+ const exchange = request.legs[0].contract.exchange;
632
+ const spreadConid = exchange === "SMART" ? "28812380" : `28812380@${exchange}`;
633
+ return {
634
+ acctId: request.accountId,
635
+ conidex: `${spreadConid};;;${request.legs
636
+ .map(({ contract, ratio }) => `${String(contract.conid)}/${String(ratio)}`)
637
+ .join(",")}`,
638
+ orderType: "LMT",
639
+ price: request.priceEffect === "CREDIT" ? -request.limit : request.limit,
640
+ side: "BUY",
641
+ tif: request.tif,
642
+ quantity: request.quantity,
643
+ outsideRTH: request.session === "OVERNIGHT",
644
+ };
645
+ }
646
+ normalizeOrderSubmission(response, clientOrderId) {
647
+ const items = Array.isArray(response) ? response : [response];
648
+ const warnings = items.flatMap((item) => {
649
+ if (!("id" in item) || typeof item.id !== "string")
650
+ return [];
651
+ const messageIds = Array.isArray(item.messageIds)
652
+ ? item.messageIds.filter((value) => typeof value === "string")
653
+ : [];
654
+ return [
655
+ {
656
+ replyId: item.id,
657
+ messages: Array.isArray(item.message)
658
+ ? item.message.filter((value) => typeof value === "string")
659
+ : [],
660
+ messageIds,
661
+ known: messageIds.length > 0 && messageIds.every((id) => KNOWN_ORDER_WARNING_IDS.has(id)),
662
+ },
663
+ ];
664
+ });
665
+ if (warnings.length > 0)
666
+ return { state: "warning", warnings };
667
+ const errors = items.flatMap((item) => {
668
+ if (!("error" in item) || item.error === undefined || item.error === null)
669
+ return [];
670
+ return [this.normalizeBrokerError(item.error, item)];
671
+ });
672
+ if (errors.length > 0) {
673
+ return { state: "rejected", reasons: errors.map(({ message }) => message), errors };
674
+ }
675
+ const accepted = items.find((item) => "order_id" in item || "orderId" in item);
676
+ if (accepted !== undefined) {
677
+ const orderId = ("order_id" in accepted ? accepted.order_id : undefined) ??
678
+ ("orderId" in accepted ? accepted.orderId : undefined);
679
+ if (typeof orderId === "string" || typeof orderId === "number") {
680
+ const orderStatus = ("order_status" in accepted ? accepted.order_status : undefined) ??
681
+ ("orderStatus" in accepted ? accepted.orderStatus : undefined);
682
+ return {
683
+ state: "accepted",
684
+ orderId: String(orderId),
685
+ status: this.normalizeDerivativeOrderStatus(typeof orderStatus === "string" ? orderStatus : undefined, 0, 0),
686
+ clientOrderId,
687
+ warnings: [],
688
+ };
689
+ }
690
+ }
691
+ const unknown = "IBKR returned an unknown order response";
692
+ return {
693
+ state: "rejected",
694
+ reasons: [unknown],
695
+ errors: [{ message: unknown, code: null, statusCode: null, details: {} }],
696
+ };
697
+ }
698
+ normalizeBrokerError(error, response) {
699
+ const nested = typeof error === "object" && error !== null ? error : undefined;
700
+ const nestedMessage = nested ? nested.message : undefined;
701
+ const responseMessage = response["message"];
702
+ const message = (typeof nestedMessage === "string" && nestedMessage.trim()) ||
703
+ (typeof error === "string" && error.trim()) ||
704
+ (typeof responseMessage === "string" && responseMessage.trim()) ||
705
+ "IBKR rejected the order";
706
+ const nestedCode = nested ? nested.code : undefined;
707
+ const responseCode = response["code"];
708
+ const codeValue = nestedCode ?? responseCode;
709
+ const statusValue = response["statusCode"];
710
+ return {
711
+ message,
712
+ code: typeof codeValue === "string" || typeof codeValue === "number" ? String(codeValue) : null,
713
+ statusCode: typeof statusValue === "number" && Number.isFinite(statusValue) ? statusValue : null,
714
+ details: response,
715
+ };
716
+ }
717
+ normalizeDerivativeOrderLifecycle(accountId, orderId, order) {
718
+ const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size);
719
+ const filledQuantity = this.firstNumber(order.cum_fill, order.cumFill, order.filledQuantity, order.filled);
720
+ const remainingQuantity = this.firstNumber(order.remainingQuantity, order.remaining_size, order.remaining);
721
+ if (quantity === undefined ||
722
+ filledQuantity === undefined ||
723
+ filledQuantity < 0 ||
724
+ remainingQuantity === undefined ||
725
+ remainingQuantity < 0) {
726
+ throw new Error(`IBKR order ${orderId} returned incomplete fill quantities`);
727
+ }
728
+ return {
729
+ accountId,
730
+ orderId,
731
+ clientOrderId: order.cOID ?? order.order_ref ?? null,
732
+ status: this.normalizeDerivativeOrderStatus(order.order_status ?? order.orderStatus ?? order.status, filledQuantity, remainingQuantity),
733
+ quantity,
734
+ filledQuantity,
735
+ remainingQuantity,
736
+ averagePrice: this.firstNumber(order.avgPrice, order.avg_price, order.average_price, order.averagePrice) ?? null,
737
+ limitPrice: this.firstNumber(order.limitPrice, order.limit_price, order.price) ?? null,
738
+ commissionAndFees: typeof order.commissionAndFees === "number"
739
+ ? order.commissionAndFees
740
+ : this.whatIfNumber(order.commissionAndFees),
741
+ legs: this.parseComboLegs(order.conidex),
742
+ updatedAt: this.parseOrderTime(order)?.toISOString() ?? null,
743
+ };
744
+ }
745
+ normalizeDerivativeExecution(accountId, trade) {
746
+ if (!trade.execution_id || !Number.isSafeInteger(trade.conid) || Number(trade.conid) <= 0) {
747
+ return undefined;
748
+ }
749
+ const commission = typeof trade.commission === "number" ? trade.commission : this.whatIfNumber(trade.commission);
750
+ const commissionCurrency = typeof trade.commission === "string"
751
+ ? (/\b(?<currency>[A-Z]{3})\s*$/.exec(trade.commission.trim())?.groups?.["currency"] ??
752
+ null)
753
+ : null;
754
+ const side = trade.side?.trim().toUpperCase();
755
+ return {
756
+ accountId,
757
+ executionId: trade.execution_id,
758
+ orderId: trade.order_id === undefined ? null : String(trade.order_id),
759
+ clientOrderId: this.trimmedString(trade.order_ref),
760
+ conid: Number(trade.conid),
761
+ symbol: this.trimmedString(trade.contract_description_1) ?? this.trimmedString(trade.symbol),
762
+ side: side === "B" || side === "BUY" || side === "BOT"
763
+ ? "BUY"
764
+ : side === "S" || side === "SELL" || side === "SLD"
765
+ ? "SELL"
766
+ : "UNKNOWN",
767
+ quantity: this.firstNumber(trade.size) ?? 0,
768
+ price: this.firstNumber(trade.price) ?? null,
769
+ commission,
770
+ commissionCurrency,
771
+ netAmount: this.firstNumber(trade.net_amount) ?? null,
772
+ exchange: this.trimmedString(trade.exchange),
773
+ executedAt: this.parseTradeTime(trade),
774
+ };
775
+ }
776
+ parseTradeTime(trade) {
777
+ if (trade.trade_time_r !== undefined) {
778
+ const epoch = new Date(trade.trade_time_r);
779
+ if (!Number.isNaN(epoch.getTime()))
780
+ return epoch.toISOString();
781
+ }
782
+ const value = trade.trade_time;
783
+ const match = value ? /^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2}):(\d{2})$/.exec(value) : null;
784
+ if (!match)
785
+ return null;
786
+ const [, year, month, day, hour, minute, second] = match;
787
+ if (!year || !month || !day || !hour || !minute || !second)
788
+ return null;
789
+ const parsed = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
790
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
791
+ }
792
+ trimmedString(value) {
793
+ if (value === undefined)
794
+ return null;
795
+ const trimmed = value.trim();
796
+ return trimmed.length === 0 ? null : trimmed;
797
+ }
798
+ validateReconciliationRequest(request) {
799
+ if (!request.accountId.trim() || !request.orderId.trim() || !request.clientOrderId.trim()) {
800
+ throw new Error("Exact account, order, and client order IDs are required for reconciliation");
801
+ }
802
+ if (!Number.isSafeInteger(request.quantity) || request.quantity <= 0) {
803
+ throw new Error("Reconciliation quantity must be a positive integer");
804
+ }
805
+ if (!Number.isFinite(request.multiplier) || request.multiplier <= 0) {
806
+ throw new Error("Reconciliation multiplier must be positive");
807
+ }
808
+ if (request.legs.some(({ conid, ratio }) => !Number.isSafeInteger(conid) || conid <= 0 || !Number.isSafeInteger(ratio) || ratio === 0) ||
809
+ request.legs[0].conid === request.legs[1].conid) {
810
+ throw new Error("Reconciliation legs require distinct conids and non-zero integer ratios");
811
+ }
812
+ if ((request.timeoutMs ?? 30_000) < 0 || (request.pollMs ?? 1_000) <= 0) {
813
+ throw new Error("Reconciliation timing must use a non-negative timeout and positive poll");
814
+ }
815
+ }
816
+ evaluateDerivativeReconciliation(request, lifecycle, trades) {
817
+ const base = {
818
+ aggregateStatus: lifecycle.status,
819
+ filledQuantity: lifecycle.filledQuantity,
820
+ remainingQuantity: lifecycle.remainingQuantity,
821
+ multiplier: request.multiplier,
822
+ };
823
+ const recovery = (reason) => ({
824
+ ...base,
825
+ state: "RECOVERY_REQUIRED",
826
+ reason,
827
+ legs: [],
828
+ grossPoints: null,
829
+ grossAmount: null,
830
+ commission: null,
831
+ netAmount: null,
832
+ });
833
+ if (lifecycle.quantity !== request.quantity) {
834
+ return recovery("Aggregate order quantity does not match the reviewed combo");
835
+ }
836
+ if (lifecycle.clientOrderId !== null && lifecycle.clientOrderId !== request.clientOrderId) {
837
+ return recovery("Aggregate client order reference does not match the reviewed combo");
838
+ }
839
+ const expectedLegs = request.legs.map(({ conid, ratio }) => ({ conid, ratio }));
840
+ if (JSON.stringify(lifecycle.legs) !== JSON.stringify(expectedLegs)) {
841
+ return recovery("Aggregate combo legs do not match the reviewed combo");
842
+ }
843
+ const matching = trades.filter(({ clientOrderId }) => clientOrderId === request.clientOrderId);
844
+ const executionIds = new Set();
845
+ for (const trade of matching) {
846
+ if (executionIds.has(trade.executionId)) {
847
+ return recovery("Duplicate execution ID requires manual recovery");
848
+ }
849
+ executionIds.add(trade.executionId);
850
+ if (trade.orderId !== null && trade.orderId !== request.orderId) {
851
+ return recovery("Execution order ID does not match the reviewed combo");
852
+ }
853
+ const expected = request.legs.find(({ conid }) => conid === trade.conid);
854
+ if (expected === undefined) {
855
+ return recovery("Execution contains an unexpected combo leg");
856
+ }
857
+ if (trade.side !== this.sideForRatio(expected.ratio)) {
858
+ return recovery("Execution side does not match the reviewed combo ratio");
859
+ }
860
+ if (trade.quantity <= 0 ||
861
+ trade.price === null ||
862
+ trade.price < 0 ||
863
+ trade.commission === null ||
864
+ trade.commission < 0 ||
865
+ trade.executedAt === null) {
866
+ return recovery("Execution contains incomplete economics or timing evidence");
867
+ }
868
+ }
869
+ const completeExecutions = matching.flatMap((trade) => trade.side !== "UNKNOWN" &&
870
+ trade.price !== null &&
871
+ trade.commission !== null &&
872
+ trade.executedAt !== null
873
+ ? [
874
+ {
875
+ ...trade,
876
+ side: trade.side,
877
+ price: trade.price,
878
+ commission: trade.commission,
879
+ executedAt: trade.executedAt,
880
+ },
881
+ ]
882
+ : []);
883
+ const summaries = [];
884
+ for (const expected of request.legs) {
885
+ const executions = completeExecutions.filter(({ conid }) => conid === expected.conid);
886
+ const quantity = executions.reduce((sum, trade) => sum + trade.quantity, 0);
887
+ const expectedQuantity = lifecycle.filledQuantity * Math.abs(expected.ratio);
888
+ if (quantity > expectedQuantity) {
889
+ return recovery("Execution quantity exceeds the aggregate fill");
890
+ }
891
+ if (quantity < expectedQuantity) {
892
+ return {
893
+ ...base,
894
+ state: "PENDING",
895
+ reason: "Terminal order is missing expected execution evidence",
896
+ legs: summaries,
897
+ grossPoints: null,
898
+ grossAmount: null,
899
+ commission: null,
900
+ netAmount: null,
901
+ };
902
+ }
903
+ if (quantity > 0) {
904
+ summaries.push({
905
+ conid: expected.conid,
906
+ side: this.sideForRatio(expected.ratio),
907
+ quantity,
908
+ averagePrice: this.round(executions.reduce((sum, trade) => sum + trade.price * trade.quantity, 0) / quantity, 8),
909
+ commission: this.round(executions.reduce((sum, trade) => sum + trade.commission, 0), 2),
910
+ executionCount: executions.length,
911
+ });
912
+ }
913
+ }
914
+ const cashFlowPoints = completeExecutions.reduce((sum, trade) => sum + (trade.side === "SELL" ? 1 : -1) * trade.price * trade.quantity, 0);
915
+ const grossPoints = lifecycle.filledQuantity > 0 ? this.round(cashFlowPoints / lifecycle.filledQuantity, 8) : 0;
916
+ const grossAmount = this.round(cashFlowPoints * request.multiplier, 2);
917
+ const commission = this.round(completeExecutions.reduce((sum, trade) => sum + trade.commission, 0), 2);
918
+ const result = {
919
+ ...base,
920
+ state: this.isTerminalDerivativeStatus(lifecycle.status) ? "VERIFIED" : "PENDING",
921
+ reason: null,
922
+ legs: summaries,
923
+ grossPoints,
924
+ grossAmount,
925
+ commission,
926
+ netAmount: this.round(grossAmount - commission, 2),
927
+ };
928
+ if (lifecycle.status === "FILLED" && lifecycle.filledQuantity !== request.quantity) {
929
+ return recovery("Filled aggregate quantity does not match the reviewed combo");
930
+ }
931
+ return result;
932
+ }
933
+ sideForRatio(ratio) {
934
+ return ratio > 0 ? "BUY" : "SELL";
935
+ }
936
+ isTerminalDerivativeStatus(status) {
937
+ return status === "FILLED" || status === "CANCELED" || status === "REJECTED";
938
+ }
939
+ round(value, decimalPlaces) {
940
+ const factor = 10 ** decimalPlaces;
941
+ return Math.round((value + Number.EPSILON) * factor) / factor;
942
+ }
943
+ parseComboLegs(conidex) {
944
+ const encoded = conidex?.split(";;;")[1];
945
+ if (!encoded)
946
+ return [];
947
+ return encoded.split(",").flatMap((leg) => {
948
+ const [conidValue, ratioValue] = leg.split("/");
949
+ const conid = Number(conidValue);
950
+ const ratio = Number(ratioValue);
951
+ return Number.isSafeInteger(conid) && conid > 0 && Number.isSafeInteger(ratio) && ratio !== 0
952
+ ? [{ conid, ratio }]
953
+ : [];
954
+ });
955
+ }
956
+ normalizeDerivativeOrderStatus(value, filledQuantity, remainingQuantity) {
957
+ const status = value
958
+ ?.replace(/([a-z])([A-Z])/g, "$1_$2")
959
+ .replace(/\s+/g, "_")
960
+ .toUpperCase();
961
+ if (filledQuantity > 0 && remainingQuantity > 0)
962
+ return "PARTIALLY_FILLED";
963
+ if (status === "FILLED")
964
+ return "FILLED";
965
+ if (status === "CANCELLED" || status === "CANCELED")
966
+ return "CANCELED";
967
+ if (status === "INACTIVE" || status === "REJECTED")
968
+ return "REJECTED";
969
+ if (status === "API_PENDING" || status === "PENDING_SUBMIT")
970
+ return "PENDING";
971
+ if (status !== undefined && IBKR_WORKING_STATUSES.has(status))
972
+ return "WORKING";
973
+ return "UNKNOWN";
974
+ }
471
975
  normalizeComboPreview(accountId, diagnostics, response) {
472
976
  const commission = this.whatIfNumber(response.amount?.commission);
473
977
  const initialMargin = this.whatIfMargin(response.initial);
@@ -608,7 +1112,10 @@ export class IbkrClient {
608
1112
  }
609
1113
  /** Discover listed derivative series over an inclusive calendar range. */
610
1114
  async getDerivativeExpiries(query) {
611
- const contracts = (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
+ }
612
1119
  const filtered = contracts.filter((contract) => contract.expiration >= query.from &&
613
1120
  contract.expiration <= query.to &&
614
1121
  (query.right === undefined || contract.right === query.right) &&
@@ -1286,6 +1793,14 @@ export class IbkrClient {
1286
1793
  wait(ms) {
1287
1794
  return sleep(ms);
1288
1795
  }
1796
+ /** Overridable monotonic-enough wall clock for bounded polling tests. */
1797
+ now() {
1798
+ return Date.now();
1799
+ }
1800
+ /** Overridable entropy source for deterministic scheduler jitter tests. */
1801
+ random() {
1802
+ return Math.random();
1803
+ }
1289
1804
  normalizeQuote(contract, snapshot, history) {
1290
1805
  const symbol = this.snapshotString(snapshot, "55") ?? contract.symbol;
1291
1806
  const description = this.snapshotString(snapshot, "58") ?? history?.text ?? contract.description;
@@ -1407,23 +1922,84 @@ export class IbkrClient {
1407
1922
  async sendRequest(input) {
1408
1923
  return (await this.raw.request(input));
1409
1924
  }
1410
- async req(input) {
1411
- let retries = 0;
1412
- 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 [];
1413
1995
  try {
1414
- return await this.sendRequest(input);
1996
+ return [JSON.stringify(value)];
1415
1997
  }
1416
- catch (error) {
1417
- const status = this.httpStatusFromError(error);
1418
- if (status !== 429 || retries >= READ_ONLY_REQUEST_MAX_RETRIES) {
1419
- throw error;
1420
- }
1421
- const retryAfter = this.retryAfterFromError(error);
1422
- const delayMs = this.computeBackoffDelayMs(retries, retryAfter);
1423
- retries += 1;
1424
- await this.wait(delayMs);
1998
+ catch {
1999
+ return [];
1425
2000
  }
1426
- }
2001
+ })
2002
+ .join(" ");
1427
2003
  }
1428
2004
  httpStatusFromError(error) {
1429
2005
  if (typeof error !== "object" || error === null)
@@ -1459,11 +2035,6 @@ export class IbkrClient {
1459
2035
  this.headerValue(directHeaders, "Retry-After");
1460
2036
  return parseRetryAfter(retryAfterRaw);
1461
2037
  }
1462
- computeBackoffDelayMs(retry, retryAfterMs) {
1463
- if (retryAfterMs !== undefined)
1464
- return Math.min(retryAfterMs, REQUEST_RETRY_MAX_DELAY_MS);
1465
- return Math.min(REQUEST_RETRY_BASE_DELAY_MS * 2 ** retry, REQUEST_RETRY_MAX_DELAY_MS);
1466
- }
1467
2038
  numberFromUnknown(value) {
1468
2039
  if (typeof value === "number")
1469
2040
  return Number.isFinite(value) ? value : undefined;