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
@@ -104,6 +104,7 @@ export function CongressTradesPane({ focused, width, height, tickerFilter }: Pan
104
104
  const [lastLoadedAt, setLastLoadedAt] = useState<number | null>(null);
105
105
  const [loadingMore, setLoadingMore] = useState(false);
106
106
  const tradeScrollRef = useRef<ScrollBoxRenderable | null>(null);
107
+ const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
107
108
  const [savedTab, setActiveTab] = usePluginPaneState<CongressTab>(`${statePrefix}activeTab`, "trades");
108
109
  const activeTab = tickerFilter ? "trades" : savedTab;
109
110
  const [selectedTradeId, setSelectedTradeId] = useDebouncedPluginPaneState<string | null>(`${statePrefix}selectedTradeId`, null);
@@ -382,7 +383,7 @@ export function CongressTradesPane({ focused, width, height, tickerFilter }: Pan
382
383
  const detailContent = detailMode?.kind === "ticker" ? (
383
384
  <CongressTradesPane focused={focused} width={width} height={height - 3} paneId="congress-ticker" paneType="congress-trades" tickerFilter={detailMode.ticker} />
384
385
  ) : detailTrade ? (
385
- <TradeDetail trade={detailTrade} width={width} />
386
+ <TradeDetail trade={detailTrade} width={width} scrollRef={detailScrollRef} />
386
387
  ) : detailMember ? (
387
388
  <MemberTradesDetail
388
389
  focused={focused}
@@ -442,6 +443,7 @@ export function CongressTradesPane({ focused, width, height, tickerFilter }: Pan
442
443
  setDetailMode({ kind: "trade", tradeId: trade.id });
443
444
  }}
444
445
  onRootKeyDown={handleFiltersKey}
446
+ detailScrollRef={detailScrollRef}
445
447
  rootWidth={width}
446
448
  rootBefore={filterBar}
447
449
  resetScrollKey={`${filterKey}:${mine}`}
@@ -11,6 +11,7 @@ import {
11
11
  } from "../../../../components";
12
12
  import { StaticScatterChartSurface } from "../../../../components/chart/static";
13
13
  import { scalarPoint, staticSeries } from "../../../../components/chart/static/series";
14
+ import { usePaneRefreshKey } from "../../../../components/data-table/table-pane";
14
15
  import { useShortcut, type KeyEventLike } from "../../../../react/input";
15
16
  import { usePaneInstance } from "../../../../state/app/context";
16
17
  import { colors } from "../../../../theme/colors";
@@ -58,13 +59,12 @@ type RelationshipGraphShortcut =
58
59
  | "window"
59
60
  | "correlation"
60
61
  | "regression"
61
- | "refresh"
62
62
  | { range: RelationshipRange };
63
63
 
64
64
  /**
65
65
  * [t]ime range cycles and 1-6 pick a range, [p]eriod cycles the correlation
66
66
  * window, [c]orr and [f]it toggle their panels. The query bar shows all four,
67
- * so none needs a footer hint. `r` refreshes this pair's history.
67
+ * so none needs a footer hint. Plain `r` is the pane refresh key.
68
68
  */
69
69
  function resolveRelationshipGraphShortcut(
70
70
  event: Pick<KeyEventLike, "name" | "key" | "ctrl" | "shift" | "alt" | "meta" | "super">,
@@ -73,8 +73,6 @@ function resolveRelationshipGraphShortcut(
73
73
 
74
74
  const key = (event.name ?? event.key ?? "").toLowerCase();
75
75
  switch (key) {
76
- case "r":
77
- return "refresh";
78
76
  case "t":
79
77
  return "range";
80
78
  case "p":
@@ -232,6 +230,7 @@ export function RelationshipGraphPane({ focused, width, height }: PaneProps) {
232
230
  return true;
233
231
  }, [alignedDates]);
234
232
 
233
+ usePaneRefreshKey(() => { void reload(); }, { focused });
235
234
  useShortcut((event) => {
236
235
  if (!focused) return;
237
236
  if (isPlainKey(event, "home", "end") && jumpCursor(event.name)) {
@@ -248,9 +247,6 @@ export function RelationshipGraphPane({ focused, width, height }: PaneProps) {
248
247
  return;
249
248
  }
250
249
  switch (shortcut) {
251
- case "refresh":
252
- void reload();
253
- return;
254
250
  case "range":
255
251
  cycleRange();
256
252
  return;
@@ -1,7 +1,7 @@
1
1
  import { apiClient } from "../../../api-client";
2
2
  import type { CotBoardPayload, CotClass, CotClassSummary, CotContractPayload, CotFamily, CotPayloadBase } from "../../../api-client/cot";
3
3
  import { createPluginCache } from "../../../data/plugin-cache";
4
- import { loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
4
+ import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
5
5
  import type { PricePoint } from "../../../types/financials";
6
6
  import { COT_CLASSES, cotPriceMapping } from "./model";
7
7
 
@@ -67,10 +67,18 @@ export async function fetchCotBoard(family: CotFamily, traderClass: CotClass, cl
67
67
  try { return validateCotBoard(await client.getCloudCotBoard(family, traderClass), family, traderClass); }
68
68
  catch (error) { throw unavailable(error); }
69
69
  }
70
+ const cotBoardKey = (family: CotFamily, traderClass: CotClass) => `${family}:${traderClass}`;
71
+ const presentCotBoard = (payload: CotBoardPayload, stale: boolean, refreshError: string | null) => ({
72
+ ...payload, gaps: [...payload.gaps, ...(refreshError ? [refreshError] : [])],
73
+ status: stale && payload.status === "available" ? "partial" as const : payload.status,
74
+ });
75
+ export function cachedCotBoard(family: CotFamily, traderClass: CotClass) {
76
+ const cached = cachedCloudResource(cotBoardCache, cotBoardKey(family, traderClass), (payload) => validateCotBoard(payload, family, traderClass));
77
+ return cached ? presentCotBoard(cached.payload, false, null) : null;
78
+ }
70
79
  export async function loadCotBoard(family: CotFamily, traderClass: CotClass, force = false) {
71
- const { payload, stale, refreshError } = await loadCloudResource(cotBoardCache, `${family}:${traderClass}`, () => fetchCotBoard(family, traderClass), { force });
72
- return { ...payload, gaps: [...payload.gaps, ...(refreshError ? [refreshError] : [])],
73
- status: stale && payload.status === "available" ? "partial" as const : payload.status };
80
+ const { payload, stale, refreshError } = await loadCloudResource(cotBoardCache, cotBoardKey(family, traderClass), () => fetchCotBoard(family, traderClass), { force });
81
+ return presentCotBoard(payload, stale, refreshError);
74
82
  }
75
83
  export async function fetchCotContract(contractCode: string, family: CotFamily, client: Pick<typeof apiClient, "getCloudCotContract"> = apiClient) {
76
84
  try { return validateCotContract(await client.getCloudCotContract(contractCode, family), family, contractCode); }
@@ -12,7 +12,7 @@ import { Box } from "../../../ui";
12
12
  import { useAutoRefresh } from "../../../react/auto-refresh";
13
13
  import { compareSortValues, nextHeaderSort, type SortPreference } from "../../../utils/sort-values";
14
14
  import { useResearchCloudSession } from "../shared/research-cloud-session";
15
- import { loadCotBoard, loadCotDetail } from "./client";
15
+ import { cachedCotBoard, loadCotBoard, loadCotDetail } from "./client";
16
16
  import { COT_CLASSES, COT_MAJOR_CODES, COT_SCOPES, cotChartSeries, cotClass, cotInteger, cotLegendValue, cotMarketName, cotScope, type CotScope } from "./model";
17
17
 
18
18
  const FAMILIES = [{ value: "legacy", label: "Legacy" }, { value: "disaggregated", label: "Disaggregated" }];
@@ -69,7 +69,7 @@ function CotBoard({ width, height, focused, family, initialCode }: PaneProps & {
69
69
  const control = useRef<SelectControl>(null);
70
70
  const scopeControl = useRef<SelectControl>(null);
71
71
  const loader = useCallback((force: boolean) => loadCotBoard(family, traderClass, force), [family, traderClass, session.requestKey]);
72
- const resource = useAsyncResource(loader, { clearOnError: isAccessDenied });
72
+ const resource = useAsyncResource(loader, { initialData: () => cachedCotBoard(family, traderClass), clearOnError: isAccessDenied });
73
73
  const data = resource.data?.traderClass === traderClass ? resource.data : null;
74
74
  const rows = useMemo(() => {
75
75
  // A typed query searches every market; the scope only shapes the unsearched board.
@@ -105,9 +105,13 @@ function buildCryptoRow(
105
105
  : isFiniteNumber(asset.circulatingSupply) && asset.circulatingSupply > 0 ? asset.circulatingSupply * price : null;
106
106
  const start = asset.history ? Date.parse(`${asset.history.start}T00:00:00Z`) : 0;
107
107
  const closes = completedCloses(asset);
108
- const history: PricePoint[] = closes.length
109
- ? [...closes, { date: new Date(Math.max(now, start + closes.length * DAY_MS)), close: price }]
110
- : closes;
108
+ const prior = rowsByAsset.get(asset)?.row.history;
109
+ const tail = prior && prior.length === closes.length + 1 ? prior.at(-1) : undefined;
110
+ const history: PricePoint[] = !closes.length
111
+ ? closes
112
+ : tail && tail.close === price
113
+ ? prior!
114
+ : [...closes, { date: new Date(Math.max(now, start + closes.length * DAY_MS)), close: price }];
111
115
  const updatedAt = quote?.lastUpdated ?? (asset.quoteTime ? Date.parse(asset.quoteTime) : null);
112
116
  // The digits follow a price that holds still for the day, so a tick across a
113
117
  // power of ten (0.9998 to 1.0002) keeps the row's decimals.
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useMemo, type ReactNode } from "react";
2
2
  import { listingIdentity, useIssuerListing } from "../shared/ticker-request";
3
- import { Box, ScrollBox, Text, useUiCapabilities } from "../../../ui";
3
+ import { Box, ScrollBox, useUiCapabilities } from "../../../ui";
4
4
  import {
5
5
  useAsyncResource,
6
6
  useAutoRefresh,
@@ -405,9 +405,6 @@ export function DebtMaturitiesPane({ width, height, focused }: PaneProps) {
405
405
  label: "Principal total", values, value: debtAmount(last), color: colors.warning,
406
406
  };
407
407
  }, [bars]);
408
- const historyWindow = latest
409
- ? `${latest.totalPrincipal.percentile.windowStart} to ${latest.asOf}`
410
- : "";
411
408
  useAutoRefresh(resource.updatedAt, resource.load);
412
409
  usePaneRefreshKey(() => void resource.reload(), { focused });
413
410
  usePaneNoticeFooter({
@@ -598,8 +595,6 @@ export function DebtMaturitiesPane({ width, height, focused }: PaneProps) {
598
595
  xAxis={barAxis}
599
596
  formatValue={(value) => debtAmount(value)}
600
597
  formatAxisValue={formatCompactAxis}
601
- legendAccessory={<Text fg={colors.textMuted}>{historyWindow}</Text>}
602
- legendAccessoryWidth={historyWindow.length}
603
598
  remoteKind="debt-filing-history"
604
599
  {...historyLink}
605
600
  />
@@ -1,10 +1,11 @@
1
- import { Box, ScrollBox, Text, type ScrollBoxRenderable } from "../../../ui";
2
- import { useState, useEffect, useCallback, useMemo, useRef } from "react";
1
+ import { Box, Text, type ScrollBoxRenderable } from "../../../ui";
2
+ import { useState, useEffect, useCallback, useMemo, useRef, type RefObject } from "react";
3
3
  import { useShortcut } from "../../../react/input";
4
4
  import { TextAttributes } from "../../../ui";
5
5
  import type { AppNotificationRequest, GloomPlugin, PaneProps } from "../../../types/plugin";
6
6
  import {
7
7
  DataTableStackView,
8
+ DetailScrollBody,
8
9
  isTableScrollNearEnd,
9
10
  QueryBar,
10
11
  usePaneFooter,
@@ -89,18 +90,16 @@ function renderDebugCell(entry: LogEntry, column: DebugColumn, _index: number, s
89
90
  return { text: entry.message.replace(/\s+/g, " "), color: state.selected ? colors.selectedText : colors.text };
90
91
  }
91
92
 
92
- function DebugEntryDetail({ entry }: { entry: LogEntry }) {
93
+ function DebugEntryDetail({ entry, scrollRef }: { entry: LogEntry; scrollRef: RefObject<ScrollBoxRenderable | null> }) {
93
94
  return (
94
- <ScrollBox flexGrow={1} scrollY focusable={false}>
95
- <Box flexDirection="column" paddingX={1}>
96
- <Text fg={colors.text} wrapText selectable>{entry.message}</Text>
97
- {entry.data !== undefined && (
98
- <Box marginTop={1}>
99
- <Text fg={colors.textDim} wrapText selectable>{JSON.stringify(entry.data, null, 2)}</Text>
100
- </Box>
101
- )}
102
- </Box>
103
- </ScrollBox>
95
+ <DetailScrollBody ref={scrollRef} resetScrollKey={entry.id}>
96
+ <Text fg={colors.text} wrapText selectable>{entry.message}</Text>
97
+ {entry.data !== undefined ? (
98
+ <Box marginTop={1}>
99
+ <Text fg={colors.textDim} wrapText selectable>{JSON.stringify(entry.data, null, 2)}</Text>
100
+ </Box>
101
+ ) : null}
102
+ </DetailScrollBody>
104
103
  );
105
104
  }
106
105
 
@@ -116,6 +115,7 @@ function DebugPane({ focused, width, height }: PaneProps) {
116
115
  const levelControl = useRef<SelectControl>(null);
117
116
  const sourceControl = useRef<SelectControl>(null);
118
117
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
118
+ const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
119
119
  // While on, the cursor rides the newest entry, which keeps the list pinned
120
120
  // to the bottom as entries arrive.
121
121
  const [autoScroll, setAutoScroll] = useState(true);
@@ -173,7 +173,6 @@ function DebugPane({ focused, width, height }: PaneProps) {
173
173
  // chord never clears the log.
174
174
  useShortcut((event) => {
175
175
  if (!focused || openId || searching || event.targetEditable || event.ctrl || event.meta || event.alt || event.super) return;
176
- if (event.name === "l") { event.preventDefault?.(); levelControl.current?.open(); return; }
177
176
  if (event.name === "s") { event.preventDefault?.(); sourceControl.current?.open(); return; }
178
177
  // Top and end of the log; G also resumes following new entries. Some
179
178
  // terminals report Shift+G as an uppercase name.
@@ -268,8 +267,9 @@ function DebugPane({ focused, width, height }: PaneProps) {
268
267
  emptyStateTitle={filtered ? "No log entries match the filter." : "No log entries."}
269
268
  detailOpen={!!openEntry}
270
269
  onBack={() => setOpenId(null)}
270
+ detailScrollRef={detailScrollRef}
271
271
  detailTitle={openEntry ? `${formatTimestamp(openEntry.timestamp)} ${LEVEL_LABELS[openEntry.level]} ${openEntry.source}` : undefined}
272
- detailContent={openEntry ? <DebugEntryDetail entry={openEntry} /> : null}
272
+ detailContent={openEntry ? <DebugEntryDetail entry={openEntry} scrollRef={detailScrollRef} /> : null}
273
273
  />
274
274
  </Box>
275
275
  );
@@ -8,6 +8,8 @@ import { inferCadence, trailingCashAt } from "./trailing-cash";
8
8
  import { dividendQuotePriceMetadata } from "./reference-price";
9
9
  import { mapQuote } from "../../../sources/gloomberb-cloud/normalizers";
10
10
  import { regularSessionValuationPrice } from "../../../market-data/quotes/regular-session-valuation";
11
+ import { declaredDividends } from "../../../utils/declared-dividends";
12
+ import type { DeclaredDividendAction } from "../../../types/financials";
11
13
 
12
14
  export const DIVIDENDS_CONNECTION_ID = "gloom-dividends";
13
15
  export const INCOMPLETE_DIVIDEND_HISTORY = "Incomplete cash history; totals unavailable.";
@@ -55,6 +57,8 @@ export function toDividendPayment(
55
57
 
56
58
  export interface DividendData {
57
59
  payments: DividendPayment[];
60
+ /** Declared and unpaid, in `currency`; never in `payments` or any figure. Absent from older servers. */
61
+ declared?: DeclaredDividendAction[];
58
62
  metrics: DividendMetrics;
59
63
  price: number | null;
60
64
  currency?: string;
@@ -113,8 +117,11 @@ export async function fetchDividendData(
113
117
  if (!historyAvailable && !historyError && metrics.trailingRate == null && metrics.forwardRate == null) {
114
118
  throw new Error(`No dividend data found for ${symbol}`);
115
119
  }
120
+ // Stated in the cash history's units, read as its payments are.
121
+ const declared = actions && cashUnit.currency
122
+ ? declaredDividends(actions).map((row) => ({ ...row, amount: row.amount / cashUnit.divisor })) : [];
116
123
  return {
117
- payments, metrics, price, currency: currency || undefined, historyAvailable,
124
+ payments, ...(declared.length > 0 ? { declared } : {}), metrics, price, currency: currency || undefined, historyAvailable,
118
125
  ...(suppliedPrice == null && price != null && quote ? dividendQuotePriceMetadata(quote) : {}),
119
126
  ...(historyError ? { historyError } : {}), ...(summaryError ? { summaryError } : {}),
120
127
  providerId: actions?.providerId ?? "gloom", fetchedAt: actions?.fetchedAt,
@@ -101,7 +101,8 @@ export function projectDividendYieldHeadless(
101
101
  : "Reference price time unavailable; cash yield may be out of date." }] : []),
102
102
  { label: "Trailing yield", value: metrics.trailingYield, formatted: formatDividendYield(metrics.trailingYield) },
103
103
  { label: "Forward yield", value: metrics.forwardYield, formatted: metrics.forwardYield == null ? formatDividendYield(null)
104
- : `${formatDividendYield(metrics.forwardYield)} (${forwardDividendBasis(metrics.forwardRateAsOf, { payDate: forwardPayDate(metrics) })})` },
104
+ : `${formatDividendYield(metrics.forwardYield)} (${forwardDividendBasis(metrics.forwardRateAsOf,
105
+ { payDate: forwardPayDate(metrics), declared: data.declared, currency })})` },
105
106
  { label: "Trailing rate", value: metrics.trailingRate, formatted: currency ? formatDistributionAmount(metrics.trailingRate ?? undefined, currency) : "—" },
106
107
  { label: "Forward rate", value: metrics.forwardRate, formatted: currency ? formatDistributionAmount(metrics.forwardRate ?? undefined, currency) : "—" },
107
108
  { label: "Earnings Payout", value: metrics.payoutRatio, formatted: formatDividendYield(metrics.payoutRatio) },
@@ -138,6 +139,8 @@ export function projectDividendYieldHeadless(
138
139
  limitations: data.notes ?? [],
139
140
  yieldMethod: "Cash distributions with ex-dates in the preceding 12 months, one payment per period for a regular cadence, divided by the reference share price; excludes reinvestment and is not SEC yield or total return.",
140
141
  forwardRateAsOf: metrics.forwardRateAsOf,
142
+ // Declared and unpaid, apart from the paid history the table and every figure read.
143
+ declaredDividends: data.declared ?? [],
141
144
  forwardRateMethod: "Indicated annual cash rate, the one fundamentals use: the latest regular distribution annualized, or once the next one is declared that one, dated by its ex-date (forwardRateAsOf; a date still ahead is the declared distribution's). Only when its currency units are comparable with the share price.",
142
145
  paymentFrequencyMethod: "Cadence inferred from ex-dates in the last two years, unavailable when no cash was reported in the last year; not an announced payment schedule.",
143
146
  },
@@ -73,14 +73,15 @@ function formatFrequency(freq: DividendMetrics["paymentFrequency"]): string {
73
73
  * clip them. The latest ex-date is the table's first row, so it shows only
74
74
  * when there is no table.
75
75
  */
76
- function buildMetricItems(metrics: DividendMetrics, currency: string, hasHistory: boolean, short: boolean): StatItem[] {
76
+ function buildMetricItems(metrics: DividendMetrics, currency: string, hasHistory: boolean, short: boolean,
77
+ declared: DividendData["declared"]): StatItem[] {
77
78
  const exDate: StatItem[] = metrics.nextExDividendDate
78
79
  ? [{ id: "next-ex", label: "Next ex", value: formatDate(metrics.nextExDividendDate) }]
79
80
  : hasHistory ? [] : [{ id: "last-ex", label: "Last ex", value: formatDate(metrics.lastExDividendDate) }];
80
- const forwardNote = metrics.forwardYield != null ? forwardDividendNote(metrics.forwardRateAsOf) : null;
81
+ const forwardNote = metrics.forwardYield != null ? forwardDividendNote(metrics.forwardRateAsOf, { declared, currency }) : null;
81
82
  return [
82
83
  { id: "ttm-yield", label: short ? "TTM yld" : "TTM yield", value: formatDividendYield(metrics.trailingYield), color: priceColor(metrics.trailingYield ?? 0) },
83
- // Dated by the distribution it annualizes, or marked declared beside the next ex-date; docs/research-data.md says how it is built.
84
+ // Dated by the distribution it annualizes, or marked declared (with its stated amount) beside the next ex-date; docs/research-data.md says how it is built.
84
85
  { id: "forward-yield", label: short ? "Fwd yld" : "Fwd yield", value: formatDividendYield(metrics.forwardYield), color: priceColor(metrics.forwardYield ?? 0),
85
86
  ...(forwardNote ? { detail: forwardNote } : {}) },
86
87
  { id: "ttm-rate", label: short ? "TTM/sh" : "TTM/share", value: formatRate(metrics.trailingRate, currency) },
@@ -225,13 +226,13 @@ export function DividendYieldPane({ focused, width, height, loadData = fetchDivi
225
226
  });
226
227
  // The header row, plus the scrollbar row once the columns overflow the pane.
227
228
  const tableChromeRows = chartTableChromeRows(columns, width);
228
- const fullFigures = metrics ? buildMetricItems(metrics, currency, sortedRows.length > 0, false) : [];
229
+ const fullFigures = metrics ? buildMetricItems(metrics, currency, sortedRows.length > 0, false, data?.declared) : [];
229
230
  const keptFigures = chartTableLayout({
230
231
  width, height, figures: fullFigures, tableRows: sortedRows.length, tableChromeRows, chart: chartPoints.length >= 2 ? {} : null,
231
232
  }).figures;
232
233
  // The desktop grid never clips a label; the terminal shortens them rather than clip.
233
234
  const figures = metrics && !nativePaneChrome && labelsClip(keptFigures, width)
234
- ? buildMetricItems(metrics, currency, sortedRows.length > 0, true) : fullFigures;
235
+ ? buildMetricItems(metrics, currency, sortedRows.length > 0, true, data?.declared) : fullFigures;
235
236
 
236
237
  const handleHeaderClick = useCallback((columnId: string) => {
237
238
  setSortPreference((current) => nextHeaderSort(current, columnId as DividendColumnId, {
@@ -14,10 +14,10 @@ const DOCS_INSTANCE_ID = "docs";
14
14
 
15
15
  function requestedTopic(context: PaneTemplateContext, options?: PaneTemplateCreateOptions): DocsTopic {
16
16
  const registry = getSharedRegistry();
17
- if (!registry) return { kind: "home" };
18
- const index = buildRegistryHelpIndex(registry);
17
+ // The command line (`gloomberb fn DOCS RV`) has no registry: the argument goes to the reader as a search.
18
+ const index = registry ? buildRegistryHelpIndex(registry) : null;
19
19
  const paneId = options?.values?.pane;
20
- if (paneId) {
20
+ if (paneId && registry && index) {
21
21
  const instance = findPaneInstance(context.layout, paneId);
22
22
  return (instance ? docsTopicForPane(index, registry, instance) : null) ?? { kind: "home" };
23
23
  }
@@ -40,6 +40,8 @@ export const docsModule: PluginModule = {
40
40
  icon: "?",
41
41
  component: DocsPane,
42
42
  reportFreshness: { status: "not-a-feed", basis: "documentation" },
43
+ // A page reads whole in a report, not cut at the first screen.
44
+ shotFitsReader: true,
43
45
  defaultPosition: "right",
44
46
  defaultMode: "floating",
45
47
  defaultFloatingSize: { width: 96, height: 34 },
@@ -30,6 +30,7 @@ import {
30
30
  type PaneHint,
31
31
  type SectionedRow,
32
32
  } from "../../../components";
33
+ import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
33
34
  import type { DocsFeed, DocsFunction } from "../../../docs/content";
34
35
  import {
35
36
  docsEntries,
@@ -48,6 +49,7 @@ import {
48
49
  type DocsTopic,
49
50
  } from "../../../docs/topics";
50
51
  import { t, tf } from "../../../i18n";
52
+ import { useRemoteUiNode } from "../../../remote/semantic-tree";
51
53
  import { usePaneInstance } from "../../../state/app/context";
52
54
  import { useThemeColors } from "../../../theme/theme-context";
53
55
  import type { PaneProps } from "../../../types/plugin";
@@ -257,7 +259,8 @@ export function DocsPane({ focused, width, height }: PaneProps) {
257
259
  const instance = usePaneInstance();
258
260
  const requestKey = instance?.params?.topic ?? null;
259
261
  const requestAt = instance?.params?.at ?? null;
260
- const { feed, offline } = useDocsFeed();
262
+ const { feed, offline, reload } = useDocsFeed();
263
+ usePaneRefreshKey(reload, { focused });
261
264
  const [stack, setStack] = usePluginPaneState<string[]>("stack", []);
262
265
  const [query, setQuery] = usePluginPaneState<string>("query", "");
263
266
  const [show, setShow] = usePluginPaneState<DocsShow>("show", "all");
@@ -287,6 +290,12 @@ export function DocsPane({ focused, width, height }: PaneProps) {
287
290
  openTopic({ kind: "page", slug: topic.query.toLowerCase() });
288
291
  return;
289
292
  }
293
+ // The command line cannot resolve a mnemonic before the reader runs, so a search that is a function's code opens its entry.
294
+ const code = topic.kind === "home" && topic.query ? findDocsFunction(feed, topic.query.trim()) ?? findDocsFunction(feed, topic.query.trim().split(/\s+/)[0] ?? "") : null;
295
+ if (code) {
296
+ openTopic({ kind: "function", code: code.code });
297
+ return;
298
+ }
290
299
  if (topic.kind === "home") setShow("all");
291
300
  openTopic(topic);
292
301
  }, [feed, handledAt, openTopic, requestAt, requestKey, setHandledAt, setShow]);
@@ -327,6 +336,14 @@ export function DocsPane({ focused, width, height }: PaneProps) {
327
336
  }, [entries, query, show]);
328
337
 
329
338
  const visibleEntries = useMemo(() => rows.filter(isSectionedItemRow).map((row) => row.item), [rows]);
339
+ // A request that matches nothing fails a report of the pane (`gloomberb fn DOCS zzzz`) instead of listing the whole index.
340
+ const requested = requestKey ? parseDocsTopicKey(requestKey) : null;
341
+ const requestedQuery = requested?.kind === "home" ? requested.query?.trim() ?? "" : "";
342
+ useRemoteUiNode(feed && requestedQuery && stack.length === 0 && query.trim() === requestedQuery && visibleEntries.length === 0 ? {
343
+ role: "report-failure",
344
+ label: "No docs entry",
345
+ getMetadata: () => ({ message: `No docs entry for ${requestedQuery}.`, details: "Run gloomberb fn DOCS for the index." }),
346
+ } : null);
330
347
  const effectiveSelectedKey = visibleEntries.some((entry) => entry.key === selectedKey)
331
348
  ? selectedKey
332
349
  : visibleEntries[0]?.key ?? null;
@@ -60,12 +60,14 @@ export function docsTopicForPane(
60
60
  /**
61
61
  * What `DOCS <argument>` asks for: a mnemonic or one of its aliases
62
62
  * (`DOCS OMON`, `DOCS ct`), with or without its own argument (`DOCS OMON
63
- * NVDA`), or words to search the docs for.
63
+ * NVDA`), or words to search the docs for. Without an index every argument
64
+ * stays a search, which the reader opens on a matching entry.
64
65
  */
65
- export function docsTopicForArgument(index: HelpFunctionIndex, argument: string): DocsTopic {
66
+ export function docsTopicForArgument(index: HelpFunctionIndex | null, argument: string): DocsTopic {
66
67
  const query = argument.trim();
67
68
  if (!query) return { kind: "home" };
68
- const fn = index.get(query.toUpperCase()) ?? index.get((query.split(/\s+/)[0] ?? "").toUpperCase());
69
+ // A command line with no registry (`gloomberb fn DOCS RV`) cannot resolve a mnemonic here: the reader does, from its own entries.
70
+ const fn = index?.get(query.toUpperCase()) ?? index?.get((query.split(/\s+/)[0] ?? "").toUpperCase());
69
71
  if (fn) return docsTopicForFunction(fn);
70
72
  return { kind: "home", query };
71
73
  }
@@ -216,6 +216,28 @@ export function loadEarningsCalls(
216
216
  return loadEarningsCallsWithClient(apiClient, ticker, options);
217
217
  }
218
218
 
219
+ export function cachedEarningsCalls(
220
+ ticker: string | null,
221
+ options?: IssuerListingParams & { limit?: number },
222
+ ): EarningsCallsResult | null {
223
+ const normalizedTicker = ticker?.trim().toUpperCase() || null;
224
+ const abroad = normalizedTicker ? listingAbroad(normalizedTicker, options?.exchange, options?.name) : null;
225
+ const limit = listLimit(options?.limit);
226
+ const cached = persistence?.getResource<CloudEarningsCallListPayload>(LIST_KIND, listKey(normalizedTicker, abroad, limit, 0), {
227
+ sourceKey: CACHE_SOURCE,
228
+ schemaVersion: LIST_CACHE_SCHEMA_VERSION,
229
+ allowExpired: true,
230
+ });
231
+ if (!cached) return null;
232
+ return {
233
+ ...cached.value,
234
+ sourceLimit: limit,
235
+ sourceLimitReached: cached.value.calls.length >= limit,
236
+ fetchedAt: cached.fetchedAt,
237
+ stale: cached.stale === true,
238
+ };
239
+ }
240
+
219
241
  /**
220
242
  * The server answers 202 with a pending marker when a call is known but not
221
243
  * transcribed yet, having just queued it. That is not a transcript and must
@@ -35,6 +35,7 @@ import { usePlanAccess } from "../../../api-client/plan-access";
35
35
  import { listingAbroad } from "../../../api-client/paths";
36
36
  import { useBoundTicker as useSymbolBinding } from "../shared/ticker-request";
37
37
  import {
38
+ cachedEarningsCalls,
38
39
  callStatusLabel,
39
40
  isPendingTranscript,
40
41
  loadEarningsCalls,
@@ -179,19 +180,27 @@ interface EarningsCallsViewProps {
179
180
  export function EarningsCallsPane({ focused, width, height }: EarningsCallsViewProps) {
180
181
  const { symbol, exchange, ticker: boundTicker } = useSymbolBinding();
181
182
  const access = usePlanAccess();
183
+ // Read once per mount: a binding change reads the cache again in its own effect.
184
+ const [initialShelf] = useState(() => {
185
+ const initialTicker = symbol ? symbol.toUpperCase() : null;
186
+ const initialAbroad = initialTicker ? listingAbroad(initialTicker, exchange, boundTicker?.metadata.name) : null;
187
+ return cachedEarningsCalls(initialTicker, {
188
+ limit: CALL_PAGE_SIZE, exchange: initialAbroad?.exchange, name: initialAbroad?.name,
189
+ });
190
+ });
182
191
 
183
- const [calls, setCalls] = useState<CloudEarningsCallPayload[]>([]);
184
- const [listStatus, setListStatus] = useState<"idle" | "loading" | "loaded" | "error">("idle");
192
+ const [calls, setCalls] = useState<CloudEarningsCallPayload[]>(initialShelf?.calls ?? []);
193
+ const [listStatus, setListStatus] = useState<"idle" | "loading" | "loaded" | "error">(initialShelf ? "loaded" : "idle");
185
194
  const [listError, setListError] = useState<{ message: string; status?: number } | null>(null);
186
- const [stale, setStale] = useState(false);
187
- const [listFetchedAt, setListFetchedAt] = useState(0);
195
+ const [stale, setStale] = useState(initialShelf?.stale === true);
196
+ const [listFetchedAt, setListFetchedAt] = useState(initialShelf?.fetchedAt ?? 0);
188
197
  const listRequestVersion = useRef(0);
189
198
  const [listCompletedRequest, setListCompletedRequest] = useState(0);
190
199
  // The shelf is paged: the server answers a page at a time and reports no
191
200
  // total, so a full page means there is probably another one behind it.
192
- const [hasMore, setHasMore] = useState(false);
201
+ const [hasMore, setHasMore] = useState(initialShelf?.sourceLimitReached === true);
193
202
  const [loadingMore, setLoadingMore] = useState(false);
194
- const [nextOffset, setNextOffset] = useState(0);
203
+ const [nextOffset, setNextOffset] = useState(initialShelf?.calls.length ?? 0);
195
204
  const tableScrollRef = useRef<ScrollBoxRenderable | null>(null);
196
205
 
197
206
  const [selectedId, setSelectedId] = usePluginPaneState<string | null>("selectedId", null);
@@ -224,7 +233,7 @@ export function EarningsCallsPane({ focused, width, height }: EarningsCallsViewP
224
233
  // Set while a call opened without a transcript is being produced.
225
234
  const [producing, setProducing] = useState(false);
226
235
  // The list answered "pending": the server is still searching for calls.
227
- const [listPending, setListPending] = useState(false);
236
+ const [listPending, setListPending] = useState(initialShelf?.pending === true && initialShelf.calls.length === 0);
228
237
 
229
238
  const ticker = symbol ? symbol.toUpperCase() : null;
230
239
  // A listing abroad asks for its own calls by venue and company, never a US namesake's.
@@ -307,14 +316,15 @@ export function EarningsCallsPane({ focused, width, height }: EarningsCallsViewP
307
316
  useEffect(() => {
308
317
  const keepRestoredCall = firstShelfLoadRef.current;
309
318
  firstShelfLoadRef.current = false;
310
- setCalls([]);
311
- setHasMore(false);
319
+ const held = cachedEarningsCalls(ticker, { limit: CALL_PAGE_SIZE, exchange: listingVenue, name: listingName });
320
+ setCalls(held?.calls ?? []);
321
+ setHasMore(held?.sourceLimitReached === true);
312
322
  setLoadingMore(false);
313
- setNextOffset(0);
314
- setListStatus("idle");
315
- setListPending(false);
316
- setStale(false);
317
- setListFetchedAt(0);
323
+ setNextOffset(held?.calls.length ?? 0);
324
+ setListStatus(held ? "loaded" : "idle");
325
+ setListPending(held?.pending === true && held.calls.length === 0);
326
+ setStale(held?.stale === true);
327
+ setListFetchedAt(held?.fetchedAt ?? 0);
318
328
  setListError(null);
319
329
  setLookup(null);
320
330
  setLookupRefresh(null);
@@ -10,7 +10,7 @@ import { buildQuoteKey } from "../../../market-data/selectors";
10
10
  import type { Quote } from "../../../types/financials";
11
11
  import { formatCurrency, formatPercentRaw } from "../../../utils/format";
12
12
  import { fetchScreen } from "./client";
13
- import { overlayScreenQuotes, screenExtendedSessions } from "./live";
13
+ import { overlayScreenQuotes, screenExtendedSessions, sortLiveScreenRows } from "./live";
14
14
  import {
15
15
  DEFAULT_SCREEN,
16
16
  EXAMPLE_SCREEN_DEFINITION,
@@ -125,7 +125,8 @@ export const equityScreenerHeadless: HeadlessPaneDefinition<"bundle"> = {
125
125
  const metric = (args.options.metric ?? "marketCap") as NumericField;
126
126
  // Last, Chg% and market cap are the regular session, as `quote` and the
127
127
  // pane read each row's quote; an extended print is a column of its own.
128
- const rows = overlayScreenQuotes(data.rows, await loadRowQuotes(data.rows, ctx));
128
+ // A sort on one of them is redone on those figures.
129
+ const rows = sortLiveScreenRows(overlayScreenQuotes(data.rows, await loadRowQuotes(data.rows, ctx)), data.definition.sort);
129
130
  const extendedSessions = screenExtendedSessions(rows);
130
131
  // Payload status also counts ordinary per-field sparsity; only generation
131
132
  // warnings are failures, and more pages are not an error.
@@ -184,7 +185,7 @@ export const equityScreenerHeadless: HeadlessPaneDefinition<"bundle"> = {
184
185
  covered: data.universe.covered,
185
186
  returned: data.rows.length,
186
187
  hasMore: !!data.nextCursor,
187
- priceBasis: "price, changePercent and the market cap are the regular session, read from each row's quote as `quote` reads it: after the close, its close and that close's move. A pre-market, after-hours or overnight print is in extendedPrice and extendedChangePercent, measured from that close. Membership, order and percentiles are the snapshot's, which ranks by the close.",
188
+ priceBasis: "price, changePercent and the market cap are the regular session, read from each row's quote as `quote` reads it: after the close, its close and that close's move. A pre-market, after-hours or overnight print is in extendedPrice and extendedChangePercent, measured from that close. Membership and percentiles are the snapshot's, which ranks by the close; a sort on price, changePercent, volume or marketCap orders these rows by the figures shown.",
188
189
  notices: data.nextCursor
189
190
  ? [
190
191
  `Top ${data.rows.length} of ${data.universe.matched} matches; narrow the criteria to see the rest.`,
@@ -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));