From 772ce99b71ef280e98cda06e27b07573b2398fcf Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Fri, 25 Sep 2026 11:38:50 -0700 Subject: [PATCH] feat: support IBKR spot FX orders --- README.md | 52 +- package.json | 2 +- src/brokers/brokerClient.ts | 2 +- src/brokers/ibkrBrokerAdapter.ts | 2 +- src/cli/equityOrders.ts | 92 +-- src/cli/forexOrders.ts | 271 +++++++++ src/cli/guardedOrderCommands.ts | 87 +++ src/cli/orders.ts | 33 +- src/cli/program.ts | 2 + src/derivatives/derivativeExecutionService.ts | 16 +- src/derivatives/derivativePreview.ts | 2 + src/derivatives/derivativePreviewService.ts | 3 + src/equities/equityOrder.ts | 38 +- src/equities/equityOrderService.ts | 504 ++-------------- src/forex/forexGatewayAdapter.ts | 48 ++ src/forex/forexOrder.ts | 56 ++ src/forex/forexOrderService.ts | 119 ++++ src/forex/forexPair.ts | 36 ++ src/format.ts | 53 +- src/gateway/gatewayMutationAdapter.ts | 5 + src/mcp/server.ts | 5 +- src/mcp/tools/forexOrders.ts | 121 ++++ src/options/optionOrderService.ts | 4 +- src/orders/singleOrderWorkflow.ts | 539 ++++++++++++++++++ test/cli/derivatives.test.ts | 1 + test/cli/equityOrders.test.ts | 1 + test/cli/forexOrders.test.ts | 260 +++++++++ test/cli/orders.test.ts | 41 ++ .../derivativeExecutionService.test.ts | 1 + .../derivativePreviewService.test.ts | 5 + test/equities/equityOrderService.test.ts | 1 + test/forex/forexOrderService.test.ts | 235 ++++++++ test/forex/forexPair.test.ts | 30 + test/format.test.ts | 23 + test/gateway/equityGatewayAdapter.test.ts | 2 + test/gateway/gatewayMutationAdapter.test.ts | 2 + test/mcp/derivatives.test.ts | 1 + test/mcp/equityOrders.test.ts | 1 + test/mcp/forexOrders.test.ts | 94 +++ yarn.lock | 10 +- 40 files changed, 2223 insertions(+), 577 deletions(-) create mode 100644 src/cli/forexOrders.ts create mode 100644 src/cli/guardedOrderCommands.ts create mode 100644 src/forex/forexGatewayAdapter.ts create mode 100644 src/forex/forexOrder.ts create mode 100644 src/forex/forexOrderService.ts create mode 100644 src/forex/forexPair.ts create mode 100644 src/mcp/tools/forexOrders.ts create mode 100644 src/orders/singleOrderWorkflow.ts create mode 100644 test/cli/forexOrders.test.ts create mode 100644 test/forex/forexOrderService.test.ts create mode 100644 test/forex/forexPair.test.ts create mode 100644 test/format.test.ts create mode 100644 test/mcp/forexOrders.test.ts diff --git a/README.md b/README.md index 207796b..4fbddc0 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ The CLI and MCP server do not talk to IBKR directly. - Market data for quotes, search, price history, movers, charts, and VIX - Shared account reads for balances, positions, transactions, and orders - Exact derivative research for IBKR -- Guarded derivative and equity preview and order lifecycle tools for IBKR -- MCP server for read, derivative, and equity tools +- Guarded derivative, equity, and spot FX preview and order lifecycle tools for IBKR +- MCP server for read, derivative, equity, and spot FX tools - Schwab-only Redis caching ## Broker support @@ -48,6 +48,8 @@ huskly-cli --broker ibkr repl | `order show/watch/acknowledge/reconcile/cancel` | ✗ | ✓ | | `equity preview` | ✗ | ✓ | | `equity submit` | ✗ | ✓ | +| `fx preview` | ✗ | ✓ | +| `fx submit` | ✗ | ✓ | | `broker doctor` | ✓ | ✓ | | `account` | ✓ | ✓ | | `user-preference` | ✓ | ✗ | @@ -105,6 +107,29 @@ huskly-cli equity submit --operator alice --confirm `preview_equity_order` and `submit_equity_order` MCP tools. STOP is a native stop-market order, not stop-limit. +### Spot FX orders (IBKR only) + +```bash +huskly-cli fx preview USD.JPY BUY 25000 --limit 147.25 +huskly-cli fx preview EUR/USD SELL 30000 --limit 1.0850 --tif GTC --json +huskly-cli fx submit --operator alice --confirm +``` + +`fx preview` resolves one exact IDEALPRO currency pair and runs a What-If for +one LIMIT order. The pair can be `USD.JPY`, `USD/JPY`, or `USDJPY`. The side +buys or sells the base currency. The quantity is whole base-currency units, +for example `25000` for 25,000 USD. The limit is the quote-currency price of +one base unit. FX trades 24/5, so there is no `--session` option. + +IBKR checks the price increment and the minimum size. An order below the +IDEALPRO minimum can route as an odd lot at a worse price; the preview then +shows the IBKR warning. Commission and margin are in the account base +currency. The output shows limit prices in the quote currency. + +`fx preview` and `fx submit` drive the same guarded service as the +`fx_order_preview` and `fx_order_submit` MCP tools. Use the `order` commands +for status, recovery, reconciliation, and cancellation. + ### Single-leg option orders `place-option-order` takes the same arguments for both brokers. @@ -413,9 +438,26 @@ Then call `submit_equity_order` with only the preview ID, operator, and exact co Submission uses only the immutable terms in the unexpired preview. Use `get_order_status`, `acknowledge_order_warning`, `reconcile_order_operation`, and `cancel_order` for the returned operation ID. +### Guarded spot FX MCP workflow + +Call `fx_order_preview` first: + +```json +{ + "pair": "USD.JPY", + "side": "BUY", + "quantity": 25000, + "limit": 147.25 +} +``` + +Review the returned contract, What-If result, warnings, environment, and +expiry time. Then call `fx_order_submit` with only the preview ID, operator, +and exact confirmation, as for `submit_equity_order`. + ## MCP server -`huskly-cli-mcp` exposes read, derivative, and guarded equity tools over stdio. +`huskly-cli-mcp` exposes read, derivative, and guarded equity and spot FX tools over stdio. `place_option_order` stays Schwab-only. The IBKR tools use the same gateway transport and safety rules as the CLI. There is no direct broker fallback. @@ -438,8 +480,11 @@ src/ ├── brokers/ ├── cli/ ├── derivatives/ +├── equities/ +├── forex/ ├── gateway/ ├── mcp/ +├── orders/ ├── cache.ts ├── cachedSchwabClient.ts ├── helpers.ts @@ -465,6 +510,7 @@ test/ - `HUSKLY_LIVE_ACCOUNT_ALLOWLIST` - Comma-separated live accounts allowed for derivative execution - `HUSKLY_PREVIEW_DIR` - Private derivative preview state directory override - `HUSKLY_EQUITY_PREVIEW_DIR` - Private equity preview state directory override +- `HUSKLY_FOREX_PREVIEW_DIR` - Private spot FX preview state directory override - `HUSKLY_EXECUTION_DIR` - Private execution state directory override - `HUSKLY_IBKR_GATEWAY_CLI_CONFIG` - CLI gateway config path override - `HUSKLY_IBKR_GATEWAY_MCP_CONFIG` - MCP gateway config path override diff --git a/package.json b/package.json index 5b30f24..fccfba1 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "typescript-eslint": "^8.69.0" }, "dependencies": { - "@huskly/ibkr-gateway-client": "0.17.0", + "@huskly/ibkr-gateway-client": "0.18.0", "@huskly/schwab-client": "^1.3.0", "@modelcontextprotocol/sdk": "^1.29.0", "asciichart": "^1.5.25", diff --git a/src/brokers/brokerClient.ts b/src/brokers/brokerClient.ts index 0b501c1..889bf7b 100644 --- a/src/brokers/brokerClient.ts +++ b/src/brokers/brokerClient.ts @@ -208,7 +208,7 @@ export interface BrokerOrderLeg { } | null; instruction?: string | null; brokerId?: number | null; - assetClass?: "STK" | "OPT" | "FOP" | null; + assetClass?: "STK" | "OPT" | "FOP" | "CASH" | null; ratio?: number | null; option?: BrokerOrderOption | null; uncertainty?: readonly string[]; diff --git a/src/brokers/ibkrBrokerAdapter.ts b/src/brokers/ibkrBrokerAdapter.ts index 3bc62da..d7056ac 100644 --- a/src/brokers/ibkrBrokerAdapter.ts +++ b/src/brokers/ibkrBrokerAdapter.ts @@ -300,7 +300,7 @@ const orderHistoryResponseSchema = z symbol: z.string().nullable(), instruction: z.string().nullable(), brokerId: z.number().int().positive().nullable().optional(), - assetClass: z.enum(["STK", "OPT", "FOP"]).nullable().optional(), + assetClass: z.enum(["STK", "OPT", "FOP", "CASH"]).nullable().optional(), ratio: z.number().int().nullable().optional(), option: z .object({ diff --git a/src/cli/equityOrders.ts b/src/cli/equityOrders.ts index 03b35b6..0d753a5 100644 --- a/src/cli/equityOrders.ts +++ b/src/cli/equityOrders.ts @@ -10,20 +10,15 @@ import { cliGatewayTransport } from "#src/gateway/gatewayTransport.js"; import { renderSafeOperation, safeOperation, type SafeOperationView } from "./operationView.js"; import { requireOperator, type BrokerResolver } from "./shared.js"; import { createGatewayExecutionService } from "./gatewayExecutionService.js"; - -/** Every gateway-backed command declares the same broker flag and fallback. */ -const GATEWAY_BROKER_FLAG: readonly [string, string] = [ - "--broker ", - "Broker to use: schwab or ibkr (default: ibkr)", -]; - -interface WarningExecutionService { - acknowledgeWarning(input: { - readonly operationId: string; - readonly replyId: string; - readonly confirm: true; - }): Promise<{ readonly operation: EquitySubmissionDto["operation"] }>; -} +import { + acknowledgeWarnings, + confirmed, + GATEWAY_BROKER_FLAG, + output, + parseSide, + parseTif, + type WarningExecutionService, +} from "./guardedOrderCommands.js"; export interface EquityCommandDependencies { readonly createEquityOrders?: (broker: BrokerName) => Promise; @@ -78,22 +73,6 @@ interface SafeEquitySubmissionView { readonly acknowledgedWarnings: number; } -function side(value: string): "BUY" | "SELL" { - const normalized = value.toUpperCase(); - if (normalized !== "BUY" && normalized !== "SELL") { - throw new Error(`Invalid side '${value}'. Expected BUY or SELL.`); - } - return normalized; -} - -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") { @@ -114,11 +93,6 @@ function parsePrice(value: string): number { return Number(value); } -function confirmed(value: boolean | undefined): true { - if (value !== true) throw new Error("This operation requires --confirm."); - return true; -} - function orderView(intent: EquityPreviewDto["order"]): SafeEquityPreviewView["order"] { const base = { symbol: intent.contract.symbol, @@ -193,50 +167,6 @@ export function renderEquitySubmission(result: SafeEquitySubmissionView): string ].join("\n"); } -async function acknowledgeWarnings( - result: EquitySubmissionDto, - createExecutionService: () => Promise -): Promise<{ readonly result: EquitySubmissionDto; readonly count: number }> { - let operation = result.operation; - let count = 0; - const handled = new Set(); - let execution: WarningExecutionService | undefined; - - while (operation.state === "warning_pending") { - const warning = operation.pendingWarning; - if (warning === null) { - throw new Error("Broker operation is warning_pending without a warning reply"); - } - const identity = `${String(warning.sequence)}:${warning.replyId}`; - if (handled.has(identity)) { - throw new Error("Broker returned a repeated warning reply"); - } - if (handled.size >= 32) { - throw new Error("Broker returned too many sequential warnings"); - } - handled.add(identity); - execution ??= await createExecutionService(); - const acknowledged = await execution.acknowledgeWarning({ - operationId: operation.operationId, - replyId: warning.replyId, - confirm: true, - }); - operation = acknowledged.operation; - count += 1; - } - - return { result: { ...result, operation }, count }; -} - -function output( - value: T, - json: boolean | undefined, - render: (result: T) => string, - log: (line: string) => void -): void { - log(json === true ? JSON.stringify(value, null, 2) : render(value)); -} - let ordersPromise: Promise | undefined; async function equityOrders(broker: BrokerName): Promise { @@ -310,10 +240,10 @@ Examples: await createEquityOrders(broker(options.broker)) ).preview({ symbol: symbolValue.toUpperCase(), - side: side(sideValue), + side: parseSide(sideValue), quantity: shares(quantity), ...terms, - tif: tif(options.tif), + tif: parseTif(options.tif), session: session(options.session), }); output(toPreviewView(result), options.json, renderEquityPreview, log); diff --git a/src/cli/forexOrders.ts b/src/cli/forexOrders.ts new file mode 100644 index 0000000..cb67517 --- /dev/null +++ b/src/cli/forexOrders.ts @@ -0,0 +1,271 @@ +import { Command } from "commander"; +import type { BrokerName } from "#src/brokers/brokerClient.js"; +import { formatForexPrice, formatMoney } from "#src/format.js"; +import type { ForexPreviewDto, ForexSubmissionDto } from "#src/forex/forexOrderService.js"; +import { cliGatewayTransport } from "#src/gateway/gatewayTransport.js"; +import { createForexTools, type ForexTools } from "#src/mcp/tools/forexOrders.js"; +import { createGatewayExecutionService } from "./gatewayExecutionService.js"; +import { + acknowledgeWarnings, + confirmed, + GATEWAY_BROKER_FLAG, + output, + parseSide, + parseTif, + type WarningExecutionService, +} from "./guardedOrderCommands.js"; +import { renderSafeOperation, safeOperation, type SafeOperationView } from "./operationView.js"; +import { requireOperator, type BrokerResolver } from "./shared.js"; + +export interface ForexCommandDependencies { + readonly createForexOrders?: (broker: BrokerName) => Promise; + readonly createExecutionService?: (broker: BrokerName) => Promise; + readonly log?: (line: string) => void; +} + +interface PreviewOptions { + broker?: string; + limit?: string; + tif: string; + json?: boolean; +} + +interface SubmitOptions { + broker?: string; + operator?: string; + confirm?: boolean; + json?: boolean; +} + +/** The FX order as the terminal and `--json` show it. */ +interface SafeForexOrderView { + readonly pair: string; + readonly baseCurrency: string; + readonly quoteCurrency: string; + readonly side: "BUY" | "SELL"; + readonly quantity: number; + readonly orderType: "LMT"; + readonly limit: number; + readonly tif: "DAY" | "GTC"; +} + +interface SafeForexPreviewView { + readonly previewId: string; + readonly createdAt: string; + readonly expiresAt: string; + readonly environment: ForexPreviewDto["environment"]; + readonly account: ForexPreviewDto["account"]; + readonly order: SafeForexOrderView; + readonly whatIf: ForexPreviewDto["whatIf"]; + readonly submitted: false; +} + +interface SafeForexSubmissionView { + readonly previewId: string; + readonly environment: ForexSubmissionDto["environment"]; + readonly account: ForexSubmissionDto["account"]; + readonly order: SafeForexOrderView; + readonly operation: SafeOperationView; + readonly recovered: boolean; + readonly acknowledgedWarnings: number; +} + +function baseUnits(value: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error( + `Quantity must be a whole number of base-currency units above zero, got '${value}'.` + ); + } + return parsed; +} + +function limitPrice(value: string | undefined): number { + if (value === undefined) throw new Error("FX orders require --limit ."); + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`Invalid limit price '${value}'.`); + return parsed; +} + +function orderView(intent: ForexPreviewDto["order"]): SafeForexOrderView { + return { + pair: intent.contract.localSymbol, + baseCurrency: intent.contract.symbol, + quoteCurrency: intent.contract.currency, + side: intent.side, + quantity: intent.quantity, + orderType: intent.orderType, + limit: intent.limit, + tif: intent.tif, + }; +} + +function toPreviewView(result: ForexPreviewDto): SafeForexPreviewView { + return { + previewId: result.previewId, + createdAt: result.createdAt, + expiresAt: result.expiresAt, + environment: result.environment, + account: result.account, + order: orderView(result.order), + whatIf: result.whatIf, + submitted: result.submitted, + }; +} + +function toSubmissionView( + result: ForexSubmissionDto, + acknowledgedWarnings = 0 +): SafeForexSubmissionView { + return { + previewId: result.previewId, + environment: result.environment, + account: result.account, + order: orderView(result.order), + operation: safeOperation(result.operation), + recovered: result.recovered, + acknowledgedWarnings, + }; +} + +/** `BUY 25,000 USD.JPY limit ¥147.25`: units in the base currency, price in the quote currency. */ +function orderLine(order: SafeForexOrderView): string { + return `${order.side} ${formatMoney(order.quantity, order.baseCurrency, { minimum: 0, maximum: 0 })} ${order.pair} limit ${formatForexPrice(order.limit, order.quoteCurrency)}`; +} + +export function renderForexPreview(result: SafeForexPreviewView): string { + const money = (value: number | null | undefined): string => + formatMoney(value, result.whatIf.currency); + return [ + `Preview: ${result.previewId}`, + `Account: ${result.account.maskedId ?? "unknown"} Environment: ${result.environment}`, + `Created: ${result.createdAt}`, + `Expires: ${result.expiresAt}`, + `${orderLine(result.order)} ${result.order.tif}`, + `Accepted: ${String(result.whatIf.accepted)}`, + `Initial margin change: ${money(result.whatIf.initialMargin?.change)}`, + `Maintenance margin change: ${money(result.whatIf.maintenanceMargin?.change)}`, + `Commission/fees: ${money(result.whatIf.commission)}`, + `Warnings: ${result.whatIf.warnings.join(" | ") || "none"}`, + `Rejections: ${result.whatIf.rejectionReasons.join(" | ") || "none"}`, + "NO ORDER WAS SUBMITTED.", + ].join("\n"); +} + +export function renderForexSubmission(result: SafeForexSubmissionView): string { + return [ + `Submission: ${result.recovered ? "recovered" : "new"}`, + `Preview: ${result.previewId}`, + `Account: ${result.account.maskedId ?? "unknown"} Environment: ${result.environment}`, + orderLine(result.order), + ...renderSafeOperation(result.operation), + ...(result.acknowledgedWarnings > 0 + ? [`Broker warnings acknowledged automatically: ${String(result.acknowledgedWarnings)}`] + : []), + ].join("\n"); +} + +let ordersPromise: Promise | undefined; + +async function forexOrders(broker: BrokerName): Promise { + if (broker !== "ibkr") throw new Error("FX orders are available for IBKR only."); + ordersPromise ??= createForexTools({ resolveGatewayTransport: cliGatewayTransport }) + .then((tools) => tools.orders) + .catch((error: unknown) => { + ordersPromise = undefined; + throw error; + }); + return ordersPromise; +} + +/** + * Register the guarded IBKR spot FX order commands. + * + * @remarks + * These mirror the `fx_order_preview` and `fx_order_submit` MCP tools. Use the + * `order` commands for status, recovery, reconciliation, and cancellation. + */ +export function addForexCommands( + program: Command, + resolveBrokerFor: BrokerResolver, + dependencies: ForexCommandDependencies = {} +): void { + const createForexOrders = dependencies.createForexOrders ?? forexOrders; + const createExecutionService = + dependencies.createExecutionService ?? createGatewayExecutionService; + const log = dependencies.log ?? console.log; + const broker = (override: string | undefined): BrokerName => { + const selected = resolveBrokerFor(override, "ibkr"); + if (selected !== "ibkr") throw new Error("FX orders are available for IBKR only."); + return selected; + }; + + const fx = new Command("fx").description("Preview and submit guarded IBKR spot FX orders"); + + fx.command("preview") + .description("Run a non-submitting What-If for one IDEALPRO spot FX LIMIT order") + .argument("", "Currency pair: USD.JPY, USD/JPY, or USDJPY") + .argument("", "BUY or SELL the base currency") + .argument("", "Whole base-currency units, for example 25000") + .option("--limit ", "Quote-currency price of one base unit") + .option("--tif ", "DAY or GTC", "DAY") + .option(...GATEWAY_BROKER_FLAG) + .option("--json", "Emit a stable JSON DTO") + .addHelpText( + "after", + ` +This never submits an order. The preview ID expires after a short time. +IBKR checks the price increment and the minimum size. A small order can +route to IDEALPRO as an odd lot at a worse price; the preview then shows +the IBKR warning. + +Examples: + $ huskly-cli fx preview USD.JPY BUY 25000 --limit 147.25 + $ huskly-cli fx preview EUR/USD SELL 30000 --limit 1.0850 --tif GTC --json` + ) + .action(async (pair: string, sideValue: string, quantity: string, options: PreviewOptions) => { + const selectedBroker = broker(options.broker); + const input = { + pair, + side: parseSide(sideValue), + quantity: baseUnits(quantity), + limit: limitPrice(options.limit), + tif: parseTif(options.tif), + }; + const result = await (await createForexOrders(selectedBroker)).preview(input); + output(toPreviewView(result), options.json, renderForexPreview, log); + }); + + fx.command("submit") + .description("Submit the exact, unexpired reviewed FX preview") + .argument("", "Exact preview ID") + .option("--operator ", "Operator identity; defaults to HUSKLY_EXT_OPERATOR") + .option("--confirm", "Confirm this order submission") + .option(...GATEWAY_BROKER_FLAG) + .option("--json", "Emit a stable JSON DTO") + .addHelpText( + "after", + ` +This places a real order in the preview-bound IBKR environment. --confirm +also acknowledges broker warnings for this submission. Use the "order" +commands for status, recovery, reconciliation, and cancellation. + +Examples: + $ huskly-cli fx submit --confirm + $ huskly-cli fx submit --operator alice --confirm --json` + ) + .action(async (previewId: string, options: SubmitOptions) => { + const selectedBroker = broker(options.broker); + const confirm = confirmed(options.confirm); + const operator = requireOperator(options.operator); + const submitted = await ( + await createForexOrders(selectedBroker) + ).submit({ previewId, operator, confirm }); + const { result, count } = await acknowledgeWarnings(submitted, () => + createExecutionService(selectedBroker) + ); + output(toSubmissionView(result, count), options.json, renderForexSubmission, log); + }); + + program.addCommand(fx); +} diff --git a/src/cli/guardedOrderCommands.ts b/src/cli/guardedOrderCommands.ts new file mode 100644 index 0000000..cf6954f --- /dev/null +++ b/src/cli/guardedOrderCommands.ts @@ -0,0 +1,87 @@ +import type { OrderOperation } from "@huskly/ibkr-gateway-client"; + +/** Every gateway-backed command declares the same broker flag and fallback. */ +export const GATEWAY_BROKER_FLAG: readonly [string, string] = [ + "--broker ", + "Broker to use: schwab or ibkr (default: ibkr)", +]; + +export interface WarningExecutionService { + acknowledgeWarning(input: { + readonly operationId: string; + readonly replyId: string; + readonly confirm: true; + }): Promise<{ readonly operation: OrderOperation }>; +} + +export function parseSide(value: string): "BUY" | "SELL" { + const normalized = value.toUpperCase(); + if (normalized !== "BUY" && normalized !== "SELL") { + throw new Error(`Invalid side '${value}'. Expected BUY or SELL.`); + } + return normalized; +} + +export function parseTif(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; +} + +export function confirmed(value: boolean | undefined): true { + if (value !== true) throw new Error("This operation requires --confirm."); + return true; +} + +export function output( + value: T, + json: boolean | undefined, + render: (result: T) => string, + log: (line: string) => void +): void { + log(json === true ? JSON.stringify(value, null, 2) : render(value)); +} + +/** + * Acknowledge each broker warning of a new submission, one at a time. + * + * @remarks + * `--confirm` on a submit command also confirms its broker warnings. A + * repeated or endless warning sequence fails instead of looping. + */ +export async function acknowledgeWarnings( + result: T, + createExecutionService: () => Promise +): Promise<{ readonly result: T; readonly count: number }> { + let operation = result.operation; + let count = 0; + const handled = new Set(); + let execution: WarningExecutionService | undefined; + + while (operation.state === "warning_pending") { + const warning = operation.pendingWarning; + if (warning === null) { + throw new Error("Broker operation is warning_pending without a warning reply"); + } + const identity = `${String(warning.sequence)}:${warning.replyId}`; + if (handled.has(identity)) { + throw new Error("Broker returned a repeated warning reply"); + } + if (handled.size >= 32) { + throw new Error("Broker returned too many sequential warnings"); + } + handled.add(identity); + execution ??= await createExecutionService(); + const acknowledged = await execution.acknowledgeWarning({ + operationId: operation.operationId, + replyId: warning.replyId, + confirm: true, + }); + operation = acknowledged.operation; + count += 1; + } + + return { result: { ...result, operation }, count }; +} diff --git a/src/cli/orders.ts b/src/cli/orders.ts index da7234c..696a78d 100644 --- a/src/cli/orders.ts +++ b/src/cli/orders.ts @@ -11,7 +11,7 @@ import { type BrokerOrdersOptions, type Observation, } from "#src/brokers/brokerClient.js"; -import { currencyFormatUsd } from "#src/format.js"; +import { currencyFormatUsd, formatForexPrice } from "#src/format.js"; import { fetchOrderContractQuotes, formatOrderCurrentPrice, @@ -98,11 +98,34 @@ function getStatusColor(status: string | null | undefined): (text: string) => st function getIbkrLegSymbol(leg: BrokerOrderLeg): string { if (isVerifiedOptionLeg(leg)) return leg.option?.symbol ?? "-"; const raw = leg.instrument?.symbol ?? "-"; - return leg.assetClass === "STK" && Number.isSafeInteger(leg.brokerId) && (leg.brokerId ?? 0) > 0 + return (leg.assetClass === "STK" || leg.assetClass === "CASH") && + Number.isSafeInteger(leg.brokerId) && + (leg.brokerId ?? 0) > 0 ? raw : `${raw} (unresolved)`; } +/** + * A spot FX order states its prices in the quote currency. The quote currency + * comes from a `BASE.QUOTE` leg symbol; `null` when the broker named no pair. + */ +interface ForexPricing { + readonly quoteCurrency: string | null; +} + +function forexPricing(order: BrokerOrder): ForexPricing | null { + const legs = order.orderLegCollection; + const leg = legs?.[0]; + if (legs?.length !== 1 || leg?.assetClass !== "CASH") return null; + const pair = /^[A-Z]{3}\.(?[A-Z]{3})$/u.exec(leg.instrument?.symbol?.trim() ?? ""); + return { quoteCurrency: pair?.groups?.["quote"] ?? null }; +} + +function formatOrderMoney(order: BrokerOrder, value: number | null | undefined): string { + const forex = forexPricing(order); + return forex === null ? currencyFormatUsd(value) : formatForexPrice(value, forex.quoteCurrency); +} + function getOrderSymbol(order: BrokerOrder, broker: BrokerName): string { const legs = order.orderLegCollection; const firstLeg = legs?.[0]; @@ -133,10 +156,10 @@ function getOrderInstruction(order: BrokerOrder): string { function getOrderPrice(order: BrokerOrder): string { if (order.price !== undefined && order.price !== null) { - return currencyFormatUsd(order.price); + return formatOrderMoney(order, order.price); } if (order.stopPrice !== undefined && order.stopPrice !== null) { - return `Stop: ${currencyFormatUsd(order.stopPrice)}`; + return `Stop: ${formatOrderMoney(order, order.stopPrice)}`; } return "-"; } @@ -215,7 +238,7 @@ export function renderOrdersObservation( order.filledQuantity?.toString() ?? (order.filledQuantity === null ? "-" : "0"); lines.push( - `${chalk.gray(formatColumn(dateLabel, COLUMN_WIDTHS.date))} ${statusColor(formatColumn(status, COLUMN_WIDTHS.status))} ${chalk.white(formatColumn(orderType, COLUMN_WIDTHS.type))} ${chalk.white(formatColumn(tif, COLUMN_WIDTHS.tif))} ${chalk.white(formatColumn(session, COLUMN_WIDTHS.session))} ${chalk.cyan(formatColumn(symbol, COLUMN_WIDTHS.symbol))} ${chalk.white(formatColumn(instruction, COLUMN_WIDTHS.instruction))} ${chalk.white(formatColumn(quantity, COLUMN_WIDTHS.quantity, "right"))} ${chalk.yellow(formatColumn(price, COLUMN_WIDTHS.price, "right"))}${showCurrent ? ` ${chalk.green(formatColumn(order.averageFillPrice == null ? "-" : currencyFormatUsd(order.averageFillPrice), COLUMN_WIDTHS.averageFill, "right"))} ${chalk.white(formatColumn(formatOrderCurrentPrice(order, currentQuotes), COLUMN_WIDTHS.current, "right"))}` : ""} ${chalk.green(formatColumn(filled, COLUMN_WIDTHS.filled, "right"))}` + `${chalk.gray(formatColumn(dateLabel, COLUMN_WIDTHS.date))} ${statusColor(formatColumn(status, COLUMN_WIDTHS.status))} ${chalk.white(formatColumn(orderType, COLUMN_WIDTHS.type))} ${chalk.white(formatColumn(tif, COLUMN_WIDTHS.tif))} ${chalk.white(formatColumn(session, COLUMN_WIDTHS.session))} ${chalk.cyan(formatColumn(symbol, COLUMN_WIDTHS.symbol))} ${chalk.white(formatColumn(instruction, COLUMN_WIDTHS.instruction))} ${chalk.white(formatColumn(quantity, COLUMN_WIDTHS.quantity, "right"))} ${chalk.yellow(formatColumn(price, COLUMN_WIDTHS.price, "right"))}${showCurrent ? ` ${chalk.green(formatColumn(order.averageFillPrice == null ? "-" : formatOrderMoney(order, order.averageFillPrice), COLUMN_WIDTHS.averageFill, "right"))} ${chalk.white(formatColumn(formatOrderCurrentPrice(order, currentQuotes), COLUMN_WIDTHS.current, "right"))}` : ""} ${chalk.green(formatColumn(filled, COLUMN_WIDTHS.filled, "right"))}` ); if ( showCurrent && diff --git a/src/cli/program.ts b/src/cli/program.ts index 7d45012..e68e830 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -22,6 +22,7 @@ import { packageVersion } from "./packageVersion.js"; import type { BrokerName } from "#src/brokers/brokerClient.js"; import { addDerivativeCommands } from "./derivatives.js"; import { addEquityCommands } from "./equityOrders.js"; +import { addForexCommands } from "./forexOrders.js"; /** Session-wide settings that a REPL fixes once and every command inherits. */ export interface ProgramSession { @@ -274,6 +275,7 @@ Omit the expiry to use the nearest listed expiry.` addDerivativeCommands(program, broker); addEquityCommands(program, broker); + addForexCommands(program, broker); program .command("account") diff --git a/src/derivatives/derivativeExecutionService.ts b/src/derivatives/derivativeExecutionService.ts index c1ccc53..90a3b0a 100644 --- a/src/derivatives/derivativeExecutionService.ts +++ b/src/derivatives/derivativeExecutionService.ts @@ -6,6 +6,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import type { CanonicalEquityIntent } from "#src/equities/equityOrder.js"; +import { canonicalForexIntentSchema, type CanonicalForexIntent } from "#src/forex/forexOrder.js"; import type { CanonicalSingleOptionIntent } from "#src/options/optionOrder.js"; import type { DerivativeDiscoveryClient } from "./derivativeDiscovery.js"; import type { @@ -46,13 +47,14 @@ export interface ComboSubmissionRecord extends SubmissionRecordBase { * One guarded single-instrument submission. * * @remarks - * The gateway uses the same `single` operation kind for an equity order and a - * single-leg option order, so both share one durable record shape. The intent - * contract asset class separates them. + * The gateway uses the same `single` operation kind for an equity order, a + * spot FX order, and a single-leg option order, so all share one durable + * record shape. The intent contract asset class separates them. */ export interface SingleSubmissionRecord extends SubmissionRecordBase { readonly operationKind: "single"; - readonly canonicalIntent: CanonicalEquityIntent | CanonicalSingleOptionIntent; + readonly canonicalIntent: + CanonicalEquityIntent | CanonicalForexIntent | CanonicalSingleOptionIntent; readonly operator: string; readonly intentHash: string; readonly account: { @@ -374,7 +376,11 @@ const submissionSchema = z.discriminatedUnion("operationKind", [ z.strictObject({ ...submissionBaseSchema, operationKind: z.literal("single"), - canonicalIntent: z.union([executionEquityIntentSchema, executionOptionIntentSchema]), + canonicalIntent: z.union([ + executionEquityIntentSchema, + canonicalForexIntentSchema, + executionOptionIntentSchema, + ]), operator: z.string().min(1).max(64), intentHash: z.string().regex(/^[a-f0-9]{64}$/u), account: z.strictObject({ diff --git a/src/derivatives/derivativePreview.ts b/src/derivatives/derivativePreview.ts index 8fff9d3..a1dfb2b 100644 --- a/src/derivatives/derivativePreview.ts +++ b/src/derivatives/derivativePreview.ts @@ -65,6 +65,8 @@ export interface DerivativeComboPreviewResult { warnings: string[]; rejectionReasons: string[]; advisoryAssetPermissions: string[]; + /** The account base currency of the commission and both margin impacts. */ + currency: string; } export interface DerivativePreviewClient { diff --git a/src/derivatives/derivativePreviewService.ts b/src/derivatives/derivativePreviewService.ts index daa5fbe..bdb1b00 100644 --- a/src/derivatives/derivativePreviewService.ts +++ b/src/derivatives/derivativePreviewService.ts @@ -110,6 +110,7 @@ const derivativeComboPreviewResultSchema = z.strictObject({ warnings: z.array(z.string()), rejectionReasons: z.array(z.string()), advisoryAssetPermissions: z.array(z.string()), + currency: z.string().regex(/^[A-Z]{3}$/u), }); export const spreadPreviewDtoSchema = z.strictObject({ previewId: z.string().regex(/^[a-f0-9]{64}$/), @@ -141,6 +142,7 @@ export const spreadPreviewDtoSchema = z.strictObject({ warnings: z.array(z.string()), rejectionReasons: z.array(z.string()), advisoryAssetPermissions: z.array(z.string()), + currency: z.string().regex(/^[A-Z]{3}$/u), }), submitted: z.literal(false), }); @@ -282,6 +284,7 @@ function toSpreadPreviewDto(record: StoredPreviewRecord): SpreadPreviewDto { warnings: record.previewResult.warnings, rejectionReasons: record.previewResult.rejectionReasons, advisoryAssetPermissions: record.previewResult.advisoryAssetPermissions, + currency: record.previewResult.currency, }, submitted: false, }; diff --git a/src/equities/equityOrder.ts b/src/equities/equityOrder.ts index f486e9c..323011d 100644 --- a/src/equities/equityOrder.ts +++ b/src/equities/equityOrder.ts @@ -1,5 +1,8 @@ -import type { OrderOperation } from "@huskly/ibkr-gateway-client"; -import type { BrokerEnvironment, MarginImpact } from "#src/derivatives/derivativePreview.js"; +import type { + SingleOrderGateway, + SingleOrderPreviewResult, + SingleOrderTradingDiagnostics, +} from "#src/orders/singleOrderWorkflow.js"; export interface EquityContract { readonly conid: number; @@ -22,34 +25,9 @@ export type CanonicalEquityIntent = { readonly session: "REGULAR" | "OVERNIGHT"; } & EquityPriceTerms; -export interface EquityPreviewResult { - readonly environment: BrokerEnvironment; - readonly accepted: boolean; - readonly submitted: false; - readonly commission: number | null; - readonly initialMargin: MarginImpact | null; - readonly maintenanceMargin: MarginImpact | null; - readonly warnings: readonly string[]; - readonly rejectionReasons: readonly string[]; - readonly advisoryAssetPermissions: readonly string[]; -} - -export interface EquityTradingDiagnostics { - readonly environment: BrokerEnvironment; - readonly accountVerified: boolean; - readonly newMutationReady: boolean; - readonly recoveryMutationReady: boolean; - readonly maskedAccountDisplay: string; -} +export type EquityPreviewResult = SingleOrderPreviewResult; +export type EquityTradingDiagnostics = SingleOrderTradingDiagnostics; -export interface EquityGatewayClient { - getTradingDiagnostics(): Promise; +export interface EquityGatewayClient extends SingleOrderGateway { resolveContract(symbol: string): Promise; - preview(intent: CanonicalEquityIntent): Promise; - create( - intent: CanonicalEquityIntent, - idempotencyKey: string, - operator: string - ): Promise; - lookup(idempotencyKey: string): Promise; } diff --git a/src/equities/equityOrderService.ts b/src/equities/equityOrderService.ts index b368c27..3203609 100644 --- a/src/equities/equityOrderService.ts +++ b/src/equities/equityOrderService.ts @@ -1,21 +1,22 @@ -import { createHash, randomUUID } from "node:crypto"; -import { readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; -import { PrivateJsonFile } from "#src/storage/privateJsonFile.js"; +import type { SingleSubmissionRecord } from "#src/derivatives/derivativeExecutionService.js"; import { - FileExecutionStateStore, - type ExecutionStateStore, - type SubmissionRecord, -} from "#src/derivatives/derivativeExecutionService.js"; -import type { OrderOperation } from "@huskly/ibkr-gateway-client"; -import type { CanonicalSingleOptionIntent } from "#src/options/optionOrder.js"; -import type { - CanonicalEquityIntent, - EquityGatewayClient, - EquityPreviewResult, -} from "./equityOrder.js"; + FileSingleOrderPreviewStore, + FileSingleOrderSubmissionStore, + InMemorySingleOrderPreviewStore, + InMemorySingleOrderSubmissionStore, + SingleOrderWorkflow, + singleOrderPreviewRecordSchema, + type SingleOrderPreviewDto, + type SingleOrderPreviewRecord, + type SingleOrderPreviewStore, + type SingleOrderSubmissionDto, + type SingleOrderSubmissionRecord, + type SingleOrderSubmissionStore, +} from "#src/orders/singleOrderWorkflow.js"; +import type { CanonicalEquityIntent, EquityGatewayClient } from "./equityOrder.js"; export type NormalizedEquityTerms = | { readonly orderType: "LIMIT"; readonly limit: number } @@ -80,65 +81,12 @@ export type PreviewEquityOrderInput = { readonly session?: "REGULAR" | "OVERNIGHT"; } & NormalizedEquityTerms; -export interface EquityPreviewRecord { - readonly schemaVersion: 1; - readonly previewId: string; - readonly nonce: string; - readonly createdAt: string; - readonly expiresAt: string; - readonly environment: "live" | "paper"; - readonly account: { - readonly maskedId: string | null; - readonly environment: "live" | "paper"; - }; - readonly canonicalIntent: CanonicalEquityIntent; - readonly previewResult: EquityPreviewResult; -} - -export interface EquityPreviewDto { - readonly previewId: string; - readonly createdAt: string; - readonly expiresAt: string; - readonly environment: "live" | "paper"; - readonly account: { - readonly maskedId: string | null; - readonly environment: "live" | "paper"; - }; - readonly order: CanonicalEquityIntent; - readonly whatIf: Omit; - readonly submitted: false; -} - -export interface EquitySubmissionRecord { - readonly schemaVersion: 1; - readonly previewId: string; - readonly operationKind: "single"; - readonly idempotencyKey: string; - readonly canonicalIntent: CanonicalEquityIntent; - readonly operator: string; - readonly intentHash: string; - readonly account: { - readonly maskedId: string | null; - readonly environment: "live" | "paper"; - }; - readonly state: "submission_pending" | "submission_uncertain" | "operation_known"; - readonly operationId: string | null; - readonly operation: OrderOperation | null; - readonly createdAt: string; - readonly updatedAt: string; -} - -export interface EquitySubmissionDto { - readonly previewId: string; - readonly environment: "live" | "paper"; - readonly account: { - readonly maskedId: string | null; - readonly environment: "live" | "paper"; - }; - readonly order: CanonicalEquityIntent; - readonly operation: OrderOperation; - readonly recovered: boolean; -} +export type EquityPreviewRecord = SingleOrderPreviewRecord; +export type EquityPreviewDto = SingleOrderPreviewDto; +export type EquitySubmissionRecord = SingleOrderSubmissionRecord; +export type EquitySubmissionDto = SingleOrderSubmissionDto; +export type EquityPreviewStore = SingleOrderPreviewStore; +export type EquitySubmissionStore = SingleOrderSubmissionStore; const contractSchema = z.strictObject({ conid: z.number().int().positive(), @@ -167,372 +115,81 @@ export const canonicalEquityIntentSchema = z.discriminatedUnion("orderType", [ stopPrice: z.number().positive(), }), ]); -const marginSchema = z - .strictObject({ current: z.number(), change: z.number(), after: z.number() }) - .nullable(); -const previewResultSchema = z.strictObject({ - environment: z.enum(["live", "paper"]), - 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()), -}); -const previewRecordSchema = z.strictObject({ - schemaVersion: z.literal(1), - previewId: z.string().regex(/^[a-f0-9]{64}$/), - nonce: z.uuid(), - createdAt: z.iso.datetime(), - expiresAt: z.iso.datetime(), - environment: z.enum(["live", "paper"]), - account: z.strictObject({ - maskedId: z.string().nullable(), - environment: z.enum(["live", "paper"]), - }), - canonicalIntent: canonicalEquityIntentSchema, - previewResult: previewResultSchema, -}); -export interface EquityPreviewStore { - create(value: EquityPreviewRecord): Promise; - load(previewId: string): Promise; - delete(previewId: string): Promise; - pruneExpired(now: Date): Promise; -} +export class InMemoryEquityPreviewStore extends InMemorySingleOrderPreviewStore {} -export interface EquitySubmissionStore { - reserve(value: EquitySubmissionRecord): Promise; - load(previewId: string): Promise; - save(value: EquitySubmissionRecord): Promise; -} +export class InMemoryEquitySubmissionStore extends InMemorySingleOrderSubmissionStore {} -export class InMemoryEquityPreviewStore implements EquityPreviewStore { - private readonly values = new Map(); - public create(value: EquityPreviewRecord): Promise { - if (this.values.has(value.previewId)) return Promise.resolve(false); - this.values.set(value.previewId, structuredClone(value)); - return Promise.resolve(true); - } - public load(previewId: string): Promise { - const value = this.values.get(previewId); - return Promise.resolve(value === undefined ? undefined : structuredClone(value)); - } - public delete(previewId: string): Promise { - this.values.delete(previewId); - return Promise.resolve(); - } - public pruneExpired(now: Date): Promise { - const cutoff = now.getTime(); - for (const [previewId, value] of this.values) { - if (new Date(value.expiresAt).getTime() <= cutoff) this.values.delete(previewId); - } - return Promise.resolve(); - } -} - -export class InMemoryEquitySubmissionStore implements EquitySubmissionStore { - private readonly values = new Map(); - public reserve(value: EquitySubmissionRecord): Promise { - if (this.values.has(value.previewId)) return Promise.resolve(false); - this.values.set(value.previewId, structuredClone(value)); - return Promise.resolve(true); - } - public load(previewId: string): Promise { - const value = this.values.get(previewId); - return Promise.resolve(value === undefined ? undefined : structuredClone(value)); - } - public save(value: EquitySubmissionRecord): Promise { - this.values.set(value.previewId, structuredClone(value)); - return Promise.resolve(); - } -} - -export class FileEquityPreviewStore implements EquityPreviewStore { +export class FileEquityPreviewStore extends FileSingleOrderPreviewStore { public constructor( - private readonly directory = process.env["HUSKLY_EQUITY_PREVIEW_DIR"] ?? + directory = process.env["HUSKLY_EQUITY_PREVIEW_DIR"] ?? join(homedir(), ".cache", "huskly-cli", "equity-previews") - ) {} - public create(value: EquityPreviewRecord): Promise { - return this.file(value.previewId).create(value); - } - public async load(previewId: string): Promise { - validatePreviewId(previewId); - const value = await this.file(previewId).load(); - if (value !== undefined && value.previewId !== previewId) - throw new Error("Preview file identity mismatch"); - return value; - } - public delete(previewId: string): Promise { - validatePreviewId(previewId); - return this.file(previewId).delete(); - } - public async pruneExpired(now: Date): Promise { - let names: string[]; - try { - names = await readdir(this.directory); - } catch (error: unknown) { - if (isNodeErrorWithCode(error, "ENOENT")) return; - throw error; - } - if (names.length > 4096) throw new Error("Equity preview directory is too large"); - const cutoff = now.getTime(); - for (const name of names) { - const match = /^([a-f0-9]{64})\.json$/u.exec(name); - if (match?.[1] === undefined) continue; - const preview = await this.load(match[1]); - if (preview !== undefined && new Date(preview.expiresAt).getTime() <= cutoff) - await this.delete(preview.previewId); - } - } - private file(previewId: string): PrivateJsonFile { - validatePreviewId(previewId); - return new PrivateJsonFile({ - directory: this.directory, - filename: `${previewId}.json`, - schema: previewRecordSchema, - maxBytes: 64 * 1024, - }); + ) { + super(directory, singleOrderPreviewRecordSchema(canonicalEquityIntentSchema)); } } -export class FileEquitySubmissionStore implements EquitySubmissionStore { - private readonly store: ExecutionStateStore; +export class FileEquitySubmissionStore extends FileSingleOrderSubmissionStore { public constructor( directory = process.env["HUSKLY_EXECUTION_DIR"] ?? join(homedir(), ".cache", "huskly-cli", "execution") ) { - this.store = new FileExecutionStateStore(directory); - } - public reserve(value: EquitySubmissionRecord): Promise { - return this.store.reserveSubmission(value satisfies SubmissionRecord); - } - public async load(previewId: string): Promise { - validatePreviewId(previewId); - const value = await this.store.loadSubmission(previewId); - if (value === undefined) return undefined; - if (value.operationKind !== "single" || !isEquityIntent(value.canonicalIntent)) - throw new Error("Submission record is not an equity order"); - return { ...value, canonicalIntent: value.canonicalIntent }; - } - public save(value: EquitySubmissionRecord): Promise { - return this.store.saveSubmission(value satisfies SubmissionRecord); + super(directory, isEquityIntent, "an equity order"); } } export class EquityOrderService { + private readonly workflow: SingleOrderWorkflow; + public constructor( private readonly gateway: EquityGatewayClient, - private readonly previews: EquityPreviewStore, - private readonly submissions: EquitySubmissionStore, - private readonly now: () => Date = () => new Date(), - private readonly ttlMs = 5 * 60 * 1000, - private readonly nonce: () => string = randomUUID, - private readonly key: () => string = randomUUID + previews: EquityPreviewStore, + submissions: EquitySubmissionStore, + now?: () => Date, + ttlMs?: number, + nonce?: () => string, + key?: () => string ) { - if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) throw new Error("Invalid preview TTL"); + this.workflow = new SingleOrderWorkflow({ + gateway, + intentSchema: canonicalEquityIntentSchema, + previews, + submissions, + ...(now === undefined ? {} : { now }), + ...(ttlMs === undefined ? {} : { ttlMs }), + ...(nonce === undefined ? {} : { nonce }), + ...(key === undefined ? {} : { key }), + }); } - public async preview(input: PreviewEquityOrderInput): Promise { - await this.previews.pruneExpired(this.now()); + public preview(input: PreviewEquityOrderInput): Promise { const symbol = normalizeSymbol(input.symbol); - const diagnostics = await this.gateway.getTradingDiagnostics(); - requireMutationReady(diagnostics); - const contract = await this.gateway.resolveContract(symbol); - if (contract.symbol !== symbol) throw new Error("Resolved equity symbol does not match"); - const canonicalIntent = canonicalEquityIntentSchema.parse({ - contract, - side: input.side, - quantity: input.quantity, - tif: input.tif ?? "DAY", - session: input.session ?? "REGULAR", - ...normalizedTermsToCanonical(input), + return this.workflow.preview(async () => { + const contract = await this.gateway.resolveContract(symbol); + if (contract.symbol !== symbol) throw new Error("Resolved equity symbol does not match"); + return canonicalEquityIntentSchema.parse({ + contract, + side: input.side, + quantity: input.quantity, + tif: input.tif ?? "DAY", + session: input.session ?? "REGULAR", + ...normalizedTermsToCanonical(input), + }); }); - const previewResult = await this.gateway.preview(canonicalIntent); - if (previewResult.environment !== diagnostics.environment) - throw new Error("Preview environment does not match gateway diagnostics"); - const createdAt = this.now(); - const recordWithoutId = { - schemaVersion: 1 as const, - nonce: this.nonce(), - createdAt: createdAt.toISOString(), - expiresAt: new Date(createdAt.getTime() + this.ttlMs).toISOString(), - environment: diagnostics.environment, - account: { - maskedId: diagnostics.maskedAccountDisplay, - environment: diagnostics.environment, - }, - canonicalIntent, - previewResult, - }; - const previewId = hash(recordWithoutId); - const record = previewRecordSchema.parse({ ...recordWithoutId, previewId }); - if (!(await this.previews.create(record))) throw new Error("Duplicate preview ID"); - return previewDto(record); } - public async submit(input: { + public submit(input: { readonly previewId: string; readonly operator: string; readonly confirm: boolean; }): Promise { - if (!input.confirm) throw new Error("Confirmation must be exactly true"); - validatePreviewId(input.previewId); - const operator = validateOperator(input.operator); - const existing = await this.submissions.load(input.previewId); - if (existing !== undefined) return this.recoverExisting(existing, operator); - - const preview = await this.requiredPreview(input.previewId); - const diagnostics = await this.gateway.getTradingDiagnostics(); - requireMutationReady(diagnostics); - if (diagnostics.environment !== preview.environment) - throw new Error("Preview environment does not match the current gateway"); - const intentHash = hash(preview.canonicalIntent); - const now = this.now().toISOString(); - const pending: EquitySubmissionRecord = { - schemaVersion: 1, - previewId: preview.previewId, - operationKind: "single", - idempotencyKey: this.key(), - canonicalIntent: preview.canonicalIntent, - operator, - intentHash, - account: preview.account, - state: "submission_pending", - operationId: null, - operation: null, - createdAt: now, - updatedAt: now, - }; - if (!(await this.submissions.reserve(pending))) { - const raced = await this.submissions.load(preview.previewId); - if (raced === undefined) throw new Error("Submission reservation is unavailable"); - this.validateExisting(raced, operator, preview); - return this.recoverExisting(raced, operator, diagnostics); - } - let operation: OrderOperation; - try { - operation = await this.gateway.create( - preview.canonicalIntent, - pending.idempotencyKey, - operator - ); - validateOperation(operation); - } catch (error: unknown) { - await this.submissions.save({ - ...pending, - state: "submission_uncertain", - updatedAt: this.now().toISOString(), - }); - throw error; - } - await this.submissions.save({ - ...pending, - state: "operation_known", - operationId: operation.operationId, - operation, - updatedAt: this.now().toISOString(), - }); - return submissionDto(pending, operation, false); - } - - private async requiredPreview(previewId: string): Promise { - validatePreviewId(previewId); - const preview = await this.previews.load(previewId); - if (preview === undefined) throw new Error("Unknown preview ID"); - const { previewId: _previewId, ...payload } = preview; - if (hash(payload) !== previewId) throw new Error("Preview content hash mismatch"); - if (this.now().getTime() >= new Date(preview.expiresAt).getTime()) { - await this.previews.delete(previewId); - throw new Error("Preview has expired"); - } - if (!preview.previewResult.accepted) throw new Error("Preview was rejected by broker What-If"); - return preview; - } - - private validateExisting( - existing: EquitySubmissionRecord, - operator: string, - preview?: EquityPreviewRecord - ): void { - if ( - existing.operator !== operator || - existing.intentHash !== hash(existing.canonicalIntent) || - (preview !== undefined && - (existing.previewId !== preview.previewId || - existing.account.environment !== preview.environment || - existing.account.maskedId !== preview.account.maskedId || - existing.intentHash !== hash(preview.canonicalIntent))) - ) - throw new Error("Submission reservation does not match the preview"); - } - - private async recoverExisting( - record: EquitySubmissionRecord, - operator: string, - knownDiagnostics?: Awaited> - ): Promise { - this.validateExisting(record, operator); - if (record.state === "operation_known" && record.operation !== null) { - validateOperation(record.operation); - return submissionDto(record, record.operation, true); - } - const diagnostics = knownDiagnostics ?? (await this.gateway.getTradingDiagnostics()); - requireRecoveryReady(diagnostics); - if (diagnostics.environment !== record.account.environment) - throw new Error("Submission environment does not match the current gateway"); - const operation = await this.gateway.lookup(record.idempotencyKey); - validateOperation(operation); - await this.submissions.save({ - ...record, - state: "operation_known", - operationId: operation.operationId, - operation, - updatedAt: this.now().toISOString(), - }); - return submissionDto(record, operation, true); + return this.workflow.submit(input); } } -function previewDto(record: EquityPreviewRecord): EquityPreviewDto { - const { environment: _environment, ...whatIf } = record.previewResult; - return { - previewId: record.previewId, - createdAt: record.createdAt, - expiresAt: record.expiresAt, - environment: record.environment, - account: record.account, - order: record.canonicalIntent, - whatIf, - submitted: false, - }; -} - -function submissionDto( - record: Pick, - operation: OrderOperation, - recovered: boolean -): EquitySubmissionDto { - return { - previewId: record.previewId, - environment: record.account.environment, - account: record.account, - order: record.canonicalIntent, - operation, - recovered, - }; -} - -function validateOperation(operation: OrderOperation): void { - if (operation.kind !== "single" || operation.operationId.length === 0) - throw new Error("Gateway returned an invalid equity operation"); -} - -/** A `single` submission record can hold an equity or an option intent. */ +/** A `single` submission record can hold an equity, forex, or option intent. */ function isEquityIntent( - intent: CanonicalEquityIntent | CanonicalSingleOptionIntent + intent: SingleSubmissionRecord["canonicalIntent"] ): intent is CanonicalEquityIntent { return intent.contract.assetClass === "STK"; } @@ -543,44 +200,9 @@ function normalizeSymbol(value: string): string { return symbol; } -function validatePreviewId(value: string): void { - if (!/^[a-f0-9]{64}$/.test(value)) throw new Error("Invalid preview ID"); -} - -function validateOperator(value: string): string { - if (value.length < 1 || value.length > 64 || value.trim() !== value || /[^ -~]/.test(value)) - throw new Error("Invalid operator identity"); - return value; -} - -function requireMutationReady(diagnostics: { - readonly environment: "live" | "paper"; - readonly accountVerified: boolean; - readonly newMutationReady: boolean; -}): void { - if (!diagnostics.accountVerified || !diagnostics.newMutationReady) - throw new Error("Gateway is not ready for a new order mutation"); -} - -function requireRecoveryReady(diagnostics: { - readonly accountVerified: boolean; - readonly recoveryMutationReady: boolean; -}): void { - if (!diagnostics.accountVerified || !diagnostics.recoveryMutationReady) - throw new Error("Gateway is not ready for order recovery"); -} - -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} - function normalizedTermsToCanonical( input: NormalizedEquityTerms ): { orderType: "LMT"; limit: number } | { orderType: "STP"; stopPrice: number } { if (input.orderType === "STOP") return { orderType: "STP", stopPrice: input.stopPrice }; return { orderType: "LMT", limit: input.limit }; } - -function hash(value: unknown): string { - return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); -} diff --git a/src/forex/forexGatewayAdapter.ts b/src/forex/forexGatewayAdapter.ts new file mode 100644 index 0000000..aeae2d6 --- /dev/null +++ b/src/forex/forexGatewayAdapter.ts @@ -0,0 +1,48 @@ +import type { OrderOperation } from "@huskly/ibkr-gateway-client"; +import { normalizeDiagnostics } from "#src/derivatives/ibkrDerivativeAdapter.js"; +import { maskAccountId } from "#src/derivatives/derivativePreviewService.js"; +import type { GatewayMutationApi } from "#src/gateway/gatewayMutationAdapter.js"; +import type { + SingleOrderPreviewResult, + SingleOrderTradingDiagnostics, +} from "#src/orders/singleOrderWorkflow.js"; +import type { CanonicalForexIntent, ForexContract, ForexGatewayClient } from "./forexOrder.js"; + +export class ForexGatewayAdapter implements ForexGatewayClient { + public constructor(private readonly api: GatewayMutationApi) {} + + public async getTradingDiagnostics(): Promise { + const diagnostics = normalizeDiagnostics(await this.api.getDiagnostics()); + return { + environment: diagnostics.environment, + accountVerified: diagnostics.accountVerified, + newMutationReady: diagnostics.newMutationReady, + recoveryMutationReady: diagnostics.recoveryMutationReady, + maskedAccountDisplay: maskAccountId(diagnostics.accountId), + }; + } + + public async resolveContract(pair: string): Promise { + const result = await this.api.resolveForexContract({ pair }); + return result.contract; + } + + public preview(intent: CanonicalForexIntent): Promise { + return this.api.previewOrders(intent); + } + + public create( + intent: CanonicalForexIntent, + idempotencyKey: string, + operator: string + ): Promise { + return this.api.createOrderOperation( + { kind: "single", ...intent, extOperator: operator, manualIndicator: true }, + idempotencyKey + ); + } + + public lookup(idempotencyKey: string): Promise { + return this.api.lookupOrderOperation({ kind: "single", idempotencyKey }); + } +} diff --git a/src/forex/forexOrder.ts b/src/forex/forexOrder.ts new file mode 100644 index 0000000..b050849 --- /dev/null +++ b/src/forex/forexOrder.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import type { + SingleOrderGateway, + SingleOrderPreviewResult, +} from "#src/orders/singleOrderWorkflow.js"; + +/** One exact IDEALPRO spot FX pair. `symbol` is the base and `currency` is the quote currency. */ +export interface ForexContract { + readonly conid: number; + readonly assetClass: "CASH"; + readonly symbol: string; + readonly currency: string; + readonly localSymbol: string; + readonly exchange: "IDEALPRO"; +} + +/** + * One spot FX LIMIT order as the gateway receives it. + * + * @remarks + * `side` buys or sells the base currency. `quantity` is whole base-currency + * units. `limit` is the quote-currency price of one base unit. FX trades + * 24/5, so there is no session. + */ +export interface CanonicalForexIntent { + readonly contract: ForexContract; + readonly side: "BUY" | "SELL"; + readonly quantity: number; + readonly tif: "DAY" | "GTC"; + readonly orderType: "LMT"; + readonly limit: number; +} + +export type ForexPreviewResult = SingleOrderPreviewResult; + +export interface ForexGatewayClient extends SingleOrderGateway { + resolveContract(pair: string): Promise; +} + +const currencyCode = z.string().regex(/^[A-Z]{3}$/u); + +export const canonicalForexIntentSchema: z.ZodType = z.strictObject({ + contract: z.strictObject({ + conid: z.number().int().positive(), + assetClass: z.literal("CASH"), + symbol: currencyCode, + currency: currencyCode, + localSymbol: z.string().regex(/^[A-Z]{3}\.[A-Z]{3}$/u), + exchange: z.literal("IDEALPRO"), + }), + side: z.enum(["BUY", "SELL"]), + quantity: z.number().int().positive(), + tif: z.enum(["DAY", "GTC"]), + orderType: z.literal("LMT"), + limit: z.number().positive(), +}); diff --git a/src/forex/forexOrderService.ts b/src/forex/forexOrderService.ts new file mode 100644 index 0000000..9c7e006 --- /dev/null +++ b/src/forex/forexOrderService.ts @@ -0,0 +1,119 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { SingleSubmissionRecord } from "#src/derivatives/derivativeExecutionService.js"; +import { + FileSingleOrderPreviewStore, + FileSingleOrderSubmissionStore, + SingleOrderWorkflow, + singleOrderPreviewRecordSchema, + type SingleOrderPreviewDto, + type SingleOrderPreviewStore, + type SingleOrderSubmissionDto, + type SingleOrderSubmissionStore, +} from "#src/orders/singleOrderWorkflow.js"; +import { + canonicalForexIntentSchema, + type CanonicalForexIntent, + type ForexGatewayClient, +} from "./forexOrder.js"; +import { parseForexPair } from "./forexPair.js"; + +export interface PreviewForexOrderInput { + /** `USD.JPY`, `USD/JPY`, or `USDJPY`. */ + readonly pair: string; + readonly side: "BUY" | "SELL"; + /** Whole base-currency units. */ + readonly quantity: number; + /** Quote-currency price of one base unit. */ + readonly limit: number; + readonly tif?: "DAY" | "GTC"; +} + +export type ForexPreviewDto = SingleOrderPreviewDto; +export type ForexSubmissionDto = SingleOrderSubmissionDto; +export type ForexPreviewStore = SingleOrderPreviewStore; +export type ForexSubmissionStore = SingleOrderSubmissionStore; + +export class FileForexPreviewStore extends FileSingleOrderPreviewStore { + public constructor( + directory = process.env["HUSKLY_FOREX_PREVIEW_DIR"] ?? + join(homedir(), ".cache", "huskly-cli", "forex-previews") + ) { + super(directory, singleOrderPreviewRecordSchema(canonicalForexIntentSchema)); + } +} + +export class FileForexSubmissionStore extends FileSingleOrderSubmissionStore { + public constructor( + directory = process.env["HUSKLY_EXECUTION_DIR"] ?? + join(homedir(), ".cache", "huskly-cli", "execution") + ) { + super(directory, isForexIntent, "a forex order"); + } +} + +export interface ForexOrderServiceOptions { + readonly gateway: ForexGatewayClient; + readonly previews: ForexPreviewStore; + readonly submissions: ForexSubmissionStore; + readonly now?: () => Date; + readonly ttlMs?: number; + readonly nonce?: () => string; + readonly key?: () => string; +} + +/** + * Guarded IDEALPRO spot FX LIMIT orders. + * + * @remarks + * The preview/submit flow is the shared single-order workflow. The service + * adds only pair resolution and the FX order terms. IBKR checks the price + * increment and the minimum size, and the preview keeps its warnings, for + * example an odd-lot route for a small order. + */ +export class ForexOrderService { + private readonly gateway: ForexGatewayClient; + private readonly workflow: SingleOrderWorkflow; + + public constructor(options: ForexOrderServiceOptions) { + this.gateway = options.gateway; + this.workflow = new SingleOrderWorkflow({ + ...options, + intentSchema: canonicalForexIntentSchema, + }); + } + + public preview(input: PreviewForexOrderInput): Promise { + const pair = parseForexPair(input.pair); + if (!Number.isSafeInteger(input.quantity) || input.quantity <= 0) + throw new Error("FX quantity must be a whole number of base-currency units above zero."); + if (!Number.isFinite(input.limit) || input.limit <= 0) + throw new Error(`Invalid limit price '${String(input.limit)}'.`); + return this.workflow.preview(async () => { + const contract = await this.gateway.resolveContract(pair.pair); + if (contract.localSymbol !== pair.pair) throw new Error("Resolved FX pair does not match"); + return { + contract, + side: input.side, + quantity: input.quantity, + tif: input.tif ?? "DAY", + orderType: "LMT", + limit: input.limit, + }; + }); + } + + public submit(input: { + readonly previewId: string; + readonly operator: string; + readonly confirm: boolean; + }): Promise { + return this.workflow.submit(input); + } +} + +function isForexIntent( + intent: SingleSubmissionRecord["canonicalIntent"] +): intent is CanonicalForexIntent { + return intent.contract.assetClass === "CASH"; +} diff --git a/src/forex/forexPair.ts b/src/forex/forexPair.ts new file mode 100644 index 0000000..ac5d09b --- /dev/null +++ b/src/forex/forexPair.ts @@ -0,0 +1,36 @@ +/** One spot FX pair in IBKR `BASE.QUOTE` form. */ +export interface ForexPair { + readonly base: string; + readonly quote: string; + /** The IBKR pair name, for example `USD.JPY`. */ + readonly pair: string; +} + +const pairForms = [ + /^(?[A-Z]{3})\.(?[A-Z]{3})$/u, + /^(?[A-Z]{3})\/(?[A-Z]{3})$/u, + /^(?[A-Z]{3})(?[A-Z]{3})$/u, +]; + +/** + * Parse one spot FX pair and normalize it to `BASE.QUOTE`. + * + * @remarks + * Accepts `USD.JPY`, `USD/JPY`, and `USDJPY`, in any letter case. Each side + * must be a three-letter currency code, and the two sides must differ. + * + * @example + * parseForexPair("usd/jpy"); // { base: "USD", quote: "JPY", pair: "USD.JPY" } + */ +export function parseForexPair(value: string): ForexPair { + const text = value.trim().toUpperCase(); + for (const form of pairForms) { + const groups = form.exec(text)?.groups; + const base = groups?.["base"]; + const quote = groups?.["quote"]; + if (base === undefined || quote === undefined) continue; + if (base === quote) throw new Error(`Invalid FX pair '${value}': base and quote are the same.`); + return { base, quote, pair: `${base}.${quote}` }; + } + throw new Error(`Invalid FX pair '${value}'. Expected BASE.QUOTE, for example USD.JPY.`); +} diff --git a/src/format.ts b/src/format.ts index 5c7f86a..973c55a 100644 --- a/src/format.ts +++ b/src/format.ts @@ -38,7 +38,58 @@ export function formatVolume(value: number | null | undefined): string { if (value >= 1_000) return (value / 1_000).toFixed(2) + "K"; return value.toFixed(0); } +export interface FractionDigits { + readonly minimum: number; + readonly maximum: number; +} + +/** + * Format an amount in one ISO 4217 currency, for example `¥3,681` or `$12.50`. + * Returns "-" for missing values. + * + * @remarks + * Without `fractionDigits` the currency's own minor unit applies, so JPY shows + * no decimals. Pass `fractionDigits` for a price that needs more precision, + * such as an FX rate. + */ +export function formatMoney( + value: number | null | undefined, + currencyCode: string, + fractionDigits?: FractionDigits +): string { + if (value === undefined || value === null) return "-"; + return value.toLocaleString("en-US", { + style: "currency", + currency: currencyCode, + ...(fractionDigits === undefined + ? {} + : { + minimumFractionDigits: fractionDigits.minimum, + maximumFractionDigits: fractionDigits.maximum, + }), + }); +} + export function currencyFormatUsd(value: number | null | undefined): string { + return formatMoney(value, "USD"); +} + +const FOREX_PRICE_DIGITS: FractionDigits = { minimum: 2, maximum: 6 }; + +/** + * Format an FX price: the quote-currency amount of one base unit, for example + * `¥147.255` for USD.JPY. IDEALPRO prices have more decimals than the quote + * currency's minor unit. Without a known quote currency the price has no symbol. + */ +export function formatForexPrice( + value: number | null | undefined, + quoteCurrency: string | null +): string { if (value === undefined || value === null) return "-"; - return value.toLocaleString("en-US", { style: "currency", currency: "USD" }); + if (quoteCurrency === null) + return value.toLocaleString("en-US", { + minimumFractionDigits: FOREX_PRICE_DIGITS.minimum, + maximumFractionDigits: FOREX_PRICE_DIGITS.maximum, + }); + return formatMoney(value, quoteCurrency, FOREX_PRICE_DIGITS); } diff --git a/src/gateway/gatewayMutationAdapter.ts b/src/gateway/gatewayMutationAdapter.ts index ff50683..11af26b 100644 --- a/src/gateway/gatewayMutationAdapter.ts +++ b/src/gateway/gatewayMutationAdapter.ts @@ -13,6 +13,8 @@ import type { PreviewOrdersRequest, PreviewOrdersResponse, ReconciliationResponse, + ResolveForexContractRequest, + ResolveForexContractResponse, } from "@huskly/ibkr-gateway-client"; import type { GatewayTransport } from "./gatewayTransport.js"; import type { @@ -67,6 +69,7 @@ import type { export interface GatewayMutationApi { getDiagnostics(): Promise; resolveEquityContract(body: EquityContractRequest): Promise; + resolveForexContract(body: ResolveForexContractRequest): Promise; previewOrders(body: GatewayPreviewRequest): Promise; createOrderOperation( body: GatewayCreateRequest, @@ -94,6 +97,8 @@ export function createGatewayMutationApi(transport: GatewayTransport): GatewayMu transport.call("resolveEquityContract", (client) => (client as unknown as EquityGatewayWireClient).resolveEquityContract(body) ), + resolveForexContract: (body) => + transport.call("resolveForexContract", (client) => client.resolveForexContract(body)), previewOrders: (body) => transport.call("previewOrders", (client) => client.previewOrders(body as PreviewOrdersRequest) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d2df0eb..1ac07d4 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -19,6 +19,7 @@ import { registerEquityOrderTools, type EquityToolDependencies, } from "#src/mcp/tools/equityOrders.js"; +import { registerForexOrderTools, type ForexToolDependencies } from "#src/mcp/tools/forexOrders.js"; export interface RegisteredMcpTool { definition: { @@ -37,7 +38,8 @@ export interface McpToolRegistrar { ): void; } -export interface McpServerDependencies extends DerivativeToolDependencies, EquityToolDependencies { +export interface McpServerDependencies + extends DerivativeToolDependencies, EquityToolDependencies, ForexToolDependencies { readonly resolveBrokerClient?: (broker: BrokerName) => Promise; readonly createDerivativeTools?: DerivativeToolDependencies["createTools"]; } @@ -77,6 +79,7 @@ export function registerMcpTools( : { createTools: dependencies.createDerivativeTools }), }); registerEquityOrderTools(server, dependencies); + registerForexOrderTools(server, dependencies); } export function createMcpServer(dependencies: McpServerDependencies = {}): McpServer { diff --git a/src/mcp/tools/forexOrders.ts b/src/mcp/tools/forexOrders.ts new file mode 100644 index 0000000..78a5560 --- /dev/null +++ b/src/mcp/tools/forexOrders.ts @@ -0,0 +1,121 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { ForexGatewayAdapter } from "#src/forex/forexGatewayAdapter.js"; +import { + FileForexPreviewStore, + FileForexSubmissionStore, + ForexOrderService, + type ForexPreviewStore, + type ForexSubmissionStore, +} from "#src/forex/forexOrderService.js"; +import { createGatewayMutationApi } from "#src/gateway/gatewayMutationAdapter.js"; +import { mcpGatewayTransport, type GatewayTransport } from "#src/gateway/gatewayTransport.js"; +import { jsonResult, runTool } from "#src/mcp/toolResult.js"; +import type { McpToolRegistrar } from "./equityOrders.js"; + +export interface ForexTools { + readonly orders: Pick; +} + +export interface ForexToolDependencies { + readonly createForexTools?: () => Promise; + readonly resolveGatewayTransport?: () => Promise; + readonly forexPreviewStore?: ForexPreviewStore; + readonly forexSubmissionStore?: ForexSubmissionStore; + readonly now?: () => Date; + readonly previewTtlMs?: number; + readonly nonce?: () => string; + readonly key?: () => string; +} + +let toolsPromise: Promise | undefined; + +export async function createForexTools( + dependencies: ForexToolDependencies = {} +): Promise { + const transport = await (dependencies.resolveGatewayTransport ?? mcpGatewayTransport)(); + return { + orders: new ForexOrderService({ + gateway: new ForexGatewayAdapter(createGatewayMutationApi(transport)), + previews: dependencies.forexPreviewStore ?? new FileForexPreviewStore(), + submissions: dependencies.forexSubmissionStore ?? new FileForexSubmissionStore(), + ...(dependencies.now === undefined ? {} : { now: dependencies.now }), + ...(dependencies.previewTtlMs === undefined ? {} : { ttlMs: dependencies.previewTtlMs }), + ...(dependencies.nonce === undefined ? {} : { nonce: dependencies.nonce }), + ...(dependencies.key === undefined ? {} : { key: dependencies.key }), + }), + }; +} + +async function forexTools(dependencies: ForexToolDependencies): Promise { + if (dependencies.createForexTools !== undefined) return dependencies.createForexTools(); + toolsPromise ??= createForexTools(dependencies).catch((error: unknown) => { + toolsPromise = undefined; + throw error; + }); + return toolsPromise; +} + +interface PreviewToolInput { + readonly pair: string; + readonly side: "BUY" | "SELL"; + readonly quantity: number; + readonly limit: number; + readonly tif: "DAY" | "GTC"; +} + +interface SubmitToolInput { + readonly previewId: string; + readonly operator: string; + readonly confirm: boolean; +} + +/** Register `fx_order_preview` and `fx_order_submit`, the MCP twins of `fx preview` and `fx submit`. */ +export function registerForexOrderTools( + server: McpToolRegistrar, + dependencies: ForexToolDependencies = {} +): void { + server.registerTool( + "fx_order_preview", + { + title: "Preview an IBKR spot FX order", + description: + "Resolve one exact IDEALPRO currency pair and run an IBKR What-If preview for a LIMIT order. " + + "This never submits an order. The returned preview ID expires after a short time. " + + "The quantity is whole base-currency units, and the limit is the quote-currency price of one base unit. " + + "IBKR checks the price increment and the minimum size; a small order can route as an odd lot, and the preview then shows the IBKR warning.", + inputSchema: { + pair: z.string().min(6).max(7).describe("Currency pair: USD.JPY, USD/JPY, or USDJPY"), + side: z.enum(["BUY", "SELL"]).describe("BUY or SELL the base currency"), + quantity: z + .number() + .int() + .positive() + .describe("Whole base-currency units, for example 25000 for 25,000 USD"), + limit: z.number().positive().describe("Quote-currency price of one base unit"), + tif: z.enum(["DAY", "GTC"]).default("DAY"), + }, + }, + async (input: PreviewToolInput): Promise => + runTool(async () => jsonResult(await (await forexTools(dependencies)).orders.preview(input))) + ); + + server.registerTool( + "fx_order_submit", + { + title: "Submit a previewed IBKR spot FX order", + description: + "Submit only the immutable terms in a valid FX preview. This places a real order in the preview-bound IBKR environment. Use operation tools for warnings, status, reconciliation, and cancellation.", + inputSchema: { + previewId: z.string().regex(/^[a-f0-9]{64}$/), + operator: z.string().min(1).max(64), + confirm: z.literal(true).describe("Must be exactly true for this broker write"), + }, + }, + async (input: SubmitToolInput): Promise => + runTool(async () => { + if (!input.confirm) throw new Error("Confirmation must be exactly true"); + return jsonResult(await (await forexTools(dependencies)).orders.submit(input)); + }) + ); +} diff --git a/src/options/optionOrderService.ts b/src/options/optionOrderService.ts index 8ab2aa2..8148629 100644 --- a/src/options/optionOrderService.ts +++ b/src/options/optionOrderService.ts @@ -209,11 +209,11 @@ function dto( }; } -/** A `single` submission record can hold an equity or an option intent. */ +/** A `single` submission record can hold an equity, forex, or option intent. */ function isOptionIntent( intent: SingleSubmissionRecord["canonicalIntent"] ): intent is CanonicalSingleOptionIntent { - return intent.contract.assetClass !== "STK"; + return intent.contract.assetClass === "OPT" || intent.contract.assetClass === "FOP"; } function account(diagnostics: OptionOrderDiagnostics): SingleSubmissionRecord["account"] { diff --git a/src/orders/singleOrderWorkflow.ts b/src/orders/singleOrderWorkflow.ts new file mode 100644 index 0000000..0902e00 --- /dev/null +++ b/src/orders/singleOrderWorkflow.ts @@ -0,0 +1,539 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readdir } from "node:fs/promises"; +import { z } from "zod"; +import type { OrderOperation } from "@huskly/ibkr-gateway-client"; +import { PrivateJsonFile } from "#src/storage/privateJsonFile.js"; +import { + FileExecutionStateStore, + type ExecutionStateStore, + type SingleSubmissionRecord, +} from "#src/derivatives/derivativeExecutionService.js"; +import type { BrokerEnvironment, MarginImpact } from "#src/derivatives/derivativePreview.js"; + +/** + * One broker What-If for a single-instrument order. + * + * @remarks + * `currency` is the account base currency. The commission and both margin + * impacts are in this currency, whatever currency the instrument trades in. + */ +export interface SingleOrderPreviewResult { + readonly environment: BrokerEnvironment; + readonly accepted: boolean; + readonly submitted: false; + readonly commission: number | null; + readonly initialMargin: MarginImpact | null; + readonly maintenanceMargin: MarginImpact | null; + readonly warnings: readonly string[]; + readonly rejectionReasons: readonly string[]; + readonly advisoryAssetPermissions: readonly string[]; + readonly currency: string; +} + +export interface SingleOrderTradingDiagnostics { + readonly environment: BrokerEnvironment; + readonly accountVerified: boolean; + readonly newMutationReady: boolean; + readonly recoveryMutationReady: boolean; + readonly maskedAccountDisplay: string; +} + +/** The gateway calls that one guarded single-instrument order needs. */ +export interface SingleOrderGateway { + getTradingDiagnostics(): Promise; + preview(intent: Intent): Promise; + create(intent: Intent, idempotencyKey: string, operator: string): Promise; + lookup(idempotencyKey: string): Promise; +} + +export interface OrderAccount { + readonly maskedId: string | null; + readonly environment: BrokerEnvironment; +} + +export interface SingleOrderPreviewRecord { + readonly schemaVersion: 1; + readonly previewId: string; + readonly nonce: string; + readonly createdAt: string; + readonly expiresAt: string; + readonly environment: BrokerEnvironment; + readonly account: OrderAccount; + readonly canonicalIntent: Intent; + readonly previewResult: SingleOrderPreviewResult; +} + +export interface SingleOrderPreviewDto { + readonly previewId: string; + readonly createdAt: string; + readonly expiresAt: string; + readonly environment: BrokerEnvironment; + readonly account: OrderAccount; + readonly order: Intent; + readonly whatIf: Omit; + readonly submitted: false; +} + +export interface SingleOrderSubmissionRecord { + readonly schemaVersion: 1; + readonly previewId: string; + readonly operationKind: "single"; + readonly idempotencyKey: string; + readonly canonicalIntent: Intent; + readonly operator: string; + readonly intentHash: string; + readonly account: OrderAccount; + readonly state: "submission_pending" | "submission_uncertain" | "operation_known"; + readonly operationId: string | null; + readonly operation: OrderOperation | null; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface SingleOrderSubmissionDto { + readonly previewId: string; + readonly environment: BrokerEnvironment; + readonly account: OrderAccount; + readonly order: Intent; + readonly operation: OrderOperation; + readonly recovered: boolean; +} + +export interface SingleOrderPreviewStore { + create(value: SingleOrderPreviewRecord): Promise; + load(previewId: string): Promise | undefined>; + delete(previewId: string): Promise; + pruneExpired(now: Date): Promise; +} + +export interface SingleOrderSubmissionStore { + reserve(value: SingleOrderSubmissionRecord): Promise; + load(previewId: string): Promise | undefined>; + save(value: SingleOrderSubmissionRecord): Promise; +} + +const marginSchema = z + .strictObject({ current: z.number(), change: z.number(), after: z.number() }) + .nullable(); +export const singleOrderPreviewResultSchema = z.strictObject({ + environment: z.enum(["live", "paper"]), + 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()), + currency: z.string().regex(/^[A-Z]{3}$/u), +}); + +/** The durable preview record schema for one intent schema. */ +export function singleOrderPreviewRecordSchema( + intentSchema: z.ZodType +): z.ZodType> { + return z.strictObject({ + schemaVersion: z.literal(1), + previewId: z.string().regex(/^[a-f0-9]{64}$/u), + nonce: z.uuid(), + createdAt: z.iso.datetime(), + expiresAt: z.iso.datetime(), + environment: z.enum(["live", "paper"]), + account: z.strictObject({ + maskedId: z.string().nullable(), + environment: z.enum(["live", "paper"]), + }), + canonicalIntent: intentSchema, + previewResult: singleOrderPreviewResultSchema, + }); +} + +export class InMemorySingleOrderPreviewStore implements SingleOrderPreviewStore { + private readonly values = new Map>(); + public create(value: SingleOrderPreviewRecord): Promise { + if (this.values.has(value.previewId)) return Promise.resolve(false); + this.values.set(value.previewId, structuredClone(value)); + return Promise.resolve(true); + } + public load(previewId: string): Promise | undefined> { + const value = this.values.get(previewId); + return Promise.resolve(value === undefined ? undefined : structuredClone(value)); + } + public delete(previewId: string): Promise { + this.values.delete(previewId); + return Promise.resolve(); + } + public pruneExpired(now: Date): Promise { + const cutoff = now.getTime(); + for (const [previewId, value] of this.values) { + if (new Date(value.expiresAt).getTime() <= cutoff) this.values.delete(previewId); + } + return Promise.resolve(); + } +} + +export class InMemorySingleOrderSubmissionStore< + Intent, +> implements SingleOrderSubmissionStore { + private readonly values = new Map>(); + public reserve(value: SingleOrderSubmissionRecord): Promise { + if (this.values.has(value.previewId)) return Promise.resolve(false); + this.values.set(value.previewId, structuredClone(value)); + return Promise.resolve(true); + } + public load(previewId: string): Promise | undefined> { + const value = this.values.get(previewId); + return Promise.resolve(value === undefined ? undefined : structuredClone(value)); + } + public save(value: SingleOrderSubmissionRecord): Promise { + this.values.set(value.previewId, structuredClone(value)); + return Promise.resolve(); + } +} + +/** Private preview files, one per preview ID, in one directory per order family. */ +export class FileSingleOrderPreviewStore implements SingleOrderPreviewStore { + public constructor( + private readonly directory: string, + private readonly schema: z.ZodType> + ) {} + public create(value: SingleOrderPreviewRecord): Promise { + return this.file(value.previewId).create(value); + } + public async load(previewId: string): Promise | undefined> { + validatePreviewId(previewId); + const value = await this.file(previewId).load(); + if (value !== undefined && value.previewId !== previewId) + throw new Error("Preview file identity mismatch"); + return value; + } + public delete(previewId: string): Promise { + validatePreviewId(previewId); + return this.file(previewId).delete(); + } + public async pruneExpired(now: Date): Promise { + let names: string[]; + try { + names = await readdir(this.directory); + } catch (error: unknown) { + if (isNodeErrorWithCode(error, "ENOENT")) return; + throw error; + } + if (names.length > 4096) throw new Error("Preview directory is too large"); + const cutoff = now.getTime(); + for (const name of names) { + const match = /^([a-f0-9]{64})\.json$/u.exec(name); + if (match?.[1] === undefined) continue; + const preview = await this.load(match[1]); + if (preview !== undefined && new Date(preview.expiresAt).getTime() <= cutoff) + await this.delete(preview.previewId); + } + } + private file(previewId: string): PrivateJsonFile> { + validatePreviewId(previewId); + return new PrivateJsonFile({ + directory: this.directory, + filename: `${previewId}.json`, + schema: this.schema, + maxBytes: 64 * 1024, + }); + } +} + +/** + * Single submissions in the shared execution store. + * + * @remarks + * Every `single` order family shares one durable record shape, so the + * `order` commands can follow any of them. `isIntent` refuses a record that + * another family wrote for the same preview ID. + */ +export class FileSingleOrderSubmissionStore< + Intent extends SingleSubmissionRecord["canonicalIntent"], +> implements SingleOrderSubmissionStore { + private readonly store: ExecutionStateStore; + public constructor( + directory: string, + private readonly isIntent: ( + intent: SingleSubmissionRecord["canonicalIntent"] + ) => intent is Intent, + private readonly family: string + ) { + this.store = new FileExecutionStateStore(directory); + } + public reserve(value: SingleOrderSubmissionRecord): Promise { + return this.store.reserveSubmission(value satisfies SingleSubmissionRecord); + } + public async load(previewId: string): Promise | undefined> { + validatePreviewId(previewId); + const value = await this.store.loadSubmission(previewId); + if (value === undefined) return undefined; + if (value.operationKind !== "single" || !this.isIntent(value.canonicalIntent)) + throw new Error(`Submission record is not ${this.family}`); + return { ...value, canonicalIntent: value.canonicalIntent }; + } + public save(value: SingleOrderSubmissionRecord): Promise { + return this.store.saveSubmission(value satisfies SingleSubmissionRecord); + } +} + +export interface SingleOrderWorkflowOptions { + readonly gateway: SingleOrderGateway; + readonly intentSchema: z.ZodType; + readonly previews: SingleOrderPreviewStore; + readonly submissions: SingleOrderSubmissionStore; + readonly now?: () => Date; + readonly ttlMs?: number; + readonly nonce?: () => string; + readonly key?: () => string; +} + +/** + * The guarded preview/submit flow that every single-instrument order shares. + * + * @remarks + * A preview stores the exact reviewed terms under a content-hash ID with a + * short expiry. A submission accepts only an unexpired, broker-accepted + * preview, reserves one idempotency key before the broker write, and recovers + * an uncertain write by that key instead of writing again. + */ +export class SingleOrderWorkflow { + private readonly gateway: SingleOrderGateway; + private readonly intentSchema: z.ZodType; + private readonly recordSchema: z.ZodType>; + private readonly previews: SingleOrderPreviewStore; + private readonly submissions: SingleOrderSubmissionStore; + private readonly now: () => Date; + private readonly ttlMs: number; + private readonly nonce: () => string; + private readonly key: () => string; + + public constructor(options: SingleOrderWorkflowOptions) { + this.gateway = options.gateway; + this.intentSchema = options.intentSchema; + this.recordSchema = singleOrderPreviewRecordSchema(options.intentSchema); + this.previews = options.previews; + this.submissions = options.submissions; + this.now = options.now ?? (() => new Date()); + this.ttlMs = options.ttlMs ?? 5 * 60 * 1000; + this.nonce = options.nonce ?? randomUUID; + this.key = options.key ?? randomUUID; + if (!Number.isSafeInteger(this.ttlMs) || this.ttlMs <= 0) + throw new Error("Invalid preview TTL"); + } + + /** Preview the intent that `buildIntent` resolves once the gateway is ready. */ + public async preview(buildIntent: () => Promise): Promise> { + await this.previews.pruneExpired(this.now()); + const diagnostics = await this.gateway.getTradingDiagnostics(); + requireMutationReady(diagnostics); + const canonicalIntent = this.intentSchema.parse(await buildIntent()); + const previewResult = singleOrderPreviewResultSchema.parse( + await this.gateway.preview(canonicalIntent) + ); + if (previewResult.environment !== diagnostics.environment) + throw new Error("Preview environment does not match gateway diagnostics"); + const createdAt = this.now(); + const recordWithoutId = { + schemaVersion: 1 as const, + nonce: this.nonce(), + createdAt: createdAt.toISOString(), + expiresAt: new Date(createdAt.getTime() + this.ttlMs).toISOString(), + environment: diagnostics.environment, + account: { + maskedId: diagnostics.maskedAccountDisplay, + environment: diagnostics.environment, + }, + canonicalIntent, + previewResult, + }; + const previewId = hash(recordWithoutId); + const record = this.recordSchema.parse({ ...recordWithoutId, previewId }); + if (!(await this.previews.create(record))) throw new Error("Duplicate preview ID"); + return previewDto(record); + } + + public async submit(input: { + readonly previewId: string; + readonly operator: string; + readonly confirm: boolean; + }): Promise> { + if (!input.confirm) throw new Error("Confirmation must be exactly true"); + validatePreviewId(input.previewId); + const operator = validateOperator(input.operator); + const existing = await this.submissions.load(input.previewId); + if (existing !== undefined) return this.recoverExisting(existing, operator); + + const preview = await this.requiredPreview(input.previewId); + const diagnostics = await this.gateway.getTradingDiagnostics(); + requireMutationReady(diagnostics); + if (diagnostics.environment !== preview.environment) + throw new Error("Preview environment does not match the current gateway"); + const now = this.now().toISOString(); + const pending: SingleOrderSubmissionRecord = { + schemaVersion: 1, + previewId: preview.previewId, + operationKind: "single", + idempotencyKey: this.key(), + canonicalIntent: preview.canonicalIntent, + operator, + intentHash: hash(preview.canonicalIntent), + account: preview.account, + state: "submission_pending", + operationId: null, + operation: null, + createdAt: now, + updatedAt: now, + }; + if (!(await this.submissions.reserve(pending))) { + const raced = await this.submissions.load(preview.previewId); + if (raced === undefined) throw new Error("Submission reservation is unavailable"); + validateExisting(raced, operator, preview); + return this.recoverExisting(raced, operator, diagnostics); + } + let operation: OrderOperation; + try { + operation = await this.gateway.create( + preview.canonicalIntent, + pending.idempotencyKey, + operator + ); + validateOperation(operation); + } catch (error: unknown) { + await this.submissions.save({ + ...pending, + state: "submission_uncertain", + updatedAt: this.now().toISOString(), + }); + throw error; + } + await this.submissions.save({ + ...pending, + state: "operation_known", + operationId: operation.operationId, + operation, + updatedAt: this.now().toISOString(), + }); + return submissionDto(pending, operation, false); + } + + private async requiredPreview(previewId: string): Promise> { + const preview = await this.previews.load(previewId); + if (preview === undefined) throw new Error("Unknown preview ID"); + const { previewId: _previewId, ...payload } = preview; + if (hash(payload) !== previewId) throw new Error("Preview content hash mismatch"); + if (this.now().getTime() >= new Date(preview.expiresAt).getTime()) { + await this.previews.delete(previewId); + throw new Error("Preview has expired"); + } + if (!preview.previewResult.accepted) throw new Error("Preview was rejected by broker What-If"); + return preview; + } + + private async recoverExisting( + record: SingleOrderSubmissionRecord, + operator: string, + knownDiagnostics?: SingleOrderTradingDiagnostics + ): Promise> { + validateExisting(record, operator); + if (record.state === "operation_known" && record.operation !== null) { + validateOperation(record.operation); + return submissionDto(record, record.operation, true); + } + const diagnostics = knownDiagnostics ?? (await this.gateway.getTradingDiagnostics()); + requireRecoveryReady(diagnostics); + if (diagnostics.environment !== record.account.environment) + throw new Error("Submission environment does not match the current gateway"); + const operation = await this.gateway.lookup(record.idempotencyKey); + validateOperation(operation); + await this.submissions.save({ + ...record, + state: "operation_known", + operationId: operation.operationId, + operation, + updatedAt: this.now().toISOString(), + }); + return submissionDto(record, operation, true); + } +} + +function validateExisting( + existing: SingleOrderSubmissionRecord, + operator: string, + preview?: SingleOrderPreviewRecord +): void { + if ( + existing.operator !== operator || + existing.intentHash !== hash(existing.canonicalIntent) || + (preview !== undefined && + (existing.previewId !== preview.previewId || + existing.account.environment !== preview.environment || + existing.account.maskedId !== preview.account.maskedId || + existing.intentHash !== hash(preview.canonicalIntent))) + ) + throw new Error("Submission reservation does not match the preview"); +} + +function previewDto( + record: SingleOrderPreviewRecord +): SingleOrderPreviewDto { + const { environment: _environment, ...whatIf } = record.previewResult; + return { + previewId: record.previewId, + createdAt: record.createdAt, + expiresAt: record.expiresAt, + environment: record.environment, + account: record.account, + order: record.canonicalIntent, + whatIf, + submitted: false, + }; +} + +function submissionDto( + record: Pick, "previewId" | "canonicalIntent" | "account">, + operation: OrderOperation, + recovered: boolean +): SingleOrderSubmissionDto { + return { + previewId: record.previewId, + environment: record.account.environment, + account: record.account, + order: record.canonicalIntent, + operation, + recovered, + }; +} + +function validateOperation(operation: OrderOperation): void { + if (operation.kind !== "single" || operation.operationId.length === 0) + throw new Error("Gateway returned an invalid single-order operation"); +} + +export function validatePreviewId(value: string): void { + if (!/^[a-f0-9]{64}$/.test(value)) throw new Error("Invalid preview ID"); +} + +function validateOperator(value: string): string { + if (value.length < 1 || value.length > 64 || value.trim() !== value || /[^ -~]/.test(value)) + throw new Error("Invalid operator identity"); + return value; +} + +function requireMutationReady(diagnostics: SingleOrderTradingDiagnostics): void { + if (!diagnostics.accountVerified || !diagnostics.newMutationReady) + throw new Error("Gateway is not ready for a new order mutation"); +} + +function requireRecoveryReady(diagnostics: SingleOrderTradingDiagnostics): void { + if (!diagnostics.accountVerified || !diagnostics.recoveryMutationReady) + throw new Error("Gateway is not ready for order recovery"); +} + +function isNodeErrorWithCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +function hash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +} diff --git a/test/cli/derivatives.test.ts b/test/cli/derivatives.test.ts index cb27246..102d060 100644 --- a/test/cli/derivatives.test.ts +++ b/test/cli/derivatives.test.ts @@ -243,6 +243,7 @@ const spreadPreview: SpreadPreviewDto = { warnings: ["known warning"], rejectionReasons: [], advisoryAssetPermissions: ["futures-options"], + currency: "USD", }, submitted: false, }; diff --git a/test/cli/equityOrders.test.ts b/test/cli/equityOrders.test.ts index 78f2795..d0f51c2 100644 --- a/test/cli/equityOrders.test.ts +++ b/test/cli/equityOrders.test.ts @@ -38,6 +38,7 @@ const preview: EquityPreviewDto = { warnings: [], rejectionReasons: [], advisoryAssetPermissions: [], + currency: "USD", } as unknown as EquityPreviewDto["whatIf"], submitted: false, }; diff --git a/test/cli/forexOrders.test.ts b/test/cli/forexOrders.test.ts new file mode 100644 index 0000000..dc83e7e --- /dev/null +++ b/test/cli/forexOrders.test.ts @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Command } from "commander"; +import { addForexCommands, type ForexCommandDependencies } from "#src/cli/forexOrders.js"; +import type { BrokerName } from "#src/brokers/brokerClient.js"; +import type { ForexPreviewDto, ForexSubmissionDto } from "#src/forex/forexOrderService.js"; + +const intent: ForexPreviewDto["order"] = { + contract: { + conid: 15016059, + assetClass: "CASH", + symbol: "USD", + currency: "JPY", + localSymbol: "USD.JPY", + exchange: "IDEALPRO", + }, + side: "BUY", + quantity: 25000, + tif: "DAY", + orderType: "LMT", + limit: 147.255, +}; + +const preview: ForexPreviewDto = { + previewId: "b".repeat(64), + createdAt: "2026-09-25T00:00:00.000Z", + expiresAt: "2026-09-25T00:05:00.000Z", + environment: "paper", + account: { maskedId: "U***567", environment: "paper" }, + order: intent, + whatIf: { + accepted: true, + submitted: false, + commission: 2, + initialMargin: { current: 1000, change: 900, after: 1900 }, + maintenanceMargin: null, + warnings: ["This order will be directed to the IDEALPRO odd lot market"], + rejectionReasons: [], + advisoryAssetPermissions: [], + currency: "USD", + }, + submitted: false, +}; + +const submission = { + previewId: "b".repeat(64), + environment: "paper", + account: { maskedId: "U***567", environment: "paper" }, + order: intent, + operation: { + operationId: "op-fx-1", + kind: "single", + action: "submission", + state: "accepted", + createdAt: "2026-09-25T00:00:00.000Z", + latestTransitionAt: "2026-09-25T00:00:01.000Z", + pendingWarning: null, + reconciliation: null, + children: [], + result: { + kind: "accepted", + warningCount: 0, + orders: [{ status: "WORKING", orderId: "99" }], + }, + }, + recovered: false, +} as unknown as ForexSubmissionDto; + +function program( + dependencies: ForexCommandDependencies = {}, + broker: BrokerName = "ibkr" +): Command { + const command = new Command(); + command.exitOverride(); + addForexCommands( + command, + (override, fallback) => + (override as BrokerName | undefined) ?? (broker === "ibkr" ? fallback : broker), + dependencies + ); + return command; +} + +function orders(overrides: Record = {}) { + return { + preview: () => Promise.resolve(preview), + submit: () => Promise.resolve(submission), + ...overrides, + } as unknown as Awaited>>; +} + +void test("fx preview shows base-currency units, quote-currency price, and IBKR warnings", async () => { + const lines: string[] = []; + await program({ + createForexOrders: () => Promise.resolve(orders()), + log: (line) => lines.push(line), + }).parseAsync(["node", "t", "fx", "preview", "USD.JPY", "BUY", "25000", "--limit", "147.255"]); + + const output = lines.join("\n"); + assert.match(output, /BUY \$25,000 USD\.JPY limit ¥147\.255 {2}DAY/); + assert.match(output, /Commission\/fees: \$2\.00/); + assert.match(output, /Initial margin change: \$900\.00/); + assert.match(output, /odd lot/); + assert.match(output, /NO ORDER WAS SUBMITTED\./); +}); + +void test("fx preview passes validated terms to the service", async () => { + let received: unknown; + await program({ + createForexOrders: () => + Promise.resolve( + orders({ + preview: (input: unknown) => { + received = input; + return Promise.resolve(preview); + }, + }) + ), + log: () => undefined, + }).parseAsync([ + "node", + "t", + "fx", + "preview", + "usd/jpy", + "sell", + "30000", + "--limit", + "147.2", + "--tif", + "gtc", + ]); + assert.deepEqual(received, { + pair: "usd/jpy", + side: "SELL", + quantity: 30000, + limit: 147.2, + tif: "GTC", + }); +}); + +void test("fx preview refuses bad input before the service", async () => { + let calls = 0; + const run = (...args: string[]): Promise => + program({ + createForexOrders: () => { + calls += 1; + return Promise.resolve(orders()); + }, + log: () => undefined, + }).parseAsync(["node", "t", "fx", "preview", ...args]); + + await assert.rejects( + run("USD.JPY", "BUY", "1.5", "--limit", "147"), + /whole number of base-currency units/ + ); + await assert.rejects(run("USD.JPY", "BUY", "25000"), /require --limit/); + await assert.rejects(run("USD.JPY", "BUY", "25000", "--limit", "0"), /Invalid limit price/); + await assert.rejects(run("USD.JPY", "HOLD", "25000", "--limit", "147"), /Expected BUY or SELL/); + await assert.rejects( + run("USD.JPY", "BUY", "25000", "--limit", "147", "--tif", "IOC"), + /Expected DAY or GTC/ + ); + assert.equal(calls, 0); +}); + +void test("fx commands refuse the Schwab broker", async () => { + for (const args of [ + ["fx", "preview", "USD.JPY", "BUY", "25000", "--limit", "147", "--broker", "schwab"], + ["fx", "submit", "b".repeat(64), "--confirm", "--operator", "alice", "--broker", "schwab"], + ]) { + await assert.rejects( + program({ + createForexOrders: () => Promise.resolve(orders()), + log: () => undefined, + }).parseAsync(["node", "t", ...args]), + /FX orders are available for IBKR only/ + ); + } + await assert.rejects( + program( + { createForexOrders: () => Promise.resolve(orders()), log: () => undefined }, + "schwab" + ).parseAsync(["node", "t", "fx", "preview", "USD.JPY", "BUY", "25000", "--limit", "147"]), + /FX orders are available for IBKR only/ + ); +}); + +void test("fx preview --json states a stable DTO", async () => { + const lines: string[] = []; + await program({ + createForexOrders: () => Promise.resolve(orders()), + log: (line) => lines.push(line), + }).parseAsync([ + "node", + "t", + "fx", + "preview", + "USD.JPY", + "BUY", + "25000", + "--limit", + "147.255", + "--json", + ]); + assert.deepEqual(JSON.parse(lines.join("\n")), { + previewId: "b".repeat(64), + createdAt: "2026-09-25T00:00:00.000Z", + expiresAt: "2026-09-25T00:05:00.000Z", + environment: "paper", + account: { maskedId: "U***567", environment: "paper" }, + order: { + pair: "USD.JPY", + baseCurrency: "USD", + quoteCurrency: "JPY", + side: "BUY", + quantity: 25000, + orderType: "LMT", + limit: 147.255, + tif: "DAY", + }, + whatIf: preview.whatIf, + submitted: false, + }); +}); + +void test("fx submit requires --confirm and reports the operation", async () => { + const lines: string[] = []; + let received: unknown; + const deps: ForexCommandDependencies = { + createForexOrders: () => + Promise.resolve( + orders({ + submit: (input: unknown) => { + received = input; + return Promise.resolve(submission); + }, + }) + ), + log: (line) => lines.push(line), + }; + await assert.rejects( + program(deps).parseAsync(["node", "t", "fx", "submit", "b".repeat(64), "--operator", "alice"]), + /requires --confirm/ + ); + await program(deps).parseAsync([ + "node", + "t", + "fx", + "submit", + "b".repeat(64), + "--operator", + "alice", + "--confirm", + ]); + assert.deepEqual(received, { previewId: "b".repeat(64), operator: "alice", confirm: true }); + const output = lines.join("\n"); + assert.match(output, /Submission: new/); + assert.match(output, /BUY \$25,000 USD\.JPY limit ¥147\.255/); +}); diff --git a/test/cli/orders.test.ts b/test/cli/orders.test.ts index 38da32f..825d66b 100644 --- a/test/cli/orders.test.ts +++ b/test/cli/orders.test.ts @@ -521,3 +521,44 @@ test("a long unresolved contract name remains fully visible", () => { const output = stripAnsi(renderOrdersObservation(observation, "ibkr", fromDate, toDate, {})); assert.match(output, /↳ IBIT {2}260925C00047000 \(unresolved\)/); }); + +test("IBKR spot FX orders show quote-currency prices, never dollar prices", () => { + const leg = (symbol: string) => ({ + instrument: { symbol }, + instruction: "BUY", + brokerId: 15016059, + assetClass: "CASH" as const, + ratio: 1, + }); + const orders: Observation = { + observedAt: null, + completeness: "available", + value: [ + { + orders: [ + { + status: "FILLED", + orderType: "LIMIT", + quantity: 25000, + price: 147.255, + averageFillPrice: 147.25, + filledQuantity: 25000, + orderLegCollection: [leg("USD.JPY")], + }, + { + status: "WORKING", + orderType: "LIMIT", + quantity: 30000, + price: 1.0851, + filledQuantity: 0, + orderLegCollection: [leg("EUR")], + }, + ], + }, + ], + }; + const output = stripAnsi(renderOrdersObservation(orders, "ibkr", fromDate, toDate, {})); + assert.match(output, /USD\.JPY\s+BUY\s+25000\s+¥147\.255\s+¥147\.25\s+-/); + assert.match(output, /EUR\s+BUY\s+30000\s+1\.0851\s+-/); + assert.doesNotMatch(output, /\$|unresolved/); +}); diff --git a/test/derivatives/derivativeExecutionService.test.ts b/test/derivatives/derivativeExecutionService.test.ts index 815faa4..5a6b62a 100644 --- a/test/derivatives/derivativeExecutionService.test.ts +++ b/test/derivatives/derivativeExecutionService.test.ts @@ -102,6 +102,7 @@ const preview: SpreadPreviewDto = { warnings: [], rejectionReasons: [], advisoryAssetPermissions: [], + currency: "USD", }, submitted: false, }; diff --git a/test/derivatives/derivativePreviewService.test.ts b/test/derivatives/derivativePreviewService.test.ts index 8921680..408c375 100644 --- a/test/derivatives/derivativePreviewService.test.ts +++ b/test/derivatives/derivativePreviewService.test.ts @@ -53,6 +53,7 @@ const preview: DerivativePreviewClient = { competingSession: false, marketDataAvailable: true, advisoryAssetPermissions: [], + currency: "USD", state: "ready", readReady: true, newMutationReady: false, @@ -77,6 +78,7 @@ const preview: DerivativePreviewClient = { warnings: [], rejectionReasons: [], advisoryAssetPermissions: ["STK"], + currency: "USD", }), }; @@ -164,6 +166,7 @@ void test("preview ignores diagnostics account authority and keeps only masked d competingSession: true, marketDataAvailable: null, advisoryAssetPermissions: [], + currency: "USD", state: "degraded", readReady: false, newMutationReady: false, @@ -236,6 +239,7 @@ void test("stores rejected previews and never submits during preview", async () competingSession: false, marketDataAvailable: true, advisoryAssetPermissions: [], + currency: "USD", state: "ready", readReady: true, newMutationReady: false, @@ -261,6 +265,7 @@ void test("stores rejected previews and never submits during preview", async () warnings: ["Permissions missing"], rejectionReasons: ["Risk rejected"], advisoryAssetPermissions: [], + currency: "USD", }); }, }; diff --git a/test/equities/equityOrderService.test.ts b/test/equities/equityOrderService.test.ts index 8d5287b..017a107 100644 --- a/test/equities/equityOrderService.test.ts +++ b/test/equities/equityOrderService.test.ts @@ -82,6 +82,7 @@ class Gateway implements EquityGatewayClient { warnings: [], rejectionReasons: this.accepted ? [] : ["rejected"], advisoryAssetPermissions: [], + currency: "USD", }); } public async create(intent: CanonicalEquityIntent, key: string, operator: string) { diff --git a/test/forex/forexOrderService.test.ts b/test/forex/forexOrderService.test.ts new file mode 100644 index 0000000..12c0cb6 --- /dev/null +++ b/test/forex/forexOrderService.test.ts @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { OrderOperation } from "@huskly/ibkr-gateway-client"; +import { FileEquitySubmissionStore } from "../../src/equities/equityOrderService.js"; +import type { + CanonicalForexIntent, + ForexContract, + ForexGatewayClient, +} from "../../src/forex/forexOrder.js"; +import { FileForexSubmissionStore, ForexOrderService } from "../../src/forex/forexOrderService.js"; +import { + InMemorySingleOrderPreviewStore, + InMemorySingleOrderSubmissionStore, + type SingleOrderPreviewResult, + type SingleOrderSubmissionStore, + type SingleOrderTradingDiagnostics, +} from "../../src/orders/singleOrderWorkflow.js"; + +const contract: ForexContract = { + conid: 15016059, + assetClass: "CASH", + symbol: "USD", + currency: "JPY", + localSymbol: "USD.JPY", + exchange: "IDEALPRO", +}; +const operation = { + operationId: "operation-fx-1", + kind: "single", + action: "submission", + parentOperationId: null, + intentSchemaVersion: 1, + intentHash: "a".repeat(64), + state: "accepted", + correlations: [], + children: [], + pendingWarning: null, + reconciliation: null, + result: { kind: "accepted", orders: [], warningCount: 0 }, + blockedCause: null, + outcome: null, + createdAt: "2026-09-25T12:00:00.000Z", + latestTransitionAt: "2026-09-25T12:00:01.000Z", +} as OrderOperation; + +class Gateway implements ForexGatewayClient { + public diagnostics: SingleOrderTradingDiagnostics = { + environment: "paper", + accountVerified: true, + newMutationReady: true, + recoveryMutationReady: true, + maskedAccountDisplay: "D***567", + }; + public resolved: ForexContract = contract; + public warnings: string[] = []; + public resolveCalls: string[] = []; + public previewCalls: CanonicalForexIntent[] = []; + public createCalls: { intent: CanonicalForexIntent; key: string; operator: string }[] = []; + public lookupCalls: string[] = []; + public createFailure: Error | undefined; + + public getTradingDiagnostics() { + return Promise.resolve(this.diagnostics); + } + public resolveContract(pair: string) { + this.resolveCalls.push(pair); + return Promise.resolve(this.resolved); + } + public preview(intent: CanonicalForexIntent): Promise { + this.previewCalls.push(intent); + return Promise.resolve({ + environment: this.diagnostics.environment, + accepted: true, + submitted: false, + commission: 2, + initialMargin: { current: 1000, change: 900, after: 1900 }, + maintenanceMargin: null, + warnings: this.warnings, + rejectionReasons: [], + advisoryAssetPermissions: [], + currency: "USD", + }); + } + public create(intent: CanonicalForexIntent, key: string, operator: string) { + this.createCalls.push({ intent, key, operator }); + if (this.createFailure !== undefined) return Promise.reject(this.createFailure); + return Promise.resolve(operation); + } + public lookup(key: string) { + this.lookupCalls.push(key); + return Promise.resolve(operation); + } +} + +function service( + now: () => Date = () => new Date("2026-09-25T12:00:00.000Z"), + submissions: SingleOrderSubmissionStore = new InMemorySingleOrderSubmissionStore() +) { + const gateway = new Gateway(); + const previews = new InMemorySingleOrderPreviewStore(); + let keys = 0; + return { + gateway, + previews, + value: new ForexOrderService({ + gateway, + previews, + submissions, + now, + ttlMs: 60_000, + nonce: () => "00000000-0000-4000-8000-000000000001", + key: () => `00000000-0000-4000-8000-00000000000${String(++keys + 1)}`, + }), + }; +} + +const input = { pair: "usd/jpy", side: "BUY", quantity: 25_000, limit: 147.25 } as const; + +test("preview resolves the normalized pair and stores LMT DAY terms without submitting", async () => { + const fx = service(); + const result = await fx.value.preview(input); + assert.deepEqual(fx.gateway.resolveCalls, ["USD.JPY"]); + assert.deepEqual(result.order, { + contract, + side: "BUY", + quantity: 25_000, + tif: "DAY", + orderType: "LMT", + limit: 147.25, + } satisfies CanonicalForexIntent); + assert.equal("session" in result.order, false); + assert.equal(result.whatIf.currency, "USD"); + assert.equal(result.submitted, false); + assert.equal(fx.gateway.createCalls.length, 0); +}); + +test("preview refuses a resolved contract for another pair", async () => { + const fx = service(); + fx.gateway.resolved = { ...contract, currency: "CNH", localSymbol: "USD.CNH" }; + await assert.rejects(fx.value.preview(input), /does not match/); + assert.equal(fx.gateway.previewCalls.length, 0); +}); + +test("preview refuses bad terms before any gateway call", () => { + const fx = service(); + for (const bad of [ + { ...input, pair: "USD.USD" }, + { ...input, quantity: 1.5 }, + { ...input, quantity: 0 }, + { ...input, limit: 0 }, + { ...input, limit: Number.NaN }, + ]) { + assert.throws(() => fx.value.preview(bad)); + } + assert.deepEqual(fx.gateway.resolveCalls, []); +}); + +test("preview keeps the IBKR odd-lot warning for a small order", async () => { + const fx = service(); + fx.gateway.warnings = ["This order will be directed to the IDEALPRO odd lot market"]; + const result = await fx.value.preview({ ...input, quantity: 1_000 }); + assert.equal(result.whatIf.accepted, true); + assert.deepEqual(result.whatIf.warnings, [ + "This order will be directed to the IDEALPRO odd lot market", + ]); +}); + +test("an expired preview cannot be submitted", async () => { + let current = new Date("2026-09-25T12:00:00.000Z"); + const fx = service(() => current); + const result = await fx.value.preview(input); + current = new Date("2026-09-25T12:01:00.000Z"); + await assert.rejects( + fx.value.submit({ previewId: result.previewId, operator: "operator-7", confirm: true }), + /expired/ + ); + assert.equal(fx.gateway.createCalls.length, 0); +}); + +test("submit writes once and a repeated submit returns the same operation", async () => { + const fx = service(); + const result = await fx.value.preview(input); + const first = await fx.value.submit({ + previewId: result.previewId, + operator: "operator-7", + confirm: true, + }); + const second = await fx.value.submit({ + previewId: result.previewId, + operator: "operator-7", + confirm: true, + }); + assert.equal(first.recovered, false); + assert.equal(second.recovered, true); + assert.equal(second.operation.operationId, first.operation.operationId); + assert.equal(fx.gateway.createCalls.length, 1); + assert.deepEqual(fx.gateway.createCalls[0]?.intent, result.order); +}); + +test("an uncertain write recovers by its idempotency key and never writes again", async () => { + const fx = service(); + const result = await fx.value.preview(input); + fx.gateway.createFailure = new Error("socket hang up"); + await assert.rejects( + fx.value.submit({ previewId: result.previewId, operator: "operator-7", confirm: true }), + /socket hang up/ + ); + fx.gateway.createFailure = undefined; + const recovered = await fx.value.submit({ + previewId: result.previewId, + operator: "operator-7", + confirm: true, + }); + assert.equal(recovered.recovered, true); + assert.equal(fx.gateway.createCalls.length, 1); + assert.deepEqual(fx.gateway.lookupCalls, [fx.gateway.createCalls[0]?.key]); +}); + +test("the shared execution store keeps forex and equity submissions apart", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "huskly-fx-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const fx = service(undefined, new FileForexSubmissionStore(directory)); + const result = await fx.value.preview(input); + await fx.value.submit({ previewId: result.previewId, operator: "operator-7", confirm: true }); + + const stored = await new FileForexSubmissionStore(directory).load(result.previewId); + assert.equal(stored?.canonicalIntent.contract.assetClass, "CASH"); + await assert.rejects( + new FileEquitySubmissionStore(directory).load(result.previewId), + /not an equity order/ + ); +}); diff --git a/test/forex/forexPair.test.ts b/test/forex/forexPair.test.ts new file mode 100644 index 0000000..9bc7ede --- /dev/null +++ b/test/forex/forexPair.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseForexPair } from "../../src/forex/forexPair.js"; + +test("accepts BASE.QUOTE, BASE/QUOTE, and BASEQUOTE and normalizes to BASE.QUOTE", () => { + for (const value of ["USD.JPY", "USD/JPY", "USDJPY", " usd.jpy ", "usdjpy"]) { + assert.deepEqual(parseForexPair(value), { base: "USD", quote: "JPY", pair: "USD.JPY" }, value); + } +}); + +test("rejects bad currency codes and other forms", () => { + for (const value of [ + "", + "USD", + "US.JPY", + "USD.JP", + "USD-JPY", + "USD.JPY.X", + "USD JPY", + "U5D.JPY", + ]) { + assert.throws(() => parseForexPair(value), /Invalid FX pair/, value); + } +}); + +test("rejects a pair whose base equals its quote", () => { + for (const value of ["USD.USD", "USDUSD", "usd/usd"]) { + assert.throws(() => parseForexPair(value), /base and quote are the same/, value); + } +}); diff --git a/test/format.test.ts b/test/format.test.ts new file mode 100644 index 0000000..700e59d --- /dev/null +++ b/test/format.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { currencyFormatUsd, formatForexPrice, formatMoney } from "#src/format.js"; + +test("formatMoney uses the currency's own minor unit unless digits are given", () => { + assert.equal(formatMoney(3681.4, "JPY"), "¥3,681"); + assert.equal(formatMoney(12.5, "EUR"), "€12.50"); + assert.equal(formatMoney(25000, "USD", { minimum: 0, maximum: 0 }), "$25,000"); + assert.equal(formatMoney(null, "USD"), "-"); +}); + +test("currencyFormatUsd stays a USD wrapper", () => { + assert.equal(currencyFormatUsd(1234.5), "$1,234.50"); + assert.equal(currencyFormatUsd(undefined), "-"); +}); + +test("formatForexPrice keeps FX rate precision in the quote currency", () => { + assert.equal(formatForexPrice(147.255, "JPY"), "¥147.255"); + assert.equal(formatForexPrice(1.0851, "USD"), "$1.0851"); + assert.equal(formatForexPrice(147.2, "JPY"), "¥147.20"); + assert.equal(formatForexPrice(1.0851, null), "1.0851"); + assert.equal(formatForexPrice(null, "JPY"), "-"); +}); diff --git a/test/gateway/equityGatewayAdapter.test.ts b/test/gateway/equityGatewayAdapter.test.ts index 9df0535..a7bb73c 100644 --- a/test/gateway/equityGatewayAdapter.test.ts +++ b/test/gateway/equityGatewayAdapter.test.ts @@ -55,6 +55,7 @@ test("equity adapter resolves previews and submits one exact generated request", warnings: [], rejectionReasons: [], advisoryAssetPermissions: [], + currency: "USD", }); }, createOrderOperation: (...args: unknown[]) => { @@ -158,6 +159,7 @@ test("equity adapter forwards exact STP preview and submit objects", async () => warnings: [], rejectionReasons: [], advisoryAssetPermissions: [], + currency: "USD", }); }, createOrderOperation: (...args: unknown[]) => { diff --git a/test/gateway/gatewayMutationAdapter.test.ts b/test/gateway/gatewayMutationAdapter.test.ts index fcf9ad9..b6c1015 100644 --- a/test/gateway/gatewayMutationAdapter.test.ts +++ b/test/gateway/gatewayMutationAdapter.test.ts @@ -93,6 +93,7 @@ void test("adapter uses exact generated requests and preserves normalized recove warnings: [], rejectionReasons: [], advisoryAssetPermissions: [], + currency: "USD", } : name === "reconcileOrderOperation" ? { operation, observation: operation.reconciliation } @@ -160,6 +161,7 @@ void test("transport API makes one direct generated client call per operation", warnings: [], rejectionReasons: [], advisoryAssetPermissions: [], + currency: "USD", } : name === "reconcileOrderOperation" ? { operation, observation: operation.reconciliation } diff --git a/test/mcp/derivatives.test.ts b/test/mcp/derivatives.test.ts index afb230d..e4893f1 100644 --- a/test/mcp/derivatives.test.ts +++ b/test/mcp/derivatives.test.ts @@ -228,6 +228,7 @@ function fakeTools() { warnings: [], rejectionReasons: [], advisoryAssetPermissions: [], + currency: "USD", }, submitted: false, }); diff --git a/test/mcp/equityOrders.test.ts b/test/mcp/equityOrders.test.ts index 54cdd58..6676f8d 100644 --- a/test/mcp/equityOrders.test.ts +++ b/test/mcp/equityOrders.test.ts @@ -69,6 +69,7 @@ function tools() { warnings: [], rejectionReasons: [], advisoryAssetPermissions: [], + currency: "USD", }, submitted: false, }; diff --git a/test/mcp/forexOrders.test.ts b/test/mcp/forexOrders.test.ts new file mode 100644 index 0000000..208450a --- /dev/null +++ b/test/mcp/forexOrders.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { RegisteredMcpTool } from "../../src/mcp/tools/equityOrders.js"; +import { registerForexOrderTools, type ForexTools } from "../../src/mcp/tools/forexOrders.js"; +import type { PreviewForexOrderInput } from "../../src/forex/forexOrderService.js"; + +class FakeServer { + public readonly tools = new Map(); + public registerTool( + name: string, + definition: RegisteredMcpTool["definition"], + handler: RegisteredMcpTool["handler"] + ): void { + this.tools.set(name, { definition, handler }); + } +} + +function requiredTool(server: FakeServer, name: string): RegisteredMcpTool { + const tool = server.tools.get(name); + assert.ok(tool, `missing tool ${name}`); + return tool; +} + +function body(result: CallToolResult): Record { + const content = result.content[0] as { readonly text: string }; + return JSON.parse(content.text) as Record; +} + +function tools() { + const received: { preview: PreviewForexOrderInput[]; submit: unknown[] } = { + preview: [], + submit: [], + }; + const value = { + orders: { + preview: (input: PreviewForexOrderInput) => { + received.preview.push(input); + return Promise.resolve({ previewId: "c".repeat(64), submitted: false }); + }, + submit: (input: unknown) => { + received.submit.push(input); + return Promise.resolve({ previewId: "c".repeat(64), recovered: false }); + }, + }, + } as unknown as ForexTools; + return { received, create: () => Promise.resolve(value) }; +} + +test("fx tools register the same account-free inputs as the CLI", () => { + const server = new FakeServer(); + registerForexOrderTools(server, { createForexTools: tools().create }); + assert.deepEqual([...server.tools.keys()], ["fx_order_preview", "fx_order_submit"]); + assert.deepEqual( + Object.keys(requiredTool(server, "fx_order_preview").definition.inputSchema as object), + ["pair", "side", "quantity", "limit", "tif"] + ); + assert.deepEqual( + Object.keys(requiredTool(server, "fx_order_submit").definition.inputSchema as object), + ["previewId", "operator", "confirm"] + ); +}); + +test("fx_order_preview passes the terms to the forex service", async () => { + const server = new FakeServer(); + const fake = tools(); + registerForexOrderTools(server, { createForexTools: fake.create }); + const input = { pair: "USD.JPY", side: "BUY", quantity: 25000, limit: 147.25, tif: "DAY" }; + const result = await requiredTool(server, "fx_order_preview").handler(input); + assert.equal(result.isError, undefined); + assert.equal(body(result)["submitted"], false); + assert.deepEqual(fake.received.preview, [input]); +}); + +test("fx_order_submit refuses an unconfirmed write", async () => { + const server = new FakeServer(); + const fake = tools(); + registerForexOrderTools(server, { createForexTools: fake.create }); + const refused = await requiredTool(server, "fx_order_submit").handler({ + previewId: "c".repeat(64), + operator: "alice", + confirm: false, + }); + assert.equal(refused.isError, true); + assert.deepEqual(fake.received.submit, []); + await requiredTool(server, "fx_order_submit").handler({ + previewId: "c".repeat(64), + operator: "alice", + confirm: true, + }); + assert.deepEqual(fake.received.submit, [ + { previewId: "c".repeat(64), operator: "alice", confirm: true }, + ]); +}); diff --git a/yarn.lock b/yarn.lock index d1ca91f..acf5066 100644 --- a/yarn.lock +++ b/yarn.lock @@ -340,7 +340,7 @@ __metadata: resolution: "@huskly/cli@workspace:." dependencies: "@eslint/js": "npm:^10.0.1" - "@huskly/ibkr-gateway-client": "npm:0.17.0" + "@huskly/ibkr-gateway-client": "npm:0.18.0" "@huskly/schwab-client": "npm:^1.3.0" "@modelcontextprotocol/sdk": "npm:^1.29.0" "@types/asciichart": "npm:^1.5.8" @@ -372,10 +372,10 @@ __metadata: languageName: unknown linkType: soft -"@huskly/ibkr-gateway-client@npm:0.17.0": - version: 0.17.0 - resolution: "@huskly/ibkr-gateway-client@npm:0.17.0" - checksum: 10c0/72658d533168f97bd6420a27ac866886fb54a9ed86c11ea45f53728dc0c45498fea976510b857bed34adf981f2f3d041ea7c27caf9c6d97fbd60b74b904bd0c5 +"@huskly/ibkr-gateway-client@npm:0.18.0": + version: 0.18.0 + resolution: "@huskly/ibkr-gateway-client@npm:0.18.0" + checksum: 10c0/875a199e51af6b6de52e53447d7b8ef682cd54e7afee6a7950119cc951c7d3cbeb90a01428c9c17093512334dc8ef3d4be31fdbcf582e25048d99bb204693c27 languageName: node linkType: hard