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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ implemented for that broker.
| `option resolve` | ✗ | ✓ |
| `option chain` | ✗ | ✓ |
| `spread quote` | ✗ | ✓ |
| `spread preview` | ✗ | ✓ |
| `broker doctor` | ✗ | ✓ |
| `account` | ✓ | ✓ |
| `user-preference` | ✓ | ✗ |
| `positions` | ✓ | ✓ |
Expand Down Expand Up @@ -308,6 +310,35 @@ expiration payoff table, and an explicit settlement/residual-exposure warning.
Derivative research calls bypass the Redis read cache; each DTO retains the broker
quote timestamp and live/delayed/frozen/unavailable availability state.

#### spread preview - Explicit IBKR What-If

Preview one atomic vertical without submitting it. An exact account is mandatory,
either through `--account` or `IBKR_ACCOUNT_ID`. User-facing credits and debits are
always positive; IBKR's signed combo-price encoding stays inside the broker adapter.

```bash
huskly-cli spread preview put-credit NQ --broker ibkr \
--account U1234567 --asset FOP --expiry 2026-08-21 --class QN3 \
--exchange CME --short 26600 --long 26400 --quantity 1 --credit 39 --json
```

The output masks the account, identifies live versus paper, includes exact legs,
margin, commissions/fees, warnings and rejections, and states `submitted: false`.
Its preview ID binds the account/environment, exact contracts, quantity, limit,
TIF, session, timestamp, and normalized What-If result and expires after five minutes.
Unknown or incomplete What-If results fail closed.

#### broker doctor - Trading Diagnostics

```bash
huskly-cli broker doctor --broker ibkr --account U1234567 --trading --json
```

This command is read-only. It reports masked account/environment identity,
authentication and competing-session state, market-data access, and advisory asset
permissions. Permission metadata is diagnostic only; an explicit What-If response is
the authoritative pre-submission gate.

### Account Commands

#### account - Account Summary
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"typescript-eslint": "^8.61.1"
},
"dependencies": {
"@huskly/ibkr-client": "^0.9.0",
"@huskly/ibkr-client": "^0.10.0",
"@huskly/schwab-client": "^0.6.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"asciichart": "^1.5.25",
Expand Down
139 changes: 138 additions & 1 deletion src/cli/derivatives.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Command } from "commander";
import type { BrokerName } from "#src/brokers/brokerClient.js";
import { derivativeDiscoveryClient } from "#src/derivatives/derivativeClient.js";
import {
derivativeDiscoveryClient,
derivativePreviewClient,
} from "#src/derivatives/derivativeClient.js";
import type {
DerivativeAssetClass,
DerivativeRight,
Expand All @@ -12,6 +15,12 @@ import {
type VerticalSpreadResearch,
} from "#src/derivatives/derivativeResearch.js";
import type { VerticalSpreadKind } from "#src/derivatives/verticalSpread.js";
import type { TradingDiagnostics } from "#src/derivatives/derivativePreview.js";
import {
DerivativePreviewService,
maskAccountId,
type SpreadPreviewDto,
} from "#src/derivatives/derivativePreviewService.js";

interface SeriesOptions {
asset: string;
Expand All @@ -35,6 +44,14 @@ interface SpreadOptions extends SeriesOptions {
limit?: string;
}

interface PreviewOptions extends SpreadOptions {
account?: string;
credit?: string;
debit?: string;
session: string;
tif: string;
}

interface ResolveOptions extends SeriesOptions {
right?: string;
strike?: string;
Expand Down Expand Up @@ -65,6 +82,28 @@ function spreadKind(value: string): VerticalSpreadKind {
return normalized as VerticalSpreadKind;
}

function accountId(value: string | undefined): string {
const account = value ?? process.env["IBKR_ACCOUNT_ID"];
if (!account?.trim()) throw new Error("An exact --account or IBKR_ACCOUNT_ID is required.");
return account;
}

function tif(value: string): "DAY" | "GTC" {
const normalized = value.toUpperCase();
if (normalized !== "DAY" && normalized !== "GTC") {
throw new Error(`Invalid TIF '${value}'. Expected DAY or GTC.`);
}
return normalized;
}

function session(value: string): "REGULAR" | "OVERNIGHT" {
const normalized = value.toUpperCase();
if (normalized !== "REGULAR" && normalized !== "OVERNIGHT") {
throw new Error(`Invalid session '${value}'. Expected REGULAR or OVERNIGHT.`);
}
return normalized;
}

function number(value: string, name: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) throw new Error(`Invalid ${name} '${value}'.`);
Expand Down Expand Up @@ -183,6 +222,32 @@ export function renderVerticalSpread(result: VerticalSpreadResearch): string {
return lines.join("\n");
}

function renderTradingDiagnostics(result: TradingDiagnostics): string {
return [
`Account: ${result.accountId} Environment: ${result.environment}`,
`Authenticated: ${String(result.authenticated)} Competing session: ${String(result.competingSession)}`,
`Selected account matches: ${String(result.selectedAccountId === result.accountId)}`,
`Market data: ${result.marketDataAvailable === null ? "unknown" : String(result.marketDataAvailable)}`,
`Advisory asset permissions: ${result.advisoryAssetPermissions.join(", ") || "unknown"}`,
"Permission metadata is diagnostic only; an explicit What-If is authoritative.",
].join("\n");
}

function renderSpreadPreview(result: SpreadPreviewDto): string {
return [
`Preview ${result.previewId}`,
`Account: ${result.account.maskedId} Environment: ${result.account.environment}`,
`Expires: ${result.expiresAt}`,
`${result.order.kind} x${String(result.order.quantity)} ${result.order.priceEffect.toLowerCase()} ${String(result.order.limit)}`,
`Initial margin change: ${formatPrice(result.whatIf.initialMargin?.change ?? null)}`,
`Maintenance margin change: ${formatPrice(result.whatIf.maintenanceMargin?.change ?? null)}`,
`Commission/fees: ${formatPrice(result.whatIf.commission)}`,
`Warnings: ${result.whatIf.warnings.join(" | ") || "none"}`,
`Rejections: ${result.whatIf.rejectionReasons.join(" | ") || "none"}`,
"NO ORDER WAS SUBMITTED.",
].join("\n");
}

function output<T>(value: T, json: boolean | undefined, render: (result: T) => string): void {
console.log(json === true ? JSON.stringify(value, null, 2) : render(value));
}
Expand All @@ -191,6 +256,14 @@ async function service(broker: BrokerName): Promise<DerivativeResearchService> {
return new DerivativeResearchService(await derivativeDiscoveryClient(broker));
}

async function previewService(broker: BrokerName): Promise<DerivativePreviewService> {
const [discovery, preview] = await Promise.all([
derivativeDiscoveryClient(broker),
derivativePreviewClient(broker),
]);
return new DerivativePreviewService(discovery, preview);
}

/** Register broker-neutral derivative research commands without changing legacy chain commands. */
export function addDerivativeCommands(
program: Command,
Expand Down Expand Up @@ -264,5 +337,69 @@ export function addDerivativeCommands(
});
output(result, options.json, renderVerticalSpread);
});

seriesOptions(
spread
.command("preview")
.description("Run an explicit non-submitting vertical What-If")
.argument("<kind>", "call-debit, call-credit, put-debit, or put-credit")
.argument("<underlying>", "Underlying symbol")
.requiredOption("--long <strike>", "Long-leg strike")
.requiredOption("--short <strike>", "Short-leg strike")
.option("--account <id>", "Exact account ID; defaults to IBKR_ACCOUNT_ID")
.option("--credit <price>", "Positive net credit")
.option("--debit <price>", "Positive net debit")
.option("--quantity <count>", "Number of spreads", "1")
.option("--tif <value>", "DAY or GTC", "DAY")
.option("--session <value>", "REGULAR or OVERNIGHT", "REGULAR")
).action(async (kindValue: string, underlying: string, options: PreviewOptions) => {
if ((options.credit === undefined) === (options.debit === undefined)) {
throw new Error("Provide exactly one of --credit or --debit.");
}
const quantity = integer(options.quantity, "Quantity");
if (quantity === 0) throw new Error("Quantity must be greater than zero.");
const priceEffect = options.credit !== undefined ? "CREDIT" : "DEBIT";
const limitValue = options.credit ?? options.debit;
if (limitValue === undefined) throw new Error("A credit or debit is required.");
const result = await (
await previewService(broker(options.broker))
).previewVertical({
...seriesRequest(underlying, options),
accountId: accountId(options.account),
kind: spreadKind(kindValue),
longStrike: number(options.long, "long strike"),
shortStrike: number(options.short, "short strike"),
quantity,
priceEffect,
limit: number(limitValue, priceEffect.toLowerCase()),
tif: tif(options.tif),
session: session(options.session),
});
output(result, options.json, renderSpreadPreview);
});
program.addCommand(spread);

const brokerCommand = new Command("broker").description("Broker diagnostics");
brokerCommand
.command("doctor")
.description("Run read-only broker trading diagnostics")
.option("--broker <name>", "Broker to use: schwab or ibkr", "ibkr")
.option("--account <id>", "Exact account ID; defaults to IBKR_ACCOUNT_ID")
.option("--trading", "Include trading-session and advisory permission diagnostics")
.option("--json", "Emit a stable JSON DTO")
.action(
async (options: { broker: string; account?: string; trading?: boolean; json?: boolean }) => {
const result = await (
await previewService(broker(options.broker))
).getTradingDiagnostics(accountId(options.account));
const safeResult: TradingDiagnostics = {
...result,
accountId: maskAccountId(result.accountId),
selectedAccountId:
result.selectedAccountId === null ? null : maskAccountId(result.selectedAccountId),
};
output(safeResult, options.json, renderTradingDiagnostics);
}
);
program.addCommand(brokerCommand);
}
9 changes: 9 additions & 0 deletions src/derivatives/derivativeClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IbkrClient, buildOauthConfig } from "@huskly/ibkr-client";
import type { BrokerName } from "#src/brokers/brokerClient.js";
import type { DerivativeDiscoveryClient } from "./derivativeDiscovery.js";
import type { DerivativePreviewClient } from "./derivativePreview.js";
import { IbkrDerivativeAdapter } from "./ibkrDerivativeAdapter.js";

export interface DerivativeDiscoveryFactories {
Expand Down Expand Up @@ -36,3 +37,11 @@ const resolveDerivativeDiscovery = createDerivativeDiscoveryResolver({
export function derivativeDiscoveryClient(broker: BrokerName): Promise<DerivativeDiscoveryClient> {
return resolveDerivativeDiscovery(broker);
}

/** Resolve the explicit What-If capability; unsupported brokers fail closed. */
export async function derivativePreviewClient(
broker: BrokerName
): Promise<DerivativePreviewClient> {
return (await resolveDerivativeDiscovery(broker)) as DerivativeDiscoveryClient &
DerivativePreviewClient;
}
52 changes: 52 additions & 0 deletions src/derivatives/derivativePreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { DerivativeContract } from "./derivativeDiscovery.js";

export type BrokerEnvironment = "live" | "paper";

export interface TradingDiagnostics {
accountId: string;
selectedAccountId: string | null;
environment: BrokerEnvironment;
authenticated: boolean;
competingSession: boolean;
marketDataAvailable: boolean | null;
advisoryAssetPermissions: string[];
}

export interface DerivativeComboPreviewRequest {
accountId: string;
legs: [
{ contract: DerivativeContract; ratio: 1 | -1 },
{ contract: DerivativeContract; ratio: 1 | -1 },
];
quantity: number;
priceEffect: "CREDIT" | "DEBIT";
limit: number;
tif: "DAY" | "GTC";
session: "REGULAR" | "OVERNIGHT";
}

export interface MarginImpact {
current: number;
change: number;
after: number;
}

export interface DerivativeComboPreviewResult {
accountId: string;
environment: BrokerEnvironment;
accepted: boolean;
submitted: false;
commission: number | null;
initialMargin: MarginImpact | null;
maintenanceMargin: MarginImpact | null;
warnings: string[];
rejectionReasons: string[];
advisoryAssetPermissions: string[];
}

export interface DerivativePreviewClient {
getTradingDiagnostics(accountId: string): Promise<TradingDiagnostics>;
previewDerivativeCombo(
request: DerivativeComboPreviewRequest
): Promise<DerivativeComboPreviewResult>;
}
Loading