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
@@ -7,6 +7,7 @@ import type { InlineTickerCatalogEntry } from "../state/hooks/inline-tickers";
7
7
  import { colors } from "../theme/colors";
8
8
  import { displayWidth } from "../utils/format";
9
9
  import { useUiCapabilities } from "../ui";
10
+ import { MARKDOWN_HEADING_RE, markdownListItem, parseInlineMarkdown, type MarkdownSegment } from "../utils/markdown";
10
11
 
11
12
  export interface MarkdownTextProps {
12
13
  text: string;
@@ -30,15 +31,9 @@ export interface MarkdownTextProps {
30
31
  onOpenLink?: (href: string) => void;
31
32
  }
32
33
 
33
- interface StyledSegment {
34
- text: string;
35
- bold?: boolean;
36
- italic?: boolean;
37
- dim?: boolean;
38
- code?: boolean;
34
+ /** A segment as drawn here: a heading or a table header has its own colour. */
35
+ interface StyledSegment extends MarkdownSegment {
39
36
  color?: string;
40
- /** Where the segment links to, when links are read. */
41
- link?: string;
42
37
  }
43
38
 
44
39
  interface ParsedLine {
@@ -47,52 +42,12 @@ interface ParsedLine {
47
42
  indent?: number;
48
43
  }
49
44
 
50
- // A backslash escapes the punctuation markdown would read ("\\*" is a star), and
51
- // an italic span may hold a bold one ("*covers **96%** of value*").
52
- const INLINE_RE =
53
- /(\\(?<escaped>[\\`*_~#>[\]()-])|\[(?<linkLabel>[^\]]+)\]\((?<linkHref>[^)\s]+)\)|\*\*(?<bold>[^*]+)\*\*|(?<!\*)\*(?!\*)(?<italic>(?:[^*]|\*\*[^*]+\*\*)+?)\*(?!\*)|`(?<code>[^`]+)`|~~(?<strike>[^~]+)~~)/g;
54
-
55
- function parseInlineMarkdown(text: string, links = false): StyledSegment[] {
56
- const segments: StyledSegment[] = [];
57
- let cursor = 0;
58
-
59
- const pattern = new RegExp(INLINE_RE.source, "g");
60
- let match: RegExpExecArray | null;
61
- while ((match = pattern.exec(text)) !== null) {
62
- if (match.groups?.linkHref != null && !links) {
63
- // Not reading links: the bracket is text, and what is inside it still styles.
64
- pattern.lastIndex = match.index + 1;
65
- continue;
66
- }
67
- if (match.index > cursor) {
68
- segments.push({ text: text.slice(cursor, match.index) });
69
- }
70
- if (match.groups?.escaped != null) {
71
- segments.push({ text: match.groups.escaped });
72
- } else if (match.groups?.linkHref != null) {
73
- for (const inner of parseInlineMarkdown(match.groups.linkLabel ?? "", false)) segments.push({ ...inner, link: match.groups.linkHref });
74
- } else if (match.groups?.bold != null) {
75
- segments.push({ text: match.groups.bold, bold: true });
76
- } else if (match.groups?.italic != null) {
77
- for (const inner of parseInlineMarkdown(match.groups.italic, links)) segments.push({ ...inner, italic: true });
78
- } else if (match.groups?.code != null) {
79
- segments.push({ text: match.groups.code, code: true });
80
- } else if (match.groups?.strike != null) {
81
- segments.push({ text: match.groups.strike, dim: true });
82
- }
83
- cursor = match.index + match[0].length;
84
- }
85
- if (cursor < text.length) {
86
- segments.push({ text: text.slice(cursor) });
87
- }
88
- return segments;
45
+ function parseInline(text: string, links = false): StyledSegment[] {
46
+ return parseInlineMarkdown(text, { links });
89
47
  }
90
48
 
91
- const HEADING_RE = /^(#{1,3})\s+(.+)$/;
92
-
93
49
  function parseLine(line: string, links: boolean): ParsedLine {
94
- // Headings
95
- const headingMatch = line.match(HEADING_RE);
50
+ const headingMatch = line.match(MARKDOWN_HEADING_RE);
96
51
  if (headingMatch) {
97
52
  return {
98
53
  heading: true,
@@ -100,22 +55,18 @@ function parseLine(line: string, links: boolean): ParsedLine {
100
55
  };
101
56
  }
102
57
 
103
- // List items
104
- const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s(.*)$/);
105
- if (listMatch) {
106
- const indent = listMatch[1]!.length;
107
- const marker = listMatch[2]!;
108
- const content = listMatch[3]!;
58
+ const item = markdownListItem(line);
59
+ if (item) {
109
60
  return {
110
- indent,
61
+ indent: item.indent,
111
62
  segments: [
112
- { text: `${marker} `, bold: true, color: colors.borderFocused },
113
- ...parseInlineMarkdown(content, links),
63
+ { text: `${item.marker} `, bold: true, color: colors.borderFocused },
64
+ ...parseInline(item.text, links),
114
65
  ],
115
66
  };
116
67
  }
117
68
 
118
- return { segments: parseInlineMarkdown(line, links) };
69
+ return { segments: parseInline(line, links) };
119
70
  }
120
71
 
121
72
  type TableAlign = "left" | "center" | "right";
@@ -176,7 +127,7 @@ function columnAlign(table: MarkdownTable, column: number): TableAlign {
176
127
  const explicit = table.align[column];
177
128
  if (explicit) return explicit;
178
129
  const values = table.rows
179
- .map((row) => parseInlineMarkdown(row[column] ?? "").map((segment) => segment.text).join("").trim())
130
+ .map((row) => parseInline(row[column] ?? "").map((segment) => segment.text).join("").trim())
180
131
  .filter((value) => value !== "" && value !== "-" && value !== "\u2014");
181
132
  return values.length > 0 && values.every((value) => NUMERIC_CELL_RE.test(value)) ? "right" : "left";
182
133
  }
@@ -214,6 +165,28 @@ function parseMarkdownBlocks(text: string): MarkdownBlock[] {
214
165
  return blocks;
215
166
  }
216
167
 
168
+ /**
169
+ * The links a text reads as, in reading order, for a reader's pane menu: the
170
+ * terminal cannot press part of a line.
171
+ */
172
+ export function markdownLinks(text: string): Array<{ label: string; href: string }> {
173
+ const links: Array<{ label: string; href: string }> = [];
174
+ // A label with bold in it is several segments of one link.
175
+ const read = (segments: StyledSegment[]) => {
176
+ let previous: string | undefined;
177
+ for (const { text: part, link } of segments) {
178
+ if (link && link === previous) links[links.length - 1]!.label += part;
179
+ else if (link) links.push({ label: part, href: link });
180
+ previous = link;
181
+ }
182
+ };
183
+ for (const block of parseMarkdownBlocks(text)) {
184
+ if (block.kind === "line") read(parseLine(block.text, true).segments);
185
+ else for (const cell of [block.table.header, ...block.table.rows].flat()) read(parseInline(cell, true));
186
+ }
187
+ return links;
188
+ }
189
+
217
190
  function segmentsWidth(segments: StyledSegment[]): number {
218
191
  return segments.reduce((total, segment) => total + displayWidth(segment.text), 0);
219
192
  }
@@ -374,7 +347,7 @@ function layoutTableRows(
374
347
  }
375
348
 
376
349
  function headerCell(text: string, links: boolean): StyledSegment[] {
377
- return parseInlineMarkdown(text, links).map((segment) => ({ ...segment, bold: true, color: colors.textDim }));
350
+ return parseInline(text, links).map((segment) => ({ ...segment, bold: true, color: colors.textDim }));
378
351
  }
379
352
 
380
353
  /**
@@ -384,7 +357,7 @@ function headerCell(text: string, links: boolean): StyledSegment[] {
384
357
  */
385
358
  function layoutMarkdownTable(table: MarkdownTable, lineWidth?: number, links = false): TableLine[] {
386
359
  const header = table.header.map((cell) => headerCell(cell, links));
387
- const body = table.rows.map((row) => row.map((cell) => parseInlineMarkdown(cell, links)));
360
+ const body = table.rows.map((row) => row.map((cell) => parseInline(cell, links)));
388
361
  const align = table.header.map((_, column) => columnAlign(table, column));
389
362
  const widths = tableColumnWidths([header, ...body], lineWidth);
390
363
  if (widths) return layoutTableRows([header, ...body], widths, align);
@@ -460,7 +433,8 @@ function SegmentSpan({ segment, wrap = false, onOpenLink }: {
460
433
  const attrs =
461
434
  (segment.bold ? TextAttributes.BOLD : 0) |
462
435
  (segment.italic ? TextAttributes.ITALIC : 0) |
463
- (segment.dim ? TextAttributes.DIM : 0) |
436
+ // Struck text reads dim here.
437
+ (segment.strike ? TextAttributes.DIM : 0) |
464
438
  (segment.link ? TextAttributes.UNDERLINE : 0);
465
439
  const href = segment.link;
466
440
  // The terminal cannot press part of a line; there the pane menu lists links.
@@ -627,7 +601,7 @@ export function MarkdownText({
627
601
  if (line.trim() === "") {
628
602
  // Under a heading set in its own style, the heading's margin is the space: it sits with what it names.
629
603
  const previous = blocks[index - 1];
630
- if (nativePaneChrome && headingStyle && previous?.kind === "line" && HEADING_RE.test(previous.text)) return null;
604
+ if (nativePaneChrome && headingStyle && previous?.kind === "line" && MARKDOWN_HEADING_RE.test(previous.text)) return null;
631
605
  return nativePaneChrome && paragraphGap
632
606
  ? <Box key={index} flexShrink={0} style={{ height: paragraphGap }} />
633
607
  : <Text key={index}>{" "}</Text>;
@@ -84,13 +84,15 @@ export interface MarketBoardStackProps<T extends MarketBoardRow> {
84
84
  const FIT_STEPS = ["labelDetail", "history", "shortAsOf", "changeAsOf", "percentile"] as const;
85
85
  /** An as-of date without its year ("09-17"). */
86
86
  const SHORT_AS_OF_WIDTH = 5;
87
+ /** The word a stale row's date carries, with its space: "2026-09-17 stale". */
88
+ const STALE_WORD = " stale";
87
89
 
88
90
  function shortIsoDate(text: string): string {
89
91
  return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text.slice(5) : text;
90
92
  }
91
93
 
92
94
  /** Fit the board to the pane rather than cut its right edge mid-value. */
93
- function fitBoardColumns(candidates: DataTableColumn[], width: number): { columns: DataTableColumn[]; shortAsOf: boolean } {
95
+ function fitBoardColumns(candidates: DataTableColumn[], width: number, wordWidth: number): { columns: DataTableColumn[]; shortAsOf: boolean } {
94
96
  const withAsOfWidth = (list: DataTableColumn[], asOfWidth: number | null) => asOfWidth === null
95
97
  ? list
96
98
  : list.map((column) => column.id === "asOf" ? { ...column, width: asOfWidth } : column);
@@ -100,7 +102,7 @@ function fitBoardColumns(candidates: DataTableColumn[], width: number): { column
100
102
  if (getTableWidth(columns) <= width) break;
101
103
  if (step === "shortAsOf") {
102
104
  shortAsOf = true;
103
- columns = withAsOfWidth(columns, SHORT_AS_OF_WIDTH);
105
+ columns = withAsOfWidth(columns, SHORT_AS_OF_WIDTH + wordWidth);
104
106
  } else {
105
107
  columns = columns.filter((column) => column.id !== step);
106
108
  }
@@ -134,6 +136,8 @@ export function MarketBoardStack<T extends MarketBoardRow>({ rows, width, height
134
136
  return compareSortValues(left, right, sort.direction);
135
137
  }) : rows, [rows, sort, extraColumns]);
136
138
  const open = rows.find((row) => row.id === openId);
139
+ // A stale row's date carries a word, so the column has room for both and the date is never cut.
140
+ const wordWidth = rows.some((row) => row.status === "stale") ? STALE_WORD.length : 0;
137
141
  const { columns, shortAsOf } = fitBoardColumns([
138
142
  { id: "label", label: labelHeader, width: labelWidth, align: "left" },
139
143
  ...(rows.some((row) => row.labelDetail !== undefined)
@@ -144,8 +148,8 @@ export function MarketBoardStack<T extends MarketBoardRow>({ rows, width, height
144
148
  ...(extraColumns?.map((item) => item.column) ?? []),
145
149
  { id: "percentile", label: percentileLabel, width: 8, align: "right" },
146
150
  { id: "history", label: "1Y", width: 14, align: "left" },
147
- { id: "asOf", label: "AS OF", width: asOfWidth, align: "left" },
148
- ], width);
151
+ { id: "asOf", label: "AS OF", width: asOfWidth + wordWidth, align: "left" },
152
+ ], width, wordWidth);
149
153
  const renderCell = (row: T, column: DataTableColumn): DataTableCell => {
150
154
  const extra = extraColumns?.find((item) => item.column.id === column.id);
151
155
  if (extra) return extra.renderCell(row);
@@ -159,8 +163,10 @@ export function MarketBoardStack<T extends MarketBoardRow>({ rows, width, height
159
163
  if (column.id === "percentile") return { text: row.percentileText ?? row.percentile?.toFixed(0) ?? "--", color: row.percentile != null && (row.percentile <= 10 || row.percentile >= 90) ? colors.warning : colors.textMuted };
160
164
  if (column.id === "history") return { text: "", content: <PriceSparkline priceHistory={row.history} width={column.width} period="1Y" trend="neutral" /> };
161
165
  const asOf = row.asOfText ?? row.asOf ?? "--";
162
- // A narrow board drops the year; the export keeps the full date.
163
- return { text: shortAsOf ? shortIsoDate(asOf) : asOf, ...(row.asOf ? { value: row.asOf } : {}),
166
+ const asOfText = shortAsOf ? shortIsoDate(asOf) : asOf;
167
+ // A narrow board drops the year; the export keeps the full date. The word
168
+ // stays with the color, so a stale row is not color alone.
169
+ return { text: row.status === "stale" ? `${asOfText}${STALE_WORD}` : asOfText, ...(row.asOf ? { value: row.asOf } : {}),
164
170
  color: row.status === "stale" ? colors.warning : colors.textDim };
165
171
  };
166
172
  return <DataTableStackView columns={columns} items={items} getItemKey={(row) => row.id} renderCell={renderCell}
@@ -1,10 +1,10 @@
1
- import { forwardRef, useMemo } from "react";
2
- import { t } from "../../i18n";
1
+ import { forwardRef, useCallback, useMemo } from "react";
2
+ import { t, tf } from "../../i18n";
3
3
  import { hoverBg } from "../../theme/colors";
4
4
  import { useThemeColors } from "../../theme/theme-context";
5
- import { Box, Text, useUiCapabilities, type ScrollBoxRenderable } from "../../ui";
5
+ import { Box, Text, useRendererHost, useUiCapabilities, type ScrollBoxRenderable } from "../../ui";
6
6
  import { wrapTextLines } from "../../utils/text-wrap";
7
- import { MarkdownText } from "../markdown-text";
7
+ import { MarkdownText, markdownLinks, type MarkdownTextProps } from "../markdown-text";
8
8
  import { DetailScrollBody } from "../ui/detail-scroll-body";
9
9
  import { KeyValueRow } from "../ui/display";
10
10
  import { ExternalLink, usePaneLinkMenuEntry } from "../ui/external-link";
@@ -121,7 +121,7 @@ function NativeDetail({ meta, facts, body, bodyFormat, note }: DetailContentProp
121
121
  ) : null}
122
122
  {bodyFormat === "markdown" && body ? (
123
123
  <Box flexDirection="column" width="100%" style={MARKDOWN_STYLE}>
124
- <MarkdownText text={body} paragraphGap={PARAGRAPH_GAP} headingStyle={READER_HEADING_STYLE} />
124
+ <MarkdownBody text={body} paragraphGap={PARAGRAPH_GAP} headingStyle={READER_HEADING_STYLE} />
125
125
  </Box>
126
126
  ) : blocks.map((block, index) => (
127
127
  <Box key={index} flexDirection="column">
@@ -140,6 +140,28 @@ function NativeDetail({ meta, facts, body, bodyFormat, note }: DetailContentProp
140
140
  );
141
141
  }
142
142
 
143
+ /**
144
+ * Markdown whose `[label](url)` links open outside the app, as the reader's
145
+ * other links do. The terminal cannot press part of a line, so the pane menu
146
+ * lists each link there (inside a `PaneLinkMenu`).
147
+ */
148
+ function MarkdownBody({ text, ...style }: Pick<MarkdownTextProps, "text" | "lineWidth" | "paragraphGap" | "headingStyle">) {
149
+ const rendererHost = useRendererHost();
150
+ const links = useMemo(() => markdownLinks(text), [text]);
151
+ const open = useCallback((href: string) => { void rendererHost.openExternal(href); }, [rendererHost]);
152
+ return (
153
+ <>
154
+ <MarkdownText text={text} onOpenLink={open} {...style} />
155
+ {links.map((link, index) => <LinkMenuEntry key={index} {...link} onOpen={open} />)}
156
+ </>
157
+ );
158
+ }
159
+
160
+ function LinkMenuEntry({ label, href, onOpen }: { label: string; href: string; onOpen: (href: string) => void }) {
161
+ usePaneLinkMenuEntry(`link:${href}`, tf("Open {label}", { label }), () => onOpen(href));
162
+ return null;
163
+ }
164
+
143
165
  function FactValue({ fact }: { fact: DetailFact }) {
144
166
  const colors = useThemeColors();
145
167
  const { link } = fact;
@@ -167,7 +189,7 @@ function TerminalDetail({ width, meta, facts, body, bodyFormat, note }: DetailCo
167
189
  return (
168
190
  <Box flexDirection="column" width={lineWidth}>
169
191
  {metaLines.map((line, index) => <Text key={index} fg={colors.textMuted}>{line}</Text>)}
170
- <MarkdownText text={body ?? ""} lineWidth={lineWidth} />
192
+ <MarkdownBody text={body ?? ""} lineWidth={lineWidth} />
171
193
  </Box>
172
194
  );
173
195
  }
@@ -68,6 +68,8 @@ export function TickerBadge({
68
68
  // badges; the desktop leaves a hairline of row above and below.
69
69
  justifyContent={nativePaneChrome ? "center" : undefined}
70
70
  style={nativePaneChrome ? NATIVE_CHIP_STYLE : undefined}
71
+ role="button"
72
+ aria-label={symbol}
71
73
  data-gloom-context-menu-surface="true"
72
74
  onMouseOver={() => {
73
75
  onHoverStart?.();
@@ -226,7 +226,12 @@ export function Divider({ width, height, orientation = "horizontal" }: DividerPr
226
226
  flexShrink={0}
227
227
  border={vertical ? ["left"] : ["top"]}
228
228
  borderColor={colors.border}
229
- style={{ border: 0, [vertical ? "borderLeft" : "borderTop"]: `1px solid ${colors.border}` }}
229
+ style={{
230
+ border: 0,
231
+ [vertical ? "borderLeft" : "borderTop"]: `1px solid ${colors.border}`,
232
+ // A vertical rule given no height runs the height of the row it stands in.
233
+ ...(vertical && height === undefined ? { height: "auto", alignSelf: "stretch" } : {}),
234
+ }}
230
235
  role="separator"
231
236
  data-gloom-ui="divider"
232
237
  />
@@ -98,11 +98,18 @@ function handleOpen(
98
98
  onOpen(url);
99
99
  }
100
100
 
101
+ /** Desktop and web: a link inside a line of text runs and wraps with the line, rather than standing as a box of its own. */
102
+ const INLINE_LINK_STYLE = { display: "inline", whiteSpace: "pre-wrap", overflowWrap: "anywhere" } as const;
103
+
101
104
  export function ExternalLinkText(
102
- { url, label, color, onOpen = openUrl }: {
105
+ { url, label, color, attributes = 0, inline = false, onOpen = openUrl }: {
103
106
  url: string;
104
107
  label?: string;
105
108
  color?: string;
109
+ /** Marks drawn with the underline, such as the bold of a bold link. */
110
+ attributes?: number;
111
+ /** Flows inside a line of text on the desktop and the web. */
112
+ inline?: boolean;
106
113
  onOpen?: (url: string) => void;
107
114
  },
108
115
  ) {
@@ -128,8 +135,9 @@ export function ExternalLinkText(
128
135
  return (
129
136
  <Text
130
137
  fg={color ?? colors.textBright}
131
- attributes={TextAttributes.UNDERLINE}
138
+ attributes={TextAttributes.UNDERLINE | attributes}
132
139
  cursor="pointer"
140
+ style={inline ? INLINE_LINK_STYLE : undefined}
133
141
  role="link"
134
142
  tabIndex={0}
135
143
  wrapText
@@ -45,7 +45,7 @@ export class MemoryResourceStore {
45
45
  this.delete(key);
46
46
  return null;
47
47
  }
48
- if (options.touch !== false) {
48
+ if (options.touch !== false && (options.touchIntervalMs == null || Date.now() - record.lastAccessedAt >= options.touchIntervalMs)) {
49
49
  record.lastAccessedAt = Date.now();
50
50
  }
51
51
  record.stale = isStale(record);
@@ -63,6 +63,13 @@ export interface GetResourceOptions {
63
63
  schemaVersion?: number;
64
64
  allowExpired?: boolean;
65
65
  touch?: boolean;
66
+ /**
67
+ * A read refreshes a row's place in the eviction order, one write per row. A
68
+ * row read many times a second (a quote, a price history) asks for it at most
69
+ * once per this many ms, so it stays recent without a write on every read.
70
+ * `touch: false` never refreshes it, and a row read that way looks old.
71
+ */
72
+ touchIntervalMs?: number;
66
73
  }
67
74
 
68
75
  export interface ListResourceOptions extends GetResourceOptions {
@@ -141,6 +148,10 @@ export class ResourceStore {
141
148
  });
142
149
  }
143
150
 
151
+ private touchDue(record: { lastAccessedAt: number }, options: GetResourceOptions): boolean {
152
+ return options.touchIntervalMs == null || Date.now() - record.lastAccessedAt >= options.touchIntervalMs;
153
+ }
154
+
144
155
  private touch(key: ResourceCacheKey): number | null {
145
156
  const touchedAt = Date.now();
146
157
  const result = trySqliteBusyOperation("touch cached resource", () => {
@@ -283,7 +294,7 @@ export class ResourceStore {
283
294
  return null;
284
295
  }
285
296
 
286
- if (options.touch !== false) {
297
+ if (options.touch !== false && this.touchDue(record, options)) {
287
298
  const touchedAt = this.touch(key);
288
299
  if (touchedAt != null) {
289
300
  record.lastAccessedAt = touchedAt;
@@ -340,6 +351,7 @@ export class ResourceStore {
340
351
  }
341
352
  if (options.touch !== false) {
342
353
  for (const record of records) {
354
+ if (!this.touchDue(record, options)) continue;
343
355
  const touchedAt = this.touch(record);
344
356
  if (touchedAt != null) record.lastAccessedAt = touchedAt;
345
357
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "format": 1,
3
3
  "site": "https://gloom.sh",
4
- "updatedAt": "2026-10-12",
4
+ "updatedAt": "2026-10-14",
5
5
  "categories": [
6
6
  {
7
7
  "id": "research",
@@ -618,7 +618,7 @@
618
618
  "code": "NI",
619
619
  "name": "Topic news",
620
620
  "description": "News by topic code or sector",
621
- "detail": "Market news by topic code (MNA, CB, ENERGY, REG, CRYPTO, EARN, IPO) or by sector.",
621
+ "detail": "Market news by topic code (MNA, CB, ENERGY, REG, CRYPTO, EARN, IPO) or by sector. LTR lists public fund and investor letters as links to the publisher's page.",
622
622
  "category": "news",
623
623
  "arg": "ENERGY",
624
624
  "shot": "https://gloom.sh/screenshots/fn/NI.png",
@@ -777,8 +777,8 @@
777
777
  {
778
778
  "code": "FUT",
779
779
  "name": "Futures board",
780
- "description": "Front-month futures",
781
- "detail": "Front-month contracts across equity index, rates, energy, metals, agriculture, and FX with last price and session change, grouped by sector.",
780
+ "description": "Most active futures",
781
+ "detail": "Each root's most active contract across equity index, rates, energy, metals, agriculture, and FX with last price, unit and session change, grouped by sector, naming the nearer month while it still trades.",
782
782
  "category": "markets",
783
783
  "shot": "https://gloom.sh/screenshots/fn/FUT.png",
784
784
  "lesson": "/academy/futures-commodities-energy/futures-from-zero"
@@ -816,7 +816,7 @@
816
816
  "code": "POWER",
817
817
  "name": "Power and grid buildout",
818
818
  "description": "Grid queues and large loads",
819
- "detail": "Grid interconnection queues, datacentre and other large-load requests, generation capacity and utility exposure, with a link to the source for every project. Pro shows the full data and history; Free shows three rows per section. EPWR opens the Prices tab, free: day-ahead power prices for 32 European markets in EUR/MWh and the EU carbon (EUA) auction clearing price in EUR/tCO2, each with its daily history.",
819
+ "detail": "Grid interconnection queues, datacentre and other large-load requests, generation capacity and utility exposure, with a link to the source for every project. Pro shows the full data and history; Free shows three rows per section. EPWR opens the Prices tab, free: day-ahead power prices for 32 European markets in EUR/MWh and the EU carbon (EUA) auction clearing price in EUR/tCO2, each with its daily history. EPWR GAS shows European gas storage from GIE AGSI+: % full, TWh and daily injection and withdrawal for the EU and each member state, against a year ago and the five-year range.",
820
820
  "category": "markets",
821
821
  "arg": "NEE",
822
822
  "access": "pro",
@@ -6329,7 +6329,7 @@
6329
6329
  "developer api",
6330
6330
  "market news"
6331
6331
  ],
6332
- "updatedAt": "2026-10-07",
6332
+ "updatedAt": "2026-10-14",
6333
6333
  "blocks": [
6334
6334
  {
6335
6335
  "type": "heading",
@@ -6360,7 +6360,7 @@
6360
6360
  [
6361
6361
  "Free key",
6362
6362
  "Free",
6363
- "Delayed 12 hours."
6363
+ "Delayed 12 hours. Real-time with a paid [Gloom Datasets](/docs/datasets) plan."
6364
6364
  ],
6365
6365
  [
6366
6366
  "Unlimited",
@@ -6380,7 +6380,7 @@
6380
6380
  },
6381
6381
  {
6382
6382
  "type": "paragraph",
6383
- "text": "Every `/v1/news` request requires a News API key. Use either header style:"
6383
+ "text": "Every `/v1/news` request requires a News API key or a [Gloom Datasets](/docs/datasets) key (`gds_`). Use either header style:"
6384
6384
  },
6385
6385
  {
6386
6386
  "type": "code",
@@ -6595,7 +6595,7 @@
6595
6595
  "cftc",
6596
6596
  "cot"
6597
6597
  ],
6598
- "updatedAt": "2026-10-08",
6598
+ "updatedAt": "2026-10-14",
6599
6599
  "blocks": [
6600
6600
  {
6601
6601
  "type": "heading",
@@ -6620,7 +6620,7 @@
6620
6620
  "items": [
6621
6621
  "**Alternative data:** GPU rental prices, supply chain, hiring postings and momentum, social mentions, app attention, power and grid, perpetuals, catalysts and litigation, research attention, congressional trades, operating exposure.",
6622
6622
  "**Filings and company text:** KPIs and guidance, executive pay, risk factors, 8-K events, transcripts, credit documents, debt maturities, revenue breakdown, SEC filings, insiders and 13F, document search.",
6623
- "**News:** Delayed stories, sources and topics.",
6623
+ "**News:** Real-time stories, sources and topics.",
6624
6624
  "**Macro and positioning:** CPI, EIA inventories, COT, yield curves and macro series, central bank rates, short volume and interest, CDS, government awards.",
6625
6625
  "**Market data and fundamentals:** Quotes and history, financial statements, statistics, profiles, earnings, estimates, IPOs, M&A, screener, options and gamma positioning, futures, rate path, crypto, session tape."
6626
6626
  ]
@@ -6644,7 +6644,7 @@
6644
6644
  },
6645
6645
  {
6646
6646
  "type": "paragraph",
6647
- "text": "They are different keys from the Gloom API's. A Gloom Pro key (`gloom_mcp_`) or a News API key (`gloom_news_`) gets `401 wrong_key` on a dataset route, and a `gds_` key gets `401` on the Gloom API's own routes, such as the [REST API](/docs/rest-api) and the [News API](/docs/news-api)."
6647
+ "text": "They are different keys from the Gloom API's. A Gloom Pro key (`gloom_mcp_`) or a News API key (`gloom_news_`) gets `401 wrong_key` on a dataset route, and a `gds_` key gets `401` on the Gloom API's own routes, such as the [REST API](/docs/rest-api), but reads the [News API](/docs/news-api) at no credit cost."
6648
6648
  },
6649
6649
  {
6650
6650
  "type": "heading",
@@ -7303,9 +7303,9 @@
7303
7303
  {
7304
7304
  "url": "/academy/futures-commodities-energy/futures-from-zero",
7305
7305
  "title": "Futures from zero (FUT)",
7306
- "summary": "A futures contract is a standard, exchange-traded promise to buy or sell a set quantity on a set month at a price agreed today, settled with daily margin, and FUT shows the front months across markets.",
7306
+ "summary": "A futures contract is a standard, exchange-traded promise to buy or sell a set quantity on a set month at a price agreed today, settled with daily margin, and FUT shows the most active months across markets.",
7307
7307
  "chapter": "Futures, commodities and energy",
7308
- "minutes": 4
7308
+ "minutes": 5
7309
7309
  },
7310
7310
  {
7311
7311
  "url": "/academy/futures-commodities-energy/curves-and-rolling",
package/src/i18n/es.ts CHANGED
@@ -982,6 +982,7 @@ export const es: Record<string, string> = {
982
982
  "Update ready, restart to apply": "Actualización lista; reinicia para aplicar",
983
983
  "Update ready": "Actualización lista",
984
984
  "Restart": "Reiniciar",
985
+ "Restart to apply v{version}": "Reinicia para aplicar v{version}",
985
986
  "Checking for updates...": "Buscando actualizaciones...",
986
987
  "Downloading v{version}: {percent}%": "Descargando v{version}: {percent}%",
987
988
  "Update failed: {error}": "Error al actualizar: {error}",
package/src/i18n/ja.ts CHANGED
@@ -996,6 +996,7 @@ export const ja: Record<string, string> = {
996
996
  "Update ready, restart to apply": "アップデートの準備ができました。適用するには再起動してください",
997
997
  "Update ready": "アップデートの準備完了",
998
998
  "Restart": "再起動",
999
+ "Restart to apply v{version}": "再起動して v{version} を適用",
999
1000
  "Checking for updates...": "アップデートを確認中...",
1000
1001
  "Downloading v{version}: {percent}%": "v{version} をダウンロード中: {percent}%",
1001
1002
  "Update failed: {error}": "更新に失敗しました: {error}",
package/src/i18n/ko.ts CHANGED
@@ -994,6 +994,7 @@ export const ko: Record<string, string> = {
994
994
  "Update ready, restart to apply": "업데이트 준비 완료. 적용하려면 다시 시작하세요",
995
995
  "Update ready": "업데이트 준비 완료",
996
996
  "Restart": "다시 시작",
997
+ "Restart to apply v{version}": "다시 시작하여 v{version} 적용",
997
998
  "Checking for updates...": "업데이트 확인 중...",
998
999
  "Downloading v{version}: {percent}%": "v{version} 다운로드 중: {percent}%",
999
1000
  "Update failed: {error}": "업데이트 실패: {error}",
package/src/i18n/zh-cn.ts CHANGED
@@ -996,6 +996,7 @@ export const zhCN: Record<string, string> = {
996
996
  "Update ready, restart to apply": "更新已就绪,重启后生效",
997
997
  "Update ready": "更新已就绪",
998
998
  "Restart": "重启",
999
+ "Restart to apply v{version}": "重启以应用 v{version}",
999
1000
  "Checking for updates...": "正在检查更新...",
1000
1001
  "Downloading v{version}: {percent}%": "正在下载 v{version}:{percent}%",
1001
1002
  "Update failed: {error}": "更新失败:{error}",
package/src/i18n/zh-tw.ts CHANGED
@@ -996,6 +996,7 @@ export const zhTW: Record<string, string> = {
996
996
  "Update ready, restart to apply": "更新已就緒,重新啟動後生效",
997
997
  "Update ready": "更新已就緒",
998
998
  "Restart": "重新啟動",
999
+ "Restart to apply v{version}": "重新啟動以套用 v{version}",
999
1000
  "Checking for updates...": "正在檢查更新...",
1000
1001
  "Downloading v{version}: {percent}%": "正在下載 v{version}:{percent}%",
1001
1002
  "Update failed: {error}": "更新失敗:{error}",
@@ -128,6 +128,13 @@ export function isFuturesContractSpec(spec: unknown): spec is FuturesContractSpe
128
128
  && (row.settlement === "physical" || row.settlement === "cash");
129
129
  }
130
130
 
131
+ function isNearestContract(value: unknown): boolean {
132
+ if (!value || typeof value !== "object") return false;
133
+ const row = value as Record<string, unknown>;
134
+ return typeof row.contract === "string" && typeof row.contractSymbol === "string"
135
+ && typeof row.deliveryMonth === "string" && /^\d{4}-(0[1-9]|1[0-2])$/.test(row.deliveryMonth) && optionalDate(row.lastTrade);
136
+ }
137
+
131
138
  export function validateFuturesContractTerms(data: FuturesContractTermsPayload): FuturesContractTermsPayload {
132
139
  const settlement = data?.settlement as unknown;
133
140
  if (!data || typeof data !== "object" || typeof data.root !== "string"
@@ -138,6 +145,9 @@ export function validateFuturesContractTerms(data: FuturesContractTermsPayload):
138
145
  && isDate((settlement as { date?: unknown }).date))) {
139
146
  throw new Error("The server returned invalid futures contract terms");
140
147
  }
148
+ // A nearest contract the server worded some other way is left out; the terms still stand.
149
+ const nearest = data.nearest as unknown;
150
+ if (nearest != null && !isNearestContract(nearest)) return { ...data, nearest: null };
141
151
  return data;
142
152
  }
143
153
 
@@ -428,7 +428,7 @@ export function formatMarketChangeWithCurrency(
428
428
  referencePrice?: number,
429
429
  ): string {
430
430
  if (value == null || !Number.isFinite(value)) return "—";
431
- if (resolvePriceBasis(options.priceBasis, options.assetCategory) !== "per-unit") return formatSignedMarketPrice(value, options);
431
+ if (isIndexLevel(options) || resolvePriceBasis(options.priceBasis, options.assetCategory) !== "per-unit") return formatSignedMarketPrice(value, options);
432
432
  if (options.fixedFractionDigits !== undefined) {
433
433
  const maxWidth = options.maxWidth == null ? undefined : Math.max(1, options.maxWidth - 1);
434
434
  const body = formatMarketPriceWithCurrency(Math.abs(value), currency, { ...options, maxWidth });
@@ -603,13 +603,18 @@ export function liveQuoteFormatOptions(
603
603
  return withStablePriceDigits(quoteFormatOptions(quote, fallbackAssetCategory, metadataInstrumentType), currency, quote);
604
604
  }
605
605
 
606
+ /** An index level is points, not money: ^GSPC at 7,805.45 has no currency to name, wherever it is shown. */
607
+ function isIndexLevel(options: MarketFormatOptions): boolean {
608
+ return options.assetCategory?.trim().toUpperCase() === "INDEX";
609
+ }
610
+
606
611
  export function formatMarketPriceWithCurrency(
607
612
  value: number | undefined,
608
613
  currency = "USD",
609
614
  options: MarketFormatOptions = {},
610
615
  ): string {
611
616
  if (value === undefined || value === null || Number.isNaN(value)) return "—";
612
- if (resolvePriceBasis(options.priceBasis, options.assetCategory) !== "per-unit") return formatMarketPrice(value, options);
617
+ if (isIndexLevel(options) || resolvePriceBasis(options.priceBasis, options.assetCategory) !== "per-unit") return formatMarketPrice(value, options);
613
618
  const normalizedCurrency = currency.trim().toUpperCase() || "USD";
614
619
  const sign = value < 0 ? "-" : "";
615
620
  const symbol = getCurrencySymbol(normalizedCurrency);