Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions src/derivatives/derivativeClient.test.ts
Original file line number Diff line number Diff line change
@@ -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'/);
});
38 changes: 38 additions & 0 deletions src/derivatives/derivativeClient.ts
Original file line number Diff line number Diff line change
@@ -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<DerivativeDiscoveryClient>;
}

/** Build a memoized capability resolver without widening the shared BrokerClient. */
export function createDerivativeDiscoveryResolver(factories: DerivativeDiscoveryFactories) {
const clients = new Map<BrokerName, Promise<DerivativeDiscoveryClient>>();
return (broker: BrokerName): Promise<DerivativeDiscoveryClient> => {
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<DerivativeDiscoveryClient> {
return resolveDerivativeDiscovery(broker);
}
87 changes: 87 additions & 0 deletions src/derivatives/derivativeDiscovery.ts
Original file line number Diff line number Diff line change
@@ -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<DerivativeExpiry[]>;
getContracts(request: DerivativeContractRequest): Promise<DerivativeContract[]>;
resolveContract(
request: DerivativeContractRequest & { right: DerivativeRight; strike: number }
): Promise<DerivativeContract>;
getChain(request: DerivativeContractRequest): Promise<DerivativeQuote[]>;
}
200 changes: 200 additions & 0 deletions src/derivatives/ibkrDerivativeAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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/
);
});
Loading