gloomberb 0.17.2 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/chat.ts +28 -0
  3. package/src/api-client/errors.ts +11 -1
  4. package/src/api-client/futures-curve.ts +16 -0
  5. package/src/api-client/index.ts +22 -9
  6. package/src/api-client/normalizers.ts +21 -0
  7. package/src/api-client/socket.ts +27 -0
  8. package/src/api-client/types.ts +3 -1
  9. package/src/app/runtime/startup.ts +4 -0
  10. package/src/cli/commands/market.ts +60 -9
  11. package/src/cli/commands/search.ts +43 -2
  12. package/src/cli/commands/ticker.ts +100 -21
  13. package/src/cli/desktop-pane-shot.ts +34 -9
  14. package/src/cli/fx-pair.ts +17 -0
  15. package/src/cli/glossary.ts +134 -6
  16. package/src/cli/history-rows.ts +16 -4
  17. package/src/cli/index.ts +44 -12
  18. package/src/cli/known-gaps.ts +21 -2
  19. package/src/cli/listing-arg.ts +19 -4
  20. package/src/cli/listing-suggestion.ts +33 -2
  21. package/src/cli/pane-functions/dom.ts +39 -6
  22. package/src/cli/pane-functions/freshness.ts +48 -5
  23. package/src/cli/pane-functions/function-help.ts +1 -1
  24. package/src/cli/pane-functions/headless.ts +85 -19
  25. package/src/cli/pane-functions/index.ts +60 -4
  26. package/src/cli/pane-functions/market-session.ts +69 -6
  27. package/src/cli/pane-functions/resolver.ts +4 -2
  28. package/src/cli/pane-functions/screenshot.ts +14 -1
  29. package/src/cli/scroll-truncation.ts +5 -1
  30. package/src/cli/symbol-args.ts +41 -3
  31. package/src/cli/symbol-list.ts +19 -7
  32. package/src/cli/unknown-command.ts +70 -0
  33. package/src/components/chart/native/surface/visibility.ts +37 -7
  34. package/src/components/index.ts +1 -1
  35. package/src/components/layout/header.tsx +348 -102
  36. package/src/components/markdown-text.tsx +40 -66
  37. package/src/components/market-board/index.tsx +12 -6
  38. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  39. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  40. package/src/components/metric-treemap/index.tsx +1 -1
  41. package/src/components/reader/detail-reader.tsx +28 -6
  42. package/src/components/ticker/badge/index.tsx +2 -0
  43. package/src/components/ui/button.tsx +24 -9
  44. package/src/components/ui/display.tsx +6 -1
  45. package/src/components/ui/external-link.tsx +10 -2
  46. package/src/components/ui/index.ts +1 -1
  47. package/src/components/ui/status.tsx +148 -5
  48. package/src/data/memory-resource-store.ts +1 -1
  49. package/src/data/resource-store.ts +13 -1
  50. package/src/docs/snapshot.json +13 -13
  51. package/src/i18n/es.ts +1 -0
  52. package/src/i18n/ja.ts +1 -0
  53. package/src/i18n/ko.ts +1 -0
  54. package/src/i18n/zh-cn.ts +1 -0
  55. package/src/i18n/zh-tw.ts +1 -0
  56. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  57. package/src/market-data/futures-board.ts +44 -1
  58. package/src/market-data/futures-contract.ts +10 -0
  59. package/src/market-data/market/format.ts +7 -2
  60. package/src/market-data/market/freshness.ts +25 -1
  61. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  62. package/src/market-data/quotes/resolution.ts +2 -0
  63. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  64. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  65. package/src/plugins/builtin/alerts/cli.ts +72 -11
  66. package/src/plugins/builtin/attention/headless.ts +19 -10
  67. package/src/plugins/builtin/attention/model.ts +22 -0
  68. package/src/plugins/builtin/awards/headless.ts +5 -1
  69. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  70. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  71. package/src/plugins/builtin/changelog/index.tsx +11 -8
  72. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  73. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  74. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  75. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  76. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  77. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  78. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  79. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  80. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  81. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  82. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  83. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  84. package/src/plugins/builtin/chat/layout.ts +91 -31
  85. package/src/plugins/builtin/chat/markdown.ts +94 -0
  86. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  87. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  88. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  89. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  90. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  91. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  92. package/src/plugins/builtin/company-attention/model.ts +53 -2
  93. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  94. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  95. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  96. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  97. package/src/plugins/builtin/cot/client.ts +12 -4
  98. package/src/plugins/builtin/cot/model.ts +20 -0
  99. package/src/plugins/builtin/cot/pane.tsx +2 -2
  100. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  101. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  102. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  103. package/src/plugins/builtin/debug/index.tsx +15 -15
  104. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  105. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  106. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  107. package/src/plugins/builtin/docs/index.tsx +5 -3
  108. package/src/plugins/builtin/docs/pane.tsx +19 -2
  109. package/src/plugins/builtin/docs/targets.ts +5 -3
  110. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  111. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  112. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  113. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  114. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  115. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  116. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  117. package/src/plugins/builtin/futures/index.tsx +33 -6
  118. package/src/plugins/builtin/futures/model.ts +69 -5
  119. package/src/plugins/builtin/futures/series.ts +3 -3
  120. package/src/plugins/builtin/futures/table.tsx +31 -11
  121. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  122. package/src/plugins/builtin/holders/pane.tsx +6 -1
  123. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  124. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  125. package/src/plugins/builtin/mna/client.ts +12 -2
  126. package/src/plugins/builtin/mna/detail.tsx +2 -2
  127. package/src/plugins/builtin/mna/pane.tsx +2 -2
  128. package/src/plugins/builtin/news/headless.ts +4 -2
  129. package/src/plugins/builtin/notes/owner.tsx +5 -4
  130. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  131. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  132. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  133. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  134. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  135. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  136. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  137. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
  138. package/src/plugins/builtin/research/event-model.ts +14 -0
  139. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  140. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  141. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  142. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  143. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  144. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  145. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  146. package/src/plugins/builtin/research-search/data.ts +11 -0
  147. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  148. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  149. package/src/plugins/builtin/seasonality/client.ts +23 -0
  150. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  151. package/src/plugins/builtin/sectors/index.tsx +7 -3
  152. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  153. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  154. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  155. package/src/plugins/builtin/short-interest/model.ts +62 -3
  156. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  157. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  158. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  159. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  160. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  161. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  162. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  163. package/src/plugins/builtin/themes/headless.ts +1 -1
  164. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  165. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  166. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  167. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  168. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  169. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  170. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  171. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  172. package/src/renderers/dom/build-assets.ts +17 -4
  173. package/src/renderers/dom/desktop/controls.tsx +2 -0
  174. package/src/renderers/dom/styles.css +6 -0
  175. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  176. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  177. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  178. package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
  179. package/src/sources/provider-router/batches.ts +2 -1
  180. package/src/sources/provider-router/cache.ts +3 -0
  181. package/src/sync/baseline.ts +19 -14
  182. package/src/sync/controller.ts +140 -40
  183. package/src/sync/core-contributors.ts +256 -32
  184. package/src/sync/types.ts +44 -0
  185. package/src/time-series/resolve.ts +24 -5
  186. package/src/types/data-provider.ts +8 -0
  187. package/src/types/financials.ts +40 -4
  188. package/src/types/headless.ts +43 -0
  189. package/src/types/plugin.ts +7 -0
  190. package/src/ui/resize-settle.ts +128 -0
  191. package/src/utils/calendar-date.ts +56 -0
  192. package/src/utils/decimal-comma.ts +31 -0
  193. package/src/utils/declared-dividends.ts +39 -0
  194. package/src/utils/forward-dividend.ts +34 -11
  195. package/src/utils/fundamentals.ts +13 -2
  196. package/src/utils/futures-generic.ts +25 -4
  197. package/src/utils/markdown.ts +180 -0
  198. package/src/utils/market-capitalization.ts +22 -0
  199. package/src/utils/plugin-proxy-hosts.json +5 -0
  200. package/src/utils/utc-time.ts +14 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/public/index.ts",
@@ -86,6 +86,7 @@
86
86
  "check:pane-screenshot": "bun run scripts/pane-chrome/screenshot.ts",
87
87
  "check:pane-rest": "bun run scripts/pane-chrome/rest-diff.ts",
88
88
  "check:pane-layout": "bun run scripts/pane-chrome/layout-sweep.ts",
89
+ "audit:ui": "bun run scripts/pane-chrome/ui-audit.ts",
89
90
  "benchmark:pane-drag:compare": "bun run scripts/pane-chrome/frame-budget.ts",
90
91
  "test:global-upgrade": "bun run scripts/smoke-global-upgrade.ts",
91
92
  "release": "./scripts/release.sh"
@@ -154,16 +154,40 @@ export class CloudChatApi {
154
154
  );
155
155
  }
156
156
 
157
+ /**
158
+ * `seen`: the app showed them where the reader was looking (the open
159
+ * channel, a toast in the foreground), which also cancels the phone push
160
+ * waiting on them. A server that predates it ignores the field.
161
+ */
157
162
  async markNotificationsDelivered(
158
163
  notificationIds: string[],
164
+ options: { seen?: boolean } = {},
159
165
  ): Promise<{ delivered: number }> {
160
166
  return this.request<{ delivered: number }>(
161
167
  "/chat/notifications/delivered",
168
+ {
169
+ method: "POST",
170
+ body: JSON.stringify({ notificationIds, ...(options.seen ? { seen: true } : {}) }),
171
+ },
172
+ );
173
+ }
174
+
175
+ /**
176
+ * The notifications among these the reader has neither read nor been shown
177
+ * in front of them, on any device. Throws on a server without the route
178
+ * (404); the caller raises the alert then, as before.
179
+ */
180
+ async getUnseenNotificationIds(notificationIds: string[]): Promise<string[]> {
181
+ const answer = await this.request<{ notificationIds?: unknown }>(
182
+ "/chat/notifications/unseen",
162
183
  {
163
184
  method: "POST",
164
185
  body: JSON.stringify({ notificationIds }),
165
186
  },
166
187
  );
188
+ return Array.isArray(answer?.notificationIds)
189
+ ? answer.notificationIds.filter((id): id is string => typeof id === "string")
190
+ : notificationIds;
167
191
  }
168
192
 
169
193
  async openDirectChannel(target: {
@@ -327,6 +351,10 @@ export class CloudChatApi {
327
351
  return this.options.socket.subscribeChatNotifications(listener);
328
352
  }
329
353
 
354
+ subscribeChannelState(listener: (state: ChatChannelState) => void): () => void {
355
+ return this.options.socket.subscribeChatChannelState(listener);
356
+ }
357
+
330
358
  subscribePresence(listener: ChatPresenceListener): () => void {
331
359
  return this.options.socket.subscribeChatPresence(listener);
332
360
  }
@@ -75,12 +75,22 @@ export function parseApiErrorMessage(body: string): string {
75
75
  (value): value is string =>
76
76
  typeof value === "string" && value.trim().length > 0,
77
77
  );
78
- return parts.join(" ") || body;
78
+ return parts.join(" ") || validationErrorText(parsed) || body;
79
79
  } catch {
80
80
  return htmlErrorTitle(body) ?? body;
81
81
  }
82
82
  }
83
83
 
84
+ /**
85
+ * A request the API's schema refused comes back as the values it was sent
86
+ * (`{"type":"validation","on":"query","found":{...}}`), which is no message to
87
+ * show; it is said in words instead.
88
+ */
89
+ function validationErrorText(parsed: Record<string, unknown>): string | null {
90
+ if (parsed.type !== "validation") return null;
91
+ return parsed.on === "query" ? "Gloom Cloud did not accept an option or filter value." : "Gloom Cloud did not accept the request.";
92
+ }
93
+
84
94
  /** A JSON error body as an object, or undefined for any other body. */
85
95
  export function parseApiErrorDetails(body: string): Readonly<Record<string, unknown>> | undefined {
86
96
  try {
@@ -30,6 +30,22 @@ export interface FuturesContractTermsPayload {
30
30
  inDelivery: boolean
31
31
  /** The newest exchange settlement confirmed for the contract, with its session. */
32
32
  settlement: { price: number; date: string } | null
33
+ /**
34
+ * For a continuous symbol (CL=F) or a front generic: the nearest contract
35
+ * still trading when the symbol has rolled past it to the most active one,
36
+ * with its last trade; null when it prices the nearest itself. Absent from
37
+ * servers that do not send it.
38
+ */
39
+ nearest?: FuturesNearestContract | null
40
+ }
41
+
42
+ /** The nearest contract still trading, which a continuous symbol has rolled past (CLX26 under CL=F on Dec). */
43
+ interface FuturesNearestContract {
44
+ contract: string
45
+ contractSymbol: string
46
+ /** YYYY-MM */
47
+ deliveryMonth: string
48
+ lastTrade: string | null
33
49
  }
34
50
 
35
51
  /** Gloom Cloud futures prices retain provider denomination and explicit quote units. */
@@ -12,7 +12,7 @@ import {
12
12
  parseCloudLayoutEntry,
13
13
  parseCloudLayoutList,
14
14
  } from "../layout-marketplace/cloud";
15
- import type { SyncSnapshot } from "../sync/types";
15
+ import { SyncRevisionConflictError, type SyncSnapshot } from "../sync/types";
16
16
  import { withDeadline } from "../utils/async-deadline";
17
17
  import { CloudASKGApi } from "./askg";
18
18
  import { CloudAuthApi } from "./auth";
@@ -60,7 +60,8 @@ export type {
60
60
  /** Server-side caps for `/assist/command`; enforced here so a 422 is never sent. */
61
61
  const ASSIST_QUERY_MAX_LENGTH = 200;
62
62
  const ASSIST_COMMAND_LIMIT = 150;
63
- const ASSIST_ARG_OPTION_LIMIT = 64;
63
+ /** The futures curve's root list is the longest a command sends: 76 since GLO-783. */
64
+ export const ASSIST_ARG_OPTION_LIMIT = 128;
64
65
  const ASSIST_ARG_OPTION_VALUE_MAX_LENGTH = 40;
65
66
  const ASSIST_ARG_OPTION_LABEL_MAX_LENGTH = 60;
66
67
  const ASSIST_REQUEST_TIMEOUT_MS = 6_000;
@@ -450,14 +451,24 @@ class GloomApiClient {
450
451
  return this.request<CloudSyncSnapshotResponse>("/sync/snapshot", { method: "GET" });
451
452
  }
452
453
 
454
+ /**
455
+ * `baseRevision` goes out as If-Match: the server ignores the body field and
456
+ * would replace a snapshot another device wrote since this one pulled. With
457
+ * the header it refuses that write (409), raised as SyncRevisionConflictError
458
+ * so the controller pulls again instead.
459
+ */
453
460
  async putSyncSnapshot(snapshot: SyncSnapshot, options?: { baseRevision?: number | null }): Promise<CloudSyncPushResponse> {
454
- return this.request<CloudSyncPushResponse>("/sync/snapshot", {
455
- method: "PUT",
456
- body: JSON.stringify({
457
- snapshot,
458
- baseRevision: options?.baseRevision ?? null,
459
- }),
460
- });
461
+ const baseRevision = options?.baseRevision ?? null;
462
+ try {
463
+ return await this.request<CloudSyncPushResponse>("/sync/snapshot", {
464
+ method: "PUT",
465
+ ...(baseRevision !== null ? { headers: { "If-Match": String(baseRevision) } } : {}),
466
+ body: JSON.stringify({ snapshot, baseRevision }),
467
+ });
468
+ } catch (error) {
469
+ if (error instanceof ApiRequestError && error.status === 409) throw new SyncRevisionConflictError(error.message);
470
+ throw error;
471
+ }
461
472
  }
462
473
 
463
474
  async getMarketplaceLayout(
@@ -636,6 +647,7 @@ class GloomApiClient {
636
647
  getChatState = this.chat.getState.bind(this.chat);
637
648
  updateChatChannelState = this.chat.updateChannelState.bind(this.chat);
638
649
  markChatNotificationsDelivered = this.chat.markNotificationsDelivered.bind(this.chat);
650
+ getUnseenChatNotificationIds = this.chat.getUnseenNotificationIds.bind(this.chat);
639
651
  openDirectChannel = this.chat.openDirectChannel.bind(this.chat);
640
652
  openGroupChannel = this.chat.openGroupChannel.bind(this.chat);
641
653
  getMessages = this.chat.getMessages.bind(this.chat);
@@ -648,6 +660,7 @@ class GloomApiClient {
648
660
  subscribeChatReactions = this.chat.subscribeReactions.bind(this.chat);
649
661
  connectChannel = this.chat.connectChannel.bind(this.chat);
650
662
  subscribeChatNotifications = this.chat.subscribeNotifications.bind(this.chat);
663
+ subscribeChatChannelState = this.chat.subscribeChannelState.bind(this.chat);
651
664
  subscribeChatPresence = this.chat.subscribePresence.bind(this.chat);
652
665
  getChatDiscordLink = this.chat.getDiscordLink.bind(this.chat);
653
666
  unlinkChatDiscord = this.chat.unlinkDiscord.bind(this.chat);
@@ -2,6 +2,7 @@ import type {
2
2
  ChatAccountType,
3
3
  ChatAttachment,
4
4
  ChatChannel,
5
+ ChatChannelState,
5
6
  ChatDiscordLink,
6
7
  ChatMessage,
7
8
  ChatMessageOrigin,
@@ -191,6 +192,26 @@ export function normalizeChatNotification(
191
192
  };
192
193
  }
193
194
 
195
+ /** A `chat.channel_state` frame, or null when it is not one. */
196
+ export function normalizeChatChannelStateFrame(data: unknown): ChatChannelState | null {
197
+ if (!data || typeof data !== "object") return null;
198
+ const state = data as Partial<ChatChannelState>;
199
+ if (
200
+ typeof state.channelId !== "string"
201
+ || typeof state.notificationsEnabled !== "boolean"
202
+ || typeof state.unreadCount !== "number"
203
+ || (state.lastReadMessageId !== null && typeof state.lastReadMessageId !== "string")
204
+ ) {
205
+ return null;
206
+ }
207
+ return {
208
+ channelId: state.channelId,
209
+ notificationsEnabled: state.notificationsEnabled,
210
+ lastReadMessageId: state.lastReadMessageId,
211
+ unreadCount: state.unreadCount,
212
+ };
213
+ }
214
+
194
215
  export function normalizeChatChannel(
195
216
  channel: ChatChannel,
196
217
  fallbackKind: ChatChannel["kind"] = "public",
@@ -1,6 +1,7 @@
1
1
  import type { TapeFeedEvent } from "./tape";
2
2
  import type {
3
3
  AuthUser,
4
+ ChatChannelState,
4
5
  ChatMessage,
5
6
  ChatNotification,
6
7
  ChatReactionsUpdate,
@@ -11,6 +12,7 @@ import type {
11
12
  TeamNotification,
12
13
  } from "./types";
13
14
  import {
15
+ normalizeChatChannelStateFrame,
14
16
  normalizeChatMessage,
15
17
  normalizeChatNotification,
16
18
  normalizeChatReactions,
@@ -42,6 +44,7 @@ const cloudApiLog = debugLog.createLogger("cloud-api");
42
44
 
43
45
  type ChannelListener = (message: ChatMessage) => void;
44
46
  type ChatNotificationListener = (notification: ChatNotification) => void;
47
+ type ChatChannelStateListener = (state: ChatChannelState) => void;
45
48
  type ChatPresenceListener = (onlineCount: number) => void;
46
49
  type ChatReactionsListener = (update: ChatReactionsUpdate) => void;
47
50
  type TeamNotificationListener = (notification: TeamNotification) => void;
@@ -108,6 +111,8 @@ export class CloudApiSocket {
108
111
  private readonly channelListeners = new Map<string, Set<ChannelListener>>();
109
112
  private readonly chatNotificationListeners =
110
113
  new Set<ChatNotificationListener>();
114
+ private readonly chatChannelStateListeners =
115
+ new Set<ChatChannelStateListener>();
111
116
  private readonly chatPresenceListeners = new Set<ChatPresenceListener>();
112
117
  private readonly chatReactionsListeners = new Set<ChatReactionsListener>();
113
118
  private readonly teamNotificationListeners =
@@ -249,6 +254,17 @@ export class CloudApiSocket {
249
254
  };
250
255
  }
251
256
 
257
+ /**
258
+ * The reader's read state or mute changed on another device or window. Like
259
+ * notifications, it never opens the socket by itself.
260
+ */
261
+ subscribeChatChannelState(listener: ChatChannelStateListener): () => void {
262
+ this.chatChannelStateListeners.add(listener);
263
+ return () => {
264
+ this.chatChannelStateListeners.delete(listener);
265
+ };
266
+ }
267
+
252
268
  /** A message's reactions changed in a channel this socket reads. */
253
269
  subscribeChatReactions(listener: ChatReactionsListener): () => void {
254
270
  this.chatReactionsListeners.add(listener);
@@ -511,6 +527,7 @@ export class CloudApiSocket {
511
527
  this.offeredFeatureListeners.clear();
512
528
  this.connectionListeners.clear();
513
529
  this.chatNotificationListeners.clear();
530
+ this.chatChannelStateListeners.clear();
514
531
  this.chatPresenceListeners.clear();
515
532
  this.chatReactionsListeners.clear();
516
533
  this.teamNotificationListeners.clear();
@@ -614,6 +631,16 @@ export class CloudApiSocket {
614
631
  return;
615
632
  }
616
633
 
634
+ if (parsed?.type === "chat.channel_state") {
635
+ const state = normalizeChatChannelStateFrame(parsed.data);
636
+ if (state) {
637
+ for (const listener of this.chatChannelStateListeners) {
638
+ listener(state);
639
+ }
640
+ }
641
+ return;
642
+ }
643
+
617
644
  if (parsed?.type === "team.notification" && parsed.data) {
618
645
  const notification = normalizeTeamNotification(
619
646
  parsed.data as TeamNotification,
@@ -459,10 +459,12 @@ export interface CloudPricePointPayload {
459
459
  low?: number;
460
460
  close: number;
461
461
  volume?: number;
462
- /** A coin's daily bar for the UTC day still forming when served; it has no volume yet. */
462
+ /** A daily bar still forming when served: a coin's UTC day, or the trade date a future is trading. */
463
463
  partial?: boolean;
464
464
  /** A coin's daily bar whose prices were rebuilt from hourly bars; a day the source left empty has no volume. */
465
465
  filled?: boolean;
466
+ /** A future's closed session whose settlement is not confirmed yet: it closes on the last trade. */
467
+ unsettled?: boolean;
466
468
  }
467
469
 
468
470
  type CloudEconImpact = "high" | "medium" | "low";
@@ -107,6 +107,10 @@ export function useAppStartupRuntime({
107
107
  appLog.info("app activity propagated", { active: appActive });
108
108
  }, [appActive]);
109
109
 
110
+ useEffect(() => {
111
+ chatController.setRaisesBackgroundAlerts(!isDetachedWindow);
112
+ }, [isDetachedWindow]);
113
+
110
114
  // Runs for the life of the app: a freeze is only reportable if something
111
115
  // was watching the clock while it happened.
112
116
  useEffect(() => startMainThreadStallMonitor(), []);
@@ -24,6 +24,7 @@ import { withCliServices, withMarketData } from "../context";
24
24
  import { isoDate, parsePositiveInt, requireOneArg, takeFlag, takeOption } from "./command-utils";
25
25
  import type { BuiltinCliCommandDef } from "../command-options";
26
26
  import { CLI_COMMAND_GROUPS, TABLE_SECTION_OPTION } from "../help";
27
+ import { CLI_EXIT_CODES } from "../exit-codes";
27
28
  import {
28
29
  formatChangePercentCell,
29
30
  formatCountCell,
@@ -44,7 +45,9 @@ import {
44
45
  import { getPublishedUsEquityCalendarYears, getPublishedUsEquitySession } from "../../market-data/published-us-sessions";
45
46
  import { isQuoteStaleForCurrentSession } from "../../market-data/quotes/freshness";
46
47
  import { isOptionsUnavailableError, listedOptionsAlternative, optionsUnavailableTitle } from "../../market-data/options-alternatives";
47
- import { cliFuturesContract, futuresContractMetrics, fundamentalsReportTables, renderFundamentalsReport } from "./ticker";
48
+ import {
49
+ cliFuturesContract, futuresContractMetrics, fundamentalsReportTables, loadForwardYieldDeclarations, renderFundamentalsReport,
50
+ } from "./ticker";
48
51
  import type { FuturesContractTermsPayload } from "../../api-client/futures-curve";
49
52
  import { futuresSettleText, isFuturesInstrument } from "../../market-data/futures-contract";
50
53
  import {
@@ -65,6 +68,7 @@ import {
65
68
  type OptionsChainBasis,
66
69
  } from "./options-chain";
67
70
  import { missingExpiryMessage } from "../../utils/option-expiry";
71
+ import { declaredDividends } from "../../utils/declared-dividends";
68
72
  import {
69
73
  DEFAULT_LEAPS_CRITERIA, LEAPS_EXTRA_COLUMNS, LEAPS_USAGE, parseLeapsCriteria, runLeapsScreen, takeLeapsOptions,
70
74
  } from "./options-leaps";
@@ -143,14 +147,14 @@ import { earningsCalendarFailure, earningsChoiceNote, earningsDateLabel, isListi
143
147
  import { formatBasisPoints, toBasisPoints } from "../../utils/basis-points";
144
148
  import { notATickerSymbol, providerMissReason } from "../../sources/provider-errors";
145
149
  import { isNotATickerMessage, withListingSuggestion } from "../not-a-ticker";
146
- import { describeSuggestion, suggestListing } from "../listing-suggestion";
150
+ import { describeSuggestion, suggestListing, suggestUsTicker, type ListingSuggestion } from "../listing-suggestion";
147
151
  import { splitSymbolArgs, uniqueSecuritySymbols } from "../symbol-args";
148
152
  import { cliPerpetualContract, perpetualContractStats, type PerpetualContractPayload } from "../perpetual-contract";
149
153
  import { subMinuteDataMessage } from "../known-gaps";
150
154
  import { secRegistrantMismatchMessage, SecRegistrantMismatchError, areDifferentCompanies } from "../../sources/sec-registrant";
151
155
  import { isCryptoPairSymbol } from "../../utils/crypto-pair";
152
156
  import { looksLikeCurrencyPair } from "../../utils/currency-pair";
153
- import { isUsListingExchange } from "../../utils/exchanges";
157
+ import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
154
158
  import { describeTickerChange, startTickerChangeLookup, tickerChangeLine, type TickerChange } from "../../tickers/ticker-changes";
155
159
  import { noSecReportsNotice, nonUsSecListingVenue } from "../../utils/sec";
156
160
  import type { MarketContext } from "../types";
@@ -208,6 +212,8 @@ type QuoteCliRecord = Omit<QuoteBatchResult, "error"> & {
208
212
  perpetualContract?: PerpetualContractPayload;
209
213
  /** The symbol changed: its issuer moved to another symbol, or its holder had another before. */
210
214
  tickerChange?: TickerChange;
215
+ /** A bare symbol read on a listing abroad that is one slip from a US ticker: the US ticker it was most likely typed for. */
216
+ usSuggestion?: ListingSuggestion;
211
217
  };
212
218
  type FinancialsCliData = TickerFinancials & {
213
219
  symbol: string;
@@ -504,6 +510,13 @@ function corporateActionRows(data: CorporateActionsData) {
504
510
  date: event.exDate,
505
511
  detail: currency ? formatDistributionAmount(event.amount, currency) : cleanDecimal(event.amount),
506
512
  })),
513
+ // Declared and unpaid, at its ex-date beside the paid rows, never one of them.
514
+ ...declaredDividends(data).map((event) => ({
515
+ type: "dividend",
516
+ date: event.exDate,
517
+ detail: `declared ${currency ? formatDistributionAmount(event.amount, currency) : cleanDecimal(event.amount)}`
518
+ + (event.payDate ? `, payable ${event.payDate}` : ""),
519
+ })),
507
520
  ...data.splits.map((event) => ({
508
521
  type: "split",
509
522
  date: event.date,
@@ -568,7 +581,9 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
568
581
  // So is whether a symbol changed (FB is now META), for the same lists.
569
582
  const changes = new Map(listings.length > MAX_CHOICE_LOOKUPS ? []
570
583
  : listings.map((listing) => [listing, startTickerChangeLookup(market.dataProvider, listing.symbol, listing.exchange)] as const));
571
- const results = await market.dataProvider.getQuotesBatch(targets, { forceRefresh: ctx.cliOptions.refresh });
584
+ // The service's quote of the moment, not each form's local copy from whenever it was last asked for:
585
+ // BRK.B saved on NYSE, BRK-B and BRK.B:NYSE print one quote (GLO-735). A local copy answers only offline.
586
+ const results = await market.dataProvider.getQuotesBatch(targets, { forceRefresh: ctx.cliOptions.refresh, revalidate: true });
572
587
  const listingOf = (result: QuoteBatchResult, index: number) => listings[targets.indexOf(result.target)] ?? listings[index]!;
573
588
  const asked = results.map(listingOf);
574
589
  const request = { command: commandName, noun: "quote" };
@@ -595,6 +610,11 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
595
610
  const perpetualContracts = await Promise.all(answers.map(({ result }) => commandName === "quote" && result.quote
596
611
  ? cliPerpetualContract(result.quote)
597
612
  : null));
613
+ // A bare symbol quoted on a listing abroad (APPL, Aptus Pharma on BSE) that is one slip from a US ticker (AAPL) says so.
614
+ const usSuggestions = await Promise.all(answers.map(({ result, listing, notes }) => {
615
+ const venue = result.quote && !listing.saved ? bareListingNote(listing, result.quote) : null;
616
+ return venue && notes.includes(venue) ? suggestUsTicker(listing.symbol, market) : null;
617
+ }));
598
618
  // Each row names its listing by key (SAN:EPA) and company, so the table needs no line above it.
599
619
  const data = answers.map(({ result, listing, failure }, index) => ({
600
620
  target: result.target,
@@ -604,6 +624,7 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
604
624
  quote: result.quote,
605
625
  ...contracts[index] ? { futuresContract: contracts[index] } : {},
606
626
  ...perpetualContracts[index] ? { perpetualContract: perpetualContracts[index] } : {},
627
+ ...usSuggestions[index] ? { usSuggestion: usSuggestions[index] } : {},
607
628
  error: failure?.oneLine ?? errorMessage(result.error),
608
629
  ...yieldIndexFields(result),
609
630
  ...tickerChanges[index] ? { tickerChange: tickerChanges[index] } : {},
@@ -630,15 +651,38 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
630
651
  freshness: quotesFreshness(data.map((row) => row.quote)),
631
652
  }, {
632
653
  rows: quoteRows,
633
- details: quoteFuturesContractText,
654
+ details: quoteDetailsText,
634
655
  columns,
635
656
  ...(yieldsOnly ? { textColumns: columns.map((column) => column.key === "changePercent" ? { ...column, header: "Chg" } : column) } : {}),
636
657
  });
658
+ // The table names each symbol it found nothing for; with none found, the exit status says so too.
659
+ if (data.every((row) => !row.quote)) process.exitCode = CLI_EXIT_CODES.error;
637
660
  });
638
661
  }
639
662
 
640
663
  /**
641
- * Under the table, each futures row's Settle line and Contract block, worded
664
+ * Under the table: each futures row's Settle line and Contract block, then the
665
+ * line for a symbol that is not a US ticker though it is one slip from one.
666
+ */
667
+ function quoteDetailsText(data: QuoteCliRecord[]): string {
668
+ return [quoteFuturesContractText(data), ...data.flatMap((row) => notUsTickerLine(row) ?? [])].filter(Boolean).join("\n\n");
669
+ }
670
+
671
+ /**
672
+ * "APPL is not a US ticker: it is Aptus Pharma Limited on BSE (INR). Did you
673
+ * mean AAPL (Apple Inc.)?", for a symbol read on a listing abroad that is one
674
+ * slip from a US ticker, worded as the not-a-ticker answers word it.
675
+ */
676
+ function notUsTickerLine({ target, quote, listing, usSuggestion }: QuoteCliRecord): string | null {
677
+ if (!quote || !usSuggestion) return null;
678
+ const venue = canonicalExchange(quote.listingExchangeName || quote.exchangeName);
679
+ const name = quote.name || listing?.name;
680
+ const what = `${name ? `${name} on ` : ""}${venue}${quote.currency ? ` (${quote.currency})` : ""}`;
681
+ return cliStyles.warning(`${target.symbol} is not a US ticker: it is ${what}. Did you mean ${describeSuggestion(usSuggestion)}?`);
682
+ }
683
+
684
+ /**
685
+ * Each futures row's Settle line and Contract block, worded
642
686
  * as `ticker` words them. Several symbols name theirs in the block's title.
643
687
  */
644
688
  function quoteFuturesContractText(data: QuoteCliRecord[]): string {
@@ -842,16 +886,18 @@ async function runHistory(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
842
886
  const flagColumn: CliResultColumn = {
843
887
  key: "flag", header: "Flag", format: (value) => typeof value === "string" ? cliStyles.warning(value) : "",
844
888
  };
845
- // Text also marks a bar still forming or rebuilt from hourly bars there; exports keep them as their own fields.
889
+ // Text also marks a bar still forming, rebuilt from hourly bars or closing before its settlement; exports keep them as fields.
846
890
  const flagTextColumn: CliResultColumn = {
847
891
  ...flagColumn,
848
892
  format: (value, row) => [
849
893
  typeof value === "string" ? cliStyles.warning(value) : "",
850
894
  row.partial ? cliStyles.muted("partial") : "",
851
895
  row.filled ? cliStyles.muted("filled") : "",
896
+ row.unsettled ? cliStyles.muted("unsettled") : "",
852
897
  ].filter(Boolean).join(" "),
853
898
  };
854
899
  const marked = data.some((row) => row.partial || row.filled);
900
+ const unsettled = data.some((row) => row.unsettled);
855
901
  ctx.printResult({
856
902
  data,
857
903
  metadata: {
@@ -880,6 +926,7 @@ async function runHistory(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
880
926
  ...priceColumns,
881
927
  flagColumn,
882
928
  ...marked ? [{ key: "partial", header: "Partial" }, { key: "filled", header: "Filled" }] : [],
929
+ ...unsettled ? [{ key: "unsettled", header: "Unsettled" }] : [],
883
930
  ...volume ? [{ key: "volumeUnit", header: "Volume unit" }, { key: "volumeNote", header: "Volume note" }] : [],
884
931
  ],
885
932
  // Every exported row says what it is a bar of, the currency a price is in and its bar size, so it survives head and concatenation.
@@ -937,6 +984,10 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
937
984
  // Market cap, the multiples and the yield at the regular session price, as the text gives them; the quote stays as sent.
938
985
  const priced = atRegularSessionPrice(financials);
939
986
  const capitalization = selectMarketCapitalization(priced.quote, priced.fundamentals);
987
+ // A forward yield dated ahead names the declared distribution it annualizes, in the text and tables.
988
+ const report = ctx.cliOptions.format === "json" ? data : {
989
+ ...data, declaredDividends: await loadForwardYieldDeclarations(market.dataProvider, listing.request, financials.fundamentals),
990
+ };
940
991
  ctx.printResult({
941
992
  // JSON reads a multiple over a loss as null with its reason, never as a number.
942
993
  data: {
@@ -951,8 +1002,8 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
951
1002
  freshness,
952
1003
  ...(choice.length > 0 ? { warnings: choice } : {}),
953
1004
  }, {
954
- text: () => renderFundamentalsReport(data, view),
955
- ...(tabular ? { tables: selectTables(fundamentalsReportTables(data, view, freshness)) } : {}),
1005
+ text: () => renderFundamentalsReport(report, view),
1006
+ ...(tabular ? { tables: selectTables(fundamentalsReportTables(report, view, freshness)) } : {}),
956
1007
  exportIdentity: { symbolOrSeries: listing.key },
957
1008
  });
958
1009
  return;
@@ -26,7 +26,7 @@ import { indexRootFor, indexRootTryLine } from "../index-roots";
26
26
  import { knownGapMessage, settingsPointer } from "../known-gaps";
27
27
  import { instrumentTypeLabel } from "../../tickers/instrument-kind";
28
28
  import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
29
- import { groupByCompany } from "../../tickers/company-name";
29
+ import { groupByCompany, sameCompany } from "../../tickers/company-name";
30
30
  import { findCatalogSeries, renderSeriesMatches, type SeriesMatch } from "../series-search";
31
31
  import { centralBankCiting, centralBankNamed, type CentralBank } from "../../utils/central-banks";
32
32
  import { createPaneCatalog } from "../pane-functions/discovery";
@@ -120,7 +120,7 @@ export async function searchCandidatesForCli({
120
120
  const quote = BARE_SYMBOL.test(symbol) && candidates.some((candidate) => candidate.symbol.toUpperCase() === symbol)
121
121
  ? await (quoted ?? loadSearchedQuote(symbol, savedExchange, dataProvider))
122
122
  : null;
123
- return withQuotedNames(symbol, withPrimaryListingFirst(symbol, candidates, quote), quote).slice(0, totalLimit);
123
+ return withQuotedNames(symbol, withHomeListingPrimary(symbol, withPrimaryListingFirst(symbol, candidates, quote)), quote).slice(0, totalLimit);
124
124
  } catch {
125
125
  return [];
126
126
  }
@@ -217,6 +217,47 @@ function withPrimaryListingFirst(
217
217
  ));
218
218
  }
219
219
 
220
+ /** Venues that only quote shares listed elsewhere: the OTC tiers, Frankfurt's open market and Germany's regional exchanges. */
221
+ const SECONDARY_VENUES = new Set(["OTC", "PNK", "OTCQX", "OTCQB", "FWB2", "MUNICH", "XSTU", "XDUS", "XBER", "XHAM", "HANOVER"]);
222
+
223
+ /**
224
+ * How far a listing is from being its company's own line: 0 a plain one, 1 a
225
+ * line that only quotes shares listed elsewhere (a regional venue, OTC, a
226
+ * Milan 4MSFT or London 0XXX code), 2 a depositary receipt.
227
+ */
228
+ function listingDistance(candidate: TickerSearchCandidate): 0 | 1 | 2 {
229
+ if (/depositary|^(?:ADR|ADS|GDR|CDR|BDR)$/i.test(candidate.instrumentType ?? "")) return 2;
230
+ return SECONDARY_VENUES.has(candidateExchange(candidate)) || /\d/.test(candidate.symbol) && /[A-Za-z]/.test(candidate.symbol) ? 1 : 0;
231
+ }
232
+
233
+ /**
234
+ * A company searched by name lists its home line as the primary listing. A
235
+ * search row names no home exchange or volume, so a company's lines go by
236
+ * how far each is from being its own line (`listingDistance`), then by the
237
+ * search score the service gives the listings people look up most. The
238
+ * listing search put first, often the one-off cross-listing whose short name
239
+ * matched the query (LUFTHANSA on Budapest), gives way only to a closer line,
240
+ * or to one searched at least twice as often, or any scored line when it has
241
+ * no score. A listing searched by its own symbol is not moved.
242
+ */
243
+ function withHomeListingPrimary(symbol: string, candidates: TickerSearchCandidate[]): TickerSearchCandidate[] {
244
+ const primary = candidates.find((candidate) => candidate.category === "Primary Listing");
245
+ if (!primary || primary.kind !== "search" || primary.symbol.toUpperCase() === symbol) return candidates;
246
+ const company = resolveSearchName(primary);
247
+ const lines = candidates.filter((candidate) => candidate.kind === "search" && candidate.category !== "Funds & Derivatives"
248
+ && (candidate.instrumentClass === "equity" || candidate.instrumentClass === "other") && sameCompany(resolveSearchName(candidate), company));
249
+ const score = (candidate: TickerSearchCandidate) => candidate.result?.popularity ?? candidate.popularity ?? null;
250
+ const [home] = [...lines].sort((a, b) => listingDistance(a) - listingDistance(b) || (score(b) ?? -1) - (score(a) ?? -1) || candidates.indexOf(a) - candidates.indexOf(b));
251
+ if (!home || home === primary) return candidates;
252
+ const closer = listingDistance(home) < listingDistance(primary);
253
+ const [homeScore, primaryScore] = [score(home), score(primary)];
254
+ const moreSearched = listingDistance(home) === listingDistance(primary) && homeScore != null && (primaryScore == null || homeScore >= primaryScore * 2);
255
+ if (!closer && !moreSearched) return candidates;
256
+ const rest = candidates.filter((candidate) => candidate !== home);
257
+ const at = rest.indexOf(primary);
258
+ return [...rest.slice(0, at), { ...home, category: "Primary Listing" as const }, { ...primary, category: "Other Listings" as const }, ...rest.slice(at + 1)];
259
+ }
260
+
220
261
  /** The candidate on the venue the quote prices; the US exchanges share one symbol, so a quote on any of them is its one US line. */
221
262
  function quotedCandidate(candidates: readonly TickerSearchCandidate[], quote: Quote | null): TickerSearchCandidate | undefined {
222
263
  const quoted = canonicalExchange(quote?.listingExchangeName || quote?.exchangeName);