From 84106eda8c114f3072e142add31b0a89d057088e Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Wed, 29 Jul 2026 12:31:15 -0700 Subject: [PATCH 1/2] feat: add derivative what-if previews --- README.md | 31 +++ src/cli/derivatives.ts | 139 ++++++++++++- src/derivatives/derivativeClient.ts | 9 + src/derivatives/derivativePreview.ts | 52 +++++ .../derivativePreviewService.test.ts | 121 +++++++++++ src/derivatives/derivativePreviewService.ts | 192 ++++++++++++++++++ src/derivatives/ibkrDerivativeAdapter.test.ts | 73 +++++++ src/derivatives/ibkrDerivativeAdapter.ts | 40 +++- 8 files changed, 655 insertions(+), 2 deletions(-) create mode 100644 src/derivatives/derivativePreview.ts create mode 100644 src/derivatives/derivativePreviewService.test.ts create mode 100644 src/derivatives/derivativePreviewService.ts diff --git a/README.md b/README.md index 45842e3..c568017 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ implemented for that broker. | `option resolve` | ✗ | ✓ | | `option chain` | ✗ | ✓ | | `spread quote` | ✗ | ✓ | +| `spread preview` | ✗ | ✓ | +| `broker doctor` | ✗ | ✓ | | `account` | ✓ | ✓ | | `user-preference` | ✓ | ✗ | | `positions` | ✓ | ✓ | @@ -308,6 +310,35 @@ 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. +#### spread preview - Explicit IBKR What-If + +Preview one atomic vertical without submitting it. An exact account is mandatory, +either through `--account` or `IBKR_ACCOUNT_ID`. User-facing credits and debits are +always positive; IBKR's signed combo-price encoding stays inside the broker adapter. + +```bash +huskly-cli spread preview put-credit NQ --broker ibkr \ + --account U1234567 --asset FOP --expiry 2026-08-21 --class QN3 \ + --exchange CME --short 26600 --long 26400 --quantity 1 --credit 39 --json +``` + +The output masks the account, identifies live versus paper, includes exact legs, +margin, commissions/fees, warnings and rejections, and states `submitted: false`. +Its preview ID binds the account/environment, exact contracts, quantity, limit, +TIF, session, timestamp, and normalized What-If result and expires after five minutes. +Unknown or incomplete What-If results fail closed. + +#### broker doctor - Trading Diagnostics + +```bash +huskly-cli broker doctor --broker ibkr --account U1234567 --trading --json +``` + +This command is read-only. It reports masked account/environment identity, +authentication and competing-session state, market-data access, and advisory asset +permissions. Permission metadata is diagnostic only; an explicit What-If response is +the authoritative pre-submission gate. + ### Account Commands #### account - Account Summary diff --git a/src/cli/derivatives.ts b/src/cli/derivatives.ts index a621749..5a10c95 100644 --- a/src/cli/derivatives.ts +++ b/src/cli/derivatives.ts @@ -1,6 +1,9 @@ import { Command } from "commander"; import type { BrokerName } from "#src/brokers/brokerClient.js"; -import { derivativeDiscoveryClient } from "#src/derivatives/derivativeClient.js"; +import { + derivativeDiscoveryClient, + derivativePreviewClient, +} from "#src/derivatives/derivativeClient.js"; import type { DerivativeAssetClass, DerivativeRight, @@ -12,6 +15,12 @@ import { type VerticalSpreadResearch, } from "#src/derivatives/derivativeResearch.js"; import type { VerticalSpreadKind } from "#src/derivatives/verticalSpread.js"; +import type { TradingDiagnostics } from "#src/derivatives/derivativePreview.js"; +import { + DerivativePreviewService, + maskAccountId, + type SpreadPreviewDto, +} from "#src/derivatives/derivativePreviewService.js"; interface SeriesOptions { asset: string; @@ -35,6 +44,14 @@ interface SpreadOptions extends SeriesOptions { limit?: string; } +interface PreviewOptions extends SpreadOptions { + account?: string; + credit?: string; + debit?: string; + session: string; + tif: string; +} + interface ResolveOptions extends SeriesOptions { right?: string; strike?: string; @@ -65,6 +82,28 @@ function spreadKind(value: string): VerticalSpreadKind { return normalized as VerticalSpreadKind; } +function accountId(value: string | undefined): string { + const account = value ?? process.env["IBKR_ACCOUNT_ID"]; + if (!account?.trim()) throw new Error("An exact --account or IBKR_ACCOUNT_ID is required."); + return account; +} + +function tif(value: string): "DAY" | "GTC" { + const normalized = value.toUpperCase(); + if (normalized !== "DAY" && normalized !== "GTC") { + throw new Error(`Invalid TIF '${value}'. Expected DAY or GTC.`); + } + return normalized; +} + +function session(value: string): "REGULAR" | "OVERNIGHT" { + const normalized = value.toUpperCase(); + if (normalized !== "REGULAR" && normalized !== "OVERNIGHT") { + throw new Error(`Invalid session '${value}'. Expected REGULAR or OVERNIGHT.`); + } + return normalized; +} + function number(value: string, name: string): number { const parsed = Number(value); if (!Number.isFinite(parsed)) throw new Error(`Invalid ${name} '${value}'.`); @@ -183,6 +222,32 @@ export function renderVerticalSpread(result: VerticalSpreadResearch): string { return lines.join("\n"); } +function renderTradingDiagnostics(result: TradingDiagnostics): string { + return [ + `Account: ${result.accountId} Environment: ${result.environment}`, + `Authenticated: ${String(result.authenticated)} Competing session: ${String(result.competingSession)}`, + `Selected account matches: ${String(result.selectedAccountId === result.accountId)}`, + `Market data: ${result.marketDataAvailable === null ? "unknown" : String(result.marketDataAvailable)}`, + `Advisory asset permissions: ${result.advisoryAssetPermissions.join(", ") || "unknown"}`, + "Permission metadata is diagnostic only; an explicit What-If is authoritative.", + ].join("\n"); +} + +function renderSpreadPreview(result: SpreadPreviewDto): string { + return [ + `Preview ${result.previewId}`, + `Account: ${result.account.maskedId} Environment: ${result.account.environment}`, + `Expires: ${result.expiresAt}`, + `${result.order.kind} x${String(result.order.quantity)} ${result.order.priceEffect.toLowerCase()} ${String(result.order.limit)}`, + `Initial margin change: ${formatPrice(result.whatIf.initialMargin?.change ?? null)}`, + `Maintenance margin change: ${formatPrice(result.whatIf.maintenanceMargin?.change ?? null)}`, + `Commission/fees: ${formatPrice(result.whatIf.commission)}`, + `Warnings: ${result.whatIf.warnings.join(" | ") || "none"}`, + `Rejections: ${result.whatIf.rejectionReasons.join(" | ") || "none"}`, + "NO ORDER WAS SUBMITTED.", + ].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)); } @@ -191,6 +256,14 @@ async function service(broker: BrokerName): Promise { return new DerivativeResearchService(await derivativeDiscoveryClient(broker)); } +async function previewService(broker: BrokerName): Promise { + const [discovery, preview] = await Promise.all([ + derivativeDiscoveryClient(broker), + derivativePreviewClient(broker), + ]); + return new DerivativePreviewService(discovery, preview); +} + /** Register broker-neutral derivative research commands without changing legacy chain commands. */ export function addDerivativeCommands( program: Command, @@ -264,5 +337,69 @@ export function addDerivativeCommands( }); output(result, options.json, renderVerticalSpread); }); + + seriesOptions( + spread + .command("preview") + .description("Run an explicit non-submitting vertical What-If") + .argument("", "call-debit, call-credit, put-debit, or put-credit") + .argument("", "Underlying symbol") + .requiredOption("--long ", "Long-leg strike") + .requiredOption("--short ", "Short-leg strike") + .option("--account ", "Exact account ID; defaults to IBKR_ACCOUNT_ID") + .option("--credit ", "Positive net credit") + .option("--debit ", "Positive net debit") + .option("--quantity ", "Number of spreads", "1") + .option("--tif ", "DAY or GTC", "DAY") + .option("--session ", "REGULAR or OVERNIGHT", "REGULAR") + ).action(async (kindValue: string, underlying: string, options: PreviewOptions) => { + if ((options.credit === undefined) === (options.debit === undefined)) { + throw new Error("Provide exactly one of --credit or --debit."); + } + const quantity = integer(options.quantity, "Quantity"); + if (quantity === 0) throw new Error("Quantity must be greater than zero."); + const priceEffect = options.credit !== undefined ? "CREDIT" : "DEBIT"; + const limitValue = options.credit ?? options.debit; + if (limitValue === undefined) throw new Error("A credit or debit is required."); + const result = await ( + await previewService(broker(options.broker)) + ).previewVertical({ + ...seriesRequest(underlying, options), + accountId: accountId(options.account), + kind: spreadKind(kindValue), + longStrike: number(options.long, "long strike"), + shortStrike: number(options.short, "short strike"), + quantity, + priceEffect, + limit: number(limitValue, priceEffect.toLowerCase()), + tif: tif(options.tif), + session: session(options.session), + }); + output(result, options.json, renderSpreadPreview); + }); program.addCommand(spread); + + const brokerCommand = new Command("broker").description("Broker diagnostics"); + brokerCommand + .command("doctor") + .description("Run read-only broker trading diagnostics") + .option("--broker ", "Broker to use: schwab or ibkr", "ibkr") + .option("--account ", "Exact account ID; defaults to IBKR_ACCOUNT_ID") + .option("--trading", "Include trading-session and advisory permission diagnostics") + .option("--json", "Emit a stable JSON DTO") + .action( + async (options: { broker: string; account?: string; trading?: boolean; json?: boolean }) => { + const result = await ( + await previewService(broker(options.broker)) + ).getTradingDiagnostics(accountId(options.account)); + const safeResult: TradingDiagnostics = { + ...result, + accountId: maskAccountId(result.accountId), + selectedAccountId: + result.selectedAccountId === null ? null : maskAccountId(result.selectedAccountId), + }; + output(safeResult, options.json, renderTradingDiagnostics); + } + ); + program.addCommand(brokerCommand); } diff --git a/src/derivatives/derivativeClient.ts b/src/derivatives/derivativeClient.ts index 36cb311..83d0393 100644 --- a/src/derivatives/derivativeClient.ts +++ b/src/derivatives/derivativeClient.ts @@ -1,6 +1,7 @@ import { IbkrClient, buildOauthConfig } from "@huskly/ibkr-client"; import type { BrokerName } from "#src/brokers/brokerClient.js"; import type { DerivativeDiscoveryClient } from "./derivativeDiscovery.js"; +import type { DerivativePreviewClient } from "./derivativePreview.js"; import { IbkrDerivativeAdapter } from "./ibkrDerivativeAdapter.js"; export interface DerivativeDiscoveryFactories { @@ -36,3 +37,11 @@ const resolveDerivativeDiscovery = createDerivativeDiscoveryResolver({ export function derivativeDiscoveryClient(broker: BrokerName): Promise { return resolveDerivativeDiscovery(broker); } + +/** Resolve the explicit What-If capability; unsupported brokers fail closed. */ +export async function derivativePreviewClient( + broker: BrokerName +): Promise { + return (await resolveDerivativeDiscovery(broker)) as DerivativeDiscoveryClient & + DerivativePreviewClient; +} diff --git a/src/derivatives/derivativePreview.ts b/src/derivatives/derivativePreview.ts new file mode 100644 index 0000000..2912a14 --- /dev/null +++ b/src/derivatives/derivativePreview.ts @@ -0,0 +1,52 @@ +import type { DerivativeContract } from "./derivativeDiscovery.js"; + +export type BrokerEnvironment = "live" | "paper"; + +export interface TradingDiagnostics { + accountId: string; + selectedAccountId: string | null; + environment: BrokerEnvironment; + authenticated: boolean; + competingSession: boolean; + marketDataAvailable: boolean | null; + advisoryAssetPermissions: string[]; +} + +export interface DerivativeComboPreviewRequest { + accountId: string; + legs: [ + { contract: DerivativeContract; ratio: 1 | -1 }, + { contract: DerivativeContract; ratio: 1 | -1 }, + ]; + quantity: number; + priceEffect: "CREDIT" | "DEBIT"; + limit: number; + tif: "DAY" | "GTC"; + session: "REGULAR" | "OVERNIGHT"; +} + +export interface MarginImpact { + current: number; + change: number; + after: number; +} + +export interface DerivativeComboPreviewResult { + accountId: string; + environment: BrokerEnvironment; + accepted: boolean; + submitted: false; + commission: number | null; + initialMargin: MarginImpact | null; + maintenanceMargin: MarginImpact | null; + warnings: string[]; + rejectionReasons: string[]; + advisoryAssetPermissions: string[]; +} + +export interface DerivativePreviewClient { + getTradingDiagnostics(accountId: string): Promise; + previewDerivativeCombo( + request: DerivativeComboPreviewRequest + ): Promise; +} diff --git a/src/derivatives/derivativePreviewService.test.ts b/src/derivatives/derivativePreviewService.test.ts new file mode 100644 index 0000000..2e32722 --- /dev/null +++ b/src/derivatives/derivativePreviewService.test.ts @@ -0,0 +1,121 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { DerivativeContract, DerivativeDiscoveryClient } from "./derivativeDiscovery.js"; +import type { DerivativePreviewClient } from "./derivativePreview.js"; +import { + DerivativePreviewService, + maskAccountId, + type PreviewVerticalRequest, +} from "./derivativePreviewService.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: strike === 26400 ? "892767804" : "892767774" }, + }; +} + +const discovery: DerivativeDiscoveryClient = { + getExpiries: () => Promise.resolve([]), + getContracts: () => Promise.resolve([]), + resolveContract: (request) => Promise.resolve(contract(request.strike)), + getChain: () => Promise.resolve([]), + getReferenceQuote: () => Promise.reject(new Error("not used")), +}; + +const preview: DerivativePreviewClient = { + getTradingDiagnostics: (accountId) => + Promise.resolve({ + accountId, + selectedAccountId: accountId, + environment: "paper", + authenticated: true, + competingSession: false, + marketDataAvailable: true, + advisoryAssetPermissions: ["STK"], + }), + previewDerivativeCombo: (request) => + Promise.resolve({ + accountId: request.accountId, + environment: "paper", + accepted: true, + submitted: false, + commission: 2.5, + initialMargin: { current: 10_000, change: 3220, after: 13_220 }, + maintenanceMargin: { current: 9000, change: 3000, after: 12_000 }, + warnings: [], + rejectionReasons: [], + advisoryAssetPermissions: ["STK"], + }), +}; + +function request(overrides: Partial = {}): PreviewVerticalRequest { + return { + accountId: "U1234567", + kind: "put-credit", + assetClass: "FOP", + underlying: "NQ", + expiration: "2026-08-21", + tradingClass: "QN3", + exchange: "CME", + longStrike: 26400, + shortStrike: 26600, + quantity: 1, + priceEffect: "CREDIT", + limit: 39, + tif: "DAY", + session: "REGULAR", + ...overrides, + }; +} + +void test("preview DTO masks the account and binds exact economic terms", async () => { + const now = new Date("2026-07-29T12:00:00.000Z"); + const service = new DerivativePreviewService(discovery, preview, () => now, 60_000); + const first = await service.previewVertical(request()); + const changed = await service.previewVertical(request({ limit: 38 })); + + assert.equal(first.account.maskedId, "U***567"); + assert.equal(first.submitted, false); + assert.equal(first.whatIf.initialMargin?.change, 3220); + assert.equal(first.order.legs[0].contract.identity.strike, 26400); + assert.notEqual(first.previewId, changed.previewId); + assert.equal( + service.validatePreview(first.previewId, { accountId: "U1234567", environment: "paper" }), + first + ); +}); + +void test("preview validation rejects account/environment mismatch and expiry", async () => { + let now = new Date("2026-07-29T12:00:00.000Z"); + const service = new DerivativePreviewService(discovery, preview, () => now, 60_000); + const result = await service.previewVertical(request()); + assert.throws( + () => service.validatePreview(result.previewId, { accountId: "U999", environment: "paper" }), + /account or environment/ + ); + now = new Date("2026-07-29T12:01:00.000Z"); + assert.throws( + () => + service.validatePreview(result.previewId, { + accountId: "U1234567", + environment: "paper", + }), + /expired/ + ); +}); + +void test("account masking covers short paper fixtures", () => { + assert.equal(maskAccountId("DU123456"), "D***456"); + assert.equal(maskAccountId("DU12"), "D***"); + assert.equal(maskAccountId("U1"), "U***"); +}); diff --git a/src/derivatives/derivativePreviewService.ts b/src/derivatives/derivativePreviewService.ts new file mode 100644 index 0000000..663ef7f --- /dev/null +++ b/src/derivatives/derivativePreviewService.ts @@ -0,0 +1,192 @@ +import { createHash } from "node:crypto"; +import type { + DerivativeContract, + DerivativeDiscoveryClient, + DerivativeRight, +} from "./derivativeDiscovery.js"; +import type { + BrokerEnvironment, + DerivativeComboPreviewResult, + DerivativePreviewClient, + TradingDiagnostics, +} from "./derivativePreview.js"; +import type { VerticalSpreadKind } from "./verticalSpread.js"; + +export interface PreviewVerticalRequest { + accountId: string; + kind: VerticalSpreadKind; + assetClass: "OPT" | "FOP"; + underlying: string; + expiration: string; + tradingClass?: string; + exchange?: string; + longStrike: number; + shortStrike: number; + quantity: number; + priceEffect: "CREDIT" | "DEBIT"; + limit: number; + tif: "DAY" | "GTC"; + session: "REGULAR" | "OVERNIGHT"; +} + +export interface SpreadPreviewDto { + previewId: string; + createdAt: string; + expiresAt: string; + account: { maskedId: string; environment: BrokerEnvironment }; + order: { + kind: VerticalSpreadKind; + legs: [ + { side: "LONG"; ratio: 1; contract: DerivativeContract }, + { side: "SHORT"; ratio: -1; contract: DerivativeContract }, + ]; + quantity: number; + priceEffect: "CREDIT" | "DEBIT"; + limit: number; + tif: "DAY" | "GTC"; + session: "REGULAR" | "OVERNIGHT"; + }; + whatIf: Omit; + submitted: false; +} + +interface StoredPreview { + dto: SpreadPreviewDto; + accountId: string; + environment: BrokerEnvironment; +} + +function rightForKind(kind: VerticalSpreadKind): DerivativeRight { + return kind.startsWith("call") ? "CALL" : "PUT"; +} + +export function maskAccountId(accountId: string): string { + if (accountId.length <= 4) return `${accountId[0] ?? "*"}***`; + return `${accountId[0] ?? "*"}***${accountId.slice(-3)}`; +} + +/** Short-lived, process-local preview registry reusable by CLI and MCP. */ +export class DerivativePreviewService { + private readonly previews = new Map(); + + constructor( + private readonly discovery: DerivativeDiscoveryClient, + private readonly preview: DerivativePreviewClient, + private readonly now: () => Date = () => new Date(), + private readonly ttlMs = 5 * 60 * 1000 + ) {} + + getTradingDiagnostics(accountId: string): Promise { + return this.preview.getTradingDiagnostics(accountId); + } + + async previewVertical(request: PreviewVerticalRequest): Promise { + if (request.priceEffect === "CREDIT" && !request.kind.endsWith("credit")) { + throw new Error(`${request.kind} requires a debit limit`); + } + if (request.priceEffect === "DEBIT" && !request.kind.endsWith("debit")) { + throw new Error(`${request.kind} requires a credit limit`); + } + const right = rightForKind(request.kind); + const base = { + assetClass: request.assetClass, + underlying: request.underlying, + expiration: request.expiration, + right, + ...(request.tradingClass !== undefined ? { tradingClass: request.tradingClass } : {}), + ...(request.exchange !== undefined ? { exchange: request.exchange } : {}), + }; + const [longContract, shortContract] = await Promise.all([ + this.discovery.resolveContract({ ...base, strike: request.longStrike }), + this.discovery.resolveContract({ ...base, strike: request.shortStrike }), + ]); + const result = await this.preview.previewDerivativeCombo({ + accountId: request.accountId, + legs: [ + { contract: longContract, ratio: 1 }, + { contract: shortContract, ratio: -1 }, + ], + quantity: request.quantity, + priceEffect: request.priceEffect, + limit: request.limit, + tif: request.tif, + session: request.session, + }); + if (result.accountId !== request.accountId) { + throw new Error("What-If account does not match the requested account"); + } + const createdAt = this.now(); + const expiresAt = new Date(createdAt.getTime() + this.ttlMs); + const material = { + broker: "ibkr", + accountId: request.accountId, + environment: result.environment, + legs: [ + { identity: longContract.identity, reference: longContract.brokerReference, ratio: 1 }, + { identity: shortContract.identity, reference: shortContract.brokerReference, ratio: -1 }, + ], + quantity: request.quantity, + priceEffect: request.priceEffect, + limit: request.limit, + tif: request.tif, + session: request.session, + createdAt: createdAt.toISOString(), + whatIf: result, + }; + const previewId = createHash("sha256").update(JSON.stringify(material)).digest("hex"); + const dto: SpreadPreviewDto = { + previewId, + createdAt: createdAt.toISOString(), + expiresAt: expiresAt.toISOString(), + account: { maskedId: maskAccountId(request.accountId), environment: result.environment }, + order: { + kind: request.kind, + legs: [ + { side: "LONG", ratio: 1, contract: longContract }, + { side: "SHORT", ratio: -1, contract: shortContract }, + ], + quantity: request.quantity, + priceEffect: request.priceEffect, + limit: request.limit, + tif: request.tif, + session: request.session, + }, + whatIf: { + accepted: result.accepted, + submitted: false, + commission: result.commission, + initialMargin: result.initialMargin, + maintenanceMargin: result.maintenanceMargin, + warnings: result.warnings, + rejectionReasons: result.rejectionReasons, + advisoryAssetPermissions: result.advisoryAssetPermissions, + }, + submitted: false, + }; + this.previews.set(previewId, { + dto, + accountId: request.accountId, + environment: result.environment, + }); + return dto; + } + + validatePreview( + previewId: string, + context: { accountId: string; environment: BrokerEnvironment } + ): SpreadPreviewDto { + const stored = this.previews.get(previewId); + if (stored === undefined) throw new Error("Unknown preview ID"); + if (this.now().getTime() >= new Date(stored.dto.expiresAt).getTime()) { + this.previews.delete(previewId); + throw new Error("Preview has expired"); + } + if (stored.accountId !== context.accountId || stored.environment !== context.environment) { + throw new Error("Preview account or environment does not match"); + } + if (!stored.dto.whatIf.accepted) { + throw new Error("Preview was rejected by broker What-If"); + } + return stored.dto; + } +} diff --git a/src/derivatives/ibkrDerivativeAdapter.test.ts b/src/derivatives/ibkrDerivativeAdapter.test.ts index 400e4db..33b7d25 100644 --- a/src/derivatives/ibkrDerivativeAdapter.test.ts +++ b/src/derivatives/ibkrDerivativeAdapter.test.ts @@ -31,6 +31,17 @@ function fakeApi(overrides: Partial = {}): IbkrDeriv last: 27865.5, mark: 27864.25, }), + getTradingDiagnostics: () => + Promise.resolve({ + accountId: "U123", + selectedAccountId: "U123", + environment: "paper", + authenticated: true, + competingSession: false, + marketDataAvailable: true, + advisoryAssetPermissions: ["OPT"], + }), + previewDerivativeCombo: () => Promise.reject(new Error("not used")), ...overrides, }; } @@ -209,3 +220,65 @@ void test("IBKR adapter rejects malformed broker-local contract references", asy /invalid broker-local derivative contract reference/ ); }); + +void test("IBKR adapter keeps combo conids behind the explicit preview boundary", async () => { + let received: unknown; + const adapter = new IbkrDerivativeAdapter( + fakeApi({ + previewDerivativeCombo: (request) => { + received = request; + return Promise.resolve({ + accountId: request.accountId, + environment: "paper", + accepted: true, + submitted: false, + commission: 2.5, + initialMargin: { current: 10_000, change: 3220, after: 13_220 }, + maintenanceMargin: { current: 9000, change: 3000, after: 12_000 }, + warnings: [], + rejectionReasons: [], + advisoryAssetPermissions: ["STK"], + }); + }, + }) + ); + const longContract = { + identity: { + assetClass: "FOP" as const, + underlying: "NQ", + expiration: "2026-08-21", + strike: 26400, + right: "PUT" as const, + tradingClass: "QN3", + exchange: "CME", + multiplier: 20, + }, + brokerReference: { broker: "ibkr" as const, contractId: "892767804" }, + }; + const shortContract = { + ...longContract, + identity: { ...longContract.identity, strike: 26600 }, + brokerReference: { broker: "ibkr" as const, contractId: "892767774" }, + }; + await adapter.previewDerivativeCombo({ + accountId: "U123", + legs: [ + { contract: longContract, ratio: 1 }, + { contract: shortContract, ratio: -1 }, + ], + quantity: 1, + priceEffect: "CREDIT", + limit: 39, + tif: "DAY", + session: "REGULAR", + }); + assert.deepEqual( + (received as { legs: { contract: { conid: number }; ratio: number }[] }).legs.map( + ({ contract, ratio }) => [contract.conid, ratio] + ), + [ + [892767804, 1], + [892767774, -1], + ] + ); +}); diff --git a/src/derivatives/ibkrDerivativeAdapter.ts b/src/derivatives/ibkrDerivativeAdapter.ts index 78afd5e..2264ef8 100644 --- a/src/derivatives/ibkrDerivativeAdapter.ts +++ b/src/derivatives/ibkrDerivativeAdapter.ts @@ -10,6 +10,12 @@ import type { DerivativeReferenceQuote, DerivativeRight, } from "./derivativeDiscovery.js"; +import type { + DerivativeComboPreviewRequest, + DerivativeComboPreviewResult, + DerivativePreviewClient, + TradingDiagnostics, +} from "./derivativePreview.js"; type IbkrOptionRight = "C" | "P"; @@ -86,6 +92,19 @@ export interface IbkrDerivativeDiscoveryApi { getDerivativeReferenceQuote( contract: IbkrDerivativeContract ): Promise; + getTradingDiagnostics(accountId: string): Promise; + previewDerivativeCombo(request: { + accountId: string; + legs: [ + { contract: IbkrDerivativeContract; ratio: 1 | -1 }, + { contract: IbkrDerivativeContract; ratio: 1 | -1 }, + ]; + quantity: number; + priceEffect: "CREDIT" | "DEBIT"; + limit: number; + tif: "DAY" | "GTC"; + session: "REGULAR" | "OVERNIGHT"; + }): Promise; } function toIbkrRight(right: DerivativeRight): IbkrOptionRight { @@ -187,7 +206,7 @@ function ibkrContract(contract: DerivativeContract): IbkrDerivativeContract { } /** Maps broker-local conids and C/P codes into the CLI's durable semantic model. */ -export class IbkrDerivativeAdapter implements DerivativeDiscoveryClient { +export class IbkrDerivativeAdapter implements DerivativeDiscoveryClient, DerivativePreviewClient { constructor(private readonly client: IbkrDerivativeDiscoveryApi) {} async getExpiries(request: DerivativeExpiryRequest): Promise { @@ -227,4 +246,23 @@ export class IbkrDerivativeAdapter implements DerivativeDiscoveryClient { mark: quote.mark, }; } + + getTradingDiagnostics(accountId: string): Promise { + return this.client.getTradingDiagnostics(accountId); + } + + previewDerivativeCombo( + request: DerivativeComboPreviewRequest + ): Promise { + return this.client.previewDerivativeCombo({ + ...request, + legs: request.legs.map(({ contract, ratio }) => ({ + contract: ibkrContract(contract), + ratio, + })) as [ + { contract: IbkrDerivativeContract; ratio: 1 | -1 }, + { contract: IbkrDerivativeContract; ratio: 1 | -1 }, + ], + }); + } } From 21817cdd345bfabb76a6bb453af657c02ac8046a Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Wed, 29 Jul 2026 13:10:32 -0700 Subject: [PATCH 2/2] chore: use ibkr what-if release --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 8125b2a..11d0c36 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "typescript-eslint": "^8.61.1" }, "dependencies": { - "@huskly/ibkr-client": "^0.9.0", + "@huskly/ibkr-client": "^0.10.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 a598335..54c652f 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.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== +"@huskly/ibkr-client@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@huskly/ibkr-client/-/ibkr-client-0.10.0.tgz#9a0bea50125a38b8e0f69621f3796f20c4ce64d7" + integrity sha512-5zpJgqpLUpHHCQnRgkc952+lx9t9P9XPLeVaZJB14Gd/D1V3dYnbgqBPyIatU+Z/QvMygRZVhrfx0+2m5Rf9GA== dependencies: dotenv "^17.2.3" ibkr-client "^1.0.4"