@huskly/ibkr-client 1.0.0 → 1.1.1

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.
@@ -259,10 +259,12 @@ export class IbkrClient {
259
259
  requestNow;
260
260
  secdefPrimingTail = Promise.resolve();
261
261
  onPriceHistoryTelemetry;
262
+ onOptionDiscoveryTelemetry;
262
263
  onRequestTelemetry;
263
264
  constructor(config, options = {}) {
264
265
  this.raw = new RawIbkrClientCtor(config);
265
266
  this.onPriceHistoryTelemetry = options.onPriceHistoryTelemetry ?? (() => undefined);
267
+ this.onOptionDiscoveryTelemetry = options.onOptionDiscoveryTelemetry ?? (() => undefined);
266
268
  this.onRequestTelemetry = options.onRequestTelemetry ?? (() => undefined);
267
269
  const schedulerOptions = options.requestScheduler;
268
270
  this.requestNow = schedulerOptions?.now ?? (() => this.now());
@@ -3283,8 +3285,8 @@ export class IbkrClient {
3283
3285
  const contracts = [];
3284
3286
  for (let index = 0; index < months.length; index += OPTION_DISCOVERY_MONTH_CONCURRENCY) {
3285
3287
  const batch = months.slice(index, index + OPTION_DISCOVERY_MONTH_CONCURRENCY);
3286
- const batchContracts = (await Promise.all(batch.map((month) => this.discoverOptions(normalized, month)))).flat();
3287
- 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));
3288
3290
  }
3289
3291
  return [
3290
3292
  ...new Set(contracts
@@ -3293,17 +3295,38 @@ export class IbkrClient {
3293
3295
  ].sort();
3294
3296
  }
3295
3297
  /** Build one exact-expiry chain with canonical OSI symbols and required pricing/greeks. */
3296
- async getOptionChain(symbol, expiry) {
3297
- const contracts = (await this.discoverOptions(symbol, monthCode(expiry))).filter((contract) => contract.expiry === expiry);
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));
3298
3303
  if (!contracts.length) {
3299
3304
  throw new Error(`IBKR returned no option contracts for ${symbol} ${expiry}`);
3300
3305
  }
3301
- const quoted = await this.fetchOptionQuotes(contracts, { allowIncomplete: true });
3306
+ const quoted = await this.fetchOptionQuotes(contracts, {
3307
+ allowIncomplete: true,
3308
+ telemetry: { symbol: normalized, month, right: right ?? null },
3309
+ });
3302
3310
  if (!quoted.length) {
3303
3311
  throw new Error(`IBKR returned no usable option quotes for ${symbol} ${expiry}`);
3304
3312
  }
3305
3313
  return quoted;
3306
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
+ }
3307
3330
  /** Fetch one exact option quote; null means the contract is not listed. */
3308
3331
  async getOptionQuote(input) {
3309
3332
  const contract = await this.resolveOptionContract(input);
@@ -3556,14 +3579,21 @@ export class IbkrClient {
3556
3579
  }
3557
3580
  return result;
3558
3581
  }
3559
- discoverOptions(symbol, month) {
3582
+ discoverOptions(symbol, month, right) {
3560
3583
  const normalized = symbol.trim().toUpperCase();
3561
- const key = `${normalized}:${month}`;
3584
+ const key = `${normalized}:${month}:${right ?? "*"}`;
3562
3585
  let pending = this.optionDiscovery.get(key);
3563
- if (!pending) {
3564
- pending = this.loadOptionContracts(normalized, month);
3565
- this.optionDiscovery.set(key, 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
+ }
3566
3594
  }
3595
+ pending ??= this.loadOptionContracts(normalized, month, right);
3596
+ this.optionDiscovery.set(key, pending);
3567
3597
  return pending;
3568
3598
  }
3569
3599
  async loadOptionUnderlying(symbol) {
@@ -3611,97 +3641,211 @@ export class IbkrClient {
3611
3641
  throw new Error(`IBKR lost the selected underlying for ${symbol}`);
3612
3642
  return underlying;
3613
3643
  }
3614
- loadOptionContracts(symbol, month) {
3615
- return this.withSecdefPriming(() => this.loadOptionContractsPrimed(symbol, month));
3616
- }
3617
- async loadOptionContractsPrimed(symbol, month) {
3618
- const underlying = await this.loadOptionUnderlying(symbol);
3619
- const strikes = await this.req({
3620
- path: "iserver/secdef/strikes",
3621
- params: { conid: String(underlying.conid), sectype: "OPT", month },
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 };
3622
3685
  });
3623
- const requests = [
3624
- ...(strikes.call ?? []).map((strike) => ({ strike, right: "C" })),
3625
- ...(strikes.put ?? []).map((strike) => ({ strike, right: "P" })),
3626
- ];
3627
- if (!requests.length) {
3628
- throw new Error(`IBKR returned empty option strikes for ${symbol} ${month} after secdef/search priming`);
3629
- }
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 };
3630
3708
  const contracts = [];
3631
- for (const batch of chunks(requests, OPTION_SECDEF_INFO_BATCH_SIZE)) {
3632
- const responses = await Promise.all(batch.map(({ strike, right }) => this.req({
3633
- path: "iserver/secdef/info",
3634
- params: {
3635
- conid: String(underlying.conid),
3636
- sectype: "OPT",
3637
- month,
3638
- strike,
3639
- right,
3640
- },
3641
- })));
3642
- for (const raw of responses.flat()) {
3643
- const contract = normalizeOptionContract({
3644
- conid: raw.conid,
3645
- symbol: raw.symbol ?? underlying.symbol,
3646
- maturityDate: raw.maturityDate,
3647
- right: raw.right,
3648
- strike: raw.strike,
3649
- });
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
+ }
3650
3735
  if (contract)
3651
3736
  contracts.push(contract);
3737
+ else
3738
+ malformedDefinitionCount += 1;
3652
3739
  }
3653
3740
  }
3654
3741
  const unique = [...new Map(contracts.map((contract) => [contract.conid, contract])).values()];
3655
3742
  if (!unique.length) {
3656
- 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
+ }
3657
3765
  }
3658
- return unique;
3766
+ const diagnostics = {
3767
+ qualifiedCount: contracts.length,
3768
+ returnedCount: quotes.length,
3769
+ malformedDefinitionCount,
3770
+ missingFieldCounts,
3771
+ };
3772
+ return { quotes, diagnostics };
3659
3773
  }
3660
- async fetchOptionQuotes(contracts, options = {}) {
3661
- const { allowIncomplete = false } = options;
3662
- const result = [];
3663
- const skipped = [];
3664
- for (const batch of chunks(contracts, OPTION_MARKETDATA_BATCH_SIZE)) {
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) {
3665
3779
  const params = {
3666
3780
  conids: batch.map((contract) => contract.conid).join(","),
3667
3781
  fields: OPTION_QUOTE_FIELDS,
3668
3782
  };
3669
3783
  await this.req({ path: "iserver/marketdata/snapshot", params });
3670
3784
  await this.wait(2000);
3671
- const snapshots = await this.req({
3785
+ const response = await this.req({
3672
3786
  path: "iserver/marketdata/snapshot",
3673
3787
  params,
3674
3788
  });
3675
- const byConid = new Map(snapshots
3676
- .filter((snapshot) => snapshot.conid !== undefined)
3677
- .map((snapshot) => [snapshot.conid, snapshot]));
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]));
3678
3797
  for (const contract of batch) {
3679
3798
  const snapshot = byConid.get(contract.conid);
3680
- const bid = snapshot ? this.snapshotNumber(snapshot, "84") : undefined;
3681
- const ask = snapshot ? this.snapshotNumber(snapshot, "86") : undefined;
3682
- const delta = snapshot ? this.snapshotNumber(snapshot, "7308") : undefined;
3683
- if (bid === undefined || ask === undefined || delta === undefined) {
3684
- if (allowIncomplete) {
3685
- skipped.push(contract.symbol);
3686
- continue;
3687
- }
3688
- throw new Error(`IBKR returned incomplete option market data for ${contract.symbol} (bid/ask/delta required)`);
3689
- }
3690
- const volume = snapshot ? (this.snapshotVolume(snapshot) ?? null) : null;
3691
- const openInterest = snapshot ? (this.snapshotNumber(snapshot, "7638") ?? null) : null;
3692
- 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({
3693
3803
  ...contract,
3694
3804
  bid,
3695
3805
  ask,
3696
- mid: (bid + ask) / 2,
3697
- delta,
3698
- volume,
3699
- openInterest,
3700
- availability: normalizeDerivativeDataAvailability(snapshot?.["6509"]),
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,
3701
3813
  timestamp: snapshot ? this.snapshotTimestamp(snapshot) : null,
3702
3814
  });
3703
3815
  }
3704
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
+ }
3705
3849
  if (allowIncomplete && skipped.length && skipped.length === contracts.length) {
3706
3850
  const symbol = contracts[0]?.underlying ?? "unknown";
3707
3851
  const expiry = contracts[0]?.expiry ?? "unknown";
@@ -3728,6 +3872,21 @@ export class IbkrClient {
3728
3872
  const endDay = this.utcDayStart(input.endDate);
3729
3873
  return { start, end: endDay + DAY_MS - 1, days: (endDay - start) / DAY_MS + 1 };
3730
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
+ }
3731
3890
  async requestPriceHistory(contract, requestedSymbol, request) {
3732
3891
  this.onPriceHistoryTelemetry({
3733
3892
  event: "PRICE_HISTORY_REQUEST",
@@ -4370,6 +4529,7 @@ export class IbkrClient {
4370
4529
  return this.requestScheduler.schedule({
4371
4530
  endpoint: this.requestEndpoint(input.path),
4372
4531
  priority: this.requestPriority(input.path),
4532
+ secdefInfo: input.path === "iserver/secdef/info",
4373
4533
  retryable: retryPolicy !== "SINGLE_ATTEMPT",
4374
4534
  retryServerErrors: retryPolicy === "PRICE_HISTORY",
4375
4535
  }, async () => {