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
@@ -0,0 +1,260 @@
1
+ import { ChoiceDialog, TextPromptDialog, usePaneMenuItems } from "../../../components";
2
+ import { createFormCollectionActions } from "../../../components/form-modal/deps";
3
+ import { t, tf } from "../../../i18n";
4
+ import { usePluginAppActions } from "../../../public/react";
5
+ import { useAppDispatch, useAppGetState, useAppSelector } from "../../../state/app/context";
6
+ import { formatTickerListInput, MAX_TICKER_LIST_SIZE } from "../../../tickers/list";
7
+ import type { TickerMetadata, TickerRecord } from "../../../types/ticker";
8
+ import { useOptionalDialog, type PromptContext } from "../../../ui/dialog";
9
+ import { canonicalExchange, publicTickerKey } from "../../../utils/exchanges";
10
+ import { slugifyName } from "../../../utils/slugify";
11
+ import { getSharedRegistry } from "../../registry";
12
+ import { MAX_CORRELATION_TICKERS } from "../correlation/settings";
13
+ import { ROTATION_LIMIT } from "../relative-rotation/model";
14
+ import { SHORT_WATCH_LIMIT } from "../short-interest/watch-model";
15
+
16
+ /** One US-listed member of a theme or fund, in the order its list shows it. */
17
+ export interface MemberListing {
18
+ symbol: string;
19
+ name?: string | null;
20
+ exchange?: string | null;
21
+ }
22
+
23
+ /** The members a pane shows, and what to call them. */
24
+ export interface MemberList {
25
+ /** Names the members in dialog titles: a theme's name or a fund's ticker. */
26
+ title: string;
27
+ /** The name the save dialog suggests. */
28
+ watchlistName: string;
29
+ members: readonly MemberListing[];
30
+ /** A search narrowing the list: what was typed and how many members the whole list has. */
31
+ search?: { query: string; total: number } | null;
32
+ }
33
+
34
+ interface MemberDestination {
35
+ templateId: string;
36
+ label: string;
37
+ /** The most symbols the destination takes; more would be refused or cut off there. */
38
+ limit: number;
39
+ minimum: number;
40
+ }
41
+
42
+ /**
43
+ * Functions that take a list of tickers through `options.symbols`. CORR and
44
+ * RIPL resolve the list the way the command bar does, which takes ten
45
+ * tickers; RIPL's own setting takes more, but not through a new pane.
46
+ */
47
+ export const MEMBER_DESTINATIONS: readonly MemberDestination[] = [
48
+ { templateId: "relative-rotation-rrg", label: "Relative Rotation (RRG)", limit: ROTATION_LIMIT, minimum: 1 },
49
+ { templateId: "correlation-pane", label: "Correlation Matrix (CORR)", limit: MAX_CORRELATION_TICKERS, minimum: 2 },
50
+ { templateId: "short-watch-pane", label: "Short Squeeze Watch (SIW)", limit: SHORT_WATCH_LIMIT, minimum: 1 },
51
+ { templateId: "earnings-ripple-pane", label: "Earnings Ripple (RIPL)", limit: MAX_TICKER_LIST_SIZE, minimum: 1 },
52
+ ];
53
+
54
+ /**
55
+ * A watchlist is a list to read and follow; a whole Russell 2000 stays in
56
+ * MEMB. Larger lists save their first 100 as listed.
57
+ */
58
+ export const MEMBER_WATCHLIST_LIMIT = 100;
59
+
60
+ /**
61
+ * Distinct members in list order, as `SYMBOL:EXCHANGE` when the list names the
62
+ * listing: CORR and RIPL resolve each one, and a bare CCJ also matches Xetra.
63
+ */
64
+ export function memberSymbols(members: readonly MemberListing[]): string[] {
65
+ const seen = new Set<string>();
66
+ return members.flatMap((member) => {
67
+ const symbol = member.symbol.trim().toUpperCase();
68
+ if (!symbol || seen.has(symbol)) return [];
69
+ seen.add(symbol);
70
+ const exchange = member.exchange?.trim().toUpperCase();
71
+ return [exchange ? `${symbol}:${exchange}` : symbol];
72
+ });
73
+ }
74
+
75
+ /**
76
+ * What each destination would open: the first `limit` symbols as listed, or
77
+ * disabled below its minimum. A disabled row cannot be selected, so its reason
78
+ * sits beside the label rather than in the description under the list.
79
+ */
80
+ export function memberDestinationChoices(symbols: readonly string[], destinations: readonly MemberDestination[]) {
81
+ return destinations.map((destination) => {
82
+ const taken = symbols.slice(0, destination.limit);
83
+ const tooFew = taken.length < destination.minimum;
84
+ return {
85
+ id: destination.templateId,
86
+ label: destination.label,
87
+ symbols: taken,
88
+ disabled: tooFew,
89
+ detail: tooFew ? tf("needs {count}", { count: destination.minimum }) : undefined,
90
+ description: tooFew ? undefined
91
+ : taken.length < symbols.length ? tf("First {count} of {total} as listed", { count: taken.length, total: symbols.length })
92
+ : tf("All {count}", { count: taken.length }),
93
+ };
94
+ });
95
+ }
96
+
97
+ /**
98
+ * The dialog text for `count` distinct members. A search says what it matched
99
+ * and how many members the whole list has, so a saved or opened subset never
100
+ * reads as the whole fund.
101
+ */
102
+ function memberListText(list: Pick<MemberList, "title" | "search">, count: number) {
103
+ const query = list.search?.query.trim();
104
+ const search = query ? { query, total: list.search!.total } : null;
105
+ const saved = Math.min(count, MEMBER_WATCHLIST_LIMIT);
106
+ return {
107
+ openTitle: search ? tf("Open {title} members matching \"{query}\" in", { title: list.title, query: search.query })
108
+ : tf("Open {title} members in", { title: list.title }),
109
+ saveBody: count > MEMBER_WATCHLIST_LIMIT
110
+ ? search ? tf("The first {count} of the {matching} members matching \"{query}\", as listed.", { count: saved, matching: count, query: search.query })
111
+ : tf("The first {count} of {total} members, as listed.", { count: saved, total: count })
112
+ : search ? tf("{count} of {total} members match \"{query}\".", { count, total: search.total, query: search.query })
113
+ : tf("All {count} members.", { count }),
114
+ };
115
+ }
116
+
117
+ /** The requested name, numbered when a watchlist already has that name or id. */
118
+ export function uniqueWatchlistName(requested: string, watchlists: readonly { id: string; name: string }[]): string {
119
+ const base = requested.trim();
120
+ const taken = (name: string) => watchlists.some((list) =>
121
+ list.name.toLowerCase() === name.toLowerCase() || list.id === slugifyName(name, "watchlist"));
122
+ let name = base;
123
+ for (let suffix = 2; taken(name); suffix += 1) name = `${base} ${suffix}`;
124
+ return name;
125
+ }
126
+
127
+ /**
128
+ * Tickers to create on the watchlist and existing ones to add to it, first
129
+ * `MEMBER_WATCHLIST_LIMIT` members as listed. A saved record of the same
130
+ * symbol on another venue (CCJ on Xetra) stays as it is: the member's listing
131
+ * gets its own `SYMBOL:EXCHANGE` record, the way search opens a second listing.
132
+ */
133
+ export function planMemberWatchlist(
134
+ tickers: ReadonlyMap<string, TickerRecord>,
135
+ watchlistId: string,
136
+ members: readonly MemberListing[],
137
+ ): { create: TickerMetadata[]; update: TickerRecord[]; count: number } {
138
+ const seen = new Set<string>();
139
+ const create: TickerMetadata[] = [];
140
+ const update: TickerRecord[] = [];
141
+ for (const member of members) {
142
+ const symbol = member.symbol.trim().toUpperCase();
143
+ if (!symbol || seen.has(symbol)) continue;
144
+ if (seen.size >= MEMBER_WATCHLIST_LIMIT) break;
145
+ seen.add(symbol);
146
+ const exchange = member.exchange?.trim() ?? "";
147
+ let key = symbol;
148
+ let existing = tickers.get(symbol);
149
+ if (existing && exchange && existing.metadata.exchange
150
+ && canonicalExchange(existing.metadata.exchange) !== canonicalExchange(exchange)) {
151
+ key = publicTickerKey(symbol, exchange);
152
+ existing = tickers.get(key);
153
+ }
154
+ if (existing) {
155
+ if (!existing.metadata.watchlists.includes(watchlistId)) {
156
+ update.push({ ...existing, metadata: { ...existing.metadata, watchlists: [...existing.metadata.watchlists, watchlistId] } });
157
+ }
158
+ continue;
159
+ }
160
+ // Theme and fund members are US listings quoted in dollars. The sector is
161
+ // left to the ticker's profile, so a list never mixes two sector schemes.
162
+ create.push({ ticker: key, name: member.name || symbol, exchange, currency: "USD",
163
+ portfolios: [], watchlists: [watchlistId], positions: [], custom: {}, tags: [] });
164
+ }
165
+ return { create, update, count: seen.size };
166
+ }
167
+
168
+ /**
169
+ * Pane menu entries for a list of members (a theme's, a fund's): open them in
170
+ * RRG, CORR, SIW or RIPL, or save them as a watchlist. Functions of disabled
171
+ * plugins are left out; each destination gets as many as it takes.
172
+ */
173
+ export function useMembersMenu(registrationId: string, list: MemberList | null) {
174
+ const dialog = useOptionalDialog();
175
+ const { createPaneFromTemplate, notify } = usePluginAppActions();
176
+ const dispatch = useAppDispatch();
177
+ const getState = useAppGetState();
178
+ const disabledPlugins = useAppSelector((state) => state.config.disabledPlugins);
179
+ usePaneMenuItems(registrationId, () => {
180
+ const registry = getSharedRegistry();
181
+ const symbols = list ? memberSymbols(list.members) : [];
182
+ if (!list || !symbols.length || !dialog || !registry) return null;
183
+ const destinations = MEMBER_DESTINATIONS.filter((destination) => {
184
+ const owner = registry.getPaneTemplatePluginId?.(destination.templateId);
185
+ return registry.paneTemplates.has(destination.templateId) && (!owner || !disabledPlugins.includes(owner));
186
+ });
187
+ const choices = memberDestinationChoices(symbols, destinations);
188
+ const text = memberListText(list, symbols.length);
189
+ const openIn = () => {
190
+ void dialog.prompt<string>({
191
+ closeOnClickOutside: true,
192
+ content: (context: PromptContext<string>) => <ChoiceDialog {...context} title={text.openTitle}
193
+ choices={choices.map(({ id, label, description, detail, disabled }) => ({ id, label: t(label), description, detail, disabled }))} />,
194
+ }).then((templateId) => {
195
+ const choice = choices.find((item) => item.id === templateId && !item.disabled);
196
+ // CORR and RIPL resolve `arg` like the command bar; RRG and SIW read `symbols`.
197
+ if (choice) createPaneFromTemplate(choice.id, { symbols: choice.symbols, arg: formatTickerListInput(choice.symbols) });
198
+ }).catch(() => {});
199
+ };
200
+ const saveAsWatchlist = async (requested: string) => {
201
+ // The writes hold the app for about a second with a hundred names; let the dialog close on screen first.
202
+ await new Promise((resolve) => setTimeout(resolve, 0));
203
+ const name = uniqueWatchlistName(requested, getState().config.watchlists);
204
+ await createFormCollectionActions({ dataProvider: registry.marketData, dispatch, getState, pluginRegistry: registry,
205
+ tickerRepository: registry.tickerRepository }, (body, options) => {
206
+ if (options?.type === "error") notify({ body, type: "error" });
207
+ }).createWatchlist(name);
208
+ const watchlist = getState().config.watchlists.find((entry) => entry.name === name);
209
+ if (!watchlist) throw new Error(t("The watchlist was not created."));
210
+ const plan = planMemberWatchlist(getState().tickers, watchlist.id, list.members);
211
+ // One ticker that fails to save leaves the others on the list; the toast says how many made it.
212
+ let failed = 0;
213
+ const created: TickerRecord[] = [];
214
+ const saved: TickerRecord[] = [];
215
+ for (const metadata of plan.create) {
216
+ try {
217
+ const ticker = await registry.tickerRepository.createTicker(metadata);
218
+ created.push(ticker);
219
+ saved.push(ticker);
220
+ } catch {
221
+ failed += 1;
222
+ }
223
+ }
224
+ for (const ticker of plan.update) {
225
+ try {
226
+ await registry.tickerRepository.saveTicker(ticker);
227
+ saved.push(ticker);
228
+ } catch {
229
+ failed += 1;
230
+ }
231
+ }
232
+ // One state update for the whole list: an update per ticker redraws every pane a hundred times.
233
+ if (saved.length) {
234
+ const tickers = new Map(getState().tickers);
235
+ for (const ticker of saved) tickers.set(ticker.metadata.ticker, ticker);
236
+ dispatch({ type: "SET_TICKERS", tickers });
237
+ }
238
+ for (const ticker of created) registry.events.emit("ticker:added", { symbol: ticker.metadata.ticker, ticker });
239
+ notify(failed
240
+ ? { type: "error", body: tf("Saved {saved} of {count} members to {name}. {failed} could not be added.", { saved: plan.count - failed, count: plan.count, name, failed }) }
241
+ : { type: "success", body: tf("Saved {count} members to {name}.", { count: plan.count, name }) });
242
+ };
243
+ return [
244
+ ...(choices.length ? [{ id: "members-open-in", label: t("Open Members In…"), onSelect: openIn }] : []),
245
+ { id: "members-save-watchlist", label: t("Save as Watchlist…"), onSelect: () => {
246
+ // 54 columns fill the dialog; the prompt's default field is wider and runs past its frame.
247
+ void dialog.prompt<string>({
248
+ closeOnClickOutside: true,
249
+ content: (context: PromptContext<string>) => <TextPromptDialog {...context} title={t("Save as Watchlist")}
250
+ body={[text.saveBody]} label={t("Name")} initialValue={list.watchlistName} confirmLabel={t("Save")} width={54} />,
251
+ }).then((name) => {
252
+ if (!name?.trim()) return;
253
+ saveAsWatchlist(name).catch((error: unknown) => {
254
+ notify({ type: "error", body: error instanceof Error ? error.message : t("The watchlist was not saved.") });
255
+ });
256
+ }).catch(() => {});
257
+ } },
258
+ ];
259
+ }, [createPaneFromTemplate, dialog, disabledPlugins, dispatch, getState, list, notify]);
260
+ }
@@ -27,7 +27,7 @@ function renderCell(row: ShortVolumeObservation, column: VolumeColumn, _index: n
27
27
  function VolumeDetail({ row, width, height }: { row: ShortVolumeObservation; width: number; height: number }) {
28
28
  const { nativePaneChrome } = useUiCapabilities();
29
29
  // The stack title is the date, so the rows start with the figures.
30
- return <ScrollBox width={width} height={nativePaneChrome ? undefined : height} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
30
+ return <ScrollBox scrollY width={width} height={nativePaneChrome ? undefined : height} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
31
31
  <KeyValueRow labelWidth={DETAIL_LABEL_WIDTH} label="Off-exchange short ratio" value={volumePercent(row.ratioPercent)} />
32
32
  <KeyValueRow labelWidth={DETAIL_LABEL_WIDTH} label="Short shares" value={exactQuantity(row.shortVolume)} />
33
33
  <KeyValueRow labelWidth={DETAIL_LABEL_WIDTH} label="Exempt shares" value={exactQuantity(row.shortExemptVolume)} detail="included in short shares" />
@@ -59,7 +59,7 @@ function DayDetail({ symbol, row, recent, width, height }: {
59
59
  { id: "ratio", label: "Vs median", value: socialRatio(row.ratio) },
60
60
  { id: "stance", label: "Stance", value: socialStance(row.stance), detail: stanceWord(row.stance) },
61
61
  ]} />
62
- <ScrollBox width={width} height={nativePaneChrome ? undefined : Math.max(1, height - 3)} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
62
+ <ScrollBox scrollY width={width} height={nativePaneChrome ? undefined : Math.max(1, height - 3)} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
63
63
  <PaneStatusBody loading={posts.loading && !list.length} error={!list.length ? posts.error : null} subject="top posts"
64
64
  empty={!posts.loading && !list.length} emptyTitle="No top posts for this day.">
65
65
  <Box flexDirection="column" gap={1} width={lineWidth}>
@@ -8,6 +8,7 @@ import { priceColor } from "../../../theme/colors";
8
8
  import { useThemeColors } from "../../../theme/theme-context";
9
9
  import { Box, Text } from "../../../ui";
10
10
  import { nextHeaderSort } from "../../../utils/sort-values";
11
+ import { useMembersMenu } from "../shared/members-menu";
11
12
  import { cachedMembers, cachedThemes, loadMembers, loadThemes } from "./client";
12
13
  import { aggregateText, coverageWidth, DEFAULT_SORT, matchTheme, memberColumns, memberPrice, percent, sortMembers, sortThemes, themeColumns, type ThemeSort } from "./model";
13
14
 
@@ -37,6 +38,8 @@ function Members({ id, width, height, focused }: { id: string; width: number; he
37
38
  useAutoRefresh(resource.updatedAt, resource.load);
38
39
  usePaneRefreshKey(() => void resource.reload(), { focused });
39
40
  useSnapshotFooter("themes:members", resource, focused);
41
+ const memberList = useMemo(() => data ? { title: data.theme.name, watchlistName: data.theme.name, members: rows } : null, [data, rows]);
42
+ useMembersMenu("themes:members-menu", memberList);
40
43
  const renderCell = useCallback((row: ThemeMember, column: DataTableColumn): DataTableCell => {
41
44
  if (column.id === "symbol") return { text: row.symbol, color: colors.textBright };
42
45
  if (column.id === "name") return { text: row.name ?? "--", color: colors.text };
@@ -166,10 +166,14 @@ function aggregateQuarterlyStatements(
166
166
  }
167
167
  const currencies = new Set(statements.map((statement) => statement.currency));
168
168
  if (currencies.size > 1) return null;
169
+ // Its counts and EPS are on the quarters' share basis when all four state the same one.
170
+ const shareBases = new Set(statements.map((statement) => statement.shareBasis));
171
+ const shareBasis = shareBases.size === 1 ? [...shareBases][0] : undefined;
169
172
 
170
173
  const aggregate: FinancialTableStatement = {
171
174
  date,
172
175
  currency: [...currencies][0],
176
+ ...(shareBasis ? { shareBasis } : {}),
173
177
  aggregation: {
174
178
  kind: "trailing-four-quarters",
175
179
  periodEnd: statements.at(-1)!.date,
@@ -107,7 +107,13 @@ const SHARE_COUNT_FIELDS = new Set<keyof FinancialStatement>([
107
107
  "basicShares", "dilutedShares", "shareIssued", "ordinarySharesNumber", "treasurySharesNumber",
108
108
  ]);
109
109
 
110
- /** Currency changes affect monetary comparisons, not counts of reported shares. */
110
+ const PER_SHARE_EARNINGS_FIELDS = new Set<keyof FinancialStatement>(["eps", "basicEps"]);
111
+
112
+ /**
113
+ * Currency changes affect monetary comparisons, not counts of reported
114
+ * shares. Counts and EPS compare only on one share basis: receipts against
115
+ * ordinary shares would read as a split.
116
+ */
111
117
  export function canCompareFinancialRow(
112
118
  row: FinancialTableRow,
113
119
  current: FinancialStatement,
@@ -116,7 +122,9 @@ export function canCompareFinancialRow(
116
122
  ): boolean {
117
123
  if (!previous) return false;
118
124
  const key = row.kind === "group" ? row.summaryKey : row.key;
119
- if (key && SHARE_COUNT_FIELDS.has(key)) return true;
125
+ const sameShareBasis = current.shareBasis === previous.shareBasis;
126
+ if (key && SHARE_COUNT_FIELDS.has(key)) return sameShareBasis;
127
+ if (key && PER_SHARE_EARNINGS_FIELDS.has(key) && !sameShareBasis) return false;
120
128
  const currentCurrency = (current.currency ?? financialCurrency)?.trim();
121
129
  const previousCurrency = (previous.currency ?? financialCurrency)?.trim();
122
130
  return !!currentCurrency && currentCurrency === previousCurrency;
@@ -269,6 +277,19 @@ export function financialStatementLimitations(financials: TickerFinancials | nul
269
277
  return limitations;
270
278
  }
271
279
 
280
+ /** "Ordinary Shares (bn ADRs)": a share-count line whose counts are depositary receipts. */
281
+ export function receiptShareCountLabel(row: Pick<FinancialTableModelRow, "key" | "label" | "unitLabel">): string {
282
+ if (!row.key || !SHARE_COUNT_FIELDS.has(row.key)) return row.unitLabel;
283
+ const unit = row.unitLabel.startsWith(`${row.label} (`) ? row.unitLabel.slice(row.label.length + 2, -1) : "";
284
+ return `${row.label} (${unit ? `${unit} ` : ""}ADRs)`;
285
+ }
286
+
287
+ /** Whether a table row shows share counts or per-share earnings. */
288
+ export function isPerShareFinancialRow(row: Pick<FinancialTableModelRow, "key" | "summaryKey">): boolean {
289
+ const key = row.key ?? row.summaryKey;
290
+ return !!key && (SHARE_COUNT_FIELDS.has(key) || PER_SHARE_EARNINGS_FIELDS.has(key));
291
+ }
292
+
272
293
  export function resolveFinancialPeriod(
273
294
  requestedPeriod: FinancialPeriod,
274
295
  hasAnnualStatements: boolean,
@@ -495,6 +516,7 @@ interface FinancialTableModelRow {
495
516
  id: string;
496
517
  key?: keyof FinancialStatement;
497
518
  summaryKey?: keyof FinancialStatement;
519
+ label: string;
498
520
  unitLabel: string;
499
521
  depth: number;
500
522
  growthDirection: FinancialGrowthDirection;
@@ -614,6 +636,7 @@ export function buildFinancialTableModel(
614
636
  id: row.id,
615
637
  key: row.kind === "metric" ? row.key : undefined,
616
638
  summaryKey: row.kind === "group" ? row.summaryKey : undefined,
639
+ label: row.label,
617
640
  unitLabel: row.unitLabel,
618
641
  depth: row.depth,
619
642
  growthDirection: row.growthDirection,
@@ -6,7 +6,11 @@ import type { TimeRange } from "../../../time-series/range";
6
6
  import { formatNumber, formatPercentRaw } from "../../../utils/format";
7
7
  import { formatMarketPrice, formatMarketPriceWithCurrency, formatPriceObservation, withCurrencyMinorDigits, type MarketFormatOptions } from "../../../market-data/market/format";
8
8
  import { pricePointValues, priceHistoryIntegrityNotice } from "../../../utils/price-history-integrity";
9
- import { buildFinancialTableModel, financialStatementCurrency, financialStatementDateNotice, financialStatementLimitations, formatFinancialHeader } from "./financials/model";
9
+ import {
10
+ buildFinancialTableModel, financialStatementCurrency, financialStatementDateNotice, financialStatementLimitations,
11
+ formatFinancialHeader, isPerShareFinancialRow, receiptShareCountLabel,
12
+ } from "./financials/model";
13
+ import { adrRatioText } from "../../../utils/depositary-receipt";
10
14
  import { paneSchemas } from "./headless-schema";
11
15
  import { loadPeriodEndHistory } from "./financials/period-end-history";
12
16
  import {
@@ -52,8 +56,18 @@ export const financialStatementsHeadless: HeadlessPaneDefinition<"rows"> = {
52
56
  const statementCurrency = financialStatementCurrency(financials, [
53
57
  ...financials.annualStatements, ...financials.quarterlyStatements,
54
58
  ]);
55
- const dates = table?.statements.map(({ date, currency, dateSource, providerDate, dateEvidence, availableAt, fieldAvailability, fieldSources, unavailableFields, epsBasis, unavailableEarnings, aggregation }) => ({
59
+ // Gloom Cloud states when a period's share counts and EPS are on the receipts.
60
+ const receiptPeriods = table?.statements.filter(({ shareBasis }) => shareBasis === "depositary_receipt").map(({ date }) => date) ?? [];
61
+ const allReceipts = receiptPeriods.length > 0 && receiptPeriods.length === table?.statements.length;
62
+ const shareBases = new Set(table?.statements.map(({ shareBasis }) => shareBasis ?? null));
63
+ const ratio = adrRatioText(financials.fundamentals?.adrRatio ?? financials.quote?.adrRatio);
64
+ const shareBasisNotice = receiptPeriods.length === 0 || !table?.rows.some(isPerShareFinancialRow) ? null
65
+ : allReceipts
66
+ ? `Share counts are in ADRs and EPS is per ADR${ratio ? `; ${ratio}` : ""}.`
67
+ : `Share counts are in ADRs and EPS is per ADR for ${receiptPeriods.join(", ")}${ratio ? ` (${ratio})` : ""}; other periods as reported.`;
68
+ const dates = table?.statements.map(({ date, currency, dateSource, providerDate, dateEvidence, availableAt, fieldAvailability, fieldSources, unavailableFields, epsBasis, unavailableEarnings, aggregation, shareBasis }) => ({
56
69
  date, currency: currency ?? statementCurrency ?? null,
70
+ shareBasis: shareBasis ?? null,
57
71
  availableAt: availableAt ?? null,
58
72
  fieldAvailability: fieldAvailability ? { ...fieldAvailability } : null,
59
73
  ...(fieldSources ? { fieldSources } : {}),
@@ -68,7 +82,7 @@ export const financialStatementsHeadless: HeadlessPaneDefinition<"rows"> = {
68
82
  label: formatFinancialHeader(date, [currency ?? statementCurrency, table.unit].filter(Boolean).join(" ") || undefined, dateSource, false, aggregation?.periodEnd).trim(),
69
83
  })) ?? [];
70
84
  const rows = table?.rows.map((row) => ({
71
- id: row.id, kind: row.kind, metric: row.unitLabel,
85
+ id: row.id, kind: row.kind, metric: allReceipts ? receiptShareCountLabel(row) : row.unitLabel,
72
86
  cells: row.cells.map((cell, index) => ({
73
87
  date: dates[index]?.date ?? "", value: cell.value ?? null,
74
88
  growth: cell.growth ?? null, formatted: cell.valueText.trim(), growthFormatted: cell.growthText.trim(),
@@ -92,7 +106,9 @@ export const financialStatementsHeadless: HeadlessPaneDefinition<"rows"> = {
92
106
  symbol, name: financials.quote?.name ?? symbol, currency: statementCurrency ?? null, unit: table?.unit ?? null, quoteCurrency: financials.quote?.currency ?? null,
93
107
  statement: table?.subTab.key ?? options.statement, statementLabel: table?.subTab.name ?? null,
94
108
  period: requestedPeriod, growthBasis: requestedPeriod === "quarterly" ? "QoQ" : "YoY", columns: dates,
95
- notices: rows.length ? [FINANCIAL_VINTAGE_NOTICE] : [],
109
+ shareBasis: shareBases.size === 1 ? [...shareBases][0] ?? null : null,
110
+ ...(receiptPeriods.length > 0 ? { adrRatio: financials.fundamentals?.adrRatio ?? financials.quote?.adrRatio ?? null } : {}),
111
+ notices: rows.length ? [FINANCIAL_VINTAGE_NOTICE, ...(shareBasisNotice ? [shareBasisNotice] : [])] : [],
96
112
  limitations: financialStatementLimitations(financials),
97
113
  dateProvenance: financialStatementDateNotice(table?.statements ?? []),
98
114
  },
@@ -22,6 +22,7 @@ import type { OverviewFunctionLink, PositionTableRow, StatField } from "./types"
22
22
  import { getPortfolioPositionMetrics, getPortfolioQuoteDisplay, resolvePortfolioMarketValue, resolvePortfolioPositionPnl, portfolioPnlPercent, signedPositionDirection } from "../../portfolio-list/position-metrics";
23
23
  import { liveDividendYield, liveForwardPE, liveMarketCapitalization, liveTrailingPE } from "../../portfolio-list/live-valuation";
24
24
  import { formatReportedMoney } from "../../../../utils/reported-money";
25
+ import { fundamentalsCurrency, reportedEnterpriseValue } from "../../../../utils/fundamentals";
25
26
  import { formatShortDate } from "../../../../utils/datetime-format";
26
27
  import { safeExternalUrl } from "../../../../utils/external-url";
27
28
 
@@ -68,6 +69,7 @@ function compactPositionAccount(position: TickerPosition): string {
68
69
  export function buildOverviewStats({
69
70
  quote,
70
71
  fundamentals,
72
+ financialCurrency,
71
73
  quoteCurrency,
72
74
  baseCurrency,
73
75
  marketCapExchangeRates = new Map(),
@@ -77,18 +79,20 @@ export function buildOverviewStats({
77
79
  }: {
78
80
  quote: Quote | undefined;
79
81
  fundamentals: TickerFinancials["fundamentals"] | undefined;
82
+ /** The currency the statements report in, for fundamentals that declare none. */
83
+ financialCurrency?: TickerFinancials["financialCurrency"];
80
84
  quoteCurrency: string;
81
85
  baseCurrency: string;
82
86
  toBase: CurrencyConverter;
83
87
  marketCapExchangeRates?: ReadonlyMap<string, number>;
84
88
  nextEarnings?: NextEarnings;
85
- /** The listing is a depositary receipt, so its share count is in receipts. */
89
+ /** The share count is in depositary receipts, not ordinary shares. */
86
90
  depositaryReceipt?: boolean;
87
91
  /** The listing's calendar day, YYYY-MM-DD; dates before it are left out. */
88
92
  today?: string;
89
93
  }): StatField[] {
90
94
  const stats: StatField[] = [];
91
- const money = (value: number, perShare = false) => formatReportedMoney(value, fundamentals?.financialCurrency, perShare);
95
+ const money = (value: number, perShare = false) => formatReportedMoney(value, fundamentalsCurrency({ fundamentals, financialCurrency }), perShare);
92
96
 
93
97
  if (quote?.volume != null) {
94
98
  // Live figures keep their decimals (12.30M, not 12.3M) so the digits hold still.
@@ -178,10 +182,11 @@ export function buildOverviewStats({
178
182
  valueColor: priceColor(fundamentals.revenueGrowth),
179
183
  });
180
184
  }
185
+ const enterpriseValue = reportedEnterpriseValue(fundamentals);
181
186
  if (fundamentals?.unavailableFields?.includes("enterpriseValue")) {
182
187
  stats.push({ label: "EV", value: "—" });
183
- } else if (fundamentals?.enterpriseValue != null) {
184
- stats.push({ label: "EV", value: formatCompactCurrency(fundamentals.enterpriseValue, quoteCurrency) });
188
+ } else if (enterpriseValue != null) {
189
+ stats.push({ label: "EV", value: formatCompactCurrency(enterpriseValue, quoteCurrency) });
185
190
  }
186
191
  if (finite(fundamentals?.beta)) {
187
192
  stats.push({ label: "Beta", value: formatNumber(fundamentals.beta, 2), link: RELATIONSHIP_GRAPH });
@@ -26,7 +26,7 @@ import { Box, ScrollBox, Text, useUiCapabilities } from "../../../ui";
26
26
  import { publicTickerKey, resolveExchangeTimeZone } from "../../../utils/exchanges";
27
27
  import { convertCurrency, displayWidth, formatPercentRaw, truncateToDisplayWidth } from "../../../utils/format";
28
28
  import { zonedDateKey } from "../../../utils/zoned-date-time";
29
- import { isDepositaryReceipt } from "../../../utils/depositary-receipt";
29
+ import { sharesOutstandingInReceipts } from "../../../utils/depositary-receipt";
30
30
  import {
31
31
  CompactRangeBar,
32
32
  FundamentalsGrid,
@@ -189,12 +189,13 @@ function ResolvedOverviewTab({ width, focused = false, ticker, financials, onOpe
189
189
  const stats = buildOverviewStats({
190
190
  quote,
191
191
  fundamentals,
192
+ financialCurrency: financials?.financialCurrency,
192
193
  quoteCurrency,
193
194
  baseCurrency,
194
195
  toBase,
195
196
  marketCapExchangeRates: exchangeRates,
196
197
  nextEarnings: financials?.nextEarnings,
197
- depositaryReceipt: isDepositaryReceipt({ ...quote, description }),
198
+ depositaryReceipt: sharesOutstandingInReceipts(quote, fundamentals, description),
198
199
  // Report and ex-dividend dates are the listing's calendar days.
199
200
  today: zonedDateKey(Date.now(), chartTimeZone ?? "America/New_York"),
200
201
  });
@@ -24,6 +24,8 @@ export interface BasemapPalette {
24
24
  land: string;
25
25
  coast: string;
26
26
  border: string;
27
+ /** Country names. */
28
+ label: string;
27
29
  }
28
30
 
29
31
  interface BasemapLayerProps {
@@ -138,9 +138,13 @@ export function tilesInView(tiles: readonly DetailTile[], view: GeoBox, margin =
138
138
  return tiles.filter(({ box }) => box[0] < east && box[2] > west && box[1] < north && box[3] > south);
139
139
  }
140
140
 
141
+ /** A country name at its label point, with the web-map zoom it first shows at and its rank. */
142
+ export type CountryLabel = readonly [name: string, longitude: number, latitude: number, minZoom: number, rank: number];
143
+
141
144
  /** What `natural-earth/index.chunk.ts` exports. */
142
145
  interface BasemapIndex {
143
146
  loadWorld: () => Promise<BasemapChunk>;
147
+ loadLabels: () => Promise<{ COUNTRY_LABELS: readonly CountryLabel[] }>;
144
148
  DETAIL_TILES: readonly DetailTile[];
145
149
  }
146
150
 
@@ -175,6 +179,20 @@ export function loadWorldBasemap(): Promise<BasemapPaths> {
175
179
  return worldPaths;
176
180
  }
177
181
 
182
+ let countryLabels: Promise<readonly CountryLabel[]> | null = null;
183
+
184
+ /** Country names, most prominent first; a lazy chunk like the land. */
185
+ export function loadCountryLabels(): Promise<readonly CountryLabel[]> {
186
+ countryLabels ??= loadIndex()
187
+ .then((index) => index.loadLabels())
188
+ .then((chunk) => chunk.COUNTRY_LABELS)
189
+ .catch((error: unknown) => {
190
+ countryLabels = null;
191
+ throw error;
192
+ });
193
+ return countryLabels;
194
+ }
195
+
178
196
  export function loadDetailIndex(): Promise<readonly DetailTile[]> {
179
197
  return loadIndex().then((index) => index.DETAIL_TILES);
180
198
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Which country names the desktop and web map write, and where. Names come
3
+ * from Natural Earth's label points, most prominent first, each with the
4
+ * web-map zoom it first shows at. A name shows once the map is that close (a
5
+ * little before), sits wholly inside the map, and keeps clear of the names
6
+ * already placed and of the selected or hovered marker. Same view, same names.
7
+ */
8
+ import type { CountryLabel } from "./basemap";
9
+
10
+ export interface PlacedCountryLabel {
11
+ name: string;
12
+ longitude: number;
13
+ latitude: number;
14
+ }
15
+
16
+ /** A marker the names keep clear of, in pixels from the map's top left. */
17
+ export interface LabelObstacle {
18
+ x: number;
19
+ y: number;
20
+ radius: number;
21
+ }
22
+
23
+ export const COUNTRY_LABEL_FONT_PX = 10;
24
+ /** The map's font is monospaced: every character advances this much of the font size. */
25
+ const CHARACTER_EM = 0.6;
26
+ /** Room kept around a name, so two never touch. */
27
+ const GAP_PX = 6;
28
+ /** Names show at Natural Earth's own zoom; this moves the threshold earlier (positive) or later. */
29
+ const ZOOM_LEAD = 0;
30
+ /** The largest countries are named on any world view, room permitting. */
31
+ const WORLD_VIEW_MIN_ZOOM = 1.7;
32
+ /** Never more names than this on one view. */
33
+ const MAX_LABELS = 60;
34
+
35
+ /** The web-map zoom (256 px tiles for 360 degrees) of a map drawn at this many pixels per degree. */
36
+ export function webMapZoom(pxPerDegree: number): number {
37
+ return Math.log2((Math.max(pxPerDegree, 1e-9) * 360) / 256);
38
+ }
39
+
40
+ export function layoutCountryLabels(
41
+ labels: readonly CountryLabel[],
42
+ options: {
43
+ pxPerDegree: number;
44
+ /** Where a longitude and latitude fall, in pixels from the map's top left. */
45
+ toPixels: (longitude: number, latitude: number) => { x: number; y: number };
46
+ widthPx: number;
47
+ heightPx: number;
48
+ obstacles?: readonly LabelObstacle[];
49
+ fontPx?: number;
50
+ },
51
+ ): PlacedCountryLabel[] {
52
+ const { toPixels, widthPx, heightPx, obstacles = [] } = options;
53
+ const fontPx = options.fontPx ?? COUNTRY_LABEL_FONT_PX;
54
+ if (!(widthPx > 0 && heightPx > 0 && options.pxPerDegree > 0)) return [];
55
+ const threshold = Math.max(webMapZoom(options.pxPerDegree) + ZOOM_LEAD, WORLD_VIEW_MIN_ZOOM);
56
+ const taken: [number, number, number, number][] = [];
57
+ const placed: PlacedCountryLabel[] = [];
58
+ for (const [name, longitude, latitude, minZoom] of labels) {
59
+ if (placed.length >= MAX_LABELS) break;
60
+ if (minZoom > threshold) continue;
61
+ const { x, y } = toPixels(longitude, latitude);
62
+ const halfWidth = (name.length * fontPx * CHARACTER_EM) / 2 + GAP_PX / 2;
63
+ const halfHeight = fontPx / 2 + GAP_PX / 4;
64
+ const box: [number, number, number, number] = [x - halfWidth, y - halfHeight, x + halfWidth, y + halfHeight];
65
+ if (box[0] < 0 || box[1] < 0 || box[2] > widthPx || box[3] > heightPx) continue;
66
+ if (taken.some((other) => box[0] < other[2] && box[2] > other[0] && box[1] < other[3] && box[3] > other[1])) continue;
67
+ if (obstacles.some((marker) => circleMeetsBox(marker, box))) continue;
68
+ taken.push(box);
69
+ placed.push({ name, longitude, latitude });
70
+ }
71
+ return placed;
72
+ }
73
+
74
+ function circleMeetsBox(circle: LabelObstacle, box: readonly [number, number, number, number]): boolean {
75
+ const nearestX = Math.max(box[0], Math.min(circle.x, box[2]));
76
+ const nearestY = Math.max(box[1], Math.min(circle.y, box[3]));
77
+ return Math.hypot(circle.x - nearestX, circle.y - nearestY) < circle.radius;
78
+ }
@@ -357,7 +357,8 @@ export function GeoSvgLayers({ overlay, matrix, project, view, unitPx, mapWidthP
357
357
  const selectedLayer = overlay.selected ? overlay.layers.find((layer) => layer.id === overlay.selected!.layerId) ?? null : null;
358
358
  const selectedFeature = selectedLayer?.features.find((feature) => feature.id === overlay.selected!.id) ?? null;
359
359
  const pxPerDegree = matrix.a * unitPx;
360
- const iconScale = { [ICON_SCALE_VAR]: 1 / Math.max(pxPerDegree, Number.EPSILON) } as CSSProperties;
360
+ // A map with no scale draws nothing; never hand the icons a counter-scale of 1/epsilon.
361
+ const iconScale = { [ICON_SCALE_VAR]: pxPerDegree > 0 && Number.isFinite(pxPerDegree) ? 1 / pxPerDegree : 0 } as CSSProperties;
361
362
  const trailPath = overlay.trail && overlay.trail.length > 1 ? degreePath([overlay.trail], false) : null;
362
363
  const points = new Map(overlay.layers.filter((layer) => layer.geometry === "point").map((layer) => {
363
364
  const landmark = layer.features.length > 0 && MAP_SYMBOLS[featureSymbol(layer.id, layer.features[0]!).id].landmark === true;