diff --git a/README.md b/README.md index 8895a9d..065680a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,25 @@ All IBKR transport, OAuth, endpoint response normalization, instrument search, transactions, and order behavior lives in `@huskly/ibkr-client`. This project keeps only the shared CLI presentation adapter and Redis read cache. +### Derivative identity boundary + +The read-only derivative foundation uses a separate `DerivativeDiscoveryClient` +capability instead of widening the account-oriented `BrokerClient`. Its normalized +identity includes asset class (`OPT` or `FOP`), underlying, expiration, strike, +right, trading class, exchange, multiplier, and optional settlement/exercise +metadata. That identity distinguishes contracts such as NDX and NDXP even when +their underlying, expiration, and strike match. + +IBKR conids are converted by `IbkrDerivativeAdapter` into an opaque +`brokerReference`. This reference is useful for an immediate broker request but +is not durable identity and must not be persisted in place of the semantic fields. +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. + ## Requirements - Node.js >= 20.0.0 diff --git a/package.json b/package.json index 2af73d3..29dbb70 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "typescript-eslint": "^8.61.1" }, "dependencies": { - "@huskly/ibkr-client": "^0.7.0", + "@huskly/ibkr-client": "^0.8.0", "@huskly/schwab-client": "^0.6.0", "@modelcontextprotocol/sdk": "^1.29.0", "asciichart": "^1.5.25", diff --git a/src/derivatives/derivativeClient.test.ts b/src/derivatives/derivativeClient.test.ts new file mode 100644 index 0000000..3be73d5 --- /dev/null +++ b/src/derivatives/derivativeClient.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createDerivativeDiscoveryResolver } from "./derivativeClient.js"; +import type { DerivativeDiscoveryClient } from "./derivativeDiscovery.js"; + +const fakeClient: DerivativeDiscoveryClient = { + getExpiries: () => Promise.resolve([]), + getContracts: () => Promise.resolve([]), + resolveContract: () => Promise.reject(new Error("not used")), + getChain: () => Promise.resolve([]), +}; + +void test("capability resolver initializes IBKR once and rejects unsupported brokers explicitly", async () => { + let creates = 0; + const resolve = createDerivativeDiscoveryResolver({ + ibkr: () => { + creates += 1; + return Promise.resolve(fakeClient); + }, + }); + + const [first, second] = await Promise.all([resolve("ibkr"), resolve("ibkr")]); + assert.equal(first, fakeClient); + assert.equal(second, fakeClient); + assert.equal(creates, 1); + await assert.rejects(() => resolve("schwab"), /not implemented for broker 'schwab'/); +}); diff --git a/src/derivatives/derivativeClient.ts b/src/derivatives/derivativeClient.ts new file mode 100644 index 0000000..36cb311 --- /dev/null +++ b/src/derivatives/derivativeClient.ts @@ -0,0 +1,38 @@ +import { IbkrClient, buildOauthConfig } from "@huskly/ibkr-client"; +import type { BrokerName } from "#src/brokers/brokerClient.js"; +import type { DerivativeDiscoveryClient } from "./derivativeDiscovery.js"; +import { IbkrDerivativeAdapter } from "./ibkrDerivativeAdapter.js"; + +export interface DerivativeDiscoveryFactories { + ibkr(): Promise; +} + +/** Build a memoized capability resolver without widening the shared BrokerClient. */ +export function createDerivativeDiscoveryResolver(factories: DerivativeDiscoveryFactories) { + const clients = new Map>(); + return (broker: BrokerName): Promise => { + if (broker !== "ibkr") { + return Promise.reject( + new Error(`Derivative discovery is not implemented for broker '${broker}' yet.`) + ); + } + const existing = clients.get(broker); + if (existing) return existing; + const client = factories.ibkr(); + clients.set(broker, client); + return client; + }; +} + +const resolveDerivativeDiscovery = createDerivativeDiscoveryResolver({ + ibkr: async () => { + const client = new IbkrClient(buildOauthConfig()); + await client.init(); + return new IbkrDerivativeAdapter(client); + }, +}); + +/** Resolve a reusable broker-specific derivative discovery capability. */ +export function derivativeDiscoveryClient(broker: BrokerName): Promise { + return resolveDerivativeDiscovery(broker); +} diff --git a/src/derivatives/derivativeDiscovery.ts b/src/derivatives/derivativeDiscovery.ts new file mode 100644 index 0000000..9a31078 --- /dev/null +++ b/src/derivatives/derivativeDiscovery.ts @@ -0,0 +1,87 @@ +import type { BrokerName } from "#src/brokers/brokerClient.js"; + +export type DerivativeAssetClass = "OPT" | "FOP"; +export type DerivativeRight = "CALL" | "PUT"; +export type DerivativeDataAvailability = + "live" | "delayed" | "frozen" | "frozen-delayed" | "unavailable"; + +/** Semantic derivative identity that is safe to persist independently of a broker. */ +export interface DerivativeIdentity { + assetClass: DerivativeAssetClass; + underlying: string; + expiration: string; + strike: number; + right: DerivativeRight; + tradingClass: string; + exchange: string; + multiplier: number; + settlement?: string; + exerciseStyle?: string; +} + +/** + * Opaque, broker-local routing identity. It is suitable for an immediate broker call, + * but must not be persisted as the durable identity of a derivative contract. + */ +export interface DerivativeBrokerReference { + broker: BrokerName; + contractId: string; +} + +export interface DerivativeContract { + identity: DerivativeIdentity; + brokerReference?: DerivativeBrokerReference; +} + +export interface DerivativeExpiry { + assetClass: DerivativeAssetClass; + underlying: string; + expiration: string; + tradingClass: string; + exchange: string; + multiplier: number; +} + +export interface DerivativeContractRequest { + assetClass: DerivativeAssetClass; + underlying: string; + expiration: string; + exchange?: string; + tradingClass?: string; + right?: DerivativeRight; + strike?: number; +} + +export interface DerivativeExpiryRequest { + assetClass: DerivativeAssetClass; + underlying: string; + from: string; + to: string; + exchange?: string; + tradingClass?: string; + right?: DerivativeRight; +} + +export interface DerivativeQuote { + contract: DerivativeContract; + dataAvailability: DerivativeDataAvailability; + timestamp: string | null; + bid: number | null; + ask: number | null; + last: number | null; + mark: number | null; + delta: number | null; + impliedVolatility: number | null; + volume: number | null; + openInterest: number | null; +} + +/** Read-only derivative capability kept separate from the shared account client. */ +export interface DerivativeDiscoveryClient { + getExpiries(request: DerivativeExpiryRequest): Promise; + getContracts(request: DerivativeContractRequest): Promise; + resolveContract( + request: DerivativeContractRequest & { right: DerivativeRight; strike: number } + ): Promise; + getChain(request: DerivativeContractRequest): Promise; +} diff --git a/src/derivatives/ibkrDerivativeAdapter.test.ts b/src/derivatives/ibkrDerivativeAdapter.test.ts new file mode 100644 index 0000000..67abf27 --- /dev/null +++ b/src/derivatives/ibkrDerivativeAdapter.test.ts @@ -0,0 +1,200 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { IbkrDerivativeAdapter } from "./ibkrDerivativeAdapter.js"; +import type { IbkrDerivativeDiscoveryApi } from "./ibkrDerivativeAdapter.js"; + +function fakeApi(overrides: Partial = {}): IbkrDerivativeDiscoveryApi { + return { + getDerivativeExpiries: () => Promise.resolve([]), + getDerivativeContracts: () => Promise.resolve([]), + resolveDerivativeContract: () => + Promise.resolve({ + conid: 892767774, + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike: 26600, + right: "P", + tradingClass: "QN3", + exchange: "CME", + multiplier: 20, + }), + getDerivativeChain: () => Promise.resolve([]), + ...overrides, + }; +} + +void test("IBKR adapter separates semantic NQ identity from its broker-local conid", async () => { + let received: unknown; + const adapter = new IbkrDerivativeAdapter( + fakeApi({ + resolveDerivativeContract: (query) => { + received = query; + return Promise.resolve({ + conid: 892767774, + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike: 26600, + right: "P", + tradingClass: "QN3", + exchange: "CME", + multiplier: 20, + }); + }, + }) + ); + + const contract = await adapter.resolveContract({ + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike: 26600, + right: "PUT", + tradingClass: "QN3", + }); + + assert.deepEqual(received, { + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike: 26600, + right: "P", + tradingClass: "QN3", + }); + assert.deepEqual(contract.identity, { + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike: 26600, + right: "PUT", + tradingClass: "QN3", + exchange: "CME", + multiplier: 20, + }); + assert.deepEqual(contract.brokerReference, { + broker: "ibkr", + contractId: "892767774", + }); + assert.equal("conid" in contract.identity, false); +}); + +void test("IBKR adapter keeps NDX and NDXP semantic identities distinct", async () => { + const adapter = new IbkrDerivativeAdapter( + fakeApi({ + getDerivativeContracts: () => + Promise.resolve([ + { + conid: 851296101, + assetClass: "OPT", + underlying: "NDX", + expiration: "2026-08-20", + strike: 26600, + right: "P", + tradingClass: "NDX", + exchange: "SMART", + multiplier: 100, + }, + { + conid: 903244292, + assetClass: "OPT", + underlying: "NDX", + expiration: "2026-08-20", + strike: 26600, + right: "P", + tradingClass: "NDXP", + exchange: "SMART", + multiplier: 100, + }, + ]), + }) + ); + + const contracts = await adapter.getContracts({ + assetClass: "OPT", + underlying: "NDX", + expiration: "2026-08-20", + strike: 26600, + right: "PUT", + exchange: "SMART", + }); + assert.deepEqual( + contracts.map(({ identity }) => identity.tradingClass), + ["NDX", "NDXP"] + ); + assert.notDeepEqual(contracts[0]?.identity, contracts[1]?.identity); +}); + +void test("IBKR adapter preserves nullable market data and availability", async () => { + const adapter = new IbkrDerivativeAdapter( + fakeApi({ + getDerivativeChain: () => + Promise.resolve([ + { + contract: { + conid: 892767774, + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike: 26600, + right: "P", + tradingClass: "QN3", + exchange: "CME", + multiplier: 20, + }, + availability: "delayed", + timestamp: null, + bid: null, + ask: null, + last: 383, + mark: null, + delta: -0.257, + impliedVolatility: null, + volume: 237, + openInterest: 50, + }, + ]), + }) + ); + + const [quote] = await adapter.getChain({ + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + }); + assert.ok(quote); + assert.equal(quote.dataAvailability, "delayed"); + assert.equal(quote.bid, null); + assert.equal(quote.ask, null); + assert.equal(quote.delta, -0.257); +}); + +void test("IBKR adapter rejects malformed broker-local contract references", async () => { + const adapter = new IbkrDerivativeAdapter( + fakeApi({ + resolveDerivativeContract: () => + Promise.resolve({ + conid: Number.NaN, + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike: 26600, + right: "P", + tradingClass: "QN3", + exchange: "CME", + multiplier: 20, + }), + }) + ); + await assert.rejects( + () => + adapter.resolveContract({ + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + strike: 26600, + right: "PUT", + }), + /invalid broker-local derivative contract reference/ + ); +}); diff --git a/src/derivatives/ibkrDerivativeAdapter.ts b/src/derivatives/ibkrDerivativeAdapter.ts new file mode 100644 index 0000000..ef1b264 --- /dev/null +++ b/src/derivatives/ibkrDerivativeAdapter.ts @@ -0,0 +1,176 @@ +import type { + DerivativeAssetClass, + DerivativeContract, + DerivativeContractRequest, + DerivativeDataAvailability, + DerivativeDiscoveryClient, + DerivativeExpiry, + DerivativeExpiryRequest, + DerivativeQuote, + DerivativeRight, +} from "./derivativeDiscovery.js"; + +type IbkrOptionRight = "C" | "P"; + +interface IbkrDerivativeExpiry { + assetClass: DerivativeAssetClass; + underlying: string; + expiration: string; + tradingClass: string; + exchange: string; + multiplier: number; +} + +interface IbkrDerivativeContract extends IbkrDerivativeExpiry { + conid: number; + strike: number; + right: IbkrOptionRight; + settlement?: string; + exerciseStyle?: string; +} + +interface IbkrDerivativeQuote { + contract: IbkrDerivativeContract; + availability: DerivativeDataAvailability; + timestamp: string | null; + bid: number | null; + ask: number | null; + last: number | null; + mark: number | null; + delta: number | null; + impliedVolatility: number | null; + volume: number | null; + openInterest: number | null; +} + +interface IbkrContractQuery { + assetClass: DerivativeAssetClass; + underlying: string; + expiration: string; + exchange?: string; + tradingClass?: string; + right?: IbkrOptionRight; + strike?: number; +} + +interface IbkrExpiryQuery { + assetClass: DerivativeAssetClass; + underlying: string; + from: string; + to: string; + exchange?: string; + tradingClass?: string; + right?: IbkrOptionRight; +} + +/** Structural boundary implemented by @huskly/ibkr-client's read-only capability. */ +export interface IbkrDerivativeDiscoveryApi { + getDerivativeExpiries(query: IbkrExpiryQuery): Promise; + getDerivativeContracts(query: IbkrContractQuery): Promise; + resolveDerivativeContract( + query: IbkrContractQuery & { right: IbkrOptionRight; strike: number } + ): Promise; + getDerivativeChain(query: IbkrContractQuery): Promise; +} + +function toIbkrRight(right: DerivativeRight): IbkrOptionRight { + return right === "CALL" ? "C" : "P"; +} + +function fromIbkrRight(right: IbkrOptionRight): DerivativeRight { + return right === "C" ? "CALL" : "PUT"; +} + +function contractQuery(request: DerivativeContractRequest): IbkrContractQuery { + return { + assetClass: request.assetClass, + underlying: request.underlying, + expiration: request.expiration, + ...(request.exchange !== undefined ? { exchange: request.exchange } : {}), + ...(request.tradingClass !== undefined ? { tradingClass: request.tradingClass } : {}), + ...(request.right !== undefined ? { right: toIbkrRight(request.right) } : {}), + ...(request.strike !== undefined ? { strike: request.strike } : {}), + }; +} + +function expiryQuery(request: DerivativeExpiryRequest): IbkrExpiryQuery { + return { + assetClass: request.assetClass, + underlying: request.underlying, + from: request.from, + to: request.to, + ...(request.exchange !== undefined ? { exchange: request.exchange } : {}), + ...(request.tradingClass !== undefined ? { tradingClass: request.tradingClass } : {}), + ...(request.right !== undefined ? { right: toIbkrRight(request.right) } : {}), + }; +} + +function normalizeExpiry(expiry: IbkrDerivativeExpiry): DerivativeExpiry { + return { ...expiry }; +} + +function normalizeContract(contract: IbkrDerivativeContract): DerivativeContract { + if (!Number.isSafeInteger(contract.conid) || contract.conid <= 0) { + throw new Error("IBKR returned an invalid broker-local derivative contract reference"); + } + return { + identity: { + assetClass: contract.assetClass, + underlying: contract.underlying, + expiration: contract.expiration, + strike: contract.strike, + right: fromIbkrRight(contract.right), + tradingClass: contract.tradingClass, + exchange: contract.exchange, + multiplier: contract.multiplier, + ...(contract.settlement !== undefined ? { settlement: contract.settlement } : {}), + ...(contract.exerciseStyle !== undefined ? { exerciseStyle: contract.exerciseStyle } : {}), + }, + brokerReference: { broker: "ibkr", contractId: String(contract.conid) }, + }; +} + +function normalizeQuote(quote: IbkrDerivativeQuote): DerivativeQuote { + return { + contract: normalizeContract(quote.contract), + dataAvailability: quote.availability, + timestamp: quote.timestamp, + bid: quote.bid, + ask: quote.ask, + last: quote.last, + mark: quote.mark, + delta: quote.delta, + impliedVolatility: quote.impliedVolatility, + volume: quote.volume, + openInterest: quote.openInterest, + }; +} + +/** 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) {} + + async getExpiries(request: DerivativeExpiryRequest): Promise { + return (await this.client.getDerivativeExpiries(expiryQuery(request))).map(normalizeExpiry); + } + + async getContracts(request: DerivativeContractRequest): Promise { + return (await this.client.getDerivativeContracts(contractQuery(request))).map( + normalizeContract + ); + } + + async resolveContract( + request: DerivativeContractRequest & { right: DerivativeRight; strike: number } + ): Promise { + const query = contractQuery(request) as IbkrContractQuery & { + right: IbkrOptionRight; + strike: number; + }; + return normalizeContract(await this.client.resolveDerivativeContract(query)); + } + + async getChain(request: DerivativeContractRequest): Promise { + return (await this.client.getDerivativeChain(contractQuery(request))).map(normalizeQuote); + } +} diff --git a/yarn.lock b/yarn.lock index edd566f..cd32b8d 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.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@huskly/ibkr-client/-/ibkr-client-0.7.0.tgz#754cc257ba79ffb7e312fc21f0fe785d37b376bd" - integrity sha512-UETYEdOOuAjww0orT4m67O0aNcGB4Nd4XrX9GhF1dyTCTPokxDHgFiVxVF57fBub5kLgtzt7+rwiwzXGR30rMw== +"@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== dependencies: dotenv "^17.2.3" ibkr-client "^1.0.4"