From 9c2e1f1175e8f442f81e1305a1f747d732599de6 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Wed, 29 Jul 2026 12:53:18 -0700 Subject: [PATCH 1/2] feat: execute reviewed derivative spreads --- README.md | 55 +- src/cli/derivatives.test.ts | 66 +++ src/cli/derivatives.ts | 200 +++++++- src/derivatives/derivativeClient.ts | 9 + src/derivatives/derivativeExecution.ts | 67 +++ .../derivativeExecutionService.test.ts | 397 +++++++++++++++ src/derivatives/derivativeExecutionService.ts | 472 ++++++++++++++++++ src/derivatives/derivativePreview.ts | 2 +- .../derivativePreviewService.test.ts | 49 +- src/derivatives/derivativePreviewService.ts | 162 +++++- src/derivatives/ibkrDerivativeAdapter.test.ts | 4 + src/derivatives/ibkrDerivativeAdapter.ts | 71 ++- src/mcp/server.ts | 2 + src/mcp/tools/derivatives.ts | 253 ++++++++++ 14 files changed, 1786 insertions(+), 23 deletions(-) create mode 100644 src/cli/derivatives.test.ts create mode 100644 src/derivatives/derivativeExecution.ts create mode 100644 src/derivatives/derivativeExecutionService.test.ts create mode 100644 src/derivatives/derivativeExecutionService.ts create mode 100644 src/mcp/tools/derivatives.ts diff --git a/README.md b/README.md index c568017..5b090a1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ implemented for that broker. | `option chain` | ✗ | ✓ | | `spread quote` | ✗ | ✓ | | `spread preview` | ✗ | ✓ | +| `spread submit` | ✗ | ✓ | +| `order show/watch/cancel` | ✗ | ✓ | | `broker doctor` | ✗ | ✓ | | `account` | ✓ | ✓ | | `user-preference` | ✓ | ✗ | @@ -59,7 +61,8 @@ implemented for that broker. IBKR currently supports the shared **`account`**, **`quote`**, **`search`**, **`positions`**, **`transactions`**, **`orders`**, and **`repl`** commands plus -read-only **`option resolve`**, **`option chain`**, and **`spread quote`** research. IBKR +exact-series **`option resolve`**, **`option chain`**, **`spread quote`**, guarded +**`spread preview`/`spread submit`**, and derivative **`order`** lifecycle commands. 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`, @@ -326,7 +329,39 @@ 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. +The owner-readable preview file stores only a one-way account digest and masked DTO so a +separate CLI invocation can submit it without persisting the full account ID. Unknown or +incomplete What-If results fail closed. + +#### spread submit and order - Guarded IBKR Execution + +Submit only the exact unexpired preview that was reviewed. Futures and futures-option writes +also require the operator identity used for CME audit metadata. + +```bash +export HUSKLY_EXT_OPERATOR=felipecsl +huskly-cli spread submit --broker ibkr --account U1234567 --confirm --json + +# A known broker warning must be acknowledged explicitly; unknown warnings stop the flow. +huskly-cli spread acknowledge \ + --broker ibkr --account U1234567 --confirm --json + +huskly-cli order show --broker ibkr --account U1234567 --json +huskly-cli order watch --broker ibkr --account U1234567 --json +huskly-cli order cancel --broker ibkr --account U1234567 \ + --operator felipecsl --confirm --json +``` + +Before placement the workflow verifies the exact account/session and environment, rejects +expired previews or contract drift, and submits one atomic combo with a unique client order ID. +After placement and cancellation it reads fresh broker state and verifies the legs, signed +ratios, quantity, signed limit, and client order identity against the reviewed preview. Partial +fills remain visible; cancellation succeeds only after the broker reaches `CANCELED`. + +Paper accounts are allowed by default. Live execution fails closed unless both +`HUSKLY_ENABLE_LIVE_EXECUTION=true` and an exact comma-separated +`HUSKLY_LIVE_ACCOUNT_ALLOWLIST` include the requested account. Execution state persists only an +account digest and masked preview DTO, never the full account identifier. #### broker doctor - Trading Diagnostics @@ -448,17 +483,21 @@ huskly-cli --broker ibkr repl ## Using with Claude Code or Codex (MCP server) -`huskly-cli-mcp` exposes market-data and account operations as MCP +`huskly-cli-mcp` exposes market-data, account, and guarded derivative operations as MCP tools — `get_quote`, `search_symbol`, `get_positions`, `get_price_history`, `get_movers`, `get_vix_level`, `get_option_chain`, `get_option_expiries`, and -`place_option_order` +`place_option_order`, plus `get_derivative_chain`, `quote_option_spread`, +`preview_option_spread_order`, `submit_option_spread_order`, +`acknowledge_order_warning`, `get_order_status`, and `cancel_order` — so Claude can answer questions like "what's the MSFT price now", "what's the AAPL last 12 months price history", or "what are my current positions" with live data. -`place_option_order` is the only write-capable MCP tool. It is Schwab-only and +`place_option_order` is Schwab-only and requires `confirm: true` to submit a real live order; when `confirm` is omitted -or `false`, it returns a preview without calling the broker. +or `false`, it returns a preview without calling the broker. The IBKR derivative tools reuse the +same exact-preview and lifecycle services as the CLI. Submission, warning acknowledgment, and +cancellation require `confirm: true`; unknown warning IDs and unguarded order IDs fail closed. It communicates over stdio and reuses the same Schwab/IBKR auth as the CLI, so authenticate first (`huskly-cli auth login` for Schwab, and/or set the IBKR @@ -518,6 +557,10 @@ src/ - `LOG_LEVEL` - Set logging level (trace, debug, info, warn, error) - `REDIS_URL` - Redis connection URL (defaults to localhost:6379) - `HUSKLY_MCP_DEFAULT_BROKER` - Default broker (`schwab` or `ibkr`) for the MCP server's broker-agnostic tools when a call omits `broker` (defaults to `schwab`; an invalid value logs a warning to stderr and falls back to `schwab` rather than failing startup) +- `HUSKLY_EXT_OPERATOR` - CME operator identity used by derivative submit/cancel CLI commands when `--operator` is omitted +- `HUSKLY_ENABLE_LIVE_EXECUTION` - Must be exactly `true` to permit live derivative execution +- `HUSKLY_LIVE_ACCOUNT_ALLOWLIST` - Comma-separated exact account IDs permitted for live derivative execution +- `HUSKLY_PREVIEW_DIR` / `HUSKLY_EXECUTION_DIR` - Optional owner-readable workflow-state directories ## License diff --git a/src/cli/derivatives.test.ts b/src/cli/derivatives.test.ts new file mode 100644 index 0000000..1681f30 --- /dev/null +++ b/src/cli/derivatives.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Command } from "commander"; +import { addDerivativeCommands } from "./derivatives.js"; + +function program(): Command { + const command = new Command(); + command.exitOverride(); + addDerivativeCommands(command, () => "ibkr"); + return command; +} + +const previewId = "a".repeat(64); + +void test("spread submission refuses to initialize a broker without explicit confirmation", async () => { + await assert.rejects( + () => + program().parseAsync([ + "node", + "test", + "spread", + "submit", + previewId, + "--account", + "DU1234567", + "--operator", + "tester", + ]), + /requires --confirm/ + ); +}); + +void test("warning acknowledgment requires a fresh explicit confirmation", async () => { + await assert.rejects( + () => + program().parseAsync([ + "node", + "test", + "spread", + "acknowledge", + previewId, + "reply-1", + "--account", + "DU1234567", + ]), + /requires --confirm/ + ); +}); + +void test("cancellation refuses to initialize a broker without explicit confirmation", async () => { + await assert.rejects( + () => + program().parseAsync([ + "node", + "test", + "order", + "cancel", + "12345", + "--account", + "DU1234567", + "--operator", + "tester", + ]), + /requires --confirm/ + ); +}); diff --git a/src/cli/derivatives.ts b/src/cli/derivatives.ts index 5a10c95..05870ef 100644 --- a/src/cli/derivatives.ts +++ b/src/cli/derivatives.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import type { BrokerName } from "#src/brokers/brokerClient.js"; import { derivativeDiscoveryClient, + derivativeExecutionClient, derivativePreviewClient, } from "#src/derivatives/derivativeClient.js"; import type { @@ -18,9 +19,16 @@ import type { VerticalSpreadKind } from "#src/derivatives/verticalSpread.js"; import type { TradingDiagnostics } from "#src/derivatives/derivativePreview.js"; import { DerivativePreviewService, + FilePreviewStore, maskAccountId, type SpreadPreviewDto, } from "#src/derivatives/derivativePreviewService.js"; +import { + DerivativeExecutionService, + FileExecutionStateStore, + type OrderLifecycleDto, + type SubmissionDto, +} from "#src/derivatives/derivativeExecutionService.js"; interface SeriesOptions { asset: string; @@ -57,6 +65,19 @@ interface ResolveOptions extends SeriesOptions { strike?: string; } +interface ExecutionOptions { + account?: string; + broker?: string; + confirm?: boolean; + json?: boolean; + operator?: string; +} + +interface WatchOptions extends ExecutionOptions { + poll: string; + timeout: string; +} + function assetClass(value: string): DerivativeAssetClass { const normalized = value.toUpperCase(); if (normalized !== "OPT" && normalized !== "FOP") { @@ -88,6 +109,19 @@ function accountId(value: string | undefined): string { return account; } +function operator(value: string | undefined): string { + const result = value ?? process.env["HUSKLY_EXT_OPERATOR"]; + if (!result?.trim()) { + throw new Error("An exact --operator or HUSKLY_EXT_OPERATOR is required."); + } + return result; +} + +function confirmed(value: boolean | undefined): true { + if (value !== true) throw new Error("This operation requires --confirm."); + return true; +} + function tif(value: string): "DAY" | "GTC" { const normalized = value.toUpperCase(); if (normalized !== "DAY" && normalized !== "GTC") { @@ -248,6 +282,38 @@ function renderSpreadPreview(result: SpreadPreviewDto): string { ].join("\n"); } +function renderSubmission(result: SubmissionDto): string { + const lines = [ + `Submission: ${result.state}`, + `Preview: ${result.previewId}`, + `Account: ${result.account.maskedId} Environment: ${result.account.environment}`, + ]; + if (result.orderId !== undefined) lines.push(`Order: ${result.orderId}`); + if (result.clientOrderId !== undefined) lines.push(`Client order: ${result.clientOrderId}`); + if (result.status !== undefined) lines.push(`Verified status: ${result.status}`); + if (result.updatedAt !== undefined) lines.push(`Updated: ${result.updatedAt ?? "unknown"}`); + for (const warning of result.warnings) { + lines.push( + `Warning ${warning.replyId} (${warning.known ? "known" : "UNKNOWN"}): ${warning.messages.join(" | ")}` + ); + } + if (result.rejectionReasons.length > 0) { + lines.push(`Rejected: ${result.rejectionReasons.join(" | ")}`); + } + return lines.join("\n"); +} + +function renderOrderLifecycle(result: OrderLifecycleDto): string { + return [ + `Order ${result.orderId}: ${result.status}`, + `Account: ${result.account.maskedId} Environment: ${result.account.environment}`, + `Quantity: ${String(result.filledQuantity)}/${String(result.quantity)} Remaining: ${String(result.remainingQuantity)}`, + `Limit: ${formatPrice(result.limitPrice)} Average fill: ${formatPrice(result.averagePrice)}`, + `Commission/fees: ${formatPrice(result.commissionAndFees)}`, + `Verified against preview: ${String(result.verifiedAgainstPreview)}`, + ].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)); } @@ -261,7 +327,35 @@ async function previewService(broker: BrokerName): Promise new Date(), + 5 * 60 * 1000, + new FilePreviewStore() + ); +} + +async function executionService(broker: BrokerName): Promise { + const [discovery, preview, execution] = await Promise.all([ + derivativeDiscoveryClient(broker), + derivativePreviewClient(broker), + derivativeExecutionClient(broker), + ]); + const previews = new DerivativePreviewService( + discovery, + preview, + () => new Date(), + 5 * 60 * 1000, + new FilePreviewStore() + ); + return new DerivativeExecutionService( + discovery, + preview, + execution, + previews, + new FileExecutionStateStore() + ); } /** Register broker-neutral derivative research commands without changing legacy chain commands. */ @@ -377,8 +471,112 @@ export function addDerivativeCommands( }); output(result, options.json, renderSpreadPreview); }); + + spread + .command("submit") + .description("Submit the exact, unexpired reviewed preview") + .argument("", "Exact preview ID") + .option("--broker ", "Broker to use", "ibkr") + .option("--account ", "Exact account ID; defaults to IBKR_ACCOUNT_ID") + .option("--operator ", "CME operator identity; defaults to HUSKLY_EXT_OPERATOR") + .option("--confirm", "Confirm this order submission") + .option("--json", "Emit a stable JSON DTO") + .action(async (previewId: string, options: ExecutionOptions) => { + const confirm = confirmed(options.confirm); + const extOperator = operator(options.operator); + const result = await ( + await executionService(broker(options.broker)) + ).submit({ + previewId, + accountId: accountId(options.account), + operator: extOperator, + confirm, + }); + output(result, options.json, renderSubmission); + }); + + spread + .command("acknowledge") + .description("Acknowledge one known broker warning for the exact preview") + .argument("", "Exact preview ID") + .argument("", "Exact broker warning reply ID") + .option("--broker ", "Broker to use", "ibkr") + .option("--account ", "Exact account ID; defaults to IBKR_ACCOUNT_ID") + .option("--confirm", "Confirm this warning acknowledgment") + .option("--json", "Emit a stable JSON DTO") + .action(async (previewId: string, replyId: string, options: ExecutionOptions) => { + const confirm = confirmed(options.confirm); + const result = await ( + await executionService(broker(options.broker)) + ).acknowledgeWarning({ + previewId, + replyId, + accountId: accountId(options.account), + confirm, + }); + output(result, options.json, renderSubmission); + }); program.addCommand(spread); + const order = new Command("order").description("Inspect or cancel guarded derivative orders"); + order + .command("show") + .argument("", "Broker order ID") + .option("--broker ", "Broker to use", "ibkr") + .option("--account ", "Exact account ID; defaults to IBKR_ACCOUNT_ID") + .option("--json", "Emit a stable JSON DTO") + .action(async (orderId: string, options: ExecutionOptions) => { + const result = await ( + await executionService(broker(options.broker)) + ).getStatus(orderId, accountId(options.account)); + output(result, options.json, renderOrderLifecycle); + }); + order + .command("watch") + .argument("", "Broker order ID") + .option("--broker ", "Broker to use", "ibkr") + .option("--account ", "Exact account ID; defaults to IBKR_ACCOUNT_ID") + .option("--timeout ", "Maximum watch duration", "300") + .option("--poll ", "Polling interval", "2") + .option("--json", "Emit a stable JSON DTO") + .action(async (orderId: string, options: WatchOptions) => { + const result = await ( + await executionService(broker(options.broker)) + ).watch({ + orderId, + accountId: accountId(options.account), + timeoutMs: number(options.timeout, "timeout") * 1000, + pollMs: number(options.poll, "poll") * 1000, + }); + output(result, options.json, renderOrderLifecycle); + }); + order + .command("cancel") + .argument("", "Broker order ID") + .option("--broker ", "Broker to use", "ibkr") + .option("--account ", "Exact account ID; defaults to IBKR_ACCOUNT_ID") + .option("--operator ", "CME operator identity; defaults to HUSKLY_EXT_OPERATOR") + .option("--confirm", "Confirm cancellation") + .option("--timeout ", "Maximum verification duration", "300") + .option("--poll ", "Polling interval", "2") + .option("--json", "Emit a stable JSON DTO") + .action(async (orderId: string, options: WatchOptions) => { + const confirm = confirmed(options.confirm); + const extOperator = operator(options.operator); + const result = await ( + await executionService(broker(options.broker)) + ).cancel({ + orderId, + accountId: accountId(options.account), + operator: extOperator, + confirm, + timeoutMs: number(options.timeout, "timeout") * 1000, + pollMs: number(options.poll, "poll") * 1000, + }); + output(result, options.json, renderOrderLifecycle); + }); + program.addCommand(order); + const brokerCommand = new Command("broker").description("Broker diagnostics"); brokerCommand .command("doctor") diff --git a/src/derivatives/derivativeClient.ts b/src/derivatives/derivativeClient.ts index 83d0393..f65a95a 100644 --- a/src/derivatives/derivativeClient.ts +++ b/src/derivatives/derivativeClient.ts @@ -2,6 +2,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 type { DerivativeExecutionClient } from "./derivativeExecution.js"; import { IbkrDerivativeAdapter } from "./ibkrDerivativeAdapter.js"; export interface DerivativeDiscoveryFactories { @@ -45,3 +46,11 @@ export async function derivativePreviewClient( return (await resolveDerivativeDiscovery(broker)) as DerivativeDiscoveryClient & DerivativePreviewClient; } + +/** Resolve the guarded live-execution capability; unsupported brokers fail closed. */ +export async function derivativeExecutionClient( + broker: BrokerName +): Promise { + return (await resolveDerivativeDiscovery(broker)) as DerivativeDiscoveryClient & + DerivativeExecutionClient; +} diff --git a/src/derivatives/derivativeExecution.ts b/src/derivatives/derivativeExecution.ts new file mode 100644 index 0000000..693b1dc --- /dev/null +++ b/src/derivatives/derivativeExecution.ts @@ -0,0 +1,67 @@ +import type { DerivativeComboPreviewRequest } from "./derivativePreview.js"; + +export interface DerivativeComboExecutionRequest extends DerivativeComboPreviewRequest { + clientOrderId: string; + extOperator: string; + manualIndicator: boolean; +} + +export interface OrderWarning { + replyId: string; + messages: string[]; + messageIds: string[]; + known: boolean; +} + +export type DerivativeOrderStatus = + | "WARNING_PENDING" + | "PENDING" + | "WORKING" + | "PARTIALLY_FILLED" + | "FILLED" + | "CANCELED" + | "REJECTED" + | "UNKNOWN"; + +export type DerivativeOrderSubmissionResult = + | { + state: "accepted"; + orderId: string; + status: DerivativeOrderStatus; + clientOrderId: string | null; + warnings: OrderWarning[]; + } + | { state: "warning"; warnings: OrderWarning[] } + | { state: "rejected"; reasons: string[] }; + +export interface DerivativeOrderLifecycle { + accountId: string; + orderId: string; + clientOrderId: string | null; + status: DerivativeOrderStatus; + quantity: number; + filledQuantity: number; + remainingQuantity: number; + averagePrice: number | null; + limitPrice: number | null; + commissionAndFees: number | null; + legs: { conid: number; ratio: number }[]; + updatedAt: string | null; +} + +export interface DerivativeExecutionClient { + submitDerivativeCombo( + request: DerivativeComboExecutionRequest + ): Promise; + acknowledgeOrderWarning(input: { + replyId: string; + confirmed: true; + }): Promise; + getDerivativeOrderStatus(accountId: string, orderId: string): Promise; + cancelDerivativeOrder(input: { + accountId: string; + orderId: string; + extOperator: string; + manualIndicator: boolean; + }): Promise; +} diff --git a/src/derivatives/derivativeExecutionService.test.ts b/src/derivatives/derivativeExecutionService.test.ts new file mode 100644 index 0000000..d057d66 --- /dev/null +++ b/src/derivatives/derivativeExecutionService.test.ts @@ -0,0 +1,397 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { DerivativeContract, DerivativeDiscoveryClient } from "./derivativeDiscovery.js"; +import type { + DerivativeComboExecutionRequest, + DerivativeExecutionClient, + DerivativeOrderLifecycle, + DerivativeOrderSubmissionResult, +} from "./derivativeExecution.js"; +import type { DerivativePreviewClient } from "./derivativePreview.js"; +import { + DerivativeExecutionService, + FileExecutionStateStore, + InMemoryExecutionStateStore, +} from "./derivativeExecutionService.js"; +import { DerivativePreviewService, InMemoryPreviewStore } from "./derivativePreviewService.js"; + +function contract(strike: number, drift = false): 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 === 26400 ? 892767804 : 892767774) + (drift ? 1 : 0)), + }, + }; +} + +class FakeDiscovery implements DerivativeDiscoveryClient { + drift = false; + getExpiries = () => Promise.resolve([]); + getContracts = () => Promise.resolve([]); + getChain = () => Promise.resolve([]); + getReferenceQuote = () => Promise.reject(new Error("not used")); + resolveContract = (request: { strike: number }) => + Promise.resolve(contract(request.strike, this.drift)); +} + +class FakePreviewClient implements DerivativePreviewClient { + environment: "paper" | "live" = "paper"; + selectedAccountId = "U1234567"; + + getTradingDiagnostics(accountId: string) { + return Promise.resolve({ + accountId, + selectedAccountId: this.selectedAccountId, + environment: this.environment, + authenticated: true, + competingSession: false, + marketDataAvailable: true, + advisoryAssetPermissions: ["STK"], + }); + } + + previewDerivativeCombo(request: { accountId: string }) { + return Promise.resolve({ + accountId: request.accountId, + environment: this.environment, + accepted: true, + submitted: false as const, + commission: 2.5, + initialMargin: { current: 10_000, change: 3220, after: 13_220 }, + maintenanceMargin: { current: 9000, change: 3000, after: 12_000 }, + warnings: [], + rejectionReasons: [], + advisoryAssetPermissions: ["STK"], + }); + } +} + +class FakeExecutionClient implements DerivativeExecutionClient { + submitted?: DerivativeComboExecutionRequest; + canceled = false; + submitResult: DerivativeOrderSubmissionResult = { + state: "accepted", + orderId: "777", + status: "WORKING", + clientOrderId: null, + warnings: [], + }; + acknowledgeResults: DerivativeOrderSubmissionResult[] = []; + statuses: DerivativeOrderLifecycle[] = []; + + submitDerivativeCombo(request: DerivativeComboExecutionRequest) { + this.submitted = request; + return Promise.resolve(this.submitResult); + } + + acknowledgeOrderWarning(_input: { replyId: string; confirmed: true }) { + const result = this.acknowledgeResults.shift(); + return result === undefined + ? Promise.reject(new Error("missing acknowledgment fixture")) + : Promise.resolve(result); + } + + getDerivativeOrderStatus(accountId: string, orderId: string) { + const fixture = this.statuses.shift(); + if (fixture !== undefined) return Promise.resolve(fixture); + return Promise.resolve(this.lifecycle(accountId, orderId, "WORKING")); + } + + cancelDerivativeOrder(_input: { + accountId: string; + orderId: string; + extOperator: string; + manualIndicator: boolean; + }) { + this.canceled = true; + return Promise.resolve(); + } + + lifecycle( + accountId: string, + orderId: string, + status: DerivativeOrderLifecycle["status"] + ): DerivativeOrderLifecycle { + return { + accountId, + orderId, + clientOrderId: this.submitted?.clientOrderId ?? "huskly-test", + status, + quantity: 1, + filledQuantity: status === "FILLED" ? 1 : 0, + remainingQuantity: status === "FILLED" || status === "CANCELED" ? 0 : 1, + averagePrice: status === "FILLED" ? -38.5 : null, + limitPrice: -39, + commissionAndFees: status === "FILLED" ? 2.5 : null, + legs: [ + { conid: 892767804, ratio: 1 }, + { conid: 892767774, ratio: -1 }, + ], + updatedAt: "2026-07-29T12:00:00.000Z", + }; + } +} + +const fixedNow = new Date("2026-07-29T12:00:00.000Z"); + +async function setup() { + const discovery = new FakeDiscovery(); + const previewClient = new FakePreviewClient(); + const execution = new FakeExecutionClient(); + const previews = new DerivativePreviewService( + discovery, + previewClient, + () => fixedNow, + 60_000, + new InMemoryPreviewStore() + ); + const preview = await previews.previewVertical({ + 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", + }); + const state = new InMemoryExecutionStateStore(); + const service = new DerivativeExecutionService( + discovery, + previewClient, + execution, + previews, + state, + () => fixedNow, + () => Promise.resolve(), + { enabled: false, accountAllowlist: [] } + ); + return { discovery, previewClient, execution, previews, preview, state, service }; +} + +void test("confirmed submission revalidates and verifies the exact preview", async () => { + const { service, execution, preview, previews } = await setup(); + const result = await service.submit({ + previewId: preview.previewId, + accountId: "U1234567", + operator: "felipecsl", + confirm: true, + }); + assert.equal(result.state, "accepted"); + assert.equal(result.account.maskedId, "U***567"); + const submitted = execution.submitted; + assert.ok(submitted); + assert.equal(submitted.manualIndicator, true); + assert.equal(submitted.extOperator, "felipecsl"); + assert.equal(submitted.limit, 39); + assert.deepEqual( + submitted.legs.map(({ contract: item, ratio }) => [item.brokerReference?.contractId, ratio]), + [ + ["892767804", 1], + ["892767774", -1], + ] + ); + await assert.rejects( + () => + previews.validatePreview(preview.previewId, { + accountId: "U1234567", + environment: "paper", + }), + /Unknown preview ID/ + ); +}); + +void test("submission rejects contract drift and unallowlisted live accounts", async () => { + const drifted = await setup(); + drifted.discovery.drift = true; + await assert.rejects( + () => + drifted.service.submit({ + previewId: drifted.preview.previewId, + accountId: "U1234567", + operator: "felipecsl", + confirm: true, + }), + /contract drifted/ + ); + assert.equal(drifted.execution.submitted, undefined); + + const live = await setup(); + live.previewClient.environment = "live"; + await assert.rejects( + () => + live.service.submit({ + previewId: live.preview.previewId, + accountId: "U1234567", + operator: "felipecsl", + confirm: true, + }), + /explicit enablement.*allowlisting/ + ); +}); + +void test("known warnings require exact acknowledgment and unknown warnings stop", async () => { + const known = await setup(); + known.execution.submitResult = { + state: "warning", + warnings: [ + { + replyId: "reply-1", + messages: ["Percentage constraint"], + messageIds: ["o163"], + known: true, + }, + ], + }; + const pending = await known.service.submit({ + previewId: known.preview.previewId, + accountId: "U1234567", + operator: "felipecsl", + confirm: true, + }); + assert.equal(pending.state, "warning"); + assert.equal(pending.status, "WARNING_PENDING"); + known.execution.acknowledgeResults.push({ + state: "accepted", + orderId: "777", + status: "WORKING", + clientOrderId: null, + warnings: [], + }); + assert.equal( + ( + await known.service.acknowledgeWarning({ + previewId: known.preview.previewId, + replyId: "reply-1", + accountId: "U1234567", + confirm: true, + }) + ).state, + "accepted" + ); + + const unknown = await setup(); + unknown.execution.submitResult = { + state: "warning", + warnings: [{ replyId: "reply-x", messages: ["Unknown"], messageIds: ["x999"], known: false }], + }; + await unknown.service.submit({ + previewId: unknown.preview.previewId, + accountId: "U1234567", + operator: "felipecsl", + confirm: true, + }); + await assert.rejects( + () => + unknown.service.acknowledgeWarning({ + previewId: unknown.preview.previewId, + replyId: "reply-x", + accountId: "U1234567", + confirm: true, + }), + /Unknown broker warning/ + ); +}); + +void test("post-submit verification rejects changed legs or economics", async () => { + const { service, execution, preview } = await setup(); + const mismatch = execution.lifecycle("U1234567", "777", "WORKING"); + mismatch.legs = [{ conid: 892767804, ratio: 1 }]; + execution.statuses.push(mismatch); + await assert.rejects( + () => + service.submit({ + previewId: preview.previewId, + accountId: "U1234567", + operator: "felipecsl", + confirm: true, + }), + /legs or ratios/ + ); +}); + +void test("watch and cancel require verified terminal lifecycle states", async () => { + const context = await setup(); + await context.service.submit({ + previewId: context.preview.previewId, + accountId: "U1234567", + operator: "felipecsl", + confirm: true, + }); + context.execution.statuses.push( + context.execution.lifecycle("U1234567", "777", "PARTIALLY_FILLED"), + context.execution.lifecycle("U1234567", "777", "FILLED") + ); + assert.equal( + ( + await context.service.watch({ + orderId: "777", + accountId: "U1234567", + timeoutMs: 1000, + pollMs: 0, + }) + ).status, + "FILLED" + ); + + context.execution.statuses.push( + context.execution.lifecycle("U1234567", "777", "WORKING"), + context.execution.lifecycle("U1234567", "777", "CANCELED") + ); + assert.equal( + ( + await context.service.cancel({ + orderId: "777", + accountId: "U1234567", + operator: "felipecsl", + confirm: true, + timeoutMs: 1000, + pollMs: 0, + }) + ).status, + "CANCELED" + ); + assert.equal(context.execution.canceled, true); +}); + +void test("file execution state validates persisted expectations without storing account IDs", async () => { + const directory = await mkdtemp(join(tmpdir(), "huskly-execution-state-")); + try { + const { preview } = await setup(); + const store = new FileExecutionStateStore(directory); + await store.saveOrder({ + orderId: "777", + previewId: preview.previewId, + accountDigest: "a".repeat(64), + environment: "paper", + clientOrderId: "huskly-test", + preview, + }); + const [filename] = await readdir(join(directory, "orders")); + assert.ok(filename); + const persisted = await readFile(join(directory, "orders", filename), "utf8"); + assert.doesNotMatch(persisted, /U1234567/); + assert.equal((await store.loadOrder("777"))?.orderId, "777"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/src/derivatives/derivativeExecutionService.ts b/src/derivatives/derivativeExecutionService.ts new file mode 100644 index 0000000..8c078c2 --- /dev/null +++ b/src/derivatives/derivativeExecutionService.ts @@ -0,0 +1,472 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import type { DerivativeDiscoveryClient } from "./derivativeDiscovery.js"; +import type { + DerivativeExecutionClient, + DerivativeOrderLifecycle, + DerivativeOrderSubmissionResult, + OrderWarning, +} from "./derivativeExecution.js"; +import type { BrokerEnvironment, DerivativePreviewClient } from "./derivativePreview.js"; +import { maskAccountId, spreadPreviewDtoSchema } from "./derivativePreviewService.js"; +import type { DerivativePreviewService, SpreadPreviewDto } from "./derivativePreviewService.js"; + +interface PendingWarning { + replyId: string; + previewId: string; + accountDigest: string; + environment: BrokerEnvironment; + warning: OrderWarning; + clientOrderId: string; +} + +interface OrderExpectation { + orderId: string; + previewId: string; + accountDigest: string; + environment: BrokerEnvironment; + clientOrderId: string; + preview: SpreadPreviewDto; +} + +export interface ExecutionStateStore { + saveWarning(value: PendingWarning): Promise; + loadWarning(replyId: string): Promise; + deleteWarning(replyId: string): Promise; + saveOrder(value: OrderExpectation): Promise; + loadOrder(orderId: string): Promise; +} + +export class InMemoryExecutionStateStore implements ExecutionStateStore { + private readonly warnings = new Map(); + private readonly orders = new Map(); + + saveWarning(value: PendingWarning): Promise { + this.warnings.set(value.replyId, value); + return Promise.resolve(); + } + + loadWarning(replyId: string): Promise { + return Promise.resolve(this.warnings.get(replyId)); + } + + deleteWarning(replyId: string): Promise { + this.warnings.delete(replyId); + return Promise.resolve(); + } + + saveOrder(value: OrderExpectation): Promise { + this.orders.set(value.orderId, value); + return Promise.resolve(); + } + + loadOrder(orderId: string): Promise { + return Promise.resolve(this.orders.get(orderId)); + } +} + +const warningSchema = z.object({ + replyId: z.string(), + previewId: z.string().regex(/^[a-f0-9]{64}$/), + accountDigest: z.string().regex(/^[a-f0-9]{64}$/), + environment: z.enum(["live", "paper"]), + warning: z.object({ + replyId: z.string(), + messages: z.array(z.string()), + messageIds: z.array(z.string()), + known: z.boolean(), + }), + clientOrderId: z.string(), +}); +const orderExpectationSchema = z.object({ + orderId: z.string(), + previewId: z.string().regex(/^[a-f0-9]{64}$/), + accountDigest: z.string().regex(/^[a-f0-9]{64}$/), + environment: z.enum(["live", "paper"]), + clientOrderId: z.string(), + preview: spreadPreviewDtoSchema, +}); + +/** Owner-readable workflow state; full account identifiers are never persisted. */ +export class FileExecutionStateStore implements ExecutionStateStore { + constructor( + private readonly directory = process.env["HUSKLY_EXECUTION_DIR"] ?? + join(homedir(), ".cache", "huskly-cli", "execution") + ) {} + + saveWarning(value: PendingWarning): Promise { + return this.write("warnings", value.replyId, value); + } + + async loadWarning(replyId: string): Promise { + const value = await this.read("warnings", replyId); + return value === undefined ? undefined : warningSchema.parse(value); + } + + deleteWarning(replyId: string): Promise { + return this.delete("warnings", replyId); + } + + saveOrder(value: OrderExpectation): Promise { + return this.write("orders", value.orderId, value); + } + + async loadOrder(orderId: string): Promise { + const value = await this.read("orders", orderId); + return value === undefined + ? undefined + : (orderExpectationSchema.parse(value) as unknown as OrderExpectation); + } + + private async write(kind: string, id: string, value: object): Promise { + const directory = join(this.directory, kind); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await writeFile(join(directory, this.filename(id)), JSON.stringify(value), { + encoding: "utf8", + mode: 0o600, + }); + } + + private async read(kind: string, id: string): Promise { + try { + return JSON.parse( + await readFile(join(this.directory, kind, this.filename(id)), "utf8") + ) as unknown; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + + private async delete(kind: string, id: string): Promise { + try { + await unlink(join(this.directory, kind, this.filename(id))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + private filename(id: string): string { + return `${createHash("sha256").update(id).digest("hex")}.json`; + } +} + +export interface SubmissionDto { + state: "accepted" | "warning" | "rejected"; + account: { maskedId: string; environment: BrokerEnvironment }; + previewId: string; + orderId?: string; + clientOrderId?: string; + status?: DerivativeOrderLifecycle["status"]; + updatedAt?: string | null; + warnings: OrderWarning[]; + rejectionReasons: string[]; +} + +export interface OrderLifecycleDto extends Omit { + account: { maskedId: string; environment: BrokerEnvironment }; + verifiedAgainstPreview: true; +} + +interface LiveExecutionPolicy { + enabled: boolean; + accountAllowlist: string[]; +} + +const terminalStatuses = new Set(["FILLED", "CANCELED", "REJECTED"]); +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Guarded execution workflow shared by CLI and MCP handlers. */ +export class DerivativeExecutionService { + constructor( + private readonly discovery: DerivativeDiscoveryClient, + private readonly previewClient: DerivativePreviewClient, + private readonly execution: DerivativeExecutionClient, + private readonly previews: DerivativePreviewService, + private readonly store: ExecutionStateStore = new InMemoryExecutionStateStore(), + private readonly now: () => Date = () => new Date(), + private readonly sleep: (ms: number) => Promise = wait, + private readonly livePolicy: LiveExecutionPolicy = { + enabled: process.env["HUSKLY_ENABLE_LIVE_EXECUTION"] === "true", + accountAllowlist: (process.env["HUSKLY_LIVE_ACCOUNT_ALLOWLIST"] ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + } + ) {} + + async submit(input: { + previewId: string; + accountId: string; + operator: string; + confirm: true; + }): Promise { + const diagnostics = await this.safeDiagnostics(input.accountId); + this.assertEnvironmentAllowed(input.accountId, diagnostics.environment); + const preview = await this.previews.validatePreview(input.previewId, { + accountId: input.accountId, + environment: diagnostics.environment, + }); + await this.assertContractsUnchanged(preview); + const clientOrderId = `huskly-${this.now().getTime().toString(36)}-${randomUUID()}`.slice( + 0, + 64 + ); + const result = await this.execution.submitDerivativeCombo({ + ...this.executionRequest(preview, input.accountId), + clientOrderId, + extOperator: input.operator, + manualIndicator: true, + }); + return this.handleSubmissionResult(result, preview, input.accountId, clientOrderId); + } + + async acknowledgeWarning(input: { + previewId: string; + replyId: string; + accountId: string; + confirm: true; + }): Promise { + const pending = await this.store.loadWarning(input.replyId); + if (pending?.previewId !== input.previewId) { + throw new Error("Warning reply does not match the exact preview"); + } + if (!pending.warning.known) throw new Error("Unknown broker warning requires manual review"); + if (pending.accountDigest !== this.accountDigest(input.accountId)) { + throw new Error("Warning reply account does not match"); + } + const diagnostics = await this.safeDiagnostics(input.accountId); + if (diagnostics.environment !== pending.environment) { + throw new Error("Warning reply environment does not match"); + } + const preview = await this.previews.validatePreview(input.previewId, { + accountId: input.accountId, + environment: diagnostics.environment, + }); + await this.store.deleteWarning(input.replyId); + const result = await this.execution.acknowledgeOrderWarning({ + replyId: input.replyId, + confirmed: true, + }); + return this.handleSubmissionResult(result, preview, input.accountId, pending.clientOrderId); + } + + async getStatus(orderId: string, accountId: string): Promise { + const expectation = await this.requiredExpectation(orderId, accountId); + const lifecycle = await this.execution.getDerivativeOrderStatus(accountId, orderId); + this.verifyLifecycle(lifecycle, expectation); + return this.lifecycleDto(lifecycle, expectation.environment); + } + + async watch(input: { + orderId: string; + accountId: string; + timeoutMs?: number; + pollMs?: number; + }): Promise { + const deadline = this.now().getTime() + (input.timeoutMs ?? 5 * 60 * 1000); + for (;;) { + const status = await this.getStatus(input.orderId, input.accountId); + if (terminalStatuses.has(status.status)) return status; + if (this.now().getTime() >= deadline) + throw new Error("Timed out waiting for terminal order status"); + await this.sleep(input.pollMs ?? 2000); + } + } + + async cancel(input: { + orderId: string; + accountId: string; + operator: string; + confirm: true; + timeoutMs?: number; + pollMs?: number; + }): Promise { + const expectation = await this.requiredExpectation(input.orderId, input.accountId); + const diagnostics = await this.safeDiagnostics(input.accountId); + if (diagnostics.environment !== expectation.environment) { + throw new Error("Order cancellation environment does not match its reviewed preview"); + } + this.assertEnvironmentAllowed(input.accountId, diagnostics.environment); + await this.execution.cancelDerivativeOrder({ + accountId: input.accountId, + orderId: input.orderId, + extOperator: input.operator, + manualIndicator: true, + }); + const terminal = await this.watch(input); + if (terminal.status !== "CANCELED") { + throw new Error(`Cancellation did not reach CANCELED; terminal status is ${terminal.status}`); + } + return terminal; + } + + private async handleSubmissionResult( + result: DerivativeOrderSubmissionResult, + preview: SpreadPreviewDto, + accountId: string, + clientOrderId: string | null + ): Promise { + if (result.state === "warning") { + if (clientOrderId === null) throw new Error("Warning response omitted client order identity"); + for (const warning of result.warnings) { + await this.store.saveWarning({ + replyId: warning.replyId, + previewId: preview.previewId, + accountDigest: this.accountDigest(accountId), + environment: preview.account.environment, + warning, + clientOrderId, + }); + } + return { + state: "warning", + account: { maskedId: maskAccountId(accountId), environment: preview.account.environment }, + previewId: preview.previewId, + status: "WARNING_PENDING", + warnings: result.warnings, + rejectionReasons: [], + }; + } + if (result.state === "rejected") { + return { + state: "rejected", + account: { maskedId: maskAccountId(accountId), environment: preview.account.environment }, + previewId: preview.previewId, + warnings: [], + rejectionReasons: result.reasons, + }; + } + const effectiveClientOrderId = result.clientOrderId ?? clientOrderId; + if (effectiveClientOrderId === null) + throw new Error("Accepted order omitted client order identity"); + const expectation: OrderExpectation = { + orderId: result.orderId, + previewId: preview.previewId, + accountDigest: this.accountDigest(accountId), + environment: preview.account.environment, + clientOrderId: effectiveClientOrderId, + preview, + }; + await this.store.saveOrder(expectation); + await this.previews.consumePreview(preview.previewId); + const lifecycle = await this.execution.getDerivativeOrderStatus(accountId, result.orderId); + this.verifyLifecycle(lifecycle, expectation); + return { + state: "accepted", + account: { maskedId: maskAccountId(accountId), environment: preview.account.environment }, + previewId: preview.previewId, + orderId: result.orderId, + clientOrderId: effectiveClientOrderId, + status: lifecycle.status, + updatedAt: lifecycle.updatedAt, + warnings: result.warnings, + rejectionReasons: [], + }; + } + + private executionRequest(preview: SpreadPreviewDto, accountId: string) { + return { + accountId, + legs: [ + { contract: preview.order.legs[0].contract, ratio: 1 as const }, + { contract: preview.order.legs[1].contract, ratio: -1 as const }, + ] as const, + quantity: preview.order.quantity, + priceEffect: preview.order.priceEffect, + limit: preview.order.limit, + tif: preview.order.tif, + session: preview.order.session, + }; + } + + private async assertContractsUnchanged(preview: SpreadPreviewDto): Promise { + for (const leg of preview.order.legs) { + const identity = leg.contract.identity; + const current = await this.discovery.resolveContract({ + assetClass: identity.assetClass, + underlying: identity.underlying, + expiration: identity.expiration, + strike: identity.strike, + right: identity.right, + tradingClass: identity.tradingClass, + exchange: identity.exchange, + }); + if (JSON.stringify(current) !== JSON.stringify(leg.contract)) { + throw new Error("Resolved contract drifted since preview"); + } + } + } + + private async safeDiagnostics(accountId: string) { + const diagnostics = await this.previewClient.getTradingDiagnostics(accountId); + if ( + !diagnostics.authenticated || + diagnostics.competingSession || + diagnostics.selectedAccountId !== accountId + ) { + throw new Error("Broker account/session is not safe for execution"); + } + return diagnostics; + } + + private assertEnvironmentAllowed(accountId: string, environment: BrokerEnvironment): void { + if (environment === "paper") return; + if (!this.livePolicy.enabled || !this.livePolicy.accountAllowlist.includes(accountId)) { + throw new Error("Live execution requires explicit enablement and exact account allowlisting"); + } + } + + private async requiredExpectation(orderId: string, accountId: string): Promise { + const expectation = await this.store.loadOrder(orderId); + if (expectation === undefined) throw new Error("Unknown guarded order identity"); + if (expectation.accountDigest !== this.accountDigest(accountId)) { + throw new Error("Order account does not match"); + } + return expectation; + } + + private verifyLifecycle( + lifecycle: DerivativeOrderLifecycle, + expectation: OrderExpectation + ): void { + const preview = expectation.preview; + const expectedLegs = preview.order.legs.map(({ contract, ratio }) => ({ + conid: Number(contract.brokerReference?.contractId), + ratio, + })); + if (JSON.stringify(lifecycle.legs) !== JSON.stringify(expectedLegs)) { + throw new Error("Returned combo legs or ratios do not match the preview"); + } + if (lifecycle.quantity !== preview.order.quantity) { + throw new Error("Returned order quantity does not match the preview"); + } + const expectedLimit = + preview.order.priceEffect === "CREDIT" ? -preview.order.limit : preview.order.limit; + if (lifecycle.limitPrice !== expectedLimit) { + throw new Error("Returned order limit does not match the preview"); + } + if (lifecycle.clientOrderId !== null && lifecycle.clientOrderId !== expectation.clientOrderId) { + throw new Error("Returned client order ID does not match"); + } + } + + private lifecycleDto( + lifecycle: DerivativeOrderLifecycle, + environment: BrokerEnvironment + ): OrderLifecycleDto { + const { accountId, ...rest } = lifecycle; + return { + ...rest, + account: { maskedId: maskAccountId(accountId), environment }, + verifiedAgainstPreview: true, + }; + } + + private accountDigest(accountId: string): string { + return createHash("sha256").update(accountId).digest("hex"); + } +} diff --git a/src/derivatives/derivativePreview.ts b/src/derivatives/derivativePreview.ts index 2912a14..6f1dbe3 100644 --- a/src/derivatives/derivativePreview.ts +++ b/src/derivatives/derivativePreview.ts @@ -14,7 +14,7 @@ export interface TradingDiagnostics { export interface DerivativeComboPreviewRequest { accountId: string; - legs: [ + legs: readonly [ { contract: DerivativeContract; ratio: 1 | -1 }, { contract: DerivativeContract; ratio: 1 | -1 }, ]; diff --git a/src/derivatives/derivativePreviewService.test.ts b/src/derivatives/derivativePreviewService.test.ts index 2e32722..af895bc 100644 --- a/src/derivatives/derivativePreviewService.test.ts +++ b/src/derivatives/derivativePreviewService.test.ts @@ -1,9 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { DerivativeContract, DerivativeDiscoveryClient } from "./derivativeDiscovery.js"; import type { DerivativePreviewClient } from "./derivativePreview.js"; import { DerivativePreviewService, + FilePreviewStore, maskAccountId, type PreviewVerticalRequest, } from "./derivativePreviewService.js"; @@ -90,7 +94,10 @@ void test("preview DTO masks the account and binds exact economic terms", async 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" }), + await service.validatePreview(first.previewId, { + accountId: "U1234567", + environment: "paper", + }), first ); }); @@ -99,12 +106,12 @@ void test("preview validation rejects account/environment mismatch and expiry", 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( + await assert.rejects( () => service.validatePreview(result.previewId, { accountId: "U999", environment: "paper" }), /account or environment/ ); now = new Date("2026-07-29T12:01:00.000Z"); - assert.throws( + await assert.rejects( () => service.validatePreview(result.previewId, { accountId: "U1234567", @@ -114,6 +121,42 @@ void test("preview validation rejects account/environment mismatch and expiry", ); }); +void test("file preview store supports separate processes without persisting full account IDs", async () => { + const directory = await mkdtemp(join(tmpdir(), "huskly-preview-test-")); + try { + const now = new Date("2026-07-29T12:00:00.000Z"); + const writer = new DerivativePreviewService( + discovery, + preview, + () => now, + 60_000, + new FilePreviewStore(directory) + ); + const result = await writer.previewVertical(request()); + const reader = new DerivativePreviewService( + discovery, + preview, + () => now, + 60_000, + new FilePreviewStore(directory) + ); + assert.equal( + ( + await reader.validatePreview(result.previewId, { + accountId: "U1234567", + environment: "paper", + }) + ).previewId, + result.previewId + ); + const [filename] = await readdir(directory); + assert.ok(filename); + assert.doesNotMatch(await readFile(join(directory, filename), "utf8"), /U1234567/); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + void test("account masking covers short paper fixtures", () => { assert.equal(maskAccountId("DU123456"), "D***456"); assert.equal(maskAccountId("DU12"), "D***"); diff --git a/src/derivatives/derivativePreviewService.ts b/src/derivatives/derivativePreviewService.ts index 663ef7f..a801849 100644 --- a/src/derivatives/derivativePreviewService.ts +++ b/src/derivatives/derivativePreviewService.ts @@ -1,4 +1,8 @@ import { createHash } from "node:crypto"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; import type { DerivativeContract, DerivativeDiscoveryClient, @@ -52,10 +56,136 @@ export interface SpreadPreviewDto { interface StoredPreview { dto: SpreadPreviewDto; - accountId: string; + accountDigest: string; environment: BrokerEnvironment; } +const marginSchema = z + .object({ current: z.number(), change: z.number(), after: z.number() }) + .nullable(); +const identitySchema = z.object({ + assetClass: z.enum(["OPT", "FOP"]), + underlying: z.string(), + expiration: z.string(), + strike: z.number(), + right: z.enum(["CALL", "PUT"]), + tradingClass: z.string(), + exchange: z.string(), + multiplier: z.number(), + settlement: z.string().optional(), + exerciseStyle: z.string().optional(), +}); +const contractSchema = z.object({ + identity: identitySchema, + brokerReference: z + .object({ broker: z.enum(["schwab", "ibkr"]), contractId: z.string() }) + .optional(), +}); +export const spreadPreviewDtoSchema = z.object({ + previewId: z.string().regex(/^[a-f0-9]{64}$/), + createdAt: z.iso.datetime(), + expiresAt: z.iso.datetime(), + account: z.object({ maskedId: z.string(), environment: z.enum(["live", "paper"]) }), + order: z.object({ + kind: z.enum(["call-debit", "call-credit", "put-debit", "put-credit"]), + legs: z.tuple([ + z.object({ side: z.literal("LONG"), ratio: z.literal(1), contract: contractSchema }), + z.object({ side: z.literal("SHORT"), ratio: z.literal(-1), contract: contractSchema }), + ]), + quantity: z.number().int().positive(), + priceEffect: z.enum(["CREDIT", "DEBIT"]), + limit: z.number().positive(), + tif: z.enum(["DAY", "GTC"]), + session: z.enum(["REGULAR", "OVERNIGHT"]), + }), + whatIf: z.object({ + accepted: z.boolean(), + submitted: z.literal(false), + commission: z.number().nullable(), + initialMargin: marginSchema, + maintenanceMargin: marginSchema, + warnings: z.array(z.string()), + rejectionReasons: z.array(z.string()), + advisoryAssetPermissions: z.array(z.string()), + }), + submitted: z.literal(false), +}); +const storedPreviewSchema = z.object({ + accountDigest: z.string().regex(/^[a-f0-9]{64}$/), + environment: z.enum(["live", "paper"]), + dto: spreadPreviewDtoSchema, +}); + +export interface PreviewStore { + save(previewId: string, preview: StoredPreview): Promise; + load(previewId: string): Promise; + delete(previewId: string): Promise; +} + +export class InMemoryPreviewStore implements PreviewStore { + private readonly previews = new Map(); + + save(previewId: string, preview: StoredPreview): Promise { + this.previews.set(previewId, preview); + return Promise.resolve(); + } + + load(previewId: string): Promise { + return Promise.resolve(this.previews.get(previewId)); + } + + delete(previewId: string): Promise { + this.previews.delete(previewId); + return Promise.resolve(); + } +} + +/** Owner-readable preview persistence for separate CLI invocations. */ +export class FilePreviewStore implements PreviewStore { + constructor( + private readonly directory = process.env["HUSKLY_PREVIEW_DIR"] ?? + join(homedir(), ".cache", "huskly-cli", "previews") + ) {} + + async save(previewId: string, preview: StoredPreview): Promise { + this.assertPreviewId(previewId); + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + await writeFile(join(this.directory, `${previewId}.json`), JSON.stringify(preview), { + encoding: "utf8", + mode: 0o600, + }); + } + + async load(previewId: string): Promise { + this.assertPreviewId(previewId); + try { + const parsed = storedPreviewSchema.parse( + JSON.parse(await readFile(join(this.directory, `${previewId}.json`), "utf8")) as unknown + ); + if (parsed.dto.previewId !== previewId) throw new Error("Preview file identity mismatch"); + // Zod represents optional keys as `T | undefined`; the domain uses exact optional keys. + // The schema has already validated every persisted execution-sensitive field. + return parsed as unknown as StoredPreview; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + + async delete(previewId: string): Promise { + this.assertPreviewId(previewId); + try { + await unlink(join(this.directory, `${previewId}.json`)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + private assertPreviewId(previewId: string): void { + if (!/^[a-f0-9]{64}$/.test(previewId)) throw new Error("Invalid preview ID"); + } +} + function rightForKind(kind: VerticalSpreadKind): DerivativeRight { return kind.startsWith("call") ? "CALL" : "PUT"; } @@ -67,13 +197,12 @@ export function maskAccountId(accountId: string): string { /** 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 + private readonly ttlMs = 5 * 60 * 1000, + private readonly store: PreviewStore = new InMemoryPreviewStore() ) {} getTradingDiagnostics(accountId: string): Promise { @@ -163,25 +292,28 @@ export class DerivativePreviewService { }, submitted: false, }; - this.previews.set(previewId, { + await this.store.save(previewId, { dto, - accountId: request.accountId, + accountDigest: this.accountDigest(request.accountId), environment: result.environment, }); return dto; } - validatePreview( + async validatePreview( previewId: string, context: { accountId: string; environment: BrokerEnvironment } - ): SpreadPreviewDto { - const stored = this.previews.get(previewId); + ): Promise { + const stored = await this.store.load(previewId); if (stored === undefined) throw new Error("Unknown preview ID"); if (this.now().getTime() >= new Date(stored.dto.expiresAt).getTime()) { - this.previews.delete(previewId); + await this.store.delete(previewId); throw new Error("Preview has expired"); } - if (stored.accountId !== context.accountId || stored.environment !== context.environment) { + if ( + stored.accountDigest !== this.accountDigest(context.accountId) || + stored.environment !== context.environment + ) { throw new Error("Preview account or environment does not match"); } if (!stored.dto.whatIf.accepted) { @@ -189,4 +321,12 @@ export class DerivativePreviewService { } return stored.dto; } + + async consumePreview(previewId: string): Promise { + await this.store.delete(previewId); + } + + private accountDigest(accountId: string): string { + return createHash("sha256").update(accountId).digest("hex"); + } } diff --git a/src/derivatives/ibkrDerivativeAdapter.test.ts b/src/derivatives/ibkrDerivativeAdapter.test.ts index 33b7d25..2b6f4fe 100644 --- a/src/derivatives/ibkrDerivativeAdapter.test.ts +++ b/src/derivatives/ibkrDerivativeAdapter.test.ts @@ -42,6 +42,10 @@ function fakeApi(overrides: Partial = {}): IbkrDeriv advisoryAssetPermissions: ["OPT"], }), previewDerivativeCombo: () => Promise.reject(new Error("not used")), + submitDerivativeCombo: () => Promise.reject(new Error("not used")), + acknowledgeOrderWarning: () => Promise.reject(new Error("not used")), + getDerivativeOrderStatus: () => Promise.reject(new Error("not used")), + cancelDerivativeOrder: () => Promise.reject(new Error("not used")), ...overrides, }; } diff --git a/src/derivatives/ibkrDerivativeAdapter.ts b/src/derivatives/ibkrDerivativeAdapter.ts index 2264ef8..eeb3e7d 100644 --- a/src/derivatives/ibkrDerivativeAdapter.ts +++ b/src/derivatives/ibkrDerivativeAdapter.ts @@ -16,6 +16,12 @@ import type { DerivativePreviewClient, TradingDiagnostics, } from "./derivativePreview.js"; +import type { + DerivativeComboExecutionRequest, + DerivativeExecutionClient, + DerivativeOrderLifecycle, + DerivativeOrderSubmissionResult, +} from "./derivativeExecution.js"; type IbkrOptionRight = "C" | "P"; @@ -105,6 +111,32 @@ export interface IbkrDerivativeDiscoveryApi { tif: "DAY" | "GTC"; session: "REGULAR" | "OVERNIGHT"; }): Promise; + submitDerivativeCombo(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"; + clientOrderId: string; + extOperator: string; + manualIndicator: boolean; + }): Promise; + acknowledgeOrderWarning(input: { + replyId: string; + confirmed: true; + }): Promise; + getDerivativeOrderStatus(accountId: string, orderId: string): Promise; + cancelDerivativeOrder(input: { + accountId: string; + orderId: string; + extOperator: string; + manualIndicator: boolean; + }): Promise; } function toIbkrRight(right: DerivativeRight): IbkrOptionRight { @@ -206,7 +238,9 @@ 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, DerivativePreviewClient { +export class IbkrDerivativeAdapter + implements DerivativeDiscoveryClient, DerivativePreviewClient, DerivativeExecutionClient +{ constructor(private readonly client: IbkrDerivativeDiscoveryApi) {} async getExpiries(request: DerivativeExpiryRequest): Promise { @@ -265,4 +299,39 @@ export class IbkrDerivativeAdapter implements DerivativeDiscoveryClient, Derivat ], }); } + + submitDerivativeCombo( + request: DerivativeComboExecutionRequest + ): Promise { + return this.client.submitDerivativeCombo({ + ...request, + legs: request.legs.map(({ contract, ratio }) => ({ + contract: ibkrContract(contract), + ratio, + })) as [ + { contract: IbkrDerivativeContract; ratio: 1 | -1 }, + { contract: IbkrDerivativeContract; ratio: 1 | -1 }, + ], + }); + } + + acknowledgeOrderWarning(input: { + replyId: string; + confirmed: true; + }): Promise { + return this.client.acknowledgeOrderWarning(input); + } + + getDerivativeOrderStatus(accountId: string, orderId: string): Promise { + return this.client.getDerivativeOrderStatus(accountId, orderId); + } + + cancelDerivativeOrder(input: { + accountId: string; + orderId: string; + extOperator: string; + manualIndicator: boolean; + }): Promise { + return this.client.cancelDerivativeOrder(input); + } } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 33dc1c2..656252f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,6 +10,7 @@ import { registerGetVixLevelTool } from "#src/mcp/tools/vix.js"; import { registerGetOptionChainTool } from "#src/mcp/tools/optionChain.js"; import { registerGetOptionExpiriesTool } from "#src/mcp/tools/optionExpiries.js"; import { registerPlaceOptionOrderTool } from "#src/mcp/tools/placeOptionOrder.js"; +import { registerDerivativeTools } from "#src/mcp/tools/derivatives.js"; async function main(): Promise { const server = new McpServer({ name: "huskly-cli-mcp", version: "1.0.0" }); @@ -23,6 +24,7 @@ async function main(): Promise { registerGetOptionChainTool(server); registerGetOptionExpiriesTool(server); registerPlaceOptionOrderTool(server); + registerDerivativeTools(server); const transport = new StdioServerTransport(); const closed = new Promise((resolve) => { diff --git a/src/mcp/tools/derivatives.ts b/src/mcp/tools/derivatives.ts new file mode 100644 index 0000000..f0d47f1 --- /dev/null +++ b/src/mcp/tools/derivatives.ts @@ -0,0 +1,253 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + derivativeDiscoveryClient, + derivativeExecutionClient, + derivativePreviewClient, +} from "#src/derivatives/derivativeClient.js"; +import { DerivativeResearchService } from "#src/derivatives/derivativeResearch.js"; +import { + DerivativePreviewService, + FilePreviewStore, +} from "#src/derivatives/derivativePreviewService.js"; +import { + DerivativeExecutionService, + FileExecutionStateStore, +} from "#src/derivatives/derivativeExecutionService.js"; +import { jsonResult, runTool } from "#src/mcp/toolResult.js"; + +const assetClass = z.enum(["OPT", "FOP"]); +const spreadKind = z.enum(["call-debit", "call-credit", "put-debit", "put-credit"]); +const series = { + assetClass, + underlying: z.string().min(1), + expiration: z.iso.date(), + tradingClass: z.string().min(1).optional(), + exchange: z.string().min(1).optional(), +}; +const account = z.string().min(1).describe("Exact IBKR account ID; never returned unmasked"); +const confirmed = z.literal(true).describe("Must be exactly true for this broker write"); + +function exactSeries(input: { + assetClass: "OPT" | "FOP"; + underlying: string; + expiration: string; + tradingClass?: string | undefined; + exchange?: string | undefined; +}) { + return { + assetClass: input.assetClass, + underlying: input.underlying.toUpperCase(), + expiration: input.expiration, + ...(input.tradingClass !== undefined ? { tradingClass: input.tradingClass.toUpperCase() } : {}), + ...(input.exchange !== undefined ? { exchange: input.exchange.toUpperCase() } : {}), + }; +} + +interface DerivativeTools { + research: DerivativeResearchService; + preview: DerivativePreviewService; + execution: DerivativeExecutionService; +} + +let toolsPromise: Promise | undefined; + +async function derivativeTools(): Promise { + toolsPromise ??= (async () => { + const [discovery, previewClient, executionClient] = await Promise.all([ + derivativeDiscoveryClient("ibkr"), + derivativePreviewClient("ibkr"), + derivativeExecutionClient("ibkr"), + ]); + const preview = new DerivativePreviewService( + discovery, + previewClient, + () => new Date(), + 5 * 60 * 1000, + new FilePreviewStore() + ); + return { + research: new DerivativeResearchService(discovery), + preview, + execution: new DerivativeExecutionService( + discovery, + previewClient, + executionClient, + preview, + new FileExecutionStateStore() + ), + }; + })(); + return toolsPromise; +} + +export function registerDerivativeTools(server: McpServer): void { + server.registerTool( + "get_derivative_chain", + { + title: "Get an exact derivative chain", + description: + "Resolve and quote an IBKR option or futures-option series. Broker references are opaque and non-durable.", + inputSchema: { + ...series, + right: z.enum(["CALL", "PUT"]).optional(), + around: z.number().optional(), + strikes: z.number().int().nonnegative().default(10), + }, + }, + async (input) => + runTool(async () => + jsonResult( + await ( + await derivativeTools() + ).research.chain({ + ...exactSeries(input), + ...(input.right !== undefined ? { right: input.right } : {}), + ...(input.around !== undefined ? { around: input.around } : {}), + strikes: input.strikes, + }) + ) + ) + ); + + server.registerTool( + "quote_option_spread", + { + title: "Quote a vertical option spread", + description: + "Research a vertical from individual IBKR leg markets. This is not an executable combo preview.", + inputSchema: { + ...series, + kind: spreadKind, + longStrike: z.number().positive(), + shortStrike: z.number().positive(), + quantity: z.number().int().positive().default(1), + limit: z.number().positive().optional(), + }, + }, + async (input) => + runTool(async () => + jsonResult( + await ( + await derivativeTools() + ).research.quoteVertical({ + ...exactSeries(input), + kind: input.kind, + longStrike: input.longStrike, + shortStrike: input.shortStrike, + quantity: input.quantity, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + ) + ) + ); + + server.registerTool( + "preview_option_spread_order", + { + title: "Preview an IBKR vertical spread order", + description: + "Run a non-submitting IBKR What-If and persist a short-lived exact preview ID. No order is submitted.", + inputSchema: { + ...series, + accountId: account, + kind: spreadKind, + longStrike: z.number().positive(), + shortStrike: z.number().positive(), + quantity: z.number().int().positive().default(1), + priceEffect: z.enum(["CREDIT", "DEBIT"]), + limit: z.number().positive(), + tif: z.enum(["DAY", "GTC"]).default("DAY"), + session: z.enum(["REGULAR", "OVERNIGHT"]).default("REGULAR"), + }, + }, + async (input) => + runTool(async () => + jsonResult( + await ( + await derivativeTools() + ).preview.previewVertical({ + ...exactSeries(input), + accountId: input.accountId, + kind: input.kind, + longStrike: input.longStrike, + shortStrike: input.shortStrike, + quantity: input.quantity, + priceEffect: input.priceEffect, + limit: input.limit, + tif: input.tif, + session: input.session, + }) + ) + ) + ); + + server.registerTool( + "submit_option_spread_order", + { + title: "Submit an exact reviewed IBKR spread preview", + description: + "Submit only an unexpired preview after account, environment, and contract revalidation. Live execution also requires the process allowlist policy.", + inputSchema: { + previewId: z.string().regex(/^[a-f0-9]{64}$/), + accountId: account, + operator: z.string().min(1).describe("CME operator identity"), + confirm: confirmed, + }, + }, + async (input) => + runTool(async () => jsonResult(await (await derivativeTools()).execution.submit(input))) + ); + + server.registerTool( + "acknowledge_order_warning", + { + title: "Acknowledge a known IBKR order warning", + description: + "Continue one exact warning reply for one exact preview. Unknown warning IDs fail closed.", + inputSchema: { + previewId: z.string().regex(/^[a-f0-9]{64}$/), + replyId: z.string().min(1), + accountId: account, + confirm: confirmed, + }, + }, + async (input) => + runTool(async () => + jsonResult(await (await derivativeTools()).execution.acknowledgeWarning(input)) + ) + ); + + server.registerTool( + "get_order_status", + { + title: "Get guarded IBKR derivative order status", + description: + "Fetch fresh lifecycle state and verify the broker order against its persisted exact preview.", + inputSchema: { orderId: z.string().min(1), accountId: account }, + }, + async ({ orderId, accountId }) => + runTool(async () => + jsonResult(await (await derivativeTools()).execution.getStatus(orderId, accountId)) + ) + ); + + server.registerTool( + "cancel_order", + { + title: "Cancel a guarded IBKR derivative order", + description: + "Request cancellation, then poll until a verified CANCELED state. Other terminal states are returned as errors.", + inputSchema: { + orderId: z.string().min(1), + accountId: account, + operator: z.string().min(1).describe("CME operator identity"), + confirm: confirmed, + timeoutMs: z.number().int().positive().max(900_000).default(300_000), + pollMs: z.number().int().positive().max(30_000).default(2_000), + }, + }, + async (input) => + runTool(async () => jsonResult(await (await derivativeTools()).execution.cancel(input))) + ); +} From 91c231cfe2e7ba06b9159b79d84f8e7bf5e55d04 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Wed, 29 Jul 2026 13:14:32 -0700 Subject: [PATCH 2/2] chore: use ibkr execution release --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 11d0c36..5b6e912 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "typescript-eslint": "^8.61.1" }, "dependencies": { - "@huskly/ibkr-client": "^0.10.0", + "@huskly/ibkr-client": "^0.11.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 54c652f..a0d5387 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.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== +"@huskly/ibkr-client@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@huskly/ibkr-client/-/ibkr-client-0.11.0.tgz#702b1dff9dc5a4ccf39fa2c1922233d4f3c828e6" + integrity sha512-cDxezYpjKpVNgKdwnr0E6B6TESIKgepA9+v/j8fdLgFZQ1pe5+n+u78ZDJJQnI8OCmJCUZ5ztb89Y7DxfDi9ng== dependencies: dotenv "^17.2.3" ibkr-client "^1.0.4"