@huskly/ibkr-client 0.29.0 → 1.1.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,26 @@ 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
+ onOptionDiscoveryTelemetry;
263
+ onRequestTelemetry;
186
264
  constructor(config, options = {}) {
187
265
  this.raw = new RawIbkrClientCtor(config);
266
+ this.onPriceHistoryTelemetry = options.onPriceHistoryTelemetry ?? (() => undefined);
267
+ this.onOptionDiscoveryTelemetry = options.onOptionDiscoveryTelemetry ?? (() => undefined);
268
+ this.onRequestTelemetry = options.onRequestTelemetry ?? (() => undefined);
269
+ const schedulerOptions = options.requestScheduler;
270
+ this.requestNow = schedulerOptions?.now ?? (() => this.now());
188
271
  this.requestScheduler = new IbkrRequestScheduler({
189
- ...options.requestScheduler,
190
- now: () => this.now(),
191
- sleep: (ms) => this.wait(ms),
192
- random: () => this.random(),
272
+ ...schedulerOptions,
273
+ now: this.requestNow,
274
+ sleep: schedulerOptions?.sleep ?? ((ms) => this.wait(ms)),
275
+ random: schedulerOptions?.random ?? (() => this.random()),
193
276
  classifyError: (error) => this.classifyRequestError(error),
194
277
  ...(options.onRequestTelemetry === undefined
195
278
  ? {}
@@ -199,7 +282,12 @@ export class IbkrClient {
199
282
  /** Obtain the live session token (idempotent — safe to await repeatedly). */
200
283
  init() {
201
284
  this.initPromise ??= (async () => {
202
- await this.raw.init();
285
+ try {
286
+ await this.raw.init();
287
+ }
288
+ catch (error) {
289
+ throw this.normalizeHttpError(error);
290
+ }
203
291
  // IBKR is slow right after init; give the session a moment to settle.
204
292
  await this.wait(1000);
205
293
  })();
@@ -250,7 +338,7 @@ export class IbkrClient {
250
338
  path: "iserver/marketdata/snapshot",
251
339
  params: { conids, fields: "6509" },
252
340
  });
253
- const response = await this.req({
341
+ const response = await this.singleAttemptRequest({
254
342
  path: `iserver/account/${request.accountId}/orders/whatif`,
255
343
  method: "POST",
256
344
  data: {
@@ -1336,7 +1424,7 @@ export class IbkrClient {
1336
1424
  openProfitLoss: toNumber(p.unrealizedPnl),
1337
1425
  };
1338
1426
  }
1339
- async getQuotes(requests) {
1427
+ async getQuotes(requests, options = {}) {
1340
1428
  const unique = new Map();
1341
1429
  for (const request of requests) {
1342
1430
  if (!request.symbol.trim())
@@ -1363,9 +1451,9 @@ export class IbkrClient {
1363
1451
  conid,
1364
1452
  };
1365
1453
  }));
1366
- return this.fetchQuotes(contracts.filter((contract) => contract !== undefined));
1454
+ return this.fetchQuotes(contracts.filter((contract) => contract !== undefined), options.includeHistory ?? true);
1367
1455
  }
1368
- async fetchQuotes(contracts) {
1456
+ async fetchQuotes(contracts, includeHistory) {
1369
1457
  if (!contracts.length)
1370
1458
  return {};
1371
1459
  const conids = contracts.map((contract) => contract.conid).join(",");
@@ -1379,7 +1467,9 @@ export class IbkrClient {
1379
1467
  const snapshotByConid = new Map(snapshots
1380
1468
  .filter((snapshot) => snapshot.conid !== undefined)
1381
1469
  .map((snapshot) => [snapshot.conid, snapshot]));
1382
- const histories = await Promise.all(contracts.map((contract) => this.fetchQuoteHistory(contract.conid)));
1470
+ const histories = includeHistory
1471
+ ? await Promise.all(contracts.map((contract) => this.fetchQuoteHistory(contract.conid)))
1472
+ : contracts.map(() => undefined);
1383
1473
  const quotes = {};
1384
1474
  for (const [index, contract] of contracts.entries()) {
1385
1475
  const snapshot = snapshotByConid.get(contract.conid);
@@ -2248,7 +2338,7 @@ export class IbkrClient {
2248
2338
  }
2249
2339
  return "IBKR returned mixed or incomplete order evidence";
2250
2340
  }
2251
- normalizeBrokerError(error, response) {
2341
+ normalizeBrokerError(error, response, defaultMessage = "IBKR rejected the order") {
2252
2342
  const nested = typeof error === "object" && error !== null ? error : undefined;
2253
2343
  const nestedMessage = nested ? nested.message : undefined;
2254
2344
  const responseMessage = response["message"];
@@ -2259,7 +2349,7 @@ export class IbkrClient {
2259
2349
  (typeof responseMessage === "string" && responseMessage.trim()) ||
2260
2350
  (typeof responseText === "string" && responseText.trim()) ||
2261
2351
  (typeof responseWarningMessage === "string" && responseWarningMessage.trim()) ||
2262
- "IBKR rejected the order";
2352
+ defaultMessage;
2263
2353
  const nestedCode = nested ? nested.code : undefined;
2264
2354
  const responseCode = response["code"];
2265
2355
  const codeValue = nestedCode ?? responseCode;
@@ -2271,6 +2361,36 @@ export class IbkrClient {
2271
2361
  details: response,
2272
2362
  };
2273
2363
  }
2364
+ /**
2365
+ * Validate `iserver/secdef/search` at one shared boundary.
2366
+ * Accept the documented success array, convert the documented error object into a typed
2367
+ * broker error, and fail closed on any other shape. Never treat an error object as an empty
2368
+ * successful search.
2369
+ */
2370
+ parseSecdefSearchResponse(response) {
2371
+ if (Array.isArray(response)) {
2372
+ return response;
2373
+ }
2374
+ if (isUnknownRecord(response) &&
2375
+ response["error"] !== undefined &&
2376
+ response["error"] !== null) {
2377
+ // Any non-null `error` field is the documented IBKR error-object shape for this
2378
+ // endpoint. Do not reclassify it as a malformed payload when the message is empty.
2379
+ const detail = this.normalizeBrokerError(response["error"], response, "IBKR rejected the security-definition search");
2380
+ throw new IbkrBrokerResponseError(detail.message, detail);
2381
+ }
2382
+ throw new Error("IBKR returned a malformed secdef/search response");
2383
+ }
2384
+ /** Serialize IBKR operations that mutate and then consume session security-definition state. */
2385
+ withSecdefPriming(operation) {
2386
+ const result = this.secdefPrimingTail.then(operation);
2387
+ this.secdefPrimingTail = result.then(() => undefined, () => undefined);
2388
+ return result;
2389
+ }
2390
+ /** Run one state-mutating search outside a larger priming transaction. */
2391
+ searchSecdef(params) {
2392
+ return this.withSecdefPriming(async () => this.parseSecdefSearchResponse(await this.req({ path: "iserver/secdef/search", params })));
2393
+ }
2274
2394
  isMeaningfulBrokerError(error, response) {
2275
2395
  const nested = typeof error === "object" && error !== null ? error : undefined;
2276
2396
  const nestedRecord = nested;
@@ -2834,7 +2954,7 @@ export class IbkrClient {
2834
2954
  if (brokerageAccounts.accounts && !brokerageAccounts.accounts.includes(accountId)) {
2835
2955
  throw new Error(`IBKR account ${accountId} is not available for trading/order queries.`);
2836
2956
  }
2837
- const switchedAccount = await this.req({
2957
+ const switchedAccount = await this.singleAttemptRequest({
2838
2958
  path: "iserver/account",
2839
2959
  method: "POST",
2840
2960
  data: { acctId: accountId },
@@ -2900,10 +3020,7 @@ export class IbkrClient {
2900
3020
  }
2901
3021
  // `trsrv/stocks` is equity/ETF-only. Fall back to security-definition search so
2902
3022
  // 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
- });
3023
+ const search = await this.searchSecdef({ symbol: symbol.trim().toUpperCase() });
2907
3024
  const candidate = search.find((item) => item.conid !== undefined &&
2908
3025
  item.sections?.some((section) => {
2909
3026
  const secType = section.secType?.trim().toUpperCase();
@@ -2920,32 +3037,136 @@ export class IbkrClient {
2920
3037
  conid: fallbackConid,
2921
3038
  };
2922
3039
  }
2923
- /** Return normalized daily price history without consulting a vendor-owned clock. */
3040
+ /** Return complete daily history with the exact validated IBKR request context. */
2924
3041
  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}`);
3042
+ const requestedSymbol = input.symbol.trim().toUpperCase();
3043
+ if (!requestedSymbol) {
3044
+ throw new IbkrPriceHistoryContractError("Price history requires a symbol", "CONTRACT_INVALID");
3045
+ }
3046
+ const interval = this.historyInterval(input);
3047
+ const period = `${String(interval.days)}d`;
3048
+ const contract = await this.resolvePriceHistoryContract(requestedSymbol, input.contract);
3049
+ const request = {
3050
+ period,
3051
+ ...(input.endDate === undefined ? {} : { startTime: this.historyStartTime(interval.end) }),
3052
+ };
3053
+ let bars;
3054
+ try {
3055
+ const history = await this.requestPriceHistory(contract, requestedSymbol, request);
3056
+ bars = this.normalizeHistoryResponse(requestedSymbol, history, interval);
3057
+ this.assertHistoryCoverage(requestedSymbol, bars, interval);
3058
+ }
3059
+ catch (error) {
3060
+ if (!this.isChartDataUnavailable(error))
3061
+ throw error;
3062
+ bars = await this.recoverDailyHistory(contract, requestedSymbol, interval, error);
3063
+ }
3064
+ return {
3065
+ bars,
3066
+ contract,
3067
+ request: { requestedSymbol, period, barSize: "1d" },
3068
+ };
3069
+ }
3070
+ async resolvePriceHistoryContract(requestedSymbol, selector) {
3071
+ if (selector !== undefined) {
3072
+ if (!Number.isSafeInteger(selector.conid) || selector.conid <= 0) {
3073
+ throw new IbkrPriceHistoryContractError(`Invalid IBKR price-history conid: ${String(selector.conid)}`, "CONTRACT_INVALID");
2939
3074
  }
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
- };
3075
+ const expectedSecurityType = selector.assetClass?.trim().toUpperCase();
3076
+ if (expectedSecurityType !== undefined && !isPriceHistorySecurityType(expectedSecurityType)) {
3077
+ throw new IbkrPriceHistoryContractError(`Invalid IBKR price-history asset class: ${expectedSecurityType}`, "CONTRACT_INVALID");
3078
+ }
3079
+ return this.validatePriceHistoryContract(requestedSymbol, selector.conid, {
3080
+ ...(expectedSecurityType === undefined ? {} : { securityType: expectedSecurityType }),
3081
+ ...(selector.exchange === undefined
3082
+ ? {}
3083
+ : { exchange: selector.exchange.trim().toUpperCase() }),
3084
+ });
3085
+ }
3086
+ const search = await this.searchSecdef({ symbol: requestedSymbol });
3087
+ const candidates = search.flatMap((item) => {
3088
+ const symbol = item.symbol?.trim().toUpperCase();
3089
+ const conid = Number(item.conid);
3090
+ if (symbol !== requestedSymbol ||
3091
+ !Number.isSafeInteger(conid) ||
3092
+ conid <= 0 ||
3093
+ !Array.isArray(item.sections)) {
3094
+ return [];
3095
+ }
3096
+ return item.sections.flatMap((section) => {
3097
+ const securityType = section.secType?.trim().toUpperCase();
3098
+ if (securityType !== "STK" && securityType !== "IND")
3099
+ return [];
3100
+ const exchange = section.exchange?.trim().toUpperCase();
3101
+ return [
3102
+ {
3103
+ conid,
3104
+ symbol,
3105
+ securityType,
3106
+ ...(exchange ? { exchange } : {}),
3107
+ },
3108
+ ];
3109
+ });
3110
+ });
3111
+ const uniqueCandidates = [
3112
+ ...new Map([...candidates]
3113
+ .sort((left, right) => (left.exchange ?? "").localeCompare(right.exchange ?? ""))
3114
+ .map((candidate) => [[candidate.conid, candidate.securityType].join(":"), candidate])).values(),
3115
+ ];
3116
+ if (uniqueCandidates.length === 0) {
3117
+ throw new IbkrPriceHistoryContractError(`IBKR returned no STK or IND contract for ${requestedSymbol}`, "CONTRACT_NOT_FOUND");
3118
+ }
3119
+ if (uniqueCandidates.length !== 1) {
3120
+ const safeCandidates = uniqueCandidates.map((candidate) => ({
3121
+ conid: candidate.conid,
3122
+ symbol: candidate.symbol,
3123
+ securityType: candidate.securityType,
3124
+ exchange: candidate.exchange ?? null,
3125
+ }));
3126
+ throw new IbkrPriceHistoryContractError(`IBKR price-history contract is ambiguous for ${requestedSymbol}; specify contract.conid`, "CONTRACT_AMBIGUOUS", safeCandidates);
3127
+ }
3128
+ const candidate = uniqueCandidates[0];
3129
+ if (candidate === undefined) {
3130
+ throw new Error("IBKR price-history resolution lost its selected contract");
3131
+ }
3132
+ return this.validatePriceHistoryContract(requestedSymbol, candidate.conid, {
3133
+ securityType: candidate.securityType,
3134
+ });
3135
+ }
3136
+ async validatePriceHistoryContract(requestedSymbol, conid, expected) {
3137
+ const response = await this.req({
3138
+ path: `iserver/contract/${String(conid)}/info`,
2948
3139
  });
3140
+ if (isUnknownRecord(response) &&
3141
+ response["error"] !== undefined &&
3142
+ response["error"] !== null) {
3143
+ const detail = this.normalizeBrokerError(response["error"], response, "IBKR rejected the contract metadata request");
3144
+ throw new IbkrBrokerResponseError(detail.message, detail);
3145
+ }
3146
+ if (!isUnknownRecord(response)) {
3147
+ throw new IbkrPriceHistoryContractError(`IBKR returned malformed contract metadata for conid ${String(conid)}`, "CONTRACT_INVALID");
3148
+ }
3149
+ const info = response;
3150
+ const returnedConid = Number(info.con_id);
3151
+ const symbol = info.local_symbol?.trim().toUpperCase();
3152
+ const securityType = info.instrument_type?.trim().toUpperCase();
3153
+ const exchange = info.exchange?.trim().toUpperCase();
3154
+ if (returnedConid !== conid ||
3155
+ symbol === undefined ||
3156
+ !symbol ||
3157
+ securityType === undefined ||
3158
+ !isPriceHistorySecurityType(securityType) ||
3159
+ exchange === undefined ||
3160
+ !exchange) {
3161
+ throw new IbkrPriceHistoryContractError(`IBKR returned incomplete contract metadata for conid ${String(conid)}`, "CONTRACT_INVALID");
3162
+ }
3163
+ const contract = { conid, symbol, securityType, exchange };
3164
+ if (symbol !== requestedSymbol ||
3165
+ (expected.securityType !== undefined && expected.securityType !== securityType) ||
3166
+ (expected.exchange !== undefined && expected.exchange !== exchange)) {
3167
+ throw new IbkrPriceHistoryContractError(`IBKR contract ${String(conid)} does not match the requested price-history identity`, "CONTRACT_MISMATCH", [contract]);
3168
+ }
3169
+ return contract;
2949
3170
  }
2950
3171
  /** Discover listed derivative series over an inclusive calendar range. */
2951
3172
  async getDerivativeExpiries(query) {
@@ -3064,8 +3285,8 @@ export class IbkrClient {
3064
3285
  const contracts = [];
3065
3286
  for (let index = 0; index < months.length; index += OPTION_DISCOVERY_MONTH_CONCURRENCY) {
3066
3287
  const batch = months.slice(index, index + OPTION_DISCOVERY_MONTH_CONCURRENCY);
3067
- const batchContracts = (await Promise.all(batch.map((month) => this.discoverOptions(normalized, month)))).flat();
3068
- contracts.push(...batchContracts);
3288
+ const batchContracts = await Promise.all(batch.map((month) => this.discoverOptions(normalized, month, right)));
3289
+ contracts.push(...batchContracts.flatMap((result) => result.contracts));
3069
3290
  }
3070
3291
  return [
3071
3292
  ...new Set(contracts
@@ -3074,17 +3295,38 @@ export class IbkrClient {
3074
3295
  ].sort();
3075
3296
  }
3076
3297
  /** Build one exact-expiry chain with canonical OSI symbols and required pricing/greeks. */
3077
- async getOptionChain(symbol, expiry) {
3078
- const contracts = (await this.discoverOptions(symbol, monthCode(expiry))).filter((contract) => contract.expiry === expiry);
3298
+ async getOptionChain(symbol, expiry, right) {
3299
+ const month = monthCode(expiry);
3300
+ const normalized = symbol.trim().toUpperCase();
3301
+ const discovery = await this.discoverOptions(normalized, month, right);
3302
+ const contracts = discovery.contracts.filter((contract) => contract.expiry === expiry && (right === undefined || contract.right === right));
3079
3303
  if (!contracts.length) {
3080
3304
  throw new Error(`IBKR returned no option contracts for ${symbol} ${expiry}`);
3081
3305
  }
3082
- const quoted = await this.fetchOptionQuotes(contracts, { allowIncomplete: true });
3306
+ const quoted = await this.fetchOptionQuotes(contracts, {
3307
+ allowIncomplete: true,
3308
+ telemetry: { symbol: normalized, month, right: right ?? null },
3309
+ });
3083
3310
  if (!quoted.length) {
3084
3311
  throw new Error(`IBKR returned no usable option quotes for ${symbol} ${expiry}`);
3085
3312
  }
3086
3313
  return quoted;
3087
3314
  }
3315
+ /** Return every qualified contract for one exact expiry and side without hiding sparse data. */
3316
+ async getOptionChainSnapshot(symbol, expiry, right) {
3317
+ const month = monthCode(expiry);
3318
+ const normalized = symbol.trim().toUpperCase();
3319
+ const discovery = await this.discoverOptions(normalized, month, right);
3320
+ const contracts = discovery.contracts.filter((contract) => contract.expiry === expiry);
3321
+ if (!contracts.length) {
3322
+ throw new Error(`IBKR returned no ${right} option contracts for ${symbol} ${expiry}`);
3323
+ }
3324
+ return this.fetchOptionChainSnapshot(contracts, discovery.malformedDefinitionCount, {
3325
+ symbol: normalized,
3326
+ month,
3327
+ right,
3328
+ });
3329
+ }
3088
3330
  /** Fetch one exact option quote; null means the contract is not listed. */
3089
3331
  async getOptionQuote(input) {
3090
3332
  const contract = await this.resolveOptionContract(input);
@@ -3137,8 +3379,11 @@ export class IbkrClient {
3137
3379
  return pending;
3138
3380
  }
3139
3381
  /** 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);
3382
+ loadExactOptionContract(input, month) {
3383
+ return this.withSecdefPriming(() => this.loadExactOptionContractPrimed(input, month));
3384
+ }
3385
+ async loadExactOptionContractPrimed(input, month) {
3386
+ const underlying = await this.loadOptionUnderlying(input.symbol);
3142
3387
  const definitions = await this.req({
3143
3388
  path: "iserver/secdef/info",
3144
3389
  params: {
@@ -3220,13 +3465,16 @@ export class IbkrClient {
3220
3465
  }
3221
3466
  return pending;
3222
3467
  }
3223
- async loadDerivativeContracts(underlying, assetClass, month, requestedExchange, requestedRight, requestedStrike) {
3468
+ loadDerivativeContracts(underlying, assetClass, month, requestedExchange, requestedRight, requestedStrike) {
3469
+ return this.withSecdefPriming(() => this.loadDerivativeContractsPrimed(underlying, assetClass, month, requestedExchange, requestedRight, requestedStrike));
3470
+ }
3471
+ async loadDerivativeContractsPrimed(underlying, assetClass, month, requestedExchange, requestedRight, requestedStrike) {
3224
3472
  // IBKR keeps this priming state in the authenticated session. Strikes may be
3225
3473
  // empty when search has not run first, even with otherwise identical params.
3226
- const search = await this.req({
3474
+ const search = this.parseSecdefSearchResponse(await this.req({
3227
3475
  path: "iserver/secdef/search",
3228
3476
  params: { symbol: underlying, ...(assetClass === "FOP" ? { secType: "FUT" } : {}) },
3229
- });
3477
+ }));
3230
3478
  const candidates = search.filter((candidate) => candidate.conid !== undefined &&
3231
3479
  candidate.symbol?.trim().toUpperCase() === underlying &&
3232
3480
  candidate.sections?.some((section) => section.secType?.toUpperCase() === assetClass));
@@ -3331,39 +3579,30 @@ export class IbkrClient {
3331
3579
  }
3332
3580
  return result;
3333
3581
  }
3334
- discoverOptions(symbol, month) {
3582
+ discoverOptions(symbol, month, right) {
3335
3583
  const normalized = symbol.trim().toUpperCase();
3336
- const key = `${normalized}:${month}`;
3584
+ const key = `${normalized}:${month}:${right ?? "*"}`;
3337
3585
  let pending = this.optionDiscovery.get(key);
3338
- if (!pending) {
3339
- pending = this.loadOptionContracts(normalized, month);
3340
- this.optionDiscovery.set(key, pending);
3341
- }
3342
- return pending;
3343
- }
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);
3586
+ if (!pending && right !== undefined) {
3587
+ const complete = this.optionDiscovery.get(`${normalized}:${month}:*`);
3588
+ if (complete !== undefined) {
3589
+ pending = complete.then((result) => ({
3590
+ contracts: result.contracts.filter((contract) => contract.right === right),
3591
+ malformedDefinitionCount: result.malformedDefinitionCount,
3592
+ }));
3593
+ }
3354
3594
  }
3595
+ pending ??= this.loadOptionContracts(normalized, month, right);
3596
+ this.optionDiscovery.set(key, pending);
3355
3597
  return pending;
3356
3598
  }
3357
3599
  async loadOptionUnderlying(symbol) {
3358
3600
  // This search is load-bearing: IBKR silently returns empty definitions unless the current
3359
3601
  // session has first searched the underlying.
3360
- const search = await this.req({
3602
+ const search = this.parseSecdefSearchResponse(await this.req({
3361
3603
  path: "iserver/secdef/search",
3362
3604
  params: { symbol },
3363
- });
3364
- if (!Array.isArray(search)) {
3365
- throw new Error(`IBKR returned malformed option underlying results for ${symbol}`);
3366
- }
3605
+ }));
3367
3606
  const candidates = search.flatMap((item) => {
3368
3607
  if (!isUnknownRecord(item))
3369
3608
  return [];
@@ -3402,94 +3641,211 @@ export class IbkrClient {
3402
3641
  throw new Error(`IBKR lost the selected underlying for ${symbol}`);
3403
3642
  return underlying;
3404
3643
  }
3405
- async loadOptionContracts(symbol, month) {
3406
- const underlying = await this.discoverOptionUnderlying(symbol);
3407
- const strikes = await this.req({
3408
- path: "iserver/secdef/strikes",
3409
- params: { conid: String(underlying.conid), sectype: "OPT", month },
3644
+ async loadOptionContracts(symbol, month, right) {
3645
+ const { underlying, requests } = await this.withSecdefPriming(async () => {
3646
+ const searchStarted = this.requestNow();
3647
+ const selectedUnderlying = await this.loadOptionUnderlying(symbol);
3648
+ this.emitOptionDiscoveryTelemetry({
3649
+ phase: "SEARCH",
3650
+ symbol,
3651
+ month,
3652
+ right: right ?? null,
3653
+ durationMs: this.elapsedSince(searchStarted),
3654
+ definitionRequestCount: 0,
3655
+ snapshotBatchCount: 0,
3656
+ });
3657
+ const strikesStarted = this.requestNow();
3658
+ const strikes = await this.req({
3659
+ path: "iserver/secdef/strikes",
3660
+ params: { conid: String(selectedUnderlying.conid), sectype: "OPT", month },
3661
+ });
3662
+ this.emitOptionDiscoveryTelemetry({
3663
+ phase: "STRIKES",
3664
+ symbol,
3665
+ month,
3666
+ right: right ?? null,
3667
+ durationMs: this.elapsedSince(strikesStarted),
3668
+ definitionRequestCount: 0,
3669
+ snapshotBatchCount: 0,
3670
+ });
3671
+ const callStrikes = strikes.call ?? [];
3672
+ const putStrikes = strikes.put ?? [];
3673
+ if (callStrikes.length === 0 && putStrikes.length === 0) {
3674
+ throw new Error(`IBKR returned empty option strikes for ${symbol} ${month} after secdef/search priming`);
3675
+ }
3676
+ const definitionRequests = [
3677
+ ...(right === undefined || right === "C"
3678
+ ? callStrikes.map((strike) => ({ strike, right: "C" }))
3679
+ : []),
3680
+ ...(right === undefined || right === "P"
3681
+ ? putStrikes.map((strike) => ({ strike, right: "P" }))
3682
+ : []),
3683
+ ];
3684
+ return { underlying: selectedUnderlying, requests: definitionRequests };
3410
3685
  });
3411
- const requests = [
3412
- ...(strikes.call ?? []).map((strike) => ({ strike, right: "C" })),
3413
- ...(strikes.put ?? []).map((strike) => ({ strike, right: "P" })),
3414
- ];
3415
- if (!requests.length) {
3416
- throw new Error(`IBKR returned empty option strikes for ${symbol} ${month} after secdef/search priming`);
3417
- }
3686
+ const definitionsStarted = this.requestNow();
3687
+ const responses = await Promise.all(requests.map(({ strike, right: requestRight }) => this.req({
3688
+ path: "iserver/secdef/info",
3689
+ params: {
3690
+ conid: String(underlying.conid),
3691
+ sectype: "OPT",
3692
+ month,
3693
+ strike,
3694
+ right: requestRight,
3695
+ },
3696
+ })));
3697
+ this.emitOptionDiscoveryTelemetry({
3698
+ phase: "DEFINITIONS",
3699
+ symbol,
3700
+ month,
3701
+ right: right ?? null,
3702
+ durationMs: this.elapsedSince(definitionsStarted),
3703
+ definitionRequestCount: requests.length,
3704
+ snapshotBatchCount: 0,
3705
+ });
3706
+ if (requests.length === 0)
3707
+ return { contracts: [], malformedDefinitionCount: 0 };
3418
3708
  const contracts = [];
3419
- for (const batch of chunks(requests, OPTION_SECDEF_INFO_BATCH_SIZE)) {
3420
- const responses = await Promise.all(batch.map(({ strike, right }) => this.req({
3421
- path: "iserver/secdef/info",
3422
- params: {
3423
- conid: String(underlying.conid),
3424
- sectype: "OPT",
3425
- month,
3426
- strike,
3427
- right,
3428
- },
3429
- })));
3430
- for (const raw of responses.flat()) {
3431
- const contract = normalizeOptionContract({
3432
- conid: raw.conid,
3433
- symbol: raw.symbol ?? underlying.symbol,
3434
- maturityDate: raw.maturityDate,
3435
- right: raw.right,
3436
- strike: raw.strike,
3437
- });
3709
+ let malformedDefinitionCount = 0;
3710
+ for (const response of responses) {
3711
+ if (!Array.isArray(response)) {
3712
+ throw new Error(`IBKR returned malformed option definitions for ${symbol} ${month}`);
3713
+ }
3714
+ for (const raw of response) {
3715
+ if (!isUnknownRecord(raw)) {
3716
+ malformedDefinitionCount += 1;
3717
+ continue;
3718
+ }
3719
+ let contract;
3720
+ try {
3721
+ contract = normalizeOptionContract({
3722
+ conid: typeof raw["conid"] === "number" ? raw["conid"] : undefined,
3723
+ symbol: typeof raw["symbol"] === "string" ? raw["symbol"] : underlying.symbol,
3724
+ maturityDate: typeof raw["maturityDate"] === "string" ? raw["maturityDate"] : undefined,
3725
+ right: typeof raw["right"] === "string" ? raw["right"] : undefined,
3726
+ strike: typeof raw["strike"] === "string" || typeof raw["strike"] === "number"
3727
+ ? raw["strike"]
3728
+ : undefined,
3729
+ });
3730
+ }
3731
+ catch {
3732
+ malformedDefinitionCount += 1;
3733
+ continue;
3734
+ }
3438
3735
  if (contract)
3439
3736
  contracts.push(contract);
3737
+ else
3738
+ malformedDefinitionCount += 1;
3440
3739
  }
3441
3740
  }
3442
3741
  const unique = [...new Map(contracts.map((contract) => [contract.conid, contract])).values()];
3443
3742
  if (!unique.length) {
3444
- throw new Error(`IBKR returned no usable option definitions for ${symbol} ${month}`);
3743
+ throw new Error(`IBKR returned no usable option definitions for ${symbol} ${month} (${String(malformedDefinitionCount)} malformed)`);
3744
+ }
3745
+ return { contracts: unique, malformedDefinitionCount };
3746
+ }
3747
+ async fetchOptionChainSnapshot(contracts, malformedDefinitionCount, telemetry) {
3748
+ const fields = [
3749
+ "bid",
3750
+ "ask",
3751
+ "mid",
3752
+ "delta",
3753
+ "volume",
3754
+ "openInterest",
3755
+ "availability",
3756
+ "timestamp",
3757
+ ];
3758
+ const missingFieldCounts = Object.fromEntries(fields.map((field) => [field, 0]));
3759
+ const quotes = await this.fetchNullableOptionQuotes(contracts, telemetry);
3760
+ for (const quote of quotes) {
3761
+ for (const field of fields) {
3762
+ if (quote[field] === null)
3763
+ missingFieldCounts[field] += 1;
3764
+ }
3445
3765
  }
3446
- return unique;
3766
+ const diagnostics = {
3767
+ qualifiedCount: contracts.length,
3768
+ returnedCount: quotes.length,
3769
+ malformedDefinitionCount,
3770
+ missingFieldCounts,
3771
+ };
3772
+ return { quotes, diagnostics };
3447
3773
  }
3448
- async fetchOptionQuotes(contracts, options = {}) {
3449
- const { allowIncomplete = false } = options;
3450
- const result = [];
3451
- const skipped = [];
3452
- for (const batch of chunks(contracts, OPTION_MARKETDATA_BATCH_SIZE)) {
3774
+ async fetchNullableOptionQuotes(contracts, telemetry) {
3775
+ const quotes = [];
3776
+ const batches = chunks(contracts, OPTION_MARKETDATA_BATCH_SIZE);
3777
+ const snapshotsStarted = this.requestNow();
3778
+ for (const batch of batches) {
3453
3779
  const params = {
3454
3780
  conids: batch.map((contract) => contract.conid).join(","),
3455
3781
  fields: OPTION_QUOTE_FIELDS,
3456
3782
  };
3457
3783
  await this.req({ path: "iserver/marketdata/snapshot", params });
3458
3784
  await this.wait(2000);
3459
- const snapshots = await this.req({
3785
+ const response = await this.req({
3460
3786
  path: "iserver/marketdata/snapshot",
3461
3787
  params,
3462
3788
  });
3463
- const byConid = new Map(snapshots
3464
- .filter((snapshot) => snapshot.conid !== undefined)
3465
- .map((snapshot) => [snapshot.conid, snapshot]));
3789
+ if (!Array.isArray(response)) {
3790
+ throw new Error("IBKR returned malformed option market-data snapshots");
3791
+ }
3792
+ const snapshots = response.filter((snapshot) => isUnknownRecord(snapshot) &&
3793
+ typeof snapshot["conid"] === "number" &&
3794
+ Number.isSafeInteger(snapshot["conid"]) &&
3795
+ snapshot["conid"] > 0);
3796
+ const byConid = new Map(snapshots.map((snapshot) => [snapshot.conid, snapshot]));
3466
3797
  for (const contract of batch) {
3467
3798
  const snapshot = byConid.get(contract.conid);
3468
- const bid = snapshot ? this.snapshotNumber(snapshot, "84") : undefined;
3469
- const ask = snapshot ? this.snapshotNumber(snapshot, "86") : undefined;
3470
- const delta = snapshot ? this.snapshotNumber(snapshot, "7308") : undefined;
3471
- if (bid === undefined || ask === undefined || delta === undefined) {
3472
- if (allowIncomplete) {
3473
- skipped.push(contract.symbol);
3474
- continue;
3475
- }
3476
- throw new Error(`IBKR returned incomplete option market data for ${contract.symbol} (bid/ask/delta required)`);
3477
- }
3478
- const volume = snapshot ? (this.snapshotVolume(snapshot) ?? null) : null;
3479
- const openInterest = snapshot ? (this.snapshotNumber(snapshot, "7638") ?? null) : null;
3480
- result.push({
3799
+ const bid = snapshot ? (this.snapshotNumber(snapshot, "84") ?? null) : null;
3800
+ const ask = snapshot ? (this.snapshotNumber(snapshot, "86") ?? null) : null;
3801
+ const rawAvailability = snapshot?.["6509"];
3802
+ quotes.push({
3481
3803
  ...contract,
3482
3804
  bid,
3483
3805
  ask,
3484
- mid: (bid + ask) / 2,
3485
- delta,
3486
- volume,
3487
- openInterest,
3488
- availability: normalizeDerivativeDataAvailability(snapshot?.["6509"]),
3806
+ mid: bid !== null && ask !== null ? (bid + ask) / 2 : null,
3807
+ delta: snapshot ? (this.snapshotNumber(snapshot, "7308") ?? null) : null,
3808
+ volume: snapshot ? (this.snapshotVolume(snapshot) ?? null) : null,
3809
+ openInterest: snapshot ? (this.snapshotNumber(snapshot, "7638") ?? null) : null,
3810
+ availability: typeof rawAvailability === "string" || typeof rawAvailability === "number"
3811
+ ? normalizeDerivativeDataAvailability(rawAvailability)
3812
+ : null,
3489
3813
  timestamp: snapshot ? this.snapshotTimestamp(snapshot) : null,
3490
3814
  });
3491
3815
  }
3492
3816
  }
3817
+ if (telemetry !== undefined) {
3818
+ this.emitOptionDiscoveryTelemetry({
3819
+ phase: "SNAPSHOTS",
3820
+ ...telemetry,
3821
+ durationMs: this.elapsedSince(snapshotsStarted),
3822
+ definitionRequestCount: 0,
3823
+ snapshotBatchCount: batches.length,
3824
+ });
3825
+ }
3826
+ return quotes;
3827
+ }
3828
+ async fetchOptionQuotes(contracts, options = {}) {
3829
+ const { allowIncomplete = false, telemetry } = options;
3830
+ const result = [];
3831
+ const skipped = [];
3832
+ for (const quote of await this.fetchNullableOptionQuotes(contracts, telemetry)) {
3833
+ if (quote.bid === null || quote.ask === null || quote.delta === null) {
3834
+ if (allowIncomplete) {
3835
+ skipped.push(quote.symbol);
3836
+ continue;
3837
+ }
3838
+ throw new Error(`IBKR returned incomplete option market data for ${quote.symbol} (bid/ask/delta required)`);
3839
+ }
3840
+ result.push({
3841
+ ...quote,
3842
+ bid: quote.bid,
3843
+ ask: quote.ask,
3844
+ mid: (quote.bid + quote.ask) / 2,
3845
+ delta: quote.delta,
3846
+ availability: quote.availability ?? "unavailable",
3847
+ });
3848
+ }
3493
3849
  if (allowIncomplete && skipped.length && skipped.length === contracts.length) {
3494
3850
  const symbol = contracts[0]?.underlying ?? "unknown";
3495
3851
  const expiry = contracts[0]?.expiry ?? "unknown";
@@ -3497,32 +3853,288 @@ export class IbkrClient {
3497
3853
  }
3498
3854
  return result;
3499
3855
  }
3500
- historyDays(input) {
3856
+ historyInterval(input) {
3501
3857
  if (input.days !== undefined) {
3502
3858
  if (!Number.isFinite(input.days) || input.days <= 0) {
3503
3859
  throw new Error(`History days must be positive: ${String(input.days)}`);
3504
3860
  }
3505
- return Math.ceil(input.days);
3861
+ const days = Math.ceil(input.days);
3862
+ const endDay = this.utcDayStart(this.now());
3863
+ return { start: endDay - (days - 1) * DAY_MS, end: endDay + DAY_MS - 1, days };
3506
3864
  }
3507
- if (input.startDate === undefined || input.endDate === undefined) {
3508
- throw new Error("Price history requires days or both startDate and endDate");
3865
+ if (!Number.isFinite(input.startDate) || !Number.isFinite(input.endDate)) {
3866
+ throw new Error("Price history boundaries must be finite epoch milliseconds");
3509
3867
  }
3510
- const duration = input.endDate - input.startDate;
3511
- if (duration < 0)
3868
+ if (input.endDate < input.startDate) {
3512
3869
  throw new Error("Price history endDate must not precede startDate");
3513
- return Math.max(1, Math.ceil(duration / 86_400_000) + 1);
3870
+ }
3871
+ const start = this.utcDayStart(input.startDate);
3872
+ const endDay = this.utcDayStart(input.endDate);
3873
+ return { start, end: endDay + DAY_MS - 1, days: (endDay - start) / DAY_MS + 1 };
3874
+ }
3875
+ elapsedSince(startedAt) {
3876
+ return Math.max(0, this.requestNow() - startedAt);
3877
+ }
3878
+ emitOptionDiscoveryTelemetry(event) {
3879
+ try {
3880
+ const result = this.onOptionDiscoveryTelemetry({
3881
+ event: "OPTION_DISCOVERY_PHASE",
3882
+ ...event,
3883
+ });
3884
+ void Promise.resolve(result).catch(() => undefined);
3885
+ }
3886
+ catch {
3887
+ // Telemetry observers cannot change discovery or quote settlement.
3888
+ }
3889
+ }
3890
+ async requestPriceHistory(contract, requestedSymbol, request) {
3891
+ this.onPriceHistoryTelemetry({
3892
+ event: "PRICE_HISTORY_REQUEST",
3893
+ requestedSymbol,
3894
+ resolvedConid: contract.conid,
3895
+ securityType: contract.securityType,
3896
+ exchange: contract.exchange,
3897
+ period: request.period,
3898
+ barSize: "1d",
3899
+ });
3900
+ const history = await this.fetchQuoteHistory(contract.conid, request.period, false, contract.exchange, "1d", request.startTime);
3901
+ if (history === undefined) {
3902
+ throw new Error("IBKR price-history response was unexpectedly unavailable");
3903
+ }
3904
+ return history;
3905
+ }
3906
+ async recoverDailyHistory(contract, symbol, interval, initialCause) {
3907
+ const observed = [];
3908
+ let cause = initialCause;
3909
+ if (interval.days <= 365) {
3910
+ this.onRequestTelemetry({
3911
+ event: "HISTORY_PERIOD_FALLBACK",
3912
+ endpoint: "iserver/marketdata",
3913
+ attempt: 1,
3914
+ delayMs: 0,
3915
+ });
3916
+ let standardBars;
3917
+ try {
3918
+ const history = await this.requestPriceHistory(contract, symbol, {
3919
+ period: "1y",
3920
+ startTime: this.historyStartTime(interval.end),
3921
+ });
3922
+ standardBars = this.normalizeHistoryResponse(symbol, history, interval);
3923
+ this.assertHistoryCoverage(symbol, standardBars, interval);
3924
+ return standardBars;
3925
+ }
3926
+ catch (error) {
3927
+ if (standardBars !== undefined)
3928
+ observed.push(standardBars);
3929
+ if (!this.isChartDataUnavailable(error) &&
3930
+ !(error instanceof IbkrInsufficientHistoryError)) {
3931
+ throw error;
3932
+ }
3933
+ cause = error;
3934
+ }
3935
+ }
3936
+ const windows = this.dailyHistoryWindows(symbol, interval, cause);
3937
+ const completed = [];
3938
+ for (const [index, window] of windows.entries()) {
3939
+ this.onRequestTelemetry({
3940
+ event: "HISTORY_WINDOW_FALLBACK",
3941
+ endpoint: "iserver/marketdata",
3942
+ attempt: index + 1,
3943
+ delayMs: 0,
3944
+ });
3945
+ let bars;
3946
+ try {
3947
+ const history = await this.requestPriceHistory(contract, symbol, {
3948
+ period: `${String(window.days)}d`,
3949
+ startTime: this.historyStartTime(window.end),
3950
+ });
3951
+ bars = this.normalizeHistoryResponse(symbol, history, window);
3952
+ }
3953
+ catch (error) {
3954
+ if (!this.isChartDataUnavailable(error))
3955
+ throw error;
3956
+ this.throwInsufficientHistory(symbol, interval, [...observed, ...completed.map(({ bars }) => bars)], error);
3957
+ }
3958
+ try {
3959
+ this.assertHistoryCoverage(symbol, bars, window);
3960
+ }
3961
+ catch (error) {
3962
+ if (!(error instanceof IbkrInsufficientHistoryError))
3963
+ throw error;
3964
+ this.throwInsufficientHistory(symbol, interval, [...observed, ...completed.map(({ bars: completedBars }) => completedBars), bars], error);
3965
+ }
3966
+ completed.push({ bars, start: window.start, end: window.end });
3967
+ }
3968
+ this.assertHistoryWindowContinuity(symbol, interval, completed);
3969
+ const result = this.mergeHistoryBars(symbol, completed.map(({ bars }) => bars));
3970
+ this.assertHistoryCoverage(symbol, result, interval);
3971
+ return result;
3972
+ }
3973
+ dailyHistoryWindows(symbol, interval, cause) {
3974
+ const maximumPeriodDays = 90;
3975
+ const overlapDays = 7;
3976
+ const windows = [];
3977
+ let end = interval.end;
3978
+ for (;;) {
3979
+ const endDay = this.utcDayStart(end);
3980
+ const start = Math.max(interval.start, endDay - (maximumPeriodDays - 1) * DAY_MS);
3981
+ windows.push({ start, end, days: (endDay - start) / DAY_MS + 1 });
3982
+ if (start === interval.start)
3983
+ return windows;
3984
+ if (windows.length >= 12) {
3985
+ throw new IbkrInsufficientHistoryError(symbol, interval.start, interval.end, null, null, {
3986
+ cause,
3987
+ });
3988
+ }
3989
+ end = start + overlapDays * DAY_MS - 1;
3990
+ }
3991
+ }
3992
+ normalizeHistoryResponse(symbol, history, interval) {
3993
+ const volumeFactor = history.volumeFactor ?? 1;
3994
+ if (!Number.isFinite(volumeFactor)) {
3995
+ throw new Error(`IBKR returned a non-finite history volume factor for ${symbol}`);
3996
+ }
3997
+ const bars = [];
3998
+ for (const bar of history.data ?? []) {
3999
+ if (!isFiniteHistoryBar(bar)) {
4000
+ throw new Error(`IBKR returned an incomplete or non-finite history bar for ${symbol}`);
4001
+ }
4002
+ const normalized = {
4003
+ datetime: bar.t,
4004
+ open: bar.o,
4005
+ high: bar.h,
4006
+ low: bar.l,
4007
+ close: bar.c,
4008
+ volume: bar.v * volumeFactor,
4009
+ };
4010
+ if (!Number.isFinite(normalized.volume)) {
4011
+ throw new Error(`IBKR returned a non-finite normalized history volume for ${symbol}`);
4012
+ }
4013
+ if (bar.t < interval.start || bar.t > interval.end)
4014
+ continue;
4015
+ bars.push(normalized);
4016
+ }
4017
+ return this.mergeHistoryBars(symbol, [bars]);
4018
+ }
4019
+ mergeHistoryBars(symbol, groups) {
4020
+ const merged = new Map();
4021
+ for (const bar of groups.flat()) {
4022
+ const existing = merged.get(bar.datetime);
4023
+ if (existing !== undefined) {
4024
+ if (existing.open !== bar.open ||
4025
+ existing.high !== bar.high ||
4026
+ existing.low !== bar.low ||
4027
+ existing.close !== bar.close ||
4028
+ existing.volume !== bar.volume) {
4029
+ throw new Error(`IBKR returned conflicting history bars for ${symbol} at ${String(bar.datetime)}`);
4030
+ }
4031
+ continue;
4032
+ }
4033
+ merged.set(bar.datetime, bar);
4034
+ }
4035
+ return [...merged.values()].sort((left, right) => left.datetime - right.datetime);
4036
+ }
4037
+ assertHistoryCoverage(symbol, bars, interval) {
4038
+ const availableStart = bars[0]?.datetime ?? null;
4039
+ const availableEnd = bars[bars.length - 1]?.datetime ?? null;
4040
+ const tolerance = Math.min(7 * DAY_MS, interval.end - interval.start);
4041
+ if (availableStart === null ||
4042
+ availableEnd === null ||
4043
+ availableStart > interval.start + tolerance ||
4044
+ availableEnd < interval.end - tolerance) {
4045
+ throw new IbkrInsufficientHistoryError(symbol, interval.start, interval.end, availableStart, availableEnd);
4046
+ }
4047
+ }
4048
+ assertHistoryWindowContinuity(symbol, interval, windows) {
4049
+ const chronological = [...windows].sort((left, right) => left.start - right.start);
4050
+ for (let index = 1; index < chronological.length; index += 1) {
4051
+ const older = chronological[index - 1];
4052
+ const newer = chronological[index];
4053
+ if (older === undefined || newer === undefined)
4054
+ continue;
4055
+ const olderEnd = older.bars[older.bars.length - 1]?.datetime;
4056
+ const newerStart = newer.bars[0]?.datetime;
4057
+ if (olderEnd === undefined || newerStart === undefined || olderEnd < newerStart) {
4058
+ this.throwInsufficientHistory(symbol, interval, chronological.map(({ bars }) => [...bars]), new Error("IBKR returned discontinuous daily history windows"));
4059
+ }
4060
+ }
4061
+ }
4062
+ throwInsufficientHistory(symbol, interval, groups, cause) {
4063
+ const bars = this.mergeHistoryBars(symbol, groups);
4064
+ throw new IbkrInsufficientHistoryError(symbol, interval.start, interval.end, bars[0]?.datetime ?? null, bars[bars.length - 1]?.datetime ?? null, { cause });
4065
+ }
4066
+ utcDayStart(epochMilliseconds) {
4067
+ const date = new Date(epochMilliseconds);
4068
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
4069
+ }
4070
+ historyStartTime(epochMilliseconds) {
4071
+ const iso = new Date(epochMilliseconds).toISOString();
4072
+ return `${iso.slice(0, 10).replaceAll("-", "")}-${iso.slice(11, 19)}`;
3514
4073
  }
3515
- async fetchQuoteHistory(conid, period = "5d", suppressErrors = true) {
4074
+ isChartDataUnavailable(error) {
4075
+ const brokerDetail = error instanceof IbkrBrokerResponseError ? error.detail : undefined;
4076
+ const status = brokerDetail?.statusCode ?? this.httpStatusFromError(error);
4077
+ if (status !== 500)
4078
+ return false;
4079
+ const transportResponse = typeof error === "object" && error !== null
4080
+ ? error.response
4081
+ : undefined;
4082
+ const responseData = typeof transportResponse === "object" && transportResponse !== null
4083
+ ? transportResponse.data
4084
+ : undefined;
4085
+ // Normalized HTTP failures keep the raw payload under `response.body`.
4086
+ const structuredBody = error instanceof IbkrHttpError ? error.response.body : undefined;
4087
+ const body = typeof error === "object" && error !== null ? error.body : undefined;
4088
+ const payload = brokerDetail?.details ?? responseData ?? structuredBody ?? body;
4089
+ const decoded = (() => {
4090
+ if (typeof payload !== "string")
4091
+ return payload;
4092
+ try {
4093
+ return JSON.parse(payload);
4094
+ }
4095
+ catch {
4096
+ return undefined;
4097
+ }
4098
+ })();
4099
+ if (!isUnknownRecord(decoded))
4100
+ return false;
4101
+ let serialized;
3516
4102
  try {
3517
- return await this.req({
4103
+ serialized = JSON.stringify(decoded);
4104
+ }
4105
+ catch {
4106
+ return false;
4107
+ }
4108
+ if (/auth|entitl|permission|subscription|invalid.{0,20}(?:contract|conid)|ambiguous.{0,20}contract|security definition/i.test(serialized)) {
4109
+ return false;
4110
+ }
4111
+ const diagnosticValues = [decoded["error"], decoded["message"], decoded["text"]].filter((value) => typeof value === "string" && value.trim() !== "");
4112
+ return (diagnosticValues.length > 0 &&
4113
+ diagnosticValues.every((value) => value.trim().toLowerCase() === "chart data unavailable"));
4114
+ }
4115
+ async fetchQuoteHistory(conid, period = "5d", suppressErrors = true, exchange, bar = "1d", startTime) {
4116
+ try {
4117
+ const response = await this.historyRequest({
3518
4118
  path: "iserver/marketdata/history",
3519
4119
  params: {
3520
4120
  conid: String(conid),
4121
+ ...(exchange === undefined ? {} : { exchange }),
3521
4122
  period,
3522
- bar: "1d",
4123
+ bar,
3523
4124
  outsideRth: true,
4125
+ ...(startTime === undefined ? {} : { startTime }),
3524
4126
  },
3525
4127
  });
4128
+ if (isUnknownRecord(response) &&
4129
+ response["error"] !== undefined &&
4130
+ response["error"] !== null) {
4131
+ const detail = this.normalizeBrokerError(response["error"], response, "IBKR rejected the market-data history request");
4132
+ throw new IbkrBrokerResponseError(detail.message, detail);
4133
+ }
4134
+ if (!isUnknownRecord(response) || !Array.isArray(response["data"])) {
4135
+ throw new Error("IBKR returned a malformed market-data history response");
4136
+ }
4137
+ return response;
3526
4138
  }
3527
4139
  catch (error) {
3528
4140
  if (!suppressErrors)
@@ -3897,20 +4509,37 @@ export class IbkrClient {
3897
4509
  }
3898
4510
  /** Typed wrapper around the raw client's untyped `request()`. */
3899
4511
  async sendRequest(input) {
3900
- return (await this.raw.request(input));
4512
+ try {
4513
+ return (await this.raw.request(input));
4514
+ }
4515
+ catch (error) {
4516
+ throw this.normalizeHttpError(error);
4517
+ }
3901
4518
  }
3902
4519
  req(input) {
3903
- return this.scheduledRequest(input, true);
4520
+ return this.scheduledRequest(input, "SAFE_READ");
4521
+ }
4522
+ historyRequest(input) {
4523
+ return this.scheduledRequest(input, "PRICE_HISTORY");
3904
4524
  }
3905
4525
  singleAttemptRequest(input) {
3906
- return this.scheduledRequest(input, false);
4526
+ return this.scheduledRequest(input, "SINGLE_ATTEMPT");
3907
4527
  }
3908
- scheduledRequest(input, retryable) {
4528
+ scheduledRequest(input, retryPolicy) {
3909
4529
  return this.requestScheduler.schedule({
3910
4530
  endpoint: this.requestEndpoint(input.path),
3911
4531
  priority: this.requestPriority(input.path),
3912
- retryable,
3913
- }, () => this.sendRequest(input));
4532
+ secdefInfo: input.path === "iserver/secdef/info",
4533
+ retryable: retryPolicy !== "SINGLE_ATTEMPT",
4534
+ retryServerErrors: retryPolicy === "PRICE_HISTORY",
4535
+ }, async () => {
4536
+ try {
4537
+ return await this.sendRequest(input);
4538
+ }
4539
+ catch (error) {
4540
+ throw this.normalizeHttpError(error);
4541
+ }
4542
+ });
3914
4543
  }
3915
4544
  requestPriority(path) {
3916
4545
  if (path === "iserver/accounts" ||
@@ -3946,12 +4575,19 @@ export class IbkrClient {
3946
4575
  if (/temporar(?:ily|y).*(?:block|ban)|(?:ip|access).*(?:temporar(?:ily|y) )?blocked/i.test(this.requestErrorText(error))) {
3947
4576
  return { kind: "TEMPORARILY_BLOCKED" };
3948
4577
  }
3949
- if (this.httpStatusFromError(error) === 429) {
4578
+ const status = this.httpStatusFromError(error);
4579
+ if (status === 429) {
3950
4580
  const retryAfterMs = this.retryAfterFromError(error);
3951
4581
  return retryAfterMs === undefined
3952
4582
  ? { kind: "THROTTLED" }
3953
4583
  : { kind: "THROTTLED", retryAfterMs };
3954
4584
  }
4585
+ if (status !== undefined && status >= 500 && status <= 599) {
4586
+ const retryAfterMs = this.retryAfterFromError(error);
4587
+ return retryAfterMs === undefined
4588
+ ? { kind: "SERVER_ERROR" }
4589
+ : { kind: "SERVER_ERROR", retryAfterMs };
4590
+ }
3955
4591
  return { kind: "OTHER" };
3956
4592
  }
3957
4593
  requestErrorText(error) {
@@ -3978,6 +4614,48 @@ export class IbkrClient {
3978
4614
  })
3979
4615
  .join(" ");
3980
4616
  }
4617
+ normalizeHttpError(error) {
4618
+ if (error instanceof IbkrHttpError)
4619
+ return error;
4620
+ const status = this.httpStatusFromError(error);
4621
+ if (status === undefined)
4622
+ return error;
4623
+ const body = this.httpResponseBody(error);
4624
+ const retryAfter = this.retryAfterHeaderFromError(error) ?? null;
4625
+ const message = body !== ""
4626
+ ? `IBKR HTTP ${String(status)}: ${body}`
4627
+ : error instanceof Error
4628
+ ? error.message.slice(0, 4_096)
4629
+ : `IBKR HTTP ${String(status)}`;
4630
+ return new IbkrHttpError(message, status, { status, body, retryAfter }, { cause: error });
4631
+ }
4632
+ httpResponseBody(error) {
4633
+ if (typeof error !== "object" || error === null)
4634
+ return "";
4635
+ const response = error.response;
4636
+ const responseData = typeof response === "object" && response !== null
4637
+ ? (response.data ??
4638
+ response.body)
4639
+ : undefined;
4640
+ const directBody = error.body;
4641
+ const message = error.message;
4642
+ const rawBody = responseData ?? directBody ?? this.bodyFromRawTransportMessage(message);
4643
+ if (typeof rawBody === "string")
4644
+ return rawBody.slice(0, 4_096);
4645
+ if (rawBody === undefined)
4646
+ return "";
4647
+ try {
4648
+ return JSON.stringify(rawBody).slice(0, 4_096);
4649
+ }
4650
+ catch {
4651
+ return "";
4652
+ }
4653
+ }
4654
+ bodyFromRawTransportMessage(message) {
4655
+ if (typeof message !== "string")
4656
+ return undefined;
4657
+ return /^Response status \d{3}: ([\s\S]*)$/.exec(message)?.[1];
4658
+ }
3981
4659
  httpStatusFromError(error) {
3982
4660
  if (typeof error !== "object" || error === null)
3983
4661
  return undefined;
@@ -3989,18 +4667,24 @@ export class IbkrClient {
3989
4667
  if (directStatusCode !== undefined)
3990
4668
  return directStatusCode;
3991
4669
  if (typeof response === "object" && response !== null) {
3992
- return this.numberFromUnknown(response.status ??
4670
+ const responseStatus = this.numberFromUnknown(response.status ??
3993
4671
  response.statusCode);
4672
+ if (responseStatus !== undefined)
4673
+ return responseStatus;
3994
4674
  }
3995
4675
  const message = error.message;
3996
- if (typeof message === "string") {
3997
- const match = /\b429\b/.exec(message);
3998
- if (match)
3999
- return 429;
4000
- }
4001
- return undefined;
4676
+ if (typeof message !== "string")
4677
+ return undefined;
4678
+ const rawStatus = /^Response status (\d{3}):/.exec(message)?.[1];
4679
+ return rawStatus === undefined ? undefined : this.numberFromUnknown(rawStatus);
4002
4680
  }
4003
4681
  retryAfterFromError(error) {
4682
+ if (typeof error !== "object" || error === null)
4683
+ return undefined;
4684
+ const structuredRetryAfter = error instanceof IbkrHttpError ? (error.response.retryAfter ?? undefined) : undefined;
4685
+ return parseRetryAfter(structuredRetryAfter ?? this.retryAfterHeaderFromError(error), this.requestNow());
4686
+ }
4687
+ retryAfterHeaderFromError(error) {
4004
4688
  if (typeof error !== "object" || error === null)
4005
4689
  return undefined;
4006
4690
  const response = error.response;
@@ -4008,9 +4692,8 @@ export class IbkrClient {
4008
4692
  ? response.headers
4009
4693
  : undefined;
4010
4694
  const directHeaders = error.headers;
4011
- const retryAfterRaw = this.headerValue(responseHeaders, "Retry-After") ??
4012
- this.headerValue(directHeaders, "Retry-After");
4013
- return parseRetryAfter(retryAfterRaw);
4695
+ return (this.headerValue(responseHeaders, "Retry-After") ??
4696
+ this.headerValue(directHeaders, "Retry-After"));
4014
4697
  }
4015
4698
  numberFromUnknown(value) {
4016
4699
  if (typeof value === "number")