gloomberb 0.17.2 → 0.17.3

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 (159) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/futures-curve.ts +16 -0
  3. package/src/api-client/index.ts +18 -8
  4. package/src/api-client/types.ts +3 -1
  5. package/src/cli/commands/market.ts +60 -9
  6. package/src/cli/commands/search.ts +43 -2
  7. package/src/cli/commands/ticker.ts +100 -21
  8. package/src/cli/desktop-pane-shot.ts +34 -9
  9. package/src/cli/fx-pair.ts +17 -0
  10. package/src/cli/glossary.ts +134 -6
  11. package/src/cli/history-rows.ts +16 -4
  12. package/src/cli/index.ts +44 -12
  13. package/src/cli/known-gaps.ts +21 -2
  14. package/src/cli/listing-arg.ts +19 -4
  15. package/src/cli/listing-suggestion.ts +33 -2
  16. package/src/cli/pane-functions/dom.ts +39 -6
  17. package/src/cli/pane-functions/freshness.ts +48 -5
  18. package/src/cli/pane-functions/function-help.ts +1 -1
  19. package/src/cli/pane-functions/headless.ts +85 -19
  20. package/src/cli/pane-functions/index.ts +60 -4
  21. package/src/cli/pane-functions/market-session.ts +69 -6
  22. package/src/cli/pane-functions/resolver.ts +4 -2
  23. package/src/cli/pane-functions/screenshot.ts +14 -1
  24. package/src/cli/scroll-truncation.ts +5 -1
  25. package/src/cli/symbol-args.ts +41 -3
  26. package/src/cli/symbol-list.ts +19 -7
  27. package/src/cli/unknown-command.ts +70 -0
  28. package/src/components/chart/native/surface/visibility.ts +37 -7
  29. package/src/components/layout/header.tsx +348 -102
  30. package/src/components/markdown-text.tsx +40 -66
  31. package/src/components/market-board/index.tsx +12 -6
  32. package/src/components/reader/detail-reader.tsx +28 -6
  33. package/src/components/ticker/badge/index.tsx +2 -0
  34. package/src/components/ui/display.tsx +6 -1
  35. package/src/components/ui/external-link.tsx +10 -2
  36. package/src/data/memory-resource-store.ts +1 -1
  37. package/src/data/resource-store.ts +13 -1
  38. package/src/docs/snapshot.json +13 -13
  39. package/src/i18n/es.ts +1 -0
  40. package/src/i18n/ja.ts +1 -0
  41. package/src/i18n/ko.ts +1 -0
  42. package/src/i18n/zh-cn.ts +1 -0
  43. package/src/i18n/zh-tw.ts +1 -0
  44. package/src/market-data/futures-contract.ts +10 -0
  45. package/src/market-data/market/format.ts +7 -2
  46. package/src/market-data/market/freshness.ts +25 -1
  47. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  48. package/src/market-data/quotes/resolution.ts +2 -0
  49. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  50. package/src/plugins/builtin/attention/headless.ts +19 -10
  51. package/src/plugins/builtin/attention/model.ts +22 -0
  52. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  53. package/src/plugins/builtin/changelog/index.tsx +11 -8
  54. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  55. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  56. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  57. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  58. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  59. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  60. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  61. package/src/plugins/builtin/chat/layout.ts +91 -31
  62. package/src/plugins/builtin/chat/markdown.ts +94 -0
  63. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  64. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  65. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  66. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  67. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  68. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  69. package/src/plugins/builtin/company-attention/model.ts +53 -2
  70. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  71. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  72. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  73. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  74. package/src/plugins/builtin/cot/client.ts +12 -4
  75. package/src/plugins/builtin/cot/pane.tsx +2 -2
  76. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  78. package/src/plugins/builtin/debug/index.tsx +15 -15
  79. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  80. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  81. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  82. package/src/plugins/builtin/docs/index.tsx +5 -3
  83. package/src/plugins/builtin/docs/pane.tsx +18 -1
  84. package/src/plugins/builtin/docs/targets.ts +5 -3
  85. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  86. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  87. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  88. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  89. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  90. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  91. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  92. package/src/plugins/builtin/futures/index.tsx +32 -4
  93. package/src/plugins/builtin/futures/model.ts +65 -3
  94. package/src/plugins/builtin/futures/table.tsx +31 -11
  95. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  96. package/src/plugins/builtin/holders/pane.tsx +6 -1
  97. package/src/plugins/builtin/mna/client.ts +12 -2
  98. package/src/plugins/builtin/mna/detail.tsx +2 -2
  99. package/src/plugins/builtin/mna/pane.tsx +2 -2
  100. package/src/plugins/builtin/notes/owner.tsx +5 -4
  101. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  102. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  103. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  104. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  105. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  106. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
  108. package/src/plugins/builtin/research/event-model.ts +14 -0
  109. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  110. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  111. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  112. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  113. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  114. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  115. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  116. package/src/plugins/builtin/research-search/data.ts +11 -0
  117. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  118. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  119. package/src/plugins/builtin/seasonality/client.ts +23 -0
  120. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  121. package/src/plugins/builtin/sectors/index.tsx +7 -3
  122. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  123. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  124. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  125. package/src/plugins/builtin/short-interest/model.ts +62 -3
  126. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  127. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  128. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  129. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  130. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  131. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  132. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  133. package/src/plugins/builtin/themes/headless.ts +1 -1
  134. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  135. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  136. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  137. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  138. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  139. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  140. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  141. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  142. package/src/renderers/dom/styles.css +6 -0
  143. package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
  144. package/src/sources/provider-router/batches.ts +2 -1
  145. package/src/sources/provider-router/cache.ts +3 -0
  146. package/src/sync/baseline.ts +19 -14
  147. package/src/sync/controller.ts +140 -40
  148. package/src/sync/core-contributors.ts +256 -32
  149. package/src/sync/types.ts +44 -0
  150. package/src/time-series/resolve.ts +19 -2
  151. package/src/types/data-provider.ts +8 -0
  152. package/src/types/financials.ts +40 -4
  153. package/src/types/headless.ts +43 -0
  154. package/src/types/plugin.ts +7 -0
  155. package/src/utils/declared-dividends.ts +39 -0
  156. package/src/utils/forward-dividend.ts +34 -11
  157. package/src/utils/fundamentals.ts +13 -2
  158. package/src/utils/markdown.ts +180 -0
  159. package/src/utils/market-capitalization.ts +22 -0
@@ -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,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  Button,
4
4
  DataTableView,
@@ -28,6 +28,7 @@ import { useLiveStreamingSetting } from "../../../state/hooks/live-streaming";
28
28
  import { quoteObservation } from "../shared/report-freshness";
29
29
  import {
30
30
  boardErrorMessage,
31
+ boardHeadlineQuote,
31
32
  quoteBoardFooterInfo,
32
33
  quoteBoardStatus,
33
34
  useQuoteBoard,
@@ -59,6 +60,7 @@ import {
59
60
  usesSessionText,
60
61
  type FuturesColumn,
61
62
  } from "./table";
63
+ import { useFuturesBoardTerms } from "./use-board-terms";
62
64
  import { useFrontContractReturns } from "./use-front-returns";
63
65
 
64
66
  const FUTURES_PANE_ID = "futures";
@@ -134,6 +136,8 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
134
136
  const paneVisible = usePaneVisible();
135
137
  const showsReturns = columns.some((column) => FUTURES_RETURN_COLUMNS[column.id]);
136
138
  const returns = useFrontContractReturns(listedContracts, quotes, dataProvider, showsReturns && paneVisible);
139
+ // The unit a price counts and the month a symbol rolled past, as `quote` reads them.
140
+ const terms = useFuturesBoardTerms(listedContracts, !!dataProvider && paneVisible);
137
141
 
138
142
  const visibleCollapsed = effectiveCollapsedSectors(collapsedSectors, searchQuery);
139
143
  const rows = useMemo(
@@ -141,15 +145,38 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
141
145
  query: searchQuery,
142
146
  collapsed: collapsedSectors,
143
147
  returns,
148
+ terms,
144
149
  }),
145
- [collapsedSectors, contractsBySector, quotes, returns, searchQuery, sortPreference],
150
+ [collapsedSectors, contractsBySector, quotes, returns, searchQuery, sortPreference, terms],
146
151
  );
147
152
 
148
153
  const sessionText = usesSessionText(width);
154
+ const quotesRef = useRef(quotes);
155
+ const returnsRef = useRef(returns);
156
+ const termsRef = useRef(terms);
157
+ quotesRef.current = quotes;
158
+ returnsRef.current = returns;
159
+ termsRef.current = terms;
149
160
  const renderCell = useCallback((
150
161
  row: FuturesTableRow,
151
162
  column: FuturesColumn,
152
- ) => renderFuturesCell(row, column, quotes, { sessionText, returns }), [quotes, returns, sessionText]);
163
+ ) => renderFuturesCell(row, column, quotesRef.current, { sessionText, returns: returnsRef.current, terms: termsRef.current }), [sessionText]);
164
+ // Everything a cell reads: the headline figures the board shows, the quote's
165
+ // other fields, whether the last good quote is held over, and the returns.
166
+ const getRowVersion = useCallback((row: FuturesTableRow) => {
167
+ if (row.type !== "row") return row.sector;
168
+ const state = quotes.get(row.contract.symbol);
169
+ const quote = state?.quote ? boardHeadlineQuote(state.quote) : null;
170
+ const values = returns.get(row.contract.symbol);
171
+ const contract = terms.get(row.contract.symbol);
172
+ return [
173
+ state?.loading, state?.stale,
174
+ quote?.price, quote?.change, quote?.changePercent, quote?.previousClose, quote?.volume, quote?.lastUpdated, quote?.marketState, quote?.symbol, quote?.name,
175
+ values?.loading, values?.values["1W"], values?.values["1M"], values?.values.YTD,
176
+ contract?.loading, contract?.terms?.spec?.unit, contract?.terms?.deliveryMonth, contract?.terms?.nearest?.contract,
177
+ contract?.terms?.nearest?.lastTrade,
178
+ ].join(":");
179
+ }, [quotes, returns, terms]);
153
180
 
154
181
  const toggleSector = useCallback((sector: FuturesSector) => {
155
182
  setCollapsedSectors((current) => {
@@ -297,6 +324,7 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
297
324
  onVisibleRangeChange={setVisibleRange}
298
325
  renderSectionHeader={renderSectorHeader}
299
326
  renderCell={renderCell}
327
+ getRowVersion={getRowVersion}
300
328
  selectedTextOverridesCellColor
301
329
  reportEveryRow
302
330
  emptyStateTitle={searchQuery.trim()
@@ -361,7 +389,7 @@ export const futuresModule: PluginModule = {
361
389
  paneId: FUTURES_PANE_ID,
362
390
  label: "Futures Board",
363
391
  description:
364
- "Front-month futures across equity index, rates, energy, metals, agriculture, livestock, and FX with last price, session change, search, and collapsible sectors.",
392
+ "Each root's most active futures contract across equity index, rates, energy, metals, agriculture, livestock, and FX with last price, unit, session change, search, and collapsible sectors.",
365
393
  keywords: [
366
394
  "futures",
367
395
  "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"
@@ -67,6 +70,59 @@ export function quotedContractMonth(
67
70
  return { year, month };
68
71
  }
69
72
 
73
+ /** "2026-10-20" as "20 Oct". */
74
+ function dayMonth(date: string): string {
75
+ return `${Number(date.slice(8, 10))} ${MONTH_NAMES[Number(date.slice(5, 7)) - 1]}`;
76
+ }
77
+
78
+ /**
79
+ * The row's headline: the contract its quote prices, as futuresContractName
80
+ * words it. While the continuous symbol has rolled to the most active month
81
+ * and a nearer one still trades, the headline says so, with the nearer one's
82
+ * last trade: "WTI Crude Oil Dec 26, most active (Nov expires 20 Oct)". The
83
+ * terms only add that note when they name the month the quote prices, so a
84
+ * roll between the two loads never mixes months, and place the month when the
85
+ * quote's name carries none. A name column too narrow for the note keeps the
86
+ * plain name.
87
+ */
88
+ export function futuresContractHeadline(
89
+ contract: FuturesContract,
90
+ quote?: Quote | null,
91
+ terms?: Pick<FuturesContractTermsPayload, "deliveryMonth" | "nearest"> | null,
92
+ width = Number.POSITIVE_INFINITY,
93
+ ): string {
94
+ const quoted = quotedContractMonth(contract, quote);
95
+ const termsMonth = terms?.deliveryMonth && /^\d{4}-\d{2}$/.test(terms.deliveryMonth)
96
+ ? { year: Number(terms.deliveryMonth.slice(0, 4)), month: Number(terms.deliveryMonth.slice(5, 7)) - 1 }
97
+ : null;
98
+ const nearest = terms?.nearest;
99
+ const agree = quoted == null || termsMonth == null || (quoted.year === termsMonth.year && quoted.month === termsMonth.month);
100
+ // A quote whose name carries no month (or a cut one) is placed by its terms.
101
+ const month = agree ? quoted ?? termsMonth : quoted;
102
+ if (!month) return contract.name;
103
+ const active = `${MONTH_NAMES[month.month]} ${String(month.year).slice(-2)}`;
104
+ const plain = `${contract.name} ${active}`;
105
+ if (!nearest || !agree || nearest.deliveryMonth >= `${month.year}-${String(month.month + 1).padStart(2, "0")}`) return plain;
106
+ const nearer = MONTH_NAMES[Number(nearest.deliveryMonth.slice(5, 7)) - 1];
107
+ const expiry = nearest.lastTrade ? `expires ${dayMonth(nearest.lastTrade)}` : "still trading";
108
+ // The month the quote prices leads, so a column that clips the tail still shows it; one too narrow for
109
+ // "most active" shows the plain name.
110
+ const note = `, most active (${nearer} ${expiry})`;
111
+ return width >= plain.length + MOST_ACTIVE_MIN.length ? `${plain}${note}` : plain;
112
+ }
113
+
114
+ /** The least of the note a name column must fit to carry it. */
115
+ const MOST_ACTIVE_MIN = ", most active";
116
+
117
+ /**
118
+ * What a row's price counts, as `quote` says it: the exchange's unit from the
119
+ * contract terms ("USD / barrel"), the catalog's where the server has none
120
+ * ("EUR / MWh"), else null.
121
+ */
122
+ export function futuresUnitLabel(contract: FuturesContract, terms?: Pick<FuturesContractTermsPayload, "spec"> | null): string | null {
123
+ return terms?.spec?.unit ?? contract.unit ?? null;
124
+ }
125
+
70
126
  function matchesFuturesSearch(contract: FuturesContract, query: string, quote?: Quote | null): boolean {
71
127
  const normalized = query.trim().toLowerCase();
72
128
  if (!normalized) return true;
@@ -89,9 +145,12 @@ function getSortValue(
89
145
  contract: FuturesContract,
90
146
  quotes: BoardQuoteMap,
91
147
  returns?: FrontReturnsMap,
148
+ terms?: FuturesTermsMap,
92
149
  ): string | number | null {
93
150
  const quote = quotes.get(contract.symbol)?.quote;
94
151
  switch (columnId) {
152
+ case "unit":
153
+ return futuresUnitLabel(contract, terms?.get(contract.symbol)?.terms);
95
154
  case "status":
96
155
  return quote?.marketState === "REGULAR" ? 0 : 1;
97
156
  case "code":
@@ -122,12 +181,13 @@ function sortContracts(
122
181
  sortPreference: FuturesSortPreference,
123
182
  quotes: BoardQuoteMap,
124
183
  returns?: FrontReturnsMap,
184
+ terms?: FuturesTermsMap,
125
185
  ): FuturesContract[] {
126
186
  const columnId = sortPreference.columnId;
127
187
  if (!columnId) return contracts;
128
188
  return [...contracts].sort((left, right) => compareSortValues(
129
- getSortValue(columnId, left, quotes, returns),
130
- getSortValue(columnId, right, quotes, returns),
189
+ getSortValue(columnId, left, quotes, returns, terms),
190
+ getSortValue(columnId, right, quotes, returns, terms),
131
191
  sortPreference.direction,
132
192
  ));
133
193
  }
@@ -139,6 +199,8 @@ export interface BuildFuturesRowsOptions {
139
199
  collapsed?: ReadonlySet<FuturesSector>;
140
200
  /** Front-contract returns, for sorting on a returns column. */
141
201
  returns?: FrontReturnsMap;
202
+ /** Each row's contract terms, for sorting on the unit. */
203
+ terms?: FuturesTermsMap;
142
204
  }
143
205
 
144
206
  const NO_COLLAPSED_SECTORS: ReadonlySet<FuturesSector> = new Set();
@@ -166,7 +228,7 @@ export function buildFuturesRows(
166
228
  const query = options?.query ?? "";
167
229
  const collapsed = effectiveCollapsedSectors(options?.collapsed, query);
168
230
  for (const sector of FUTURES_SECTOR_ORDER) {
169
- const contracts = sortContracts(contractsBySector.get(sector) ?? [], sortPreference, quotes, options?.returns)
231
+ const contracts = sortContracts(contractsBySector.get(sector) ?? [], sortPreference, quotes, options?.returns, options?.terms)
170
232
  .filter((contract) => matchesFuturesSearch(contract, query, quotes.get(contract.symbol)?.quote));
171
233
  if (contracts.length === 0) continue;
172
234
  rows.push({ type: "header", sector });
@@ -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")!;
@@ -9,7 +9,7 @@ import type {
9
9
  } from "../../../api-client/mna";
10
10
  import type { HeadlessPaneApiClient } from "../../../types/plugin";
11
11
  import { createPluginCache } from "../../../data/plugin-cache";
12
- import { loadCloudResource, unavailableOnServer, type CloudResource } from "../shared/cloud-resource";
12
+ import { cachedCloudResource, loadCloudResource, unavailableOnServer, type CloudResource } from "../shared/cloud-resource";
13
13
 
14
14
  /**
15
15
  * Deals change when a story or a filing lands, a few times an hour at most,
@@ -119,6 +119,10 @@ const listKey = (params: MnaDealsParams, pro: boolean) =>
119
119
  JSON.stringify([params.status ?? "pending", params.target ?? "all", params.region ?? "all",
120
120
  params.symbol?.toUpperCase() ?? "", params.query?.trim().toLowerCase() ?? "", params.offset ?? 0, pro ? "full" : "delayed"]);
121
121
 
122
+ export function cachedMnaDeals(params: MnaDealsParams, pro: boolean) {
123
+ return cachedCloudResource(mnaDealsCache, listKey(params, pro), validateMnaDeals);
124
+ }
125
+
122
126
  export function loadMnaDeals(params: MnaDealsParams, pro: boolean, force = false): Promise<CloudResource<MnaDealsPayload>> {
123
127
  return loadCloudResource(mnaDealsCache, listKey(params, pro), () => fetchMnaDeals(params), {
124
128
  force,
@@ -126,8 +130,14 @@ export function loadMnaDeals(params: MnaDealsParams, pro: boolean, force = false
126
130
  });
127
131
  }
128
132
 
133
+ const dealKey = (id: string, pro: boolean) => `${id}:${pro ? "full" : "delayed"}`;
134
+
135
+ export function cachedMnaDeal(id: string, pro: boolean) {
136
+ return cachedCloudResource(mnaDealCache, dealKey(id, pro), validateMnaDeal);
137
+ }
138
+
129
139
  export function loadMnaDeal(id: string, pro: boolean, force = false): Promise<CloudResource<MnaDealPayload>> {
130
- return loadCloudResource(mnaDealCache, `${id}:${pro ? "full" : "delayed"}`, () => fetchMnaDeal(id), {
140
+ return loadCloudResource(mnaDealCache, dealKey(id, pro), () => fetchMnaDeal(id), {
131
141
  force,
132
142
  validate: validateMnaDeal,
133
143
  });
@@ -22,7 +22,7 @@ import { Box, useRendererHost } from "../../../ui";
22
22
  import { useAssetData, usePluginTickerActions } from "../../runtime";
23
23
  import { useResearchCloudSession } from "../shared/research-cloud-session";
24
24
  import { useQuoteBoard } from "../shared/use-quote-board";
25
- import { loadMnaDeal } from "./client";
25
+ import { cachedMnaDeal, loadMnaDeal } from "./client";
26
26
  import {
27
27
  dealSpread,
28
28
  formatDealValue,
@@ -136,7 +136,7 @@ export function MnaDealDetail({ id, seed, focused, width, height }: {
136
136
  // eslint-disable-next-line react-hooks/exhaustive-deps
137
137
  [id, pro, session.requestKey],
138
138
  );
139
- const resource = useAsyncResource(loader, { clearOnError: isAccessDenied });
139
+ const resource = useAsyncResource(loader, { initialData: () => cachedMnaDeal(id, pro), clearOnError: isAccessDenied });
140
140
  const payload = resource.data?.payload ?? null;
141
141
  const deal = payload?.deal ?? seed;
142
142
  const events = payload?.events ?? NO_EVENTS;
@@ -37,7 +37,7 @@ import { useCloudAccessFooter } from "../shared/cloud-upgrade";
37
37
  import { Blurred, UpgradeLabel } from "../shared/locked-rows";
38
38
  import { useResearchCloudSession } from "../shared/research-cloud-session";
39
39
  import { useQuoteBoard } from "../shared/use-quote-board";
40
- import { appendMnaDeals, fetchMnaDeals, loadMnaDeals } from "./client";
40
+ import { appendMnaDeals, cachedMnaDeals, fetchMnaDeals, loadMnaDeals } from "./client";
41
41
  import { MnaDealDetail, mnaDealTitle } from "./detail";
42
42
  import { DistressBoard } from "./distress/pane";
43
43
  import {
@@ -220,7 +220,7 @@ function MnaDealsView({ focused, width, height, symbol }: {
220
220
  // eslint-disable-next-line react-hooks/exhaustive-deps
221
221
  [paramsKey, pro, session.requestKey],
222
222
  );
223
- const resource = useAsyncResource(loader, { clearOnError: isAccessDenied });
223
+ const resource = useAsyncResource(loader, { initialData: () => cachedMnaDeals(params, pro), clearOnError: isAccessDenied });
224
224
  const first = resource.data?.payload ?? null;
225
225
  const [paged, setPaged] = useState<{ owner: MnaDealsPayload; payload: MnaDealsPayload } | null>(null);
226
226
  const data = paged && paged.owner === first ? paged.payload : first;
@@ -1,8 +1,9 @@
1
1
  import { useSyncExternalStore } from "react";
2
2
  import type { TeamSummary } from "../../../api-client";
3
3
  import { ChoiceDialog } from "../../../components/ui/choice-dialog";
4
+ import { Notice } from "../../../components/ui/status";
4
5
  import { QueryBar } from "../../../components/ui/query-bar";
5
- import { Box, Text } from "../../../ui";
6
+ import { Box } from "../../../ui";
6
7
  import type { DialogApi, PromptContext } from "../../../ui/dialog";
7
8
  import { colors } from "../../../theme/colors";
8
9
  import { teamAccentHex, teamPrefix } from "../../../team/model";
@@ -118,12 +119,12 @@ export async function resolveNoteConflict(dialog: DialogApi, error: NoteConflict
118
119
 
119
120
  export function ReadOnlyBanner({ owner, teams }: { owner: NoteOwner; teams: readonly TeamSummary[] }) {
120
121
  return (
121
- <Box height={1} paddingLeft={1}>
122
- <Text fg={colors.warning}>
122
+ <Box paddingLeft={1}>
123
+ <Notice>
123
124
  {owner.kind === "user"
124
125
  ? "Offline: notes are read-only until Gloom Cloud is reachable."
125
126
  : `Offline: ${ownerLabel(owner, teams)} notes are read-only.`}
126
- </Text>
127
+ </Notice>
127
128
  </Box>
128
129
  );
129
130
  }
@@ -1,9 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { Box, Text, type TextareaRenderable } from "../../../ui";
2
+ import { Box, type TextareaRenderable } from "../../../ui";
3
3
  import { useShortcut } from "../../../react/input";
4
4
  import { isPlainKey } from "../../../utils/keyboard";
5
5
  import type { TickerResearchTabProps } from "../../../types/plugin";
6
- import { colors } from "../../../theme/colors";
7
6
  import { MarkdownEditor } from "../../../components/markdown-editor";
8
7
  import { EmptyState, usePaneFooter } from "../../../components";
9
8
  import { usePluginAppActions, usePluginPaneState } from "../../runtime";
@@ -178,7 +177,7 @@ export function createNotesTab(registry: NotesStoreRegistry) {
178
177
  : [{ id: "mode", parts: [{ text: notesFiles.readOnly ? "read-only" : notesFocused ? "editing" : "viewing", tone: "muted" as const }] }],
179
178
  }), [loadError, notesFiles.readOnly, notesFocused]);
180
179
 
181
- if (!ticker) return <Text fg={colors.textDim}>Select a ticker to view notes.</Text>;
180
+ if (!ticker) return <EmptyState title="Select a ticker." />;
182
181
 
183
182
  return (
184
183
  <Box flexDirection="column" flexGrow={1}>