From d57df09919d8b58975b7d7d4f22552153de0d81e Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Wed, 29 Jul 2026 12:20:50 -0700 Subject: [PATCH 1/2] feat: add derivative spread research --- README.md | 52 +++- src/cli/derivatives.ts | 268 +++++++++++++++++ src/cli/index.ts | 7 +- src/derivatives/derivativeClient.test.ts | 1 + src/derivatives/derivativeDiscovery.ts | 12 + src/derivatives/derivativeResearch.test.ts | 141 +++++++++ src/derivatives/derivativeResearch.ts | 196 +++++++++++++ src/derivatives/ibkrDerivativeAdapter.test.ts | 11 + src/derivatives/ibkrDerivativeAdapter.ts | 54 ++++ src/derivatives/verticalSpread.test.ts | 205 +++++++++++++ src/derivatives/verticalSpread.ts | 273 ++++++++++++++++++ 11 files changed, 1215 insertions(+), 5 deletions(-) create mode 100644 src/cli/derivatives.ts create mode 100644 src/derivatives/derivativeResearch.test.ts create mode 100644 src/derivatives/derivativeResearch.ts create mode 100644 src/derivatives/verticalSpread.test.ts create mode 100644 src/derivatives/verticalSpread.ts diff --git a/README.md b/README.md index 065680a..45842e3 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ implemented for that broker. | `vix` | ✓ | ✗ | | `expiries` | ✓ | ✗ | | `chain` | ✓ | ✗ | +| `option resolve` | ✗ | ✓ | +| `option chain` | ✗ | ✓ | +| `spread quote` | ✗ | ✓ | | `account` | ✓ | ✓ | | `user-preference` | ✓ | ✗ | | `positions` | ✓ | ✓ | @@ -53,7 +56,8 @@ implemented for that broker. | `repl` | ✓ | ✓ | IBKR currently supports the shared **`account`**, **`quote`**, **`search`**, -**`positions`**, **`transactions`**, **`orders`**, and **`repl`** commands. IBKR +**`positions`**, **`transactions`**, **`orders`**, and **`repl`** commands plus +read-only **`option resolve`**, **`option chain`**, and **`spread quote`** research. IBKR `search` supports symbol lookup via `symbol-search` / `search`; Schwab-specific regex, description, and fundamental projections report a clear error under `--broker ibkr`. All other broker commands (`chain`, `movers`, `place-order`, @@ -87,8 +91,10 @@ Market-data availability remains explicit (`live`, `delayed`, `frozen`, `frozen-delayed`, or `unavailable`), and missing prices, activity, or Greeks stay nullable. Discovery cannot reach preview or order-write endpoints. -User-facing `option` and `spread` commands are added by the next epic stage; the -existing `expiries` and `chain` commands remain Schwab-only until then. +The broker-neutral research service powers the user-facing `option` and `spread` +commands. Its current production adapter is IBKR; Schwab remains unsupported until +it can supply the exact trading-class/exchange identity without guessing. The +existing top-level `expiries` and `chain` commands remain unchanged and Schwab-only. ## Requirements @@ -262,6 +268,46 @@ Options: - `-a, --around ` - Filter strikes around this price (defaults to last price) - `-s, --strikes ` - Number of strikes to show above/below center (default: 10) +#### option - Exact Derivative Research + +Resolve one exact contract or quote a series without collapsing trading class, +exchange, asset class, or multiplier. Every exploratory command supports `--json`. + +```bash +huskly-cli option resolve NQ --broker ibkr \ + --asset FOP --expiry 2026-08-21 --class QN3 --exchange CME --json + +huskly-cli option chain NDX --broker ibkr \ + --asset OPT --expiry 2026-08-20 --class NDXP --exchange SMART --right PUT \ + --around 26600 --strikes 4 +``` + +For futures options, the reference quote is the actual underlying futures contract +reported by IBKR, not a guessed spot/index proxy. For example, the NQ `QN3` option +series can reference the September NQ future. + +#### spread quote - Vertical Spread Research + +Analyze call/put debit or credit verticals from exact individual-leg markets. +Amounts include contract multiplier and quantity. + +```bash +huskly-cli spread quote put-credit NQ --broker ibkr \ + --asset FOP --expiry 2026-08-21 --class QN3 --exchange CME \ + --long 26400 --short 26600 --quantity 1 --limit 39 --json + +huskly-cli spread quote put-credit NDX --broker ibkr \ + --asset OPT --expiry 2026-08-20 --class NDXP --exchange SMART \ + --long 26400 --short 26600 +``` + +The natural and midpoint scenarios are synthetic values computed from individual +leg quotes. They are not a broker combo NBBO, executable preview, or order quote. +The result includes maximum profit/loss, breakeven, return on risk, net delta, an +expiration payoff table, and an explicit settlement/residual-exposure warning. +Derivative research calls bypass the Redis read cache; each DTO retains the broker +quote timestamp and live/delayed/frozen/unavailable availability state. + ### Account Commands #### account - Account Summary diff --git a/src/cli/derivatives.ts b/src/cli/derivatives.ts new file mode 100644 index 0000000..a621749 --- /dev/null +++ b/src/cli/derivatives.ts @@ -0,0 +1,268 @@ +import { Command } from "commander"; +import type { BrokerName } from "#src/brokers/brokerClient.js"; +import { derivativeDiscoveryClient } from "#src/derivatives/derivativeClient.js"; +import type { + DerivativeAssetClass, + DerivativeRight, +} from "#src/derivatives/derivativeDiscovery.js"; +import { + DerivativeResearchService, + type OptionDiscoveryResearch, + type OptionChainResearch, + type VerticalSpreadResearch, +} from "#src/derivatives/derivativeResearch.js"; +import type { VerticalSpreadKind } from "#src/derivatives/verticalSpread.js"; + +interface SeriesOptions { + asset: string; + broker?: string; + class?: string; + exchange?: string; + expiry: string; + json?: boolean; +} + +interface ChainOptions extends SeriesOptions { + right?: string; + around?: string; + strikes: string; +} + +interface SpreadOptions extends SeriesOptions { + long: string; + short: string; + quantity: string; + limit?: string; +} + +interface ResolveOptions extends SeriesOptions { + right?: string; + strike?: string; +} + +function assetClass(value: string): DerivativeAssetClass { + const normalized = value.toUpperCase(); + if (normalized !== "OPT" && normalized !== "FOP") { + throw new Error(`Invalid derivative asset class '${value}'. Expected OPT or FOP.`); + } + return normalized; +} + +function right(value: string): DerivativeRight { + const normalized = value.toUpperCase(); + if (normalized !== "CALL" && normalized !== "PUT") { + throw new Error(`Invalid option right '${value}'. Expected CALL or PUT.`); + } + return normalized; +} + +function spreadKind(value: string): VerticalSpreadKind { + const normalized = value.toLowerCase(); + const kinds: VerticalSpreadKind[] = ["call-debit", "call-credit", "put-debit", "put-credit"]; + if (!kinds.includes(normalized as VerticalSpreadKind)) { + throw new Error(`Invalid vertical kind '${value}'. Expected one of: ${kinds.join(", ")}.`); + } + return normalized as VerticalSpreadKind; +} + +function number(value: string, name: string): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) throw new Error(`Invalid ${name} '${value}'.`); + return parsed; +} + +function integer(value: string, name: string): number { + const parsed = number(value, name); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`${name} must be a non-negative integer.`); + } + return parsed; +} + +function seriesOptions(command: Command): Command { + return command + .option("--broker ", "Broker to use: schwab or ibkr") + .option("--asset ", "Derivative asset class: OPT or FOP", "OPT") + .requiredOption("--expiry ", "Expiration date (YYYY-MM-DD)") + .option("--class ", "Exact broker trading class") + .option("--exchange ", "Exact listing/routing exchange") + .option("--json", "Emit a stable JSON DTO"); +} + +function seriesRequest(underlying: string, options: SeriesOptions) { + return { + assetClass: assetClass(options.asset), + underlying: underlying.toUpperCase(), + expiration: options.expiry, + ...(options.class !== undefined ? { tradingClass: options.class.toUpperCase() } : {}), + ...(options.exchange !== undefined ? { exchange: options.exchange.toUpperCase() } : {}), + }; +} + +function formatPrice(value: number | null): string { + return value === null ? "-" : String(value); +} + +export function renderOptionDiscovery(result: OptionDiscoveryResearch): string { + const reference = result.referenceQuote; + const lines = [ + reference === null + ? "Reference: unavailable" + : `Reference ${reference.symbol}: bid ${formatPrice(reference.bid)} ask ${formatPrice(reference.ask)} last ${formatPrice(reference.last)} mark ${formatPrice(reference.mark)} data ${reference.dataAvailability}`, + `Contracts: ${String(result.contracts.length)}`, + "EXPIRY STRIKE RIGHT ASSET CLASS EXCHANGE MULTIPLIER BROKER-REFERENCE", + ]; + for (const contract of result.contracts) { + const identity = contract.identity; + lines.push( + [ + identity.expiration, + identity.strike, + identity.right, + identity.assetClass, + identity.tradingClass, + identity.exchange, + identity.multiplier, + `${contract.brokerReference?.broker ?? "-"}:${contract.brokerReference?.contractId ?? "-"}`, + ].join(" ") + ); + } + lines.push("Broker references are opaque and non-durable."); + return lines.join("\n"); +} + +export function renderOptionChain(result: OptionChainResearch): string { + const reference = result.referenceQuote; + const lines = [ + reference === null + ? "Reference: unavailable" + : `Reference ${reference.symbol}: ${formatPrice(reference.mark ?? reference.last)} (${reference.dataAvailability})`, + `Center: ${formatPrice(result.center)} Contracts: ${String(result.quotes.length)}`, + "STRIKE RIGHT BID ASK MARK DELTA CLASS EXCHANGE MULTIPLIER DATA", + ]; + for (const quote of result.quotes) { + const identity = quote.contract.identity; + lines.push( + [ + identity.strike, + identity.right, + formatPrice(quote.bid), + formatPrice(quote.ask), + formatPrice(quote.mark), + formatPrice(quote.delta), + identity.tradingClass, + identity.exchange, + identity.multiplier, + quote.dataAvailability, + ].join(" ") + ); + } + return lines.join("\n"); +} + +export function renderVerticalSpread(result: VerticalSpreadResearch): string { + const { spread } = result; + const lines = [ + `${spread.kind} x${String(spread.quantity)} width ${String(spread.width)} multiplier ${String(spread.multiplier)}`, + `Reference ${result.referenceQuote.symbol}: ${formatPrice(result.referenceQuote.mark ?? result.referenceQuote.last)} (${result.referenceQuote.dataAvailability})`, + `Long ${String(spread.longLeg.quote.contract.identity.strike)} @ ${formatPrice(spread.longLeg.quote.bid)} x ${formatPrice(spread.longLeg.quote.ask)}`, + `Short ${String(spread.shortLeg.quote.contract.identity.strike)} @ ${formatPrice(spread.shortLeg.quote.bid)} x ${formatPrice(spread.shortLeg.quote.ask)}`, + ]; + for (const scenario of spread.scenarios) { + if (scenario.analysis === null) { + lines.push( + `${scenario.source}: ${String(scenario.price)} unavailable (${scenario.error ?? "invalid"})` + ); + continue; + } + lines.push( + `${scenario.source}: ${scenario.analysis.priceEffect.toLowerCase()} ${String(scenario.price)} max profit ${String(scenario.analysis.maximumProfit)} max loss ${String(scenario.analysis.maximumLoss)} breakeven ${String(scenario.analysis.breakeven)} return/risk ${String(scenario.analysis.returnOnRisk)} net delta ${formatPrice(scenario.analysis.netDelta)}` + ); + } + lines.push(result.pricingNotice, spread.settlementWarning); + return lines.join("\n"); +} + +function output(value: T, json: boolean | undefined, render: (result: T) => string): void { + console.log(json === true ? JSON.stringify(value, null, 2) : render(value)); +} + +async function service(broker: BrokerName): Promise { + return new DerivativeResearchService(await derivativeDiscoveryClient(broker)); +} + +/** Register broker-neutral derivative research commands without changing legacy chain commands. */ +export function addDerivativeCommands( + program: Command, + broker: (override?: string) => BrokerName +): void { + const option = new Command("option").description("Resolve and research exact derivative series"); + + seriesOptions( + option + .command("resolve") + .description("Resolve exact contracts in a derivative series") + .argument("", "Underlying symbol") + .option("--right ", "Filter to CALL or PUT") + .option("--strike ", "Filter to one exact strike") + ).action(async (underlying: string, options: ResolveOptions) => { + const result = await ( + await service(broker(options.broker)) + ).discover({ + ...seriesRequest(underlying, options), + ...(options.strike !== undefined ? { strike: number(options.strike, "strike") } : {}), + ...(options.right !== undefined ? { right: right(options.right) } : {}), + }); + output(result, options.json, renderOptionDiscovery); + }); + + seriesOptions( + option + .command("chain") + .description("Quote an exact derivative series") + .argument("", "Underlying symbol") + .option("--right ", "Filter to CALL or PUT") + .option("--around ", "Center strike; defaults to the reference market") + .option("--strikes ", "Strikes on each side of center", "10") + ).action(async (underlying: string, options: ChainOptions) => { + const result = await ( + await service(broker(options.broker)) + ).chain({ + ...seriesRequest(underlying, options), + ...(options.right !== undefined ? { right: right(options.right) } : {}), + ...(options.around !== undefined ? { around: number(options.around, "around strike") } : {}), + strikes: integer(options.strikes, "Strike count"), + }); + output(result, options.json, renderOptionChain); + }); + + program.addCommand(option); + + const spread = new Command("spread").description("Research multi-leg derivative spreads"); + seriesOptions( + spread + .command("quote") + .description("Analyze a two-leg vertical from individual leg markets") + .argument("", "call-debit, call-credit, put-debit, or put-credit") + .argument("", "Underlying symbol") + .requiredOption("--long ", "Long-leg strike") + .requiredOption("--short ", "Short-leg strike") + .option("--quantity ", "Number of spreads", "1") + .option("--limit ", "Optional user limit for an additional scenario") + ).action(async (kindValue: string, underlying: string, options: SpreadOptions) => { + const quantity = integer(options.quantity, "Quantity"); + if (quantity === 0) throw new Error("Quantity must be greater than zero."); + const result = await ( + await service(broker(options.broker)) + ).quoteVertical({ + ...seriesRequest(underlying, options), + kind: spreadKind(kindValue), + longStrike: number(options.long, "long strike"), + shortStrike: number(options.short, "short strike"), + quantity, + ...(options.limit !== undefined ? { limit: number(options.limit, "limit") } : {}), + }); + output(result, options.json, renderVerticalSpread); + }); + program.addCommand(spread); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index bff6a86..4fd76dc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,6 +20,7 @@ import { handleMovers } from "./movers.js"; import { disconnectCache } from "#src/cache.js"; import { resolveBroker, requireSchwab } from "./shared.js"; import type { BrokerName } from "#src/brokers/brokerClient.js"; +import { addDerivativeCommands } from "./derivatives.js"; const program = new Command(); @@ -30,8 +31,8 @@ program .option("--broker ", "Broker to use: schwab or ibkr", "schwab"); /** The broker selected via the global --broker flag (defaults to schwab). */ -function broker(): BrokerName { - return resolveBroker(program.opts<{ broker?: string }>().broker); +function broker(override?: string): BrokerName { + return resolveBroker(override ?? program.opts<{ broker?: string }>().broker); } /** Resolve the broker and assert the command is Schwab-only. */ @@ -171,6 +172,8 @@ program } ); +addDerivativeCommands(program, broker); + program .command("account") .description("Show account equity/net liquidation value") diff --git a/src/derivatives/derivativeClient.test.ts b/src/derivatives/derivativeClient.test.ts index 3be73d5..f6ba9c7 100644 --- a/src/derivatives/derivativeClient.test.ts +++ b/src/derivatives/derivativeClient.test.ts @@ -8,6 +8,7 @@ const fakeClient: DerivativeDiscoveryClient = { getContracts: () => Promise.resolve([]), resolveContract: () => Promise.reject(new Error("not used")), getChain: () => Promise.resolve([]), + getReferenceQuote: () => Promise.reject(new Error("not used")), }; void test("capability resolver initializes IBKR once and rejects unsupported brokers explicitly", async () => { diff --git a/src/derivatives/derivativeDiscovery.ts b/src/derivatives/derivativeDiscovery.ts index 9a31078..5904e9a 100644 --- a/src/derivatives/derivativeDiscovery.ts +++ b/src/derivatives/derivativeDiscovery.ts @@ -76,6 +76,17 @@ export interface DerivativeQuote { openInterest: number | null; } +export interface DerivativeReferenceQuote { + brokerReference: DerivativeBrokerReference; + symbol: string; + dataAvailability: DerivativeDataAvailability; + timestamp: string | null; + bid: number | null; + ask: number | null; + last: number | null; + mark: number | null; +} + /** Read-only derivative capability kept separate from the shared account client. */ export interface DerivativeDiscoveryClient { getExpiries(request: DerivativeExpiryRequest): Promise; @@ -84,4 +95,5 @@ export interface DerivativeDiscoveryClient { request: DerivativeContractRequest & { right: DerivativeRight; strike: number } ): Promise; getChain(request: DerivativeContractRequest): Promise; + getReferenceQuote(contract: DerivativeContract): Promise; } diff --git a/src/derivatives/derivativeResearch.test.ts b/src/derivatives/derivativeResearch.test.ts new file mode 100644 index 0000000..0e6f89d --- /dev/null +++ b/src/derivatives/derivativeResearch.test.ts @@ -0,0 +1,141 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { + DerivativeContract, + DerivativeDiscoveryClient, + DerivativeQuote, +} from "./derivativeDiscovery.js"; +import { DerivativeResearchService } from "./derivativeResearch.js"; + +function contract(strike: number): DerivativeContract { + return { + identity: { + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike, + right: "PUT", + tradingClass: "QN3", + exchange: "CME", + multiplier: 20, + }, + brokerReference: { broker: "ibkr", contractId: String(strike) }, + }; +} + +function quote(strike: number, bid: number, ask: number, delta: number): DerivativeQuote { + return { + contract: contract(strike), + dataAvailability: "live", + timestamp: null, + bid, + ask, + last: null, + mark: (bid + ask) / 2, + delta, + impliedVolatility: null, + volume: null, + openInterest: null, + }; +} + +function client(): DerivativeDiscoveryClient { + const quotes = [ + quote(26200, 250, 255, -0.18), + quote(26400, 291, 297, -0.229), + quote(26600, 330.5, 337.5, -0.257), + quote(26800, 380, 388, -0.31), + ]; + return { + getExpiries: () => Promise.resolve([]), + getContracts: () => Promise.resolve(quotes.map(({ contract: item }) => item)), + resolveContract: (request) => { + const resolved = quotes.find(({ contract: item }) => item.identity.strike === request.strike); + return resolved === undefined + ? Promise.reject(new Error("not found")) + : Promise.resolve(resolved.contract); + }, + getChain: (request) => + Promise.resolve( + request.strike === undefined + ? quotes + : quotes.filter(({ contract: item }) => item.identity.strike === request.strike) + ), + getReferenceQuote: () => + Promise.resolve({ + brokerReference: { broker: "ibkr", contractId: "770561204" }, + symbol: "NQ", + dataAvailability: "live", + timestamp: "2026-07-29T12:00:00.000Z", + bid: 27865, + ask: 27866.5, + last: 27865.5, + mark: 26500, + }), + }; +} + +void test("chain research filters around the true reference quote", async () => { + const service = new DerivativeResearchService(client()); + const result = await service.chain({ + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + right: "PUT", + strikes: 1, + }); + assert.equal(result.referenceQuote?.brokerReference.contractId, "770561204"); + assert.equal(result.center, 26500); + assert.deepEqual( + result.quotes.map(({ contract: item }) => item.identity.strike), + [26400, 26600, 26800] + ); +}); + +void test("vertical research resolves exact legs and preserves synthetic-price semantics", async () => { + const service = new DerivativeResearchService(client()); + const result = await service.quoteVertical({ + kind: "put-credit", + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + longStrike: 26400, + shortStrike: 26600, + quantity: 1, + tradingClass: "QN3", + exchange: "CME", + limit: 39, + }); + assert.equal(result.referenceQuote.symbol, "NQ"); + assert.equal(result.spread.scenarios[0]?.price, 33.5); + assert.equal(result.spread.scenarios[1]?.analysis?.maximumProfit, 800); + assert.equal(result.spread.scenarios[2]?.analysis?.maximumLoss, 3220); + assert.match(result.pricingNotice, /not a broker combo NBBO/); +}); + +void test("resolve returns exact identity with its true underlying reference quote", async () => { + const service = new DerivativeResearchService(client()); + const result = await service.resolve({ + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike: 26600, + right: "PUT", + }); + assert.equal(result.contract.identity.tradingClass, "QN3"); + assert.equal(result.referenceQuote.brokerReference.contractId, "770561204"); +}); + +void test("series discovery retains exact contract identities", async () => { + const service = new DerivativeResearchService(client()); + const result = await service.discover({ + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + tradingClass: "QN3", + right: "PUT", + }); + assert.equal(result.contracts.length, 4); + assert.equal(result.contracts[0]?.identity.multiplier, 20); + assert.equal(result.referenceQuote?.brokerReference.contractId, "770561204"); +}); diff --git a/src/derivatives/derivativeResearch.ts b/src/derivatives/derivativeResearch.ts new file mode 100644 index 0000000..923f479 --- /dev/null +++ b/src/derivatives/derivativeResearch.ts @@ -0,0 +1,196 @@ +import type { + DerivativeContract, + DerivativeContractRequest, + DerivativeDiscoveryClient, + DerivativeQuote, + DerivativeReferenceQuote, + DerivativeRight, +} from "./derivativeDiscovery.js"; +import { + buildVerticalSpread, + type VerticalSpreadKind, + type VerticalSpreadQuote, +} from "./verticalSpread.js"; + +export interface OptionResolution { + contract: DerivativeContract; + referenceQuote: DerivativeReferenceQuote; +} + +export interface OptionDiscoveryResearch { + contracts: DerivativeContract[]; + referenceQuote: DerivativeReferenceQuote | null; +} + +export interface OptionChainRequest extends DerivativeContractRequest { + around?: number; + strikes?: number; +} + +export interface OptionChainResearch { + referenceQuote: DerivativeReferenceQuote | null; + center: number | null; + quotes: DerivativeQuote[]; +} + +export interface VerticalSpreadResearchRequest { + kind: VerticalSpreadKind; + assetClass: DerivativeContractRequest["assetClass"]; + underlying: string; + expiration: string; + longStrike: number; + shortStrike: number; + quantity: number; + exchange?: string; + tradingClass?: string; + limit?: number; +} + +export interface VerticalSpreadResearch { + referenceQuote: DerivativeReferenceQuote; + spread: VerticalSpreadQuote; + pricingNotice: string; +} + +function rightForKind(kind: VerticalSpreadKind): DerivativeRight { + return kind.startsWith("call") ? "CALL" : "PUT"; +} + +function quoteCenter(reference: DerivativeReferenceQuote): number | null { + if (reference.mark !== null) return reference.mark; + if (reference.bid !== null && reference.ask !== null) return (reference.bid + reference.ask) / 2; + return reference.last; +} + +function filterAround( + quotes: DerivativeQuote[], + center: number | null, + strikes: number | undefined +): DerivativeQuote[] { + if (strikes === undefined) return quotes; + if (!Number.isSafeInteger(strikes) || strikes < 0) { + throw new Error("Strike count must be a non-negative integer"); + } + if (center === null) { + throw new Error("--strikes requires --around or a usable reference quote"); + } + const uniqueStrikes = [...new Set(quotes.map(({ contract }) => contract.identity.strike))].sort( + (left, right) => left - right + ); + let centerIndex = 0; + for (let index = 1; index < uniqueStrikes.length; index += 1) { + const candidate = uniqueStrikes[index]; + const selected = uniqueStrikes[centerIndex]; + if ( + candidate !== undefined && + selected !== undefined && + Math.abs(candidate - center) <= Math.abs(selected - center) + ) { + centerIndex = index; + } + } + const selected = new Set( + uniqueStrikes.slice( + Math.max(0, centerIndex - strikes), + Math.min(uniqueStrikes.length, centerIndex + strikes + 1) + ) + ); + return quotes.filter(({ contract }) => selected.has(contract.identity.strike)); +} + +function sameContract(left: DerivativeContract, right: DerivativeContract): boolean { + return ( + left.identity.assetClass === right.identity.assetClass && + left.identity.underlying === right.identity.underlying && + left.identity.expiration === right.identity.expiration && + left.identity.strike === right.identity.strike && + left.identity.right === right.identity.right && + left.identity.tradingClass === right.identity.tradingClass && + left.identity.exchange === right.identity.exchange && + left.identity.multiplier === right.identity.multiplier + ); +} + +/** Read-only option and spread research shared by CLI and MCP presentation layers. */ +export class DerivativeResearchService { + constructor(private readonly client: DerivativeDiscoveryClient) {} + + async discover(request: DerivativeContractRequest): Promise { + const contracts = await this.client.getContracts(request); + const first = contracts[0]; + return { + contracts, + referenceQuote: first === undefined ? null : await this.client.getReferenceQuote(first), + }; + } + + async resolve( + request: DerivativeContractRequest & { right: DerivativeRight; strike: number } + ): Promise { + const contract = await this.client.resolveContract(request); + return { + contract, + referenceQuote: await this.client.getReferenceQuote(contract), + }; + } + + async chain(request: OptionChainRequest): Promise { + const { around, strikes, ...contractRequest } = request; + const quotes = await this.client.getChain(contractRequest); + if (quotes.length === 0) { + return { referenceQuote: null, center: around ?? null, quotes: [] }; + } + const first = quotes[0]; + if (first === undefined) throw new Error("Derivative chain unexpectedly has no first quote"); + const referenceQuote = await this.client.getReferenceQuote(first.contract); + const center = around ?? quoteCenter(referenceQuote); + return { + referenceQuote, + center, + quotes: filterAround(quotes, center, strikes), + }; + } + + async quoteVertical(request: VerticalSpreadResearchRequest): Promise { + const right = rightForKind(request.kind); + const base = { + assetClass: request.assetClass, + underlying: request.underlying, + expiration: request.expiration, + right, + ...(request.exchange !== undefined ? { exchange: request.exchange } : {}), + ...(request.tradingClass !== undefined ? { tradingClass: request.tradingClass } : {}), + }; + const [longQuote, shortQuote] = await Promise.all([ + this.exactQuote({ ...base, strike: request.longStrike }), + this.exactQuote({ ...base, strike: request.shortStrike }), + ]); + const referenceQuote = await this.client.getReferenceQuote(longQuote.contract); + return { + referenceQuote, + spread: buildVerticalSpread({ + kind: request.kind, + quantity: request.quantity, + longQuote, + shortQuote, + ...(request.limit !== undefined ? { limit: request.limit } : {}), + }), + pricingNotice: + "Natural and midpoint prices are synthesized from individual leg markets; they are not a broker combo NBBO or executable preview.", + }; + } + + private async exactQuote( + request: DerivativeContractRequest & { right: DerivativeRight; strike: number } + ): Promise { + const contract = await this.client.resolveContract(request); + const quotes = await this.client.getChain(request); + const quote = quotes.find((candidate) => sameContract(candidate.contract, contract)); + if (quote === undefined) { + throw new Error( + `No market quote returned for ${request.underlying} ${request.expiration} ${String(request.strike)} ${request.right}` + ); + } + return quote; + } +} diff --git a/src/derivatives/ibkrDerivativeAdapter.test.ts b/src/derivatives/ibkrDerivativeAdapter.test.ts index 67abf27..400e4db 100644 --- a/src/derivatives/ibkrDerivativeAdapter.test.ts +++ b/src/derivatives/ibkrDerivativeAdapter.test.ts @@ -20,6 +20,17 @@ function fakeApi(overrides: Partial = {}): IbkrDeriv multiplier: 20, }), getDerivativeChain: () => Promise.resolve([]), + getDerivativeReferenceQuote: () => + Promise.resolve({ + conid: 770561204, + symbol: "NQ", + availability: "live", + timestamp: null, + bid: 27865, + ask: 27866.5, + last: 27865.5, + mark: 27864.25, + }), ...overrides, }; } diff --git a/src/derivatives/ibkrDerivativeAdapter.ts b/src/derivatives/ibkrDerivativeAdapter.ts index ef1b264..78afd5e 100644 --- a/src/derivatives/ibkrDerivativeAdapter.ts +++ b/src/derivatives/ibkrDerivativeAdapter.ts @@ -7,6 +7,7 @@ import type { DerivativeExpiry, DerivativeExpiryRequest, DerivativeQuote, + DerivativeReferenceQuote, DerivativeRight, } from "./derivativeDiscovery.js"; @@ -43,6 +44,17 @@ interface IbkrDerivativeQuote { openInterest: number | null; } +interface IbkrDerivativeReferenceQuote { + conid: number; + symbol: string; + availability: DerivativeDataAvailability; + timestamp: string | null; + bid: number | null; + ask: number | null; + last: number | null; + mark: number | null; +} + interface IbkrContractQuery { assetClass: DerivativeAssetClass; underlying: string; @@ -71,6 +83,9 @@ export interface IbkrDerivativeDiscoveryApi { query: IbkrContractQuery & { right: IbkrOptionRight; strike: number } ): Promise; getDerivativeChain(query: IbkrContractQuery): Promise; + getDerivativeReferenceQuote( + contract: IbkrDerivativeContract + ): Promise; } function toIbkrRight(right: DerivativeRight): IbkrOptionRight { @@ -146,6 +161,31 @@ function normalizeQuote(quote: IbkrDerivativeQuote): DerivativeQuote { }; } +function ibkrContract(contract: DerivativeContract): IbkrDerivativeContract { + const reference = contract.brokerReference; + const conid = Number(reference?.contractId); + if (reference?.broker !== "ibkr" || !Number.isSafeInteger(conid) || conid <= 0) { + throw new Error("Derivative contract does not contain a valid IBKR broker reference"); + } + return { + conid, + assetClass: contract.identity.assetClass, + underlying: contract.identity.underlying, + expiration: contract.identity.expiration, + strike: contract.identity.strike, + right: toIbkrRight(contract.identity.right), + tradingClass: contract.identity.tradingClass, + exchange: contract.identity.exchange, + multiplier: contract.identity.multiplier, + ...(contract.identity.settlement !== undefined + ? { settlement: contract.identity.settlement } + : {}), + ...(contract.identity.exerciseStyle !== undefined + ? { exerciseStyle: contract.identity.exerciseStyle } + : {}), + }; +} + /** Maps broker-local conids and C/P codes into the CLI's durable semantic model. */ export class IbkrDerivativeAdapter implements DerivativeDiscoveryClient { constructor(private readonly client: IbkrDerivativeDiscoveryApi) {} @@ -173,4 +213,18 @@ export class IbkrDerivativeAdapter implements DerivativeDiscoveryClient { async getChain(request: DerivativeContractRequest): Promise { return (await this.client.getDerivativeChain(contractQuery(request))).map(normalizeQuote); } + + async getReferenceQuote(contract: DerivativeContract): Promise { + const quote = await this.client.getDerivativeReferenceQuote(ibkrContract(contract)); + return { + brokerReference: { broker: "ibkr", contractId: String(quote.conid) }, + symbol: quote.symbol, + dataAvailability: quote.availability, + timestamp: quote.timestamp, + bid: quote.bid, + ask: quote.ask, + last: quote.last, + mark: quote.mark, + }; + } } diff --git a/src/derivatives/verticalSpread.test.ts b/src/derivatives/verticalSpread.test.ts new file mode 100644 index 0000000..fffca05 --- /dev/null +++ b/src/derivatives/verticalSpread.test.ts @@ -0,0 +1,205 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { DerivativeQuote, DerivativeRight } from "./derivativeDiscovery.js"; +import { buildVerticalSpread, type VerticalSpreadKind } from "./verticalSpread.js"; + +interface QuoteInput { + strike: number; + right: DerivativeRight; + bid: number | null; + ask: number | null; + delta?: number | null; + multiplier?: number; + tradingClass?: string; +} + +function quote(input: QuoteInput): DerivativeQuote { + return { + contract: { + identity: { + assetClass: input.multiplier === 100 ? "OPT" : "FOP", + underlying: input.multiplier === 100 ? "NDX" : "NQ", + expiration: "2026-08-21", + strike: input.strike, + right: input.right, + tradingClass: input.tradingClass ?? (input.multiplier === 100 ? "NDXP" : "QN3"), + exchange: input.multiplier === 100 ? "SMART" : "CME", + multiplier: input.multiplier ?? 20, + }, + brokerReference: { broker: "ibkr", contractId: String(input.strike) }, + }, + dataAvailability: "live", + timestamp: "2026-07-29T12:00:00.000Z", + bid: input.bid, + ask: input.ask, + last: null, + mark: null, + delta: input.delta ?? null, + impliedVolatility: null, + volume: null, + openInterest: null, + }; +} + +function analysis(kind: VerticalSpreadKind) { + const call = kind.startsWith("call"); + const credit = kind.endsWith("credit"); + const longStrike = call === credit ? 110 : 100; + const shortStrike = call === credit ? 100 : 110; + const spread = buildVerticalSpread({ + kind, + quantity: 2, + longQuote: quote({ + strike: longStrike, + right: call ? "CALL" : "PUT", + bid: credit ? 2 : 7, + ask: credit ? 3 : 8, + delta: call ? 0.3 : -0.3, + multiplier: 100, + }), + shortQuote: quote({ + strike: shortStrike, + right: call ? "CALL" : "PUT", + bid: credit ? 7 : 2, + ask: credit ? 8 : 3, + delta: call ? 0.6 : -0.6, + multiplier: 100, + }), + }); + const midpoint = spread.scenarios.find(({ source }) => source === "synthetic-midpoint"); + assert.ok(midpoint?.analysis); + return midpoint.analysis; +} + +void test("NQ put-credit analytics use the actual leg markets and futures multiplier", () => { + const spread = buildVerticalSpread({ + kind: "put-credit", + quantity: 1, + longQuote: quote({ strike: 26400, right: "PUT", bid: 291, ask: 297, delta: -0.229 }), + shortQuote: quote({ + strike: 26600, + right: "PUT", + bid: 330.5, + ask: 337.5, + delta: -0.257, + }), + limit: 39, + }); + + assert.equal(spread.multiplier, 20); + assert.equal(spread.width, 200); + assert.match(spread.settlementWarning, /residual futures position/); + const [natural, midpoint, limit] = spread.scenarios; + assert.ok(natural); + assert.ok(midpoint); + assert.ok(limit); + assert.equal(natural.price, 33.5); + assert.equal(midpoint.price, 40); + assert.ok(midpoint.analysis); + assert.deepEqual( + { + maximumProfit: midpoint.analysis.maximumProfit, + maximumLoss: midpoint.analysis.maximumLoss, + breakeven: midpoint.analysis.breakeven, + returnOnRisk: midpoint.analysis.returnOnRisk, + }, + { + maximumProfit: 800, + maximumLoss: 3200, + breakeven: 26560, + returnOnRisk: 0.25, + } + ); + assert.ok(midpoint.analysis.netDelta !== null); + assert.ok(Math.abs(midpoint.analysis.netDelta - 0.56) < 1e-12); + assert.equal(limit.analysis?.maximumProfit, 780); + assert.equal(limit.analysis.maximumLoss, 3220); + assert.equal(limit.analysis.breakeven, 26561); +}); + +void test("all vertical kinds use the correct risk and breakeven formulas", () => { + const cases: { kind: VerticalSpreadKind; breakeven: number }[] = [ + { kind: "call-debit", breakeven: 105 }, + { kind: "call-credit", breakeven: 105 }, + { kind: "put-debit", breakeven: 105 }, + { kind: "put-credit", breakeven: 105 }, + ]; + for (const { kind, breakeven } of cases) { + const result = analysis(kind); + assert.equal(result.price, 5, kind); + assert.equal(result.maximumProfit, 1000, kind); + assert.equal(result.maximumLoss, 1000, kind); + assert.equal(result.breakeven, breakeven, kind); + assert.equal(result.netDelta, kind.startsWith("call") ? -60 : 60, kind); + const bullish = kind === "call-debit" || kind === "put-credit"; + assert.deepEqual( + result.expirationPayoff.map(({ profitLoss }) => profitLoss), + bullish ? [-1000, -1000, 1000, 1000] : [1000, 1000, -1000, -1000], + kind + ); + } +}); + +void test("spread analytics preserve unknown delta and explain invalid user limits", () => { + const spread = buildVerticalSpread({ + kind: "call-debit", + quantity: 1, + longQuote: quote({ strike: 100, right: "CALL", bid: 5, ask: 6, delta: null }), + shortQuote: quote({ strike: 110, right: "CALL", bid: 2, ask: 3, delta: 0.2 }), + limit: 12, + }); + assert.equal(spread.scenarios[1]?.analysis?.netDelta, null); + assert.equal(spread.scenarios[2]?.analysis, null); + assert.match(spread.scenarios[2].error ?? "", /less than spread width 10/); +}); + +void test("spread analytics reject invalid legs and unusable markets", () => { + const validLong = quote({ strike: 100, right: "CALL", bid: 5, ask: 6 }); + const validShort = quote({ strike: 110, right: "CALL", bid: 2, ask: 3 }); + assert.throws( + () => + buildVerticalSpread({ + kind: "call-debit", + quantity: 0, + longQuote: validLong, + shortQuote: validShort, + }), + /positive integer/ + ); + assert.throws( + () => + buildVerticalSpread({ + kind: "call-credit", + quantity: 1, + longQuote: validLong, + shortQuote: validShort, + }), + /Invalid call-credit strikes/ + ); + assert.throws( + () => + buildVerticalSpread({ + kind: "call-debit", + quantity: 1, + longQuote: quote({ strike: 100, right: "CALL", bid: 5, ask: null }), + shortQuote: validShort, + }), + /usable bid and ask/ + ); + assert.throws( + () => + buildVerticalSpread({ + kind: "call-debit", + quantity: 1, + longQuote: validLong, + shortQuote: quote({ + strike: 110, + right: "CALL", + bid: 2, + ask: 3, + tradingClass: "NDX", + }), + }), + /differ on tradingClass/ + ); +}); diff --git a/src/derivatives/verticalSpread.ts b/src/derivatives/verticalSpread.ts new file mode 100644 index 0000000..cd07de9 --- /dev/null +++ b/src/derivatives/verticalSpread.ts @@ -0,0 +1,273 @@ +import type { DerivativeIdentity, DerivativeQuote } from "./derivativeDiscovery.js"; + +export type VerticalSpreadKind = "call-debit" | "call-credit" | "put-debit" | "put-credit"; +export type VerticalPriceSource = "synthetic-natural" | "synthetic-midpoint" | "user-limit"; + +export interface VerticalSpreadLeg { + side: "LONG" | "SHORT"; + quote: DerivativeQuote; +} + +export interface VerticalSpreadAnalysis { + source: VerticalPriceSource; + price: number; + priceEffect: "CREDIT" | "DEBIT"; + maximumProfit: number; + maximumLoss: number; + breakeven: number; + returnOnRisk: number; + netDelta: number | null; + expirationPayoff: { underlyingPrice: number; profitLoss: number }[]; +} + +export interface VerticalSpreadScenario { + source: VerticalPriceSource; + price: number; + analysis: VerticalSpreadAnalysis | null; + error?: string; +} + +export interface VerticalSpreadQuote { + kind: VerticalSpreadKind; + quantity: number; + multiplier: number; + width: number; + longLeg: VerticalSpreadLeg; + shortLeg: VerticalSpreadLeg; + scenarios: VerticalSpreadScenario[]; + settlementWarning: string; +} + +export interface BuildVerticalSpreadInput { + kind: VerticalSpreadKind; + quantity: number; + longQuote: DerivativeQuote; + shortQuote: DerivativeQuote; + limit?: number; +} + +function isCredit(kind: VerticalSpreadKind): boolean { + return kind.endsWith("credit"); +} + +function isCall(kind: VerticalSpreadKind): boolean { + return kind.startsWith("call"); +} + +function assertSameSeries( + longIdentity: DerivativeIdentity, + shortIdentity: DerivativeIdentity +): void { + const fields: (keyof DerivativeIdentity)[] = [ + "assetClass", + "underlying", + "expiration", + "right", + "tradingClass", + "exchange", + "multiplier", + ]; + for (const field of fields) { + if (longIdentity[field] !== shortIdentity[field]) { + throw new Error(`Vertical legs differ on ${field}`); + } + } +} + +function assertLegDirection( + kind: VerticalSpreadKind, + longStrike: number, + shortStrike: number +): void { + const valid = + kind === "call-debit" + ? longStrike < shortStrike + : kind === "call-credit" + ? shortStrike < longStrike + : kind === "put-debit" + ? longStrike > shortStrike + : shortStrike > longStrike; + if (!valid) { + throw new Error( + `Invalid ${kind} strikes: long ${String(longStrike)}, short ${String(shortStrike)}` + ); + } +} + +function intrinsic(right: "CALL" | "PUT", strike: number, underlyingPrice: number): number { + return right === "CALL" + ? Math.max(0, underlyingPrice - strike) + : Math.max(0, strike - underlyingPrice); +} + +function expirationPayoff( + kind: VerticalSpreadKind, + longStrike: number, + shortStrike: number, + price: number, + multiplier: number, + quantity: number +): { underlyingPrice: number; profitLoss: number }[] { + const width = Math.abs(longStrike - shortStrike); + const minimumStrike = Math.min(longStrike, shortStrike); + const maximumStrike = Math.max(longStrike, shortStrike); + const prices = [ + Math.max(0, minimumStrike - width), + minimumStrike, + maximumStrike, + maximumStrike + width, + ]; + const right = isCall(kind) ? "CALL" : "PUT"; + const premium = isCredit(kind) ? price : -price; + return prices.map((underlyingPrice) => ({ + underlyingPrice, + profitLoss: + (intrinsic(right, longStrike, underlyingPrice) - + intrinsic(right, shortStrike, underlyingPrice) + + premium) * + multiplier * + quantity, + })); +} + +function analyze( + kind: VerticalSpreadKind, + source: VerticalPriceSource, + price: number, + longQuote: DerivativeQuote, + shortQuote: DerivativeQuote, + quantity: number +): VerticalSpreadAnalysis { + const multiplier = longQuote.contract.identity.multiplier; + const longStrike = longQuote.contract.identity.strike; + const shortStrike = shortQuote.contract.identity.strike; + const width = Math.abs(longStrike - shortStrike); + if (!Number.isFinite(price) || price <= 0 || price >= width) { + throw new Error( + `${source} ${isCredit(kind) ? "credit" : "debit"} must be greater than 0 and less than spread width ${String(width)}` + ); + } + const maximumProfit = (isCredit(kind) ? price : width - price) * multiplier * quantity; + const maximumLoss = (isCredit(kind) ? width - price : price) * multiplier * quantity; + const breakeven = isCall(kind) + ? (isCredit(kind) ? shortStrike : longStrike) + price + : (isCredit(kind) ? shortStrike : longStrike) - price; + const netDelta = + longQuote.delta === null || shortQuote.delta === null + ? null + : (longQuote.delta - shortQuote.delta) * multiplier * quantity; + return { + source, + price, + priceEffect: isCredit(kind) ? "CREDIT" : "DEBIT", + maximumProfit, + maximumLoss, + breakeven, + returnOnRisk: maximumProfit / maximumLoss, + netDelta, + expirationPayoff: expirationPayoff(kind, longStrike, shortStrike, price, multiplier, quantity), + }; +} + +function scenario( + kind: VerticalSpreadKind, + source: VerticalPriceSource, + price: number, + longQuote: DerivativeQuote, + shortQuote: DerivativeQuote, + quantity: number +): VerticalSpreadScenario { + try { + return { + source, + price, + analysis: analyze(kind, source, price, longQuote, shortQuote, quantity), + }; + } catch (error) { + return { + source, + price, + analysis: null, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function settlementWarning(identity: DerivativeIdentity): string { + const metadata = [identity.settlement, identity.exerciseStyle].filter(Boolean).join(", "); + const details = metadata ? ` Broker metadata: ${metadata}.` : " Broker metadata is unavailable."; + const residual = + identity.assetClass === "FOP" + ? " Futures-option exercise can create a residual futures position." + : " Settlement timing can leave residual exercise or assignment exposure."; + return `${details}${residual} Verify settlement and exercise terms before trading.`.trim(); +} + +/** Construct and analyze a two-leg vertical from executable leg markets. */ +export function buildVerticalSpread(input: BuildVerticalSpreadInput): VerticalSpreadQuote { + if (!Number.isSafeInteger(input.quantity) || input.quantity <= 0) { + throw new Error("Vertical quantity must be a positive integer"); + } + const longIdentity = input.longQuote.contract.identity; + const shortIdentity = input.shortQuote.contract.identity; + assertSameSeries(longIdentity, shortIdentity); + const expectedRight = isCall(input.kind) ? "CALL" : "PUT"; + if (longIdentity.right !== expectedRight) { + throw new Error(`${input.kind} requires ${expectedRight} contracts`); + } + assertLegDirection(input.kind, longIdentity.strike, shortIdentity.strike); + if ( + input.longQuote.ask === null || + input.longQuote.bid === null || + input.shortQuote.ask === null || + input.shortQuote.bid === null + ) { + throw new Error("Both vertical legs require usable bid and ask prices"); + } + const longMid = (input.longQuote.bid + input.longQuote.ask) / 2; + const shortMid = (input.shortQuote.bid + input.shortQuote.ask) / 2; + const natural = isCredit(input.kind) + ? input.shortQuote.bid - input.longQuote.ask + : input.longQuote.ask - input.shortQuote.bid; + const midpoint = isCredit(input.kind) ? shortMid - longMid : longMid - shortMid; + const scenarios = [ + scenario( + input.kind, + "synthetic-natural", + natural, + input.longQuote, + input.shortQuote, + input.quantity + ), + scenario( + input.kind, + "synthetic-midpoint", + midpoint, + input.longQuote, + input.shortQuote, + input.quantity + ), + ...(input.limit === undefined + ? [] + : [ + scenario( + input.kind, + "user-limit", + input.limit, + input.longQuote, + input.shortQuote, + input.quantity + ), + ]), + ]; + return { + kind: input.kind, + quantity: input.quantity, + multiplier: longIdentity.multiplier, + width: Math.abs(longIdentity.strike - shortIdentity.strike), + longLeg: { side: "LONG", quote: input.longQuote }, + shortLeg: { side: "SHORT", quote: input.shortQuote }, + scenarios, + settlementWarning: settlementWarning(longIdentity), + }; +} From 1a9093578f230686179c1d65858df2d05e41db8c Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Wed, 29 Jul 2026 13:05:23 -0700 Subject: [PATCH 2/2] chore: use ibkr derivative reference release --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 29dbb70..8125b2a 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "typescript-eslint": "^8.61.1" }, "dependencies": { - "@huskly/ibkr-client": "^0.8.0", + "@huskly/ibkr-client": "^0.9.0", "@huskly/schwab-client": "^0.6.0", "@modelcontextprotocol/sdk": "^1.29.0", "asciichart": "^1.5.25", diff --git a/yarn.lock b/yarn.lock index cd32b8d..a598335 100644 --- a/yarn.lock +++ b/yarn.lock @@ -221,10 +221,10 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== -"@huskly/ibkr-client@^0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@huskly/ibkr-client/-/ibkr-client-0.8.0.tgz#6b99400222417ce510eefb3d0246784d21885257" - integrity sha512-RTbVL1xFoNBcA6eo5J/64cv3xXRo024HmIcyeN2k4Pz+5F9Wt1m4wnrIT72giiJfJwwxnydHaHrG66SNtIMidA== +"@huskly/ibkr-client@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@huskly/ibkr-client/-/ibkr-client-0.9.0.tgz#8b97aeef0350fb5182fbb540d2314959685fb407" + integrity sha512-zE5qNIxAjVd0HyL3mPjxy6OWwSV8pqmzKCBlhGjJl9z6jEgkVXJOyF5wj5a/c50yfeo9PvqDVzIwvCkFCx+hbg== dependencies: dotenv "^17.2.3" ibkr-client "^1.0.4"