@huskly/ibkr-client 1.3.0 → 1.4.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 +56 -2
- package/dist/ibkr/ibkrApiTypes.d.ts +3 -0
- package/dist/ibkr/ibkrApiTypes.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.d.ts +27 -2
- package/dist/ibkr/ibkrClient.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.js +287 -78
- package/dist/ibkr/ibkrClient.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/ibkr/ibkrClient.js
CHANGED
|
@@ -99,6 +99,77 @@ function parseRetryAfter(raw, now) {
|
|
|
99
99
|
function isUnknownRecord(input) {
|
|
100
100
|
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Every distinct listing of an exact symbol that carries a section of one asset class (#671).
|
|
104
|
+
*
|
|
105
|
+
* Duplicate conids collapse: IBKR repeats a listing across sections, and the same contract twice
|
|
106
|
+
* is one candidate, not an ambiguity. A symbol that does not match exactly is never a candidate,
|
|
107
|
+
* because a foreign listing under a different ticker is a different instrument.
|
|
108
|
+
*/
|
|
109
|
+
function secdefListings(search, symbol, assetClass) {
|
|
110
|
+
const requested = symbol.trim().toUpperCase();
|
|
111
|
+
const wanted = assetClass.trim().toUpperCase();
|
|
112
|
+
const listings = search.flatMap((item) => {
|
|
113
|
+
if (item.symbol?.trim().toUpperCase() !== requested)
|
|
114
|
+
return [];
|
|
115
|
+
const conid = Number(item.conid);
|
|
116
|
+
if (!Number.isSafeInteger(conid) || conid <= 0)
|
|
117
|
+
return [];
|
|
118
|
+
const sections = Array.isArray(item.sections) ? item.sections : [];
|
|
119
|
+
const matched = sections.filter((section) => section.secType?.trim().toUpperCase() === wanted);
|
|
120
|
+
if (matched.length === 0)
|
|
121
|
+
return [];
|
|
122
|
+
const exchanges = [
|
|
123
|
+
...new Set(matched.flatMap((section) => (section.exchange ?? "")
|
|
124
|
+
.split(";")
|
|
125
|
+
.map((name) => name.trim().toUpperCase())
|
|
126
|
+
.filter(Boolean))),
|
|
127
|
+
];
|
|
128
|
+
// An empty label is no label, so it falls through to the next source rather than becoming
|
|
129
|
+
// an empty description in an operator message.
|
|
130
|
+
const labels = [item.companyHeader?.trim(), item.description?.trim()];
|
|
131
|
+
const description = labels.find((label) => label !== undefined && label !== "") ?? null;
|
|
132
|
+
return [
|
|
133
|
+
{
|
|
134
|
+
conid,
|
|
135
|
+
symbol: requested,
|
|
136
|
+
supportsSmartOptions: supportsSmartOptions(sections),
|
|
137
|
+
exchanges,
|
|
138
|
+
description,
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
});
|
|
142
|
+
return [...new Map(listings.map((listing) => [listing.conid, listing])).values()];
|
|
143
|
+
}
|
|
144
|
+
/** The listings that route options on SMART, which is the US listing a caller means. */
|
|
145
|
+
function smartOptionListings(listings) {
|
|
146
|
+
return listings.filter((listing) => listing.supportsSmartOptions);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Narrow listings to the SMART-routed one, keeping them all when none routes SMART (#671).
|
|
150
|
+
*
|
|
151
|
+
* The fallback matters: futures options and other non-SMART series never advertise SMART, and
|
|
152
|
+
* dropping every candidate there would refuse a symbol that is not ambiguous at all.
|
|
153
|
+
*/
|
|
154
|
+
function preferSmartOptionListings(listings) {
|
|
155
|
+
const smart = smartOptionListings(listings);
|
|
156
|
+
return smart.length > 0 ? smart : [...listings];
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Name the competing listings so an operator can pass an explicit `tradingClass` or conid (#671).
|
|
160
|
+
*
|
|
161
|
+
* It reports the conid, IBKR's description, and the exchanges of each listing. It carries no
|
|
162
|
+
* account identity, no order identity, and no credentials.
|
|
163
|
+
*/
|
|
164
|
+
function describeSecdefListings(listings) {
|
|
165
|
+
return listings
|
|
166
|
+
.map((listing) => {
|
|
167
|
+
const venue = listing.exchanges.length > 0 ? listing.exchanges.join("/") : "no exchange";
|
|
168
|
+
const description = listing.description === null ? "" : ` ${listing.description}`;
|
|
169
|
+
return `conid ${String(listing.conid)}${description} (${venue})`;
|
|
170
|
+
})
|
|
171
|
+
.join("; ");
|
|
172
|
+
}
|
|
102
173
|
function supportsSmartOptions(sections) {
|
|
103
174
|
if (!Array.isArray(sections))
|
|
104
175
|
return false;
|
|
@@ -201,6 +272,59 @@ function monthCodes(fromDate, toDate) {
|
|
|
201
272
|
}
|
|
202
273
|
return result;
|
|
203
274
|
}
|
|
275
|
+
/** Refuse a band that is not made of finite numbers, or that can never select a strike. */
|
|
276
|
+
function normalizeStrikeRange(range) {
|
|
277
|
+
if (range === undefined)
|
|
278
|
+
return undefined;
|
|
279
|
+
const { min, max } = range;
|
|
280
|
+
if (min !== undefined && !Number.isFinite(min)) {
|
|
281
|
+
throw new TypeError("Option strike range min must be a finite number");
|
|
282
|
+
}
|
|
283
|
+
if (max !== undefined && !Number.isFinite(max)) {
|
|
284
|
+
throw new TypeError("Option strike range max must be a finite number");
|
|
285
|
+
}
|
|
286
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
287
|
+
throw new TypeError(`Option strike range min ${String(min)} is above max ${String(max)}`);
|
|
288
|
+
}
|
|
289
|
+
if (min === undefined && max === undefined)
|
|
290
|
+
return undefined;
|
|
291
|
+
return range;
|
|
292
|
+
}
|
|
293
|
+
/** One stable memo token for a band, so a narrowed result cannot answer a wider request. */
|
|
294
|
+
function strikeRangeKey(range) {
|
|
295
|
+
if (range === undefined)
|
|
296
|
+
return "*";
|
|
297
|
+
return `${String(range.min ?? "-inf")}..${String(range.max ?? "+inf")}`;
|
|
298
|
+
}
|
|
299
|
+
/** A strike that IBKR does not report as a finite number is never selected. */
|
|
300
|
+
function strikeInRange(strike, range) {
|
|
301
|
+
if (!Number.isFinite(strike))
|
|
302
|
+
return false;
|
|
303
|
+
if (range === undefined)
|
|
304
|
+
return true;
|
|
305
|
+
if (range.min !== undefined && strike < range.min)
|
|
306
|
+
return false;
|
|
307
|
+
if (range.max !== undefined && strike > range.max)
|
|
308
|
+
return false;
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
/** A cached record is used only when it is a whole contract and it answers the key it is filed under. */
|
|
312
|
+
function isCachedOptionContract(value, key) {
|
|
313
|
+
if (!isUnknownRecord(value))
|
|
314
|
+
return false;
|
|
315
|
+
const { conid, symbol, underlying, expiry, strike, right } = value;
|
|
316
|
+
return (typeof conid === "number" &&
|
|
317
|
+
Number.isFinite(conid) &&
|
|
318
|
+
typeof symbol === "string" &&
|
|
319
|
+
symbol.length > 0 &&
|
|
320
|
+
typeof underlying === "string" &&
|
|
321
|
+
underlying.length > 0 &&
|
|
322
|
+
typeof expiry === "string" &&
|
|
323
|
+
expiry.length > 0 &&
|
|
324
|
+
typeof strike === "number" &&
|
|
325
|
+
strike === key.strike &&
|
|
326
|
+
right === key.right);
|
|
327
|
+
}
|
|
204
328
|
/** A typed HTTP failure from the IBKR transport. */
|
|
205
329
|
export class IbkrHttpError extends Error {
|
|
206
330
|
status;
|
|
@@ -266,6 +390,7 @@ export class IbkrClient {
|
|
|
266
390
|
initPromise;
|
|
267
391
|
accountIdPromise;
|
|
268
392
|
optionDiscovery = new Map();
|
|
393
|
+
optionDefinitionCache;
|
|
269
394
|
optionContractResolution = new Map();
|
|
270
395
|
priceHistoryContractResolution = new Map();
|
|
271
396
|
derivativeDiscovery = new Map();
|
|
@@ -279,6 +404,7 @@ export class IbkrClient {
|
|
|
279
404
|
this.raw = new RawIbkrClientCtor(config);
|
|
280
405
|
this.onPriceHistoryTelemetry = options.onPriceHistoryTelemetry ?? (() => undefined);
|
|
281
406
|
this.onOptionDiscoveryTelemetry = options.onOptionDiscoveryTelemetry ?? (() => undefined);
|
|
407
|
+
this.optionDefinitionCache = options.optionDefinitionCache;
|
|
282
408
|
this.onRequestTelemetry = options.onRequestTelemetry ?? (() => undefined);
|
|
283
409
|
const schedulerOptions = options.requestScheduler;
|
|
284
410
|
this.requestNow = schedulerOptions?.now ?? (() => this.now());
|
|
@@ -3036,12 +3162,12 @@ export class IbkrClient {
|
|
|
3036
3162
|
if (!requestedSymbol)
|
|
3037
3163
|
return undefined;
|
|
3038
3164
|
const search = await this.searchSecdef({ symbol: requestedSymbol });
|
|
3039
|
-
const optionable =
|
|
3165
|
+
const optionable = smartOptionListings(secdefListings(search, requestedSymbol, "OPT"));
|
|
3040
3166
|
if (optionable.length > 1)
|
|
3041
3167
|
return undefined;
|
|
3042
|
-
const [
|
|
3043
|
-
if (
|
|
3044
|
-
return { requestedSymbol: symbol, symbol: requestedSymbol, conid:
|
|
3168
|
+
const [optionableListing] = optionable;
|
|
3169
|
+
if (optionableListing !== undefined) {
|
|
3170
|
+
return { requestedSymbol: symbol, symbol: requestedSymbol, conid: optionableListing.conid };
|
|
3045
3171
|
}
|
|
3046
3172
|
// `trsrv/stocks` is equity/ETF-only, so a symbol with no SMART options can still be a plain
|
|
3047
3173
|
// stock or ETF that the security-definition search does not describe as optionable.
|
|
@@ -3160,16 +3286,7 @@ export class IbkrClient {
|
|
|
3160
3286
|
});
|
|
3161
3287
|
}
|
|
3162
3288
|
const search = await this.searchSecdef({ symbol: requestedSymbol });
|
|
3163
|
-
const smartOptionConids = new Set(search.
|
|
3164
|
-
const symbol = item.symbol?.trim().toUpperCase();
|
|
3165
|
-
const conid = Number(item.conid);
|
|
3166
|
-
return symbol === requestedSymbol &&
|
|
3167
|
-
Number.isSafeInteger(conid) &&
|
|
3168
|
-
conid > 0 &&
|
|
3169
|
-
supportsSmartOptions(item.sections)
|
|
3170
|
-
? [conid]
|
|
3171
|
-
: [];
|
|
3172
|
-
}));
|
|
3289
|
+
const smartOptionConids = new Set(smartOptionListings(secdefListings(search, requestedSymbol, "OPT")).map((listing) => listing.conid));
|
|
3173
3290
|
const candidates = search.flatMap((item) => {
|
|
3174
3291
|
const symbol = item.symbol?.trim().toUpperCase();
|
|
3175
3292
|
const conid = Number(item.conid);
|
|
@@ -3560,24 +3677,20 @@ export class IbkrClient {
|
|
|
3560
3677
|
path: "iserver/secdef/search",
|
|
3561
3678
|
params: { symbol: underlying, ...(assetClass === "FOP" ? { secType: "FUT" } : {}) },
|
|
3562
3679
|
}));
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3680
|
+
// Order placement resolves its legs through here, so it narrows the search exactly as option
|
|
3681
|
+
// discovery does. Without it a ticker that also names a Canadian Depositary Receipt, such as
|
|
3682
|
+
// `UNH` or `NFLX`, refuses every order for a listing it does not trade (#671).
|
|
3683
|
+
const listings = secdefListings(search, underlying, assetClass);
|
|
3684
|
+
const candidates = preferSmartOptionListings(listings);
|
|
3566
3685
|
if (candidates.length !== 1) {
|
|
3567
|
-
|
|
3686
|
+
const detail = candidates.length > 1 ? `; competing listings: ${describeSecdefListings(candidates)}` : "";
|
|
3687
|
+
throw new Error(`IBKR ${assetClass} underlying identity is ${candidates.length ? "ambiguous" : "missing"} for ${underlying}${detail}`);
|
|
3568
3688
|
}
|
|
3569
3689
|
const candidate = candidates[0];
|
|
3570
3690
|
if (!candidate)
|
|
3571
3691
|
throw new Error(`IBKR lost the selected underlying for ${underlying}`);
|
|
3572
|
-
const conid =
|
|
3573
|
-
|
|
3574
|
-
throw new Error(`IBKR returned an invalid underlying contract id for ${underlying}`);
|
|
3575
|
-
}
|
|
3576
|
-
const section = candidate.sections?.find((item) => item.secType?.toUpperCase() === assetClass);
|
|
3577
|
-
const exchanges = (section?.exchange ?? "")
|
|
3578
|
-
.split(";")
|
|
3579
|
-
.map((value) => value.trim().toUpperCase())
|
|
3580
|
-
.filter(Boolean);
|
|
3692
|
+
const conid = candidate.conid;
|
|
3693
|
+
const exchanges = candidate.exchanges;
|
|
3581
3694
|
if (requestedExchange && !exchanges.includes(requestedExchange)) {
|
|
3582
3695
|
throw new Error(`IBKR does not list ${underlying} ${assetClass} discovery on ${requestedExchange}`);
|
|
3583
3696
|
}
|
|
@@ -3666,16 +3779,24 @@ export class IbkrClient {
|
|
|
3666
3779
|
}
|
|
3667
3780
|
discoverOptions(symbol, month, right, options = {}) {
|
|
3668
3781
|
const normalized = symbol.trim().toUpperCase();
|
|
3782
|
+
const strikeRange = normalizeStrikeRange(options.strikeRange);
|
|
3783
|
+
const discoveryOptions = {
|
|
3784
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
3785
|
+
...(strikeRange === undefined ? {} : { strikeRange }),
|
|
3786
|
+
};
|
|
3669
3787
|
if (options.signal !== undefined) {
|
|
3670
|
-
return this.loadOptionContracts(normalized, month, right,
|
|
3788
|
+
return this.loadOptionContracts(normalized, month, right, discoveryOptions);
|
|
3671
3789
|
}
|
|
3672
|
-
|
|
3790
|
+
// The band belongs in the memo key. A narrowed result holds only part of the month, so it must
|
|
3791
|
+
// never answer a later request for a different band.
|
|
3792
|
+
const bandKey = strikeRangeKey(strikeRange);
|
|
3793
|
+
const key = `${normalized}:${month}:${right ?? "*"}:${bandKey}`;
|
|
3673
3794
|
const cached = this.optionDiscovery.get(key);
|
|
3674
3795
|
if (cached !== undefined)
|
|
3675
3796
|
return cached;
|
|
3676
3797
|
let discovery;
|
|
3677
3798
|
if (right !== undefined) {
|
|
3678
|
-
const complete = this.optionDiscovery.get(`${normalized}:${month}
|
|
3799
|
+
const complete = this.optionDiscovery.get(`${normalized}:${month}:*:${bandKey}`);
|
|
3679
3800
|
if (complete !== undefined) {
|
|
3680
3801
|
discovery = complete.then((result) => ({
|
|
3681
3802
|
contracts: result.contracts.filter((contract) => contract.right === right),
|
|
@@ -3683,7 +3804,7 @@ export class IbkrClient {
|
|
|
3683
3804
|
}));
|
|
3684
3805
|
}
|
|
3685
3806
|
}
|
|
3686
|
-
discovery ??= this.loadOptionContracts(normalized, month, right);
|
|
3807
|
+
discovery ??= this.loadOptionContracts(normalized, month, right, discoveryOptions);
|
|
3687
3808
|
const pending = discovery.catch((error) => {
|
|
3688
3809
|
if (this.optionDiscovery.get(key) === pending)
|
|
3689
3810
|
this.optionDiscovery.delete(key);
|
|
@@ -3699,39 +3820,19 @@ export class IbkrClient {
|
|
|
3699
3820
|
path: "iserver/secdef/search",
|
|
3700
3821
|
params: { symbol },
|
|
3701
3822
|
}, signal));
|
|
3702
|
-
const
|
|
3703
|
-
if (!isUnknownRecord(item))
|
|
3704
|
-
return [];
|
|
3705
|
-
const candidateSymbol = item["symbol"];
|
|
3706
|
-
if (typeof candidateSymbol !== "string" || candidateSymbol.trim().toUpperCase() !== symbol) {
|
|
3707
|
-
return [];
|
|
3708
|
-
}
|
|
3709
|
-
const sections = item["sections"];
|
|
3710
|
-
if (!Array.isArray(sections))
|
|
3711
|
-
return [];
|
|
3712
|
-
const hasOptions = sections.some((section) => isUnknownRecord(section) &&
|
|
3713
|
-
typeof section["secType"] === "string" &&
|
|
3714
|
-
section["secType"].trim().toUpperCase() === "OPT");
|
|
3715
|
-
if (!hasOptions)
|
|
3716
|
-
return [];
|
|
3717
|
-
const supportsSmart = supportsSmartOptions(sections);
|
|
3718
|
-
const conid = Number(item["conid"]);
|
|
3719
|
-
return Number.isSafeInteger(conid) && conid > 0 ? [{ conid, symbol, supportsSmart }] : [];
|
|
3720
|
-
});
|
|
3721
|
-
const unique = [
|
|
3722
|
-
...new Map(candidates.map((candidate) => [candidate.conid, candidate])).values(),
|
|
3723
|
-
];
|
|
3724
|
-
const smart = unique.filter((candidate) => candidate.supportsSmart);
|
|
3725
|
-
const eligible = smart.length > 0 ? smart : unique;
|
|
3823
|
+
const eligible = preferSmartOptionListings(secdefListings(search, symbol, "OPT"));
|
|
3726
3824
|
if (eligible.length !== 1) {
|
|
3727
|
-
|
|
3825
|
+
const detail = eligible.length > 1 ? `; competing listings: ${describeSecdefListings(eligible)}` : "";
|
|
3826
|
+
throw new Error(`IBKR option underlying identity is ${eligible.length ? "ambiguous" : "missing"} for ${symbol}${detail}`);
|
|
3728
3827
|
}
|
|
3729
3828
|
const [underlying] = eligible;
|
|
3730
3829
|
if (underlying === undefined)
|
|
3731
3830
|
throw new Error(`IBKR lost the selected underlying for ${symbol}`);
|
|
3732
|
-
return underlying;
|
|
3831
|
+
return { conid: underlying.conid, symbol };
|
|
3733
3832
|
}
|
|
3734
|
-
async loadOptionContracts(symbol, month, right,
|
|
3833
|
+
async loadOptionContracts(symbol, month, right, options = {}) {
|
|
3834
|
+
const callerSignal = options.signal;
|
|
3835
|
+
const strikeRange = normalizeStrikeRange(options.strikeRange);
|
|
3735
3836
|
const operation = new AbortController();
|
|
3736
3837
|
const abortFromCaller = () => {
|
|
3737
3838
|
operation.abort(callerSignal?.reason);
|
|
@@ -3741,7 +3842,7 @@ export class IbkrClient {
|
|
|
3741
3842
|
else
|
|
3742
3843
|
callerSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
3743
3844
|
try {
|
|
3744
|
-
const { underlying, requests } = await this.withSecdefPriming(async () => {
|
|
3845
|
+
const { underlying, requests, listedStrikeCount } = await this.withSecdefPriming(async () => {
|
|
3745
3846
|
const searchStarted = this.requestNow();
|
|
3746
3847
|
const selectedUnderlying = await this.loadOptionUnderlying(symbol, operation.signal);
|
|
3747
3848
|
this.emitOptionDiscoveryTelemetry({
|
|
@@ -3750,29 +3851,19 @@ export class IbkrClient {
|
|
|
3750
3851
|
month,
|
|
3751
3852
|
right: right ?? null,
|
|
3752
3853
|
durationMs: this.elapsedSince(searchStarted),
|
|
3753
|
-
definitionRequestCount: 0,
|
|
3754
|
-
snapshotBatchCount: 0,
|
|
3755
3854
|
});
|
|
3756
3855
|
const strikesStarted = this.requestNow();
|
|
3757
3856
|
const strikes = await this.req({
|
|
3758
3857
|
path: "iserver/secdef/strikes",
|
|
3759
3858
|
params: { conid: String(selectedUnderlying.conid), sectype: "OPT", month },
|
|
3760
3859
|
}, operation.signal);
|
|
3761
|
-
this.
|
|
3762
|
-
phase: "STRIKES",
|
|
3763
|
-
symbol,
|
|
3764
|
-
month,
|
|
3765
|
-
right: right ?? null,
|
|
3766
|
-
durationMs: this.elapsedSince(strikesStarted),
|
|
3767
|
-
definitionRequestCount: 0,
|
|
3768
|
-
snapshotBatchCount: 0,
|
|
3769
|
-
});
|
|
3860
|
+
const strikesDurationMs = this.elapsedSince(strikesStarted);
|
|
3770
3861
|
const callStrikes = strikes.call ?? [];
|
|
3771
3862
|
const putStrikes = strikes.put ?? [];
|
|
3772
3863
|
if (callStrikes.length === 0 && putStrikes.length === 0) {
|
|
3773
3864
|
throw new Error(`IBKR returned empty option strikes for ${symbol} ${month} after secdef/search priming`);
|
|
3774
3865
|
}
|
|
3775
|
-
const
|
|
3866
|
+
const listed = [
|
|
3776
3867
|
...(right === undefined || right === "C"
|
|
3777
3868
|
? callStrikes.map((strike) => ({ strike, right: "C" }))
|
|
3778
3869
|
: []),
|
|
@@ -3780,12 +3871,48 @@ export class IbkrClient {
|
|
|
3780
3871
|
? putStrikes.map((strike) => ({ strike, right: "P" }))
|
|
3781
3872
|
: []),
|
|
3782
3873
|
];
|
|
3783
|
-
|
|
3874
|
+
// One security definition costs one paced request, so the band is applied here, before any
|
|
3875
|
+
// definition is requested. Applying it later would keep the full cost of the month.
|
|
3876
|
+
const selected = listed.filter(({ strike }) => strikeInRange(strike, strikeRange));
|
|
3877
|
+
this.emitOptionDiscoveryTelemetry({
|
|
3878
|
+
phase: "STRIKES",
|
|
3879
|
+
symbol,
|
|
3880
|
+
month,
|
|
3881
|
+
right: right ?? null,
|
|
3882
|
+
durationMs: strikesDurationMs,
|
|
3883
|
+
listedStrikeCount: listed.length,
|
|
3884
|
+
selectedStrikeCount: selected.length,
|
|
3885
|
+
});
|
|
3886
|
+
// A band that keeps nothing is a caller mistake, not an empty chain. Reporting it as an
|
|
3887
|
+
// empty result would look like an unlisted expiry.
|
|
3888
|
+
if (listed.length > 0 && selected.length === 0) {
|
|
3889
|
+
throw new Error(`IBKR option strike range [${String(strikeRange?.min ?? "-inf")}, ` +
|
|
3890
|
+
`${String(strikeRange?.max ?? "+inf")}] selected none of the ` +
|
|
3891
|
+
`${String(listed.length)} listed strikes for ${symbol} ${month}`);
|
|
3892
|
+
}
|
|
3893
|
+
return {
|
|
3894
|
+
underlying: selectedUnderlying,
|
|
3895
|
+
requests: selected,
|
|
3896
|
+
listedStrikeCount: listed.length,
|
|
3897
|
+
};
|
|
3784
3898
|
});
|
|
3785
3899
|
const definitionsStarted = this.requestNow();
|
|
3786
3900
|
const contracts = [];
|
|
3787
3901
|
let malformedDefinitionCount = 0;
|
|
3788
|
-
|
|
3902
|
+
const cached = await this.readCachedOptionDefinitions(underlying.conid, month, requests);
|
|
3903
|
+
const pending = [];
|
|
3904
|
+
let cachedDefinitionCount = 0;
|
|
3905
|
+
for (const [position, request] of requests.entries()) {
|
|
3906
|
+
const hit = cached[position];
|
|
3907
|
+
if (hit === undefined || hit === null) {
|
|
3908
|
+
pending.push(request);
|
|
3909
|
+
continue;
|
|
3910
|
+
}
|
|
3911
|
+
cachedDefinitionCount += 1;
|
|
3912
|
+
contracts.push(...hit);
|
|
3913
|
+
}
|
|
3914
|
+
const resolved = [];
|
|
3915
|
+
for (const batch of chunks(pending, OPTION_SECDEF_INFO_BATCH_SIZE)) {
|
|
3789
3916
|
let responses;
|
|
3790
3917
|
try {
|
|
3791
3918
|
responses = await Promise.all(batch.map(({ strike, right: requestRight }) => this.req({
|
|
@@ -3805,13 +3932,17 @@ export class IbkrClient {
|
|
|
3805
3932
|
operation.abort(error);
|
|
3806
3933
|
throw error;
|
|
3807
3934
|
}
|
|
3808
|
-
for (const response of responses) {
|
|
3935
|
+
for (const [position, response] of responses.entries()) {
|
|
3809
3936
|
if (!Array.isArray(response)) {
|
|
3810
3937
|
throw new Error(`IBKR returned malformed option definitions for ${symbol} ${month}`);
|
|
3811
3938
|
}
|
|
3939
|
+
const request = batch[position];
|
|
3940
|
+
const resolvedForRequest = [];
|
|
3941
|
+
let requestHadMalformedRecord = false;
|
|
3812
3942
|
for (const raw of response) {
|
|
3813
3943
|
if (!isUnknownRecord(raw)) {
|
|
3814
3944
|
malformedDefinitionCount += 1;
|
|
3945
|
+
requestHadMalformedRecord = true;
|
|
3815
3946
|
continue;
|
|
3816
3947
|
}
|
|
3817
3948
|
let contract;
|
|
@@ -3828,23 +3959,43 @@ export class IbkrClient {
|
|
|
3828
3959
|
}
|
|
3829
3960
|
catch {
|
|
3830
3961
|
malformedDefinitionCount += 1;
|
|
3962
|
+
requestHadMalformedRecord = true;
|
|
3831
3963
|
continue;
|
|
3832
3964
|
}
|
|
3833
3965
|
if (contract)
|
|
3834
|
-
|
|
3835
|
-
else
|
|
3966
|
+
resolvedForRequest.push(contract);
|
|
3967
|
+
else {
|
|
3836
3968
|
malformedDefinitionCount += 1;
|
|
3969
|
+
requestHadMalformedRecord = true;
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
contracts.push(...resolvedForRequest);
|
|
3973
|
+
// A partial answer is never stored. A later run must ask the broker again rather than
|
|
3974
|
+
// read a record that already lost contracts.
|
|
3975
|
+
if (request !== undefined && !requestHadMalformedRecord) {
|
|
3976
|
+
resolved.push({
|
|
3977
|
+
key: {
|
|
3978
|
+
underlyingConid: underlying.conid,
|
|
3979
|
+
month,
|
|
3980
|
+
right: request.right,
|
|
3981
|
+
strike: request.strike,
|
|
3982
|
+
},
|
|
3983
|
+
contracts: resolvedForRequest,
|
|
3984
|
+
});
|
|
3837
3985
|
}
|
|
3838
3986
|
}
|
|
3839
3987
|
}
|
|
3988
|
+
await this.writeCachedOptionDefinitions(resolved);
|
|
3840
3989
|
this.emitOptionDiscoveryTelemetry({
|
|
3841
3990
|
phase: "DEFINITIONS",
|
|
3842
3991
|
symbol,
|
|
3843
3992
|
month,
|
|
3844
3993
|
right: right ?? null,
|
|
3845
3994
|
durationMs: this.elapsedSince(definitionsStarted),
|
|
3846
|
-
definitionRequestCount:
|
|
3847
|
-
|
|
3995
|
+
definitionRequestCount: pending.length,
|
|
3996
|
+
listedStrikeCount,
|
|
3997
|
+
selectedStrikeCount: requests.length,
|
|
3998
|
+
cachedDefinitionCount,
|
|
3848
3999
|
});
|
|
3849
4000
|
if (requests.length === 0)
|
|
3850
4001
|
return { contracts: [], malformedDefinitionCount: 0 };
|
|
@@ -3858,6 +4009,59 @@ export class IbkrClient {
|
|
|
3858
4009
|
callerSignal?.removeEventListener("abort", abortFromCaller);
|
|
3859
4010
|
}
|
|
3860
4011
|
}
|
|
4012
|
+
/**
|
|
4013
|
+
* Read one definition for each request from the cache, aligned by index.
|
|
4014
|
+
*
|
|
4015
|
+
* Every failure mode collapses to "miss": no cache, a rejected read, a misaligned result, or a
|
|
4016
|
+
* record that is not a valid contract for the key it answers. The broker is then asked, so a bad
|
|
4017
|
+
* cache costs time and never changes an answer.
|
|
4018
|
+
*/
|
|
4019
|
+
async readCachedOptionDefinitions(underlyingConid, month, requests) {
|
|
4020
|
+
const cache = this.optionDefinitionCache;
|
|
4021
|
+
if (cache === undefined || requests.length === 0)
|
|
4022
|
+
return requests.map(() => null);
|
|
4023
|
+
const keys = requests.map(({ strike, right }) => ({
|
|
4024
|
+
underlyingConid,
|
|
4025
|
+
month,
|
|
4026
|
+
right,
|
|
4027
|
+
strike,
|
|
4028
|
+
}));
|
|
4029
|
+
// The result is treated as untrusted data, not as the declared type. An implementation that
|
|
4030
|
+
// returns the wrong shape must degrade to a miss, never to a fabricated contract.
|
|
4031
|
+
let answer;
|
|
4032
|
+
try {
|
|
4033
|
+
answer = await cache.get(keys);
|
|
4034
|
+
}
|
|
4035
|
+
catch {
|
|
4036
|
+
return requests.map(() => null);
|
|
4037
|
+
}
|
|
4038
|
+
if (!Array.isArray(answer) || answer.length !== keys.length)
|
|
4039
|
+
return requests.map(() => null);
|
|
4040
|
+
const records = answer;
|
|
4041
|
+
return keys.map((key, position) => {
|
|
4042
|
+
const record = records[position];
|
|
4043
|
+
if (record === null || record === undefined)
|
|
4044
|
+
return null;
|
|
4045
|
+
if (!Array.isArray(record))
|
|
4046
|
+
return null;
|
|
4047
|
+
const cachedContracts = record;
|
|
4048
|
+
if (!cachedContracts.every((contract) => isCachedOptionContract(contract, key)))
|
|
4049
|
+
return null;
|
|
4050
|
+
return cachedContracts;
|
|
4051
|
+
});
|
|
4052
|
+
}
|
|
4053
|
+
/** Store resolved definitions. A store failure is never fatal: the identity came from IBKR. */
|
|
4054
|
+
async writeCachedOptionDefinitions(entries) {
|
|
4055
|
+
const cache = this.optionDefinitionCache;
|
|
4056
|
+
if (cache === undefined || entries.length === 0)
|
|
4057
|
+
return;
|
|
4058
|
+
try {
|
|
4059
|
+
await cache.set(entries);
|
|
4060
|
+
}
|
|
4061
|
+
catch {
|
|
4062
|
+
// A cache is an accelerator. Discovery already holds the broker answer.
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
3861
4065
|
async fetchOptionChainSnapshot(contracts, malformedDefinitionCount, telemetry, signal) {
|
|
3862
4066
|
const fields = [
|
|
3863
4067
|
"bid",
|
|
@@ -3990,6 +4194,11 @@ export class IbkrClient {
|
|
|
3990
4194
|
try {
|
|
3991
4195
|
const result = this.onOptionDiscoveryTelemetry({
|
|
3992
4196
|
event: "OPTION_DISCOVERY_PHASE",
|
|
4197
|
+
definitionRequestCount: 0,
|
|
4198
|
+
snapshotBatchCount: 0,
|
|
4199
|
+
listedStrikeCount: 0,
|
|
4200
|
+
selectedStrikeCount: 0,
|
|
4201
|
+
cachedDefinitionCount: 0,
|
|
3993
4202
|
...event,
|
|
3994
4203
|
});
|
|
3995
4204
|
void Promise.resolve(result).catch(() => undefined);
|