@huskly/ibkr-client 1.1.0 → 1.2.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.
@@ -55,7 +55,6 @@ const QUOTE_FIELDS = [
55
55
  "6509", // Market data availability
56
56
  "7762", // Unformatted volume
57
57
  ].join(",");
58
- const OPTION_DISCOVERY_MONTH_CONCURRENCY = 1;
59
58
  const OPTION_SECDEF_INFO_BATCH_SIZE = 8;
60
59
  const OPTION_MARKETDATA_BATCH_SIZE = 100;
61
60
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -3279,14 +3278,13 @@ export class IbkrClient {
3279
3278
  };
3280
3279
  }
3281
3280
  /** Discover every listed weekly/monthly expiry in the requested calendar range. */
3282
- async getOptionExpiries(symbol, right, fromDate, toDate) {
3281
+ async getOptionExpiries(symbol, right, fromDate, toDate, options = {}) {
3283
3282
  const normalized = symbol.trim().toUpperCase();
3284
3283
  const months = monthCodes(fromDate, toDate);
3285
3284
  const contracts = [];
3286
- for (let index = 0; index < months.length; index += OPTION_DISCOVERY_MONTH_CONCURRENCY) {
3287
- const batch = months.slice(index, index + OPTION_DISCOVERY_MONTH_CONCURRENCY);
3288
- const batchContracts = await Promise.all(batch.map((month) => this.discoverOptions(normalized, month, right)));
3289
- contracts.push(...batchContracts.flatMap((result) => result.contracts));
3285
+ for (const month of months) {
3286
+ const result = await this.discoverOptions(normalized, month, right, options);
3287
+ contracts.push(...result.contracts);
3290
3288
  }
3291
3289
  return [
3292
3290
  ...new Set(contracts
@@ -3295,10 +3293,10 @@ export class IbkrClient {
3295
3293
  ].sort();
3296
3294
  }
3297
3295
  /** Build one exact-expiry chain with canonical OSI symbols and required pricing/greeks. */
3298
- async getOptionChain(symbol, expiry, right) {
3296
+ async getOptionChain(symbol, expiry, right, options = {}) {
3299
3297
  const month = monthCode(expiry);
3300
3298
  const normalized = symbol.trim().toUpperCase();
3301
- const discovery = await this.discoverOptions(normalized, month, right);
3299
+ const discovery = await this.discoverOptions(normalized, month, right, options);
3302
3300
  const contracts = discovery.contracts.filter((contract) => contract.expiry === expiry && (right === undefined || contract.right === right));
3303
3301
  if (!contracts.length) {
3304
3302
  throw new Error(`IBKR returned no option contracts for ${symbol} ${expiry}`);
@@ -3306,6 +3304,7 @@ export class IbkrClient {
3306
3304
  const quoted = await this.fetchOptionQuotes(contracts, {
3307
3305
  allowIncomplete: true,
3308
3306
  telemetry: { symbol: normalized, month, right: right ?? null },
3307
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
3309
3308
  });
3310
3309
  if (!quoted.length) {
3311
3310
  throw new Error(`IBKR returned no usable option quotes for ${symbol} ${expiry}`);
@@ -3313,19 +3312,15 @@ export class IbkrClient {
3313
3312
  return quoted;
3314
3313
  }
3315
3314
  /** Return every qualified contract for one exact expiry and side without hiding sparse data. */
3316
- async getOptionChainSnapshot(symbol, expiry, right) {
3315
+ async getOptionChainSnapshot(symbol, expiry, right, options = {}) {
3317
3316
  const month = monthCode(expiry);
3318
3317
  const normalized = symbol.trim().toUpperCase();
3319
- const discovery = await this.discoverOptions(normalized, month, right);
3318
+ const discovery = await this.discoverOptions(normalized, month, right, options);
3320
3319
  const contracts = discovery.contracts.filter((contract) => contract.expiry === expiry);
3321
3320
  if (!contracts.length) {
3322
3321
  throw new Error(`IBKR returned no ${right} option contracts for ${symbol} ${expiry}`);
3323
3322
  }
3324
- return this.fetchOptionChainSnapshot(contracts, discovery.malformedDefinitionCount, {
3325
- symbol: normalized,
3326
- month,
3327
- right,
3328
- });
3323
+ return this.fetchOptionChainSnapshot(contracts, discovery.malformedDefinitionCount, { symbol: normalized, month, right }, options.signal);
3329
3324
  }
3330
3325
  /** Fetch one exact option quote; null means the contract is not listed. */
3331
3326
  async getOptionQuote(input) {
@@ -3579,30 +3574,41 @@ export class IbkrClient {
3579
3574
  }
3580
3575
  return result;
3581
3576
  }
3582
- discoverOptions(symbol, month, right) {
3577
+ discoverOptions(symbol, month, right, options = {}) {
3583
3578
  const normalized = symbol.trim().toUpperCase();
3579
+ if (options.signal !== undefined) {
3580
+ return this.loadOptionContracts(normalized, month, right, options.signal);
3581
+ }
3584
3582
  const key = `${normalized}:${month}:${right ?? "*"}`;
3585
- let pending = this.optionDiscovery.get(key);
3586
- if (!pending && right !== undefined) {
3583
+ const cached = this.optionDiscovery.get(key);
3584
+ if (cached !== undefined)
3585
+ return cached;
3586
+ let discovery;
3587
+ if (right !== undefined) {
3587
3588
  const complete = this.optionDiscovery.get(`${normalized}:${month}:*`);
3588
3589
  if (complete !== undefined) {
3589
- pending = complete.then((result) => ({
3590
+ discovery = complete.then((result) => ({
3590
3591
  contracts: result.contracts.filter((contract) => contract.right === right),
3591
3592
  malformedDefinitionCount: result.malformedDefinitionCount,
3592
3593
  }));
3593
3594
  }
3594
3595
  }
3595
- pending ??= this.loadOptionContracts(normalized, month, right);
3596
+ discovery ??= this.loadOptionContracts(normalized, month, right);
3597
+ const pending = discovery.catch((error) => {
3598
+ if (this.optionDiscovery.get(key) === pending)
3599
+ this.optionDiscovery.delete(key);
3600
+ throw error;
3601
+ });
3596
3602
  this.optionDiscovery.set(key, pending);
3597
3603
  return pending;
3598
3604
  }
3599
- async loadOptionUnderlying(symbol) {
3605
+ async loadOptionUnderlying(symbol, signal) {
3600
3606
  // This search is load-bearing: IBKR silently returns empty definitions unless the current
3601
3607
  // session has first searched the underlying.
3602
3608
  const search = this.parseSecdefSearchResponse(await this.req({
3603
3609
  path: "iserver/secdef/search",
3604
3610
  params: { symbol },
3605
- }));
3611
+ }, signal));
3606
3612
  const candidates = search.flatMap((item) => {
3607
3613
  if (!isUnknownRecord(item))
3608
3614
  return [];
@@ -3641,110 +3647,134 @@ export class IbkrClient {
3641
3647
  throw new Error(`IBKR lost the selected underlying for ${symbol}`);
3642
3648
  return underlying;
3643
3649
  }
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 },
3650
+ async loadOptionContracts(symbol, month, right, callerSignal) {
3651
+ const operation = new AbortController();
3652
+ const abortFromCaller = () => {
3653
+ operation.abort(callerSignal?.reason);
3654
+ };
3655
+ if (callerSignal?.aborted)
3656
+ abortFromCaller();
3657
+ else
3658
+ callerSignal?.addEventListener("abort", abortFromCaller, { once: true });
3659
+ try {
3660
+ const { underlying, requests } = await this.withSecdefPriming(async () => {
3661
+ const searchStarted = this.requestNow();
3662
+ const selectedUnderlying = await this.loadOptionUnderlying(symbol, operation.signal);
3663
+ this.emitOptionDiscoveryTelemetry({
3664
+ phase: "SEARCH",
3665
+ symbol,
3666
+ month,
3667
+ right: right ?? null,
3668
+ durationMs: this.elapsedSince(searchStarted),
3669
+ definitionRequestCount: 0,
3670
+ snapshotBatchCount: 0,
3671
+ });
3672
+ const strikesStarted = this.requestNow();
3673
+ const strikes = await this.req({
3674
+ path: "iserver/secdef/strikes",
3675
+ params: { conid: String(selectedUnderlying.conid), sectype: "OPT", month },
3676
+ }, operation.signal);
3677
+ this.emitOptionDiscoveryTelemetry({
3678
+ phase: "STRIKES",
3679
+ symbol,
3680
+ month,
3681
+ right: right ?? null,
3682
+ durationMs: this.elapsedSince(strikesStarted),
3683
+ definitionRequestCount: 0,
3684
+ snapshotBatchCount: 0,
3685
+ });
3686
+ const callStrikes = strikes.call ?? [];
3687
+ const putStrikes = strikes.put ?? [];
3688
+ if (callStrikes.length === 0 && putStrikes.length === 0) {
3689
+ throw new Error(`IBKR returned empty option strikes for ${symbol} ${month} after secdef/search priming`);
3690
+ }
3691
+ const definitionRequests = [
3692
+ ...(right === undefined || right === "C"
3693
+ ? callStrikes.map((strike) => ({ strike, right: "C" }))
3694
+ : []),
3695
+ ...(right === undefined || right === "P"
3696
+ ? putStrikes.map((strike) => ({ strike, right: "P" }))
3697
+ : []),
3698
+ ];
3699
+ return { underlying: selectedUnderlying, requests: definitionRequests };
3661
3700
  });
3701
+ const definitionsStarted = this.requestNow();
3702
+ const contracts = [];
3703
+ let malformedDefinitionCount = 0;
3704
+ for (const batch of chunks(requests, OPTION_SECDEF_INFO_BATCH_SIZE)) {
3705
+ let responses;
3706
+ try {
3707
+ responses = await Promise.all(batch.map(({ strike, right: requestRight }) => this.req({
3708
+ path: "iserver/secdef/info",
3709
+ params: {
3710
+ conid: String(underlying.conid),
3711
+ sectype: "OPT",
3712
+ month,
3713
+ strike,
3714
+ right: requestRight,
3715
+ },
3716
+ }, operation.signal, (error) => {
3717
+ operation.abort(error);
3718
+ })));
3719
+ }
3720
+ catch (error) {
3721
+ operation.abort(error);
3722
+ throw error;
3723
+ }
3724
+ for (const response of responses) {
3725
+ if (!Array.isArray(response)) {
3726
+ throw new Error(`IBKR returned malformed option definitions for ${symbol} ${month}`);
3727
+ }
3728
+ for (const raw of response) {
3729
+ if (!isUnknownRecord(raw)) {
3730
+ malformedDefinitionCount += 1;
3731
+ continue;
3732
+ }
3733
+ let contract;
3734
+ try {
3735
+ contract = normalizeOptionContract({
3736
+ conid: typeof raw["conid"] === "number" ? raw["conid"] : undefined,
3737
+ symbol: typeof raw["symbol"] === "string" ? raw["symbol"] : underlying.symbol,
3738
+ maturityDate: typeof raw["maturityDate"] === "string" ? raw["maturityDate"] : undefined,
3739
+ right: typeof raw["right"] === "string" ? raw["right"] : undefined,
3740
+ strike: typeof raw["strike"] === "string" || typeof raw["strike"] === "number"
3741
+ ? raw["strike"]
3742
+ : undefined,
3743
+ });
3744
+ }
3745
+ catch {
3746
+ malformedDefinitionCount += 1;
3747
+ continue;
3748
+ }
3749
+ if (contract)
3750
+ contracts.push(contract);
3751
+ else
3752
+ malformedDefinitionCount += 1;
3753
+ }
3754
+ }
3755
+ }
3662
3756
  this.emitOptionDiscoveryTelemetry({
3663
- phase: "STRIKES",
3757
+ phase: "DEFINITIONS",
3664
3758
  symbol,
3665
3759
  month,
3666
3760
  right: right ?? null,
3667
- durationMs: this.elapsedSince(strikesStarted),
3668
- definitionRequestCount: 0,
3761
+ durationMs: this.elapsedSince(definitionsStarted),
3762
+ definitionRequestCount: requests.length,
3669
3763
  snapshotBatchCount: 0,
3670
3764
  });
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 };
3685
- });
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 };
3708
- const contracts = [];
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
- }
3735
- if (contract)
3736
- contracts.push(contract);
3737
- else
3738
- malformedDefinitionCount += 1;
3765
+ if (requests.length === 0)
3766
+ return { contracts: [], malformedDefinitionCount: 0 };
3767
+ const unique = [...new Map(contracts.map((contract) => [contract.conid, contract])).values()];
3768
+ if (!unique.length) {
3769
+ throw new Error(`IBKR returned no usable option definitions for ${symbol} ${month} (${String(malformedDefinitionCount)} malformed)`);
3739
3770
  }
3771
+ return { contracts: unique, malformedDefinitionCount };
3740
3772
  }
3741
- const unique = [...new Map(contracts.map((contract) => [contract.conid, contract])).values()];
3742
- if (!unique.length) {
3743
- throw new Error(`IBKR returned no usable option definitions for ${symbol} ${month} (${String(malformedDefinitionCount)} malformed)`);
3773
+ finally {
3774
+ callerSignal?.removeEventListener("abort", abortFromCaller);
3744
3775
  }
3745
- return { contracts: unique, malformedDefinitionCount };
3746
3776
  }
3747
- async fetchOptionChainSnapshot(contracts, malformedDefinitionCount, telemetry) {
3777
+ async fetchOptionChainSnapshot(contracts, malformedDefinitionCount, telemetry, signal) {
3748
3778
  const fields = [
3749
3779
  "bid",
3750
3780
  "ask",
@@ -3756,7 +3786,7 @@ export class IbkrClient {
3756
3786
  "timestamp",
3757
3787
  ];
3758
3788
  const missingFieldCounts = Object.fromEntries(fields.map((field) => [field, 0]));
3759
- const quotes = await this.fetchNullableOptionQuotes(contracts, telemetry);
3789
+ const quotes = await this.fetchNullableOptionQuotes(contracts, telemetry, signal);
3760
3790
  for (const quote of quotes) {
3761
3791
  for (const field of fields) {
3762
3792
  if (quote[field] === null)
@@ -3771,7 +3801,7 @@ export class IbkrClient {
3771
3801
  };
3772
3802
  return { quotes, diagnostics };
3773
3803
  }
3774
- async fetchNullableOptionQuotes(contracts, telemetry) {
3804
+ async fetchNullableOptionQuotes(contracts, telemetry, signal) {
3775
3805
  const quotes = [];
3776
3806
  const batches = chunks(contracts, OPTION_MARKETDATA_BATCH_SIZE);
3777
3807
  const snapshotsStarted = this.requestNow();
@@ -3780,12 +3810,9 @@ export class IbkrClient {
3780
3810
  conids: batch.map((contract) => contract.conid).join(","),
3781
3811
  fields: OPTION_QUOTE_FIELDS,
3782
3812
  };
3783
- await this.req({ path: "iserver/marketdata/snapshot", params });
3813
+ await this.req({ path: "iserver/marketdata/snapshot", params }, signal);
3784
3814
  await this.wait(2000);
3785
- const response = await this.req({
3786
- path: "iserver/marketdata/snapshot",
3787
- params,
3788
- });
3815
+ const response = await this.req({ path: "iserver/marketdata/snapshot", params }, signal);
3789
3816
  if (!Array.isArray(response)) {
3790
3817
  throw new Error("IBKR returned malformed option market-data snapshots");
3791
3818
  }
@@ -3826,10 +3853,10 @@ export class IbkrClient {
3826
3853
  return quotes;
3827
3854
  }
3828
3855
  async fetchOptionQuotes(contracts, options = {}) {
3829
- const { allowIncomplete = false, telemetry } = options;
3856
+ const { allowIncomplete = false, telemetry, signal } = options;
3830
3857
  const result = [];
3831
3858
  const skipped = [];
3832
- for (const quote of await this.fetchNullableOptionQuotes(contracts, telemetry)) {
3859
+ for (const quote of await this.fetchNullableOptionQuotes(contracts, telemetry, signal)) {
3833
3860
  if (quote.bid === null || quote.ask === null || quote.delta === null) {
3834
3861
  if (allowIncomplete) {
3835
3862
  skipped.push(quote.symbol);
@@ -4516,8 +4543,8 @@ export class IbkrClient {
4516
4543
  throw this.normalizeHttpError(error);
4517
4544
  }
4518
4545
  }
4519
- req(input) {
4520
- return this.scheduledRequest(input, "SAFE_READ");
4546
+ req(input, signal, onTerminalFailure) {
4547
+ return this.scheduledRequest(input, "SAFE_READ", signal, onTerminalFailure);
4521
4548
  }
4522
4549
  historyRequest(input) {
4523
4550
  return this.scheduledRequest(input, "PRICE_HISTORY");
@@ -4525,13 +4552,15 @@ export class IbkrClient {
4525
4552
  singleAttemptRequest(input) {
4526
4553
  return this.scheduledRequest(input, "SINGLE_ATTEMPT");
4527
4554
  }
4528
- scheduledRequest(input, retryPolicy) {
4555
+ scheduledRequest(input, retryPolicy, signal, onTerminalFailure) {
4529
4556
  return this.requestScheduler.schedule({
4530
4557
  endpoint: this.requestEndpoint(input.path),
4531
4558
  priority: this.requestPriority(input.path),
4532
4559
  secdefInfo: input.path === "iserver/secdef/info",
4533
4560
  retryable: retryPolicy !== "SINGLE_ATTEMPT",
4534
4561
  retryServerErrors: retryPolicy === "PRICE_HISTORY",
4562
+ ...(signal === undefined ? {} : { signal }),
4563
+ ...(onTerminalFailure === undefined ? {} : { onTerminalFailure }),
4535
4564
  }, async () => {
4536
4565
  try {
4537
4566
  return await this.sendRequest(input);