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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` | ✓ | ✗ |
Expand Down Expand Up @@ -105,6 +107,29 @@ huskly-cli equity submit <preview-id> --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 <preview-id> --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.
Expand Down Expand Up @@ -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.
Expand All @@ -438,8 +480,11 @@ src/
├── brokers/
├── cli/
├── derivatives/
├── equities/
├── forex/
├── gateway/
├── mcp/
├── orders/
├── cache.ts
├── cachedSchwabClient.ts
├── helpers.ts
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/brokers/brokerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
2 changes: 1 addition & 1 deletion src/brokers/ibkrBrokerAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
92 changes: 11 additions & 81 deletions src/cli/equityOrders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>",
"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<EquityTools["orders"]>;
Expand Down Expand Up @@ -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") {
Expand All @@ -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,
Expand Down Expand Up @@ -193,50 +167,6 @@ export function renderEquitySubmission(result: SafeEquitySubmissionView): string
].join("\n");
}

async function acknowledgeWarnings(
result: EquitySubmissionDto,
createExecutionService: () => Promise<WarningExecutionService>
): Promise<{ readonly result: EquitySubmissionDto; readonly count: number }> {
let operation = result.operation;
let count = 0;
const handled = new Set<string>();
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<T>(
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<EquityTools["orders"]> | undefined;

async function equityOrders(broker: BrokerName): Promise<EquityTools["orders"]> {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading