@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.
@@ -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 = this.quoteContractConids(requestedSymbol, search, (item) => supportsSmartOptions(item.sections));
3165
+ const optionable = smartOptionListings(secdefListings(search, requestedSymbol, "OPT"));
3040
3166
  if (optionable.length > 1)
3041
3167
  return undefined;
3042
- const [optionableConid] = optionable;
3043
- if (optionableConid !== undefined) {
3044
- return { requestedSymbol: symbol, symbol: requestedSymbol, conid: optionableConid };
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.flatMap((item) => {
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
- const candidates = search.filter((candidate) => candidate.conid !== undefined &&
3564
- candidate.symbol?.trim().toUpperCase() === underlying &&
3565
- candidate.sections?.some((section) => section.secType?.toUpperCase() === assetClass));
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
- throw new Error(`IBKR ${assetClass} underlying identity is ${candidates.length ? "ambiguous" : "missing"} for ${underlying}`);
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 = Number(candidate.conid);
3573
- if (!Number.isSafeInteger(conid) || conid <= 0) {
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, options.signal);
3788
+ return this.loadOptionContracts(normalized, month, right, discoveryOptions);
3671
3789
  }
3672
- const key = `${normalized}:${month}:${right ?? "*"}`;
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 candidates = search.flatMap((item) => {
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
- throw new Error(`IBKR option underlying identity is ${eligible.length ? "ambiguous" : "missing"} for ${symbol}`);
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, callerSignal) {
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.emitOptionDiscoveryTelemetry({
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 definitionRequests = [
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
- return { underlying: selectedUnderlying, requests: definitionRequests };
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
- for (const batch of chunks(requests, OPTION_SECDEF_INFO_BATCH_SIZE)) {
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
- contracts.push(contract);
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: requests.length,
3847
- snapshotBatchCount: 0,
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);