@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.
- package/README.md +102 -17
- package/dist/ibkr/ibkrApiTypes.d.ts +7 -0
- package/dist/ibkr/ibkrApiTypes.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.d.ts +81 -9
- package/dist/ibkr/ibkrClient.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.js +620 -97
- package/dist/ibkr/ibkrClient.js.map +1 -1
- package/dist/ibkr/requestScheduler.d.ts +6 -1
- package/dist/ibkr/requestScheduler.d.ts.map +1 -1
- package/dist/ibkr/requestScheduler.js +60 -4
- package/dist/ibkr/requestScheduler.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +61 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/ibkr/ibkrClient.js
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
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 -
|
|
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
|
-
...
|
|
190
|
-
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
3038
|
+
/** Return complete daily history with the exact validated IBKR request context. */
|
|
2924
3039
|
async getPriceHistory(input) {
|
|
2925
|
-
const
|
|
2926
|
-
if (!
|
|
2927
|
-
throw new
|
|
2928
|
-
|
|
2929
|
-
const
|
|
2930
|
-
const
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
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
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
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
|
-
|
|
3141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3508
|
-
throw new Error("Price history
|
|
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
|
-
|
|
3511
|
-
if (duration < 0)
|
|
3724
|
+
if (input.endDate < input.startDate) {
|
|
3512
3725
|
throw new Error("Price history endDate must not precede startDate");
|
|
3513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
4367
|
+
return this.scheduledRequest(input, "SINGLE_ATTEMPT");
|
|
3907
4368
|
}
|
|
3908
|
-
scheduledRequest(input,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
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
|
-
|
|
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")
|