gloomberb 0.16.2 → 0.16.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 (124) hide show
  1. package/package.json +1 -1
  2. package/src/brokers/account-cache.ts +45 -0
  3. package/src/cli/command-options.ts +74 -0
  4. package/src/cli/commands/command-utils.ts +29 -0
  5. package/src/cli/commands/market.ts +170 -54
  6. package/src/cli/commands/overview.ts +62 -19
  7. package/src/cli/commands/ticker.ts +138 -43
  8. package/src/cli/desktop-pane-shot.ts +3 -0
  9. package/src/cli/help.ts +26 -6
  10. package/src/cli/history-rows.ts +187 -15
  11. package/src/cli/index.ts +12 -7
  12. package/src/cli/options.ts +5 -1
  13. package/src/cli/pane-functions/capabilities.ts +5 -0
  14. package/src/cli/pane-functions/dom.ts +69 -16
  15. package/src/cli/pane-functions/function-help.ts +4 -2
  16. package/src/cli/pane-functions/headless.ts +163 -31
  17. package/src/cli/pane-functions/index.ts +9 -2
  18. package/src/cli/pane-functions/report.ts +8 -1
  19. package/src/cli/pane-functions/resolver.ts +28 -2
  20. package/src/cli/pane-functions/screenshot.ts +4 -0
  21. package/src/cli/registry.ts +6 -3
  22. package/src/cli/report-tables.ts +460 -0
  23. package/src/cli/result.ts +13 -3
  24. package/src/components/data-table/export.ts +6 -51
  25. package/src/components/data-table/view.tsx +3 -1
  26. package/src/components/layout/shell/active-drag.ts +67 -35
  27. package/src/components/layout/shell/cursor-occlusion.ts +6 -5
  28. package/src/components/layout/shell/drag/index.ts +12 -7
  29. package/src/components/layout/shell/drag/live.ts +122 -0
  30. package/src/components/layout/shell/drag/overlays.tsx +179 -35
  31. package/src/components/layout/shell/drag/runtime.ts +62 -50
  32. package/src/components/layout/shell/drag/slide.ts +10 -0
  33. package/src/components/layout/shell/index.tsx +37 -51
  34. package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
  35. package/src/components/layout/shell/native/surfaces.ts +30 -14
  36. package/src/components/layout/shell/pane/layers.tsx +419 -179
  37. package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
  38. package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
  39. package/src/data/json-persistence.ts +17 -3
  40. package/src/data/json-storage.ts +2 -0
  41. package/src/data/memory-resource-store.ts +46 -0
  42. package/src/market-data/market/freshness.ts +45 -0
  43. package/src/market-data/quotes/freshness.ts +5 -0
  44. package/src/plugins/builtin/analytics/pane-model.ts +10 -6
  45. package/src/plugins/builtin/attention/pane.tsx +1 -1
  46. package/src/plugins/builtin/catalysts/detail.tsx +1 -1
  47. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  48. package/src/plugins/builtin/holders/client.ts +3 -6
  49. package/src/plugins/builtin/holders/format.ts +31 -1
  50. package/src/plugins/builtin/holders/headless.ts +26 -5
  51. package/src/plugins/builtin/holders/pane.tsx +22 -13
  52. package/src/plugins/builtin/holders/report-header.ts +46 -5
  53. package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
  54. package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
  55. package/src/plugins/builtin/members/pane.tsx +10 -0
  56. package/src/plugins/builtin/perps/pane.tsx +1 -1
  57. package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
  58. package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
  59. package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
  60. package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
  61. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  62. package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
  63. package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
  64. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
  65. package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
  66. package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
  67. package/src/plugins/builtin/shared/members-menu.tsx +260 -0
  68. package/src/plugins/builtin/short-volume/pane.tsx +1 -1
  69. package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
  70. package/src/plugins/builtin/themes/pane.tsx +3 -0
  71. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
  72. package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
  73. package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
  74. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
  75. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
  76. package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
  77. package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
  78. package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
  79. package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
  80. package/src/plugins/builtin/world-venue-map/index.ts +5 -5
  81. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
  82. package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
  83. package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
  84. package/src/plugins/builtin/world-venue-map/model.ts +16 -0
  85. package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
  86. package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
  87. package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
  88. package/src/renderers/browser/app-services.ts +3 -1
  89. package/src/renderers/dom/data-table/dom.ts +31 -0
  90. package/src/renderers/dom/data-table/index.tsx +5 -0
  91. package/src/renderers/dom/host/box.tsx +113 -17
  92. package/src/renderers/dom/host/drag-shield.ts +24 -0
  93. package/src/renderers/dom/host/live-frame.ts +54 -0
  94. package/src/renderers/dom/host/mouse.ts +8 -1
  95. package/src/renderers/dom/host/scroll-box.tsx +6 -5
  96. package/src/renderers/dom/host/tabs.tsx +3 -2
  97. package/src/renderers/dom/styles.css +13 -5
  98. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
  99. package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
  100. package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
  101. package/src/renderers/electrobun/shared/protocol.ts +10 -1
  102. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
  103. package/src/renderers/electrobun/view/host/native.ts +0 -10
  104. package/src/renderers/electrobun/view/host/window-move.ts +28 -0
  105. package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
  106. package/src/renderers/electrobun/view/ui-host.tsx +2 -5
  107. package/src/renderers/opentui/ui-host.tsx +7 -1
  108. package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
  109. package/src/sources/provider-router/batches.ts +3 -3
  110. package/src/sources/provider-router/financial-routes.ts +2 -2
  111. package/src/sources/provider-router/financials.ts +70 -7
  112. package/src/sources/provider-router/primary.ts +2 -2
  113. package/src/state/pane-scroll-registry.tsx +67 -35
  114. package/src/test-support/margin-account.ts +108 -0
  115. package/src/time-series/resolution.ts +15 -0
  116. package/src/types/financials.ts +32 -3
  117. package/src/ui/host.tsx +12 -0
  118. package/src/ui/index.tsx +8 -3
  119. package/src/ui/scroll-box-axis.ts +26 -0
  120. package/src/utils/currency-units.ts +8 -3
  121. package/src/utils/depositary-receipt.ts +54 -10
  122. package/src/utils/display-number.ts +63 -0
  123. package/src/utils/fundamentals.ts +17 -1
  124. package/src/utils/price-history-integrity.ts +19 -6
@@ -87,8 +87,8 @@ export function useShellTerminalPointerRuntime({
87
87
  windowMode,
88
88
  }: UseShellTerminalPointerRuntimeOptions) {
89
89
  const {
90
- dragFloatingRect,
91
- dragRef,
90
+ live,
91
+ startDrag,
92
92
  updateDividerPreview,
93
93
  updateDragFloatingRect,
94
94
  } = dragRuntime;
@@ -112,7 +112,7 @@ export function useShellTerminalPointerRuntime({
112
112
 
113
113
  for (const { pane, rect: visibleRect } of sortedFloatingPanes(visibleFloatingPanes)) {
114
114
  const paneId = pane.instance.instanceId;
115
- const rect = getVisibleFloatingRect(visibleRect, paneId, dragFloatingRect, width, contentHeight);
115
+ const rect = getVisibleFloatingRect(visibleRect, paneId, live.get().floating, width, contentHeight);
116
116
  if (!pointInRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }, event.x, shellY)) continue;
117
117
 
118
118
  const relativeX = event.x - rect.x;
@@ -120,13 +120,13 @@ export function useShellTerminalPointerRuntime({
120
120
  selectWindowModePane(paneId);
121
121
 
122
122
  if (relativeX >= rect.width - 2 && relativeY >= rect.height - 1) {
123
- dragRef.current = {
123
+ startDrag({
124
124
  type: "float-resize",
125
125
  paneId,
126
126
  startX: preciseX,
127
127
  startY: preciseShellY,
128
128
  origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
129
- };
129
+ });
130
130
  updateDragFloatingRect({ paneId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
131
131
  }
132
132
 
@@ -138,7 +138,7 @@ export function useShellTerminalPointerRuntime({
138
138
  if (!transientFocusActive) {
139
139
  for (const divider of dockDividerLayouts) {
140
140
  if (!pointInRect(divider.rect, event.x, shellY)) continue;
141
- dragRef.current = {
141
+ startDrag({
142
142
  type: "divider",
143
143
  path: divider.path,
144
144
  axis: divider.axis,
@@ -146,7 +146,7 @@ export function useShellTerminalPointerRuntime({
146
146
  startY: preciseShellY,
147
147
  startRatio: divider.ratio,
148
148
  bounds: divider.bounds,
149
- };
149
+ });
150
150
  updateDividerPreview({
151
151
  pathKey: divider.path.join("."),
152
152
  rect: divider.rect,
@@ -179,7 +179,7 @@ export function useShellTerminalPointerRuntime({
179
179
 
180
180
  for (const { pane, rect: visibleRect } of sortedFloatingPanes(visibleFloatingPanes)) {
181
181
  const paneId = pane.instance.instanceId;
182
- const rect = getVisibleFloatingRect(visibleRect, paneId, dragFloatingRect, width, contentHeight);
182
+ const rect = getVisibleFloatingRect(visibleRect, paneId, live.get().floating, width, contentHeight);
183
183
  if (!pointInRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }, event.x, shellY)) continue;
184
184
  const relativeX = event.x - rect.x;
185
185
  const relativeY = shellY - rect.y;
@@ -212,13 +212,13 @@ export function useShellTerminalPointerRuntime({
212
212
  return;
213
213
  }
214
214
  if (!transientFocusActive && relativeX >= rect.width - 2 && relativeY >= rect.height - 1) {
215
- dragRef.current = {
215
+ startDrag({
216
216
  type: "float-resize",
217
217
  paneId,
218
218
  startX: preciseX,
219
219
  startY: preciseShellY,
220
220
  origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
221
- };
221
+ });
222
222
  updateDragFloatingRect({ paneId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
223
223
  event.stopPropagation();
224
224
  event.preventDefault();
@@ -230,14 +230,14 @@ export function useShellTerminalPointerRuntime({
230
230
  return;
231
231
  }
232
232
  if (relativeY === 0) {
233
- dragRef.current = {
233
+ startDrag({
234
234
  type: "pane-drag",
235
235
  paneId,
236
236
  mode: "floating",
237
237
  startX: preciseX,
238
238
  startY: preciseShellY,
239
239
  origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
240
- };
240
+ });
241
241
  updateDragFloatingRect({ paneId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
242
242
  event.stopPropagation();
243
243
  event.preventDefault();
@@ -248,7 +248,7 @@ export function useShellTerminalPointerRuntime({
248
248
 
249
249
  for (const divider of dockDividerLayouts) {
250
250
  if (!pointInRect(divider.rect, event.x, shellY)) continue;
251
- dragRef.current = {
251
+ startDrag({
252
252
  type: "divider",
253
253
  path: divider.path,
254
254
  axis: divider.axis,
@@ -256,7 +256,7 @@ export function useShellTerminalPointerRuntime({
256
256
  startY: preciseShellY,
257
257
  startRatio: divider.ratio,
258
258
  bounds: divider.bounds,
259
- };
259
+ });
260
260
  updateDividerPreview({
261
261
  pathKey: divider.path.join("."),
262
262
  rect: divider.rect,
@@ -308,14 +308,14 @@ export function useShellTerminalPointerRuntime({
308
308
  return;
309
309
  }
310
310
  if (relativeY === 0) {
311
- dragRef.current = {
311
+ startDrag({
312
312
  type: "pane-drag",
313
313
  paneId: leaf.instanceId,
314
314
  mode: "docked",
315
315
  startX: preciseX,
316
316
  startY: preciseShellY,
317
317
  origRect: { x: leaf.rect.x, y: leaf.rect.y, width: leaf.rect.width, height: leaf.rect.height },
318
- };
318
+ });
319
319
  event.stopPropagation();
320
320
  event.preventDefault();
321
321
  return;
@@ -333,12 +333,11 @@ export function useShellTerminalPointerRuntime({
333
333
  contentHeight,
334
334
  dockDividerLayouts,
335
335
  dockLeafLayouts,
336
- dragFloatingRect,
337
- dragRef,
338
336
  focusPane,
339
337
  focusedPaneId,
340
338
  handleActiveDrag,
341
339
  handleFloatingClose,
340
+ live,
342
341
  restoreFullscreen,
343
342
  menuState,
344
343
  openPaneMenu,
@@ -346,6 +345,7 @@ export function useShellTerminalPointerRuntime({
346
345
  selectWindowModePane,
347
346
  setHoveredMenuItemId,
348
347
  setMenuState,
348
+ startDrag,
349
349
  transientFocusActive,
350
350
  updateDividerPreview,
351
351
  updateDragFloatingRect,
@@ -1,3 +1,4 @@
1
+ import { useCallback } from "react";
1
2
  import { Box, Text } from "../../../../ui";
2
3
  import type {
3
4
  DockGeometryOptions,
@@ -8,6 +9,7 @@ import type {
8
9
  } from "../../../../layout/pane-manager";
9
10
  import { colors } from "../../../../theme/colors";
10
11
  import { constrainFloatingRectToBounds } from "../drag";
12
+ import { useLiveDrag, type LiveDragGeometry, type LiveDragStore } from "../drag/live";
11
13
  import {
12
14
  getFloatingResizeCornerPosition,
13
15
  type WindowEditDockMovePreview,
@@ -27,9 +29,9 @@ interface ShellWindowModeOverlaysProps {
27
29
  contentHeight: number;
28
30
  dockGeometryOptions: DockGeometryOptions;
29
31
  dockLeafLayouts: DockLeafLayout[];
30
- dragFloatingRect: { paneId: string; rect: FloatingRect } | null;
31
32
  focusedPaneId: string | null;
32
33
  getPaneTitle: (pane: ResolvedPane) => string;
34
+ live: LiveDragStore;
33
35
  menuOpen: boolean;
34
36
  nativePaneChrome: boolean;
35
37
  nativeWindowModePanelRect: LayoutBounds | null;
@@ -64,9 +66,9 @@ export function ShellWindowModeOverlays({
64
66
  contentHeight,
65
67
  dockGeometryOptions,
66
68
  dockLeafLayouts,
67
- dragFloatingRect,
68
69
  focusedPaneId,
69
70
  getPaneTitle,
71
+ live,
70
72
  menuOpen,
71
73
  nativePaneChrome,
72
74
  nativeWindowModePanelRect,
@@ -84,9 +86,15 @@ export function ShellWindowModeOverlays({
84
86
  const highlightedDockedLeaf = highlightedPaneId && !highlightedFloatingPane
85
87
  ? dockLeafLayouts.find((leaf) => leaf.instanceId === highlightedPaneId)
86
88
  : null;
89
+ // The terminal frame follows a floating pane while it moves; desktop panes draw their own.
90
+ const followedPaneId = !nativePaneChrome && highlightedFloatingPane ? highlightedFloatingPane.pane.instance.instanceId : null;
91
+ const selectDragRect = useCallback((geometry: LiveDragGeometry) => (
92
+ followedPaneId && geometry.floating?.paneId === followedPaneId ? geometry.floating.rect : null
93
+ ), [followedPaneId]);
94
+ const dragRect = useLiveDrag(live, selectDragRect);
87
95
  const highlightedRect = highlightedFloatingPane
88
- ? dragFloatingRect?.paneId === highlightedFloatingPane.pane.instance.instanceId
89
- ? constrainFloatingRectToBounds(dragFloatingRect.rect, width, contentHeight)
96
+ ? dragRect
97
+ ? constrainFloatingRectToBounds(dragRect, width, contentHeight)
90
98
  : highlightedFloatingPane.rect
91
99
  : highlightedDockedLeaf?.rect ?? null;
92
100
  const highlightedZIndex = highlightedFloatingPane
@@ -1,21 +1,35 @@
1
1
  import type { AppPersistencePort } from "../core/app-service-ports";
2
2
  import type { PluginStateRecord } from "./plugin-state-store";
3
3
  import type { SessionSnapshotRecord } from "./session-store";
4
- import { MemoryResourceStore } from "./memory-resource-store";
4
+ import { resourceRecordKey, WriteThroughResourceStore } from "./memory-resource-store";
5
+ import type { CachedResourceRecord, ResourceCacheKey } from "./resource-store";
5
6
  import { BROWSER_STORAGE_KEYS, SafeJsonStorage, type StorageLike } from "./json-storage";
6
7
  import { isRecord } from "../utils/guards";
7
8
 
8
9
  type PluginState = Record<string, Record<string, PluginStateRecord>>;
9
10
  type Sessions = Record<string, SessionSnapshotRecord>;
11
+ type SavedResources = Record<string, CachedResourceRecord>;
10
12
 
11
13
  export class JsonPersistence implements AppPersistencePort {
12
- readonly resources = new MemoryResourceStore();
14
+ /** In memory, except the records `keepResource` picks, which outlive a reload like the positions do. */
15
+ readonly resources: WriteThroughResourceStore;
13
16
  private readonly pluginStateData: SafeJsonStorage<PluginState>;
14
17
  private readonly sessionData: SafeJsonStorage<Sessions>;
18
+ private readonly resourceData: SafeJsonStorage<SavedResources>;
15
19
 
16
- constructor(storage?: StorageLike) {
20
+ constructor(storage?: StorageLike, { keepResource = () => false }: { keepResource?: (key: ResourceCacheKey) => boolean } = {}) {
17
21
  this.pluginStateData = new SafeJsonStorage(storage, BROWSER_STORAGE_KEYS.pluginState, {}, (value): value is PluginState => isRecord(value));
18
22
  this.sessionData = new SafeJsonStorage(storage, BROWSER_STORAGE_KEYS.session, {}, (value): value is Sessions => isRecord(value));
23
+ this.resourceData = new SafeJsonStorage(storage, BROWSER_STORAGE_KEYS.resources, {}, (value): value is SavedResources => isRecord(value));
24
+ const saved = Object.values(this.resourceData.get()).filter((record) => isRecord(record) && typeof record.kind === "string");
25
+ this.resources = new WriteThroughResourceStore(saved, {
26
+ set: (record) => this.resourceData.set({ ...this.resourceData.get(), [resourceRecordKey(record)]: record }),
27
+ delete: (key) => {
28
+ const next = { ...this.resourceData.get() };
29
+ delete next[resourceRecordKey(key)];
30
+ this.resourceData.set(next);
31
+ },
32
+ }, keepResource);
19
33
  }
20
34
 
21
35
  readonly pluginState = {
@@ -47,4 +47,6 @@ export const BROWSER_STORAGE_KEYS = {
47
47
  tickers: "gloomberb.web.tickers.v1",
48
48
  pluginState: "gloomberb.web.plugin-state.v1",
49
49
  session: "gloomberb.web.session.v1",
50
+ /** Broker account snapshots; other caches stay in memory. */
51
+ resources: "gloomberb.web.resources.v1",
50
52
  } as const;
@@ -103,4 +103,50 @@ export class MemoryResourceStore {
103
103
  delete(key: ResourceCacheKey): void {
104
104
  this.records.delete(buildMapKey(key));
105
105
  }
106
+
107
+ /** Puts back a record saved elsewhere, with its own times. */
108
+ protected restore(record: CachedResourceRecord): void {
109
+ this.records.set(buildMapKey(record), { ...record });
110
+ }
111
+ }
112
+
113
+ export function resourceRecordKey(key: ResourceCacheKey): string {
114
+ return buildMapKey(key);
115
+ }
116
+
117
+ /** Where a renderer without the cache database keeps the records that must outlive a restart. */
118
+ export interface ResourceRecordSink {
119
+ set(record: CachedResourceRecord): void;
120
+ delete(key: ResourceCacheKey): void;
121
+ }
122
+
123
+ /**
124
+ * The in-memory store, except that the records `durable` picks are handed to
125
+ * a sink as they change and come back from it on the next start. The desktop
126
+ * view and the web app have no cache database: without this a broker
127
+ * account's snapshot lived only until the app closed, while its positions
128
+ * were saved.
129
+ */
130
+ export class WriteThroughResourceStore extends MemoryResourceStore {
131
+ constructor(
132
+ saved: readonly CachedResourceRecord[],
133
+ private readonly sink: ResourceRecordSink,
134
+ private readonly durable: (key: ResourceCacheKey) => boolean,
135
+ ) {
136
+ super();
137
+ for (const record of saved) {
138
+ if (durable(record)) this.restore(record);
139
+ }
140
+ }
141
+
142
+ override set<T>(key: ResourceCacheKey, value: T, options: SetResourceOptions): CachedResourceRecord<T> {
143
+ const record = super.set(key, value, options);
144
+ if (this.durable(key)) this.sink.set(record);
145
+ return record;
146
+ }
147
+
148
+ override delete(key: ResourceCacheKey): void {
149
+ super.delete(key);
150
+ if (this.durable(key)) this.sink.delete(key);
151
+ }
106
152
  }
@@ -414,6 +414,51 @@ export function isUsPriorSessionPremarketQuote(
414
414
  return localTradingDaysBetween(canonical, timestampDate, currentDate) === 1;
415
415
  }
416
416
 
417
+ /**
418
+ * After the regular close and before any after-hours trade, a US quote's last
419
+ * print is today's regular session, and that close is the current price: a thin
420
+ * listing may not trade again tonight, and for fifteen minutes after the close
421
+ * the delayed feed still shows the session's last minutes. The source labels
422
+ * the quote POST (or CLOSED) and has no after-hours price to show. The print
423
+ * must be from today's New York trading day, from the regular open through the
424
+ * regular close (the published early close included). An earlier day's print
425
+ * or this morning's pre-market is not today's close.
426
+ */
427
+ export function isUsRegularCloseQuoteInPostSession(
428
+ timestampMs: number,
429
+ exchange: string | undefined,
430
+ marketState: MarketState | undefined,
431
+ now = Date.now(),
432
+ ): boolean {
433
+ const canonical = canonicalExchange(exchange);
434
+ if ((marketState !== "POST" && marketState !== "CLOSED") || !isUsListingExchange(canonical)) return false;
435
+ if (!Number.isFinite(timestampMs) || !Number.isFinite(now) || timestampMs > now + quoteFutureToleranceMs()) return false;
436
+ if (!Number.isFinite(new Date(now).getTime()) || usSessionState(now) !== "POST") return false;
437
+ const currentDate = exchangeLocalDate(canonical, now);
438
+ if (!currentDate || exchangeLocalDate(canonical, timestampMs) !== currentDate) return false;
439
+ if (!isLocalTradingDay(canonical, currentDate)) return false;
440
+ const published = getPublishedUsEquitySession(canonical, currentDate);
441
+ if (published) return published.kind === "session" && timestampMs >= published.open && timestampMs <= published.close;
442
+ return usSessionState(timestampMs) === "REGULAR";
443
+ }
444
+
445
+ /**
446
+ * Whether a US print belongs to the after-hours session in progress: today's,
447
+ * from the regular close. An earlier day's after-hours print is not.
448
+ */
449
+ export function isUsPostSessionPrint(
450
+ timestampMs: number,
451
+ exchange: string | undefined,
452
+ now = Date.now(),
453
+ ): boolean {
454
+ const canonical = canonicalExchange(exchange);
455
+ if (!isUsListingExchange(canonical)) return false;
456
+ if (!Number.isFinite(timestampMs) || !Number.isFinite(now) || timestampMs > now + quoteFutureToleranceMs()) return false;
457
+ if (!Number.isFinite(new Date(now).getTime()) || usSessionState(now) !== "POST") return false;
458
+ const currentDate = exchangeLocalDate(canonical, now);
459
+ return !!currentDate && exchangeLocalDate(canonical, timestampMs) === currentDate && usSessionState(timestampMs) === "POST";
460
+ }
461
+
417
462
  export function isTimestampStaleForExchangeSession(
418
463
  timestampMs: number,
419
464
  exchange?: string,
@@ -7,6 +7,7 @@ import {
7
7
  activeUsExtendedHoursSession,
8
8
  isTimestampStaleForExchangeSession,
9
9
  isUsPriorSessionPremarketQuote,
10
+ isUsRegularCloseQuoteInPostSession,
10
11
  } from "../market/freshness";
11
12
 
12
13
  // Give the next regular session's NAV time to publish after its actual close.
@@ -61,6 +62,10 @@ function isQuoteMissingActiveSessionPrice(quote: Quote, now: number): boolean {
61
62
  if (!isExtendedHoursExchange(quote)) return false;
62
63
  const activeSession = activeUsExtendedHoursSession(now);
63
64
  if (!activeSession) return false;
65
+ // No after-hours trade yet: today's regular close is the current price.
66
+ if (activeSession === "POST" && quote.postMarketPrice == null && isUsRegularCloseQuoteInPostSession(
67
+ quote.lastUpdated, quote.listingExchangeName || quote.exchangeName, quote.marketState, now,
68
+ )) return false;
64
69
  if (quote.marketState !== activeSession) return true;
65
70
  if (activeSession === "POST") return quote.postMarketPrice == null;
66
71
  // No pre-market trade yet: the previous session's close is the current price.
@@ -12,6 +12,7 @@ import { instrumentFromTicker, type ChartRequest, type TickerInstrumentOptions }
12
12
  import { buildChartKey } from "../../../market-data/selectors";
13
13
  import {
14
14
  resolvePortfolioAccountMetrics,
15
+ resolvePortfolioLeverage,
15
16
  resolvePortfolioMarketValue,
16
17
  resolvePortfolioNetLiquidation,
17
18
  } from "../portfolio-list/account-metrics";
@@ -69,8 +70,8 @@ function formatAccountFreshness(account: ResolvedPortfolioAccountState["account"
69
70
  }
70
71
 
71
72
  function formatMarginLeverage(netLiquidation: number | undefined, totalMarketValue: number): string | null {
72
- if (!isFiniteNumber(netLiquidation) || netLiquidation <= 0 || !isFiniteNumber(totalMarketValue) || totalMarketValue < 0) return null;
73
- return `${(totalMarketValue / netLiquidation).toFixed(1)}x`;
73
+ const leverage = resolvePortfolioLeverage(totalMarketValue, netLiquidation);
74
+ return leverage == null ? null : `${leverage.toFixed(1)}x`;
74
75
  }
75
76
 
76
77
  export function buildPortfolioChartTargets(
@@ -205,6 +206,7 @@ export function buildPortfolioBetaResult(portfolio: PortfolioReturnSeriesResult,
205
206
 
206
207
  export function buildAnalyticsSummaryRows({
207
208
  accountState,
209
+ activePortfolio,
208
210
  brokerPerformance,
209
211
  portfolioStats,
210
212
  convertAccountValue = (value) => value,
@@ -232,6 +234,8 @@ export function buildAnalyticsSummaryRows({
232
234
  const totalMarketValue = resolvePortfolioMarketValue(portfolioStats, account, convertAccountValue, basis);
233
235
  const netLiquidation = resolvePortfolioNetLiquidation(portfolioStats, account, convertAccountValue, basis);
234
236
  const hasMarketValue = portfolioStats.hasPositions || isFiniteNumber(account?.grossPositionValue);
237
+ // As in the portfolio header: a broker account is worth its equity, and its positions alone are its gross.
238
+ const broker = !!account || !!activePortfolio?.brokerInstanceId;
235
239
 
236
240
  if (portfolioStats.unavailableConversions?.length || (account && !Number.isFinite(convertAccountValue(1)))) {
237
241
  rows.push({
@@ -241,19 +245,19 @@ export function buildAnalyticsSummaryRows({
241
245
  });
242
246
  }
243
247
 
244
- if (netLiquidation != null) {
248
+ if (netLiquidation != null || (broker && hasMarketValue)) {
245
249
  rows.push({
246
250
  id: "net-liquidation",
247
251
  label: "Net Liq",
248
- value: money(netLiquidation),
249
- color: colors.text,
252
+ value: money(netLiquidation ?? Number.NaN),
253
+ color: netLiquidation != null ? colors.text : colors.textDim,
250
254
  });
251
255
  }
252
256
 
253
257
  if (hasMarketValue) {
254
258
  rows.push({
255
259
  id: "total-value",
256
- label: "Val",
260
+ label: broker || portfolioStats.hasShorts ? "Gross" : "Val",
257
261
  value: money(totalMarketValue),
258
262
  color: colors.text,
259
263
  });
@@ -58,7 +58,7 @@ function Evidence({ row, data, width, height }: { row: DetailRow; data: Attentio
58
58
  const sameDay = row.evidence.periodStart.slice(0, 10) === row.evidence.periodEnd.slice(0, 10);
59
59
  const period = sameDay ? `${hour(row.evidence.periodStart)} to ${row.evidence.periodEnd.slice(11, 16)} UTC` : `${hour(row.evidence.periodStart)} to ${date(row.evidence.periodEnd)}`;
60
60
  const latest = point?.bucketStart === row.history.at(-1)?.bucketStart;
61
- return <ScrollBox width={width} height={desktop ? undefined : height} flexGrow={1} flexBasis={0} contentOptions={{ paddingX: 1 }}>
61
+ return <ScrollBox scrollY width={width} height={desktop ? undefined : height} flexGrow={1} flexBasis={0} contentOptions={{ paddingX: 1 }}>
62
62
  <KeyValueRow label="Listing" value={row.symbol} detail={row.name ?? undefined} labelWidth={20} />
63
63
  <SectionHeading title="Publication" />
64
64
  <KeyValueRow label="Research hours" value={number(row.researchUnits)} detail={`rounded to ${data.privacy.rounding}`} labelWidth={20} />
@@ -18,7 +18,7 @@ function Evidence({ event, width, height }: { event: CatalystEvent; width: numbe
18
18
  const facts = catalystFacts(event);
19
19
  const procedures = Array.isArray(event.metadata?.proceduralStatements) ? event.metadata.proceduralStatements.filter((value): value is string => typeof value === "string") : [];
20
20
  const documents = Array.isArray(event.metadata?.documents) ? event.metadata.documents.filter((value): value is { url: string; title?: string; label?: string } => !!value && typeof value === "object" && "url" in value && typeof value.url === "string" && /^https?:\/\//.test(value.url)) : [];
21
- return <ScrollBox width={width} height={desktop ? undefined : height} flexGrow={1} flexBasis={0} contentOptions={{ paddingX: 1 }}>
21
+ return <ScrollBox scrollY width={width} height={desktop ? undefined : height} flexGrow={1} flexBasis={0} contentOptions={{ paddingX: 1 }}>
22
22
  <KeyValueRow label="Classification" value={`${catalystLabel(event.type)} · ${catalystAgency(event.agency)} · ${event.country ?? event.jurisdiction}`} labelWidth={18} />
23
23
  <KeyValueRow label="Status" value={humanLabel(event.status)} detail={`Revision ${event.revision}`} labelWidth={18} />
24
24
  <KeyValueRow label="Announced" value={catalystDate(event, "announced")} labelWidth={18} />
@@ -33,7 +33,7 @@ const LABEL_COLUMNS = new Set(["chart"]);
33
33
  function Evidence({ row, width, height }: Size & { row: AttentionRow }) {
34
34
  const colors = useThemeColors();
35
35
  const desktop = !!useUiCapabilities().nativePaneChrome;
36
- return <ScrollBox width={width} height={desktop ? undefined : height} flexGrow={1} flexBasis={0} contentOptions={{ paddingX: 1 }}>
36
+ return <ScrollBox scrollY width={width} height={desktop ? undefined : height} flexGrow={1} flexBasis={0} contentOptions={{ paddingX: 1 }}>
37
37
  {(row.details ?? Object.entries(row.values).map(([label, value]) => ({ label, value: String(value ?? "--") }))).map((entry) => <KeyValueRow key={entry.label} label={entry.label} value={entry.value} labelWidth={22} />)}
38
38
  {row.url ? <Box paddingY={1}><Text fg={colors.textMuted}>{row.url}</Text></Box> : null}
39
39
  </ScrollBox>;
@@ -1,5 +1,6 @@
1
1
  import type { DataProvider, MarketDataRequestContext } from "../../../types/data-provider";
2
2
  import type { HolderData } from "../../../types/financials";
3
+ import { holderStakeMarketCap, holderValueCurrency } from "./format";
3
4
 
4
5
  export async function loadHolderData(
5
6
  provider: DataProvider,
@@ -20,10 +21,6 @@ export async function loadHolderSnapshot(
20
21
  loadHolderData(provider, symbol, exchange),
21
22
  provider.getTickerFinancials(symbol, exchange).catch(() => null),
22
23
  ]);
23
- const quoteMarketCap = financials?.quote?.marketCap;
24
- const marketCap = financials?.quote?.currency && data.currency
25
- && financials.quote.currency !== data.currency
26
- ? undefined
27
- : quoteMarketCap;
28
- return { data, marketCap };
24
+ const quote = financials?.quote;
25
+ return { data, marketCap: holderStakeMarketCap(quote?.marketCap, quote?.currency, holderValueCurrency(data)) };
29
26
  }
@@ -1,4 +1,4 @@
1
- import type { HolderRecord } from "../../../types/financials";
1
+ import type { HolderData, HolderRecord } from "../../../types/financials";
2
2
  import { formatCompact, formatPercent, formatPercentRaw } from "../../../utils/format";
3
3
  import type { HolderRow } from "./types";
4
4
 
@@ -16,15 +16,45 @@ export function formatMaybePercent(value: number | undefined): string {
16
16
  return Math.abs(value) <= 1 ? formatPercent(value) : formatPercentRaw(value);
17
17
  }
18
18
 
19
+ /**
20
+ * The currency holder values are in. Gloom Cloud names it when it differs
21
+ * from the listing's (dollars from 13F filings on a London line); an older
22
+ * service priced them in the listing's currency.
23
+ */
24
+ export function holderValueCurrency(
25
+ data: Pick<HolderData, "valueCurrency" | "currency"> | null | undefined,
26
+ fallback?: string,
27
+ ): string | undefined {
28
+ return data?.valueCurrency?.trim() || data?.currency?.trim() || fallback;
29
+ }
30
+
31
+ /**
32
+ * The market cap a holding's value can be divided by: only one in the
33
+ * values' own currency. A cap whose currency, or values whose currency, is
34
+ * not known is taken as it was before currencies were stated.
35
+ */
36
+ export function holderStakeMarketCap(
37
+ marketCap: number | undefined,
38
+ marketCapCurrency: string | null | undefined,
39
+ valueCurrency: string | null | undefined,
40
+ ): number | undefined {
41
+ if (marketCap == null) return undefined;
42
+ if (marketCapCurrency && valueCurrency && marketCapCurrency !== valueCurrency) return undefined;
43
+ return marketCap;
44
+ }
45
+
46
+ /** The reported stake, else the value over a market cap `holderStakeMarketCap` allowed. */
19
47
  export function resolveHolderOwnershipPercent(row: Pick<HolderRecord, "percentHeld" | "value">, marketCap: number | undefined): number | undefined {
20
48
  if (row.percentHeld != null) return row.percentHeld;
21
49
  if (row.value == null || row.value < 0 || marketCap == null || marketCap <= 0) return undefined;
22
50
  return row.value / marketCap;
23
51
  }
24
52
 
53
+ /** A stake too small for two decimals reads "<0.01%", not a zero. */
25
54
  export function formatHolderOwnershipPercent(value: number | undefined): string {
26
55
  if (value == null) return "-";
27
56
  const percent = Math.abs(value) <= 1 ? value * 100 : value;
57
+ if (percent > 0 && percent < 0.005) return "<0.01%";
28
58
  return `${percent.toFixed(2)}%`;
29
59
  }
30
60
 
@@ -12,6 +12,7 @@ import {
12
12
  formatMaybePercent,
13
13
  formatMoneyCompact,
14
14
  formatSignedCompact,
15
+ holderValueCurrency,
15
16
  resolveHolderOwnershipPercent,
16
17
  } from "./format";
17
18
  import { buildRows, sortRows } from "./table-model";
@@ -19,6 +20,8 @@ import type { HolderData } from "../../../types/financials";
19
20
  import type { HolderColumnId } from "./types";
20
21
  import { SEC_FILINGS } from "../shared/report-freshness";
21
22
  import {
23
+ holderShareBasisMarker,
24
+ holderShareBasisNote,
22
25
  holderValueBasis,
23
26
  holdersHeaderLine,
24
27
  moneyColumnHeader,
@@ -28,6 +31,8 @@ import {
28
31
 
29
32
  /** Columns a source fills only when it reports the quarter's change. */
30
33
  const CHANGE_COLUMN_KEYS = new Set(["changeShares", "changePercent"]);
34
+ /** Shown only when some row is held as depositary receipts, as on a home line abroad. */
35
+ const BASIS_COLUMN_KEY = "shareBasis";
31
36
  import type { BeneficialOwnersPayload } from "../../../api-client/beneficial-owners";
32
37
  import { fetchBeneficialOwners, type BeneficialOwnersRequest } from "./beneficial-client";
33
38
  import type { BeneficialColumnId } from "./beneficial-model";
@@ -60,6 +65,11 @@ const HOLDER_COLUMNS: HeadlessPaneColumn[] = [
60
65
  align: "right",
61
66
  format: (value) => value == null ? "-" : formatCompact(Number(value)),
62
67
  },
68
+ {
69
+ key: BASIS_COLUMN_KEY,
70
+ header: "Basis",
71
+ format: (_value, row) => holderShareBasisMarker(row),
72
+ },
63
73
  {
64
74
  key: "changeShares",
65
75
  header: "Chg",
@@ -238,6 +248,8 @@ export function createHoldersHeadless(
238
248
  }
239
249
  const { data, marketCap } = await dependencies.loadSnapshot(symbol, args, ctx);
240
250
  const currency = data.currency ?? "USD";
251
+ // A London line's values can be dollars from the 13F filings; they are labelled and formatted as such.
252
+ const valueCurrency = holderValueCurrency(data, currency)!;
241
253
  const sorted = sortRows(buildRows(data), {
242
254
  columnId: SORT_COLUMNS[String(args.options.sort)] ?? "value",
243
255
  direction: args.options.order === "asc" ? "asc" : "desc",
@@ -253,19 +265,23 @@ export function createHoldersHeadless(
253
265
  changePercent: row.changePercent ?? null,
254
266
  percentHeld: resolveHolderOwnershipPercent(row, marketCap) ?? null,
255
267
  reportDate: row.reportDate ?? null,
256
- currency,
268
+ currency: valueCurrency,
269
+ shareBasis: row.shareBasis ?? null,
257
270
  }));
258
271
  const total = data.summary?.institutionsCount ?? null;
259
272
  const changeReported = rows.some((row) => row.changeShares != null || row.changePercent != null);
260
273
  const reportDate = sharedReportDate(rows);
261
- const valueBasis = holderValueBasis(reportDate);
274
+ const valueBasis = holderValueBasis(reportDate, data.valueBasis, valueCurrency);
262
275
  const positionsBasis = rows.length > 0 ? nonUsHolderCaveat(data.exchange, currency) : null;
276
+ const shareBasisNote = holderShareBasisNote(data, rows);
277
+ const markedRows = rows.some((row) => holderShareBasisMarker(row));
263
278
  // The title names the ticker and the closing line carries the as-of date.
264
279
  const header = holdersHeaderLine({
265
280
  name: data.name,
266
281
  symbol: data.symbol && data.symbol !== symbol ? data.symbol : null,
267
282
  exchange: data.exchange,
268
- currency,
283
+ currency: valueCurrency,
284
+ listingCurrency: data.currency,
269
285
  shown: rows.length,
270
286
  reported: sorted.length,
271
287
  total,
@@ -278,13 +294,18 @@ export function createHoldersHeadless(
278
294
  // A change column that is empty on every row says nothing; the line above explains it once.
279
295
  columns: HOLDER_COLUMNS
280
296
  .filter((column) => changeReported || !CHANGE_COLUMN_KEYS.has(column.key))
281
- .map((column) => column.key === "value" ? { ...column, header: moneyColumnHeader(column.header, currency) } : column),
297
+ .filter((column) => markedRows || column.key !== BASIS_COLUMN_KEY)
298
+ .map((column) => column.key === "value" ? { ...column, header: moneyColumnHeader(column.header, valueCurrency) } : column),
282
299
  rows,
283
300
  metadata: {
284
301
  symbol: data.symbol || symbol,
285
302
  name: data.name ?? null,
286
303
  exchange: data.exchange ?? null,
287
304
  currency,
305
+ valueCurrency,
306
+ ...(data.isDepositaryReceipt != null ? { isDepositaryReceipt: data.isDepositaryReceipt } : {}),
307
+ ...(data.shareBasis ? { shareBasis: data.shareBasis } : {}),
308
+ ...(data.adrRatio != null ? { adrRatio: data.adrRatio } : {}),
288
309
  asOf: data.asOf ?? null,
289
310
  summary: data.summary ?? null,
290
311
  shown: rows.length,
@@ -294,7 +315,7 @@ export function createHoldersHeadless(
294
315
  valueBasis,
295
316
  changeReported,
296
317
  positionsBasis,
297
- notices: [header, basis, positionsBasis].filter((line): line is string => !!line),
318
+ notices: [header, basis, positionsBasis, shareBasisNote].filter((line): line is string => !!line),
298
319
  },
299
320
  };
300
321
  },