gloomberb 0.17.2 → 0.17.4

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 (200) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/chat.ts +28 -0
  3. package/src/api-client/errors.ts +11 -1
  4. package/src/api-client/futures-curve.ts +16 -0
  5. package/src/api-client/index.ts +22 -9
  6. package/src/api-client/normalizers.ts +21 -0
  7. package/src/api-client/socket.ts +27 -0
  8. package/src/api-client/types.ts +3 -1
  9. package/src/app/runtime/startup.ts +4 -0
  10. package/src/cli/commands/market.ts +60 -9
  11. package/src/cli/commands/search.ts +43 -2
  12. package/src/cli/commands/ticker.ts +100 -21
  13. package/src/cli/desktop-pane-shot.ts +34 -9
  14. package/src/cli/fx-pair.ts +17 -0
  15. package/src/cli/glossary.ts +134 -6
  16. package/src/cli/history-rows.ts +16 -4
  17. package/src/cli/index.ts +44 -12
  18. package/src/cli/known-gaps.ts +21 -2
  19. package/src/cli/listing-arg.ts +19 -4
  20. package/src/cli/listing-suggestion.ts +33 -2
  21. package/src/cli/pane-functions/dom.ts +39 -6
  22. package/src/cli/pane-functions/freshness.ts +48 -5
  23. package/src/cli/pane-functions/function-help.ts +1 -1
  24. package/src/cli/pane-functions/headless.ts +85 -19
  25. package/src/cli/pane-functions/index.ts +60 -4
  26. package/src/cli/pane-functions/market-session.ts +69 -6
  27. package/src/cli/pane-functions/resolver.ts +4 -2
  28. package/src/cli/pane-functions/screenshot.ts +14 -1
  29. package/src/cli/scroll-truncation.ts +5 -1
  30. package/src/cli/symbol-args.ts +41 -3
  31. package/src/cli/symbol-list.ts +19 -7
  32. package/src/cli/unknown-command.ts +70 -0
  33. package/src/components/chart/native/surface/visibility.ts +37 -7
  34. package/src/components/index.ts +1 -1
  35. package/src/components/layout/header.tsx +348 -102
  36. package/src/components/markdown-text.tsx +40 -66
  37. package/src/components/market-board/index.tsx +12 -6
  38. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  39. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  40. package/src/components/metric-treemap/index.tsx +1 -1
  41. package/src/components/reader/detail-reader.tsx +28 -6
  42. package/src/components/ticker/badge/index.tsx +2 -0
  43. package/src/components/ui/button.tsx +24 -9
  44. package/src/components/ui/display.tsx +6 -1
  45. package/src/components/ui/external-link.tsx +10 -2
  46. package/src/components/ui/index.ts +1 -1
  47. package/src/components/ui/status.tsx +148 -5
  48. package/src/data/memory-resource-store.ts +1 -1
  49. package/src/data/resource-store.ts +13 -1
  50. package/src/docs/snapshot.json +13 -13
  51. package/src/i18n/es.ts +1 -0
  52. package/src/i18n/ja.ts +1 -0
  53. package/src/i18n/ko.ts +1 -0
  54. package/src/i18n/zh-cn.ts +1 -0
  55. package/src/i18n/zh-tw.ts +1 -0
  56. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  57. package/src/market-data/futures-board.ts +44 -1
  58. package/src/market-data/futures-contract.ts +10 -0
  59. package/src/market-data/market/format.ts +7 -2
  60. package/src/market-data/market/freshness.ts +25 -1
  61. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  62. package/src/market-data/quotes/resolution.ts +2 -0
  63. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  64. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  65. package/src/plugins/builtin/alerts/cli.ts +72 -11
  66. package/src/plugins/builtin/attention/headless.ts +19 -10
  67. package/src/plugins/builtin/attention/model.ts +22 -0
  68. package/src/plugins/builtin/awards/headless.ts +5 -1
  69. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  70. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  71. package/src/plugins/builtin/changelog/index.tsx +11 -8
  72. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  73. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  74. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  75. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  76. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  77. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  78. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  79. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  80. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  81. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  82. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  83. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  84. package/src/plugins/builtin/chat/layout.ts +91 -31
  85. package/src/plugins/builtin/chat/markdown.ts +94 -0
  86. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  87. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  88. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  89. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  90. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  91. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  92. package/src/plugins/builtin/company-attention/model.ts +53 -2
  93. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  94. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  95. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  96. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  97. package/src/plugins/builtin/cot/client.ts +12 -4
  98. package/src/plugins/builtin/cot/model.ts +20 -0
  99. package/src/plugins/builtin/cot/pane.tsx +2 -2
  100. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  101. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  102. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  103. package/src/plugins/builtin/debug/index.tsx +15 -15
  104. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  105. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  106. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  107. package/src/plugins/builtin/docs/index.tsx +5 -3
  108. package/src/plugins/builtin/docs/pane.tsx +19 -2
  109. package/src/plugins/builtin/docs/targets.ts +5 -3
  110. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  111. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  112. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  113. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  114. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  115. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  116. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  117. package/src/plugins/builtin/futures/index.tsx +33 -6
  118. package/src/plugins/builtin/futures/model.ts +69 -5
  119. package/src/plugins/builtin/futures/series.ts +3 -3
  120. package/src/plugins/builtin/futures/table.tsx +31 -11
  121. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  122. package/src/plugins/builtin/holders/pane.tsx +6 -1
  123. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  124. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  125. package/src/plugins/builtin/mna/client.ts +12 -2
  126. package/src/plugins/builtin/mna/detail.tsx +2 -2
  127. package/src/plugins/builtin/mna/pane.tsx +2 -2
  128. package/src/plugins/builtin/news/headless.ts +4 -2
  129. package/src/plugins/builtin/notes/owner.tsx +5 -4
  130. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  131. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  132. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  133. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  134. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  135. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  136. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  137. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
  138. package/src/plugins/builtin/research/event-model.ts +14 -0
  139. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  140. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  141. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  142. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  143. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  144. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  145. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  146. package/src/plugins/builtin/research-search/data.ts +11 -0
  147. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  148. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  149. package/src/plugins/builtin/seasonality/client.ts +23 -0
  150. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  151. package/src/plugins/builtin/sectors/index.tsx +7 -3
  152. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  153. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  154. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  155. package/src/plugins/builtin/short-interest/model.ts +62 -3
  156. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  157. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  158. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  159. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  160. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  161. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  162. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  163. package/src/plugins/builtin/themes/headless.ts +1 -1
  164. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  165. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  166. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  167. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  168. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  169. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  170. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  171. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  172. package/src/renderers/dom/build-assets.ts +17 -4
  173. package/src/renderers/dom/desktop/controls.tsx +2 -0
  174. package/src/renderers/dom/styles.css +6 -0
  175. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  176. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  177. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  178. package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
  179. package/src/sources/provider-router/batches.ts +2 -1
  180. package/src/sources/provider-router/cache.ts +3 -0
  181. package/src/sync/baseline.ts +19 -14
  182. package/src/sync/controller.ts +140 -40
  183. package/src/sync/core-contributors.ts +256 -32
  184. package/src/sync/types.ts +44 -0
  185. package/src/time-series/resolve.ts +24 -5
  186. package/src/types/data-provider.ts +8 -0
  187. package/src/types/financials.ts +40 -4
  188. package/src/types/headless.ts +43 -0
  189. package/src/types/plugin.ts +7 -0
  190. package/src/ui/resize-settle.ts +128 -0
  191. package/src/utils/calendar-date.ts +56 -0
  192. package/src/utils/decimal-comma.ts +31 -0
  193. package/src/utils/declared-dividends.ts +39 -0
  194. package/src/utils/forward-dividend.ts +34 -11
  195. package/src/utils/fundamentals.ts +13 -2
  196. package/src/utils/futures-generic.ts +25 -4
  197. package/src/utils/markdown.ts +180 -0
  198. package/src/utils/market-capitalization.ts +22 -0
  199. package/src/utils/plugin-proxy-hosts.json +5 -0
  200. package/src/utils/utc-time.ts +14 -3
@@ -1,4 +1,4 @@
1
- import type { ScreenMetric, ScreenRow } from "../../../api-client/equity-screener";
1
+ import type { ScreenDefinition, ScreenMetric, ScreenRow } from "../../../api-client/equity-screener";
2
2
  import { getExtendedSessionDisplay, type ExtendedSession, type ExtendedSessionDisplay } from "../../../market-data/market/status";
3
3
  import { quoteTradingDay } from "../../../market-data/quotes/day-range";
4
4
  import { getRegularSessionQuoteDisplay } from "../../../market-data/quotes/screener-live-quotes";
@@ -112,6 +112,32 @@ export function overlayScreenQuotes(rows: readonly ScreenRow[], quotes: Readonly
112
112
  return overlayRows(rows, (key) => quotes.get(key));
113
113
  }
114
114
 
115
+ /** The fields a quote moves on a row (overlayLiveScreenRow). */
116
+ const LIVE_SORT_FIELDS: ReadonlySet<string> = new Set(["price", "changePercent", "volume", "marketCap"]);
117
+
118
+ /**
119
+ * Rows in the screen's order by the figures they show. A sort on a field the
120
+ * quotes move (price, change, volume, market cap) is redone on the overlaid
121
+ * values, so the column reads monotone: GRAB's snapshot cap, two closes old,
122
+ * kept it above MANH after its quote moved it below (GLO-754). Rows without a
123
+ * value stay last, and ties keep the snapshot's order. Any other sort is the
124
+ * snapshot's, which the overlay does not touch.
125
+ */
126
+ export function sortLiveScreenRows<T extends ScreenRow>(rows: readonly T[], sort: ScreenDefinition["sort"]): T[] {
127
+ if (!LIVE_SORT_FIELDS.has(sort.field)) return [...rows];
128
+ const field = sort.field as "price" | "changePercent" | "volume" | "marketCap";
129
+ const sign = sort.direction === "desc" ? -1 : 1;
130
+ return rows
131
+ .map((row, index) => ({ row, index, value: row.metrics[field]?.value }))
132
+ .sort((a, b) => {
133
+ const aKnown = isFiniteNumber(a.value), bKnown = isFiniteNumber(b.value);
134
+ if (aKnown !== bKnown) return aKnown ? -1 : 1;
135
+ if (aKnown && bKnown && a.value !== b.value) return sign * (a.value! - b.value!);
136
+ return a.index - b.index;
137
+ })
138
+ .map(({ row }) => row);
139
+ }
140
+
115
141
  /** The extended sessions some row has a print for, in the order their columns go. */
116
142
  export function screenExtendedSessions(rows: readonly LiveScreenRow[]): ExtendedSession[] {
117
143
  return (["PRE", "POST", "OVERNIGHT"] as const).filter((session) => rows.some((row) => row.extended?.session === session));
@@ -48,6 +48,7 @@ import { EXTENDED_SESSION_LABELS, extendedSessionStateLabel, type ExtendedSessio
48
48
  import { useOpenDescription } from "../shared/open-description";
49
49
  import { useResearchCloudSession } from "../shared/research-cloud-session";
50
50
  import { INITIAL_STREAM_RANGE, streamWindowRows } from "../shared/use-quote-board";
51
+ import { useSampledValue } from "../../../state/hooks/live-ticker-financials";
51
52
  import { usePlanAccess } from "../../../api-client/plan-access";
52
53
  import { SignInWall } from "../cloud/auth-actions";
53
54
  import { CriterionEditor } from "./criterion-editor";
@@ -71,12 +72,14 @@ import {
71
72
  screenRowId,
72
73
  } from "./model";
73
74
  import { useScreenResults } from "./results";
74
- import { overlayLiveScreenRows, screenExtendedMove, screenExtendedSessions, type LiveScreenRow } from "./live";
75
+ import { overlayLiveScreenRows, screenExtendedMove, screenExtendedSessions, sortLiveScreenRows, type LiveScreenRow } from "./live";
75
76
  import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
76
77
  import { buildScreenerQuoteTargets } from "../../../market-data/quotes/screener-live-quotes";
77
78
  import { useLiveStreamingSetting } from "../../../state/hooks/live-streaming";
78
79
  import { getTableWidth } from "../../../components/ui/table-layout";
79
80
 
81
+ /** How often a live sort re-reads the figures it orders by. */
82
+ const LIVE_ORDER_SAMPLE_MS = 5_000;
80
83
  const TABS = [
81
84
  { value: "results", label: "Results" },
82
85
  { value: "criteria", label: "Criteria" },
@@ -290,7 +293,23 @@ function EquityScreenView({
290
293
  freshnessScopeKey: `equity-screener:${JSON.stringify(definition)}`,
291
294
  liveStreaming,
292
295
  });
293
- const rows = useMemo(() => overlayLiveScreenRows(snapshotRows, liveEntries), [liveEntries, snapshotRows]);
296
+ const liveRows = useMemo(() => overlayLiveScreenRows(snapshotRows, liveEntries), [liveEntries, snapshotRows]);
297
+ // A sort on a field the quotes move is redone on the figures shown, so the
298
+ // column reads monotone. Re-sorting on every tick would reshuffle rows under
299
+ // the cursor; the order is taken from values sampled every few seconds,
300
+ // and at once when the screen or its pages change.
301
+ const orderRows = useSampledValue(
302
+ liveRows,
303
+ LIVE_ORDER_SAMPLE_MS,
304
+ `${data?.snapshot?.id ?? ""}:${JSON.stringify(definition.sort)}:${snapshotRows.length}`,
305
+ );
306
+ const rows = useMemo(() => {
307
+ const order = new Map(sortLiveScreenRows(orderRows, definition.sort).map((row, index) => [screenRowId(row), index]));
308
+ return liveRows
309
+ .map((row, index) => ({ row, index, place: order.get(screenRowId(row)) ?? Number.POSITIVE_INFINITY }))
310
+ .sort((a, b) => a.place - b.place || a.index - b.index)
311
+ .map(({ row }) => row);
312
+ }, [definition.sort, liveRows, orderRows]);
294
313
  const opened = rows.find((row) => screenRowId(row) === openId);
295
314
  const selected =
296
315
  opened ?? rows.find((row) => screenRowId(row) === selectedId) ?? rows[0];
@@ -111,6 +111,7 @@ function SentimentPlot({
111
111
  valueFormat,
112
112
  updatedAt,
113
113
  overlays,
114
+ focused = false,
114
115
  }: {
115
116
  rating: FearGreedRating;
116
117
  points: FearGreedIndicator["points"];
@@ -118,6 +119,7 @@ function SentimentPlot({
118
119
  valueFormat: FearGreedValueFormat;
119
120
  updatedAt: Date | null;
120
121
  overlays?: StaticChartOverlay[];
122
+ focused?: boolean;
121
123
  }) {
122
124
  const chartWidth = Math.max(24, width - 2);
123
125
  const chartHeight = width >= 96 ? 12 : 10;
@@ -143,6 +145,7 @@ function SentimentPlot({
143
145
  mode="line"
144
146
  colors={palette}
145
147
  overlays={overlays}
148
+ focused={focused}
146
149
  showTimeAxis
147
150
  timeAxisColor={colors.textDim}
148
151
  yAxisColor={colors.textDim}
@@ -224,7 +227,7 @@ function ChartStats({
224
227
 
225
228
  // The gauge above already shows the score, its rating and what the series is,
226
229
  // so the history is the plot alone.
227
- export function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number }) {
230
+ export function IndexHistoryChart({ data, width, focused = false }: { data: FearGreedData; width: number; focused?: boolean }) {
228
231
  return (
229
232
  <Box flexDirection="column" paddingX={1}>
230
233
  <SentimentPlot
@@ -233,6 +236,7 @@ export function IndexHistoryChart({ data, width }: { data: FearGreedData; width:
233
236
  width={width}
234
237
  valueFormat="score"
235
238
  updatedAt={data.overall.updatedAt}
239
+ focused={focused}
236
240
  />
237
241
  </Box>
238
242
  );
@@ -91,7 +91,7 @@ export function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
91
91
  segments={FEAR_GREED_GAUGE_SEGMENTS}
92
92
  minWidth={isDesktopWeb ? Math.min(34, Math.max(1, width - 2)) : undefined}
93
93
  />
94
- <IndexHistoryChart data={data} width={width} />
94
+ <IndexHistoryChart data={data} width={width} focused={focused} />
95
95
  <Box paddingX={1} marginTop={2}>
96
96
  <SectionHeading title="Indicators" />
97
97
  </Box>
@@ -1,6 +1,5 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
- Button,
4
3
  DataTableView,
5
4
  PaneStatusBody,
6
5
  QueryBar,
@@ -28,6 +27,7 @@ import { useLiveStreamingSetting } from "../../../state/hooks/live-streaming";
28
27
  import { quoteObservation } from "../shared/report-freshness";
29
28
  import {
30
29
  boardErrorMessage,
30
+ boardHeadlineQuote,
31
31
  quoteBoardFooterInfo,
32
32
  quoteBoardStatus,
33
33
  useQuoteBoard,
@@ -59,6 +59,7 @@ import {
59
59
  usesSessionText,
60
60
  type FuturesColumn,
61
61
  } from "./table";
62
+ import { useFuturesBoardTerms } from "./use-board-terms";
62
63
  import { useFrontContractReturns } from "./use-front-returns";
63
64
 
64
65
  const FUTURES_PANE_ID = "futures";
@@ -134,6 +135,8 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
134
135
  const paneVisible = usePaneVisible();
135
136
  const showsReturns = columns.some((column) => FUTURES_RETURN_COLUMNS[column.id]);
136
137
  const returns = useFrontContractReturns(listedContracts, quotes, dataProvider, showsReturns && paneVisible);
138
+ // The unit a price counts and the month a symbol rolled past, as `quote` reads them.
139
+ const terms = useFuturesBoardTerms(listedContracts, !!dataProvider && paneVisible);
137
140
 
138
141
  const visibleCollapsed = effectiveCollapsedSectors(collapsedSectors, searchQuery);
139
142
  const rows = useMemo(
@@ -141,15 +144,38 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
141
144
  query: searchQuery,
142
145
  collapsed: collapsedSectors,
143
146
  returns,
147
+ terms,
144
148
  }),
145
- [collapsedSectors, contractsBySector, quotes, returns, searchQuery, sortPreference],
149
+ [collapsedSectors, contractsBySector, quotes, returns, searchQuery, sortPreference, terms],
146
150
  );
147
151
 
148
152
  const sessionText = usesSessionText(width);
153
+ const quotesRef = useRef(quotes);
154
+ const returnsRef = useRef(returns);
155
+ const termsRef = useRef(terms);
156
+ quotesRef.current = quotes;
157
+ returnsRef.current = returns;
158
+ termsRef.current = terms;
149
159
  const renderCell = useCallback((
150
160
  row: FuturesTableRow,
151
161
  column: FuturesColumn,
152
- ) => renderFuturesCell(row, column, quotes, { sessionText, returns }), [quotes, returns, sessionText]);
162
+ ) => renderFuturesCell(row, column, quotesRef.current, { sessionText, returns: returnsRef.current, terms: termsRef.current }), [sessionText]);
163
+ // Everything a cell reads: the headline figures the board shows, the quote's
164
+ // other fields, whether the last good quote is held over, and the returns.
165
+ const getRowVersion = useCallback((row: FuturesTableRow) => {
166
+ if (row.type !== "row") return row.sector;
167
+ const state = quotes.get(row.contract.symbol);
168
+ const quote = state?.quote ? boardHeadlineQuote(state.quote) : null;
169
+ const values = returns.get(row.contract.symbol);
170
+ const contract = terms.get(row.contract.symbol);
171
+ return [
172
+ state?.loading, state?.stale,
173
+ quote?.price, quote?.change, quote?.changePercent, quote?.previousClose, quote?.volume, quote?.lastUpdated, quote?.marketState, quote?.symbol, quote?.name,
174
+ values?.loading, values?.values["1W"], values?.values["1M"], values?.values.YTD,
175
+ contract?.loading, contract?.terms?.spec?.unit, contract?.terms?.deliveryMonth, contract?.terms?.nearest?.contract,
176
+ contract?.terms?.nearest?.lastTrade,
177
+ ].join(":");
178
+ }, [quotes, returns, terms]);
153
179
 
154
180
  const toggleSector = useCallback((sector: FuturesSector) => {
155
181
  setCollapsedSectors((current) => {
@@ -259,7 +285,7 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
259
285
  height={height}
260
286
  error={errorMessage ?? "No quotes returned."}
261
287
  subject="Futures quotes"
262
- actions={<Button label="Retry" variant="secondary" compact onPress={refresh} />}
288
+ retry={{ onRetry: refresh }}
263
289
  />
264
290
  );
265
291
  }
@@ -297,6 +323,7 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
297
323
  onVisibleRangeChange={setVisibleRange}
298
324
  renderSectionHeader={renderSectorHeader}
299
325
  renderCell={renderCell}
326
+ getRowVersion={getRowVersion}
300
327
  selectedTextOverridesCellColor
301
328
  reportEveryRow
302
329
  emptyStateTitle={searchQuery.trim()
@@ -361,7 +388,7 @@ export const futuresModule: PluginModule = {
361
388
  paneId: FUTURES_PANE_ID,
362
389
  label: "Futures Board",
363
390
  description:
364
- "Front-month futures across equity index, rates, energy, metals, agriculture, livestock, and FX with last price, session change, search, and collapsible sectors.",
391
+ "Each root's most active futures contract across equity index, equity sectors, rates, energy, metals, agriculture, livestock, and FX with last price, unit, session change, search, and collapsible sectors.",
365
392
  keywords: [
366
393
  "futures",
367
394
  "commodities",
@@ -1,8 +1,10 @@
1
+ import type { FuturesContractTermsPayload } from "../../../api-client/futures-curve";
1
2
  import type { Quote } from "../../../types/financials";
2
3
  import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
3
4
  import type { BoardQuoteMap } from "../shared/use-quote-board";
4
5
  import { FUTURES_SECTOR_ORDER, type FuturesContract, type FuturesSector } from "../../../market-data/futures-board";
5
6
  import type { FuturesReturnHorizon } from "./returns";
7
+ import type { FuturesTermsMap } from "./use-board-terms";
6
8
  import type { FrontReturnsMap } from "./use-front-returns";
7
9
 
8
10
  export type FuturesTableRow =
@@ -14,6 +16,7 @@ export type FuturesColumnId =
14
16
  | "code"
15
17
  | "name"
16
18
  | "price"
19
+ | "unit"
17
20
  | "change"
18
21
  | "changePercent"
19
22
  | "return1w"
@@ -37,7 +40,8 @@ export function futuresRowId(row: FuturesTableRow): string {
37
40
  return row.type === "header" ? `header-${row.sector}` : row.contract.symbol;
38
41
  }
39
42
 
40
- const CONTRACT_MONTH = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[ -](\d{4}|\d{2})$/i;
43
+ /** "Nov 26" or "Futures,Nov-2026"; a hyphen takes only a whole year, since "Futures,Nov-20" is 2026 cut short. */
44
+ const CONTRACT_MONTH = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?: (\d{4}|\d{2})|-(\d{4}))$/i;
41
45
 
42
46
  /**
43
47
  * The alias stays the row identity while its quoted contract rolls, so the
@@ -63,10 +67,64 @@ export function quotedContractMonth(
63
67
  const match = CONTRACT_MONTH.exec(quote.name?.trim() ?? "");
64
68
  if (!match) return null;
65
69
  const month = MONTH_NAMES.findIndex((name) => name.toLowerCase() === match[1]!.toLowerCase());
66
- const year = match[2]!.length === 2 ? 2000 + Number(match[2]) : Number(match[2]);
70
+ const written = match[2] ?? match[3]!;
71
+ const year = written.length === 2 ? 2000 + Number(written) : Number(written);
67
72
  return { year, month };
68
73
  }
69
74
 
75
+ /** "2026-10-20" as "20 Oct". */
76
+ function dayMonth(date: string): string {
77
+ return `${Number(date.slice(8, 10))} ${MONTH_NAMES[Number(date.slice(5, 7)) - 1]}`;
78
+ }
79
+
80
+ /**
81
+ * The row's headline: the contract its quote prices, as futuresContractName
82
+ * words it. While the continuous symbol has rolled to the most active month
83
+ * and a nearer one still trades, the headline says so, with the nearer one's
84
+ * last trade: "WTI Crude Oil Dec 26, most active (Nov expires 20 Oct)". The
85
+ * terms only add that note when they name the month the quote prices, so a
86
+ * roll between the two loads never mixes months, and place the month when the
87
+ * quote's name carries none. A name column too narrow for the note keeps the
88
+ * plain name.
89
+ */
90
+ export function futuresContractHeadline(
91
+ contract: FuturesContract,
92
+ quote?: Quote | null,
93
+ terms?: Pick<FuturesContractTermsPayload, "deliveryMonth" | "nearest"> | null,
94
+ width = Number.POSITIVE_INFINITY,
95
+ ): string {
96
+ const quoted = quotedContractMonth(contract, quote);
97
+ const termsMonth = terms?.deliveryMonth && /^\d{4}-\d{2}$/.test(terms.deliveryMonth)
98
+ ? { year: Number(terms.deliveryMonth.slice(0, 4)), month: Number(terms.deliveryMonth.slice(5, 7)) - 1 }
99
+ : null;
100
+ const nearest = terms?.nearest;
101
+ const agree = quoted == null || termsMonth == null || (quoted.year === termsMonth.year && quoted.month === termsMonth.month);
102
+ // A quote whose name carries no month (or a cut one) is placed by its terms.
103
+ const month = agree ? quoted ?? termsMonth : quoted;
104
+ if (!month) return contract.name;
105
+ const active = `${MONTH_NAMES[month.month]} ${String(month.year).slice(-2)}`;
106
+ const plain = `${contract.name} ${active}`;
107
+ if (!nearest || !agree || nearest.deliveryMonth >= `${month.year}-${String(month.month + 1).padStart(2, "0")}`) return plain;
108
+ const nearer = MONTH_NAMES[Number(nearest.deliveryMonth.slice(5, 7)) - 1];
109
+ const expiry = nearest.lastTrade ? `expires ${dayMonth(nearest.lastTrade)}` : "still trading";
110
+ // The month the quote prices leads, so a column that clips the tail still shows it; one too narrow for
111
+ // "most active" shows the plain name.
112
+ const note = `, most active (${nearer} ${expiry})`;
113
+ return width >= plain.length + MOST_ACTIVE_MIN.length ? `${plain}${note}` : plain;
114
+ }
115
+
116
+ /** The least of the note a name column must fit to carry it. */
117
+ const MOST_ACTIVE_MIN = ", most active";
118
+
119
+ /**
120
+ * What a row's price counts, as `quote` says it: the exchange's unit from the
121
+ * contract terms ("USD / barrel"), the catalog's where the server has none
122
+ * ("EUR / MWh"), else null.
123
+ */
124
+ export function futuresUnitLabel(contract: FuturesContract, terms?: Pick<FuturesContractTermsPayload, "spec"> | null): string | null {
125
+ return terms?.spec?.unit ?? contract.unit ?? null;
126
+ }
127
+
70
128
  function matchesFuturesSearch(contract: FuturesContract, query: string, quote?: Quote | null): boolean {
71
129
  const normalized = query.trim().toLowerCase();
72
130
  if (!normalized) return true;
@@ -89,9 +147,12 @@ function getSortValue(
89
147
  contract: FuturesContract,
90
148
  quotes: BoardQuoteMap,
91
149
  returns?: FrontReturnsMap,
150
+ terms?: FuturesTermsMap,
92
151
  ): string | number | null {
93
152
  const quote = quotes.get(contract.symbol)?.quote;
94
153
  switch (columnId) {
154
+ case "unit":
155
+ return futuresUnitLabel(contract, terms?.get(contract.symbol)?.terms);
95
156
  case "status":
96
157
  return quote?.marketState === "REGULAR" ? 0 : 1;
97
158
  case "code":
@@ -122,12 +183,13 @@ function sortContracts(
122
183
  sortPreference: FuturesSortPreference,
123
184
  quotes: BoardQuoteMap,
124
185
  returns?: FrontReturnsMap,
186
+ terms?: FuturesTermsMap,
125
187
  ): FuturesContract[] {
126
188
  const columnId = sortPreference.columnId;
127
189
  if (!columnId) return contracts;
128
190
  return [...contracts].sort((left, right) => compareSortValues(
129
- getSortValue(columnId, left, quotes, returns),
130
- getSortValue(columnId, right, quotes, returns),
191
+ getSortValue(columnId, left, quotes, returns, terms),
192
+ getSortValue(columnId, right, quotes, returns, terms),
131
193
  sortPreference.direction,
132
194
  ));
133
195
  }
@@ -139,6 +201,8 @@ export interface BuildFuturesRowsOptions {
139
201
  collapsed?: ReadonlySet<FuturesSector>;
140
202
  /** Front-contract returns, for sorting on a returns column. */
141
203
  returns?: FrontReturnsMap;
204
+ /** Each row's contract terms, for sorting on the unit. */
205
+ terms?: FuturesTermsMap;
142
206
  }
143
207
 
144
208
  const NO_COLLAPSED_SECTORS: ReadonlySet<FuturesSector> = new Set();
@@ -166,7 +230,7 @@ export function buildFuturesRows(
166
230
  const query = options?.query ?? "";
167
231
  const collapsed = effectiveCollapsedSectors(options?.collapsed, query);
168
232
  for (const sector of FUTURES_SECTOR_ORDER) {
169
- const contracts = sortContracts(contractsBySector.get(sector) ?? [], sortPreference, quotes, options?.returns)
233
+ const contracts = sortContracts(contractsBySector.get(sector) ?? [], sortPreference, quotes, options?.returns, options?.terms)
170
234
  .filter((contract) => matchesFuturesSearch(contract, query, quotes.get(contract.symbol)?.quote));
171
235
  if (contracts.length === 0) continue;
172
236
  rows.push({ type: "header", sector });
@@ -9,8 +9,8 @@ import { FUTURES_CONTRACTS, FUTURES_SECTOR_LABELS, type FuturesContract } from "
9
9
  const US_CENT_CONTRACTS: ReadonlySet<string> = new Set(["ZC", "ZS", "ZL", "ZW", "KE", "KC", "SB", "CT"]);
10
10
 
11
11
  /**
12
- * The unit the board prints, not a blanket dollar price. Index futures are
13
- * points, rates are points (the board prints decimals, not 32nds), cents
12
+ * The unit the board prints, not a blanket dollar price. Index and sector
13
+ * futures are points, rates are points (the board prints decimals, not 32nds), cents
14
14
  * contracts are USX, and a declared quote unit wins over USD. Currency
15
15
  * futures are dollars per unit of the foreign currency.
16
16
  */
@@ -18,7 +18,7 @@ function futuresSeriesUnit(contract: FuturesContract): string {
18
18
  if (contract.unit?.startsWith("US cents") || US_CENT_CONTRACTS.has(contract.code)) return "USX";
19
19
  if (contract.unit?.startsWith("EUR")) return "EUR";
20
20
  if (contract.unit?.startsWith("USD")) return "USD";
21
- if (contract.sector === "equity-index" || contract.sector === "rates") return "points";
21
+ if (contract.sector === "equity-index" || contract.sector === "equity-sectors" || contract.sector === "rates") return "points";
22
22
  return "USD";
23
23
  }
24
24
 
@@ -5,7 +5,8 @@ import { TextAttributes } from "../../../ui";
5
5
  import { formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
6
6
  import { isBoardRowLoading, renderQuoteBoardCell, type BoardQuoteMap } from "../shared/use-quote-board";
7
7
  import { tickDecimals, type FuturesContract } from "../../../market-data/futures-board";
8
- import { FUTURES_RETURN_COLUMNS, futuresContractName, type FuturesColumnId, type FuturesTableRow } from "./model";
8
+ import { FUTURES_RETURN_COLUMNS, futuresContractHeadline, futuresUnitLabel, type FuturesColumnId, type FuturesTableRow } from "./model";
9
+ import type { FuturesTermsMap } from "./use-board-terms";
9
10
  import type { FrontReturnsMap } from "./use-front-returns";
10
11
 
11
12
  export type FuturesColumn = DataTableColumn & { id: FuturesColumnId };
@@ -21,6 +22,7 @@ export const FUTURES_COLUMN_DEFS: readonly FuturesColumnDef[] = [
21
22
  { id: "code", label: "Symbol", description: "Exchange contract code." },
22
23
  { id: "name", label: "Contract", description: "Contract name." },
23
24
  { id: "price", label: "Last", description: "Last traded price." },
25
+ { id: "unit", label: "Unit", description: "What a price counts, as the exchange quotes it (USD / barrel)." },
24
26
  { id: "change", label: "Change", description: "Change on the session." },
25
27
  { id: "changePercent", label: "Change %", description: "Percent change on the session." },
26
28
  { id: "return1w", label: "1W", description: "Front contract's return over one week." },
@@ -35,22 +37,32 @@ const DEFAULT_FUTURES_COLUMN_IDS = FUTURES_COLUMN_DEFS.map((column) => column.id
35
37
 
36
38
  /**
37
39
  * Extra columns only earn their width once the board is wide enough to keep a
38
- * readable contract name; a narrow pane drops them instead of clipping.
40
+ * readable contract name (24 cells or more); a narrow pane drops them instead
41
+ * of clipping. The unit a price counts outranks its session change in points,
42
+ * which the change in percent still gives. The previous close (the last price
43
+ * less the change) goes first, then each row's quote time (the status line
44
+ * dates the newest), which leaves a 1280-pixel shot room for "WTI Crude Oil
45
+ * Dec 26, most active (Nov expires 20 Oct)".
39
46
  */
40
47
  const COLUMN_MIN_PANE_WIDTH: Partial<Record<FuturesColumnId, number>> = {
41
- return1w: 84,
42
- return1m: 92,
43
- returnYtd: 106,
44
- volume: 116,
45
- prevClose: 128,
46
- time: 140,
48
+ change: 100,
49
+ return1w: 108,
50
+ return1m: 117,
51
+ returnYtd: 126,
52
+ volume: 136,
53
+ time: 170,
54
+ prevClose: 200,
47
55
  };
56
+ /** From this width the unit column fits lumber's "USD / 1,000 board ft" whole. */
57
+ const WIDE_UNIT_MIN_WIDTH = 200;
48
58
  const SESSION_TEXT_MIN_WIDTH = 100;
49
59
 
50
60
  const COLUMN_WIDTHS: Record<Exclude<FuturesColumnId, "name">, number> = {
51
61
  status: 1,
52
62
  code: 5,
53
63
  price: 12,
64
+ // "US cents / bushel"; lumber's longer unit fits from WIDE_UNIT_MIN_WIDTH.
65
+ unit: 17,
54
66
  change: 10,
55
67
  changePercent: 9,
56
68
  return1w: 8,
@@ -70,6 +82,7 @@ export function usesSessionText(width: number): boolean {
70
82
 
71
83
  function columnWidth(id: Exclude<FuturesColumnId, "name">, paneWidth: number): number {
72
84
  if (id === "status") return usesSessionText(paneWidth) ? 9 : 1;
85
+ if (id === "unit" && paneWidth >= WIDE_UNIT_MIN_WIDTH) return 20;
73
86
  return COLUMN_WIDTHS[id];
74
87
  }
75
88
 
@@ -95,7 +108,7 @@ export function createFuturesColumns(width: number, visibleIds?: readonly string
95
108
  // TIME is left-aligned like the text columns: the shared table trims a few
96
109
  // cells off the right edge of a floating pane, and a right-aligned value
97
110
  // there would lose digits.
98
- align: id === "code" || id === "status" || id === "time" ? "left" : "right",
111
+ align: id === "code" || id === "status" || id === "unit" || id === "time" ? "left" : "right",
99
112
  };
100
113
  });
101
114
  }
@@ -105,6 +118,7 @@ const FUTURES_HEADER_LABELS: Record<FuturesColumnId, string> = {
105
118
  code: "SYM",
106
119
  name: "CONTRACT",
107
120
  price: "LAST",
121
+ unit: "UNIT",
108
122
  change: "CHG",
109
123
  changePercent: "CHG%",
110
124
  return1w: "1W",
@@ -200,7 +214,7 @@ export function renderFuturesCell(
200
214
  row: FuturesTableRow,
201
215
  column: FuturesColumn,
202
216
  quotes: BoardQuoteMap,
203
- options?: { sessionText?: boolean; returns?: FrontReturnsMap },
217
+ options?: { sessionText?: boolean; returns?: FrontReturnsMap; terms?: FuturesTermsMap },
204
218
  ): DataTableCell {
205
219
  if (row.type === "header") return { text: "" };
206
220
 
@@ -216,7 +230,13 @@ export function renderFuturesCell(
216
230
  attributes: TextAttributes.BOLD,
217
231
  };
218
232
  case "name":
219
- return { text: futuresContractName(contract, quote) };
233
+ return { text: futuresContractHeadline(contract, quote, options?.terms?.get(contract.symbol)?.terms, column.width) };
234
+ case "unit": {
235
+ const terms = options?.terms?.get(contract.symbol);
236
+ const unit = futuresUnitLabel(contract, terms?.terms);
237
+ if (unit) return { text: unit, color: colors.textDim };
238
+ return terms?.loading ? { text: "…", color: colors.textDim } : { text: "—", color: colors.textDim };
239
+ }
220
240
  case "volume":
221
241
  if (isBoardRowLoading(state)) return { text: "…", color: colors.textDim };
222
242
  if (!quote || quote.volume == null || !Number.isFinite(quote.volume)) {
@@ -0,0 +1,89 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import type { FuturesContractTermsPayload } from "../../../api-client/futures-curve";
3
+ import type { FuturesContract } from "../../../market-data/futures-board";
4
+ import { loadFuturesContractTerms } from "../../../market-data/futures-contract";
5
+
6
+ interface FuturesTermsState {
7
+ /** The row's terms are still being asked for. */
8
+ loading: boolean;
9
+ /** Null when the server has no terms for the symbol, or could not answer. */
10
+ terms: FuturesContractTermsPayload | null;
11
+ }
12
+
13
+ export type FuturesTermsMap = ReadonlyMap<string, FuturesTermsState>;
14
+
15
+ /** The terms change with a roll, and a roll is decided once a day. */
16
+ const TERMS_TTL_MS = 30 * 60_000;
17
+ /** Forty-odd rows open at once; a few requests at a time keep the board's quotes first. */
18
+ const TERMS_CONCURRENCY = 4;
19
+
20
+ interface CachedTerms {
21
+ at: number;
22
+ /** Null when the server has no terms for the symbol, or could not answer. */
23
+ terms: FuturesContractTermsPayload | null;
24
+ }
25
+
26
+ const termsCache = new Map<string, CachedTerms>();
27
+ const inFlight = new Map<string, Promise<void>>();
28
+
29
+ function fresh(symbol: string, now: number): CachedTerms | undefined {
30
+ const cached = termsCache.get(symbol);
31
+ return cached && now - cached.at < TERMS_TTL_MS ? cached : undefined;
32
+ }
33
+
34
+ function loadTerms(symbol: string): Promise<void> {
35
+ const pending = inFlight.get(symbol);
36
+ if (pending) return pending;
37
+ const request = loadFuturesContractTerms(symbol)
38
+ .then((terms) => { termsCache.set(symbol, { at: Date.now(), terms }); })
39
+ .catch(() => { termsCache.set(symbol, { at: Date.now(), terms: null }); })
40
+ .finally(() => { inFlight.delete(symbol); });
41
+ inFlight.set(symbol, request);
42
+ return request;
43
+ }
44
+
45
+ /**
46
+ * Each listed row's contract terms, as `quote` reads them: the unit its price
47
+ * counts, and the nearer month still trading when its continuous symbol has
48
+ * rolled to the most active one. Only the rows the board lists are asked for,
49
+ * and a row whose terms have not arrived (or that has none) renders without them.
50
+ */
51
+ export function useFuturesBoardTerms(contracts: readonly FuturesContract[], enabled: boolean): FuturesTermsMap {
52
+ const symbolKey = contracts.map((contract) => contract.symbol).join(",");
53
+ const [version, setVersion] = useState(0);
54
+
55
+ useEffect(() => {
56
+ if (!enabled || !symbolKey) return;
57
+ const now = Date.now();
58
+ const queue = symbolKey.split(",").filter((symbol) => !fresh(symbol, now));
59
+ if (queue.length === 0) return;
60
+ let cancelled = false;
61
+ const worker = async () => {
62
+ for (let symbol = queue.shift(); symbol; symbol = queue.shift()) {
63
+ await loadTerms(symbol);
64
+ if (!cancelled) setVersion((current) => current + 1);
65
+ }
66
+ };
67
+ void Promise.all(Array.from({ length: Math.min(TERMS_CONCURRENCY, queue.length) }, worker));
68
+ return () => {
69
+ cancelled = true;
70
+ queue.length = 0;
71
+ };
72
+ }, [enabled, symbolKey]);
73
+
74
+ return useMemo(() => {
75
+ const result = new Map<string, FuturesTermsState>();
76
+ if (!enabled) return result;
77
+ for (const symbol of symbolKey ? symbolKey.split(",") : []) {
78
+ const cached = termsCache.get(symbol);
79
+ result.set(symbol, { loading: !cached, terms: cached?.terms ?? null });
80
+ }
81
+ return result;
82
+ // `version` re-reads the cache as loads land.
83
+ }, [enabled, symbolKey, version]);
84
+ }
85
+
86
+ /** Forgets every cached row's terms (tests). */
87
+ export function resetFuturesBoardTerms(): void {
88
+ termsCache.clear();
89
+ }
@@ -73,7 +73,12 @@ export function HoldersView({ focused, width, height }: { focused: boolean; widt
73
73
  } : null, [dataProvider, exchange, symbol]);
74
74
  // Another ticker's holders stay up until its own answer, and a failed
75
75
  // refresh keeps the last holders; the failure goes to the footer.
76
- const { data, loading, error, reload } = useAsyncResource<HolderData>(loadHolders, { keepPreviousData: true });
76
+ const { data, loading, error, reload } = useAsyncResource<HolderData>(loadHolders, {
77
+ initialData: () => symbol && dataProvider?.getCachedQuery
78
+ ? dataProvider.getCachedQuery("getHolders", [symbol, exchange]).getSnapshot().result?.value ?? null
79
+ : null,
80
+ keepPreviousData: true,
81
+ });
77
82
 
78
83
  // The values' own currency: dollars from the 13F filings on a London line, not its pence.
79
84
  const currency = holderValueCurrency(data, ticker?.metadata.currency || "USD")!;