@huskly/ibkr-client 1.3.1 → 1.5.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.
@@ -272,6 +272,81 @@ function monthCodes(fromDate, toDate) {
272
272
  }
273
273
  return result;
274
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
+ /** The first pair of contracts that share one durable symbol, or `null` when every one is unique. */
294
+ function firstSymbolCollision(contracts) {
295
+ const bySymbol = new Map();
296
+ for (const contract of contracts) {
297
+ const seen = bySymbol.get(contract.symbol);
298
+ if (seen !== undefined)
299
+ return { symbol: contract.symbol, first: seen, second: contract };
300
+ bySymbol.set(contract.symbol, contract);
301
+ }
302
+ return null;
303
+ }
304
+ /** One stable memo token for a band, so a narrowed result cannot answer a wider request. */
305
+ function strikeRangeKey(range) {
306
+ if (range === undefined)
307
+ return "*";
308
+ return `${String(range.min ?? "-inf")}..${String(range.max ?? "+inf")}`;
309
+ }
310
+ /** A strike that IBKR does not report as a finite number is never selected. */
311
+ function strikeInRange(strike, range) {
312
+ if (!Number.isFinite(strike))
313
+ return false;
314
+ if (range === undefined)
315
+ return true;
316
+ if (range.min !== undefined && strike < range.min)
317
+ return false;
318
+ if (range.max !== undefined && strike > range.max)
319
+ return false;
320
+ return true;
321
+ }
322
+ /** A cached record is used only when it is a whole contract and it answers the key it is filed under. */
323
+ function isCachedOptionContract(value, key) {
324
+ if (!isUnknownRecord(value))
325
+ return false;
326
+ const { conid, symbol, underlying, tradingClass, expiry, strike, right } = value;
327
+ if (typeof conid !== "number" ||
328
+ !Number.isFinite(conid) ||
329
+ typeof symbol !== "string" ||
330
+ symbol.length === 0 ||
331
+ typeof underlying !== "string" ||
332
+ underlying.length === 0 ||
333
+ typeof expiry !== "string" ||
334
+ expiry.length === 0 ||
335
+ typeof strike !== "number" ||
336
+ strike !== key.strike ||
337
+ right !== key.right) {
338
+ return false;
339
+ }
340
+ // The class is part of identity, so a record that predates it, or that lost it, is a miss and
341
+ // the broker answers. `null` is a stated absence and is accepted; `undefined` is a record shape
342
+ // this version does not recognize.
343
+ if (tradingClass !== null && (typeof tradingClass !== "string" || tradingClass.length === 0)) {
344
+ return false;
345
+ }
346
+ // A record whose symbol does not carry its own root is not the identity it claims to be.
347
+ const parsed = parseOsiOptionSymbol(symbol);
348
+ return parsed !== null && parsed.root === (tradingClass ?? underlying);
349
+ }
275
350
  /** A typed HTTP failure from the IBKR transport. */
276
351
  export class IbkrHttpError extends Error {
277
352
  status;
@@ -337,6 +412,7 @@ export class IbkrClient {
337
412
  initPromise;
338
413
  accountIdPromise;
339
414
  optionDiscovery = new Map();
415
+ optionDefinitionCache;
340
416
  optionContractResolution = new Map();
341
417
  priceHistoryContractResolution = new Map();
342
418
  derivativeDiscovery = new Map();
@@ -350,6 +426,7 @@ export class IbkrClient {
350
426
  this.raw = new RawIbkrClientCtor(config);
351
427
  this.onPriceHistoryTelemetry = options.onPriceHistoryTelemetry ?? (() => undefined);
352
428
  this.onOptionDiscoveryTelemetry = options.onOptionDiscoveryTelemetry ?? (() => undefined);
429
+ this.optionDefinitionCache = options.optionDefinitionCache;
353
430
  this.onRequestTelemetry = options.onRequestTelemetry ?? (() => undefined);
354
431
  const schedulerOptions = options.requestScheduler;
355
432
  this.requestNow = schedulerOptions?.now ?? (() => this.now());
@@ -3089,11 +3166,17 @@ export class IbkrClient {
3089
3166
  async resolveQuoteContract(symbol) {
3090
3167
  const osi = parseOsiOptionSymbol(symbol);
3091
3168
  if (osi) {
3169
+ // The OSI root names the listing class, which is the underlying only for a single-class
3170
+ // name. It is passed as both: as the search root, which is all IBKR can be asked for here,
3171
+ // and as the class, so a month that lists more than one class resolves to the one this
3172
+ // symbol names instead of refusing. A caller holding a class-rooted symbol whose class IBKR
3173
+ // does not resolve as a root must quote by `brokerId` (the conid) instead.
3092
3174
  const option = await this.resolveOptionContract({
3093
- symbol: osi.underlying,
3175
+ symbol: osi.root,
3094
3176
  expiry: osi.expiry,
3095
3177
  strike: osi.strike,
3096
3178
  right: osi.right,
3179
+ tradingClass: osi.root,
3097
3180
  });
3098
3181
  return option
3099
3182
  ? {
@@ -3500,8 +3583,11 @@ export class IbkrClient {
3500
3583
  return null;
3501
3584
  return normalizeOptionContract({
3502
3585
  conid: raw.conid ?? conid,
3503
- // `symbol` is absent on this payload; IBKR names the underlying `ticker`/`undSym` here.
3504
- symbol: raw.symbol ?? raw.ticker ?? raw.undSym,
3586
+ // `symbol` is absent on this payload; IBKR names the underlying `undSym` and the listing
3587
+ // class `ticker` here. `ticker` is the class - `SPXW` where `undSym` is `SPX` - so it is the
3588
+ // trading class, and the underlying falls back to it for a single-listing name.
3589
+ symbol: raw.undSym ?? raw.symbol ?? raw.ticker,
3590
+ tradingClass: raw.tradingClass ?? raw.ticker,
3505
3591
  maturityDate: raw.expiry ?? raw.maturityDate,
3506
3592
  right: raw.putOrCall,
3507
3593
  strike: raw.strike,
@@ -3510,7 +3596,13 @@ export class IbkrClient {
3510
3596
  resolveOptionContract(input) {
3511
3597
  const underlying = input.symbol.trim().toUpperCase();
3512
3598
  const month = monthCode(input.expiry);
3513
- const key = [underlying, input.expiry, String(input.strike), input.right].join(":");
3599
+ const key = [
3600
+ underlying,
3601
+ input.expiry,
3602
+ String(input.strike),
3603
+ input.right,
3604
+ input.tradingClass?.trim().toUpperCase() ?? "*",
3605
+ ].join(":");
3514
3606
  let pending = this.optionContractResolution.get(key);
3515
3607
  if (!pending) {
3516
3608
  pending = this.loadExactOptionContract({ ...input, symbol: underlying }, month).then((contract) => {
@@ -3553,6 +3645,7 @@ export class IbkrClient {
3553
3645
  }
3554
3646
  const rawConid = raw["conid"];
3555
3647
  const rawSymbol = raw["symbol"];
3648
+ const rawTradingClass = raw["tradingClass"];
3556
3649
  const rawMaturityDate = raw["maturityDate"];
3557
3650
  const rawRight = raw["right"];
3558
3651
  const rawStrike = raw["strike"];
@@ -3562,6 +3655,7 @@ export class IbkrClient {
3562
3655
  contract = normalizeOptionContract({
3563
3656
  conid: typeof rawConid === "number" ? rawConid : undefined,
3564
3657
  symbol: typeof rawSymbol === "string" ? rawSymbol : underlying.symbol,
3658
+ tradingClass: typeof rawTradingClass === "string" ? rawTradingClass : undefined,
3565
3659
  maturityDate: typeof rawMaturityDate === "string" ? rawMaturityDate : undefined,
3566
3660
  right: typeof rawRight === "string" ? rawRight : undefined,
3567
3661
  strike: typeof rawStrike === "string" || typeof rawStrike === "number" ? rawStrike : undefined,
@@ -3576,10 +3670,20 @@ export class IbkrClient {
3576
3670
  malformed = true;
3577
3671
  continue;
3578
3672
  }
3579
- if (contract.underlying === input.symbol &&
3673
+ const requestedClass = input.tradingClass?.trim().toUpperCase();
3674
+ // A caller reaches this with either a plain underlying or an OSI root that names a class, so
3675
+ // both are accepted as the requested root. The class filter below is what keeps two listings
3676
+ // apart; without it, an underlying that lists two classes still refuses rather than guesses.
3677
+ const rootMatches = contract.underlying === input.symbol || contract.tradingClass === input.symbol;
3678
+ if (rootMatches &&
3580
3679
  contract.expiry === input.expiry &&
3581
3680
  contract.right === input.right &&
3582
- contract.strike === input.strike) {
3681
+ contract.strike === input.strike &&
3682
+ (requestedClass === undefined ||
3683
+ contract.tradingClass === requestedClass ||
3684
+ // A contract with no stated class answers a request for its own underlying root, which
3685
+ // is what a single-listing name asks for. It never answers for another class.
3686
+ (contract.tradingClass === null && contract.underlying === requestedClass))) {
3583
3687
  matches.push(contract);
3584
3688
  }
3585
3689
  }
@@ -3588,7 +3692,16 @@ export class IbkrClient {
3588
3692
  }
3589
3693
  const unique = [...new Map(matches.map((contract) => [contract.conid, contract])).values()];
3590
3694
  if (unique.length > 1) {
3591
- throw new Error(`IBKR returned ambiguous option definitions for ${input.symbol} ${input.expiry}`);
3695
+ // Two listing classes of one underlying are two products, and the caller must say which one
3696
+ // it means. Two contracts inside one class are a collision the client cannot resolve; both
3697
+ // stay a refusal rather than a guess.
3698
+ const classes = [
3699
+ ...new Set(unique.map((contract) => contract.tradingClass ?? contract.underlying)),
3700
+ ].sort();
3701
+ const detail = classes.length > 1
3702
+ ? `; listing classes: ${classes.join(", ")}. Name one in 'tradingClass'.`
3703
+ : "";
3704
+ throw new Error(`IBKR returned ambiguous option definitions for ${input.symbol} ${input.expiry}${detail}`);
3592
3705
  }
3593
3706
  return unique[0] ?? null;
3594
3707
  }
@@ -3724,16 +3837,24 @@ export class IbkrClient {
3724
3837
  }
3725
3838
  discoverOptions(symbol, month, right, options = {}) {
3726
3839
  const normalized = symbol.trim().toUpperCase();
3840
+ const strikeRange = normalizeStrikeRange(options.strikeRange);
3841
+ const discoveryOptions = {
3842
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
3843
+ ...(strikeRange === undefined ? {} : { strikeRange }),
3844
+ };
3727
3845
  if (options.signal !== undefined) {
3728
- return this.loadOptionContracts(normalized, month, right, options.signal);
3846
+ return this.loadOptionContracts(normalized, month, right, discoveryOptions);
3729
3847
  }
3730
- const key = `${normalized}:${month}:${right ?? "*"}`;
3848
+ // The band belongs in the memo key. A narrowed result holds only part of the month, so it must
3849
+ // never answer a later request for a different band.
3850
+ const bandKey = strikeRangeKey(strikeRange);
3851
+ const key = `${normalized}:${month}:${right ?? "*"}:${bandKey}`;
3731
3852
  const cached = this.optionDiscovery.get(key);
3732
3853
  if (cached !== undefined)
3733
3854
  return cached;
3734
3855
  let discovery;
3735
3856
  if (right !== undefined) {
3736
- const complete = this.optionDiscovery.get(`${normalized}:${month}:*`);
3857
+ const complete = this.optionDiscovery.get(`${normalized}:${month}:*:${bandKey}`);
3737
3858
  if (complete !== undefined) {
3738
3859
  discovery = complete.then((result) => ({
3739
3860
  contracts: result.contracts.filter((contract) => contract.right === right),
@@ -3741,7 +3862,7 @@ export class IbkrClient {
3741
3862
  }));
3742
3863
  }
3743
3864
  }
3744
- discovery ??= this.loadOptionContracts(normalized, month, right);
3865
+ discovery ??= this.loadOptionContracts(normalized, month, right, discoveryOptions);
3745
3866
  const pending = discovery.catch((error) => {
3746
3867
  if (this.optionDiscovery.get(key) === pending)
3747
3868
  this.optionDiscovery.delete(key);
@@ -3767,7 +3888,9 @@ export class IbkrClient {
3767
3888
  throw new Error(`IBKR lost the selected underlying for ${symbol}`);
3768
3889
  return { conid: underlying.conid, symbol };
3769
3890
  }
3770
- async loadOptionContracts(symbol, month, right, callerSignal) {
3891
+ async loadOptionContracts(symbol, month, right, options = {}) {
3892
+ const callerSignal = options.signal;
3893
+ const strikeRange = normalizeStrikeRange(options.strikeRange);
3771
3894
  const operation = new AbortController();
3772
3895
  const abortFromCaller = () => {
3773
3896
  operation.abort(callerSignal?.reason);
@@ -3777,7 +3900,7 @@ export class IbkrClient {
3777
3900
  else
3778
3901
  callerSignal?.addEventListener("abort", abortFromCaller, { once: true });
3779
3902
  try {
3780
- const { underlying, requests } = await this.withSecdefPriming(async () => {
3903
+ const { underlying, requests, listedStrikeCount } = await this.withSecdefPriming(async () => {
3781
3904
  const searchStarted = this.requestNow();
3782
3905
  const selectedUnderlying = await this.loadOptionUnderlying(symbol, operation.signal);
3783
3906
  this.emitOptionDiscoveryTelemetry({
@@ -3786,29 +3909,19 @@ export class IbkrClient {
3786
3909
  month,
3787
3910
  right: right ?? null,
3788
3911
  durationMs: this.elapsedSince(searchStarted),
3789
- definitionRequestCount: 0,
3790
- snapshotBatchCount: 0,
3791
3912
  });
3792
3913
  const strikesStarted = this.requestNow();
3793
3914
  const strikes = await this.req({
3794
3915
  path: "iserver/secdef/strikes",
3795
3916
  params: { conid: String(selectedUnderlying.conid), sectype: "OPT", month },
3796
3917
  }, operation.signal);
3797
- this.emitOptionDiscoveryTelemetry({
3798
- phase: "STRIKES",
3799
- symbol,
3800
- month,
3801
- right: right ?? null,
3802
- durationMs: this.elapsedSince(strikesStarted),
3803
- definitionRequestCount: 0,
3804
- snapshotBatchCount: 0,
3805
- });
3918
+ const strikesDurationMs = this.elapsedSince(strikesStarted);
3806
3919
  const callStrikes = strikes.call ?? [];
3807
3920
  const putStrikes = strikes.put ?? [];
3808
3921
  if (callStrikes.length === 0 && putStrikes.length === 0) {
3809
3922
  throw new Error(`IBKR returned empty option strikes for ${symbol} ${month} after secdef/search priming`);
3810
3923
  }
3811
- const definitionRequests = [
3924
+ const listed = [
3812
3925
  ...(right === undefined || right === "C"
3813
3926
  ? callStrikes.map((strike) => ({ strike, right: "C" }))
3814
3927
  : []),
@@ -3816,12 +3929,48 @@ export class IbkrClient {
3816
3929
  ? putStrikes.map((strike) => ({ strike, right: "P" }))
3817
3930
  : []),
3818
3931
  ];
3819
- return { underlying: selectedUnderlying, requests: definitionRequests };
3932
+ // One security definition costs one paced request, so the band is applied here, before any
3933
+ // definition is requested. Applying it later would keep the full cost of the month.
3934
+ const selected = listed.filter(({ strike }) => strikeInRange(strike, strikeRange));
3935
+ this.emitOptionDiscoveryTelemetry({
3936
+ phase: "STRIKES",
3937
+ symbol,
3938
+ month,
3939
+ right: right ?? null,
3940
+ durationMs: strikesDurationMs,
3941
+ listedStrikeCount: listed.length,
3942
+ selectedStrikeCount: selected.length,
3943
+ });
3944
+ // A band that keeps nothing is a caller mistake, not an empty chain. Reporting it as an
3945
+ // empty result would look like an unlisted expiry.
3946
+ if (listed.length > 0 && selected.length === 0) {
3947
+ throw new Error(`IBKR option strike range [${String(strikeRange?.min ?? "-inf")}, ` +
3948
+ `${String(strikeRange?.max ?? "+inf")}] selected none of the ` +
3949
+ `${String(listed.length)} listed strikes for ${symbol} ${month}`);
3950
+ }
3951
+ return {
3952
+ underlying: selectedUnderlying,
3953
+ requests: selected,
3954
+ listedStrikeCount: listed.length,
3955
+ };
3820
3956
  });
3821
3957
  const definitionsStarted = this.requestNow();
3822
3958
  const contracts = [];
3823
3959
  let malformedDefinitionCount = 0;
3824
- for (const batch of chunks(requests, OPTION_SECDEF_INFO_BATCH_SIZE)) {
3960
+ const cached = await this.readCachedOptionDefinitions(underlying.conid, month, requests);
3961
+ const pending = [];
3962
+ let cachedDefinitionCount = 0;
3963
+ for (const [position, request] of requests.entries()) {
3964
+ const hit = cached[position];
3965
+ if (hit === undefined || hit === null) {
3966
+ pending.push(request);
3967
+ continue;
3968
+ }
3969
+ cachedDefinitionCount += 1;
3970
+ contracts.push(...hit);
3971
+ }
3972
+ const resolved = [];
3973
+ for (const batch of chunks(pending, OPTION_SECDEF_INFO_BATCH_SIZE)) {
3825
3974
  let responses;
3826
3975
  try {
3827
3976
  responses = await Promise.all(batch.map(({ strike, right: requestRight }) => this.req({
@@ -3841,13 +3990,17 @@ export class IbkrClient {
3841
3990
  operation.abort(error);
3842
3991
  throw error;
3843
3992
  }
3844
- for (const response of responses) {
3993
+ for (const [position, response] of responses.entries()) {
3845
3994
  if (!Array.isArray(response)) {
3846
3995
  throw new Error(`IBKR returned malformed option definitions for ${symbol} ${month}`);
3847
3996
  }
3997
+ const request = batch[position];
3998
+ const resolvedForRequest = [];
3999
+ let requestHadMalformedRecord = false;
3848
4000
  for (const raw of response) {
3849
4001
  if (!isUnknownRecord(raw)) {
3850
4002
  malformedDefinitionCount += 1;
4003
+ requestHadMalformedRecord = true;
3851
4004
  continue;
3852
4005
  }
3853
4006
  let contract;
@@ -3855,6 +4008,7 @@ export class IbkrClient {
3855
4008
  contract = normalizeOptionContract({
3856
4009
  conid: typeof raw["conid"] === "number" ? raw["conid"] : undefined,
3857
4010
  symbol: typeof raw["symbol"] === "string" ? raw["symbol"] : underlying.symbol,
4011
+ tradingClass: typeof raw["tradingClass"] === "string" ? raw["tradingClass"] : undefined,
3858
4012
  maturityDate: typeof raw["maturityDate"] === "string" ? raw["maturityDate"] : undefined,
3859
4013
  right: typeof raw["right"] === "string" ? raw["right"] : undefined,
3860
4014
  strike: typeof raw["strike"] === "string" || typeof raw["strike"] === "number"
@@ -3864,23 +4018,43 @@ export class IbkrClient {
3864
4018
  }
3865
4019
  catch {
3866
4020
  malformedDefinitionCount += 1;
4021
+ requestHadMalformedRecord = true;
3867
4022
  continue;
3868
4023
  }
3869
4024
  if (contract)
3870
- contracts.push(contract);
3871
- else
4025
+ resolvedForRequest.push(contract);
4026
+ else {
3872
4027
  malformedDefinitionCount += 1;
4028
+ requestHadMalformedRecord = true;
4029
+ }
4030
+ }
4031
+ contracts.push(...resolvedForRequest);
4032
+ // A partial answer is never stored. A later run must ask the broker again rather than
4033
+ // read a record that already lost contracts.
4034
+ if (request !== undefined && !requestHadMalformedRecord) {
4035
+ resolved.push({
4036
+ key: {
4037
+ underlyingConid: underlying.conid,
4038
+ month,
4039
+ right: request.right,
4040
+ strike: request.strike,
4041
+ },
4042
+ contracts: resolvedForRequest,
4043
+ });
3873
4044
  }
3874
4045
  }
3875
4046
  }
4047
+ await this.writeCachedOptionDefinitions(resolved);
3876
4048
  this.emitOptionDiscoveryTelemetry({
3877
4049
  phase: "DEFINITIONS",
3878
4050
  symbol,
3879
4051
  month,
3880
4052
  right: right ?? null,
3881
4053
  durationMs: this.elapsedSince(definitionsStarted),
3882
- definitionRequestCount: requests.length,
3883
- snapshotBatchCount: 0,
4054
+ definitionRequestCount: pending.length,
4055
+ listedStrikeCount,
4056
+ selectedStrikeCount: requests.length,
4057
+ cachedDefinitionCount,
3884
4058
  });
3885
4059
  if (requests.length === 0)
3886
4060
  return { contracts: [], malformedDefinitionCount: 0 };
@@ -3888,12 +4062,74 @@ export class IbkrClient {
3888
4062
  if (!unique.length) {
3889
4063
  throw new Error(`IBKR returned no usable option definitions for ${symbol} ${month} (${String(malformedDefinitionCount)} malformed)`);
3890
4064
  }
4065
+ // Two conids that reach one durable symbol are two contracts a consumer cannot tell apart.
4066
+ // This is the check that lets an unstated listing class fall back to the underlying root: a
4067
+ // fallback that would hide a collision is refused instead of returned.
4068
+ const collision = firstSymbolCollision(unique);
4069
+ if (collision !== null) {
4070
+ throw new Error(`IBKR returned two option contracts with one identity for ${symbol} ${month}: ` +
4071
+ `${collision.symbol} is conid ${String(collision.first.conid)} and ` +
4072
+ `${String(collision.second.conid)}. IBKR stated no listing class for at least one.`);
4073
+ }
3891
4074
  return { contracts: unique, malformedDefinitionCount };
3892
4075
  }
3893
4076
  finally {
3894
4077
  callerSignal?.removeEventListener("abort", abortFromCaller);
3895
4078
  }
3896
4079
  }
4080
+ /**
4081
+ * Read one definition for each request from the cache, aligned by index.
4082
+ *
4083
+ * Every failure mode collapses to "miss": no cache, a rejected read, a misaligned result, or a
4084
+ * record that is not a valid contract for the key it answers. The broker is then asked, so a bad
4085
+ * cache costs time and never changes an answer.
4086
+ */
4087
+ async readCachedOptionDefinitions(underlyingConid, month, requests) {
4088
+ const cache = this.optionDefinitionCache;
4089
+ if (cache === undefined || requests.length === 0)
4090
+ return requests.map(() => null);
4091
+ const keys = requests.map(({ strike, right }) => ({
4092
+ underlyingConid,
4093
+ month,
4094
+ right,
4095
+ strike,
4096
+ }));
4097
+ // The result is treated as untrusted data, not as the declared type. An implementation that
4098
+ // returns the wrong shape must degrade to a miss, never to a fabricated contract.
4099
+ let answer;
4100
+ try {
4101
+ answer = await cache.get(keys);
4102
+ }
4103
+ catch {
4104
+ return requests.map(() => null);
4105
+ }
4106
+ if (!Array.isArray(answer) || answer.length !== keys.length)
4107
+ return requests.map(() => null);
4108
+ const records = answer;
4109
+ return keys.map((key, position) => {
4110
+ const record = records[position];
4111
+ if (record === null || record === undefined)
4112
+ return null;
4113
+ if (!Array.isArray(record))
4114
+ return null;
4115
+ const cachedContracts = record;
4116
+ if (!cachedContracts.every((contract) => isCachedOptionContract(contract, key)))
4117
+ return null;
4118
+ return cachedContracts;
4119
+ });
4120
+ }
4121
+ /** Store resolved definitions. A store failure is never fatal: the identity came from IBKR. */
4122
+ async writeCachedOptionDefinitions(entries) {
4123
+ const cache = this.optionDefinitionCache;
4124
+ if (cache === undefined || entries.length === 0)
4125
+ return;
4126
+ try {
4127
+ await cache.set(entries);
4128
+ }
4129
+ catch {
4130
+ // A cache is an accelerator. Discovery already holds the broker answer.
4131
+ }
4132
+ }
3897
4133
  async fetchOptionChainSnapshot(contracts, malformedDefinitionCount, telemetry, signal) {
3898
4134
  const fields = [
3899
4135
  "bid",
@@ -4026,6 +4262,11 @@ export class IbkrClient {
4026
4262
  try {
4027
4263
  const result = this.onOptionDiscoveryTelemetry({
4028
4264
  event: "OPTION_DISCOVERY_PHASE",
4265
+ definitionRequestCount: 0,
4266
+ snapshotBatchCount: 0,
4267
+ listedStrikeCount: 0,
4268
+ selectedStrikeCount: 0,
4269
+ cachedDefinitionCount: 0,
4029
4270
  ...event,
4030
4271
  });
4031
4272
  void Promise.resolve(result).catch(() => undefined);
@@ -4612,6 +4853,14 @@ export class IbkrClient {
4612
4853
  const parsed = Number(cleaned);
4613
4854
  return Number.isFinite(parsed) ? parsed : undefined;
4614
4855
  }
4856
+ /**
4857
+ * Read IBKR's `_updated` snapshot field as an ISO instant.
4858
+ *
4859
+ * `_updated` is a last-change time, not an observation time: it moves only when IBKR's record
4860
+ * for the contract changes, and a repeated request does not move it. A quiet option on a live
4861
+ * feed can hold one value for several minutes while it reports the same bid and ask, so a
4862
+ * consumer must not read the age of this value as the age of its own reading.
4863
+ */
4615
4864
  snapshotTimestamp(snapshot) {
4616
4865
  const updated = this.snapshotNumber(snapshot, "_updated");
4617
4866
  if (updated === undefined)