gloomberb 0.4.1 → 0.5.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.
Files changed (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +24 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -0,0 +1,573 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { DataTable, type DataTableCell, type DataTableColumn } from "../../../components";
5
+ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
6
+ import { colors, priceColor, blendHex } from "../../../theme/colors";
7
+ import { formatCurrency, formatCompact, formatPercentRaw } from "../../../utils/format";
8
+ import { usePluginTickerActions } from "../../plugin-runtime";
9
+ import { getSharedDataProvider } from "../../registry";
10
+ import {
11
+ fetchScreener,
12
+ fetchTrending,
13
+ MARKET_SUMMARY_SYMBOLS,
14
+ type ScreenerCategory,
15
+ type ScreenerQuote,
16
+ type MarketSummaryQuote,
17
+ } from "./screener";
18
+
19
+ const CACHE_TTL_MS = 5 * 60 * 1000;
20
+
21
+ type TabId = "gainers" | "losers" | "actives" | "trending";
22
+
23
+ const TABS: Array<{ id: TabId; label: string }> = [
24
+ { id: "gainers", label: "Gainers" },
25
+ { id: "losers", label: "Losers" },
26
+ { id: "actives", label: "Most Active" },
27
+ { id: "trending", label: "Trending" },
28
+ ];
29
+
30
+ const CATEGORY_MAP: Record<Exclude<TabId, "trending">, ScreenerCategory> = {
31
+ gainers: "day_gainers",
32
+ losers: "day_losers",
33
+ actives: "most_actives",
34
+ };
35
+
36
+ interface TabCache {
37
+ data: ScreenerQuote[];
38
+ fetchedAt: number;
39
+ }
40
+
41
+ type MarketMoverColumnId =
42
+ | "rank"
43
+ | "symbol"
44
+ | "name"
45
+ | "price"
46
+ | "changePercent"
47
+ | "volume"
48
+ | "volumeRatio"
49
+ | "range"
50
+ | "marketCap";
51
+ type MarketMoverColumn = DataTableColumn & { id: MarketMoverColumnId };
52
+ type SortDirection = "asc" | "desc";
53
+ type MarketMoverRow = ScreenerQuote & { rank: number };
54
+
55
+ interface MarketMoverSortPreference {
56
+ columnId: MarketMoverColumnId | null;
57
+ direction: SortDirection;
58
+ }
59
+
60
+ const DEFAULT_SORT_PREFERENCE: MarketMoverSortPreference = {
61
+ columnId: null,
62
+ direction: "asc",
63
+ };
64
+
65
+ function formatVolRatio(ratio: number): string {
66
+ if (ratio <= 0) return "—";
67
+ if (ratio >= 10) return `${Math.round(ratio)}x`;
68
+ return `${ratio.toFixed(1)}x`;
69
+ }
70
+
71
+ function volRatioColor(ratio: number): string {
72
+ if (ratio >= 3) return colors.textBright;
73
+ if (ratio >= 1.5) return colors.text;
74
+ return colors.textDim;
75
+ }
76
+
77
+ function fiftyTwoWeekPositionPercent(price: number, low: number | undefined, high: number | undefined): number | null {
78
+ if (low == null || high == null || high <= low) return null;
79
+ return ((price - low) / (high - low)) * 100;
80
+ }
81
+
82
+ function fiftyTwoWeekPosition(price: number, low: number | undefined, high: number | undefined): string {
83
+ const pct = fiftyTwoWeekPositionPercent(price, low, high);
84
+ return pct == null ? "—" : `${Math.round(pct)}%`;
85
+ }
86
+
87
+ function getSortValue(
88
+ columnId: MarketMoverColumnId,
89
+ row: MarketMoverRow,
90
+ ): string | number | null {
91
+ switch (columnId) {
92
+ case "rank":
93
+ return row.rank;
94
+ case "symbol":
95
+ return row.symbol;
96
+ case "name":
97
+ return row.name;
98
+ case "price":
99
+ return row.price;
100
+ case "changePercent":
101
+ return row.changePercent;
102
+ case "volume":
103
+ return row.volume;
104
+ case "volumeRatio":
105
+ return row.volumeRatio;
106
+ case "range":
107
+ return fiftyTwoWeekPositionPercent(row.price, row.fiftyTwoWeekLow, row.fiftyTwoWeekHigh);
108
+ case "marketCap":
109
+ return row.marketCap ?? null;
110
+ }
111
+ }
112
+
113
+ function compareSortValues(
114
+ left: string | number | null,
115
+ right: string | number | null,
116
+ direction: SortDirection,
117
+ ): number {
118
+ if (left == null && right == null) return 0;
119
+ if (left == null) return 1;
120
+ if (right == null) return -1;
121
+
122
+ const comparison = typeof left === "string" && typeof right === "string"
123
+ ? left.localeCompare(right)
124
+ : Number(left) - Number(right);
125
+ return direction === "asc" ? comparison : -comparison;
126
+ }
127
+
128
+ function sortRows(
129
+ rows: MarketMoverRow[],
130
+ sortPreference: MarketMoverSortPreference,
131
+ ): MarketMoverRow[] {
132
+ const sortColumnId = sortPreference.columnId;
133
+ if (!sortColumnId) return rows;
134
+ return [...rows].sort((left, right) => compareSortValues(
135
+ getSortValue(sortColumnId, left),
136
+ getSortValue(sortColumnId, right),
137
+ sortPreference.direction,
138
+ ));
139
+ }
140
+
141
+ function nextSortPreference(
142
+ current: MarketMoverSortPreference,
143
+ columnId: string,
144
+ ): MarketMoverSortPreference {
145
+ const typedColumnId = columnId as MarketMoverColumnId;
146
+ if (current.columnId !== typedColumnId) {
147
+ return { columnId: typedColumnId, direction: "asc" };
148
+ }
149
+ if (current.direction === "asc") {
150
+ return { columnId: typedColumnId, direction: "desc" };
151
+ }
152
+ return DEFAULT_SORT_PREFERENCE;
153
+ }
154
+
155
+ function createRows(quotes: ScreenerQuote[]): MarketMoverRow[] {
156
+ return quotes.map((quote, index) => ({
157
+ ...quote,
158
+ rank: index + 1,
159
+ }));
160
+ }
161
+
162
+ function buildColumns(width: number): MarketMoverColumn[] {
163
+ const rankWidth = 3;
164
+ const tickerWidth = 8;
165
+ const priceWidth = 11;
166
+ const chgWidth = 9;
167
+ const volWidth = 8;
168
+ const volRatioWidth = 6;
169
+ const rangeWidth = 6;
170
+ const mcapWidth = 8;
171
+ const columnCount = 9;
172
+ const fixedWidth = rankWidth + tickerWidth + priceWidth + chgWidth + volWidth + volRatioWidth + rangeWidth + mcapWidth;
173
+ const nameWidth = Math.max(6, width - 2 - columnCount - fixedWidth);
174
+
175
+ return [
176
+ { id: "rank", label: "#", width: rankWidth, align: "left" },
177
+ { id: "symbol", label: "TICKER", width: tickerWidth, align: "left" },
178
+ { id: "name", label: "NAME", width: nameWidth, align: "left" },
179
+ { id: "price", label: "LAST", width: priceWidth, align: "right" },
180
+ { id: "changePercent", label: "CHG%", width: chgWidth, align: "right" },
181
+ { id: "volume", label: "VOL", width: volWidth, align: "right" },
182
+ { id: "volumeRatio", label: "V/AVG", width: volRatioWidth, align: "right" },
183
+ { id: "range", label: "52W%", width: rangeWidth, align: "right" },
184
+ { id: "marketCap", label: "MCAP", width: mcapWidth, align: "right" },
185
+ ];
186
+ }
187
+
188
+ const INDEX_SHORT: Record<string, string> = {
189
+ "^GSPC": "SPX",
190
+ "^DJI": "DJIA",
191
+ "^IXIC": "COMP",
192
+ "^RUT": "RUT",
193
+ };
194
+
195
+ export function MarketMoversPane({ focused, width, height }: PaneProps) {
196
+ const { pinTicker } = usePluginTickerActions();
197
+ const [activeTab, setActiveTab] = useState<TabId>("gainers");
198
+ const [quotes, setQuotes] = useState<ScreenerQuote[]>([]);
199
+ const [loading, setLoading] = useState(false);
200
+ const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
201
+ const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
202
+ const [sortPreference, setSortPreference] = useState<MarketMoverSortPreference>(DEFAULT_SORT_PREFERENCE);
203
+ const [summaryQuotes, setSummaryQuotes] = useState<MarketSummaryQuote[]>([]);
204
+
205
+ const cacheRef = useRef<Map<TabId, TabCache>>(new Map());
206
+ const fetchGenRef = useRef(0);
207
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
208
+ const headerScrollRef = useRef<ScrollBoxRenderable>(null);
209
+
210
+ const columns = useMemo(() => buildColumns(width), [width]);
211
+ const rankedRows = useMemo(() => createRows(quotes), [quotes]);
212
+ const rows = useMemo(() => sortRows(rankedRows, sortPreference), [rankedRows, sortPreference]);
213
+ const selectedIdx = selectedSymbol
214
+ ? rows.findIndex((row) => row.symbol === selectedSymbol)
215
+ : -1;
216
+ const activeIdx = selectedIdx >= 0 ? selectedIdx : (rows.length > 0 ? 0 : -1);
217
+
218
+ const syncHeaderScroll = useCallback(() => {
219
+ const bodyScrollBox = scrollRef.current;
220
+ const headerScrollBox = headerScrollRef.current;
221
+ if (bodyScrollBox && headerScrollBox && headerScrollBox.scrollLeft !== bodyScrollBox.scrollLeft) {
222
+ headerScrollBox.scrollLeft = bodyScrollBox.scrollLeft;
223
+ }
224
+ }, []);
225
+
226
+ const handleBodyScrollActivity = useCallback(() => {
227
+ syncHeaderScroll();
228
+ }, [syncHeaderScroll]);
229
+
230
+ const resetTableScroll = useCallback(() => {
231
+ const bodyScrollBox = scrollRef.current;
232
+ if (bodyScrollBox) {
233
+ bodyScrollBox.scrollTop = 0;
234
+ bodyScrollBox.scrollLeft = 0;
235
+ }
236
+ const headerScrollBox = headerScrollRef.current;
237
+ if (headerScrollBox) {
238
+ headerScrollBox.scrollLeft = 0;
239
+ }
240
+ }, []);
241
+
242
+ useEffect(() => {
243
+ if (selectedSymbol && selectedIdx >= 0) return;
244
+ const firstRow = rows[0];
245
+ if (firstRow) {
246
+ setSelectedSymbol(firstRow.symbol);
247
+ } else if (selectedSymbol !== null) {
248
+ setSelectedSymbol(null);
249
+ }
250
+ }, [rows, selectedIdx, selectedSymbol]);
251
+
252
+ // Fetch market summary via the provider router
253
+ useEffect(() => {
254
+ const provider = getSharedDataProvider();
255
+ if (!provider) return;
256
+ const loadSummary = async () => {
257
+ const results: MarketSummaryQuote[] = [];
258
+ await Promise.allSettled(
259
+ MARKET_SUMMARY_SYMBOLS.map(async (symbol) => {
260
+ try {
261
+ const q = await provider.getQuote(symbol, "");
262
+ if (q) {
263
+ results.push({
264
+ symbol,
265
+ name: q.name ?? symbol,
266
+ price: q.price,
267
+ change: q.change,
268
+ changePercent: q.changePercent,
269
+ });
270
+ }
271
+ } catch { /* skip */ }
272
+ }),
273
+ );
274
+ // Preserve the original symbol order
275
+ setSummaryQuotes(
276
+ MARKET_SUMMARY_SYMBOLS
277
+ .map((s) => results.find((r) => r.symbol === s))
278
+ .filter((r): r is MarketSummaryQuote => r !== undefined),
279
+ );
280
+ };
281
+ loadSummary();
282
+ const interval = setInterval(loadSummary, 60_000);
283
+ return () => clearInterval(interval);
284
+ }, []);
285
+
286
+ const loadTab = async (tab: TabId) => {
287
+ const cached = cacheRef.current.get(tab);
288
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
289
+ setQuotes(cached.data);
290
+ setSelectedSymbol(null);
291
+ resetTableScroll();
292
+ return;
293
+ }
294
+
295
+ fetchGenRef.current += 1;
296
+ const gen = fetchGenRef.current;
297
+ setLoading(true);
298
+
299
+ try {
300
+ let data: ScreenerQuote[];
301
+
302
+ if (tab === "trending") {
303
+ const trending = await fetchTrending(25);
304
+ if (fetchGenRef.current !== gen) return;
305
+
306
+ const provider = getSharedDataProvider();
307
+ const resolved: ScreenerQuote[] = [];
308
+
309
+ if (provider) {
310
+ await Promise.allSettled(
311
+ trending.slice(0, 25).map(async ({ symbol }) => {
312
+ try {
313
+ const q = await provider.getQuote(symbol, "");
314
+ if (fetchGenRef.current !== gen) return;
315
+ if (q) {
316
+ resolved.push({
317
+ symbol,
318
+ name: q.name ?? symbol,
319
+ price: q.price ?? 0,
320
+ change: q.change ?? 0,
321
+ changePercent: q.changePercent ?? 0,
322
+ volume: q.volume ?? 0,
323
+ avgVolume: 0,
324
+ volumeRatio: 0,
325
+ marketCap: undefined,
326
+ currency: q.currency ?? "USD",
327
+ fiftyTwoWeekHigh: undefined,
328
+ fiftyTwoWeekLow: undefined,
329
+ dayHigh: undefined,
330
+ dayLow: undefined,
331
+ exchange: "",
332
+ });
333
+ }
334
+ } catch { /* skip */ }
335
+ }),
336
+ );
337
+ }
338
+
339
+ if (fetchGenRef.current !== gen) return;
340
+ data = trending
341
+ .map((t) => resolved.find((r) => r.symbol === t.symbol))
342
+ .filter((r): r is ScreenerQuote => r !== undefined);
343
+ } else {
344
+ data = await fetchScreener(CATEGORY_MAP[tab], 25);
345
+ if (fetchGenRef.current !== gen) return;
346
+ }
347
+
348
+ cacheRef.current.set(tab, { data, fetchedAt: Date.now() });
349
+ setQuotes(data);
350
+ setSelectedSymbol(null);
351
+ resetTableScroll();
352
+ } catch { /* leave existing data */ }
353
+ finally {
354
+ if (fetchGenRef.current === gen) setLoading(false);
355
+ }
356
+ };
357
+
358
+ useEffect(() => { loadTab(activeTab); }, [activeTab]);
359
+
360
+ const openSymbol = useCallback((symbol: string) => {
361
+ pinTicker(symbol, { floating: true, paneType: "ticker-detail" });
362
+ }, [pinTicker]);
363
+
364
+ const openSelected = useCallback((idx: number) => {
365
+ const row = rows[idx];
366
+ if (!row) return;
367
+ openSymbol(row.symbol);
368
+ }, [openSymbol, rows]);
369
+
370
+ const handleHeaderClick = useCallback((columnId: string) => {
371
+ setSortPreference((current) => nextSortPreference(current, columnId));
372
+ }, []);
373
+
374
+ useKeyboard((event) => {
375
+ if (!focused) return;
376
+
377
+ const key = event.name;
378
+ const isEnter = key === "enter" || key === "return";
379
+
380
+ if (key === "tab" || key === "right" || key === "l") {
381
+ event.preventDefault?.();
382
+ const currentTabIdx = TABS.findIndex((t) => t.id === activeTab);
383
+ setActiveTab(TABS[(currentTabIdx + 1) % TABS.length]!.id);
384
+ setSelectedSymbol(null);
385
+ resetTableScroll();
386
+ } else if (key === "left" || key === "h") {
387
+ event.preventDefault?.();
388
+ const currentTabIdx = TABS.findIndex((t) => t.id === activeTab);
389
+ setActiveTab(TABS[(currentTabIdx - 1 + TABS.length) % TABS.length]!.id);
390
+ setSelectedSymbol(null);
391
+ resetTableScroll();
392
+ } else if (key === "j" || key === "down") {
393
+ event.preventDefault?.();
394
+ if (rows.length === 0) return;
395
+ const nextIdx = activeIdx >= 0 ? Math.min(activeIdx + 1, rows.length - 1) : 0;
396
+ setSelectedSymbol(rows[nextIdx]!.symbol);
397
+ } else if (key === "k" || key === "up") {
398
+ event.preventDefault?.();
399
+ if (rows.length === 0) return;
400
+ const nextIdx = activeIdx > 0 ? activeIdx - 1 : 0;
401
+ setSelectedSymbol(rows[nextIdx]!.symbol);
402
+ } else if (isEnter) {
403
+ event.preventDefault?.();
404
+ openSelected(activeIdx);
405
+ } else if (key === "r") {
406
+ event.preventDefault?.();
407
+ cacheRef.current.delete(activeTab);
408
+ loadTab(activeTab);
409
+ }
410
+ });
411
+
412
+ // Scroll to keep selected row visible
413
+ useEffect(() => {
414
+ const sb = scrollRef.current;
415
+ if (!sb?.viewport || rows.length === 0 || activeIdx < 0) return;
416
+ const viewportHeight = Math.max(sb.viewport.height, 1);
417
+ if (activeIdx < sb.scrollTop) {
418
+ sb.scrollTo(activeIdx);
419
+ } else if (activeIdx >= sb.scrollTop + viewportHeight) {
420
+ sb.scrollTo(activeIdx - viewportHeight + 1);
421
+ }
422
+ }, [activeIdx, rows.length]);
423
+
424
+ const renderCell = useCallback((
425
+ row: MarketMoverRow,
426
+ column: MarketMoverColumn,
427
+ _index: number,
428
+ rowState: { selected: boolean },
429
+ ): DataTableCell => {
430
+ const selectedColor = rowState.selected ? colors.selectedText : undefined;
431
+
432
+ switch (column.id) {
433
+ case "rank":
434
+ return { text: String(row.rank), color: selectedColor ?? colors.textDim };
435
+ case "symbol":
436
+ return {
437
+ text: row.symbol,
438
+ color: selectedColor ?? colors.textBright,
439
+ attributes: TextAttributes.BOLD,
440
+ };
441
+ case "name":
442
+ return { text: row.name, color: selectedColor };
443
+ case "price":
444
+ return { text: formatCurrency(row.price, row.currency), color: selectedColor };
445
+ case "changePercent":
446
+ return {
447
+ text: formatPercentRaw(row.changePercent),
448
+ color: selectedColor ?? priceColor(row.changePercent),
449
+ };
450
+ case "volume":
451
+ return { text: formatCompact(row.volume), color: selectedColor ?? colors.textDim };
452
+ case "volumeRatio":
453
+ return {
454
+ text: formatVolRatio(row.volumeRatio),
455
+ color: selectedColor ?? volRatioColor(row.volumeRatio),
456
+ };
457
+ case "range":
458
+ return {
459
+ text: fiftyTwoWeekPosition(row.price, row.fiftyTwoWeekLow, row.fiftyTwoWeekHigh),
460
+ color: selectedColor ?? colors.textDim,
461
+ };
462
+ case "marketCap":
463
+ return {
464
+ text: row.marketCap != null ? formatCompact(row.marketCap) : "—",
465
+ color: selectedColor ?? colors.textDim,
466
+ };
467
+ }
468
+ }, []);
469
+
470
+ const summaryBg = blendHex(colors.bg, colors.border, 0.2);
471
+
472
+ return (
473
+ <box flexDirection="column" width={width} height={height}>
474
+ {/* Market Summary Bar */}
475
+ {summaryQuotes.length > 0 ? (
476
+ <box flexDirection="row" height={1} backgroundColor={summaryBg} paddingX={1} gap={2}>
477
+ {summaryQuotes.map((idx) => {
478
+ const short = INDEX_SHORT[idx.symbol] ?? idx.symbol;
479
+ const chgColor = priceColor(idx.changePercent);
480
+ return (
481
+ <box key={idx.symbol} flexDirection="row" gap={1}>
482
+ <text fg={colors.textBright} attributes={TextAttributes.BOLD}>{short}</text>
483
+ <text fg={colors.text}>{formatCurrency(idx.price, "USD")}</text>
484
+ <text fg={chgColor}>{formatPercentRaw(idx.changePercent)}</text>
485
+ </box>
486
+ );
487
+ })}
488
+ {loading && <text fg={colors.textMuted}> loading…</text>}
489
+ </box>
490
+ ) : null}
491
+
492
+ {/* Tab bar */}
493
+ <box flexDirection="row" height={1} paddingX={1}>
494
+ {TABS.map((tab) => {
495
+ const isActive = tab.id === activeTab;
496
+ return (
497
+ <box
498
+ key={tab.id}
499
+ paddingX={1}
500
+ marginRight={1}
501
+ onMouseDown={(event: any) => {
502
+ event.preventDefault?.();
503
+ setActiveTab(tab.id);
504
+ setSelectedSymbol(null);
505
+ resetTableScroll();
506
+ }}
507
+ >
508
+ <text
509
+ fg={isActive ? colors.textBright : colors.textDim}
510
+ attributes={isActive ? TextAttributes.BOLD | TextAttributes.UNDERLINE : TextAttributes.NONE}
511
+ >
512
+ {tab.label}
513
+ </text>
514
+ </box>
515
+ );
516
+ })}
517
+ <box flexGrow={1} />
518
+ <text fg={colors.textMuted}>{quotes.length} stocks</text>
519
+ </box>
520
+
521
+ <DataTable<MarketMoverRow, MarketMoverColumn>
522
+ columns={columns}
523
+ items={rows}
524
+ sortColumnId={sortPreference.columnId}
525
+ sortDirection={sortPreference.direction}
526
+ onHeaderClick={handleHeaderClick}
527
+ headerScrollRef={headerScrollRef}
528
+ scrollRef={scrollRef}
529
+ syncHeaderScroll={syncHeaderScroll}
530
+ onBodyScrollActivity={handleBodyScrollActivity}
531
+ hoveredIdx={hoveredIdx}
532
+ setHoveredIdx={setHoveredIdx}
533
+ getItemKey={(row) => `${row.symbol}-${row.rank}`}
534
+ isSelected={(row) => row.symbol === selectedSymbol}
535
+ onSelect={(row) => setSelectedSymbol(row.symbol)}
536
+ onActivate={(row) => openSymbol(row.symbol)}
537
+ renderCell={renderCell}
538
+ emptyStateTitle={loading ? "Loading movers..." : "No data"}
539
+ />
540
+ </box>
541
+ );
542
+ }
543
+
544
+ export const marketMoversPlugin: GloomPlugin = {
545
+ id: "market-movers",
546
+ name: "Market Movers",
547
+ version: "1.0.0",
548
+ description: "Top gainers, losers, most active, and trending tickers",
549
+ toggleable: true,
550
+
551
+ panes: [
552
+ {
553
+ id: "market-movers",
554
+ name: "Market Movers",
555
+ icon: "T",
556
+ component: MarketMoversPane,
557
+ defaultPosition: "right",
558
+ defaultMode: "floating",
559
+ defaultFloatingSize: { width: 100, height: 36 },
560
+ },
561
+ ],
562
+
563
+ paneTemplates: [
564
+ {
565
+ id: "market-movers-pane",
566
+ paneId: "market-movers",
567
+ label: "Market Movers",
568
+ description: "Top gainers, losers, most active, and trending tickers.",
569
+ keywords: ["movers", "gainers", "losers", "active", "trending", "screener", "top"],
570
+ shortcut: { prefix: "TOP" },
571
+ },
572
+ ],
573
+ };