gloomberb 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -19
- package/bin/gloomberb +3 -0
- package/package.json +5 -4
- package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
- package/src/app.tsx +1330 -0
- package/src/brokers/sync-broker-instance.test.ts +100 -0
- package/src/brokers/sync-broker-instance.ts +341 -0
- package/src/cli/commands/plugins.ts +173 -0
- package/src/cli/commands/portfolio.ts +210 -0
- package/src/cli/commands/search.ts +120 -0
- package/src/cli/commands/ticker.ts +375 -0
- package/src/cli/commands/watchlist.ts +210 -0
- package/src/cli/context.ts +62 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/helpers.ts +85 -0
- package/src/cli/index.ts +143 -0
- package/src/cli/ticker-resolution.ts +39 -0
- package/src/cli/types.ts +15 -0
- package/src/cli.test.ts +361 -0
- package/src/components/chart/chart-data.ts +239 -0
- package/src/components/chart/chart-renderer.ts +1199 -0
- package/src/components/chart/chart-types.ts +82 -0
- package/src/components/chart/chart-viewport.ts +21 -0
- package/src/components/chart/chart.test.ts +439 -0
- package/src/components/chart/comparison-chart-data.test.ts +109 -0
- package/src/components/chart/comparison-chart-data.ts +316 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
- package/src/components/chart/comparison-chart-renderer.ts +354 -0
- package/src/components/chart/comparison-stock-chart.tsx +1486 -0
- package/src/components/chart/cursor-motion.ts +48 -0
- package/src/components/chart/native/chart-rasterizer.ts +732 -0
- package/src/components/chart/native/kitty-adapter.ts +12 -0
- package/src/components/chart/native/kitty-manager.ts +142 -0
- package/src/components/chart/native/kitty-protocol.ts +110 -0
- package/src/components/chart/native/kitty-support.ts +71 -0
- package/src/components/chart/native/kitty.test.ts +620 -0
- package/src/components/chart/native/renderer-selection.ts +24 -0
- package/src/components/chart/native/surface-manager.ts +225 -0
- package/src/components/chart/native/surface-sync.ts +34 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
- package/src/components/chart/stock-chart.test.ts +107 -0
- package/src/components/chart/stock-chart.tsx +1556 -0
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
- package/src/components/command-bar/command-bar.test.tsx +1524 -0
- package/src/components/command-bar/command-bar.tsx +4823 -0
- package/src/components/command-bar/command-registry.ts +261 -0
- package/src/components/command-bar/helpers.test.ts +224 -0
- package/src/components/command-bar/helpers.ts +371 -0
- package/src/components/command-bar/root-shortcuts.ts +158 -0
- package/src/components/command-bar/view-model.test.ts +152 -0
- package/src/components/command-bar/view-model.ts +185 -0
- package/src/components/command-bar/workflow-ops.test.ts +70 -0
- package/src/components/command-bar/workflow-ops.ts +433 -0
- package/src/components/command-bar/workflow-types.ts +127 -0
- package/src/components/detail-feed-view.test.tsx +105 -0
- package/src/components/detail-feed-view.tsx +295 -0
- package/src/components/index.ts +32 -0
- package/src/components/layout/floating-pane.tsx +49 -0
- package/src/components/layout/header.tsx +129 -0
- package/src/components/layout/pane-header.tsx +44 -0
- package/src/components/layout/pane.tsx +49 -0
- package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
- package/src/components/layout/pixel-mouse.test.ts +40 -0
- package/src/components/layout/shell.test.tsx +625 -0
- package/src/components/layout/shell.tsx +1200 -0
- package/src/components/layout/status-bar.test.tsx +173 -0
- package/src/components/layout/status-bar.tsx +138 -0
- package/src/components/markdown-editor.tsx +134 -0
- package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
- package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
- package/src/components/pane-settings-dialog.tsx +468 -0
- package/src/components/pane-template-wizard.test.tsx +52 -0
- package/src/components/pane-template-wizard.tsx +199 -0
- package/src/components/price-selector-dialog.tsx +195 -0
- package/src/components/spinner.tsx +2 -0
- package/src/components/tab-bar.tsx +17 -0
- package/src/components/ticker-badge-text.test.tsx +160 -0
- package/src/components/ticker-badge-text.tsx +58 -0
- package/src/components/ticker-badge.tsx +88 -0
- package/src/components/ticker-list-table.tsx +188 -0
- package/src/components/toggle-list.tsx +74 -0
- package/src/components/ui/button.tsx +77 -0
- package/src/components/ui/data-table.tsx +210 -0
- package/src/components/ui/external-link.tsx +22 -0
- package/src/components/ui/fields.tsx +187 -0
- package/src/components/ui/frame.tsx +73 -0
- package/src/components/ui/index.ts +53 -0
- package/src/components/ui/list-view.tsx +166 -0
- package/src/components/ui/loading.tsx +70 -0
- package/src/components/ui/status.tsx +83 -0
- package/src/components/ui/tabs.tsx +47 -0
- package/src/components/ui/toggle.tsx +164 -0
- package/src/components/ui/ui.test.tsx +134 -0
- package/src/data/app-persistence.ts +25 -0
- package/src/data/config-store.test.ts +310 -0
- package/src/data/config-store.ts +469 -0
- package/src/data/plugin-state-store.ts +102 -0
- package/src/data/resource-store.ts +323 -0
- package/src/data/session-store.ts +52 -0
- package/src/data/sqlite-cache.test.ts +103 -0
- package/src/data/sqlite-database.ts +62 -0
- package/src/data/sqlite-json.ts +19 -0
- package/src/data/ticker-repository.ts +45 -0
- package/src/data/ticker-store.ts +46 -0
- package/src/index.tsx +67 -0
- package/src/market-data/coordinator.test.ts +369 -0
- package/src/market-data/coordinator.ts +580 -0
- package/src/market-data/hooks.tsx +234 -0
- package/src/market-data/query-store.ts +28 -0
- package/src/market-data/request-types.test.ts +64 -0
- package/src/market-data/request-types.ts +70 -0
- package/src/market-data/result-types.ts +56 -0
- package/src/market-data/selectors.ts +166 -0
- package/src/market-data/trace.ts +7 -0
- package/src/pane-settings.test.ts +71 -0
- package/src/pane-settings.ts +73 -0
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
- package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
- package/src/plugins/builtin/ai/index.test.tsx +139 -0
- package/src/plugins/builtin/ai/index.tsx +86 -0
- package/src/plugins/builtin/ai/providers.ts +99 -0
- package/src/plugins/builtin/ai/runner.ts +82 -0
- package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
- package/src/plugins/builtin/ai/screener-contract.ts +174 -0
- package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
- package/src/plugins/builtin/ai/settings.ts +79 -0
- package/src/plugins/builtin/ai/ticker-context.ts +64 -0
- package/src/plugins/builtin/ai/utils.ts +71 -0
- package/src/plugins/builtin/ask-ai.test.tsx +258 -0
- package/src/plugins/builtin/ask-ai.tsx +9 -0
- package/src/plugins/builtin/chat-controller.test.ts +217 -0
- package/src/plugins/builtin/chat-controller.ts +340 -0
- package/src/plugins/builtin/chat.test.tsx +451 -0
- package/src/plugins/builtin/chat.tsx +620 -0
- package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
- package/src/plugins/builtin/comparison-chart.tsx +169 -0
- package/src/plugins/builtin/data-providers.ts +16 -0
- package/src/plugins/builtin/debug.tsx +351 -0
- package/src/plugins/builtin/help.test.tsx +110 -0
- package/src/plugins/builtin/help.tsx +281 -0
- package/src/plugins/builtin/layout-manager.tsx +264 -0
- package/src/plugins/builtin/manual-entry.tsx +25 -0
- package/src/plugins/builtin/news.tsx +143 -0
- package/src/plugins/builtin/notes-files.ts +58 -0
- package/src/plugins/builtin/notes.tsx +396 -0
- package/src/plugins/builtin/options-availability.test.tsx +179 -0
- package/src/plugins/builtin/options-availability.ts +158 -0
- package/src/plugins/builtin/options.tsx +364 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
- package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
- package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
- package/src/plugins/builtin/portfolio-list.test.ts +74 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
- package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
- package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
- package/src/plugins/builtin/sec.tsx +250 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
- package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
- package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
- package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
- package/src/plugins/builtin/yahoo.ts +18 -0
- package/src/plugins/event-bus.ts +37 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
- package/src/plugins/ibkr/account-cache.test.ts +101 -0
- package/src/plugins/ibkr/account-cache.ts +137 -0
- package/src/plugins/ibkr/broker-adapter.ts +168 -0
- package/src/plugins/ibkr/config.test.ts +68 -0
- package/src/plugins/ibkr/config.ts +262 -0
- package/src/plugins/ibkr/dialogs.tsx +83 -0
- package/src/plugins/ibkr/flex.test.ts +107 -0
- package/src/plugins/ibkr/flex.ts +267 -0
- package/src/plugins/ibkr/fundamental-parser.ts +149 -0
- package/src/plugins/ibkr/gateway-helpers.ts +28 -0
- package/src/plugins/ibkr/gateway-selection.ts +51 -0
- package/src/plugins/ibkr/gateway-service.test.ts +450 -0
- package/src/plugins/ibkr/gateway-service.ts +2228 -0
- package/src/plugins/ibkr/index.test.tsx +183 -0
- package/src/plugins/ibkr/index.tsx +153 -0
- package/src/plugins/ibkr/instance-selection.test.ts +121 -0
- package/src/plugins/ibkr/instance-selection.ts +36 -0
- package/src/plugins/ibkr/price-normalization.test.ts +38 -0
- package/src/plugins/ibkr/price-normalization.ts +59 -0
- package/src/plugins/ibkr/trade-badge.tsx +27 -0
- package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
- package/src/plugins/ibkr/trade-tab.tsx +1050 -0
- package/src/plugins/ibkr/trade-utils.test.ts +175 -0
- package/src/plugins/ibkr/trade-utils.ts +149 -0
- package/src/plugins/ibkr/trading-pane.tsx +438 -0
- package/src/plugins/ibkr/trading-state.test.ts +109 -0
- package/src/plugins/ibkr/trading-state.ts +339 -0
- package/src/plugins/loader.ts +71 -0
- package/src/plugins/pane-manager.test.ts +103 -0
- package/src/plugins/pane-manager.ts +974 -0
- package/src/plugins/plugin-persistence.ts +85 -0
- package/src/plugins/plugin-runtime.test.tsx +185 -0
- package/src/plugins/plugin-runtime.tsx +175 -0
- package/src/plugins/prediction-markets/cache.ts +114 -0
- package/src/plugins/prediction-markets/categories.ts +223 -0
- package/src/plugins/prediction-markets/chart.tsx +512 -0
- package/src/plugins/prediction-markets/columns.ts +133 -0
- package/src/plugins/prediction-markets/controller-data.ts +614 -0
- package/src/plugins/prediction-markets/controller.ts +725 -0
- package/src/plugins/prediction-markets/detail/book.tsx +118 -0
- package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
- package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
- package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
- package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
- package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
- package/src/plugins/prediction-markets/detail.test.tsx +47 -0
- package/src/plugins/prediction-markets/index.tsx +102 -0
- package/src/plugins/prediction-markets/keyboard.ts +64 -0
- package/src/plugins/prediction-markets/launch.test.ts +126 -0
- package/src/plugins/prediction-markets/launch.ts +240 -0
- package/src/plugins/prediction-markets/metrics.ts +311 -0
- package/src/plugins/prediction-markets/navigation.ts +90 -0
- package/src/plugins/prediction-markets/outcome-order.ts +19 -0
- package/src/plugins/prediction-markets/pane.test.tsx +425 -0
- package/src/plugins/prediction-markets/pane.tsx +294 -0
- package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
- package/src/plugins/prediction-markets/rows.ts +229 -0
- package/src/plugins/prediction-markets/services/fetch.ts +97 -0
- package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
- package/src/plugins/prediction-markets/settings.ts +164 -0
- package/src/plugins/prediction-markets/status.ts +24 -0
- package/src/plugins/prediction-markets/table.tsx +84 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
- package/src/plugins/prediction-markets/types.ts +186 -0
- package/src/plugins/registry.ts +633 -0
- package/src/sources/exchange-rates.ts +18 -0
- package/src/sources/gloomberb-cloud.test.ts +199 -0
- package/src/sources/gloomberb-cloud.ts +326 -0
- package/src/sources/provider-errors.ts +19 -0
- package/src/sources/provider-router.test.ts +1476 -0
- package/src/sources/provider-router.ts +1405 -0
- package/src/sources/sec-edgar.test.ts +286 -0
- package/src/sources/sec-edgar.ts +526 -0
- package/src/sources/yahoo-finance.test.ts +14 -0
- package/src/sources/yahoo-finance.ts +1017 -0
- package/src/state/app-activity.test.ts +41 -0
- package/src/state/app-activity.ts +106 -0
- package/src/state/app-bootstrap.test.ts +471 -0
- package/src/state/app-bootstrap.ts +308 -0
- package/src/state/app-context.test.ts +406 -0
- package/src/state/app-context.test.tsx +132 -0
- package/src/state/app-context.tsx +969 -0
- package/src/state/selectors.ts +67 -0
- package/src/state/session-persistence.test.ts +128 -0
- package/src/state/session-persistence.ts +161 -0
- package/src/state/ticker-refresh-queue.test.ts +46 -0
- package/src/state/ticker-refresh-queue.ts +40 -0
- package/src/state/use-inline-tickers.ts +174 -0
- package/src/state/use-quote-streaming.test.ts +37 -0
- package/src/state/use-quote-streaming.test.tsx +70 -0
- package/src/state/use-quote-streaming.ts +80 -0
- package/src/state/use-session-snapshot.ts +84 -0
- package/src/test-support/data-provider.ts +22 -0
- package/src/theme/color-utils.ts +67 -0
- package/src/theme/colors.ts +128 -0
- package/src/theme/themes.test.ts +45 -0
- package/src/theme/themes.ts +491 -0
- package/src/types/broker.ts +105 -0
- package/src/types/config.ts +434 -0
- package/src/types/data-provider.ts +81 -0
- package/src/types/financials.ts +142 -0
- package/src/types/instrument.ts +29 -0
- package/src/types/persistence.ts +21 -0
- package/src/types/plugin.ts +354 -0
- package/src/types/ticker.ts +58 -0
- package/src/types/trading.ts +85 -0
- package/src/updater.test.ts +162 -0
- package/src/updater.ts +247 -0
- package/src/utils/api-client.ts +666 -0
- package/src/utils/ascii-chart.ts +172 -0
- package/src/utils/broker-instances.ts +47 -0
- package/src/utils/cli-output.ts +83 -0
- package/src/utils/currency-units.test.ts +49 -0
- package/src/utils/currency-units.ts +82 -0
- package/src/utils/debug-log.ts +133 -0
- package/src/utils/exchange-rate-map.ts +8 -0
- package/src/utils/format.ts +158 -0
- package/src/utils/fuzzy-search.ts +34 -0
- package/src/utils/market-status.ts +95 -0
- package/src/utils/options.ts +86 -0
- package/src/utils/price-history.ts +39 -0
- package/src/utils/quote-time.test.ts +22 -0
- package/src/utils/quote-time.ts +50 -0
- package/src/utils/sec.test.ts +55 -0
- package/src/utils/sec.ts +45 -0
- package/src/utils/ticker-list.test.ts +22 -0
- package/src/utils/ticker-list.ts +35 -0
- package/src/utils/ticker-metadata.ts +51 -0
- package/src/utils/ticker-search.test.ts +227 -0
- package/src/utils/ticker-search.ts +764 -0
- package/src/utils/ticker-tokenizer.test.ts +48 -0
- package/src/utils/ticker-tokenizer.ts +72 -0
- package/src/version.ts +1 -0
- package/install.mjs +0 -68
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, useState } from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import type { DataProvider } from "../../types/data-provider";
|
|
5
|
+
import type { OptionsChain } from "../../types/financials";
|
|
6
|
+
import type { TickerRecord } from "../../types/ticker";
|
|
7
|
+
import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
|
|
8
|
+
import { createTestDataProvider } from "../../test-support/data-provider";
|
|
9
|
+
import {
|
|
10
|
+
fetchOptionsAvailability,
|
|
11
|
+
readOptionsAvailability,
|
|
12
|
+
resetOptionsAvailabilityCache,
|
|
13
|
+
useOptionsAvailability,
|
|
14
|
+
} from "./options-availability";
|
|
15
|
+
import { resolveOptionsTarget } from "../../utils/options";
|
|
16
|
+
|
|
17
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
18
|
+
let setHarnessTicker: ((ticker: TickerRecord | null) => void) | null = null;
|
|
19
|
+
let sharedCoordinator: MarketDataCoordinator | null = null;
|
|
20
|
+
|
|
21
|
+
function makeTicker(symbol: string, assetCategory = "STK"): TickerRecord {
|
|
22
|
+
return {
|
|
23
|
+
metadata: {
|
|
24
|
+
ticker: symbol,
|
|
25
|
+
exchange: "NASDAQ",
|
|
26
|
+
currency: "USD",
|
|
27
|
+
name: symbol,
|
|
28
|
+
assetCategory,
|
|
29
|
+
portfolios: [],
|
|
30
|
+
watchlists: [],
|
|
31
|
+
positions: [],
|
|
32
|
+
custom: {},
|
|
33
|
+
tags: [],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createProvider(
|
|
39
|
+
getOptionsChain: NonNullable<DataProvider["getOptionsChain"]>,
|
|
40
|
+
): DataProvider {
|
|
41
|
+
return createTestDataProvider({
|
|
42
|
+
getOptionsChain,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createChain(underlyingSymbol: string, expirationDates: number[]): OptionsChain {
|
|
47
|
+
return {
|
|
48
|
+
underlyingSymbol,
|
|
49
|
+
expirationDates,
|
|
50
|
+
calls: [],
|
|
51
|
+
puts: [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createDeferred<T>() {
|
|
56
|
+
let resolve!: (value: T) => void;
|
|
57
|
+
let reject!: (reason?: unknown) => void;
|
|
58
|
+
const promise = new Promise<T>((res, rej) => {
|
|
59
|
+
resolve = res;
|
|
60
|
+
reject = rej;
|
|
61
|
+
});
|
|
62
|
+
return { promise, resolve, reject };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function AvailabilityHarness({ initialTicker }: { initialTicker: TickerRecord | null }) {
|
|
66
|
+
const [ticker, setTicker] = useState(initialTicker);
|
|
67
|
+
setHarnessTicker = setTicker;
|
|
68
|
+
const available = useOptionsAvailability(ticker);
|
|
69
|
+
return <text>{`${ticker?.metadata.ticker ?? "none"}:${available}`}</text>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function renderFrame() {
|
|
73
|
+
await act(async () => {
|
|
74
|
+
await testSetup!.renderOnce();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
if (testSetup) {
|
|
80
|
+
testSetup.renderer.destroy();
|
|
81
|
+
testSetup = undefined;
|
|
82
|
+
}
|
|
83
|
+
setHarnessTicker = null;
|
|
84
|
+
resetOptionsAvailabilityCache();
|
|
85
|
+
sharedCoordinator = null;
|
|
86
|
+
setSharedMarketDataCoordinator(null);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
resetOptionsAvailabilityCache();
|
|
91
|
+
setSharedMarketDataCoordinator(null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("options availability", () => {
|
|
95
|
+
test("marks a stock with expirations as available", async () => {
|
|
96
|
+
const target = resolveOptionsTarget(makeTicker("AAPL"));
|
|
97
|
+
const provider = createProvider(async () => createChain("AAPL", [1_717_113_600]));
|
|
98
|
+
|
|
99
|
+
expect(target).not.toBeNull();
|
|
100
|
+
expect(await fetchOptionsAvailability(target!, provider)).toBe(true);
|
|
101
|
+
expect(readOptionsAvailability(target!)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("marks an empty chain as unavailable", async () => {
|
|
105
|
+
const target = resolveOptionsTarget(makeTicker("AAPL"));
|
|
106
|
+
const provider = createProvider(async () => createChain("AAPL", []));
|
|
107
|
+
|
|
108
|
+
expect(target).not.toBeNull();
|
|
109
|
+
expect(await fetchOptionsAvailability(target!, provider)).toBe(false);
|
|
110
|
+
expect(readOptionsAvailability(target!)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("resolves option positions to the underlying before checking", async () => {
|
|
114
|
+
const ticker = makeTicker("SPY 260619C00500000", "OPT");
|
|
115
|
+
const calls: Array<{ symbol: string; exchange: string }> = [];
|
|
116
|
+
const provider = createProvider(async (symbol, exchange = "") => {
|
|
117
|
+
calls.push({ symbol, exchange });
|
|
118
|
+
return createChain(symbol, [1_717_113_600]);
|
|
119
|
+
});
|
|
120
|
+
const target = resolveOptionsTarget(ticker);
|
|
121
|
+
|
|
122
|
+
expect(target).not.toBeNull();
|
|
123
|
+
await fetchOptionsAvailability(target!, provider);
|
|
124
|
+
|
|
125
|
+
expect(calls).toEqual([{ symbol: "SPY", exchange: "" }]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("ignores stale async results when the selected ticker changes", async () => {
|
|
129
|
+
const aapl = createDeferred<OptionsChain>();
|
|
130
|
+
const tsla = createDeferred<OptionsChain>();
|
|
131
|
+
sharedCoordinator = new MarketDataCoordinator(createProvider(async (symbol) => (
|
|
132
|
+
symbol === "AAPL" ? aapl.promise : tsla.promise
|
|
133
|
+
)));
|
|
134
|
+
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
135
|
+
|
|
136
|
+
testSetup = await testRender(<AvailabilityHarness initialTicker={makeTicker("AAPL")} />, {
|
|
137
|
+
width: 40,
|
|
138
|
+
height: 4,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await renderFrame();
|
|
142
|
+
|
|
143
|
+
await act(async () => {
|
|
144
|
+
setHarnessTicker!(makeTicker("TSLA"));
|
|
145
|
+
await Promise.resolve();
|
|
146
|
+
});
|
|
147
|
+
await renderFrame();
|
|
148
|
+
|
|
149
|
+
await act(async () => {
|
|
150
|
+
tsla.resolve(createChain("TSLA", []));
|
|
151
|
+
await Promise.resolve();
|
|
152
|
+
});
|
|
153
|
+
await renderFrame();
|
|
154
|
+
|
|
155
|
+
await act(async () => {
|
|
156
|
+
aapl.resolve(createChain("AAPL", [1_717_113_600]));
|
|
157
|
+
await Promise.resolve();
|
|
158
|
+
});
|
|
159
|
+
await renderFrame();
|
|
160
|
+
|
|
161
|
+
const frame = testSetup.captureCharFrame();
|
|
162
|
+
expect(frame).toContain("TSLA:false");
|
|
163
|
+
expect(frame).not.toContain("TSLA:true");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("suppresses duplicate preflight calls while the cache is fresh", async () => {
|
|
167
|
+
const target = resolveOptionsTarget(makeTicker("AAPL"));
|
|
168
|
+
let calls = 0;
|
|
169
|
+
const provider = createProvider(async () => {
|
|
170
|
+
calls += 1;
|
|
171
|
+
return createChain("AAPL", [1_717_113_600]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(target).not.toBeNull();
|
|
175
|
+
expect(await fetchOptionsAvailability(target!, provider)).toBe(true);
|
|
176
|
+
expect(await fetchOptionsAvailability(target!, provider)).toBe(true);
|
|
177
|
+
expect(calls).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { getSharedMarketDataCoordinator } from "../../market-data/coordinator";
|
|
3
|
+
import type { DataProvider } from "../../types/data-provider";
|
|
4
|
+
import type { TickerRecord } from "../../types/ticker";
|
|
5
|
+
import type { ResolvedOptionsTarget } from "../../utils/options";
|
|
6
|
+
import { resolveOptionsTarget } from "../../utils/options";
|
|
7
|
+
|
|
8
|
+
export const OPTIONS_AVAILABILITY_TTL_MS = 5 * 60_000;
|
|
9
|
+
|
|
10
|
+
const EXPECTED_OPTIONS_MISS = /no data found|symbol may be delisted|not found|no options|no provider available/i;
|
|
11
|
+
|
|
12
|
+
type OptionsAvailabilityRecord = {
|
|
13
|
+
available: boolean;
|
|
14
|
+
checkedAt: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const optionsAvailabilityCache = new Map<string, OptionsAvailabilityRecord>();
|
|
18
|
+
const optionsAvailabilityInFlight = new Map<string, Promise<boolean>>();
|
|
19
|
+
|
|
20
|
+
function getRequestContext(target: ResolvedOptionsTarget) {
|
|
21
|
+
return {
|
|
22
|
+
brokerId: target.instrument?.brokerId,
|
|
23
|
+
brokerInstanceId: target.instrument?.brokerInstanceId,
|
|
24
|
+
instrument: target.instrument,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isCacheFresh(record: OptionsAvailabilityRecord | undefined, now: number): record is OptionsAvailabilityRecord {
|
|
29
|
+
return !!record && (now - record.checkedAt) < OPTIONS_AVAILABILITY_TTL_MS;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readOptionsAvailability(
|
|
33
|
+
targetOrKey: ResolvedOptionsTarget | string,
|
|
34
|
+
now = Date.now(),
|
|
35
|
+
): boolean | null {
|
|
36
|
+
const key = typeof targetOrKey === "string" ? targetOrKey : targetOrKey.cacheKey;
|
|
37
|
+
const record = optionsAvailabilityCache.get(key);
|
|
38
|
+
return isCacheFresh(record, now) ? record.available : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setOptionsAvailability(
|
|
42
|
+
targetOrKey: ResolvedOptionsTarget | string,
|
|
43
|
+
available: boolean,
|
|
44
|
+
checkedAt = Date.now(),
|
|
45
|
+
): void {
|
|
46
|
+
const key = typeof targetOrKey === "string" ? targetOrKey : targetOrKey.cacheKey;
|
|
47
|
+
optionsAvailabilityCache.set(key, { available, checkedAt });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resetOptionsAvailabilityCache(): void {
|
|
51
|
+
optionsAvailabilityCache.clear();
|
|
52
|
+
optionsAvailabilityInFlight.clear();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function fetchOptionsAvailability(
|
|
56
|
+
target: ResolvedOptionsTarget,
|
|
57
|
+
provider?: DataProvider,
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
const cached = readOptionsAvailability(target);
|
|
60
|
+
if (cached != null) return cached;
|
|
61
|
+
|
|
62
|
+
if (!target.effectiveTicker) {
|
|
63
|
+
setOptionsAvailability(target, false);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const existing = optionsAvailabilityInFlight.get(target.cacheKey);
|
|
68
|
+
if (existing) return existing;
|
|
69
|
+
|
|
70
|
+
const request = (async () => {
|
|
71
|
+
try {
|
|
72
|
+
if (provider?.getOptionsChain) {
|
|
73
|
+
const chain = await provider.getOptionsChain(
|
|
74
|
+
target.effectiveTicker,
|
|
75
|
+
target.effectiveExchange,
|
|
76
|
+
undefined,
|
|
77
|
+
getRequestContext(target),
|
|
78
|
+
);
|
|
79
|
+
const available = chain.expirationDates.length > 0;
|
|
80
|
+
setOptionsAvailability(target, available);
|
|
81
|
+
return available;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const coordinator = getSharedMarketDataCoordinator();
|
|
85
|
+
if (!coordinator) {
|
|
86
|
+
setOptionsAvailability(target, false);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const entry = await coordinator.loadOptions({
|
|
90
|
+
instrument: {
|
|
91
|
+
symbol: target.effectiveTicker,
|
|
92
|
+
exchange: target.effectiveExchange,
|
|
93
|
+
brokerId: target.instrument?.brokerId,
|
|
94
|
+
brokerInstanceId: target.instrument?.brokerInstanceId,
|
|
95
|
+
instrument: target.instrument,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const chain = entry.data ?? entry.lastGoodData;
|
|
99
|
+
if (!chain) {
|
|
100
|
+
setOptionsAvailability(target, false);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const available = chain.expirationDates.length > 0;
|
|
104
|
+
setOptionsAvailability(target, available);
|
|
105
|
+
return available;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
108
|
+
if (!EXPECTED_OPTIONS_MISS.test(message)) {
|
|
109
|
+
setOptionsAvailability(target, false);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
setOptionsAvailability(target, false);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
})().finally(() => {
|
|
116
|
+
optionsAvailabilityInFlight.delete(target.cacheKey);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
optionsAvailabilityInFlight.set(target.cacheKey, request);
|
|
120
|
+
return request;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function useOptionsAvailability(ticker: TickerRecord | null | undefined): boolean {
|
|
124
|
+
const target = useMemo(() => resolveOptionsTarget(ticker), [
|
|
125
|
+
ticker?.metadata.ticker,
|
|
126
|
+
ticker?.metadata.exchange,
|
|
127
|
+
ticker?.metadata.assetCategory,
|
|
128
|
+
ticker?.metadata.broker_contracts?.[0]?.brokerInstanceId,
|
|
129
|
+
ticker?.metadata.broker_contracts?.[0]?.conId,
|
|
130
|
+
]);
|
|
131
|
+
const [available, setAvailable] = useState(() => (target ? readOptionsAvailability(target) ?? false : false));
|
|
132
|
+
const requestRef = useRef(0);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!target) {
|
|
136
|
+
requestRef.current += 1;
|
|
137
|
+
setAvailable(false);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const cached = readOptionsAvailability(target);
|
|
142
|
+
if (cached != null) {
|
|
143
|
+
requestRef.current += 1;
|
|
144
|
+
setAvailable(cached);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setAvailable(false);
|
|
149
|
+
const requestId = ++requestRef.current;
|
|
150
|
+
void fetchOptionsAvailability(target).then((result) => {
|
|
151
|
+
if (requestRef.current === requestId) {
|
|
152
|
+
setAvailable(result);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}, [target?.cacheKey]);
|
|
156
|
+
|
|
157
|
+
return available;
|
|
158
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
|
+
import { TextAttributes } from "@opentui/core";
|
|
4
|
+
import type { GloomPlugin, DetailTabProps } from "../../types/plugin";
|
|
5
|
+
import type { OptionContract, OptionsChain } from "../../types/financials";
|
|
6
|
+
import { usePaneTicker } from "../../state/app-context";
|
|
7
|
+
import { ListView, type ListViewItem } from "../../components/ui";
|
|
8
|
+
import { colors, hoverBg } from "../../theme/colors";
|
|
9
|
+
import { padTo, formatCompact, formatNumber } from "../../utils/format";
|
|
10
|
+
import { formatExpDate, resolveOptionsTarget } from "../../utils/options";
|
|
11
|
+
import { useOptionsQuery, useResolvedEntryValue } from "../../market-data/hooks";
|
|
12
|
+
import { Spinner } from "../../components/spinner";
|
|
13
|
+
import { setOptionsAvailability } from "./options-availability";
|
|
14
|
+
|
|
15
|
+
function OptionsTab({ width, height, focused, onCapture }: DetailTabProps) {
|
|
16
|
+
const { ticker } = usePaneTicker();
|
|
17
|
+
const [expIdx, setExpIdx] = useState(0);
|
|
18
|
+
const [strikeIdx, setStrikeIdx] = useState(0);
|
|
19
|
+
const [interactive, setInteractive] = useState(false);
|
|
20
|
+
const [hoveredExpIdx, setHoveredExpIdx] = useState<number | null>(null);
|
|
21
|
+
const target = resolveOptionsTarget(ticker);
|
|
22
|
+
const isOpt = target?.isOptionTicker ?? false;
|
|
23
|
+
const parsed = target?.parsedOption ?? null;
|
|
24
|
+
const effectiveTicker = target?.effectiveTicker ?? "";
|
|
25
|
+
const effectiveExchange = target?.effectiveExchange ?? "";
|
|
26
|
+
const instrument = target?.instrument ?? null;
|
|
27
|
+
const baseRequest = target
|
|
28
|
+
? {
|
|
29
|
+
instrument: {
|
|
30
|
+
symbol: effectiveTicker,
|
|
31
|
+
exchange: effectiveExchange,
|
|
32
|
+
brokerId: instrument?.brokerId,
|
|
33
|
+
brokerInstanceId: instrument?.brokerInstanceId,
|
|
34
|
+
instrument,
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
: null;
|
|
38
|
+
const initialChainEntry = useOptionsQuery(baseRequest);
|
|
39
|
+
const initialChain = useResolvedEntryValue(initialChainEntry);
|
|
40
|
+
const selectedExpiration = initialChain?.expirationDates[expIdx];
|
|
41
|
+
const expirationChainEntry = useOptionsQuery(
|
|
42
|
+
baseRequest && selectedExpiration != null
|
|
43
|
+
? { ...baseRequest, expirationDate: selectedExpiration }
|
|
44
|
+
: null,
|
|
45
|
+
);
|
|
46
|
+
const expirationChain = useResolvedEntryValue(expirationChainEntry);
|
|
47
|
+
const chain = expirationChain ?? initialChain;
|
|
48
|
+
const loading = (initialChainEntry?.phase === "loading" || initialChainEntry?.phase === "refreshing") && !chain
|
|
49
|
+
|| (expirationChainEntry?.phase === "loading" || expirationChainEntry?.phase === "refreshing");
|
|
50
|
+
const error = initialChainEntry?.phase === "error"
|
|
51
|
+
? initialChainEntry.error?.message ?? "Failed to load options"
|
|
52
|
+
: expirationChainEntry?.phase === "error"
|
|
53
|
+
? expirationChainEntry.error?.message ?? "Failed to load options"
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
const enterInteractive = () => {
|
|
57
|
+
if (!interactive) {
|
|
58
|
+
setInteractive(true);
|
|
59
|
+
onCapture(true);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const exitInteractive = () => {
|
|
64
|
+
if (interactive) {
|
|
65
|
+
setInteractive(false);
|
|
66
|
+
onCapture(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Exit interactive mode when ticker changes
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
exitInteractive();
|
|
73
|
+
setExpIdx(0);
|
|
74
|
+
setStrikeIdx(0);
|
|
75
|
+
}, [effectiveTicker]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
setHoveredExpIdx(null);
|
|
79
|
+
}, [expIdx]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!target) return;
|
|
83
|
+
setOptionsAvailability(target, !!chain && chain.expirationDates.length > 0);
|
|
84
|
+
}, [chain, target]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!parsed || !initialChain || initialChain.expirationDates.length === 0) return;
|
|
88
|
+
const bestExpIdx = initialChain.expirationDates.reduce((best, ts, i) =>
|
|
89
|
+
Math.abs(ts - parsed.expTs) < Math.abs(initialChain.expirationDates[best]! - parsed.expTs) ? i : best, 0);
|
|
90
|
+
if (bestExpIdx !== expIdx) {
|
|
91
|
+
setExpIdx(bestExpIdx);
|
|
92
|
+
}
|
|
93
|
+
}, [expIdx, initialChain, parsed]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
setStrikeIdx(0);
|
|
97
|
+
}, [expIdx]);
|
|
98
|
+
|
|
99
|
+
// Build sorted strike list from the union of calls and puts
|
|
100
|
+
const strikes = chain ? buildStrikeList(chain) : [];
|
|
101
|
+
|
|
102
|
+
// Auto-scroll to matching strike when viewing an option position
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!parsed || strikes.length === 0) return;
|
|
105
|
+
const matchIdx = strikes.findIndex((s) => Math.abs(s - parsed.strike) < 0.01);
|
|
106
|
+
if (matchIdx >= 0) setStrikeIdx(matchIdx);
|
|
107
|
+
}, [strikes.length, parsed?.strike]);
|
|
108
|
+
|
|
109
|
+
// Keyboard navigation
|
|
110
|
+
useKeyboard((event) => {
|
|
111
|
+
if (!focused || !chain) return;
|
|
112
|
+
|
|
113
|
+
const isEnter = event.name === "enter" || event.name === "return";
|
|
114
|
+
|
|
115
|
+
// Enter/Escape for interactive mode
|
|
116
|
+
if (isEnter && !interactive) {
|
|
117
|
+
enterInteractive();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (event.name === "escape" && interactive) {
|
|
121
|
+
exitInteractive();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!interactive) return;
|
|
126
|
+
|
|
127
|
+
// Arrow keys + j/k/h/l in interactive mode
|
|
128
|
+
const numExp = chain.expirationDates.length;
|
|
129
|
+
if (event.name === "j" || event.name === "down") {
|
|
130
|
+
setStrikeIdx((i) => Math.min(i + 1, strikes.length - 1));
|
|
131
|
+
} else if (event.name === "k" || event.name === "up") {
|
|
132
|
+
setStrikeIdx((i) => Math.max(i - 1, 0));
|
|
133
|
+
} else if (event.name === "h" || event.name === "left") {
|
|
134
|
+
setExpIdx((i) => Math.max(i - 1, 0));
|
|
135
|
+
} else if (event.name === "l" || event.name === "right") {
|
|
136
|
+
setExpIdx((i) => Math.min(i + 1, numExp - 1));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!ticker) return <text fg={colors.textDim}>Select a ticker to view options.</text>;
|
|
141
|
+
if (loading && !chain) return <Spinner label="Loading options chain..." />;
|
|
142
|
+
if (error) return <text fg={colors.textDim}>{error}</text>;
|
|
143
|
+
if (!chain || chain.expirationDates.length === 0) return <text fg={colors.textDim}>No options available for {effectiveTicker}.</text>;
|
|
144
|
+
|
|
145
|
+
const innerWidth = Math.max(width - 4, 60);
|
|
146
|
+
const callsByStrike = new Map<number, OptionContract>(chain.calls.map((c) => [c.strike, c]));
|
|
147
|
+
const putsByStrike = new Map<number, OptionContract>(chain.puts.map((p) => [p.strike, p]));
|
|
148
|
+
|
|
149
|
+
// Column widths for each side
|
|
150
|
+
const colW = { last: 7, bid: 7, ask: 7, vol: 6, oi: 6 };
|
|
151
|
+
const sideW = colW.last + colW.bid + colW.ask + colW.vol + colW.oi + 4; // 4 gaps
|
|
152
|
+
const strikeW = 9;
|
|
153
|
+
const divW = 1;
|
|
154
|
+
|
|
155
|
+
// Position info
|
|
156
|
+
const posShares = isOpt && parsed
|
|
157
|
+
? ticker!.metadata.positions.reduce((sum, p) => sum + p.shares, 0)
|
|
158
|
+
: 0;
|
|
159
|
+
|
|
160
|
+
// Expiration selector — show a window of dates around the current selection
|
|
161
|
+
const maxExpVisible = Math.max(Math.floor((innerWidth - 14) / 13), 3);
|
|
162
|
+
const expStart = Math.max(0, Math.min(expIdx - Math.floor(maxExpVisible / 2), chain.expirationDates.length - maxExpVisible));
|
|
163
|
+
const visibleExps = chain.expirationDates.slice(expStart, expStart + maxExpVisible);
|
|
164
|
+
|
|
165
|
+
// Selected row detail
|
|
166
|
+
const selectedStrike = strikes[strikeIdx];
|
|
167
|
+
const selectedCall = selectedStrike != null ? callsByStrike.get(selectedStrike) : undefined;
|
|
168
|
+
const selectedPut = selectedStrike != null ? putsByStrike.get(selectedStrike) : undefined;
|
|
169
|
+
const strikeItems: ListViewItem[] = strikes.map((strike) => ({
|
|
170
|
+
id: String(strike),
|
|
171
|
+
label: formatStrikeLabel(strike),
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
const hoverColor = hoverBg();
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<box flexDirection="column" flexGrow={1} paddingX={1} onMouseDown={() => { if (!interactive) enterInteractive(); }}>
|
|
178
|
+
{/* Expiration selector */}
|
|
179
|
+
<box flexDirection="row" height={1} gap={1}>
|
|
180
|
+
<text fg={colors.textDim}>Exp:</text>
|
|
181
|
+
{visibleExps.map((ts, i) => {
|
|
182
|
+
const realIdx = expStart + i;
|
|
183
|
+
const isActive = realIdx === expIdx;
|
|
184
|
+
const isHovered = realIdx === hoveredExpIdx && !isActive;
|
|
185
|
+
return (
|
|
186
|
+
<box
|
|
187
|
+
key={ts}
|
|
188
|
+
onMouseMove={() => setHoveredExpIdx(realIdx)}
|
|
189
|
+
onMouseOut={() => setHoveredExpIdx(null)}
|
|
190
|
+
onMouseDown={() => { enterInteractive(); setExpIdx(realIdx); }}
|
|
191
|
+
>
|
|
192
|
+
<text
|
|
193
|
+
fg={isActive ? colors.textBright : isHovered ? colors.text : colors.textMuted}
|
|
194
|
+
attributes={isActive ? TextAttributes.BOLD : isHovered ? TextAttributes.UNDERLINE : 0}
|
|
195
|
+
>
|
|
196
|
+
{isActive ? `[${formatExpDate(ts)}]` : ` ${formatExpDate(ts)} `}
|
|
197
|
+
</text>
|
|
198
|
+
</box>
|
|
199
|
+
);
|
|
200
|
+
})}
|
|
201
|
+
{loading && <spinner name="dots" color={colors.textDim} />}
|
|
202
|
+
</box>
|
|
203
|
+
|
|
204
|
+
{/* Position banner */}
|
|
205
|
+
{isOpt && parsed && (
|
|
206
|
+
<box height={1}>
|
|
207
|
+
<text fg={colors.textBright}>
|
|
208
|
+
{`Position: ${posShares} ${parsed.side === "C" ? "call" : "put"} contract${posShares !== 1 ? "s" : ""} @ $${parsed.strike}`}
|
|
209
|
+
</text>
|
|
210
|
+
</box>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Header labels */}
|
|
214
|
+
<box flexDirection="row" height={1}>
|
|
215
|
+
<box width={sideW}><text attributes={TextAttributes.BOLD} fg={colors.positive}>CALLS</text></box>
|
|
216
|
+
<box width={divW}><text fg={colors.textDim}>{"\u2502"}</text></box>
|
|
217
|
+
<box width={strikeW} />
|
|
218
|
+
<box width={divW}><text fg={colors.textDim}>{"\u2502"}</text></box>
|
|
219
|
+
<box width={sideW}><text attributes={TextAttributes.BOLD} fg={colors.negative}>PUTS</text></box>
|
|
220
|
+
</box>
|
|
221
|
+
|
|
222
|
+
{/* Column headers */}
|
|
223
|
+
<box flexDirection="row" height={1}>
|
|
224
|
+
<box width={sideW}>
|
|
225
|
+
<text attributes={TextAttributes.BOLD} fg={colors.textDim}>
|
|
226
|
+
{padTo("Last", colW.last)} {padTo("Bid", colW.bid)} {padTo("Ask", colW.ask)} {padTo("Vol", colW.vol)} {padTo("OI", colW.oi)}
|
|
227
|
+
</text>
|
|
228
|
+
</box>
|
|
229
|
+
<box width={divW}><text fg={colors.textDim}>{"\u2502"}</text></box>
|
|
230
|
+
<box width={strikeW}>
|
|
231
|
+
<text attributes={TextAttributes.BOLD} fg={colors.textDim}>{padTo("Strike", strikeW, "center")}</text>
|
|
232
|
+
</box>
|
|
233
|
+
<box width={divW}><text fg={colors.textDim}>{"\u2502"}</text></box>
|
|
234
|
+
<box width={sideW}>
|
|
235
|
+
<text attributes={TextAttributes.BOLD} fg={colors.textDim}>
|
|
236
|
+
{padTo("Last", colW.last)} {padTo("Bid", colW.bid)} {padTo("Ask", colW.ask)} {padTo("Vol", colW.vol)} {padTo("OI", colW.oi)}
|
|
237
|
+
</text>
|
|
238
|
+
</box>
|
|
239
|
+
</box>
|
|
240
|
+
|
|
241
|
+
{/* Chain rows */}
|
|
242
|
+
<ListView
|
|
243
|
+
items={strikeItems}
|
|
244
|
+
selectedIndex={interactive ? strikeIdx : -1}
|
|
245
|
+
scrollIndex={strikeIdx}
|
|
246
|
+
onSelect={(index) => {
|
|
247
|
+
enterInteractive();
|
|
248
|
+
setStrikeIdx(index);
|
|
249
|
+
}}
|
|
250
|
+
renderRow={(_, state, i) => {
|
|
251
|
+
const strike = strikes[i]!;
|
|
252
|
+
const call = callsByStrike.get(strike);
|
|
253
|
+
const put = putsByStrike.get(strike);
|
|
254
|
+
const isSelected = state.selected;
|
|
255
|
+
const isPositionStrike = parsed && Math.abs(strike - parsed.strike) < 0.01;
|
|
256
|
+
const callItm = call?.inTheMoney;
|
|
257
|
+
const putItm = put?.inTheMoney;
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<box flexDirection="row">
|
|
261
|
+
<box width={sideW}>
|
|
262
|
+
<text fg={callItm ? colors.textBright : colors.text}>
|
|
263
|
+
{formatContractRow(call, colW)}
|
|
264
|
+
</text>
|
|
265
|
+
</box>
|
|
266
|
+
<box width={divW}><text fg={colors.textDim}>{"\u2502"}</text></box>
|
|
267
|
+
<box width={strikeW}>
|
|
268
|
+
<text fg={isSelected ? colors.textBright : colors.neutral} attributes={isSelected ? TextAttributes.BOLD : 0}>
|
|
269
|
+
{padTo(formatStrikeLabel(strike), strikeW, "center")}
|
|
270
|
+
</text>
|
|
271
|
+
</box>
|
|
272
|
+
<box width={divW}><text fg={colors.textDim}>{"\u2502"}</text></box>
|
|
273
|
+
<box width={sideW}>
|
|
274
|
+
<text fg={putItm ? colors.textBright : colors.text}>
|
|
275
|
+
{formatContractRow(put, colW)}
|
|
276
|
+
</text>
|
|
277
|
+
</box>
|
|
278
|
+
</box>
|
|
279
|
+
);
|
|
280
|
+
}}
|
|
281
|
+
getRowBackgroundColor={(_, state, i) => {
|
|
282
|
+
const strike = strikes[i]!;
|
|
283
|
+
const isPositionStrike = parsed && Math.abs(strike - parsed.strike) < 0.01;
|
|
284
|
+
if (state.selected) return colors.selected;
|
|
285
|
+
if (state.hovered) return hoverColor;
|
|
286
|
+
return isPositionStrike ? colors.selected : colors.bg;
|
|
287
|
+
}}
|
|
288
|
+
hoverBgColor={hoverColor}
|
|
289
|
+
flexGrow={1}
|
|
290
|
+
scrollable
|
|
291
|
+
/>
|
|
292
|
+
|
|
293
|
+
{/* Detail for selected row */}
|
|
294
|
+
{interactive && (selectedCall || selectedPut) && (
|
|
295
|
+
<box height={1}>
|
|
296
|
+
<text fg={colors.textDim}>
|
|
297
|
+
{selectedCall ? `Call IV: ${(selectedCall.impliedVolatility * 100).toFixed(1)}%` : ""}
|
|
298
|
+
{selectedCall && selectedPut ? " | " : ""}
|
|
299
|
+
{selectedPut ? `Put IV: ${(selectedPut.impliedVolatility * 100).toFixed(1)}%` : ""}
|
|
300
|
+
</text>
|
|
301
|
+
</box>
|
|
302
|
+
)}
|
|
303
|
+
|
|
304
|
+
{/* Help */}
|
|
305
|
+
<box height={1}>
|
|
306
|
+
<text fg={colors.textMuted}>
|
|
307
|
+
{interactive
|
|
308
|
+
? "j/k/\u2191\u2193 strike h/l/\u2190\u2192 expiration Esc exit"
|
|
309
|
+
: "Enter to interact"}
|
|
310
|
+
</text>
|
|
311
|
+
</box>
|
|
312
|
+
</box>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildStrikeList(chain: OptionsChain): number[] {
|
|
317
|
+
const set = new Set<number>();
|
|
318
|
+
for (const c of chain.calls) set.add(c.strike);
|
|
319
|
+
for (const p of chain.puts) set.add(p.strike);
|
|
320
|
+
return Array.from(set).sort((a, b) => a - b);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function formatStrikeLabel(strike: number): string {
|
|
324
|
+
const decimals = strike % 1 === 0 ? 0 : 2;
|
|
325
|
+
return formatNumber(strike, decimals).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
interface ColWidths {
|
|
329
|
+
last: number;
|
|
330
|
+
bid: number;
|
|
331
|
+
ask: number;
|
|
332
|
+
vol: number;
|
|
333
|
+
oi: number;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function formatContractRow(contract: { lastPrice: number; bid: number; ask: number; volume: number; openInterest: number } | undefined, w: ColWidths): string {
|
|
337
|
+
if (!contract) {
|
|
338
|
+
return padTo("—", w.last) + " " + padTo("—", w.bid) + " " + padTo("—", w.ask) + " " + padTo("—", w.vol) + " " + padTo("—", w.oi);
|
|
339
|
+
}
|
|
340
|
+
return (
|
|
341
|
+
padTo(contract.lastPrice.toFixed(2), w.last) + " " +
|
|
342
|
+
padTo(contract.bid.toFixed(2), w.bid) + " " +
|
|
343
|
+
padTo(contract.ask.toFixed(2), w.ask) + " " +
|
|
344
|
+
padTo(formatCompact(contract.volume), w.vol) + " " +
|
|
345
|
+
padTo(formatCompact(contract.openInterest), w.oi)
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const optionsPlugin: GloomPlugin = {
|
|
350
|
+
id: "options",
|
|
351
|
+
name: "Options",
|
|
352
|
+
version: "1.0.0",
|
|
353
|
+
description: "View options chain for tickers",
|
|
354
|
+
toggleable: true,
|
|
355
|
+
|
|
356
|
+
setup(ctx) {
|
|
357
|
+
ctx.registerDetailTab({
|
|
358
|
+
id: "options",
|
|
359
|
+
name: "Options",
|
|
360
|
+
order: 35, // after Chart (30), before News (40)
|
|
361
|
+
component: OptionsTab,
|
|
362
|
+
});
|
|
363
|
+
},
|
|
364
|
+
};
|