@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.
- package/README.md +88 -0
- package/dist/ibkr/ibkrApiTypes.d.ts +2 -0
- package/dist/ibkr/ibkrApiTypes.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.d.ts +35 -2
- package/dist/ibkr/ibkrClient.d.ts.map +1 -1
- package/dist/ibkr/ibkrClient.js +281 -32
- package/dist/ibkr/ibkrClient.js.map +1 -1
- package/dist/ibkr/optionContract.d.ts +13 -1
- package/dist/ibkr/optionContract.d.ts.map +1 -1
- package/dist/ibkr/optionContract.js +15 -3
- package/dist/ibkr/optionContract.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 +77 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/ibkr/ibkrClient.js
CHANGED
|
@@ -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.
|
|
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 `
|
|
3504
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
3846
|
+
return this.loadOptionContracts(normalized, month, right, discoveryOptions);
|
|
3729
3847
|
}
|
|
3730
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
3883
|
-
|
|
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)
|