@huskly/ibkr-client 0.28.0 → 1.0.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.
@@ -81,16 +81,17 @@ function extractOsiPositionSymbol(contractDescription) {
81
81
  return /\[([A-Z]+\s*\d{6}[CP]\d{8})\s+\d+\]\s*$/.exec(contractDescription)?.[1];
82
82
  }
83
83
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
- function parseRetryAfter(raw) {
84
+ function parseRetryAfter(raw, now) {
85
85
  const asString = typeof raw === "string" ? raw.trim() : undefined;
86
86
  if (!asString)
87
87
  return undefined;
88
88
  const numeric = Number(asString);
89
- if (Number.isFinite(numeric) && numeric > 0)
90
- return Math.ceil(numeric * 1000);
89
+ const numericMs = Math.ceil(numeric * 1000);
90
+ if (Number.isSafeInteger(numericMs) && numericMs > 0)
91
+ return numericMs;
91
92
  const date = Date.parse(asString);
92
93
  if (!Number.isNaN(date)) {
93
- const ms = Math.max(0, date - Date.now());
94
+ const ms = Math.max(0, date - now);
94
95
  if (ms > 0)
95
96
  return ms;
96
97
  }
@@ -99,6 +100,25 @@ function parseRetryAfter(raw) {
99
100
  function isUnknownRecord(input) {
100
101
  return typeof input === "object" && input !== null && !Array.isArray(input);
101
102
  }
103
+ const PRICE_HISTORY_SECURITY_TYPES = new Set([
104
+ "STK",
105
+ "IND",
106
+ "OPT",
107
+ "FUT",
108
+ "FOP",
109
+ "CASH",
110
+ "CFD",
111
+ "WAR",
112
+ "FUND",
113
+ "BOND",
114
+ "CMDTY",
115
+ ]);
116
+ function isPriceHistorySecurityType(value) {
117
+ return PRICE_HISTORY_SECURITY_TYPES.has(value);
118
+ }
119
+ function isFiniteHistoryBar(bar) {
120
+ return [bar.t, bar.o, bar.h, bar.l, bar.c, bar.v].every((value) => typeof value === "number" && Number.isFinite(value));
121
+ }
102
122
  function isHeadersLike(input) {
103
123
  return (typeof input === "object" &&
104
124
  input !== null &&
@@ -168,6 +188,60 @@ function monthCodes(fromDate, toDate) {
168
188
  }
169
189
  return result;
170
190
  }
191
+ /** A typed HTTP failure from the IBKR transport. */
192
+ export class IbkrHttpError extends Error {
193
+ status;
194
+ response;
195
+ statusCode;
196
+ constructor(message, status, response, options) {
197
+ super(message, options);
198
+ this.status = status;
199
+ this.response = response;
200
+ this.name = "IbkrHttpError";
201
+ this.statusCode = status;
202
+ }
203
+ }
204
+ /**
205
+ * Typed broker rejection for documented IBKR error-object payloads outside order
206
+ * submission result envelopes. Callers can catch this class to read the retained
207
+ * {@link BrokerErrorDetail} without parsing free-form message text.
208
+ */
209
+ export class IbkrBrokerResponseError extends Error {
210
+ detail;
211
+ constructor(message, detail, options) {
212
+ super(message, options);
213
+ this.detail = detail;
214
+ this.name = "IbkrBrokerResponseError";
215
+ }
216
+ }
217
+ /** Typed contract-resolution failure raised before a price-history broker request. */
218
+ export class IbkrPriceHistoryContractError extends Error {
219
+ code;
220
+ candidates;
221
+ constructor(message, code, candidates = []) {
222
+ super(message);
223
+ this.code = code;
224
+ this.candidates = candidates;
225
+ this.name = "IbkrPriceHistoryContractError";
226
+ }
227
+ }
228
+ /** A daily history request for which IBKR did not return the full requested interval. */
229
+ export class IbkrInsufficientHistoryError extends Error {
230
+ symbol;
231
+ requestedStart;
232
+ requestedEnd;
233
+ availableStart;
234
+ availableEnd;
235
+ constructor(symbol, requestedStart, requestedEnd, availableStart, availableEnd, options) {
236
+ super(`IBKR returned insufficient daily history for ${symbol}`, options);
237
+ this.symbol = symbol;
238
+ this.requestedStart = requestedStart;
239
+ this.requestedEnd = requestedEnd;
240
+ this.availableStart = availableStart;
241
+ this.availableEnd = availableEnd;
242
+ this.name = "IbkrInsufficientHistoryError";
243
+ }
244
+ }
171
245
  /**
172
246
  * Typed IBKR Web API client implementing the broker-neutral {@link BrokerClient}.
173
247
  * Wraps the `ibkr-client` npm package, which performs the OAuth 1.0a
@@ -179,17 +253,24 @@ export class IbkrClient {
179
253
  initPromise;
180
254
  accountIdPromise;
181
255
  optionDiscovery = new Map();
182
- optionUnderlyingDiscovery = new Map();
183
256
  optionContractResolution = new Map();
184
257
  derivativeDiscovery = new Map();
185
258
  requestScheduler;
259
+ requestNow;
260
+ secdefPrimingTail = Promise.resolve();
261
+ onPriceHistoryTelemetry;
262
+ onRequestTelemetry;
186
263
  constructor(config, options = {}) {
187
264
  this.raw = new RawIbkrClientCtor(config);
265
+ this.onPriceHistoryTelemetry = options.onPriceHistoryTelemetry ?? (() => undefined);
266
+ this.onRequestTelemetry = options.onRequestTelemetry ?? (() => undefined);
267
+ const schedulerOptions = options.requestScheduler;
268
+ this.requestNow = schedulerOptions?.now ?? (() => this.now());
188
269
  this.requestScheduler = new IbkrRequestScheduler({
189
- ...options.requestScheduler,
190
- now: () => this.now(),
191
- sleep: (ms) => this.wait(ms),
192
- random: () => this.random(),
270
+ ...schedulerOptions,
271
+ now: this.requestNow,
272
+ sleep: schedulerOptions?.sleep ?? ((ms) => this.wait(ms)),
273
+ random: schedulerOptions?.random ?? (() => this.random()),
193
274
  classifyError: (error) => this.classifyRequestError(error),
194
275
  ...(options.onRequestTelemetry === undefined
195
276
  ? {}
@@ -199,7 +280,12 @@ export class IbkrClient {
199
280
  /** Obtain the live session token (idempotent — safe to await repeatedly). */
200
281
  init() {
201
282
  this.initPromise ??= (async () => {
202
- await this.raw.init();
283
+ try {
284
+ await this.raw.init();
285
+ }
286
+ catch (error) {
287
+ throw this.normalizeHttpError(error);
288
+ }
203
289
  // IBKR is slow right after init; give the session a moment to settle.
204
290
  await this.wait(1000);
205
291
  })();
@@ -250,7 +336,7 @@ export class IbkrClient {
250
336
  path: "iserver/marketdata/snapshot",
251
337
  params: { conids, fields: "6509" },
252
338
  });
253
- const response = await this.req({
339
+ const response = await this.singleAttemptRequest({
254
340
  path: `iserver/account/${request.accountId}/orders/whatif`,
255
341
  method: "POST",
256
342
  data: {
@@ -1336,7 +1422,7 @@ export class IbkrClient {
1336
1422
  openProfitLoss: toNumber(p.unrealizedPnl),
1337
1423
  };
1338
1424
  }
1339
- async getQuotes(requests) {
1425
+ async getQuotes(requests, options = {}) {
1340
1426
  const unique = new Map();
1341
1427
  for (const request of requests) {
1342
1428
  if (!request.symbol.trim())
@@ -1363,9 +1449,9 @@ export class IbkrClient {
1363
1449
  conid,
1364
1450
  };
1365
1451
  }));
1366
- return this.fetchQuotes(contracts.filter((contract) => contract !== undefined));
1452
+ return this.fetchQuotes(contracts.filter((contract) => contract !== undefined), options.includeHistory ?? true);
1367
1453
  }
1368
- async fetchQuotes(contracts) {
1454
+ async fetchQuotes(contracts, includeHistory) {
1369
1455
  if (!contracts.length)
1370
1456
  return {};
1371
1457
  const conids = contracts.map((contract) => contract.conid).join(",");
@@ -1379,7 +1465,9 @@ export class IbkrClient {
1379
1465
  const snapshotByConid = new Map(snapshots
1380
1466
  .filter((snapshot) => snapshot.conid !== undefined)
1381
1467
  .map((snapshot) => [snapshot.conid, snapshot]));
1382
- const histories = await Promise.all(contracts.map((contract) => this.fetchQuoteHistory(contract.conid)));
1468
+ const histories = includeHistory
1469
+ ? await Promise.all(contracts.map((contract) => this.fetchQuoteHistory(contract.conid)))
1470
+ : contracts.map(() => undefined);
1383
1471
  const quotes = {};
1384
1472
  for (const [index, contract] of contracts.entries()) {
1385
1473
  const snapshot = snapshotByConid.get(contract.conid);
@@ -2248,7 +2336,7 @@ export class IbkrClient {
2248
2336
  }
2249
2337
  return "IBKR returned mixed or incomplete order evidence";
2250
2338
  }
2251
- normalizeBrokerError(error, response) {
2339
+ normalizeBrokerError(error, response, defaultMessage = "IBKR rejected the order") {
2252
2340
  const nested = typeof error === "object" && error !== null ? error : undefined;
2253
2341
  const nestedMessage = nested ? nested.message : undefined;
2254
2342
  const responseMessage = response["message"];
@@ -2259,7 +2347,7 @@ export class IbkrClient {
2259
2347
  (typeof responseMessage === "string" && responseMessage.trim()) ||
2260
2348
  (typeof responseText === "string" && responseText.trim()) ||
2261
2349
  (typeof responseWarningMessage === "string" && responseWarningMessage.trim()) ||
2262
- "IBKR rejected the order";
2350
+ defaultMessage;
2263
2351
  const nestedCode = nested ? nested.code : undefined;
2264
2352
  const responseCode = response["code"];
2265
2353
  const codeValue = nestedCode ?? responseCode;
@@ -2271,6 +2359,36 @@ export class IbkrClient {
2271
2359
  details: response,
2272
2360
  };
2273
2361
  }
2362
+ /**
2363
+ * Validate `iserver/secdef/search` at one shared boundary.
2364
+ * Accept the documented success array, convert the documented error object into a typed
2365
+ * broker error, and fail closed on any other shape. Never treat an error object as an empty
2366
+ * successful search.
2367
+ */
2368
+ parseSecdefSearchResponse(response) {
2369
+ if (Array.isArray(response)) {
2370
+ return response;
2371
+ }
2372
+ if (isUnknownRecord(response) &&
2373
+ response["error"] !== undefined &&
2374
+ response["error"] !== null) {
2375
+ // Any non-null `error` field is the documented IBKR error-object shape for this
2376
+ // endpoint. Do not reclassify it as a malformed payload when the message is empty.
2377
+ const detail = this.normalizeBrokerError(response["error"], response, "IBKR rejected the security-definition search");
2378
+ throw new IbkrBrokerResponseError(detail.message, detail);
2379
+ }
2380
+ throw new Error("IBKR returned a malformed secdef/search response");
2381
+ }
2382
+ /** Serialize IBKR operations that mutate and then consume session security-definition state. */
2383
+ withSecdefPriming(operation) {
2384
+ const result = this.secdefPrimingTail.then(operation);
2385
+ this.secdefPrimingTail = result.then(() => undefined, () => undefined);
2386
+ return result;
2387
+ }
2388
+ /** Run one state-mutating search outside a larger priming transaction. */
2389
+ searchSecdef(params) {
2390
+ return this.withSecdefPriming(async () => this.parseSecdefSearchResponse(await this.req({ path: "iserver/secdef/search", params })));
2391
+ }
2274
2392
  isMeaningfulBrokerError(error, response) {
2275
2393
  const nested = typeof error === "object" && error !== null ? error : undefined;
2276
2394
  const nestedRecord = nested;
@@ -2834,7 +2952,7 @@ export class IbkrClient {
2834
2952
  if (brokerageAccounts.accounts && !brokerageAccounts.accounts.includes(accountId)) {
2835
2953
  throw new Error(`IBKR account ${accountId} is not available for trading/order queries.`);
2836
2954
  }
2837
- const switchedAccount = await this.req({
2955
+ const switchedAccount = await this.singleAttemptRequest({
2838
2956
  path: "iserver/account",
2839
2957
  method: "POST",
2840
2958
  data: { acctId: accountId },
@@ -2900,10 +3018,7 @@ export class IbkrClient {
2900
3018
  }
2901
3019
  // `trsrv/stocks` is equity/ETF-only. Fall back to security-definition search so
2902
3020
  // non-stock roots (indexes like VIX, futures, etc.) still resolve to a conid.
2903
- const search = await this.req({
2904
- path: "iserver/secdef/search",
2905
- params: { symbol: symbol.trim().toUpperCase() },
2906
- });
3021
+ const search = await this.searchSecdef({ symbol: symbol.trim().toUpperCase() });
2907
3022
  const candidate = search.find((item) => item.conid !== undefined &&
2908
3023
  item.sections?.some((section) => {
2909
3024
  const secType = section.secType?.trim().toUpperCase();
@@ -2920,33 +3035,137 @@ export class IbkrClient {
2920
3035
  conid: fallbackConid,
2921
3036
  };
2922
3037
  }
2923
- /** Return normalized daily price history without consulting a vendor-owned clock. */
3038
+ /** Return complete daily history with the exact validated IBKR request context. */
2924
3039
  async getPriceHistory(input) {
2925
- const contract = await this.resolveQuoteContract(input.symbol);
2926
- if (!contract)
2927
- throw new Error(`IBKR could not resolve market-data contract: ${input.symbol}`);
2928
- const days = this.historyDays(input);
2929
- const history = await this.fetchQuoteHistory(contract.conid, `${String(days)}d`, false);
2930
- const volumeFactor = history?.volumeFactor ?? 1;
2931
- return (history?.data ?? []).map((bar) => {
2932
- if (bar.t === undefined ||
2933
- bar.o === undefined ||
2934
- bar.h === undefined ||
2935
- bar.l === undefined ||
2936
- bar.c === undefined ||
2937
- bar.v === undefined) {
2938
- throw new Error(`IBKR returned an incomplete history bar for ${input.symbol}`);
3040
+ const requestedSymbol = input.symbol.trim().toUpperCase();
3041
+ if (!requestedSymbol) {
3042
+ throw new IbkrPriceHistoryContractError("Price history requires a symbol", "CONTRACT_INVALID");
3043
+ }
3044
+ const interval = this.historyInterval(input);
3045
+ const period = `${String(interval.days)}d`;
3046
+ const contract = await this.resolvePriceHistoryContract(requestedSymbol, input.contract);
3047
+ const request = {
3048
+ period,
3049
+ ...(input.endDate === undefined ? {} : { startTime: this.historyStartTime(interval.end) }),
3050
+ };
3051
+ let bars;
3052
+ try {
3053
+ const history = await this.requestPriceHistory(contract, requestedSymbol, request);
3054
+ bars = this.normalizeHistoryResponse(requestedSymbol, history, interval);
3055
+ this.assertHistoryCoverage(requestedSymbol, bars, interval);
3056
+ }
3057
+ catch (error) {
3058
+ if (!this.isChartDataUnavailable(error))
3059
+ throw error;
3060
+ bars = await this.recoverDailyHistory(contract, requestedSymbol, interval, error);
3061
+ }
3062
+ return {
3063
+ bars,
3064
+ contract,
3065
+ request: { requestedSymbol, period, barSize: "1d" },
3066
+ };
3067
+ }
3068
+ async resolvePriceHistoryContract(requestedSymbol, selector) {
3069
+ if (selector !== undefined) {
3070
+ if (!Number.isSafeInteger(selector.conid) || selector.conid <= 0) {
3071
+ throw new IbkrPriceHistoryContractError(`Invalid IBKR price-history conid: ${String(selector.conid)}`, "CONTRACT_INVALID");
2939
3072
  }
2940
- return {
2941
- datetime: bar.t,
2942
- open: bar.o,
2943
- high: bar.h,
2944
- low: bar.l,
2945
- close: bar.c,
2946
- volume: bar.v * volumeFactor,
2947
- };
3073
+ const expectedSecurityType = selector.assetClass?.trim().toUpperCase();
3074
+ if (expectedSecurityType !== undefined && !isPriceHistorySecurityType(expectedSecurityType)) {
3075
+ throw new IbkrPriceHistoryContractError(`Invalid IBKR price-history asset class: ${expectedSecurityType}`, "CONTRACT_INVALID");
3076
+ }
3077
+ return this.validatePriceHistoryContract(requestedSymbol, selector.conid, {
3078
+ ...(expectedSecurityType === undefined ? {} : { securityType: expectedSecurityType }),
3079
+ ...(selector.exchange === undefined
3080
+ ? {}
3081
+ : { exchange: selector.exchange.trim().toUpperCase() }),
3082
+ });
3083
+ }
3084
+ const search = await this.searchSecdef({ symbol: requestedSymbol });
3085
+ const candidates = search.flatMap((item) => {
3086
+ const symbol = item.symbol?.trim().toUpperCase();
3087
+ const conid = Number(item.conid);
3088
+ if (symbol !== requestedSymbol ||
3089
+ !Number.isSafeInteger(conid) ||
3090
+ conid <= 0 ||
3091
+ !Array.isArray(item.sections)) {
3092
+ return [];
3093
+ }
3094
+ return item.sections.flatMap((section) => {
3095
+ const securityType = section.secType?.trim().toUpperCase();
3096
+ if (securityType !== "STK" && securityType !== "IND")
3097
+ return [];
3098
+ const exchange = section.exchange?.trim().toUpperCase();
3099
+ return [
3100
+ {
3101
+ conid,
3102
+ symbol,
3103
+ securityType,
3104
+ ...(exchange ? { exchange } : {}),
3105
+ },
3106
+ ];
3107
+ });
3108
+ });
3109
+ const uniqueCandidates = [
3110
+ ...new Map([...candidates]
3111
+ .sort((left, right) => (left.exchange ?? "").localeCompare(right.exchange ?? ""))
3112
+ .map((candidate) => [[candidate.conid, candidate.securityType].join(":"), candidate])).values(),
3113
+ ];
3114
+ if (uniqueCandidates.length === 0) {
3115
+ throw new IbkrPriceHistoryContractError(`IBKR returned no STK or IND contract for ${requestedSymbol}`, "CONTRACT_NOT_FOUND");
3116
+ }
3117
+ if (uniqueCandidates.length !== 1) {
3118
+ const safeCandidates = uniqueCandidates.map((candidate) => ({
3119
+ conid: candidate.conid,
3120
+ symbol: candidate.symbol,
3121
+ securityType: candidate.securityType,
3122
+ exchange: candidate.exchange ?? null,
3123
+ }));
3124
+ throw new IbkrPriceHistoryContractError(`IBKR price-history contract is ambiguous for ${requestedSymbol}; specify contract.conid`, "CONTRACT_AMBIGUOUS", safeCandidates);
3125
+ }
3126
+ const candidate = uniqueCandidates[0];
3127
+ if (candidate === undefined) {
3128
+ throw new Error("IBKR price-history resolution lost its selected contract");
3129
+ }
3130
+ return this.validatePriceHistoryContract(requestedSymbol, candidate.conid, {
3131
+ securityType: candidate.securityType,
2948
3132
  });
2949
3133
  }
3134
+ async validatePriceHistoryContract(requestedSymbol, conid, expected) {
3135
+ const response = await this.req({
3136
+ path: `iserver/contract/${String(conid)}/info`,
3137
+ });
3138
+ if (isUnknownRecord(response) &&
3139
+ response["error"] !== undefined &&
3140
+ response["error"] !== null) {
3141
+ const detail = this.normalizeBrokerError(response["error"], response, "IBKR rejected the contract metadata request");
3142
+ throw new IbkrBrokerResponseError(detail.message, detail);
3143
+ }
3144
+ if (!isUnknownRecord(response)) {
3145
+ throw new IbkrPriceHistoryContractError(`IBKR returned malformed contract metadata for conid ${String(conid)}`, "CONTRACT_INVALID");
3146
+ }
3147
+ const info = response;
3148
+ const returnedConid = Number(info.con_id);
3149
+ const symbol = info.local_symbol?.trim().toUpperCase();
3150
+ const securityType = info.instrument_type?.trim().toUpperCase();
3151
+ const exchange = info.exchange?.trim().toUpperCase();
3152
+ if (returnedConid !== conid ||
3153
+ symbol === undefined ||
3154
+ !symbol ||
3155
+ securityType === undefined ||
3156
+ !isPriceHistorySecurityType(securityType) ||
3157
+ exchange === undefined ||
3158
+ !exchange) {
3159
+ throw new IbkrPriceHistoryContractError(`IBKR returned incomplete contract metadata for conid ${String(conid)}`, "CONTRACT_INVALID");
3160
+ }
3161
+ const contract = { conid, symbol, securityType, exchange };
3162
+ if (symbol !== requestedSymbol ||
3163
+ (expected.securityType !== undefined && expected.securityType !== securityType) ||
3164
+ (expected.exchange !== undefined && expected.exchange !== exchange)) {
3165
+ throw new IbkrPriceHistoryContractError(`IBKR contract ${String(conid)} does not match the requested price-history identity`, "CONTRACT_MISMATCH", [contract]);
3166
+ }
3167
+ return contract;
3168
+ }
2950
3169
  /** Discover listed derivative series over an inclusive calendar range. */
2951
3170
  async getDerivativeExpiries(query) {
2952
3171
  const contracts = [];
@@ -3137,8 +3356,11 @@ export class IbkrClient {
3137
3356
  return pending;
3138
3357
  }
3139
3358
  /** Resolve one known option directly, without enumerating its month's complete chain. */
3140
- async loadExactOptionContract(input, month) {
3141
- const underlying = await this.discoverOptionUnderlying(input.symbol);
3359
+ loadExactOptionContract(input, month) {
3360
+ return this.withSecdefPriming(() => this.loadExactOptionContractPrimed(input, month));
3361
+ }
3362
+ async loadExactOptionContractPrimed(input, month) {
3363
+ const underlying = await this.loadOptionUnderlying(input.symbol);
3142
3364
  const definitions = await this.req({
3143
3365
  path: "iserver/secdef/info",
3144
3366
  params: {
@@ -3220,13 +3442,16 @@ export class IbkrClient {
3220
3442
  }
3221
3443
  return pending;
3222
3444
  }
3223
- async loadDerivativeContracts(underlying, assetClass, month, requestedExchange, requestedRight, requestedStrike) {
3445
+ loadDerivativeContracts(underlying, assetClass, month, requestedExchange, requestedRight, requestedStrike) {
3446
+ return this.withSecdefPriming(() => this.loadDerivativeContractsPrimed(underlying, assetClass, month, requestedExchange, requestedRight, requestedStrike));
3447
+ }
3448
+ async loadDerivativeContractsPrimed(underlying, assetClass, month, requestedExchange, requestedRight, requestedStrike) {
3224
3449
  // IBKR keeps this priming state in the authenticated session. Strikes may be
3225
3450
  // empty when search has not run first, even with otherwise identical params.
3226
- const search = await this.req({
3451
+ const search = this.parseSecdefSearchResponse(await this.req({
3227
3452
  path: "iserver/secdef/search",
3228
3453
  params: { symbol: underlying, ...(assetClass === "FOP" ? { secType: "FUT" } : {}) },
3229
- });
3454
+ }));
3230
3455
  const candidates = search.filter((candidate) => candidate.conid !== undefined &&
3231
3456
  candidate.symbol?.trim().toUpperCase() === underlying &&
3232
3457
  candidate.sections?.some((section) => section.secType?.toUpperCase() === assetClass));
@@ -3341,29 +3566,13 @@ export class IbkrClient {
3341
3566
  }
3342
3567
  return pending;
3343
3568
  }
3344
- /** Search once per underlying because IBKR uses this call to prime option lookup state. */
3345
- discoverOptionUnderlying(symbol) {
3346
- const normalized = symbol.trim().toUpperCase();
3347
- let pending = this.optionUnderlyingDiscovery.get(normalized);
3348
- if (!pending) {
3349
- pending = this.loadOptionUnderlying(normalized).catch((error) => {
3350
- this.optionUnderlyingDiscovery.delete(normalized);
3351
- throw error;
3352
- });
3353
- this.optionUnderlyingDiscovery.set(normalized, pending);
3354
- }
3355
- return pending;
3356
- }
3357
3569
  async loadOptionUnderlying(symbol) {
3358
3570
  // This search is load-bearing: IBKR silently returns empty definitions unless the current
3359
3571
  // session has first searched the underlying.
3360
- const search = await this.req({
3572
+ const search = this.parseSecdefSearchResponse(await this.req({
3361
3573
  path: "iserver/secdef/search",
3362
3574
  params: { symbol },
3363
- });
3364
- if (!Array.isArray(search)) {
3365
- throw new Error(`IBKR returned malformed option underlying results for ${symbol}`);
3366
- }
3575
+ }));
3367
3576
  const candidates = search.flatMap((item) => {
3368
3577
  if (!isUnknownRecord(item))
3369
3578
  return [];
@@ -3372,28 +3581,41 @@ export class IbkrClient {
3372
3581
  return [];
3373
3582
  }
3374
3583
  const sections = item["sections"];
3375
- if (!Array.isArray(sections) ||
3376
- !sections.some((section) => isUnknownRecord(section) &&
3377
- typeof section["secType"] === "string" &&
3378
- section["secType"].trim().toUpperCase() === "OPT")) {
3584
+ if (!Array.isArray(sections))
3379
3585
  return [];
3380
- }
3586
+ const optionSections = sections.flatMap((section) => {
3587
+ if (!isUnknownRecord(section))
3588
+ return [];
3589
+ const secType = section["secType"];
3590
+ if (typeof secType !== "string" || secType.trim().toUpperCase() !== "OPT")
3591
+ return [];
3592
+ const exchange = section["exchange"];
3593
+ return [{ exchange: typeof exchange === "string" ? exchange : null }];
3594
+ });
3595
+ if (!optionSections.length)
3596
+ return [];
3597
+ const supportsSmart = optionSections.some(({ exchange }) => exchange?.split(";").some((name) => name.trim().toUpperCase() === "SMART") ?? false);
3381
3598
  const conid = Number(item["conid"]);
3382
- return Number.isSafeInteger(conid) && conid > 0 ? [{ conid, symbol }] : [];
3599
+ return Number.isSafeInteger(conid) && conid > 0 ? [{ conid, symbol, supportsSmart }] : [];
3383
3600
  });
3384
3601
  const unique = [
3385
3602
  ...new Map(candidates.map((candidate) => [candidate.conid, candidate])).values(),
3386
3603
  ];
3387
- if (unique.length !== 1) {
3388
- throw new Error(`IBKR option underlying identity is ${unique.length ? "ambiguous" : "missing"} for ${symbol}`);
3604
+ const smart = unique.filter((candidate) => candidate.supportsSmart);
3605
+ const eligible = smart.length > 0 ? smart : unique;
3606
+ if (eligible.length !== 1) {
3607
+ throw new Error(`IBKR option underlying identity is ${eligible.length ? "ambiguous" : "missing"} for ${symbol}`);
3389
3608
  }
3390
- const [underlying] = unique;
3609
+ const [underlying] = eligible;
3391
3610
  if (underlying === undefined)
3392
3611
  throw new Error(`IBKR lost the selected underlying for ${symbol}`);
3393
3612
  return underlying;
3394
3613
  }
3395
- async loadOptionContracts(symbol, month) {
3396
- const underlying = await this.discoverOptionUnderlying(symbol);
3614
+ loadOptionContracts(symbol, month) {
3615
+ return this.withSecdefPriming(() => this.loadOptionContractsPrimed(symbol, month));
3616
+ }
3617
+ async loadOptionContractsPrimed(symbol, month) {
3618
+ const underlying = await this.loadOptionUnderlying(symbol);
3397
3619
  const strikes = await this.req({
3398
3620
  path: "iserver/secdef/strikes",
3399
3621
  params: { conid: String(underlying.conid), sectype: "OPT", month },
@@ -3487,32 +3709,273 @@ export class IbkrClient {
3487
3709
  }
3488
3710
  return result;
3489
3711
  }
3490
- historyDays(input) {
3712
+ historyInterval(input) {
3491
3713
  if (input.days !== undefined) {
3492
3714
  if (!Number.isFinite(input.days) || input.days <= 0) {
3493
3715
  throw new Error(`History days must be positive: ${String(input.days)}`);
3494
3716
  }
3495
- return Math.ceil(input.days);
3717
+ const days = Math.ceil(input.days);
3718
+ const endDay = this.utcDayStart(this.now());
3719
+ return { start: endDay - (days - 1) * DAY_MS, end: endDay + DAY_MS - 1, days };
3496
3720
  }
3497
- if (input.startDate === undefined || input.endDate === undefined) {
3498
- throw new Error("Price history requires days or both startDate and endDate");
3721
+ if (!Number.isFinite(input.startDate) || !Number.isFinite(input.endDate)) {
3722
+ throw new Error("Price history boundaries must be finite epoch milliseconds");
3499
3723
  }
3500
- const duration = input.endDate - input.startDate;
3501
- if (duration < 0)
3724
+ if (input.endDate < input.startDate) {
3502
3725
  throw new Error("Price history endDate must not precede startDate");
3503
- return Math.max(1, Math.ceil(duration / 86_400_000) + 1);
3726
+ }
3727
+ const start = this.utcDayStart(input.startDate);
3728
+ const endDay = this.utcDayStart(input.endDate);
3729
+ return { start, end: endDay + DAY_MS - 1, days: (endDay - start) / DAY_MS + 1 };
3730
+ }
3731
+ async requestPriceHistory(contract, requestedSymbol, request) {
3732
+ this.onPriceHistoryTelemetry({
3733
+ event: "PRICE_HISTORY_REQUEST",
3734
+ requestedSymbol,
3735
+ resolvedConid: contract.conid,
3736
+ securityType: contract.securityType,
3737
+ exchange: contract.exchange,
3738
+ period: request.period,
3739
+ barSize: "1d",
3740
+ });
3741
+ const history = await this.fetchQuoteHistory(contract.conid, request.period, false, contract.exchange, "1d", request.startTime);
3742
+ if (history === undefined) {
3743
+ throw new Error("IBKR price-history response was unexpectedly unavailable");
3744
+ }
3745
+ return history;
3746
+ }
3747
+ async recoverDailyHistory(contract, symbol, interval, initialCause) {
3748
+ const observed = [];
3749
+ let cause = initialCause;
3750
+ if (interval.days <= 365) {
3751
+ this.onRequestTelemetry({
3752
+ event: "HISTORY_PERIOD_FALLBACK",
3753
+ endpoint: "iserver/marketdata",
3754
+ attempt: 1,
3755
+ delayMs: 0,
3756
+ });
3757
+ let standardBars;
3758
+ try {
3759
+ const history = await this.requestPriceHistory(contract, symbol, {
3760
+ period: "1y",
3761
+ startTime: this.historyStartTime(interval.end),
3762
+ });
3763
+ standardBars = this.normalizeHistoryResponse(symbol, history, interval);
3764
+ this.assertHistoryCoverage(symbol, standardBars, interval);
3765
+ return standardBars;
3766
+ }
3767
+ catch (error) {
3768
+ if (standardBars !== undefined)
3769
+ observed.push(standardBars);
3770
+ if (!this.isChartDataUnavailable(error) &&
3771
+ !(error instanceof IbkrInsufficientHistoryError)) {
3772
+ throw error;
3773
+ }
3774
+ cause = error;
3775
+ }
3776
+ }
3777
+ const windows = this.dailyHistoryWindows(symbol, interval, cause);
3778
+ const completed = [];
3779
+ for (const [index, window] of windows.entries()) {
3780
+ this.onRequestTelemetry({
3781
+ event: "HISTORY_WINDOW_FALLBACK",
3782
+ endpoint: "iserver/marketdata",
3783
+ attempt: index + 1,
3784
+ delayMs: 0,
3785
+ });
3786
+ let bars;
3787
+ try {
3788
+ const history = await this.requestPriceHistory(contract, symbol, {
3789
+ period: `${String(window.days)}d`,
3790
+ startTime: this.historyStartTime(window.end),
3791
+ });
3792
+ bars = this.normalizeHistoryResponse(symbol, history, window);
3793
+ }
3794
+ catch (error) {
3795
+ if (!this.isChartDataUnavailable(error))
3796
+ throw error;
3797
+ this.throwInsufficientHistory(symbol, interval, [...observed, ...completed.map(({ bars }) => bars)], error);
3798
+ }
3799
+ try {
3800
+ this.assertHistoryCoverage(symbol, bars, window);
3801
+ }
3802
+ catch (error) {
3803
+ if (!(error instanceof IbkrInsufficientHistoryError))
3804
+ throw error;
3805
+ this.throwInsufficientHistory(symbol, interval, [...observed, ...completed.map(({ bars: completedBars }) => completedBars), bars], error);
3806
+ }
3807
+ completed.push({ bars, start: window.start, end: window.end });
3808
+ }
3809
+ this.assertHistoryWindowContinuity(symbol, interval, completed);
3810
+ const result = this.mergeHistoryBars(symbol, completed.map(({ bars }) => bars));
3811
+ this.assertHistoryCoverage(symbol, result, interval);
3812
+ return result;
3813
+ }
3814
+ dailyHistoryWindows(symbol, interval, cause) {
3815
+ const maximumPeriodDays = 90;
3816
+ const overlapDays = 7;
3817
+ const windows = [];
3818
+ let end = interval.end;
3819
+ for (;;) {
3820
+ const endDay = this.utcDayStart(end);
3821
+ const start = Math.max(interval.start, endDay - (maximumPeriodDays - 1) * DAY_MS);
3822
+ windows.push({ start, end, days: (endDay - start) / DAY_MS + 1 });
3823
+ if (start === interval.start)
3824
+ return windows;
3825
+ if (windows.length >= 12) {
3826
+ throw new IbkrInsufficientHistoryError(symbol, interval.start, interval.end, null, null, {
3827
+ cause,
3828
+ });
3829
+ }
3830
+ end = start + overlapDays * DAY_MS - 1;
3831
+ }
3832
+ }
3833
+ normalizeHistoryResponse(symbol, history, interval) {
3834
+ const volumeFactor = history.volumeFactor ?? 1;
3835
+ if (!Number.isFinite(volumeFactor)) {
3836
+ throw new Error(`IBKR returned a non-finite history volume factor for ${symbol}`);
3837
+ }
3838
+ const bars = [];
3839
+ for (const bar of history.data ?? []) {
3840
+ if (!isFiniteHistoryBar(bar)) {
3841
+ throw new Error(`IBKR returned an incomplete or non-finite history bar for ${symbol}`);
3842
+ }
3843
+ const normalized = {
3844
+ datetime: bar.t,
3845
+ open: bar.o,
3846
+ high: bar.h,
3847
+ low: bar.l,
3848
+ close: bar.c,
3849
+ volume: bar.v * volumeFactor,
3850
+ };
3851
+ if (!Number.isFinite(normalized.volume)) {
3852
+ throw new Error(`IBKR returned a non-finite normalized history volume for ${symbol}`);
3853
+ }
3854
+ if (bar.t < interval.start || bar.t > interval.end)
3855
+ continue;
3856
+ bars.push(normalized);
3857
+ }
3858
+ return this.mergeHistoryBars(symbol, [bars]);
3859
+ }
3860
+ mergeHistoryBars(symbol, groups) {
3861
+ const merged = new Map();
3862
+ for (const bar of groups.flat()) {
3863
+ const existing = merged.get(bar.datetime);
3864
+ if (existing !== undefined) {
3865
+ if (existing.open !== bar.open ||
3866
+ existing.high !== bar.high ||
3867
+ existing.low !== bar.low ||
3868
+ existing.close !== bar.close ||
3869
+ existing.volume !== bar.volume) {
3870
+ throw new Error(`IBKR returned conflicting history bars for ${symbol} at ${String(bar.datetime)}`);
3871
+ }
3872
+ continue;
3873
+ }
3874
+ merged.set(bar.datetime, bar);
3875
+ }
3876
+ return [...merged.values()].sort((left, right) => left.datetime - right.datetime);
3877
+ }
3878
+ assertHistoryCoverage(symbol, bars, interval) {
3879
+ const availableStart = bars[0]?.datetime ?? null;
3880
+ const availableEnd = bars[bars.length - 1]?.datetime ?? null;
3881
+ const tolerance = Math.min(7 * DAY_MS, interval.end - interval.start);
3882
+ if (availableStart === null ||
3883
+ availableEnd === null ||
3884
+ availableStart > interval.start + tolerance ||
3885
+ availableEnd < interval.end - tolerance) {
3886
+ throw new IbkrInsufficientHistoryError(symbol, interval.start, interval.end, availableStart, availableEnd);
3887
+ }
3888
+ }
3889
+ assertHistoryWindowContinuity(symbol, interval, windows) {
3890
+ const chronological = [...windows].sort((left, right) => left.start - right.start);
3891
+ for (let index = 1; index < chronological.length; index += 1) {
3892
+ const older = chronological[index - 1];
3893
+ const newer = chronological[index];
3894
+ if (older === undefined || newer === undefined)
3895
+ continue;
3896
+ const olderEnd = older.bars[older.bars.length - 1]?.datetime;
3897
+ const newerStart = newer.bars[0]?.datetime;
3898
+ if (olderEnd === undefined || newerStart === undefined || olderEnd < newerStart) {
3899
+ this.throwInsufficientHistory(symbol, interval, chronological.map(({ bars }) => [...bars]), new Error("IBKR returned discontinuous daily history windows"));
3900
+ }
3901
+ }
3902
+ }
3903
+ throwInsufficientHistory(symbol, interval, groups, cause) {
3904
+ const bars = this.mergeHistoryBars(symbol, groups);
3905
+ throw new IbkrInsufficientHistoryError(symbol, interval.start, interval.end, bars[0]?.datetime ?? null, bars[bars.length - 1]?.datetime ?? null, { cause });
3906
+ }
3907
+ utcDayStart(epochMilliseconds) {
3908
+ const date = new Date(epochMilliseconds);
3909
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
3910
+ }
3911
+ historyStartTime(epochMilliseconds) {
3912
+ const iso = new Date(epochMilliseconds).toISOString();
3913
+ return `${iso.slice(0, 10).replaceAll("-", "")}-${iso.slice(11, 19)}`;
3914
+ }
3915
+ isChartDataUnavailable(error) {
3916
+ const brokerDetail = error instanceof IbkrBrokerResponseError ? error.detail : undefined;
3917
+ const status = brokerDetail?.statusCode ?? this.httpStatusFromError(error);
3918
+ if (status !== 500)
3919
+ return false;
3920
+ const transportResponse = typeof error === "object" && error !== null
3921
+ ? error.response
3922
+ : undefined;
3923
+ const responseData = typeof transportResponse === "object" && transportResponse !== null
3924
+ ? transportResponse.data
3925
+ : undefined;
3926
+ // Normalized HTTP failures keep the raw payload under `response.body`.
3927
+ const structuredBody = error instanceof IbkrHttpError ? error.response.body : undefined;
3928
+ const body = typeof error === "object" && error !== null ? error.body : undefined;
3929
+ const payload = brokerDetail?.details ?? responseData ?? structuredBody ?? body;
3930
+ const decoded = (() => {
3931
+ if (typeof payload !== "string")
3932
+ return payload;
3933
+ try {
3934
+ return JSON.parse(payload);
3935
+ }
3936
+ catch {
3937
+ return undefined;
3938
+ }
3939
+ })();
3940
+ if (!isUnknownRecord(decoded))
3941
+ return false;
3942
+ let serialized;
3943
+ try {
3944
+ serialized = JSON.stringify(decoded);
3945
+ }
3946
+ catch {
3947
+ return false;
3948
+ }
3949
+ if (/auth|entitl|permission|subscription|invalid.{0,20}(?:contract|conid)|ambiguous.{0,20}contract|security definition/i.test(serialized)) {
3950
+ return false;
3951
+ }
3952
+ const diagnosticValues = [decoded["error"], decoded["message"], decoded["text"]].filter((value) => typeof value === "string" && value.trim() !== "");
3953
+ return (diagnosticValues.length > 0 &&
3954
+ diagnosticValues.every((value) => value.trim().toLowerCase() === "chart data unavailable"));
3504
3955
  }
3505
- async fetchQuoteHistory(conid, period = "5d", suppressErrors = true) {
3956
+ async fetchQuoteHistory(conid, period = "5d", suppressErrors = true, exchange, bar = "1d", startTime) {
3506
3957
  try {
3507
- return await this.req({
3958
+ const response = await this.historyRequest({
3508
3959
  path: "iserver/marketdata/history",
3509
3960
  params: {
3510
3961
  conid: String(conid),
3962
+ ...(exchange === undefined ? {} : { exchange }),
3511
3963
  period,
3512
- bar: "1d",
3964
+ bar,
3513
3965
  outsideRth: true,
3966
+ ...(startTime === undefined ? {} : { startTime }),
3514
3967
  },
3515
3968
  });
3969
+ if (isUnknownRecord(response) &&
3970
+ response["error"] !== undefined &&
3971
+ response["error"] !== null) {
3972
+ const detail = this.normalizeBrokerError(response["error"], response, "IBKR rejected the market-data history request");
3973
+ throw new IbkrBrokerResponseError(detail.message, detail);
3974
+ }
3975
+ if (!isUnknownRecord(response) || !Array.isArray(response["data"])) {
3976
+ throw new Error("IBKR returned a malformed market-data history response");
3977
+ }
3978
+ return response;
3516
3979
  }
3517
3980
  catch (error) {
3518
3981
  if (!suppressErrors)
@@ -3887,20 +4350,36 @@ export class IbkrClient {
3887
4350
  }
3888
4351
  /** Typed wrapper around the raw client's untyped `request()`. */
3889
4352
  async sendRequest(input) {
3890
- return (await this.raw.request(input));
4353
+ try {
4354
+ return (await this.raw.request(input));
4355
+ }
4356
+ catch (error) {
4357
+ throw this.normalizeHttpError(error);
4358
+ }
3891
4359
  }
3892
4360
  req(input) {
3893
- return this.scheduledRequest(input, true);
4361
+ return this.scheduledRequest(input, "SAFE_READ");
4362
+ }
4363
+ historyRequest(input) {
4364
+ return this.scheduledRequest(input, "PRICE_HISTORY");
3894
4365
  }
3895
4366
  singleAttemptRequest(input) {
3896
- return this.scheduledRequest(input, false);
4367
+ return this.scheduledRequest(input, "SINGLE_ATTEMPT");
3897
4368
  }
3898
- scheduledRequest(input, retryable) {
4369
+ scheduledRequest(input, retryPolicy) {
3899
4370
  return this.requestScheduler.schedule({
3900
4371
  endpoint: this.requestEndpoint(input.path),
3901
4372
  priority: this.requestPriority(input.path),
3902
- retryable,
3903
- }, () => this.sendRequest(input));
4373
+ retryable: retryPolicy !== "SINGLE_ATTEMPT",
4374
+ retryServerErrors: retryPolicy === "PRICE_HISTORY",
4375
+ }, async () => {
4376
+ try {
4377
+ return await this.sendRequest(input);
4378
+ }
4379
+ catch (error) {
4380
+ throw this.normalizeHttpError(error);
4381
+ }
4382
+ });
3904
4383
  }
3905
4384
  requestPriority(path) {
3906
4385
  if (path === "iserver/accounts" ||
@@ -3936,12 +4415,19 @@ export class IbkrClient {
3936
4415
  if (/temporar(?:ily|y).*(?:block|ban)|(?:ip|access).*(?:temporar(?:ily|y) )?blocked/i.test(this.requestErrorText(error))) {
3937
4416
  return { kind: "TEMPORARILY_BLOCKED" };
3938
4417
  }
3939
- if (this.httpStatusFromError(error) === 429) {
4418
+ const status = this.httpStatusFromError(error);
4419
+ if (status === 429) {
3940
4420
  const retryAfterMs = this.retryAfterFromError(error);
3941
4421
  return retryAfterMs === undefined
3942
4422
  ? { kind: "THROTTLED" }
3943
4423
  : { kind: "THROTTLED", retryAfterMs };
3944
4424
  }
4425
+ if (status !== undefined && status >= 500 && status <= 599) {
4426
+ const retryAfterMs = this.retryAfterFromError(error);
4427
+ return retryAfterMs === undefined
4428
+ ? { kind: "SERVER_ERROR" }
4429
+ : { kind: "SERVER_ERROR", retryAfterMs };
4430
+ }
3945
4431
  return { kind: "OTHER" };
3946
4432
  }
3947
4433
  requestErrorText(error) {
@@ -3968,6 +4454,48 @@ export class IbkrClient {
3968
4454
  })
3969
4455
  .join(" ");
3970
4456
  }
4457
+ normalizeHttpError(error) {
4458
+ if (error instanceof IbkrHttpError)
4459
+ return error;
4460
+ const status = this.httpStatusFromError(error);
4461
+ if (status === undefined)
4462
+ return error;
4463
+ const body = this.httpResponseBody(error);
4464
+ const retryAfter = this.retryAfterHeaderFromError(error) ?? null;
4465
+ const message = body !== ""
4466
+ ? `IBKR HTTP ${String(status)}: ${body}`
4467
+ : error instanceof Error
4468
+ ? error.message.slice(0, 4_096)
4469
+ : `IBKR HTTP ${String(status)}`;
4470
+ return new IbkrHttpError(message, status, { status, body, retryAfter }, { cause: error });
4471
+ }
4472
+ httpResponseBody(error) {
4473
+ if (typeof error !== "object" || error === null)
4474
+ return "";
4475
+ const response = error.response;
4476
+ const responseData = typeof response === "object" && response !== null
4477
+ ? (response.data ??
4478
+ response.body)
4479
+ : undefined;
4480
+ const directBody = error.body;
4481
+ const message = error.message;
4482
+ const rawBody = responseData ?? directBody ?? this.bodyFromRawTransportMessage(message);
4483
+ if (typeof rawBody === "string")
4484
+ return rawBody.slice(0, 4_096);
4485
+ if (rawBody === undefined)
4486
+ return "";
4487
+ try {
4488
+ return JSON.stringify(rawBody).slice(0, 4_096);
4489
+ }
4490
+ catch {
4491
+ return "";
4492
+ }
4493
+ }
4494
+ bodyFromRawTransportMessage(message) {
4495
+ if (typeof message !== "string")
4496
+ return undefined;
4497
+ return /^Response status \d{3}: ([\s\S]*)$/.exec(message)?.[1];
4498
+ }
3971
4499
  httpStatusFromError(error) {
3972
4500
  if (typeof error !== "object" || error === null)
3973
4501
  return undefined;
@@ -3979,18 +4507,24 @@ export class IbkrClient {
3979
4507
  if (directStatusCode !== undefined)
3980
4508
  return directStatusCode;
3981
4509
  if (typeof response === "object" && response !== null) {
3982
- return this.numberFromUnknown(response.status ??
4510
+ const responseStatus = this.numberFromUnknown(response.status ??
3983
4511
  response.statusCode);
4512
+ if (responseStatus !== undefined)
4513
+ return responseStatus;
3984
4514
  }
3985
4515
  const message = error.message;
3986
- if (typeof message === "string") {
3987
- const match = /\b429\b/.exec(message);
3988
- if (match)
3989
- return 429;
3990
- }
3991
- return undefined;
4516
+ if (typeof message !== "string")
4517
+ return undefined;
4518
+ const rawStatus = /^Response status (\d{3}):/.exec(message)?.[1];
4519
+ return rawStatus === undefined ? undefined : this.numberFromUnknown(rawStatus);
3992
4520
  }
3993
4521
  retryAfterFromError(error) {
4522
+ if (typeof error !== "object" || error === null)
4523
+ return undefined;
4524
+ const structuredRetryAfter = error instanceof IbkrHttpError ? (error.response.retryAfter ?? undefined) : undefined;
4525
+ return parseRetryAfter(structuredRetryAfter ?? this.retryAfterHeaderFromError(error), this.requestNow());
4526
+ }
4527
+ retryAfterHeaderFromError(error) {
3994
4528
  if (typeof error !== "object" || error === null)
3995
4529
  return undefined;
3996
4530
  const response = error.response;
@@ -3998,9 +4532,8 @@ export class IbkrClient {
3998
4532
  ? response.headers
3999
4533
  : undefined;
4000
4534
  const directHeaders = error.headers;
4001
- const retryAfterRaw = this.headerValue(responseHeaders, "Retry-After") ??
4002
- this.headerValue(directHeaders, "Retry-After");
4003
- return parseRetryAfter(retryAfterRaw);
4535
+ return (this.headerValue(responseHeaders, "Retry-After") ??
4536
+ this.headerValue(directHeaders, "Retry-After"));
4004
4537
  }
4005
4538
  numberFromUnknown(value) {
4006
4539
  if (typeof value === "number")