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 @@ -44,6 +44,9 @@ implemented for that broker.
| `vix` | ✓ | ✗ |
| `expiries` | ✓ | ✗ |
| `chain` | ✓ | ✗ |
| `option resolve` | ✗ | ✓ |
| `option chain` | ✗ | ✓ |
| `spread quote` | ✗ | ✓ |
| `account` | ✓ | ✓ |
| `user-preference` | ✓ | ✗ |
| `positions` | ✓ | ✓ |
Expand All @@ -53,7 +56,8 @@ implemented for that broker.
| `repl` | ✓ | ✓ |

IBKR currently supports the shared **`account`**, **`quote`**, **`search`**,
**`positions`**, **`transactions`**, **`orders`**, and **`repl`** commands. IBKR
**`positions`**, **`transactions`**, **`orders`**, and **`repl`** commands plus
read-only **`option resolve`**, **`option chain`**, and **`spread quote`** research. IBKR
`search` supports symbol lookup via `symbol-search` / `search`; Schwab-specific
regex, description, and fundamental projections report a clear error under
`--broker ibkr`. All other broker commands (`chain`, `movers`, `place-order`,
Expand Down Expand Up @@ -87,8 +91,10 @@ Market-data availability remains explicit (`live`, `delayed`, `frozen`,
`frozen-delayed`, or `unavailable`), and missing prices, activity, or Greeks stay
nullable. Discovery cannot reach preview or order-write endpoints.

User-facing `option` and `spread` commands are added by the next epic stage; the
existing `expiries` and `chain` commands remain Schwab-only until then.
The broker-neutral research service powers the user-facing `option` and `spread`
commands. Its current production adapter is IBKR; Schwab remains unsupported until
it can supply the exact trading-class/exchange identity without guessing. The
existing top-level `expiries` and `chain` commands remain unchanged and Schwab-only.

## Requirements

Expand Down Expand Up @@ -262,6 +268,46 @@ Options:
- `-a, --around <strike>` - Filter strikes around this price (defaults to last price)
- `-s, --strikes <count>` - Number of strikes to show above/below center (default: 10)

#### option - Exact Derivative Research

Resolve one exact contract or quote a series without collapsing trading class,
exchange, asset class, or multiplier. Every exploratory command supports `--json`.

```bash
huskly-cli option resolve NQ --broker ibkr \
--asset FOP --expiry 2026-08-21 --class QN3 --exchange CME --json

huskly-cli option chain NDX --broker ibkr \
--asset OPT --expiry 2026-08-20 --class NDXP --exchange SMART --right PUT \
--around 26600 --strikes 4
```

For futures options, the reference quote is the actual underlying futures contract
reported by IBKR, not a guessed spot/index proxy. For example, the NQ `QN3` option
series can reference the September NQ future.

#### spread quote - Vertical Spread Research

Analyze call/put debit or credit verticals from exact individual-leg markets.
Amounts include contract multiplier and quantity.

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

huskly-cli spread quote put-credit NDX --broker ibkr \
--asset OPT --expiry 2026-08-20 --class NDXP --exchange SMART \
--long 26400 --short 26600
```

The natural and midpoint scenarios are synthetic values computed from individual
leg quotes. They are not a broker combo NBBO, executable preview, or order quote.
The result includes maximum profit/loss, breakeven, return on risk, net delta, an
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.

### 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.8.0",
"@huskly/ibkr-client": "^0.9.0",
"@huskly/schwab-client": "^0.6.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"asciichart": "^1.5.25",
Expand Down
268 changes: 268 additions & 0 deletions src/cli/derivatives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { Command } from "commander";
import type { BrokerName } from "#src/brokers/brokerClient.js";
import { derivativeDiscoveryClient } from "#src/derivatives/derivativeClient.js";
import type {
DerivativeAssetClass,
DerivativeRight,
} from "#src/derivatives/derivativeDiscovery.js";
import {
DerivativeResearchService,
type OptionDiscoveryResearch,
type OptionChainResearch,
type VerticalSpreadResearch,
} from "#src/derivatives/derivativeResearch.js";
import type { VerticalSpreadKind } from "#src/derivatives/verticalSpread.js";

interface SeriesOptions {
asset: string;
broker?: string;
class?: string;
exchange?: string;
expiry: string;
json?: boolean;
}

interface ChainOptions extends SeriesOptions {
right?: string;
around?: string;
strikes: string;
}

interface SpreadOptions extends SeriesOptions {
long: string;
short: string;
quantity: string;
limit?: string;
}

interface ResolveOptions extends SeriesOptions {
right?: string;
strike?: string;
}

function assetClass(value: string): DerivativeAssetClass {
const normalized = value.toUpperCase();
if (normalized !== "OPT" && normalized !== "FOP") {
throw new Error(`Invalid derivative asset class '${value}'. Expected OPT or FOP.`);
}
return normalized;
}

function right(value: string): DerivativeRight {
const normalized = value.toUpperCase();
if (normalized !== "CALL" && normalized !== "PUT") {
throw new Error(`Invalid option right '${value}'. Expected CALL or PUT.`);
}
return normalized;
}

function spreadKind(value: string): VerticalSpreadKind {
const normalized = value.toLowerCase();
const kinds: VerticalSpreadKind[] = ["call-debit", "call-credit", "put-debit", "put-credit"];
if (!kinds.includes(normalized as VerticalSpreadKind)) {
throw new Error(`Invalid vertical kind '${value}'. Expected one of: ${kinds.join(", ")}.`);
}
return normalized as VerticalSpreadKind;
}

function number(value: string, name: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) throw new Error(`Invalid ${name} '${value}'.`);
return parsed;
}

function integer(value: string, name: string): number {
const parsed = number(value, name);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new Error(`${name} must be a non-negative integer.`);
}
return parsed;
}

function seriesOptions(command: Command): Command {
return command
.option("--broker <name>", "Broker to use: schwab or ibkr")
.option("--asset <class>", "Derivative asset class: OPT or FOP", "OPT")
.requiredOption("--expiry <date>", "Expiration date (YYYY-MM-DD)")
.option("--class <trading-class>", "Exact broker trading class")
.option("--exchange <exchange>", "Exact listing/routing exchange")
.option("--json", "Emit a stable JSON DTO");
}

function seriesRequest(underlying: string, options: SeriesOptions) {
return {
assetClass: assetClass(options.asset),
underlying: underlying.toUpperCase(),
expiration: options.expiry,
...(options.class !== undefined ? { tradingClass: options.class.toUpperCase() } : {}),
...(options.exchange !== undefined ? { exchange: options.exchange.toUpperCase() } : {}),
};
}

function formatPrice(value: number | null): string {
return value === null ? "-" : String(value);
}

export function renderOptionDiscovery(result: OptionDiscoveryResearch): string {
const reference = result.referenceQuote;
const lines = [
reference === null
? "Reference: unavailable"
: `Reference ${reference.symbol}: bid ${formatPrice(reference.bid)} ask ${formatPrice(reference.ask)} last ${formatPrice(reference.last)} mark ${formatPrice(reference.mark)} data ${reference.dataAvailability}`,
`Contracts: ${String(result.contracts.length)}`,
"EXPIRY STRIKE RIGHT ASSET CLASS EXCHANGE MULTIPLIER BROKER-REFERENCE",
];
for (const contract of result.contracts) {
const identity = contract.identity;
lines.push(
[
identity.expiration,
identity.strike,
identity.right,
identity.assetClass,
identity.tradingClass,
identity.exchange,
identity.multiplier,
`${contract.brokerReference?.broker ?? "-"}:${contract.brokerReference?.contractId ?? "-"}`,
].join(" ")
);
}
lines.push("Broker references are opaque and non-durable.");
return lines.join("\n");
}

export function renderOptionChain(result: OptionChainResearch): string {
const reference = result.referenceQuote;
const lines = [
reference === null
? "Reference: unavailable"
: `Reference ${reference.symbol}: ${formatPrice(reference.mark ?? reference.last)} (${reference.dataAvailability})`,
`Center: ${formatPrice(result.center)} Contracts: ${String(result.quotes.length)}`,
"STRIKE RIGHT BID ASK MARK DELTA CLASS EXCHANGE MULTIPLIER DATA",
];
for (const quote of result.quotes) {
const identity = quote.contract.identity;
lines.push(
[
identity.strike,
identity.right,
formatPrice(quote.bid),
formatPrice(quote.ask),
formatPrice(quote.mark),
formatPrice(quote.delta),
identity.tradingClass,
identity.exchange,
identity.multiplier,
quote.dataAvailability,
].join(" ")
);
}
return lines.join("\n");
}

export function renderVerticalSpread(result: VerticalSpreadResearch): string {
const { spread } = result;
const lines = [
`${spread.kind} x${String(spread.quantity)} width ${String(spread.width)} multiplier ${String(spread.multiplier)}`,
`Reference ${result.referenceQuote.symbol}: ${formatPrice(result.referenceQuote.mark ?? result.referenceQuote.last)} (${result.referenceQuote.dataAvailability})`,
`Long ${String(spread.longLeg.quote.contract.identity.strike)} @ ${formatPrice(spread.longLeg.quote.bid)} x ${formatPrice(spread.longLeg.quote.ask)}`,
`Short ${String(spread.shortLeg.quote.contract.identity.strike)} @ ${formatPrice(spread.shortLeg.quote.bid)} x ${formatPrice(spread.shortLeg.quote.ask)}`,
];
for (const scenario of spread.scenarios) {
if (scenario.analysis === null) {
lines.push(
`${scenario.source}: ${String(scenario.price)} unavailable (${scenario.error ?? "invalid"})`
);
continue;
}
lines.push(
`${scenario.source}: ${scenario.analysis.priceEffect.toLowerCase()} ${String(scenario.price)} max profit ${String(scenario.analysis.maximumProfit)} max loss ${String(scenario.analysis.maximumLoss)} breakeven ${String(scenario.analysis.breakeven)} return/risk ${String(scenario.analysis.returnOnRisk)} net delta ${formatPrice(scenario.analysis.netDelta)}`
);
}
lines.push(result.pricingNotice, spread.settlementWarning);
return lines.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));
}

async function service(broker: BrokerName): Promise<DerivativeResearchService> {
return new DerivativeResearchService(await derivativeDiscoveryClient(broker));
}

/** Register broker-neutral derivative research commands without changing legacy chain commands. */
export function addDerivativeCommands(
program: Command,
broker: (override?: string) => BrokerName
): void {
const option = new Command("option").description("Resolve and research exact derivative series");

seriesOptions(
option
.command("resolve")
.description("Resolve exact contracts in a derivative series")
.argument("<underlying>", "Underlying symbol")
.option("--right <right>", "Filter to CALL or PUT")
.option("--strike <strike>", "Filter to one exact strike")
).action(async (underlying: string, options: ResolveOptions) => {
const result = await (
await service(broker(options.broker))
).discover({
...seriesRequest(underlying, options),
...(options.strike !== undefined ? { strike: number(options.strike, "strike") } : {}),
...(options.right !== undefined ? { right: right(options.right) } : {}),
});
output(result, options.json, renderOptionDiscovery);
});

seriesOptions(
option
.command("chain")
.description("Quote an exact derivative series")
.argument("<underlying>", "Underlying symbol")
.option("--right <right>", "Filter to CALL or PUT")
.option("--around <strike>", "Center strike; defaults to the reference market")
.option("--strikes <count>", "Strikes on each side of center", "10")
).action(async (underlying: string, options: ChainOptions) => {
const result = await (
await service(broker(options.broker))
).chain({
...seriesRequest(underlying, options),
...(options.right !== undefined ? { right: right(options.right) } : {}),
...(options.around !== undefined ? { around: number(options.around, "around strike") } : {}),
strikes: integer(options.strikes, "Strike count"),
});
output(result, options.json, renderOptionChain);
});

program.addCommand(option);

const spread = new Command("spread").description("Research multi-leg derivative spreads");
seriesOptions(
spread
.command("quote")
.description("Analyze a two-leg vertical from individual leg markets")
.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("--quantity <count>", "Number of spreads", "1")
.option("--limit <price>", "Optional user limit for an additional scenario")
).action(async (kindValue: string, underlying: string, options: SpreadOptions) => {
const quantity = integer(options.quantity, "Quantity");
if (quantity === 0) throw new Error("Quantity must be greater than zero.");
const result = await (
await service(broker(options.broker))
).quoteVertical({
...seriesRequest(underlying, options),
kind: spreadKind(kindValue),
longStrike: number(options.long, "long strike"),
shortStrike: number(options.short, "short strike"),
quantity,
...(options.limit !== undefined ? { limit: number(options.limit, "limit") } : {}),
});
output(result, options.json, renderVerticalSpread);
});
program.addCommand(spread);
}
7 changes: 5 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { handleMovers } from "./movers.js";
import { disconnectCache } from "#src/cache.js";
import { resolveBroker, requireSchwab } from "./shared.js";
import type { BrokerName } from "#src/brokers/brokerClient.js";
import { addDerivativeCommands } from "./derivatives.js";

const program = new Command();

Expand All @@ -30,8 +31,8 @@ program
.option("--broker <name>", "Broker to use: schwab or ibkr", "schwab");

/** The broker selected via the global --broker flag (defaults to schwab). */
function broker(): BrokerName {
return resolveBroker(program.opts<{ broker?: string }>().broker);
function broker(override?: string): BrokerName {
return resolveBroker(override ?? program.opts<{ broker?: string }>().broker);
}

/** Resolve the broker and assert the command is Schwab-only. */
Expand Down Expand Up @@ -171,6 +172,8 @@ program
}
);

addDerivativeCommands(program, broker);

program
.command("account")
.description("Show account equity/net liquidation value")
Expand Down
1 change: 1 addition & 0 deletions src/derivatives/derivativeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const fakeClient: DerivativeDiscoveryClient = {
getContracts: () => Promise.resolve([]),
resolveContract: () => Promise.reject(new Error("not used")),
getChain: () => Promise.resolve([]),
getReferenceQuote: () => Promise.reject(new Error("not used")),
};

void test("capability resolver initializes IBKR once and rejects unsupported brokers explicitly", async () => {
Expand Down
Loading