gloomberb 0.2.0 → 0.3.2

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.
Files changed (311) hide show
  1. package/README.md +20 -19
  2. package/bin/gloomberb +3 -0
  3. package/package.json +5 -4
  4. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  5. package/src/app.tsx +1365 -0
  6. package/src/brokers/sync-broker-instance.test.ts +100 -0
  7. package/src/brokers/sync-broker-instance.ts +341 -0
  8. package/src/cli/commands/plugins.ts +173 -0
  9. package/src/cli/commands/portfolio.ts +210 -0
  10. package/src/cli/commands/search.ts +120 -0
  11. package/src/cli/commands/ticker.ts +375 -0
  12. package/src/cli/commands/watchlist.ts +210 -0
  13. package/src/cli/context.ts +62 -0
  14. package/src/cli/errors.ts +13 -0
  15. package/src/cli/helpers.ts +85 -0
  16. package/src/cli/index.ts +143 -0
  17. package/src/cli/ticker-resolution.ts +39 -0
  18. package/src/cli/types.ts +15 -0
  19. package/src/cli.test.ts +361 -0
  20. package/src/components/chart/chart-data.ts +239 -0
  21. package/src/components/chart/chart-renderer.ts +1199 -0
  22. package/src/components/chart/chart-types.ts +82 -0
  23. package/src/components/chart/chart-viewport.ts +21 -0
  24. package/src/components/chart/chart.test.ts +439 -0
  25. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  26. package/src/components/chart/comparison-chart-data.ts +316 -0
  27. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  28. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  29. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  30. package/src/components/chart/cursor-motion.ts +48 -0
  31. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  32. package/src/components/chart/native/kitty-adapter.ts +12 -0
  33. package/src/components/chart/native/kitty-manager.ts +142 -0
  34. package/src/components/chart/native/kitty-protocol.ts +110 -0
  35. package/src/components/chart/native/kitty-support.ts +71 -0
  36. package/src/components/chart/native/kitty.test.ts +620 -0
  37. package/src/components/chart/native/renderer-selection.ts +24 -0
  38. package/src/components/chart/native/surface-manager.ts +225 -0
  39. package/src/components/chart/native/surface-sync.ts +34 -0
  40. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  41. package/src/components/chart/stock-chart.test.ts +107 -0
  42. package/src/components/chart/stock-chart.tsx +1556 -0
  43. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  44. package/src/components/command-bar/command-bar.test.tsx +1546 -0
  45. package/src/components/command-bar/command-bar.tsx +4841 -0
  46. package/src/components/command-bar/command-registry.ts +269 -0
  47. package/src/components/command-bar/helpers.test.ts +224 -0
  48. package/src/components/command-bar/helpers.ts +371 -0
  49. package/src/components/command-bar/root-shortcuts.ts +158 -0
  50. package/src/components/command-bar/view-model.test.ts +152 -0
  51. package/src/components/command-bar/view-model.ts +185 -0
  52. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  53. package/src/components/command-bar/workflow-ops.ts +433 -0
  54. package/src/components/command-bar/workflow-types.ts +127 -0
  55. package/src/components/detail-feed-view.test.tsx +105 -0
  56. package/src/components/detail-feed-view.tsx +295 -0
  57. package/src/components/index.ts +32 -0
  58. package/src/components/layout/floating-pane.tsx +49 -0
  59. package/src/components/layout/header.tsx +151 -0
  60. package/src/components/layout/pane-header.tsx +44 -0
  61. package/src/components/layout/pane.tsx +49 -0
  62. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  63. package/src/components/layout/pixel-mouse.test.ts +40 -0
  64. package/src/components/layout/shell.test.tsx +651 -0
  65. package/src/components/layout/shell.tsx +1200 -0
  66. package/src/components/layout/status-bar.test.tsx +173 -0
  67. package/src/components/layout/status-bar.tsx +138 -0
  68. package/src/components/markdown-editor.tsx +134 -0
  69. package/src/components/markdown-text.tsx +218 -0
  70. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  71. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  72. package/src/components/pane-settings-dialog.tsx +468 -0
  73. package/src/components/pane-template-wizard.test.tsx +52 -0
  74. package/src/components/pane-template-wizard.tsx +199 -0
  75. package/src/components/price-selector-dialog.tsx +195 -0
  76. package/src/components/spinner.tsx +2 -0
  77. package/src/components/tab-bar.tsx +17 -0
  78. package/src/components/ticker-badge-text.test.tsx +160 -0
  79. package/src/components/ticker-badge-text.tsx +58 -0
  80. package/src/components/ticker-badge.tsx +88 -0
  81. package/src/components/ticker-list-table.tsx +188 -0
  82. package/src/components/toggle-list.tsx +74 -0
  83. package/src/components/ui/button.tsx +77 -0
  84. package/src/components/ui/data-table.tsx +210 -0
  85. package/src/components/ui/external-link.tsx +22 -0
  86. package/src/components/ui/fields.tsx +187 -0
  87. package/src/components/ui/frame.tsx +73 -0
  88. package/src/components/ui/index.ts +53 -0
  89. package/src/components/ui/list-view.tsx +166 -0
  90. package/src/components/ui/loading.tsx +70 -0
  91. package/src/components/ui/status.tsx +83 -0
  92. package/src/components/ui/tabs.tsx +47 -0
  93. package/src/components/ui/toggle.tsx +164 -0
  94. package/src/components/ui/ui.test.tsx +134 -0
  95. package/src/data/app-persistence.ts +25 -0
  96. package/src/data/config-store.test.ts +347 -0
  97. package/src/data/config-store.ts +507 -0
  98. package/src/data/plugin-state-store.ts +102 -0
  99. package/src/data/resource-store.ts +323 -0
  100. package/src/data/session-store.ts +52 -0
  101. package/src/data/sqlite-cache.test.ts +103 -0
  102. package/src/data/sqlite-database.ts +62 -0
  103. package/src/data/sqlite-json.ts +19 -0
  104. package/src/data/ticker-repository.ts +45 -0
  105. package/src/data/ticker-store.ts +46 -0
  106. package/src/index.tsx +67 -0
  107. package/src/market-data/coordinator.test.ts +369 -0
  108. package/src/market-data/coordinator.ts +580 -0
  109. package/src/market-data/hooks.tsx +234 -0
  110. package/src/market-data/query-store.ts +28 -0
  111. package/src/market-data/request-types.test.ts +64 -0
  112. package/src/market-data/request-types.ts +70 -0
  113. package/src/market-data/result-types.ts +56 -0
  114. package/src/market-data/selectors.ts +166 -0
  115. package/src/market-data/trace.ts +7 -0
  116. package/src/pane-settings.test.ts +55 -0
  117. package/src/pane-settings.ts +73 -0
  118. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +350 -0
  119. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  120. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  121. package/src/plugins/builtin/ai/index.tsx +86 -0
  122. package/src/plugins/builtin/ai/providers.ts +99 -0
  123. package/src/plugins/builtin/ai/runner.ts +82 -0
  124. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  125. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  126. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  127. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  128. package/src/plugins/builtin/ai/settings.ts +79 -0
  129. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  130. package/src/plugins/builtin/ai/utils.ts +71 -0
  131. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  132. package/src/plugins/builtin/ask-ai.tsx +9 -0
  133. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  134. package/src/plugins/builtin/chat-controller.ts +340 -0
  135. package/src/plugins/builtin/chat.test.tsx +451 -0
  136. package/src/plugins/builtin/chat.tsx +620 -0
  137. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  138. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  139. package/src/plugins/builtin/data-providers.ts +16 -0
  140. package/src/plugins/builtin/debug.tsx +351 -0
  141. package/src/plugins/builtin/help.test.tsx +110 -0
  142. package/src/plugins/builtin/help.tsx +281 -0
  143. package/src/plugins/builtin/layout-manager.tsx +264 -0
  144. package/src/plugins/builtin/manual-entry.tsx +25 -0
  145. package/src/plugins/builtin/news.tsx +143 -0
  146. package/src/plugins/builtin/notes-files.ts +58 -0
  147. package/src/plugins/builtin/notes.tsx +396 -0
  148. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  149. package/src/plugins/builtin/options-availability.ts +158 -0
  150. package/src/plugins/builtin/options.tsx +364 -0
  151. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  152. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  153. package/src/plugins/builtin/portfolio-list/metrics.ts +460 -0
  154. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  155. package/src/plugins/builtin/portfolio-list/settings.ts +270 -0
  156. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  157. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  158. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  159. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  160. package/src/plugins/builtin/portfolio-metrics.test.ts +224 -0
  161. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  162. package/src/plugins/builtin/sec.tsx +250 -0
  163. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  164. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  165. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  166. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  167. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  168. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  169. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  170. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  171. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  172. package/src/plugins/builtin/yahoo.ts +18 -0
  173. package/src/plugins/event-bus.ts +37 -0
  174. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  175. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  176. package/src/plugins/ibkr/account-cache.ts +137 -0
  177. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  178. package/src/plugins/ibkr/config.test.ts +68 -0
  179. package/src/plugins/ibkr/config.ts +262 -0
  180. package/src/plugins/ibkr/dialogs.tsx +83 -0
  181. package/src/plugins/ibkr/flex.test.ts +107 -0
  182. package/src/plugins/ibkr/flex.ts +267 -0
  183. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  184. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  185. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  186. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  187. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  188. package/src/plugins/ibkr/index.test.tsx +183 -0
  189. package/src/plugins/ibkr/index.tsx +153 -0
  190. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  191. package/src/plugins/ibkr/instance-selection.ts +36 -0
  192. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  193. package/src/plugins/ibkr/price-normalization.ts +59 -0
  194. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  195. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  196. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  197. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  198. package/src/plugins/ibkr/trade-utils.ts +149 -0
  199. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  200. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  201. package/src/plugins/ibkr/trading-state.ts +339 -0
  202. package/src/plugins/loader.ts +71 -0
  203. package/src/plugins/pane-manager.test.ts +103 -0
  204. package/src/plugins/pane-manager.ts +974 -0
  205. package/src/plugins/plugin-persistence.ts +85 -0
  206. package/src/plugins/plugin-runtime.test.tsx +185 -0
  207. package/src/plugins/plugin-runtime.tsx +175 -0
  208. package/src/plugins/prediction-markets/cache.ts +114 -0
  209. package/src/plugins/prediction-markets/categories.ts +223 -0
  210. package/src/plugins/prediction-markets/chart.tsx +512 -0
  211. package/src/plugins/prediction-markets/columns.ts +133 -0
  212. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  213. package/src/plugins/prediction-markets/controller.ts +725 -0
  214. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  215. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  216. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  217. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  218. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  219. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  220. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  221. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  222. package/src/plugins/prediction-markets/index.tsx +102 -0
  223. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  224. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  225. package/src/plugins/prediction-markets/launch.ts +240 -0
  226. package/src/plugins/prediction-markets/metrics.ts +311 -0
  227. package/src/plugins/prediction-markets/navigation.ts +90 -0
  228. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  229. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  230. package/src/plugins/prediction-markets/pane.tsx +294 -0
  231. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  232. package/src/plugins/prediction-markets/rows.ts +229 -0
  233. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  234. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  235. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  236. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  237. package/src/plugins/prediction-markets/settings.ts +164 -0
  238. package/src/plugins/prediction-markets/status.ts +24 -0
  239. package/src/plugins/prediction-markets/table.tsx +84 -0
  240. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  241. package/src/plugins/prediction-markets/types.ts +186 -0
  242. package/src/plugins/registry.ts +633 -0
  243. package/src/sources/exchange-rates.ts +18 -0
  244. package/src/sources/gloomberb-cloud.test.ts +199 -0
  245. package/src/sources/gloomberb-cloud.ts +326 -0
  246. package/src/sources/provider-errors.ts +19 -0
  247. package/src/sources/provider-router.test.ts +1476 -0
  248. package/src/sources/provider-router.ts +1405 -0
  249. package/src/sources/sec-edgar.test.ts +286 -0
  250. package/src/sources/sec-edgar.ts +526 -0
  251. package/src/sources/yahoo-finance.test.ts +14 -0
  252. package/src/sources/yahoo-finance.ts +1017 -0
  253. package/src/state/app-activity.test.ts +41 -0
  254. package/src/state/app-activity.ts +106 -0
  255. package/src/state/app-bootstrap.test.ts +471 -0
  256. package/src/state/app-bootstrap.ts +308 -0
  257. package/src/state/app-context.test.ts +406 -0
  258. package/src/state/app-context.test.tsx +162 -0
  259. package/src/state/app-context.tsx +981 -0
  260. package/src/state/selectors.ts +67 -0
  261. package/src/state/session-persistence.test.ts +128 -0
  262. package/src/state/session-persistence.ts +161 -0
  263. package/src/state/ticker-refresh-queue.test.ts +46 -0
  264. package/src/state/ticker-refresh-queue.ts +40 -0
  265. package/src/state/use-inline-tickers.ts +174 -0
  266. package/src/state/use-quote-streaming.test.ts +37 -0
  267. package/src/state/use-quote-streaming.test.tsx +70 -0
  268. package/src/state/use-quote-streaming.ts +80 -0
  269. package/src/state/use-session-snapshot.ts +84 -0
  270. package/src/test-support/data-provider.ts +22 -0
  271. package/src/theme/color-utils.ts +67 -0
  272. package/src/theme/colors.ts +128 -0
  273. package/src/theme/themes.test.ts +45 -0
  274. package/src/theme/themes.ts +491 -0
  275. package/src/types/broker.ts +105 -0
  276. package/src/types/config.ts +444 -0
  277. package/src/types/data-provider.ts +81 -0
  278. package/src/types/financials.ts +142 -0
  279. package/src/types/instrument.ts +29 -0
  280. package/src/types/persistence.ts +21 -0
  281. package/src/types/plugin.ts +354 -0
  282. package/src/types/ticker.ts +58 -0
  283. package/src/types/trading.ts +85 -0
  284. package/src/updater.test.ts +282 -0
  285. package/src/updater.ts +303 -0
  286. package/src/utils/api-client.ts +666 -0
  287. package/src/utils/ascii-chart.ts +172 -0
  288. package/src/utils/broker-instances.ts +47 -0
  289. package/src/utils/cli-output.ts +83 -0
  290. package/src/utils/currency-units.test.ts +49 -0
  291. package/src/utils/currency-units.ts +82 -0
  292. package/src/utils/debug-log.ts +133 -0
  293. package/src/utils/exchange-rate-map.ts +8 -0
  294. package/src/utils/format.ts +158 -0
  295. package/src/utils/fuzzy-search.ts +34 -0
  296. package/src/utils/market-status.ts +95 -0
  297. package/src/utils/options.ts +86 -0
  298. package/src/utils/price-history.ts +39 -0
  299. package/src/utils/quote-time.test.ts +22 -0
  300. package/src/utils/quote-time.ts +50 -0
  301. package/src/utils/sec.test.ts +55 -0
  302. package/src/utils/sec.ts +45 -0
  303. package/src/utils/ticker-list.test.ts +22 -0
  304. package/src/utils/ticker-list.ts +35 -0
  305. package/src/utils/ticker-metadata.ts +51 -0
  306. package/src/utils/ticker-search.test.ts +227 -0
  307. package/src/utils/ticker-search.ts +764 -0
  308. package/src/utils/ticker-tokenizer.test.ts +48 -0
  309. package/src/utils/ticker-tokenizer.ts +72 -0
  310. package/src/version.ts +1 -0
  311. 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
+ };