@huskly/ibkr-client 0.29.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 [];
@@ -3402,8 +3611,11 @@ export class IbkrClient {
3402
3611
  throw new Error(`IBKR lost the selected underlying for ${symbol}`);
3403
3612
  return underlying;
3404
3613
  }
3405
- async loadOptionContracts(symbol, month) {
3406
- 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);
3407
3619
  const strikes = await this.req({
3408
3620
  path: "iserver/secdef/strikes",
3409
3621
  params: { conid: String(underlying.conid), sectype: "OPT", month },
@@ -3497,32 +3709,273 @@ export class IbkrClient {
3497
3709
  }
3498
3710
  return result;
3499
3711
  }
3500
- historyDays(input) {
3712
+ historyInterval(input) {
3501
3713
  if (input.days !== undefined) {
3502
3714
  if (!Number.isFinite(input.days) || input.days <= 0) {
3503
3715
  throw new Error(`History days must be positive: ${String(input.days)}`);
3504
3716
  }
3505
- 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 };
3506
3720
  }
3507
- if (input.startDate === undefined || input.endDate === undefined) {
3508
- 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");
3509
3723
  }
3510
- const duration = input.endDate - input.startDate;
3511
- if (duration < 0)
3724
+ if (input.endDate < input.startDate) {
3512
3725
  throw new Error("Price history endDate must not precede startDate");
3513
- 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
+ }
3514
3832
  }
3515
- async fetchQuoteHistory(conid, period = "5d", suppressErrors = true) {
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"));
3955
+ }
3956
+ async fetchQuoteHistory(conid, period = "5d", suppressErrors = true, exchange, bar = "1d", startTime) {
3516
3957
  try {
3517
- return await this.req({
3958
+ const response = await this.historyRequest({
3518
3959
  path: "iserver/marketdata/history",
3519
3960
  params: {
3520
3961
  conid: String(conid),
3962
+ ...(exchange === undefined ? {} : { exchange }),
3521
3963
  period,
3522
- bar: "1d",
3964
+ bar,
3523
3965
  outsideRth: true,
3966
+ ...(startTime === undefined ? {} : { startTime }),
3524
3967
  },
3525
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;
3526
3979
  }
3527
3980
  catch (error) {
3528
3981
  if (!suppressErrors)
@@ -3897,20 +4350,36 @@ export class IbkrClient {
3897
4350
  }
3898
4351
  /** Typed wrapper around the raw client's untyped `request()`. */
3899
4352
  async sendRequest(input) {
3900
- 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
+ }
3901
4359
  }
3902
4360
  req(input) {
3903
- return this.scheduledRequest(input, true);
4361
+ return this.scheduledRequest(input, "SAFE_READ");
4362
+ }
4363
+ historyRequest(input) {
4364
+ return this.scheduledRequest(input, "PRICE_HISTORY");
3904
4365
  }
3905
4366
  singleAttemptRequest(input) {
3906
- return this.scheduledRequest(input, false);
4367
+ return this.scheduledRequest(input, "SINGLE_ATTEMPT");
3907
4368
  }
3908
- scheduledRequest(input, retryable) {
4369
+ scheduledRequest(input, retryPolicy) {
3909
4370
  return this.requestScheduler.schedule({
3910
4371
  endpoint: this.requestEndpoint(input.path),
3911
4372
  priority: this.requestPriority(input.path),
3912
- retryable,
3913
- }, () => 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
+ });
3914
4383
  }
3915
4384
  requestPriority(path) {
3916
4385
  if (path === "iserver/accounts" ||
@@ -3946,12 +4415,19 @@ export class IbkrClient {
3946
4415
  if (/temporar(?:ily|y).*(?:block|ban)|(?:ip|access).*(?:temporar(?:ily|y) )?blocked/i.test(this.requestErrorText(error))) {
3947
4416
  return { kind: "TEMPORARILY_BLOCKED" };
3948
4417
  }
3949
- if (this.httpStatusFromError(error) === 429) {
4418
+ const status = this.httpStatusFromError(error);
4419
+ if (status === 429) {
3950
4420
  const retryAfterMs = this.retryAfterFromError(error);
3951
4421
  return retryAfterMs === undefined
3952
4422
  ? { kind: "THROTTLED" }
3953
4423
  : { kind: "THROTTLED", retryAfterMs };
3954
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
+ }
3955
4431
  return { kind: "OTHER" };
3956
4432
  }
3957
4433
  requestErrorText(error) {
@@ -3978,6 +4454,48 @@ export class IbkrClient {
3978
4454
  })
3979
4455
  .join(" ");
3980
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
+ }
3981
4499
  httpStatusFromError(error) {
3982
4500
  if (typeof error !== "object" || error === null)
3983
4501
  return undefined;
@@ -3989,18 +4507,24 @@ export class IbkrClient {
3989
4507
  if (directStatusCode !== undefined)
3990
4508
  return directStatusCode;
3991
4509
  if (typeof response === "object" && response !== null) {
3992
- return this.numberFromUnknown(response.status ??
4510
+ const responseStatus = this.numberFromUnknown(response.status ??
3993
4511
  response.statusCode);
4512
+ if (responseStatus !== undefined)
4513
+ return responseStatus;
3994
4514
  }
3995
4515
  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;
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);
4002
4520
  }
4003
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) {
4004
4528
  if (typeof error !== "object" || error === null)
4005
4529
  return undefined;
4006
4530
  const response = error.response;
@@ -4008,9 +4532,8 @@ export class IbkrClient {
4008
4532
  ? response.headers
4009
4533
  : undefined;
4010
4534
  const directHeaders = error.headers;
4011
- const retryAfterRaw = this.headerValue(responseHeaders, "Retry-After") ??
4012
- this.headerValue(directHeaders, "Retry-After");
4013
- return parseRetryAfter(retryAfterRaw);
4535
+ return (this.headerValue(responseHeaders, "Retry-After") ??
4536
+ this.headerValue(directHeaders, "Retry-After"));
4014
4537
  }
4015
4538
  numberFromUnknown(value) {
4016
4539
  if (typeof value === "number")