@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.
- package/README.md +120 -24
- package/dist/ibkr/ibkrApiTypes.d.ts +7 -0
- package/dist/ibkr/ibkrApiTypes.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.d.ts +90 -10
- package/dist/ibkr/ibkrClient.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.js +848 -165
- package/dist/ibkr/ibkrClient.js.map +1 -1
- package/dist/ibkr/requestScheduler.d.ts +14 -1
- package/dist/ibkr/requestScheduler.d.ts.map +1 -1
- package/dist/ibkr/requestScheduler.js +93 -10
- 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 +97 -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,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
|
-
...
|
|
190
|
-
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
3040
|
+
/** Return complete daily history with the exact validated IBKR request context. */
|
|
2924
3041
|
async getPriceHistory(input) {
|
|
2925
|
-
const
|
|
2926
|
-
if (!
|
|
2927
|
-
throw new
|
|
2928
|
-
|
|
2929
|
-
const
|
|
2930
|
-
const
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
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
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
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 =
|
|
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
|
|
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, {
|
|
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
|
-
|
|
3141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
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.
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
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
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
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
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
}
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
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
|
-
|
|
3766
|
+
const diagnostics = {
|
|
3767
|
+
qualifiedCount: contracts.length,
|
|
3768
|
+
returnedCount: quotes.length,
|
|
3769
|
+
malformedDefinitionCount,
|
|
3770
|
+
missingFieldCounts,
|
|
3771
|
+
};
|
|
3772
|
+
return { quotes, diagnostics };
|
|
3447
3773
|
}
|
|
3448
|
-
async
|
|
3449
|
-
const
|
|
3450
|
-
const
|
|
3451
|
-
const
|
|
3452
|
-
for (const batch of
|
|
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
|
|
3785
|
+
const response = await this.req({
|
|
3460
3786
|
path: "iserver/marketdata/snapshot",
|
|
3461
3787
|
params,
|
|
3462
3788
|
});
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
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") :
|
|
3469
|
-
const ask = snapshot ? this.snapshotNumber(snapshot, "86") :
|
|
3470
|
-
const
|
|
3471
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3508
|
-
throw new Error("Price history
|
|
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
|
-
|
|
3511
|
-
if (duration < 0)
|
|
3868
|
+
if (input.endDate < input.startDate) {
|
|
3512
3869
|
throw new Error("Price history endDate must not precede startDate");
|
|
3513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
4526
|
+
return this.scheduledRequest(input, "SINGLE_ATTEMPT");
|
|
3907
4527
|
}
|
|
3908
|
-
scheduledRequest(input,
|
|
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
|
-
|
|
3913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
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
|
-
|
|
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")
|